diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts index 39dfc7b0b..791d15553 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-movie-gate.spec.ts @@ -311,4 +311,51 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => { expect(component.showMovieDetail()).toBe(true); }); + + describe('volume handed to each new player', () => { + afterEach(() => localStorage.removeItem('volume')); + + it('re-reads the shared volume bus on every channel change', () => { + localStorage.setItem('volume', '0.4'); + syncStoreState(movieChannel); + fixture.detectChanges(); + expect(component.volume()).toBe(0.4); + + // The engines persist straight to localStorage and never call + // back, so a volume set inside the player is only visible there. + localStorage.setItem('volume', '0.15'); + syncStoreState(liveChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(0.15); + }); + + it.each(['not-a-number', '', ' ', '5', '-1'])( + 'falls back to full volume for the stored value %p', + (stored) => { + localStorage.setItem('volume', stored); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(1); + } + ); + + it('falls back to full volume when nothing is stored yet', () => { + localStorage.removeItem('volume'); + syncStoreState(movieChannel); + fixture.detectChanges(); + + // `Number(null)` is 0 — a first run must not start muted. + expect(component.volume()).toBe(1); + }); + + it('keeps a stored silence', () => { + localStorage.setItem('volume', '0'); + syncStoreState(movieChannel); + fixture.detectChanges(); + + expect(component.volume()).toBe(0); + }); + }); }); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index bc718c980..a6633699a 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -11,6 +11,7 @@ import { computed, effect, inject, + linkedSignal, signal, untracked, } from '@angular/core'; @@ -131,6 +132,23 @@ const M3U_SIDEBAR_MIN_WIDTH = 200; const M3U_SIDEBAR_MAX_WIDTH = 600; const M3U_SIDEBAR_DEFAULT_WIDTH = 460; +/** + * Shared `volume` bus the player engines and the audio player persist to. + * + * The empty cases must be rejected BEFORE `Number()` sees them: it maps both + * `null` (nothing stored yet) and `''` to 0, which would silently start every + * first-run playback muted. + */ +function readStoredVolume(): number { + const stored = localStorage.getItem('volume')?.trim(); + if (!stored) { + return 1; + } + + const parsed = Number(stored); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 1; +} + @Component({ selector: 'app-video-player', imports: [ @@ -456,15 +474,25 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { showChannelNumberOverlay = false; private channelNumberTimeout?: number; - readonly volume = signal(1); + /** + * Volume handed to each new player instance, re-read from the shared + * `volume` localStorage bus on every channel change. + * + * The bus is what the engines actually write to (they persist on + * `volumechange` and never call back into this component), and every + * channel switch mints a new source revision, which recreates the engine + * component and re-reads this input. A plain constructor snapshot + * therefore snapped playback back to the volume the page was opened + * with as soon as the user zapped after adjusting it in the player. + * Remote-control writes still `set()` this signal directly and store the + * same value, so re-reading the bus per channel agrees with them. + */ + readonly volume = linkedSignal({ + source: () => this.activeChannel()?.id ?? null, + computation: () => readStoredVolume(), + }); constructor() { - // Initialize volume from localStorage in constructor - const savedVolume = localStorage.getItem('volume'); - if (savedVolume !== null) { - this.volume.set(Number(savedVolume)); - } - // React to settings changes effect(() => { this.playerSettings = {