/** * `` — YouTube-style controls element. * * Wraps `` with a full player UI: play/pause, seek bar, * volume, settings menu (speed, subtitles, audio tracks), fullscreen, * keyboard shortcuts, touch gestures, and auto-hiding controls. * * All properties, methods, and events from `` are proxied * through. Consumers interact with `` exclusively. */ // Import the class concretely and register — side-effect-only imports // are tree-shaken by Rollup in production builds. import { AvbridgeVideoElement } from "./avbridge-video.js"; if (typeof customElements !== "undefined" && !customElements.get("avbridge-video")) { customElements.define("avbridge-video", AvbridgeVideoElement); } import { PLAYER_STYLES } from "./player-styles.js"; import { ICON_PLAY, ICON_PAUSE, ICON_VOLUME_UP, ICON_VOLUME_OFF, ICON_SETTINGS, ICON_FULLSCREEN, ICON_FULLSCREEN_EXIT, ICON_REPLAY_10, ICON_FORWARD_10, } from "./player-icons.js"; import type { AvbridgeVideoElementEventMap, SettingsSectionConfig } from "../types.js"; // ── Helpers ────────────────────────────────────────────────────────────── function formatTime(sec: number): string { if (!Number.isFinite(sec) || sec < 0) sec = 0; const total = Math.floor(sec); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; const mm = String(m).padStart(h > 0 ? 2 : 1, "0"); const ss = String(s).padStart(2, "0"); return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`; } const PLAYBACK_SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] as const; const DEFAULT_CONTROLS_HIDE_MS = 3000; type PlayerState = "idle" | "loading" | "playing" | "paused" | "buffering" | "ended" | "error"; // ── Forwarded events ───────────────────────────────────────────────────── const FORWARDED_EVENTS = [ "ready", "error", "strategychange", "trackschange", "loadstart", "destroy", "play", "playing", "pause", "seeking", "seeked", "volumechange", "ratechange", "durationchange", "canplay", "canplaythrough", "waiting", "stalled", "emptied", "resize", "loadedmetadata", "loadeddata", "timeupdate", "ended", "progress", ] as const; // ── Observed attributes ────────────────────────────────────────────────── const PROXY_ATTRIBUTES = [ "src", "autoplay", "muted", "loop", "preload", "poster", "playsinline", "crossorigin", "disableremoteplayback", "preferstrategy", "fit", ] as const; /** Player-only attributes that don't forward to . */ const PLAYER_ATTRIBUTES = ["show-fit"] as const; const FIT_MODES = ["contain", "cover", "fill"] as const; type FitMode = (typeof FIT_MODES)[number]; // ═══════════════════════════════════════════════════════════════════════════ export class AvbridgePlayerElement extends HTMLElement { static readonly observedAttributes = [...PROXY_ATTRIBUTES, ...PLAYER_ATTRIBUTES]; /** * Returns `true` if a DOM event originated from one of the player's * **interactive chrome elements** (seek bar, control buttons, settings * menu, overlay play button) rather than the bare video surface. * * This is the escape hatch for host pages that wrap the player in a * gesture recognizer (e.g. TikTok-style vertical-swipe pager). For * bubble-phase listeners the player's own handlers already call * `stopPropagation()` on chrome interactions — but **capture-phase** * listeners run *before* the player's handlers, so they need to check * the event's path themselves and bail. This helper does that check * via `composedPath()`, which traverses shadow boundaries correctly. * * Returns `false` for events on the bare video surface — host pages * remain free to claim those for their own gestures (e.g. swipe-to-pan * to the next video). Returns `false` for events that never hit a * player at all. * * @example * // TikTok-style vertical swipe on the document, capture phase: * document.addEventListener("pointerdown", (e) => { * if (AvbridgePlayerElement.isPlayerChromeEvent(e)) return; * startSwipeGesture(e); * }, { capture: true }); */ static isPlayerChromeEvent(event: Event): boolean { // Mirrors the selector used by the player's internal tap-target // gate (see `_onPlayerSurfaceClick` and friends): anything inside // these regions is "chrome", everything else is the bare video. const CHROME_SELECTOR = ".avp-controls, .avp-settings, .avp-overlay-btn"; for (const node of event.composedPath()) { if (node instanceof HTMLElement && node.matches?.(CHROME_SELECTOR)) { return true; } } return false; } // ── Internal DOM refs ────────────────────────────────────────────────── private _video!: AvbridgeVideoElement; private _playBtn!: HTMLButtonElement; private _overlayBtn!: HTMLButtonElement; private _seekInput!: HTMLInputElement; private _seekProgress!: HTMLDivElement; private _seekBuffered!: HTMLDivElement; private _seekThumb!: HTMLDivElement; private _seekTooltip!: HTMLDivElement; private _timeDisplay!: HTMLSpanElement; private _volumeBtn!: HTMLButtonElement; private _volumeInput!: HTMLInputElement; private _settingsBtn!: HTMLButtonElement; private _settingsMenu!: HTMLDivElement; private _settingsScrim!: HTMLDivElement; private _customSections: import("../types.js").SettingsSectionConfig[] = []; private _fullscreenBtn!: HTMLButtonElement; // Strategy badge removed — visible in Stats for Nerds instead. // Spinner is rendered but driven entirely by CSS :host([data-state]) selectors. private _speedIndicator!: HTMLDivElement; private _rippleLeft!: HTMLDivElement; private _rippleRight!: HTMLDivElement; // ── State ────────────────────────────────────────────────────────────── private _state: PlayerState = "idle"; private _controlsTimer: ReturnType | null = null; private _settingsOpen = false; private _activeAudioTrackId: number | null = null; private _activeSubtitleTrackId: number | null = null; private _userSeeking = false; /** Last seek target the user committed. The thumb stays here (and * `_updateTime` skips updating from `timeupdate`) until the underlying * `currentTime` actually catches up — otherwise the thumb visibly snaps * back to the pre-seek position while the remux pipeline rebuilds. */ private _pendingSeekTarget: number | null = null; private _holdTimer: ReturnType | null = null; private _holdSpeedActive = false; private _savedPlaybackRate = 1; private _lastTapTime = 0; private _tapTimer: ReturnType | null = null; private _statsOpen = false; private _statsEl!: HTMLDivElement; private _statsInterval: ReturnType | null = null; private _eventCleanup: (() => void)[] = []; private _updateToolbarEmpty: () => void = () => { /* wired in constructor */ }; private _toolbarTop!: HTMLDivElement; // ── Constructor ──────────────────────────────────────────────────────── constructor() { super(); const shadow = this.attachShadow({ mode: "open" }); shadow.innerHTML = `${this._template()}`; // Grab refs this._video = shadow.querySelector("avbridge-video") as AvbridgeVideoElement; this._playBtn = shadow.querySelector(".avp-play") as HTMLButtonElement; this._overlayBtn = shadow.querySelector(".avp-overlay-btn") as HTMLButtonElement; this._seekInput = shadow.querySelector(".avp-seek-input") as HTMLInputElement; this._seekProgress = shadow.querySelector(".avp-seek-progress") as HTMLDivElement; this._seekBuffered = shadow.querySelector(".avp-seek-buffered") as HTMLDivElement; this._seekThumb = shadow.querySelector(".avp-seek-thumb") as HTMLDivElement; this._seekTooltip = shadow.querySelector(".avp-seek-tooltip") as HTMLDivElement; this._timeDisplay = shadow.querySelector(".avp-time") as HTMLSpanElement; this._volumeBtn = shadow.querySelector(".avp-volume-btn") as HTMLButtonElement; this._volumeInput = shadow.querySelector(".avp-volume-input") as HTMLInputElement; this._settingsBtn = shadow.querySelector(".avp-settings-btn") as HTMLButtonElement; this._settingsMenu = shadow.querySelector(".avp-settings") as HTMLDivElement; this._settingsScrim = shadow.querySelector(".avp-settings-scrim") as HTMLDivElement; this._fullscreenBtn = shadow.querySelector(".avp-fullscreen") as HTMLButtonElement; // Badge removed from controls bar — strategy visible in Stats for Nerds. // Spinner is rendered in shadow DOM, driven by CSS :host([data-state]). this._speedIndicator = shadow.querySelector(".avp-speed-indicator") as HTMLDivElement; this._statsEl = shadow.querySelector(".avp-stats") as HTMLDivElement; this._rippleLeft = shadow.querySelector(".avp-ripple-left") as HTMLDivElement; this._rippleRight = shadow.querySelector(".avp-ripple-right") as HTMLDivElement; this._toolbarTop = shadow.querySelector('[part="toolbar-top"]') as HTMLDivElement; // Start visible — controls are shown until the auto-hide timer fires. this._toolbarTop.setAttribute("data-visible", "true"); // Track whether the top toolbar has any slotted content. Used to hide // its gradient when empty (see data-toolbar-empty in player-styles.ts). // MUST defer the initial attribute write to connectedCallback — the // Custom Elements spec forbids constructors from adding attributes. const slots = shadow.querySelectorAll('slot[name="top-left"], slot[name="top-right"]'); this._updateToolbarEmpty = () => { const hasContent = Array.from(slots).some((s) => s.assignedNodes({ flatten: true }).length > 0); if (hasContent) this.removeAttribute("data-toolbar-empty"); else this.setAttribute("data-toolbar-empty", ""); }; for (const s of slots) s.addEventListener("slotchange", this._updateToolbarEmpty); this._bindEvents(); } private _template(): string { return `
2x
${ICON_REPLAY_10}
${ICON_FORWARD_10}
0:00
0:00 / 0:00
`; } // ── Event wiring ─────────────────────────────────────────────────────── private _bindEvents(): void { const on = ( el: EventTarget, event: K | string, fn: (e: Event) => void, opts?: AddEventListenerOptions, ) => { el.addEventListener(event, fn, opts); this._eventCleanup.push(() => el.removeEventListener(event, fn, opts)); }; // Forward events from inner video for (const name of FORWARDED_EVENTS) { on(this._video, name, (e) => { const detail = (e as CustomEvent).detail; this.dispatchEvent( detail !== undefined ? new CustomEvent(name, { detail, bubbles: e.bubbles, composed: true }) : new Event(name, { bubbles: e.bubbles }), ); }); } // State tracking on(this._video, "loadstart", () => { this._pendingSeekTarget = null; this._setState("loading"); }); on(this._video, "ready", () => { this._setState(this._video.paused ? "paused" : "playing"); this._seekInput.max = String(this._video.duration || 0); this._updateTime(); this._buildSettingsMenu(); }); on(this._video, "play", () => this._setState("playing")); on(this._video, "playing", () => this._setState("playing")); on(this._video, "pause", () => this._setState("paused")); on(this._video, "waiting", () => this._setState("buffering")); on(this._video, "ended", () => this._setState("ended")); on(this._video, "error", () => this._setState("error")); on(this._video, "timeupdate", () => this._updateTime()); // `progress` fires as the inner element's buffered ranges grow — keep the // seek bar's buffered indicator fresh even when paused or filling ahead // without timeupdate advancing. `` dispatches this on // all strategies (including the synthesized ranges for canvas strategies). on(this._video, "progress", () => this._updateBuffered()); on(this._video, "volumechange", () => this._updateVolume()); // Strategy changes are visible in Stats for Nerds. on(this._video, "trackschange", () => this._buildSettingsMenu()); on(this._video, "durationchange", () => { this._seekInput.max = String(this._video.duration || 0); }); // Play / pause on(this._playBtn, "click", (e) => { e.stopPropagation(); this._togglePlay(); }); on(this._overlayBtn, "click", (e) => { e.stopPropagation(); this._togglePlay(); }); // Seek bar — manual pointer handling so the click position maps // linearly across the FULL track width (native // clamps the thumb center inside [thumbWidth/2, trackWidth - // thumbWidth/2], which causes a visible click-to-thumb offset at // the edges). The input is still used for keyboard accessibility // (arrow keys, home/end) via its 'input' event. on(this._seekInput, "input", () => { // Only accept keyboard-driven input events (not synthesized // from pointer, which we handle manually below). if (this._userSeeking) return; this._onSeekInput(); }); on(this._seekInput, "change", () => { if (this._userSeeking) return; this._onSeekCommit(); }); const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement; on(seekBar, "pointerdown", (e) => this._onSeekPointerDown(e as PointerEvent)); on(seekBar, "pointermove", (e) => this._onSeekHover(e as PointerEvent)); // Volume on(this._volumeBtn, "click", (e) => { e.stopPropagation(); this._toggleMute(); }); on(this._volumeInput, "input", () => { const vol = Number(this._volumeInput.value); this._video.volume = vol; this._video.videoElement.volume = vol; this._video.muted = false; this._video.videoElement.muted = false; this._updateVolume(); }); // Settings on(this._settingsBtn, "click", (e) => { e.stopPropagation(); this._toggleSettings(); }); on(this._settingsScrim, "click", () => this._closeSettings()); // Fullscreen on(this._fullscreenBtn, "click", (e) => { e.stopPropagation(); this._toggleFullscreen(); }); on(document, "fullscreenchange", () => this._updateFullscreenIcon()); // Click / tap on video area — uses a delayed-tap pattern (like YouTube) // to distinguish single-tap (play/pause) from double-tap (seek ±10s). // On mouse: single click → play/pause, dblclick → fullscreen. // On touch: single tap (after 250ms) → play/pause, double tap → seek. const container = this.shadowRoot!.querySelector(".avp")!; on(container, "click", (e) => this._onContainerClick(e as MouseEvent)); on(container, "dblclick", (e) => this._onContainerDblClick(e as MouseEvent)); // Dismiss settings sheet when clicking outside it. The scrim handles // most of this (its own click handler calls _closeSettings), but we // also catch clicks outside the player element entirely. on(document, "click", (e) => { if (this._settingsOpen && !this.contains(e.target as Node)) { this._closeSettings(); } }); // Auto-hide controls on(container, "pointermove", () => this._showControls()); on(container, "pointerleave", () => this._scheduleHide()); // Touch gestures: hold for 2x speed on(container, "pointerdown", (e) => this._onPointerDown(e as PointerEvent)); on(container, "pointerup", (e) => this._onPointerUp(e as PointerEvent)); on(container, "pointercancel", () => this._cancelHold()); // Drag-and-drop file input. Drop a video file onto the player area // and it loads + plays. Files of non-video types are rejected silently // (no MIME sniffing — we let probe() decide). The dragover listener // calls preventDefault so the drop event actually fires. on(container, "dragenter", (e) => { e.preventDefault(); const dt = (e as DragEvent).dataTransfer; if (!dt || !Array.from(dt.types).includes("Files")) return; (container as HTMLElement).classList.add("avp-dragover"); }); on(container, "dragover", (e) => { e.preventDefault(); const dt = (e as DragEvent).dataTransfer; if (dt) dt.dropEffect = "copy"; }); on(container, "dragleave", (e) => { // dragleave fires on every child — only clear when we leave the container. if ((e as DragEvent).target === container) { (container as HTMLElement).classList.remove("avp-dragover"); } }); on(container, "drop", (e) => { e.preventDefault(); (container as HTMLElement).classList.remove("avp-dragover"); const file = (e as DragEvent).dataTransfer?.files?.[0]; if (!file) return; // Reuse the existing source-assignment path. play() errors are // reported via the normal error event; don't swallow here. (this._video as unknown as { source: unknown }).source = file; void this._video.play().catch(() => { /* error event already fired */ }); }); // Keyboard on(this, "keydown", (e) => this._onKeydown(e as KeyboardEvent)); } // ── Lifecycle ────────────────────────────────────────────────────────── connectedCallback(): void { this._setState("idle"); // Attribute writes the Custom Elements spec forbids in constructors // — document.createElement rejects with "The result must not have // attributes" — deferred here. Surfaced by the Playwright // cross-browser tests. if (!this.hasAttribute("tabindex")) { this.setAttribute("tabindex", "0"); } this._updateToolbarEmpty(); } disconnectedCallback(): void { this._clearTimers(); } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (!this._video) return; // Player-only attributes — not forwarded to the inner . if ((PLAYER_ATTRIBUTES as readonly string[]).includes(name)) { if (name === "show-fit" && this._settingsOpen) { this._buildSettingsMenu(); } return; } // Proxy everything else down. if (value == null) this._video.removeAttribute(name); else this._video.setAttribute(name, value); } // ── State management ─────────────────────────────────────────────────── private _setState(state: PlayerState): void { this._state = state; this.dataset.state = state; // Update play/pause icons const playing = state === "playing" || state === "buffering"; this._playBtn.innerHTML = playing ? ICON_PAUSE : ICON_PLAY; this._playBtn.ariaLabel = playing ? "Pause" : "Play"; this._overlayBtn.innerHTML = ICON_PLAY; // Auto-hide logic if (playing) this._scheduleHide(); else this._showControls(); } // ── Controls: play/pause ─────────────────────────────────────────────── private _togglePlay(): void { if (this._state === "idle" || this._state === "error") return; if (this._video.paused) void this._video.play(); else this._video.pause(); } // ── Controls: seek ───────────────────────────────────────────────────── private _onSeekInput(): void { const t = Number(this._seekInput.value); this._updateSeekVisuals(t); this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(this._video.duration)}`; } private _onSeekCommit(): void { const target = Number(this._seekInput.value); this._pendingSeekTarget = target; this._video.currentTime = target; this._userSeeking = false; } /** Linear click-to-time mapping across the full track width (no edge clamping). */ private _timeFromSeekPointer(clientX: number): number { const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement; const rect = seekBar.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); return frac * (this._video.duration || 0); } private _onSeekPointerDown(e: PointerEvent): void { // Ignore synthetic clicks originating from the input's own handling if (e.button !== 0 && e.pointerType === "mouse") return; e.preventDefault(); // Consume the event so host pages can layer the player inside a // swipe-driven UI (e.g. TikTok-style vertical pager) without the // pointerdown bubbling out and latching their gesture recognizer. // The seekbar's CSS sets `touch-action: none` to suppress native // browser pan/zoom — this complements that on the JS side, since // swipe handlers built on PointerEvents wouldn't honor touch-action. e.stopPropagation(); this._userSeeking = true; const seekBar = this.shadowRoot!.querySelector(".avp-seek") as HTMLElement; seekBar.setPointerCapture(e.pointerId); seekBar.setAttribute("data-seeking", ""); // Two seek modes, picked by `(pointer: coarse)`: // - **fine** (mouse / trackpad / stylus): absolute mapping — // pointer X maps directly to seek time, thumb jumps under // cursor. Standard desktop YouTube behavior. // - **coarse** (touch): relative drag — initial tap doesn't move // the thumb; finger Δx maps to a Δt added to the time at // pointerdown. Standard YouTube-mobile behavior; matters because // finger positioning is too imprecise for absolute on a small bar. // Both modes commit live during drag (throttled to ~4 Hz so we don't // overwhelm the seek pipeline — every commit restarts the decoder // pump on canvas strategies) and once more on pointerup. const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches; const startTime = coarse ? (this._video.currentTime || 0) : 0; const startClientX = e.clientX; let lastCommit = 0; const timeAt = (clientX: number): number => { if (coarse) { const rect = seekBar.getBoundingClientRect(); const dx = clientX - startClientX; const dt = (dx / rect.width) * (this._video.duration || 0); return Math.max(0, Math.min(this._video.duration || 0, startTime + dt)); } return this._timeFromSeekPointer(clientX); }; const showTooltip = (t: number, clientX: number): void => { if (coarse) this._updateSeekTooltipAtTime(t); else this._updateSeekTooltip(clientX); }; // Fine mode: tap commits immediately (thumb jumps under pointer). // Coarse mode: tap parks at the current time; only drag moves it. if (!coarse) { const initial = timeAt(e.clientX); this._seekInput.value = String(initial); this._onSeekInput(); showTooltip(initial, e.clientX); this._onSeekCommit(); this._userSeeking = true; // commit clears it; we're still seeking } else { showTooltip(startTime, e.clientX); } const onMove = (ev: PointerEvent) => { // Belt-and-suspenders for the host's swipe handler. Pointer capture // changes the *target* of subsequent pointermove events to seekBar, // but they still bubble through ancestors — a swipe listener // attached to document/window would otherwise see every drag tick. ev.stopPropagation(); const t = timeAt(ev.clientX); this._seekInput.value = String(t); this._onSeekInput(); showTooltip(t, ev.clientX); const now = performance.now(); if (now - lastCommit > 250) { lastCommit = now; this._onSeekCommit(); this._userSeeking = true; } }; const onUp = (ev: PointerEvent) => { ev.stopPropagation(); const t = timeAt(ev.clientX); this._seekInput.value = String(t); this._onSeekCommit(); this._seekInput.focus(); seekBar.removeAttribute("data-seeking"); seekBar.removeEventListener("pointermove", onMove); seekBar.removeEventListener("pointerup", onUp); seekBar.removeEventListener("pointercancel", onUp); try { seekBar.releasePointerCapture(e.pointerId); } catch { /* ignore */ } }; seekBar.addEventListener("pointermove", onMove); seekBar.addEventListener("pointerup", onUp); seekBar.addEventListener("pointercancel", onUp); } private _onSeekHover(e: PointerEvent): void { this._updateSeekTooltip(e.clientX); } private _updateSeekTooltip(clientX: number): void { const rect = this._seekInput.getBoundingClientRect(); const frac = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); const t = frac * (this._video.duration || 0); this._seekTooltip.textContent = formatTime(t); this._seekTooltip.style.left = `${frac * 100}%`; } /** Position the tooltip over a specific time (vs. pointer X). Used by * relative-drag scrub on coarse pointers, where the displayed time * is decoupled from the finger position. */ private _updateSeekTooltipAtTime(t: number): void { const dur = this._video.duration || 0; const frac = dur > 0 ? Math.max(0, Math.min(1, t / dur)) : 0; this._seekTooltip.textContent = formatTime(t); this._seekTooltip.style.left = `${frac * 100}%`; } private _updateSeekVisuals(t: number): void { const dur = this._video.duration || 0; const pct = dur > 0 ? (t / dur) * 100 : 0; this._seekProgress.style.width = `${pct}%`; // Thumb position uses a CSS calc that matches the native range input's // click-to-value math (thumb stays within track bounds). See player-styles.ts. this._seekThumb.style.setProperty("--pct", String(pct)); } // ── Controls: time ───────────────────────────────────────────────────── private _updateTime(): void { if (this._userSeeking) return; const t = this._video.currentTime; const d = this._video.duration; // While a committed seek is still settling, keep the thumb at the // target so it doesn't snap back to the pre-seek position. Clear once // currentTime has landed within 0.5s of the target. if (this._pendingSeekTarget !== null) { if (Math.abs(t - this._pendingSeekTarget) < 0.5) { this._pendingSeekTarget = null; } else { this._timeDisplay.textContent = `${formatTime(this._pendingSeekTarget)} / ${formatTime(d)}`; this._updateBuffered(); return; } } this._seekInput.value = String(t); this._updateSeekVisuals(t); this._timeDisplay.textContent = `${formatTime(t)} / ${formatTime(d)}`; this._updateBuffered(); } /** * Render every buffered range as its own segment so gaps (common on MSE * after seeks) are visible. Not gated by `_userSeeking` — ranges should * keep updating while the user scrubs, and runs cheaply on `progress`. */ private _updateBuffered(): void { const d = this._video.duration; if (!(d > 0)) return; let buf: TimeRanges; try { buf = this._video.buffered; } catch { return; } const count = buf ? buf.length : 0; const host = this._seekBuffered; // Reconcile child count. Segment divs are styled via .avp-seek-buffered-range. while (host.childElementCount > count) host.lastElementChild!.remove(); while (host.childElementCount < count) { const seg = document.createElement("div"); seg.className = "avp-seek-buffered-range"; host.appendChild(seg); } for (let i = 0; i < count; i++) { let start: number; let end: number; try { start = buf.start(i); end = buf.end(i); } catch { continue; } const s = Math.max(0, start); const e = Math.min(d, end); if (e <= s) continue; const seg = host.children[i] as HTMLElement; seg.style.left = `${(s / d) * 100}%`; seg.style.width = `${((e - s) / d) * 100}%`; } } // ── Controls: volume ─────────────────────────────────────────────────── private _toggleMute(): void { // Set both the element attribute AND the inner