fix(playlist): keep the M3U player volume in step with the shared bus

The volume handed to each new player instance was a constructor snapshot of
localStorage, while the engines persist their own changes straight to that
key and never call back. Every channel switch mints a new source revision,
which recreates the engine component and re-reads the input, so lowering the
volume in the player and zapping snapped playback back to the value the page
was opened with — on the live layout as much as on the new movie detail.

The signal now re-reads the shared bus per channel. Empty and out-of-range
stored values fall back to full volume: Number() maps both null and '' to 0,
which would have started a first run muted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 18:41:35 +02:00
1 parent e8f9592eca
commit 2930729e80
2 files changed
+82 -7

No files matched your search

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