mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(playlist): re-read the volume bus when the movie player remounts
Browse → Play remounts the engine without a channel change, so the volume signal — which only recomputed per channel — handed the remounted player the value from before the user adjusted it. The detail host now announces playbackStarted and the parent re-reads the shared bus, keeping one owner. Adds the missing E2E for the workflow (web-e2e/m3u-movie-details.e2e.ts): recognition into the detail view with a routed TMDB mock, async metadata, the live-channel fallback, and the volume surviving Browse → Play. Verified to fail without the fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3170b21e74
commit
d75e656de5
7 files changed
+275
-1
No files matched your search
@@ -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<void> {
|
||||
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<void> {
|
||||
await page.route(`${FIXTURE_HOST}/**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'video/mp4',
|
||||
body: Buffer.alloc(0),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function saveSettings(page: Page): Promise<void> {
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
await saveButton.click();
|
||||
await expect(saveButton).toBeHidden();
|
||||
}
|
||||
|
||||
async function selectHtml5Player(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+15
@@ -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() });
|
||||
|
||||
|
||||
@@ -70,6 +70,14 @@ export class M3uVodDetailComponent {
|
||||
readonly volume = input(1);
|
||||
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
/**
|
||||
* 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<void>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
|
||||
[volume]="volume()"
|
||||
(playbackStarted)="refreshVolumeFromBus()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user