From 0ca37a31a68fef1f599df1cfcc4273bf23ce7f2a Mon Sep 17 00:00:00 2001 From: Logan Date: Tue, 11 Aug 2026 01:14:47 -0400 Subject: [PATCH] fix(player): show mute in the volume indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Muting does not change the volume level, and VolumeChanged carried nothing but that level — so pressing M silenced playback and left the indicator showing the volume it still had. The UI had nothing to react to. Mute rides on its own event rather than widening the volume payload, since the two are genuinely independent: a muted player at 40% is a different state from a player at 0%, and only one of them comes back when you unmute. The icon crosses out and dims, and the popup gains an explicit Mute/Unmute so the keyboard shortcut is not the only way in. MuteToggle also now takes the speaker lock (it was mutating the effects chain from outside it) and refuses politely rather than dereferencing a nil streamer when nothing has been loaded yet. --- backend/events/events.go | 1 + backend/player/player.go | 24 ++++++++- .../volume-control/volume-control.ts | 44 +++++++++++++++-- frontend/src/events.ts | 1 + .../store/controllers/player-controller.ts | 8 +++ frontend/src/store/player-store.ts | 10 ++++ frontend/test/components/transport.test.ts | 49 +++++++++++++++++++ frontend/test/stores/player-store.test.ts | 15 ++++++ frontend/wailsjs/go/player/Player.d.ts | 2 + frontend/wailsjs/go/player/Player.js | 4 ++ 10 files changed, 154 insertions(+), 4 deletions(-) diff --git a/backend/events/events.go b/backend/events/events.go index a486300..7f317b5 100644 --- a/backend/events/events.go +++ b/backend/events/events.go @@ -12,6 +12,7 @@ const ( TrackChanged = "TrackChanged" SeekFailed = "SeekFailed" VolumeChanged = "VolumeChanged" + MuteChanged = "MuteChanged" ) // Queue events (backend → frontend push). diff --git a/backend/player/player.go b/backend/player/player.go index bf826c7..1e6e46b 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -224,12 +224,19 @@ func (p *Player) emitVolumeChanged() { } volume := int(p.getUserVolume()) + muted := p.volume != nil && p.volume.Silent p.logger.Info( - "Emitting VolumeChangedEvent", "volume", volume, + "Emitting VolumeChangedEvent", "volume", volume, "muted", muted, ) events.Emit(p.ctx, events.VolumeChanged, volume) + // Mute rides on its own event rather than widening the volume + // payload: silence does not change the volume level, so a UI that + // only watched VolumeChanged saw nothing happen when the user hit + // the mute key. + events.Emit(p.ctx, events.MuteChanged, muted) + if p.mediaControls != nil { // MPRIS volume is 0.0–1.0 linear. p.mediaControls.UpdateVolume( @@ -692,12 +699,27 @@ func (p *Player) getUserVolume() UserVolume { return Volume(p.volume.Volume).ToUserVolume() } +// Muted reports whether playback is currently silenced. +func (p *Player) Muted() bool { + p.mu.Lock() + defer p.mu.Unlock() + + return p.volume != nil && p.volume.Silent +} + // MuteToggle toggles the mute state. func (p *Player) MuteToggle() error { p.mu.Lock() defer p.mu.Unlock() + if p.volume == nil { + return errNoAudioFileLoaded + } + + speaker.Lock() p.volume.Silent = !p.volume.Silent + speaker.Unlock() + p.emitVolumeChanged() p.saveState() diff --git a/frontend/src/components/audio-player/volume-control/volume-control.ts b/frontend/src/components/audio-player/volume-control/volume-control.ts index c281e65..cf55707 100644 --- a/frontend/src/components/audio-player/volume-control/volume-control.ts +++ b/frontend/src/components/audio-player/volume-control/volume-control.ts @@ -45,6 +45,23 @@ export class VolumeControl extends LitElement { align-items: center; } + /* Muted is a state the volume number cannot express, so it gets a + colour of its own on top of the crossed-out icon. */ + button.muted { + color: var(--yj-text-tertiary, #888); + } + + .volume-popup.muted wa-slider::part(indicator) { + background: var(--yj-text-tertiary, #888); + } + + .mute-toggle { + margin-top: 10px; + font-size: 11px; + color: var(--yj-text-secondary, #b3b3b3); + white-space: nowrap; + } + .volume-popup { position: absolute; bottom: 100%; @@ -56,6 +73,8 @@ export class VolumeControl extends LitElement { padding: 16px 8px; margin-bottom: 8px; display: flex; + flex-direction: column; + align-items: center; justify-content: center; z-index: 100; } @@ -91,7 +110,8 @@ export class VolumeControl extends LitElement { private get volumeIcon(): string { const vol = this.currentVolume; - if (vol === 0) return 'volume-xmark'; + if (this.player.muted) return 'volume-xmark'; + if (vol === 0) return 'volume-off'; if (vol <= 50) return 'volume-low'; return 'volume-high'; @@ -169,13 +189,25 @@ export class VolumeControl extends LitElement { // =================================================================== override render() { + const muted = this.player.muted; + return html` - ${this.showSlider ? html` -
+
+
` : ''} diff --git a/frontend/src/events.ts b/frontend/src/events.ts index ecc6c8a..4e221a2 100644 --- a/frontend/src/events.ts +++ b/frontend/src/events.ts @@ -7,6 +7,7 @@ export const Events = { TrackChanged: "TrackChanged", SeekFailed: "SeekFailed", VolumeChanged: "VolumeChanged", + MuteChanged: "MuteChanged", // Queue events (backend → frontend push) QueueChanged: "QueueChanged", diff --git a/frontend/src/store/controllers/player-controller.ts b/frontend/src/store/controllers/player-controller.ts index 642436a..5edb89b 100644 --- a/frontend/src/store/controllers/player-controller.ts +++ b/frontend/src/store/controllers/player-controller.ts @@ -62,6 +62,10 @@ export class PlayerController implements ReactiveController { return this.state.volume; } + get muted(): boolean { + return this.state.muted; + } + // =================================================================== // ACTIONS // Delegate to store (which delegates to backend) @@ -82,4 +86,8 @@ export class PlayerController implements ReactiveController { setVolume(level: number): void { playerStore.setVolume(level); } + + toggleMute(): void { + playerStore.toggleMute(); + } } diff --git a/frontend/src/store/player-store.ts b/frontend/src/store/player-store.ts index 10aaefe..6e41778 100644 --- a/frontend/src/store/player-store.ts +++ b/frontend/src/store/player-store.ts @@ -28,6 +28,7 @@ export interface PlayerState { isPlaying: boolean; currentTrack: TrackInfo | null; volume: number; // 0-100 + muted: boolean; // silenced independently of the volume level // Frontend-only state (for future use) // selectedTrackIds: Set; @@ -41,6 +42,7 @@ class PlayerStore { isPlaying: false, currentTrack: null, volume: 50, + muted: false, }; private subscribers = new Set(); @@ -72,6 +74,10 @@ class PlayerStore { EventsOn(Events.VolumeChanged, (volume: number) => { this.update({ volume }); }); + + EventsOn(Events.MuteChanged, (muted: boolean) => { + this.update({ muted }); + }); } // =================================================================== @@ -103,6 +109,10 @@ class PlayerStore { Player.SetVolume(level); } + toggleMute(): void { + void Player.MuteToggle(); + } + // =================================================================== // SUBSCRIPTION SYSTEM // =================================================================== diff --git a/frontend/test/components/transport.test.ts b/frontend/test/components/transport.test.ts index 1a7293c..64396b7 100644 --- a/frontend/test/components/transport.test.ts +++ b/frontend/test/components/transport.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; import '@components/audio-player/controls/player-controls'; import '@components/audio-player/seekbar/seek-bar'; +import '@components/audio-player/volume-control/volume-control'; import { Events } from '../../src/events'; import { emit, calls, lastArgs, flush } from '@test/support/harness'; import { @@ -310,3 +311,51 @@ describe('', () => { expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30'); }); }); + +/** + * Mute is silence at an unchanged volume level, so the indicator has to + * be driven by its own event — watching the volume number, as it used + * to, meant pressing M visibly did nothing. + */ +describe('volume control: mute', () => { + beforeEach(() => { + emit(Events.VolumeChanged, 40); + emit(Events.MuteChanged, false); + }); + + it('shows a muted glyph and label once the backend reports mute', async () => { + const el = await fixture('volume-control'); + + expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false'); + + emit(Events.MuteChanged, true); + await flush(); + await el.updateComplete; + + expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('true'); + expect(shadow(el, 'button wa-icon')?.getAttribute('name')).toBe( + 'volume-xmark', + ); + expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe('Muted'); + }); + + it('keeps showing the volume level while muted, because it is unchanged', async () => { + emit(Events.MuteChanged, true); + await flush(); + + const el = await fixture('volume-control'); + await click(el, 'button'); + + expect(shadow(el, 'wa-slider')?.value).toBe(40); + }); + + it('toggles mute through the backend rather than locally', async () => { + const el = await fixture('volume-control'); + await click(el, 'button'); + await click(el, '.mute-toggle'); + + expect(calls('player.Player.MuteToggle').length).toBe(1); + // Nothing optimistic: the icon follows the backend's event. + expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false'); + }); +}); diff --git a/frontend/test/stores/player-store.test.ts b/frontend/test/stores/player-store.test.ts index 204d50e..01bba6c 100644 --- a/frontend/test/stores/player-store.test.ts +++ b/frontend/test/stores/player-store.test.ts @@ -79,6 +79,19 @@ describe('player store: playback state', () => { expect(playerStore.getState().volume).toBe(42); }); + it('tracks mute separately from the volume level', () => { + // Mute leaves the volume number alone, which is exactly why it + // needs an event of its own: the indicator had nothing to react to. + emit(Events.VolumeChanged, 42); + emit(Events.MuteChanged, true); + + expect(playerStore.getState()).toMatchObject({ volume: 42, muted: true }); + + emit(Events.MuteChanged, false); + + expect(playerStore.getState().muted).toBe(false); + }); + it('replaces state rather than mutating it, so a saved reference is stable', () => { emit(Events.VolumeChanged, 10); const before = playerStore.getState(); @@ -110,12 +123,14 @@ describe('player store: actions', () => { playerStore.loadTrack('/music/one.mp3'); playerStore.seek(30); playerStore.setVolume(60); + playerStore.toggleMute(); expect(calls().map((c) => c.path)).toEqual([ 'player.Player.Pause', 'player.Player.LoadFile', 'player.Player.Seek', 'player.Player.SetVolume', + 'player.Player.MuteToggle', ]); }); diff --git a/frontend/wailsjs/go/player/Player.d.ts b/frontend/wailsjs/go/player/Player.d.ts index 2dee35c..2a948a6 100755 --- a/frontend/wailsjs/go/player/Player.d.ts +++ b/frontend/wailsjs/go/player/Player.d.ts @@ -22,6 +22,8 @@ export function LoadFile(arg1:string):Promise; export function MuteToggle():Promise; +export function Muted():Promise; + export function Pause():Promise; export function Play():Promise; diff --git a/frontend/wailsjs/go/player/Player.js b/frontend/wailsjs/go/player/Player.js index c25834c..e3885be 100755 --- a/frontend/wailsjs/go/player/Player.js +++ b/frontend/wailsjs/go/player/Player.js @@ -38,6 +38,10 @@ export function MuteToggle() { return window['go']['player']['Player']['MuteToggle'](); } +export function Muted() { + return window['go']['player']['Player']['Muted'](); +} + export function Pause() { return window['go']['player']['Player']['Pause'](); }