feat(player): add volume/mute controls (#651)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Ángel
2026-05-07 00:38:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 614f764bcd
commit a7a6648852
11 changed files with 490 additions and 2 deletions
+24
View File
@@ -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);