diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 97ca74257..61e220f3b 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -362,7 +362,10 @@ Shared legacy rules: seek is gated on authoritative `isLive` plus a finite, positive duration (ArtPlayer gates on `art.duration`, the same value its seek setter clamps against, so an unknown duration never jumps to zero); volume steps by ±5% and syncs muted state the way `applyVideoVolume` does (raising -out of mute unmutes, reaching zero mutes); `isAvailable` is the host's +out of mute unmutes, reaching zero mutes); M mirrors `ControlsVolume`'s mute +memory through `LegacyMuteMemory` — muting remembers the audible volume, and +unmuting while the volume sits at zero restores it (same 0.5 fallback), so M +can never leave the player silently "unmuted"; `isAvailable` is the host's `interactionEnabled`, so a visible playback diagnostic disables the keys; and Escape defaults to a no-op without consuming the key, because the vendor chrome owns its own overlays. Instances attach in the component's legacy diff --git a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts index ba29716a8..daf3a91ad 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.spec.ts @@ -85,8 +85,34 @@ describe('attachArtPlayerLegacyShortcuts', () => { it('toggles mute with M', () => { dispatchKey('m'); expect(art.muted).toBe(true); + expect(art.volume).toBe(0.5); dispatchKey('m'); expect(art.muted).toBe(false); + expect(art.volume).toBe(0.5); + }); + + it('restores an audible volume when unmuting from zero', () => { + art.volume = 0.03; + dispatchKey('ArrowDown'); + expect(art.volume).toBe(0); + expect(art.muted).toBe(true); + + dispatchKey('m'); + expect(art.muted).toBe(false); + expect(art.volume).toBe(0.5); + }); + + it('restores the remembered volume when unmuting after mute zeroed out', () => { + art.volume = 0.7; + dispatchKey('m'); + expect(art.muted).toBe(true); + + dispatchKey('ArrowDown'); + expect(art.volume).toBe(0); + + dispatchKey('m'); + expect(art.muted).toBe(false); + expect(art.volume).toBe(0.7); }); it('toggles the vendor fullscreen with F', () => { diff --git a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts index 73e0e182e..7487c12e6 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-legacy-shortcuts.ts @@ -1,5 +1,5 @@ import type Artplayer from 'artplayer'; -import { LegacyPlayerShortcuts } from '../player-controls'; +import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls'; export interface ArtPlayerLegacyShortcutOptions { player: () => Artplayer | null; @@ -21,6 +21,7 @@ export function attachArtPlayerLegacyShortcuts( options: ArtPlayerLegacyShortcutOptions ): LegacyPlayerShortcuts { const shortcuts = new LegacyPlayerShortcuts(); + const muteMemory = new LegacyMuteMemory(); shortcuts.attach({ isAvailable: options.isAvailable, hostElement: options.hostElement, @@ -71,8 +72,15 @@ export function attachArtPlayerLegacyShortcuts( }, toggleMute: () => { const player = options.player(); - if (player) { - player.muted = !player.muted; + if (!player) { + return; + } + if (player.muted) { + player.volume = muteMemory.unmuteVolume(player.volume); + player.muted = false; + } else { + muteMemory.rememberIfAudible(player.volume); + player.muted = true; } }, }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts index 4734754ca..52109d76b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.spec.ts @@ -85,8 +85,34 @@ describe('attachHtmlVideoLegacyShortcuts', () => { it('toggles mute with M', () => { dispatchKey('m'); expect(video.muted).toBe(true); + expect(video.volume).toBe(0.5); dispatchKey('m'); expect(video.muted).toBe(false); + expect(video.volume).toBe(0.5); + }); + + it('restores an audible volume when unmuting from zero', () => { + video.volume = 0.03; + dispatchKey('ArrowDown'); + expect(video.volume).toBe(0); + expect(video.muted).toBe(true); + + dispatchKey('m'); + expect(video.muted).toBe(false); + expect(video.volume).toBe(0.5); + }); + + it('restores the remembered volume when unmuting after mute zeroed out', () => { + video.volume = 0.7; + dispatchKey('m'); + expect(video.muted).toBe(true); + + dispatchKey('ArrowDown'); + expect(video.volume).toBe(0); + + dispatchKey('m'); + expect(video.muted).toBe(false); + expect(video.volume).toBe(0.7); }); it('fullscreens the video element itself with F', () => { diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts index db3fa9998..f4b48bb8b 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-legacy-shortcuts.ts @@ -1,4 +1,4 @@ -import { LegacyPlayerShortcuts } from '../player-controls'; +import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls'; import { applyVideoCurrentTime, applyVideoVolume, @@ -21,6 +21,7 @@ export function attachHtmlVideoLegacyShortcuts( options: HtmlVideoLegacyShortcutOptions ): LegacyPlayerShortcuts { const shortcuts = new LegacyPlayerShortcuts(); + const muteMemory = new LegacyMuteMemory(); shortcuts.attach({ isAvailable: options.isAvailable, hostElement: options.hostElement, @@ -55,7 +56,12 @@ export function attachHtmlVideoLegacyShortcuts( }, toggleMute: () => { const video = options.video(); - video.muted = !video.muted; + if (video.muted) { + applyVideoVolume(video, muteMemory.unmuteVolume(video.volume)); + } else { + muteMemory.rememberIfAudible(video.volume); + video.muted = true; + } }, }); return shortcuts; diff --git a/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts index f0dfec078..f9554c04a 100644 --- a/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts +++ b/libs/ui/playback/src/lib/player-controls/legacy-player-shortcuts.ts @@ -59,3 +59,26 @@ export class LegacyPlayerShortcuts { this.shortcuts.detach(); } } + +/** + * Mute memory for the legacy engine adapters, mirroring the shared controls' + * `ControlsVolume`: muting remembers the audible volume, and unmuting while + * the volume sits at zero restores it (same 0.5 fallback), so M can never + * leave the player silently "unmuted". + */ +export class LegacyMuteMemory { + private lastAudibleVolume: number | null = null; + + rememberIfAudible(volume: number): void { + if (Number.isFinite(volume) && volume > 0) { + this.lastAudibleVolume = volume; + } + } + + unmuteVolume(currentVolume: number): number { + if (Number.isFinite(currentVolume) && currentVolume > 0) { + return currentVolume; + } + return this.lastAudibleVolume ?? 0.5; + } +} diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts index c1629f2b6..05c13b738 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.spec.ts @@ -124,8 +124,34 @@ describe('attachVjsLegacyShortcuts', () => { it('toggles mute with M', () => { dispatchKey('m'); expect(mock.mutedValue).toBe(true); + expect(mock.volumeValue).toBe(0.5); dispatchKey('m'); expect(mock.mutedValue).toBe(false); + expect(mock.volumeValue).toBe(0.5); + }); + + it('restores an audible volume when unmuting from zero', () => { + mock.volumeValue = 0.03; + dispatchKey('ArrowDown'); + expect(mock.volumeValue).toBe(0); + expect(mock.mutedValue).toBe(true); + + dispatchKey('m'); + expect(mock.mutedValue).toBe(false); + expect(mock.volumeValue).toBe(0.5); + }); + + it('restores the remembered volume when unmuting after mute zeroed out', () => { + mock.volumeValue = 0.7; + dispatchKey('m'); + expect(mock.mutedValue).toBe(true); + + dispatchKey('ArrowDown'); + expect(mock.volumeValue).toBe(0); + + dispatchKey('m'); + expect(mock.mutedValue).toBe(false); + expect(mock.volumeValue).toBe(0.7); }); it('toggles the vendor fullscreen with F', () => { diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts index 8c6dbdd01..0ea5b2839 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-legacy-shortcuts.ts @@ -1,4 +1,4 @@ -import { LegacyPlayerShortcuts } from '../player-controls'; +import { LegacyMuteMemory, LegacyPlayerShortcuts } from '../player-controls'; import type { VideoJsPlayer } from './vjs-player.types'; export interface VjsLegacyShortcutOptions { @@ -17,6 +17,7 @@ export function attachVjsLegacyShortcuts( options: VjsLegacyShortcutOptions ): LegacyPlayerShortcuts { const shortcuts = new LegacyPlayerShortcuts(); + const muteMemory = new LegacyMuteMemory(); shortcuts.attach({ isAvailable: options.isAvailable, hostElement: options.hostElement, @@ -74,7 +75,16 @@ export function attachVjsLegacyShortcuts( }, toggleMute: () => { const player = options.player(); - player?.muted(!player.muted()); + if (!player) { + return; + } + if (player.muted()) { + player.volume(muteMemory.unmuteVolume(player.volume() ?? 0)); + player.muted(false); + } else { + muteMemory.rememberIfAudible(player.volume() ?? 0); + player.muted(true); + } }, }); return shortcuts;