From a7a6648852ccc16f5b163f6d320a23a1a349af79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 7 May 2026 00:38:27 +0200 Subject: [PATCH] feat(player): add volume/mute controls (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(player): add volume/mute controls to the player Adds a mute toggle button and volume slider to the controls bar, positioned between the time display and speed selector. The slider expands on hover for a compact default footprint. - `volume` attribute/property (0–1, clamped) with `volumechange` event - `muted` attribute now syncs to the controls UI (icon updates) - Three volume icons: high, low, muted — updates reactively - Volume forwarded to parent-frame audio proxies and iframe runtime via `set-volume` postMessage control - 9 new tests covering volume clamping, events, controls rendering, mute toggle, and iframe message forwarding Co-Authored-By: Claude Opus 4.6 (1M context) * fix(player): wire set-volume through runtime bridge + address review feedback Addresses the blocker from PR review: the iframe runtime now handles `set-volume` messages end-to-end (types → bridge → init → media sync). Runtime side: - Add `set-volume` to RuntimeBridgeControlAction union - Add `volume` field to RuntimeBridgeControlMessage - Handle `set-volume` in bridge.ts with [0,1] clamping - Store bridgeVolume in RuntimeState, apply to media elements - syncRuntimeMedia composes userVolume × clip author volume Player side: - Muted toggle now dispatches `volumechange` (HTML5 spec compliance) - Volume slider auto-unmutes when scrubbed above 0 while muted - Touch support on volume slider (touchstart/move/end) Tests: 5 new (3 bridge, 2 media) Co-Authored-By: Claude Opus 4.6 (1M context) * fix(player): add ARIA keyboard controls, fix icon collision and clipVolume parity - Volume slider: role="slider", aria-label, aria-valuemin/max/now, tabindex=0, arrow key support (5% steps, auto-unmutes) - Volume=0 unmuted now shows low-volume icon instead of muted icon - Fix clipVolume divergence: init.ts uses Number.isFinite() matching media.ts semantics (preserves data-volume="0") - 3 new tests: ARIA attributes, volumechange on mute, icon collision Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- packages/core/src/runtime/bridge.test.ts | 24 +++ packages/core/src/runtime/bridge.ts | 5 + packages/core/src/runtime/init.ts | 11 ++ packages/core/src/runtime/media.test.ts | 24 +++ packages/core/src/runtime/media.ts | 8 +- packages/core/src/runtime/state.ts | 2 + packages/core/src/runtime/types.ts | 2 + packages/player/src/controls.ts | 139 +++++++++++++- .../player/src/hyperframes-player.test.ts | 181 ++++++++++++++++++ packages/player/src/hyperframes-player.ts | 29 +++ packages/player/src/styles.ts | 67 +++++++ 11 files changed, 490 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/bridge.test.ts b/packages/core/src/runtime/bridge.test.ts index 6b07478cf..81f7560d5 100644 --- a/packages/core/src/runtime/bridge.test.ts +++ b/packages/core/src/runtime/bridge.test.ts @@ -7,6 +7,7 @@ function createMockDeps() { onPause: vi.fn(), onSeek: vi.fn(), onSetMuted: vi.fn(), + onSetVolume: vi.fn(), onSetMediaOutputMuted: vi.fn(), onSetPlaybackRate: vi.fn(), onEnablePickMode: vi.fn(), @@ -56,6 +57,29 @@ describe("installRuntimeControlBridge", () => { expect(deps.onSetMuted).toHaveBeenCalledWith(true); }); + it("dispatches set-volume command", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-volume", { volume: 0.5 })); + expect(deps.onSetVolume).toHaveBeenCalledWith(0.5); + }); + + it("clamps set-volume to [0, 1]", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-volume", { volume: 1.5 })); + expect(deps.onSetVolume).toHaveBeenCalledWith(1); + handler(makeControlMessage("set-volume", { volume: -0.5 })); + expect(deps.onSetVolume).toHaveBeenCalledWith(0); + }); + + it("defaults volume to 1 when absent", () => { + const deps = createMockDeps(); + const handler = installRuntimeControlBridge(deps); + handler(makeControlMessage("set-volume")); + expect(deps.onSetVolume).toHaveBeenCalledWith(1); + }); + it("dispatches set-media-output-muted command", () => { const deps = createMockDeps(); const handler = installRuntimeControlBridge(deps); diff --git a/packages/core/src/runtime/bridge.ts b/packages/core/src/runtime/bridge.ts index e11966a23..73fcdbd4b 100644 --- a/packages/core/src/runtime/bridge.ts +++ b/packages/core/src/runtime/bridge.ts @@ -5,6 +5,7 @@ type BridgeDeps = { onPause: () => void; onSeek: (frame: number, seekMode: "drag" | "commit") => void; onSetMuted: (muted: boolean) => void; + onSetVolume: (volume: number) => void; onSetMediaOutputMuted: (muted: boolean) => void; onSetPlaybackRate: (rate: number) => void; onEnablePickMode: () => void; @@ -40,6 +41,10 @@ export function installRuntimeControlBridge(deps: BridgeDeps): (event: MessageEv deps.onSetMuted(Boolean(data.muted)); return; } + if (action === "set-volume") { + deps.onSetVolume(Math.max(0, Math.min(1, Number(data.volume ?? 1)))); + return; + } if (action === "set-media-output-muted") { deps.onSetMediaOutputMuted(Boolean(data.muted)); return; diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 3693954be..25e685a8b 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1296,6 +1296,7 @@ export function initSandboxRuntimeModular(): void { playbackRate: state.playbackRate, outputMuted: state.mediaOutputMuted, userMuted: state.bridgeMuted, + userVolume: state.bridgeVolume, onAutoplayBlocked: () => { if (state.mediaAutoplayBlockedPosted) return; state.mediaAutoplayBlockedPosted = true; @@ -1555,6 +1556,16 @@ export function initSandboxRuntimeModular(): void { el.muted = effective; } }, + onSetVolume: (volume) => { + state.bridgeVolume = volume; + const mediaEls = document.querySelectorAll("video, audio"); + for (const el of mediaEls) { + if (!(el instanceof HTMLMediaElement)) continue; + const parsed = parseFloat(el.dataset.volume ?? ""); + const clipVolume = Number.isFinite(parsed) ? parsed : 1; + el.volume = clipVolume * volume; + } + }, onSetMediaOutputMuted: (muted) => { state.mediaOutputMuted = muted; const effective = muted || state.bridgeMuted; diff --git a/packages/core/src/runtime/media.test.ts b/packages/core/src/runtime/media.test.ts index dbf6abe77..7496c0097 100644 --- a/packages/core/src/runtime/media.test.ts +++ b/packages/core/src/runtime/media.test.ts @@ -319,6 +319,30 @@ describe("syncRuntimeMedia", () => { expect(clip.el.volume).toBe(0.7); }); + it("applies userVolume as a multiplier on clip volume", () => { + const clip = createMockClip({ start: 0, end: 10, volume: 0.8 }); + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 5, + playing: false, + playbackRate: 1, + userVolume: 0.5, + }); + expect(clip.el.volume).toBeCloseTo(0.4); + }); + + it("applies userVolume to clips without explicit volume (default 1)", () => { + const clip = createMockClip({ start: 0, end: 10 }); + syncRuntimeMedia({ + clips: [clip], + timeSeconds: 5, + playing: false, + playbackRate: 1, + userVolume: 0.3, + }); + expect(clip.el.volume).toBeCloseTo(0.3); + }); + it("hard-syncs on the first active tick (sub-composition activation, mediaStart offsets)", () => { const clip = createMockClip({ start: 0, end: 10, mediaStart: 0 }); Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 99ac54c41..710999cb0 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -118,6 +118,11 @@ export function syncRuntimeMedia(params: { * before the next bridge message lands. */ userMuted?: boolean; + /** + * User's volume preference (0–1, set via `onSetVolume`). Multiplied with the + * per-clip author volume so `data-volume="0.5"` at user volume 0.8 yields 0.4. + */ + userVolume?: number; /** * Invoked at most once when a media element's `play()` promise rejects with * `NotAllowedError`. The caller is expected to latch and post a single @@ -142,7 +147,8 @@ export function syncRuntimeMedia(params: { relTime = clip.mediaStart + ((relTime - clip.mediaStart) % loopLength); } } - if (clip.volume != null) el.volume = clip.volume; + const userVol = params.userVolume ?? 1; + el.volume = (clip.volume ?? 1) * userVol; if (shouldMute) el.muted = true; // Ensure full preload for every active media element. Streaming // formats (MP3) may arrive with preload="metadata", which only diff --git a/packages/core/src/runtime/state.ts b/packages/core/src/runtime/state.ts index a05279117..c6ddaf57d 100644 --- a/packages/core/src/runtime/state.ts +++ b/packages/core/src/runtime/state.ts @@ -10,6 +10,7 @@ export type RuntimeState = { parityModeEnabled: boolean; canonicalFps: number; bridgeMuted: boolean; + bridgeVolume: number; /** * Internal mute of audible media output, owned by the audio-ownership * protocol between the parent (``) and this runtime. @@ -78,6 +79,7 @@ export function createRuntimeState(): RuntimeState { parityModeEnabled: true, canonicalFps: 30, bridgeMuted: false, + bridgeVolume: 1, mediaOutputMuted: false, mediaAutoplayBlockedPosted: false, playbackRate: 1, diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index da94ffbe1..2e10a6d12 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -11,6 +11,7 @@ export type RuntimeBridgeControlAction = | "pause" | "seek" | "set-muted" + | "set-volume" | "set-media-output-muted" | "set-playback-rate" | "enable-pick-mode" @@ -23,6 +24,7 @@ export type RuntimeBridgeControlMessage = { action: RuntimeBridgeControlAction; frame?: number; muted?: boolean; + volume?: number; playbackRate?: number; seekMode?: "drag" | "commit"; }; diff --git a/packages/player/src/controls.ts b/packages/player/src/controls.ts index 8f7ae5d1d..7b799422b 100644 --- a/packages/player/src/controls.ts +++ b/packages/player/src/controls.ts @@ -1,10 +1,18 @@ -import { PLAY_ICON, PAUSE_ICON } from "./styles.js"; +import { + PLAY_ICON, + PAUSE_ICON, + VOLUME_HIGH_ICON, + VOLUME_LOW_ICON, + VOLUME_MUTED_ICON, +} from "./styles.js"; export interface ControlsCallbacks { onPlay: () => void; onPause: () => void; onSeek: (fraction: number) => void; onSpeedChange: (speed: number) => void; + onMuteToggle: () => void; + onVolumeChange: (volume: number) => void; } /** Default logarithmic speed presets — each step roughly doubles/halves. */ @@ -38,6 +46,8 @@ export function createControls( updateTime: (current: number, duration: number) => void; updatePlaying: (playing: boolean) => void; updateSpeed: (speed: number) => void; + updateMuted: (muted: boolean) => void; + updateVolume: (volume: number) => void; show: () => void; hide: () => void; destroy: () => void; @@ -94,23 +104,135 @@ export function createControls( speedWrap.appendChild(speedMenu); speedWrap.appendChild(speedBtn); + const volumeWrap = document.createElement("div"); + volumeWrap.className = "hfp-volume-wrap"; + + const muteBtn = document.createElement("button"); + muteBtn.className = "hfp-mute-btn"; + muteBtn.type = "button"; + muteBtn.innerHTML = VOLUME_HIGH_ICON; + muteBtn.setAttribute("aria-label", "Mute"); + + const volumeSliderWrap = document.createElement("div"); + volumeSliderWrap.className = "hfp-volume-slider-wrap"; + + const volumeSlider = document.createElement("div"); + volumeSlider.className = "hfp-volume-slider"; + volumeSlider.setAttribute("role", "slider"); + volumeSlider.setAttribute("aria-label", "Volume"); + volumeSlider.setAttribute("aria-valuemin", "0"); + volumeSlider.setAttribute("aria-valuemax", "100"); + volumeSlider.setAttribute("aria-valuenow", "100"); + volumeSlider.tabIndex = 0; + const volumeFill = document.createElement("div"); + volumeFill.className = "hfp-volume-fill"; + volumeFill.style.width = "100%"; + volumeSlider.appendChild(volumeFill); + volumeSliderWrap.appendChild(volumeSlider); + + volumeWrap.appendChild(volumeSliderWrap); + volumeWrap.appendChild(muteBtn); + controls.appendChild(playBtn); controls.appendChild(scrubber); controls.appendChild(time); + controls.appendChild(volumeWrap); controls.appendChild(speedWrap); parent.appendChild(controls); let isPlaying = false; + let isMuted = false; + let currentVolume = 1; let hideTimeout: ReturnType | null = null; let speedIndex = presets.indexOf(1); // start at 1x if (speedIndex === -1) speedIndex = 0; + const getVolumeIcon = (muted: boolean, volume: number): string => { + if (muted) return VOLUME_MUTED_ICON; + if (volume === 0) return VOLUME_LOW_ICON; + if (volume < 0.5) return VOLUME_LOW_ICON; + return VOLUME_HIGH_ICON; + }; + playBtn.addEventListener("click", (e) => { e.stopPropagation(); if (isPlaying) callbacks.onPause(); else callbacks.onPlay(); }); + muteBtn.addEventListener("click", (e) => { + e.stopPropagation(); + callbacks.onMuteToggle(); + }); + + let volumeScrubbing = false; + + const handleVolumeAt = (clientX: number) => { + const rect = volumeSlider.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + currentVolume = fraction; + volumeFill.style.width = `${fraction * 100}%`; + volumeSlider.setAttribute("aria-valuenow", String(Math.round(fraction * 100))); + if (isMuted && fraction > 0) callbacks.onMuteToggle(); + muteBtn.innerHTML = getVolumeIcon(isMuted, fraction); + callbacks.onVolumeChange(fraction); + }; + + volumeSlider.addEventListener("mousedown", (e) => { + e.stopPropagation(); + volumeScrubbing = true; + handleVolumeAt(e.clientX); + }); + const onVolumeMouseMove = (e: MouseEvent) => { + if (volumeScrubbing) handleVolumeAt(e.clientX); + }; + const onVolumeMouseUp = () => { + volumeScrubbing = false; + }; + document.addEventListener("mousemove", onVolumeMouseMove); + document.addEventListener("mouseup", onVolumeMouseUp); + + volumeSlider.addEventListener( + "touchstart", + (e) => { + volumeScrubbing = true; + const touch = e.touches[0]; + if (touch) handleVolumeAt(touch.clientX); + }, + { passive: true }, + ); + const onVolumeTouchMove = (e: TouchEvent) => { + if (volumeScrubbing) { + const touch = e.touches[0]; + if (touch) handleVolumeAt(touch.clientX); + } + }; + const onVolumeTouchEnd = () => { + volumeScrubbing = false; + }; + document.addEventListener("touchmove", onVolumeTouchMove, { passive: true }); + document.addEventListener("touchend", onVolumeTouchEnd); + + const VOLUME_STEP = 0.05; + volumeSlider.addEventListener("keydown", (e) => { + let newVol = currentVolume; + if (e.key === "ArrowRight" || e.key === "ArrowUp") { + newVol = Math.min(1, currentVolume + VOLUME_STEP); + } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { + newVol = Math.max(0, currentVolume - VOLUME_STEP); + } else { + return; + } + e.preventDefault(); + e.stopPropagation(); + currentVolume = newVol; + volumeFill.style.width = `${newVol * 100}%`; + volumeSlider.setAttribute("aria-valuenow", String(Math.round(newVol * 100))); + if (isMuted && newVol > 0) callbacks.onMuteToggle(); + muteBtn.innerHTML = getVolumeIcon(isMuted, newVol); + callbacks.onVolumeChange(newVol); + }); + const setActiveOption = (speed: number) => { for (const opt of speedMenu.querySelectorAll(".hfp-speed-option")) { opt.classList.toggle("hfp-active", (opt as HTMLElement).dataset.speed === String(speed)); @@ -221,6 +343,17 @@ export function createControls( speedBtn.textContent = formatSpeed(speed); setActiveOption(speed); }, + updateMuted(muted: boolean) { + isMuted = muted; + muteBtn.innerHTML = getVolumeIcon(muted, currentVolume); + muteBtn.setAttribute("aria-label", muted ? "Unmute" : "Mute"); + }, + updateVolume(volume: number) { + currentVolume = volume; + volumeFill.style.width = `${volume * 100}%`; + volumeSlider.setAttribute("aria-valuenow", String(Math.round(volume * 100))); + muteBtn.innerHTML = getVolumeIcon(isMuted, volume); + }, show() { controls.style.display = ""; }, @@ -232,6 +365,10 @@ export function createControls( document.removeEventListener("mouseup", onMouseUp); document.removeEventListener("touchmove", onTouchMove); document.removeEventListener("touchend", onTouchEnd); + document.removeEventListener("mousemove", onVolumeMouseMove); + document.removeEventListener("mouseup", onVolumeMouseUp); + document.removeEventListener("touchmove", onVolumeTouchMove); + document.removeEventListener("touchend", onVolumeTouchEnd); document.removeEventListener("click", onDocClick); if (hideTimeout) clearTimeout(hideTimeout); }, diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 352639ccd..8e17d7ee5 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1171,3 +1171,184 @@ describe("HyperframesPlayer srcdoc attribute", () => { player.remove(); }); }); + +// ── Volume / Mute controls ── + +describe("HyperframesPlayer volume and mute", () => { + let player: HTMLElement & { + muted: boolean; + volume: number; + iframeElement: HTMLIFrameElement; + }; + let mockAudio: { + preload: string; + src: string; + muted: boolean; + volume: number; + playbackRate: number; + currentTime: number; + load: ReturnType; + play: ReturnType; + pause: ReturnType; + }; + + beforeEach(async () => { + await import("./hyperframes-player.js"); + + mockAudio = { + preload: "", + src: "", + muted: false, + volume: 1, + playbackRate: 1, + currentTime: 0, + load: vi.fn(), + play: vi.fn().mockResolvedValue(undefined), + pause: vi.fn(), + }; + vi.spyOn(globalThis, "Audio").mockImplementation( + () => mockAudio as unknown as HTMLAudioElement, + ); + + player = document.createElement("hyperframes-player") as typeof player; + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("defaults volume to 1", () => { + document.body.appendChild(player); + expect(player.volume).toBe(1); + }); + + it("sets volume on parent media when audio-src is configured", () => { + player.setAttribute("volume", "0.5"); + player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); + document.body.appendChild(player); + + expect(mockAudio.volume).toBe(0.5); + }); + + it("updates parent media volume when volume attribute changes", () => { + player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); + document.body.appendChild(player); + + player.setAttribute("volume", "0.3"); + expect(mockAudio.volume).toBe(0.3); + }); + + it("clamps volume to [0, 1]", () => { + document.body.appendChild(player); + + player.volume = 1.5; + expect(player.volume).toBe(1); + + player.volume = -0.5; + expect(player.volume).toBe(0); + }); + + it("dispatches volumechange event when volume changes", () => { + document.body.appendChild(player); + + const handler = vi.fn(); + player.addEventListener("volumechange", handler); + + player.setAttribute("volume", "0.7"); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("muted property toggles the muted attribute", () => { + document.body.appendChild(player); + + player.muted = true; + expect(player.hasAttribute("muted")).toBe(true); + + player.muted = false; + expect(player.hasAttribute("muted")).toBe(false); + }); + + it("sends set-volume control to iframe", () => { + document.body.appendChild(player); + + const postMessageSpy = vi.fn(); + Object.defineProperty(player.iframeElement, "contentWindow", { + value: { postMessage: postMessageSpy }, + configurable: true, + }); + + player.setAttribute("volume", "0.6"); + expect(postMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + source: "hf-parent", + type: "control", + action: "set-volume", + volume: 0.6, + }), + "*", + ); + }); + + it("controls bar shows mute button when controls are enabled", () => { + player.setAttribute("controls", ""); + document.body.appendChild(player); + + const shadow = player.shadowRoot!; + const muteBtn = shadow.querySelector(".hfp-mute-btn"); + expect(muteBtn).toBeTruthy(); + expect(muteBtn?.getAttribute("aria-label")).toBe("Mute"); + }); + + it("controls bar shows volume slider when controls are enabled", () => { + player.setAttribute("controls", ""); + document.body.appendChild(player); + + const shadow = player.shadowRoot!; + const slider = shadow.querySelector(".hfp-volume-slider"); + expect(slider).toBeTruthy(); + }); + + it("volume slider has ARIA slider attributes", () => { + player.setAttribute("controls", ""); + document.body.appendChild(player); + + const shadow = player.shadowRoot!; + const slider = shadow.querySelector(".hfp-volume-slider")!; + expect(slider.getAttribute("role")).toBe("slider"); + expect(slider.getAttribute("aria-label")).toBe("Volume"); + expect(slider.getAttribute("aria-valuemin")).toBe("0"); + expect(slider.getAttribute("aria-valuemax")).toBe("100"); + expect(slider.getAttribute("aria-valuenow")).toBe("100"); + expect(slider.getAttribute("tabindex")).toBe("0"); + }); + + it("dispatches volumechange when muted toggles (HTML5 spec)", () => { + document.body.appendChild(player); + + const handler = vi.fn(); + player.addEventListener("volumechange", handler); + + player.muted = true; + expect(handler).toHaveBeenCalledTimes(1); + + player.muted = false; + expect(handler).toHaveBeenCalledTimes(2); + }); + + it("muted icon differs from volume=0 unmuted icon", () => { + player.setAttribute("controls", ""); + document.body.appendChild(player); + + const shadow = player.shadowRoot!; + const muteBtn = shadow.querySelector(".hfp-mute-btn")!; + + player.setAttribute("volume", "0"); + const zeroVolumeHtml = muteBtn.innerHTML; + + player.muted = true; + const mutedHtml = muteBtn.innerHTML; + + expect(zeroVolumeHtml).not.toBe(mutedHtml); + }); +}); diff --git a/packages/player/src/hyperframes-player.ts b/packages/player/src/hyperframes-player.ts index 0b43e671a..545778ea0 100644 --- a/packages/player/src/hyperframes-player.ts +++ b/packages/player/src/hyperframes-player.ts @@ -138,6 +138,7 @@ class HyperframesPlayer extends HTMLElement { "height", "controls", "muted", + "volume", "poster", "playback-rate", "audio-src", @@ -166,6 +167,7 @@ class HyperframesPlayer extends HTMLElement { private _duration = 0; private _currentTime = 0; private _paused = true; + private _volume = 1; private _compositionWidth = 1920; private _compositionHeight = 1080; private _probeInterval: ReturnType | null = null; @@ -362,7 +364,18 @@ class HyperframesPlayer extends HTMLElement { case "muted": for (const m of this._parentMedia) m.el.muted = val !== null; this._sendControl("set-muted", { muted: val !== null }); + this.controlsApi?.updateMuted(val !== null); + this.dispatchEvent(new Event("volumechange")); break; + case "volume": { + const v = Math.max(0, Math.min(1, parseFloat(val || "1"))); + this._volume = v; + for (const m of this._parentMedia) m.el.volume = v; + this._sendControl("set-volume", { volume: v }); + this.controlsApi?.updateVolume(v); + this.dispatchEvent(new Event("volumechange")); + break; + } case "audio-src": if (val) this._setupParentAudioFromUrl(val); break; @@ -517,6 +530,13 @@ class HyperframesPlayer extends HTMLElement { else this.removeAttribute("muted"); } + get volume() { + return this._volume; + } + set volume(v: number) { + this.setAttribute("volume", String(Math.max(0, Math.min(1, v)))); + } + get loop() { return this.hasAttribute("loop"); } @@ -1061,6 +1081,12 @@ class HyperframesPlayer extends HTMLElement { onSpeedChange: (speed) => { this.playbackRate = speed; }, + onMuteToggle: () => { + this.muted = !this.muted; + }, + onVolumeChange: (volume) => { + this.volume = volume; + }, }; const presetsAttr = this.getAttribute("speed-presets"); const speedPresets = presetsAttr @@ -1070,6 +1096,8 @@ class HyperframesPlayer extends HTMLElement { .filter((n) => !isNaN(n) && n > 0) : undefined; this.controlsApi = createControls(this.shadow, callbacks, { speedPresets }); + this.controlsApi.updateMuted(this.muted); + this.controlsApi.updateVolume(this._volume); } private _setupPoster() { @@ -1242,6 +1270,7 @@ class HyperframesPlayer extends HTMLElement { el.src = src; el.load(); el.muted = this.muted; + el.volume = this._volume; if (this.playbackRate !== 1) el.playbackRate = this.playbackRate; const entry = { el, start, duration, driftSamples: 0 }; diff --git a/packages/player/src/styles.ts b/packages/player/src/styles.ts index 200269e77..4d99c1e76 100644 --- a/packages/player/src/styles.ts +++ b/packages/player/src/styles.ts @@ -350,10 +350,77 @@ export const PLAYER_STYLES = /* css */ ` color: var(--hfp-accent, #fff); font-weight: 600; } + + .hfp-volume-wrap { + position: relative; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0; + } + + .hfp-mute-btn { + background: none; + border: none; + color: var(--hfp-color, #fff); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + } + + .hfp-mute-btn:hover { + opacity: 0.8; + } + + .hfp-mute-btn svg, + .hfp-mute-btn svg * { + pointer-events: none; + } + + .hfp-volume-slider-wrap { + width: 0; + overflow: hidden; + transition: width 0.2s ease; + display: flex; + align-items: center; + } + + .hfp-volume-wrap:hover .hfp-volume-slider-wrap { + width: 64px; + } + + .hfp-volume-slider { + width: 56px; + height: var(--hfp-scrubber-height, 4px); + background: var(--hfp-scrubber-bg, rgba(255, 255, 255, 0.3)); + border-radius: var(--hfp-scrubber-radius, 2px); + cursor: pointer; + position: relative; + margin-left: 4px; + margin-right: 4px; + } + + .hfp-volume-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--hfp-accent, #fff); + border-radius: var(--hfp-scrubber-radius, 2px); + pointer-events: none; + } `; export const PLAY_ICON = ``; export const PAUSE_ICON = ``; +export const VOLUME_HIGH_ICON = ``; +export const VOLUME_LOW_ICON = ``; +export const VOLUME_MUTED_ICON = ``; /** * Process-wide cache for the constructed PLAYER_STYLES sheet. Lazy so the