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.