From e8f9592eca6fa3b3a90b3c8c11ee5b806a25f4b6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 12 Aug 2026 17:59:59 +0200 Subject: [PATCH] fix(playlist): keep M3U movie playback stable and volume-aware Review follow-up on the M3U VOD detail view: - The inline playback payload no longer reads TMDB signals. Its object identity is the player's source-application key, so rebuilding it with the resolved title/poster restarted the movie the moment enrichment landed. Metadata now only feeds the hero/About presentation. - Thread M3U's persisted volume through to the player: PortalInlinePlayerComponent gained an optional volume input (default 1, so the portals are unchanged) and the detail host forwards the parent's value. Recognized movies used to start at full volume regardless of the saved setting. Co-Authored-By: Claude Fable 5 --- docs/architecture/m3u-playlist-module.md | 14 +++- .../m3u-vod-detail.component.html | 1 + .../m3u-vod-detail.component.spec.ts | 68 +++++++++++++++++-- .../m3u-vod-detail.component.ts | 27 ++++---- .../video-player/video-player.component.html | 1 + .../portal-inline-player-sources.spec.ts | 1 + .../portal-inline-player-up-next.spec.ts | 1 + .../portal-inline-player.component.html | 1 + .../portal-inline-player.component.spec.ts | 1 + .../portal-inline-player.component.ts | 6 ++ 10 files changed, 102 insertions(+), 19 deletions(-) diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 8d5139e14..afbb1be49 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1097,9 +1097,17 @@ Known accepted cost: "Star Wars: Episode 1" is skipped. hero, Play re-enters watch. The hero back arrow is hidden (the channel sidebar next to the detail IS the navigation). - The parent passes its already-built `embeddedPlayback()` - (`ResolvedPortalPlayback` with headers/EXTVLCOPT resolved); the host - overrides `isLive: false` (seek bar/VOD semantics) and stamps the resolved - title/poster into the player chrome. + (`ResolvedPortalPlayback` with headers/EXTVLCOPT resolved) plus its shared + persisted `volume()`; the host only overrides `isLive: false` (seek + bar/VOD semantics). The payload's OBJECT IDENTITY is the player's + source-application key (`createWebPlayerApplicationState` mints a new + source revision for any new payload), so it must never depend on TMDB + signals — folding the resolved title/poster in there restarted the movie + the moment enrichment landed. Metadata lives in the hero/About + presentation only. +- `PortalInlinePlayerComponent` gained an optional `volume` input + (default `1`) for this host: the M3U player owns a persisted volume shared + across its channels, while the portals keep the engines' own default. - `M3uVodMetadataService` (component-provided) calls `TmdbEnrichmentService.enrichMovie({ title: channel.name, year: releaseTagYear(channel.name) })` — the resolver already normalizes titles diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html index 6aaa151a5..0a29562ff 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.html @@ -90,6 +90,7 @@ detail-player [playbackSessionKey]="playbackSessionKey()" [playback]="playback" + [volume]="volume()" (closed)="closeInlinePlayback()" (externalFallbackRequested)="externalFallbackRequested.emit($event)" /> diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts index 6c2c4f5ff..0d769294d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts @@ -1,5 +1,6 @@ import { Component, input, output } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { TmdbEnrichmentService } from '@iptvnator/services'; import { Channel, ResolvedPortalPlayback } from '@iptvnator/shared/interfaces'; import { TranslatePipe } from '@ngx-translate/core'; @@ -43,6 +44,7 @@ class StubPortalDetailShellComponent { class StubPortalInlinePlayerComponent { readonly playbackSessionKey = input.required(); readonly playback = input(null); + readonly volume = input(1); readonly closed = output(); readonly externalFallbackRequested = output(); } @@ -95,6 +97,7 @@ describe('M3uVodDetailComponent', () => { channel: Channel; playback?: ResolvedPortalPlayback | null; inlinePlayerAvailable?: boolean; + volume?: number; }) => { fixture = TestBed.createComponent(M3uVodDetailComponent); fixture.componentRef.setInput('channel', inputs.channel); @@ -106,10 +109,18 @@ describe('M3uVodDetailComponent', () => { inputs.inlinePlayerAvailable ); } + if (inputs.volume !== undefined) { + fixture.componentRef.setInput('volume', inputs.volume); + } fixture.detectChanges(); await flush(); }; + const inlinePlayerStub = () => + fixture.debugElement.query( + By.directive(StubPortalInlinePlayerComponent) + )?.componentInstance as StubPortalInlinePlayerComponent | undefined; + beforeEach(async () => { enrichMovie = jest.fn().mockResolvedValue(null); @@ -157,13 +168,48 @@ describe('M3uVodDetailComponent', () => { ).not.toBeNull(); }); - it('plays with VOD semantics and provider identity before TMDB answers', async () => { + it('plays with VOD semantics and the parent payload', async () => { await create({ channel: channel(), playback: playback() }); const inline = fixture.componentInstance.inlinePlayback(); expect(inline?.isLive).toBe(false); + expect(inline?.streamUrl).toBe('http://host/movie/user/pass/1.mkv'); expect(inline?.title).toBe('Dune (2021) 1080p'); - expect(inline?.thumbnail).toBe('http://logo/dune.png'); + }); + + it("forwards the host's persisted volume to the player", async () => { + await create({ + channel: channel(), + playback: playback(), + volume: 0.35, + }); + + expect(inlinePlayerStub()?.volume()).toBe(0.35); + }); + + it('keeps the playback payload identical when TMDB metadata lands', async () => { + // Payload identity is the player's source-application key: a new + // object recreates the player and restarts the movie. Enrichment must + // never reach it — only the presentation around it. + let resolveEnrichment!: (details: unknown) => void; + enrichMovie.mockReturnValue( + new Promise((resolve) => { + resolveEnrichment = resolve; + }) + ); + await create({ channel: channel(), playback: playback() }); + + const before = fixture.componentInstance.inlinePlayback(); + resolveEnrichment({ + id: 42, + title: 'Dune', + poster_path: '/p.jpg', + backdrop_path: '/b.jpg', + }); + await flush(); + + expect(fixture.componentInstance.title()).toBe('Dune'); + expect(fixture.componentInstance.inlinePlayback()).toBe(before); }); it('patches the view when the TMDB match lands', async () => { @@ -198,8 +244,6 @@ describe('M3uVodDetailComponent', () => { 'Timothée Chalamet', ]); expect(component.directors()).toBe('Denis Villeneuve'); - // The player chrome follows the resolved identity - expect(component.inlinePlayback()?.title).toBe('Dune'); }); it('keeps the provider presentation when TMDB has no match', async () => { @@ -213,6 +257,22 @@ describe('M3uVodDetailComponent', () => { expect(component.playbackActive()).toBe(true); }); + it('restarts the payload identity when zapping to another movie', async () => { + await create({ channel: channel(), playback: playback() }); + const first = fixture.componentInstance.inlinePlayback(); + + fixture.componentRef.setInput( + 'playback', + playback({ streamUrl: 'http://host/movie/user/pass/2.mkv' }) + ); + fixture.detectChanges(); + + expect(fixture.componentInstance.inlinePlayback()).not.toBe(first); + expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe( + 'http://host/movie/user/pass/2.mkv' + ); + }); + it('close reveals browse with a Play action; Play re-enters watch', async () => { await create({ channel: channel(), playback: playback() }); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts index 01e1c9ef1..e53d03a86 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.ts @@ -66,6 +66,8 @@ export class M3uVodDetailComponent { readonly playbackSessionKey = input.required(); /** Parent's `shouldShowInlinePlayer` verdict — false for MPV/VLC users. */ readonly inlinePlayerAvailable = input(true); + /** M3U's shared persisted volume (localStorage `volume`). */ + readonly volume = input(1); readonly externalFallbackRequested = output(); @@ -135,23 +137,24 @@ export class M3uVodDetailComponent { .join(', ') ); + /** + * The playback payload handed to the player. Its OBJECT IDENTITY is the + * source-application key downstream (`createWebPlayerApplicationState` + * mints a new source revision for any new payload, which recreates the + * player and re-applies the source), so it must depend on the parent's + * payload alone. Reading TMDB signals here would restart the movie a + * couple of seconds in, the moment enrichment resolves — metadata belongs + * in the About/hero presentation, never in the source identity. + * + * The parent built the payload for a LIVE channel; the recognized movie + * only flips to VOD semantics (seek bar, duration). + */ readonly inlinePlayback = computed(() => { if (!this.inlinePlayerAvailable() || this.playerDismissed()) { return null; } const playback = this.playback(); - if (!playback) { - return null; - } - // The parent built this payload for a LIVE channel; the recognized - // movie plays with VOD semantics (seek bar, duration) and wears the - // resolved title/poster in the player chrome. - return { - ...playback, - isLive: false, - title: this.title(), - thumbnail: this.posterUrl() ?? playback.thumbnail, - }; + return playback ? { ...playback, isLive: false } : null; }); readonly playbackActive = computed(() => this.inlinePlayback() !== null); readonly canPlayInline = computed( diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index 7b670425c..45c37ee94 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -70,6 +70,7 @@ [playback]="embeddedPlayback()" [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" + [volume]="volume()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 4db6ae12e..6b4f7f3a6 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -26,6 +26,7 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 7b09f18de..e8ecacb6d 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -23,6 +23,7 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 50f3235bf..00d15040c 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -89,6 +89,7 @@ [title]="title()" [mediaTitle]="playerMediaTitle()" [playback]="playback()" + [volume]="volume()" [startTime]="startTime()" [seriesNavigation]="seriesNavigation()" [alternativeSources]="alternativeSources()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index b547f6392..30e40ef4e 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -26,6 +26,7 @@ class StubWebPlayerViewComponent { readonly title = input(''); readonly mediaTitle = input(null); readonly playback = input(null); + readonly volume = input(1); readonly startTime = input(0); readonly seriesNavigation = input(null); readonly alternativeSources = input([]); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 25437a2bc..bce038d85 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -72,6 +72,12 @@ export class PortalInlinePlayerComponent { readonly seriesTitle = input(null); /** "Up Next" entries built by the series host; null for movies/live. */ readonly upNextEpisodes = input(null); + /** + * Initial player volume. Only hosts that own a persisted volume pass it + * (the M3U player shares one across its channels); the portals keep the + * engines' own default, which is what this default preserves. + */ + readonly volume = input(1); private readonly settingsStore = inject(SettingsStore); // Strip only live-channel titles — VOD/series titles ("Mission: // Impossible - Fallout") must never lose their leading segment.