fix(core): derive effective mute state for volume UI components (#753)

This commit is contained in:
rahim
2026-03-05 23:10:25 -08:00
committed by GitHub
parent dce16ae5fd
commit 14bcdc833f
6 changed files with 43 additions and 29 deletions
@@ -68,7 +68,7 @@ export class MuteButtonCore {
getState(): MuteButtonState { getState(): MuteButtonState {
const media = this.#media!; const media = this.#media!;
return { return {
muted: media.muted, muted: media.muted || media.volume === 0,
volumeLevel: getVolumeLevel(media), volumeLevel: getVolumeLevel(media),
}; };
} }
@@ -50,6 +50,15 @@ describe('MuteButtonCore', () => {
expect(core.getState().volumeLevel).toBe('off'); expect(core.getState().volumeLevel).toBe('off');
}); });
it('derives muted as true when volume is 0 and not muted', () => {
const core = new MuteButtonCore();
core.setMedia(createMediaState({ volume: 0, muted: false }));
const state = core.getState();
expect(state.muted).toBe(true);
expect(state.volumeLevel).toBe('off');
});
it('returns low when volume < 0.5', () => { it('returns low when volume < 0.5', () => {
const core = new MuteButtonCore(); const core = new MuteButtonCore();
core.setMedia(createMediaState({ volume: 0.3 })); core.setMedia(createMediaState({ volume: 0.3 }));
@@ -105,6 +105,16 @@ describe('VolumeSliderCore', () => {
expect(state.muted).toBe(false); expect(state.muted).toBe(false);
}); });
it('derives muted as true when volume is 0 and not muted', () => {
const core = new VolumeSliderCore();
core.setInput(createInput());
core.setMedia(createMediaState({ volume: 0, muted: false }));
const state = core.getState();
expect(state.muted).toBe(true);
expect(state.fillPercent).toBe(0);
});
}); });
describe('getAttrs', () => { describe('getAttrs', () => {
@@ -150,14 +160,14 @@ describe('VolumeSliderCore', () => {
expect(attrs['aria-label']).toBe('Audio'); expect(attrs['aria-label']).toBe('Audio');
}); });
it('shows 0 percent when volume is 0', () => { it('shows 0 percent muted when volume is 0', () => {
const core = new VolumeSliderCore(); const core = new VolumeSliderCore();
core.setInput(createInput()); core.setInput(createInput());
core.setMedia(createMediaState({ volume: 0 })); core.setMedia(createMediaState({ volume: 0 }));
const state = core.getState(); const state = core.getState();
const attrs = core.getAttrs(state); const attrs = core.getAttrs(state);
expect(attrs['aria-valuetext']).toBe('0 percent'); expect(attrs['aria-valuetext']).toBe('0 percent, muted');
}); });
}); });
@@ -40,6 +40,7 @@ export class VolumeSliderCore extends SliderCore {
getState(): VolumeSliderState { getState(): VolumeSliderState {
const media = this.#media!; const media = this.#media!;
const { volume, muted } = media; const { volume, muted } = media;
const effectivelyMuted = muted || volume === 0;
const { dragging, dragPercent } = this.input; const { dragging, dragPercent } = this.input;
const volumePercent = volume * 100; const volumePercent = volume * 100;
const value = dragging ? this.valueFromPercent(dragPercent) : volumePercent; const value = dragging ? this.valueFromPercent(dragPercent) : volumePercent;
@@ -47,9 +48,9 @@ export class VolumeSliderCore extends SliderCore {
return { return {
...base, ...base,
fillPercent: muted ? 0 : base.fillPercent, fillPercent: effectivelyMuted ? 0 : base.fillPercent,
volume, volume,
muted, muted: effectivelyMuted,
}; };
} }
@@ -114,25 +114,27 @@ describe('volumeFeature', () => {
}); });
describe('toggleMuted', () => { describe('toggleMuted', () => {
it('toggles mute from false to true', async () => { it('mutes when unmuted with volume > 0', async () => {
const video = createMockVideo({ muted: false }); const video = createMockVideo({ muted: false, volume: 0.8 });
const store = createStore<PlayerTarget>()(volumeFeature); const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null }); store.attach({ media: video, container: null });
const result = await store.toggleMuted(); const result = await store.toggleMuted();
expect(video.muted).toBe(true); expect(video.muted).toBe(true);
expect(video.volume).toBe(0.8);
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('toggles mute from true to false', async () => { it('unmutes when muted with volume > 0', async () => {
const video = createMockVideo({ muted: true }); const video = createMockVideo({ muted: true, volume: 0.6 });
const store = createStore<PlayerTarget>()(volumeFeature); const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null }); store.attach({ media: video, container: null });
const result = await store.toggleMuted(); const result = await store.toggleMuted();
expect(video.muted).toBe(false); expect(video.muted).toBe(false);
expect(video.volume).toBe(0.6);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@@ -147,26 +149,16 @@ describe('volumeFeature', () => {
expect(video.volume).toBe(0.25); expect(video.volume).toBe(0.25);
}); });
it('preserves volume when unmuting with volume > 0', async () => { it('unmutes and restores volume when volume is 0 and not muted', async () => {
const video = createMockVideo({ muted: true, volume: 0.6 }); const video = createMockVideo({ muted: false, volume: 0 });
const store = createStore<PlayerTarget>()(volumeFeature); const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null }); store.attach({ media: video, container: null });
await store.toggleMuted(); const result = await store.toggleMuted();
expect(video.muted).toBe(false); expect(video.muted).toBe(false);
expect(video.volume).toBe(0.6); expect(video.volume).toBe(0.25);
}); expect(result).toBe(false);
it('does not change volume when muting', async () => {
const video = createMockVideo({ muted: false, volume: 0.8 });
const store = createStore<PlayerTarget>()(volumeFeature);
store.attach({ media: video, container: null });
await store.toggleMuted();
expect(video.muted).toBe(true);
expect(video.volume).toBe(0.8);
}); });
}); });
}); });
@@ -28,12 +28,14 @@ export const volumeFeature = definePlayerFeature({
toggleMuted() { toggleMuted() {
const { media } = target(); const { media } = target();
const willUnmute = media.muted; const effectivelyMuted = media.muted || media.volume === 0;
media.muted = !media.muted;
// Restore a sensible volume when unmuting at zero. if (effectivelyMuted) {
if (willUnmute && media.volume === 0) { media.muted = false;
media.volume = UNMUTE_VOLUME; // Restore a sensible volume when unmuting at zero.
if (media.volume === 0) media.volume = UNMUTE_VOLUME;
} else {
media.muted = true;
} }
return media.muted; return media.muted;