fix(playback): restore audible volume when M unmutes at zero volume

Addresses the Codex review finding on #1398: after arrowing the volume
down to zero (which mutes), M flipped muted off while leaving the volume
at 0, so the player looked unmuted but stayed silent — in all three
legacy engine adapters.

Mirror the shared controls' ControlsVolume semantics with a per-adapter
LegacyMuteMemory: muting remembers the audible volume, and unmuting while
the volume sits at zero restores it, with the same 0.5 fallback when
nothing was remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB
This commit is contained in:
Claude committed 2026-08-09 20:40:14 +00:00
1 parent f22f517b84
commit a37b6c3fd9
8 files changed
+136 -8

No files matched your search

@@ -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
@@ -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', () => {
@@ -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;
}
},
});
@@ -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', () => {
@@ -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;
@@ -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;
}
}
@@ -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', () => {
@@ -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;