diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts new file mode 100644 index 000000000..7f21e369d --- /dev/null +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -0,0 +1,217 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from './fixtures'; + +/** + * The M3U movie-recognition workflow end to end: a playlist entry whose URL + * looks like a movie file opens the VOD detail experience instead of the + * player + EPG zone, TMDB metadata patches it asynchronously, and the + * watch ↔ browse transitions keep the persisted volume. + * + * Playback is asserted through the real component composition (detail shell → + * inline player → web player view → engine) but never decodes: the workflow + * claims are about layout, metadata and volume, so a decodable fixture would + * only add flakiness. + */ + +const FIXTURE_HOST = 'https://m3u-movie-fixture.local'; +const MOVIE_PLAYLIST = [ + '#EXTM3U', + '#EXTINF:-1 group-title="Movies",Dune (2021) 1080p', + `${FIXTURE_HOST}/dune.mp4`, + '#EXTINF:-1 tvg-id="live-one" group-title="News",Live One', + `${FIXTURE_HOST}/live.m3u8`, +].join('\n'); + +const TMDB_MOVIE_ID = 438631; + +test.use({ serviceWorkers: 'block' }); + +async function serveTmdb(page: Page): Promise { + await page.route('https://api.themoviedb.org/**', async (route) => { + const url = new URL(route.request().url()); + const json = (body: unknown) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(body), + }); + + if (url.pathname.endsWith('/search/movie')) { + return json({ + results: [ + { + id: TMDB_MOVIE_ID, + title: 'Dune', + original_title: 'Dune', + release_date: '2021-09-15', + vote_count: 9000, + vote_average: 7.8, + }, + ], + }); + } + + if (url.pathname.endsWith(`/movie/${TMDB_MOVIE_ID}`)) { + return json({ + id: TMDB_MOVIE_ID, + title: 'Dune', + overview: 'Paul Atreides arrives on Arrakis.', + release_date: '2021-09-15', + runtime: 155, + vote_average: 7.8, + vote_count: 9000, + genres: [{ id: 878, name: 'Science Fiction' }], + credits: { + cast: [{ id: 1, name: 'Timothee Chalamet', order: 0 }], + crew: [ + { id: 2, name: 'Denis Villeneuve', job: 'Director' }, + ], + }, + }); + } + + // /configuration (the settings "check key" button) and anything else + return json({}); + }); +} + +async function serveStreams(page: Page): Promise { + await page.route(`${FIXTURE_HOST}/**`, (route) => + route.fulfill({ + status: 200, + contentType: 'video/mp4', + body: Buffer.alloc(0), + }) + ); +} + +async function saveSettings(page: Page): Promise { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); +} + +async function selectHtml5Player(page: Page): Promise { + await page.goto('/workspace/settings/playback'); + await page.locator('[data-test-id="select-video-player"]').click(); + await page + .getByRole('option', { name: 'HTML5 video player', exact: true }) + .click(); + await saveSettings(page); +} + +async function enableTmdb(page: Page): Promise { + await page.goto('/workspace/settings/tmdb'); + await page.locator('[data-test-id="tmdb-enabled"] input').check(); + await page.locator('[data-test-id="tmdb-api-key"]').fill('e2e-key'); + // The M3U recognition toggle only appears once TMDB itself is on, and it + // ships enabled — assert rather than click, so a changed default fails. + await expect( + page.locator('[data-test-id="tmdb-m3u-vod-details"] input') + ).toBeChecked(); + await saveSettings(page); +} + +async function importPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog + .getByLabel('Insert m3u(8) playlist as text') + .fill(MOVIE_PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('2 channels')).toBeVisible(); +} + +const detail = (page: Page) => page.locator('app-m3u-vod-detail'); +const inlineVideo = (page: Page) => + page.locator('app-m3u-vod-detail app-portal-inline-player video'); +// The sidebar numbers every entry ("1. Dune (2021) 1080p"), so match the +// row by its own test id and filter on the name rather than on exact text. +const sidebarEntry = (page: Page, name: string) => + page.locator('[data-test-id="channel-item"]').filter({ hasText: name }); + +test('@web @m3u @tmdb recognized movies open the VOD detail view', async ({ + page, +}) => { + await serveTmdb(page); + await serveStreams(page); + await selectHtml5Player(page); + await enableTmdb(page); + await importPlaylist(page); + + // Watch-first: activating the entry plays immediately inside the detail + // shell, with no EPG zone in sight. + await sidebarEntry(page, 'Dune (2021) 1080p').click(); + await expect(detail(page)).toBeVisible(); + await expect(inlineVideo(page)).toBeVisible(); + await expect(page.locator('app-epg-timeline')).toHaveCount(0); + + // Metadata patches the mounted view asynchronously. The shell stamps the + // host templates into BOTH the hero and the watch-state About block, so + // every metadata string legitimately resolves twice. + await expect( + detail(page).getByText('Paul Atreides arrives on Arrakis.').first() + ).toBeVisible(); + await expect( + detail(page).getByText('Denis Villeneuve').first() + ).toBeVisible(); + await expect( + detail(page).getByText('Science Fiction').first() + ).toBeVisible(); + + // A live channel keeps the classic layout. + await sidebarEntry(page, 'Live One').click(); + await expect(detail(page)).toHaveCount(0); +}); + +test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ + page, +}) => { + await serveTmdb(page); + await serveStreams(page); + await selectHtml5Player(page); + await enableTmdb(page); + await importPlaylist(page); + + // A volume persisted before the movie was ever opened must reach the + // player — the regression that shipped the detail view without it. + await page.evaluate(() => localStorage.setItem('volume', '0.5')); + + await sidebarEntry(page, 'Dune (2021) 1080p').click(); + await expect(inlineVideo(page)).toBeVisible(); + await expect + .poll(() => + inlineVideo(page).evaluate( + (video: HTMLVideoElement) => video.volume + ) + ) + .toBe(0.5); + + // Stand in for the user moving the engine's own volume slider: the + // engines persist straight to this bus and never call back, which is + // exactly why the remount below has to re-read it. (That the engines + // write on `volumechange` is covered by their own unit tests.) + await page.evaluate(() => localStorage.setItem('volume', '0.25')); + + await page.keyboard.press('Escape'); + await expect(inlineVideo(page)).toHaveCount(0); + const playButton = page.locator('[data-test-id="m3u-vod-play"]'); + await expect(playButton).toBeVisible(); + + // Browse → Play remounts the engine without a channel change. + await playButton.click(); + await expect(inlineVideo(page)).toBeVisible(); + await expect + .poll(() => + inlineVideo(page).evaluate( + (video: HTMLVideoElement) => video.volume + ) + ) + .toBe(0.25); +}); diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index afbb1be49..e2061bcfe 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1107,7 +1107,15 @@ Known accepted cost: "Star Wars: Episode 1" is skipped. 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. + across its channels, while the portals keep the engines' own default. The + parent re-reads that bus per channel AND on the host's `playbackStarted` + output, because Browse → Play remounts the engine without a channel change + and the engines only ever write to the bus. + +**Coverage:** `apps/web-e2e/src/m3u-movie-details.e2e.ts` drives the workflow +against the real composition with a routed TMDB mock — recognition, async +metadata, the live-channel fallback, and the volume the remount must keep. + - `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.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-vod-detail/m3u-vod-detail.component.spec.ts index 0d769294d..8d3a52086 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 @@ -273,6 +273,21 @@ describe('M3uVodDetailComponent', () => { ); }); + it('asks the host to refresh volume before remounting the player', async () => { + await create({ channel: channel(), playback: playback() }); + const started = jest.fn(); + fixture.componentInstance.playbackStarted.subscribe(started); + + fixture.componentInstance.closeInlinePlayback(); + fixture.detectChanges(); + fixture.componentInstance.startPlayback(); + fixture.detectChanges(); + + // Browse → Play remounts the engine without a channel change, and the + // engines only write their own volume to the shared bus. + expect(started).toHaveBeenCalledTimes(1); + }); + 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 e53d03a86..fe3ae5658 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 @@ -70,6 +70,14 @@ export class M3uVodDetailComponent { readonly volume = input(1); readonly externalFallbackRequested = output(); + /** + * A player is about to be mounted for the current channel. The host owns + * the persisted volume and re-reads it per channel, but Browse → Play + * remounts the engine without a channel change, and the engines only + * WRITE their own adjustments to the shared bus — so without this the + * remount would restore the volume from before the user touched it. + */ + readonly playbackStarted = output(); /** Player closed by the user — Browse until Play or the next channel. */ private readonly playerDismissed = signal(false); @@ -166,6 +174,7 @@ export class M3uVodDetailComponent { } startPlayback(): void { + this.playbackStarted.emit(); this.playerDismissed.set(false); } } 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 ec2092a1c..4633093cc 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 @@ -358,6 +358,20 @@ describe('VideoPlayerComponent — M3U movie recognition gate', () => { expect(component.volume()).toBe(0); }); + it('refreshes from the bus when the detail host remounts the player', () => { + localStorage.setItem('volume', '0.9'); + syncStoreState(movieChannel); + fixture.detectChanges(); + expect(component.volume()).toBe(0.9); + + // Engine-side change during playback, then Browse → Play on the + // SAME channel: no channel change, so only this call refreshes. + localStorage.setItem('volume', '0.25'); + component.refreshVolumeFromBus(); + + expect(component.volume()).toBe(0.25); + }); + it('re-reads even when the next entry shares the previous id', () => { // `createChannel` falls back to the URL for the id, so one stream // listed under two groups yields two entries with the same id. 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 45c37ee94..e5dee4647 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 @@ -71,6 +71,7 @@ [playbackSessionKey]="playbackSessionKey()" [inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)" [volume]="volume()" + (playbackStarted)="refreshVolumeFromBus()" (externalFallbackRequested)=" handleExternalFallbackRequest($event) " 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 96b15ba09..04ccb2aa3 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 @@ -1153,6 +1153,16 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } } + /** + * Pull the latest volume off the shared bus before a player is mounted + * without a channel change (the movie detail's Browse → Play). The + * engines persist their adjustments there and never call back, so the + * remounted player would otherwise start at the pre-adjustment value. + */ + refreshVolumeFromBus(): void { + this.volume.set(readStoredVolume()); + } + private setVolume(next: number): void { const clamped = Math.max(0, Math.min(1, Number(next.toFixed(2)))); this.volume.set(clamped);