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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 17:59:59 +02:00
1 parent 6ec681618d
commit e8f9592eca
10 files changed
+102 -19

No files matched your search

+11 -3
View File
@@ -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
@@ -90,6 +90,7 @@
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[volume]="volume()"
(closed)="closeInlinePlayback()"
(externalFallbackRequested)="externalFallbackRequested.emit($event)"
/>
@@ -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<string>();
readonly playback = input<ResolvedPortalPlayback | null>(null);
readonly volume = input(1);
readonly closed = output<void>();
readonly externalFallbackRequested = output<unknown>();
}
@@ -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() });
@@ -66,6 +66,8 @@ export class M3uVodDetailComponent {
readonly playbackSessionKey = input.required<string>();
/** 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<PlaybackFallbackRequest>();
@@ -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<ResolvedPortalPlayback | null>(() => {
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(
@@ -70,6 +70,7 @@
[playback]="embeddedPlayback()"
[playbackSessionKey]="playbackSessionKey()"
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
[volume]="volume()"
(externalFallbackRequested)="
handleExternalFallbackRequest($event)
"
@@ -26,6 +26,7 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -23,6 +23,7 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -89,6 +89,7 @@
[title]="title()"
[mediaTitle]="playerMediaTitle()"
[playback]="playback()"
[volume]="volume()"
[startTime]="startTime()"
[seriesNavigation]="seriesNavigation()"
[alternativeSources]="alternativeSources()"
@@ -26,6 +26,7 @@ class StubWebPlayerViewComponent {
readonly title = input('');
readonly mediaTitle = input<unknown>(null);
readonly playback = input<unknown>(null);
readonly volume = input(1);
readonly startTime = input(0);
readonly seriesNavigation = input<unknown>(null);
readonly alternativeSources = input<unknown[]>([]);
@@ -72,6 +72,12 @@ export class PortalInlinePlayerComponent {
readonly seriesTitle = input<string | null>(null);
/** "Up Next" entries built by the series host; null for movies/live. */
readonly upNextEpisodes = input<UpNextRailItem[] | null>(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.