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'](); }