mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playlist): open recognized M3U movies in the VOD detail view (#1420)
M3U entries recognized as movie files now open in the portals' two-state VOD detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first: activation still plays immediately, with plot, cast, rating and artwork below the player; Escape reveals the Browse hero. Recognition is a synchronous URL-shape heuristic (movie container extension or an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker names keep today's live layout), gated on TMDB enrichment plus the new default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA. Review follow-ups included: the playback payload no longer carries TMDB fields (its identity is the player's source-application key), the persisted volume reaches the player and survives Browse → Play, the enrichment guard keys on the full lookup identity, and the saved engine mounts first time instead of briefly falling back to Video.js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
82a2948ffd
commit
f7bb3a13db
49 files changed
+2344
-146
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: feature
|
||||
area: playlist
|
||||
---
|
||||
|
||||
M3U entries that are movie files now open in the same movie detail view the
|
||||
portals use: playback starts immediately with plot, cast, rating and artwork
|
||||
from TMDB below the player instead of the empty program guide, and closing the
|
||||
player reveals the full poster page. Requires TMDB metadata to be enabled; a
|
||||
toggle in Settings → Metadata (TMDB) turns recognition off.
|
||||
@@ -485,6 +485,21 @@ Key radio behavior:
|
||||
- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute
|
||||
- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
|
||||
|
||||
**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry
|
||||
recognized as a movie FILE swaps the player + EPG area for the portals'
|
||||
two-state VOD detail shell fed by TMDB, watch-first (activation still plays
|
||||
immediately; Esc reveals the Browse hero). Detection is synchronous URL-shape
|
||||
heuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`)
|
||||
or an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/`
|
||||
paths and episode-marker names (`S01E02`, "2 серия") fail toward the live
|
||||
layout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB
|
||||
enrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in
|
||||
Settings → Metadata (TMDB)). Host: `m3u-vod-detail/` in
|
||||
`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`,
|
||||
parent's `embeddedPlayback()` with `isLive: false`); external MPV/VLC users
|
||||
keep Browse. See "Movie Recognition (VOD Detail View)" in
|
||||
`docs/architecture/m3u-playlist-module.md`.
|
||||
|
||||
Channel List Component Structure (parent coordinator pattern):
|
||||
|
||||
```
|
||||
@@ -1294,6 +1309,7 @@ engine` (restart required) or
|
||||
**TMDB Metadata Enrichment** (opt-in):
|
||||
|
||||
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
|
||||
- The M3U player consumes it too: entries recognized as movie files open in the VOD detail shell fed purely by `enrichMovie` (no provider payload to merge); the extra `Settings.m3uVodDetails` toggle (default on) sits in the TMDB settings section — see "M3U Movie Recognition" above
|
||||
- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
|
||||
- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
|
||||
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
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);
|
||||
|
||||
// Record every engine that is ever attached. A polling assertion cannot
|
||||
// see the defect this guards: mounting Video.js first and correcting to
|
||||
// the saved engine a tick later leaves the same final DOM.
|
||||
await page.evaluate(() => {
|
||||
const seen = new Set<string>();
|
||||
(window as unknown as { __enginesSeen: Set<string> }).__enginesSeen =
|
||||
seen;
|
||||
const record = () => {
|
||||
for (const selector of [
|
||||
'app-vjs-player',
|
||||
'app-html-video-player',
|
||||
]) {
|
||||
if (document.querySelector(selector)) {
|
||||
seen.add(selector);
|
||||
}
|
||||
}
|
||||
};
|
||||
record();
|
||||
new MutationObserver(record).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
// The engine is part of the application token, so the saved player must
|
||||
// mount FIRST TIME — a late correction swaps the player mid-playback.
|
||||
await expect(detail(page).locator('app-html-video-player')).toHaveCount(1);
|
||||
expect(
|
||||
await page.evaluate(() => [
|
||||
...(window as unknown as { __enginesSeen: Set<string> })
|
||||
.__enginesSeen,
|
||||
])
|
||||
).toEqual(['app-html-video-player']);
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -35,6 +35,7 @@ export function createSettingsForm(
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
...(supportsEpg
|
||||
? { epgUrl: new FormArray<FormControl<string | null>>([]) }
|
||||
: {}),
|
||||
@@ -125,6 +126,7 @@ export function createSettingsFromFormValue(
|
||||
playerAmbientMode: value.playerAmbientMode ?? false,
|
||||
playerUpNextRail: value.playerUpNextRail ?? true,
|
||||
vodAutoFailover: value.vodAutoFailover ?? false,
|
||||
m3uVodDetails: value.m3uVodDetails ?? true,
|
||||
streamFormat: value.streamFormat ?? StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: value.openStreamOnDoubleClick ?? false,
|
||||
language: value.language ?? Language.ENGLISH,
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<section
|
||||
class="settings-group"
|
||||
id="tmdb"
|
||||
[formGroup]="form()"
|
||||
>
|
||||
<section class="settings-group" id="tmdb" [formGroup]="form()">
|
||||
<header class="settings-group__header">
|
||||
<div class="settings-group__header-icon">
|
||||
<mat-icon>movie</mat-icon>
|
||||
@@ -98,6 +94,30 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (form().value.tmdb?.enabled === true) {
|
||||
@if (m3uVodDetailsControl; as m3uVodDetails) {
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>
|
||||
{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }}
|
||||
</h4>
|
||||
<p>
|
||||
{{
|
||||
'SETTINGS.TMDB_M3U_VOD_DETAILS_DESCRIPTION'
|
||||
| translate
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="setting-item__control setting-item__toggle">
|
||||
<mat-checkbox
|
||||
[formControl]="m3uVodDetails"
|
||||
data-test-id="tmdb-m3u-vod-details"
|
||||
></mat-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('SettingsTmdbSectionComponent', () => {
|
||||
|
||||
const createForm = () =>
|
||||
new FormGroup({
|
||||
m3uVodDetails: new FormControl(true),
|
||||
tmdb: new FormGroup({
|
||||
enabled: new FormControl(true),
|
||||
apiKey: new FormControl(''),
|
||||
@@ -86,6 +87,47 @@ describe('SettingsTmdbSectionComponent', () => {
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('offers the M3U movie-recognition toggle while TMDB is enabled', async () => {
|
||||
await settle();
|
||||
|
||||
const checkbox = queryByTestId('tmdb-m3u-vod-details');
|
||||
expect(checkbox).not.toBeNull();
|
||||
|
||||
(checkbox?.querySelector('input') as HTMLInputElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.m3uVodDetailsControl?.value).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('hides the M3U toggle when TMDB is disabled', async () => {
|
||||
await settle();
|
||||
|
||||
fixture.componentInstance.form().get('tmdb.enabled')?.setValue(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(queryByTestId('tmdb-m3u-vod-details')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the M3U toggle row when the host form lacks the control', async () => {
|
||||
fixture.destroy();
|
||||
fixture = TestBed.createComponent(SettingsTmdbSectionComponent);
|
||||
fixture.componentRef.setInput(
|
||||
'form',
|
||||
new FormGroup({
|
||||
tmdb: new FormGroup({
|
||||
enabled: new FormControl(true),
|
||||
apiKey: new FormControl(''),
|
||||
}),
|
||||
})
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await settle();
|
||||
|
||||
expect(queryByTestId('tmdb-m3u-vod-details')).toBeNull();
|
||||
});
|
||||
|
||||
it('sizes the cache as soon as the section page opens', async () => {
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core';
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
@@ -102,6 +102,18 @@ export class SettingsTmdbSectionComponent {
|
||||
return (this.form().value.tmdb?.apiKey ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3U movie-recognition toggle lives at the settings ROOT (it is a
|
||||
* player-layout preference, not part of the persisted `tmdb` group this
|
||||
* section's `formGroupName` wraps), so the template binds it via
|
||||
* `[formControl]` instead of a group-relative `formControlName`.
|
||||
* Nullable: the template hides the row for a form without the control.
|
||||
*/
|
||||
get m3uVodDetailsControl(): FormControl | null {
|
||||
const control = this.form().get('m3uVodDetails');
|
||||
return control instanceof FormControl ? control : null;
|
||||
}
|
||||
|
||||
async testApiKey(): Promise<void> {
|
||||
const apiKey = this.enteredApiKey;
|
||||
if (!apiKey || this.keyTestState() === 'testing') {
|
||||
@@ -152,5 +164,4 @@ export class SettingsTmdbSectionComponent {
|
||||
this.cacheError.set(stats === null);
|
||||
this.cacheStats.set(stats);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export const DEFAULT_SETTINGS = {
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "مفتاح TMDB API (اختياري)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "استخدم مفتاح TMDB API الخاص بك بدلاً من المفتاح المدمج",
|
||||
"TMDB_GET_KEY_HINT": "احصل على مفتاح API مجاني على themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "التعرّف على الأفلام في قوائم تشغيل M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "عناصر قائمة التشغيل التي تبدو كملفات أفلام تُفتح في صفحة تفاصيل الفيلم مع بيانات TMDB الوصفية بدلاً من لوحة دليل البرامج.",
|
||||
"TMDB_TEST_BUTTON": "التحقق من المفتاح",
|
||||
"TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح",
|
||||
"TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "مفتاح TMDB API (اختياري)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "استعمل مفتاح TMDB API ديالك بلاصة المفتاح المدمج",
|
||||
"TMDB_GET_KEY_HINT": "جيب مفتاح API مجاني من themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "التعرف على الأفلام فقوائم M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "العناصر ديال القائمة اللي كيبانو بحال ملفات أفلام كيتفتحو فصفحة تفاصيل الفيلم مع ميتاداتا TMDB بلاصة لوحة دليل البرامج.",
|
||||
"TMDB_TEST_BUTTON": "تأكد من المفتاح",
|
||||
"TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان",
|
||||
"TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Ключ TMDB API (неабавязкова)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Выкарыстоўвайце ўласны ключ TMDB API замест убудаванага",
|
||||
"TMDB_GET_KEY_HINT": "Атрымайце бясплатны API-ключ на themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Распазнаваць фільмы ў плэйлістах M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Элементы плэйліста, падобныя на файлы фільмаў, адкрываюцца на старонцы дэталяў фільма з метаданымі TMDB замест панэлі тэлепраграмы.",
|
||||
"TMDB_TEST_BUTTON": "Праверыць ключ",
|
||||
"TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў",
|
||||
"TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB-API-Schlüssel (optional)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Eigenen TMDB-API-Schlüssel anstelle des integrierten verwenden",
|
||||
"TMDB_GET_KEY_HINT": "Kostenlosen API-Schlüssel auf themoviedb.org erhalten",
|
||||
"TMDB_M3U_VOD_DETAILS": "Filme in M3U-Playlists erkennen",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Playlist-Einträge, die wie Filmdateien aussehen, öffnen sich in der Film-Detailansicht mit TMDB-Metadaten anstelle des EPG-Bereichs.",
|
||||
"TMDB_TEST_BUTTON": "Schlüssel prüfen",
|
||||
"TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet",
|
||||
"TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Κλειδί API TMDB (προαιρετικό)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Χρησιμοποιήστε το δικό σας κλειδί API TMDB αντί για το ενσωματωμένο",
|
||||
"TMDB_GET_KEY_HINT": "Αποκτήστε δωρεάν κλειδί API στο themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Αναγνώριση ταινιών σε λίστες αναπαραγωγής M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Οι καταχωρήσεις λίστας που μοιάζουν με αρχεία ταινιών ανοίγουν στην προβολή λεπτομερειών ταινίας με μεταδεδομένα TMDB αντί για τον πίνακα EPG.",
|
||||
"TMDB_TEST_BUTTON": "Έλεγχος κλειδιού",
|
||||
"TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία",
|
||||
"TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API key (optional)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Use your own TMDB API key instead of the built-in one",
|
||||
"TMDB_GET_KEY_HINT": "Get a free API key on themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Recognize movies in M3U playlists",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Playlist entries that look like movie files open in the movie detail view with TMDB metadata instead of the program guide panel.",
|
||||
"TMDB_TEST_BUTTON": "Check key",
|
||||
"TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully",
|
||||
"TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Clave de API de TMDB (opcional)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Usa tu propia clave de API de TMDB en lugar de la integrada",
|
||||
"TMDB_GET_KEY_HINT": "Obtén una clave de API gratuita en themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Reconocer películas en listas M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Las entradas de la lista que parecen archivos de películas se abren en la vista de detalles de la película con metadatos de TMDB en lugar del panel de EPG.",
|
||||
"TMDB_TEST_BUTTON": "Comprobar clave",
|
||||
"TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente",
|
||||
"TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Clé API TMDB (optionnelle)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Utiliser votre propre clé API TMDB au lieu de celle intégrée",
|
||||
"TMDB_GET_KEY_HINT": "Obtenez une clé API gratuite sur themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Reconnaître les films dans les listes M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Les entrées de liste qui ressemblent à des fichiers de films s'ouvrent dans la vue détaillée du film avec les métadonnées TMDB au lieu du panneau EPG.",
|
||||
"TMDB_TEST_BUTTON": "Vérifier la clé",
|
||||
"TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès",
|
||||
"TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API-kulcs (nem kötelező)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "A beépített helyett saját TMDB API-kulcs használata",
|
||||
"TMDB_GET_KEY_HINT": "Ingyenes API-kulcs igénylése a themoviedb.org webhelyen",
|
||||
"TMDB_M3U_VOD_DETAILS": "Filmek felismerése az M3U lejátszási listákban",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "A filmfájlnak tűnő listaelemek a film adatlapján nyílnak meg TMDB-metaadatokkal az EPG panel helyett.",
|
||||
"TMDB_TEST_BUTTON": "Kulcs ellenőrzése",
|
||||
"TMDB_TEST_SUCCESS": "A kulcs működik – a TMDB sikeresen válaszolt",
|
||||
"TMDB_TEST_FAILED": "Az ellenőrzés sikertelen – a TMDB elutasította a kulcsot, vagy nem érhető el",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Chiave API TMDB (opzionale)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Usa la tua chiave API TMDB al posto di quella integrata",
|
||||
"TMDB_GET_KEY_HINT": "Ottieni una chiave API gratuita su themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Riconosci i film nelle playlist M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Le voci della playlist che sembrano file di film si aprono nella vista di dettaglio del film con i metadati TMDB al posto del pannello EPG.",
|
||||
"TMDB_TEST_BUTTON": "Verifica chiave",
|
||||
"TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente",
|
||||
"TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API キー(任意)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "内蔵のキーの代わりに独自の TMDB API キーを使用します",
|
||||
"TMDB_GET_KEY_HINT": "themoviedb.org で無料の API キーを取得",
|
||||
"TMDB_M3U_VOD_DETAILS": "M3U プレイリスト内の映画を認識",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "映画ファイルと思われるプレイリスト項目は、EPG パネルの代わりに TMDB メタデータ付きの映画詳細画面で開きます。",
|
||||
"TMDB_TEST_BUTTON": "キーを確認",
|
||||
"TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました",
|
||||
"TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API 키 (선택 사항)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "기본 제공 키 대신 자신의 TMDB API 키를 사용합니다",
|
||||
"TMDB_GET_KEY_HINT": "themoviedb.org에서 무료 API 키 받기",
|
||||
"TMDB_M3U_VOD_DETAILS": "M3U 재생목록에서 영화 인식",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "영화 파일로 보이는 재생목록 항목은 EPG 패널 대신 TMDB 메타데이터가 포함된 영화 상세 보기로 열립니다.",
|
||||
"TMDB_TEST_BUTTON": "키 확인",
|
||||
"TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다",
|
||||
"TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API-sleutel (optioneel)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Gebruik je eigen TMDB API-sleutel in plaats van de ingebouwde",
|
||||
"TMDB_GET_KEY_HINT": "Vraag een gratis API-sleutel aan op themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Films in M3U-afspeellijsten herkennen",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Afspeellijstitems die op filmbestanden lijken, openen in de filmdetailweergave met TMDB-metadata in plaats van het EPG-paneel.",
|
||||
"TMDB_TEST_BUTTON": "Sleutel controleren",
|
||||
"TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd",
|
||||
"TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Klucz API TMDB (opcjonalnie)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Użyj własnego klucza API TMDB zamiast wbudowanego",
|
||||
"TMDB_GET_KEY_HINT": "Uzyskaj bezpłatny klucz API na themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Rozpoznawaj filmy na playlistach M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Pozycje playlisty wyglądające jak pliki filmowe otwierają się w widoku szczegółów filmu z metadanymi TMDB zamiast panelu EPG.",
|
||||
"TMDB_TEST_BUTTON": "Sprawdź klucz",
|
||||
"TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie",
|
||||
"TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "Chave de API do TMDB (opcional)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Use sua própria chave de API do TMDB em vez da chave integrada",
|
||||
"TMDB_GET_KEY_HINT": "Obtenha uma chave de API gratuita em themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Reconhecer filmes em playlists M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "As entradas da playlist que parecem ficheiros de filmes abrem na vista de detalhes do filme com metadados do TMDB em vez do painel de EPG.",
|
||||
"TMDB_TEST_BUTTON": "Verificar chave",
|
||||
"TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso",
|
||||
"TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "API-ключ TMDB (необязательно)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Использовать собственный API-ключ TMDB вместо встроенного",
|
||||
"TMDB_GET_KEY_HINT": "Получите бесплатный API-ключ на themoviedb.org",
|
||||
"TMDB_M3U_VOD_DETAILS": "Распознавать фильмы в плейлистах M3U",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Элементы плейлиста, похожие на файлы фильмов, открываются на странице деталей фильма с метаданными TMDB вместо панели телепрограммы.",
|
||||
"TMDB_TEST_BUTTON": "Проверить ключ",
|
||||
"TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил",
|
||||
"TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API anahtarı (isteğe bağlı)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "Yerleşik anahtar yerine kendi TMDB API anahtarınızı kullanın",
|
||||
"TMDB_GET_KEY_HINT": "themoviedb.org üzerinden ücretsiz API anahtarı alın",
|
||||
"TMDB_M3U_VOD_DETAILS": "M3U oynatma listelerindeki filmleri tanı",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "Film dosyasına benzeyen oynatma listesi öğeleri, EPG paneli yerine TMDB meta verileriyle film ayrıntı görünümünde açılır.",
|
||||
"TMDB_TEST_BUTTON": "Anahtarı kontrol et",
|
||||
"TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi",
|
||||
"TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API 密钥(可选)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 密钥替代内置密钥",
|
||||
"TMDB_GET_KEY_HINT": "在 themoviedb.org 获取免费的 API 密钥",
|
||||
"TMDB_M3U_VOD_DETAILS": "识别 M3U 播放列表中的电影",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "疑似电影文件的播放列表条目会在包含 TMDB 元数据的电影详情视图中打开,而不是 EPG 面板。",
|
||||
"TMDB_TEST_BUTTON": "检查密钥",
|
||||
"TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功",
|
||||
"TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问",
|
||||
|
||||
@@ -264,6 +264,8 @@
|
||||
"TMDB_API_KEY": "TMDB API 金鑰(選填)",
|
||||
"TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 金鑰,而非內建金鑰",
|
||||
"TMDB_GET_KEY_HINT": "在 themoviedb.org 取得免費的 API 金鑰",
|
||||
"TMDB_M3U_VOD_DETAILS": "辨識 M3U 播放清單中的電影",
|
||||
"TMDB_M3U_VOD_DETAILS_DESCRIPTION": "看似電影檔案的播放清單項目會在包含 TMDB 中繼資料的電影詳細資訊頁面中開啟,而非 EPG 面板。",
|
||||
"TMDB_TEST_BUTTON": "檢查金鑰",
|
||||
"TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功",
|
||||
"TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線",
|
||||
|
||||
@@ -1062,6 +1062,76 @@ bypass Playwright interception) and
|
||||
`electron-backend-e2e:src/dash-clearkey.e2e.ts` (real ClearKey EME in
|
||||
Electron).
|
||||
|
||||
## Movie Recognition (VOD Detail View)
|
||||
|
||||
M3U playlists routinely mix live channels with movie FILES ("Movies"/"VOD"
|
||||
groups, Xtream-derived exports). A recognized movie swaps the player + EPG
|
||||
zone for the same two-state VOD detail shell the portals use, fed by TMDB
|
||||
metadata. Works in both Electron and the PWA.
|
||||
|
||||
**Gate** (`VideoPlayerComponent.showMovieDetail`): the branch activates only
|
||||
when ALL hold — the URL-shape heuristic recognizes the channel, TMDB
|
||||
enrichment is enabled (`TmdbEnrichmentService.isEnabled()`), and
|
||||
`Settings.m3uVodDetails` is not `false` (default on; the checkbox lives in
|
||||
Settings → Metadata (TMDB) under the master toggle). The decision is
|
||||
**synchronous** — layout is chosen at activation; the async TMDB lookup only
|
||||
patches metadata into an already-mounted view, so a missed match never causes
|
||||
a layout jump.
|
||||
|
||||
**Detection** (`isLikelyM3uMovie` in
|
||||
`libs/shared/m3u-utils/src/lib/m3u-vod-detection.util.ts`): a movie-file
|
||||
container extension (`mkv`, `mp4`, `avi`, … — deliberately NOT `ts`/`m3u8`/
|
||||
`m4s` which deliver live streams, and not `mpd` which has its own DASH path)
|
||||
OR an Xtream-style `/movie/`, `/movies/`, `/vod/` path segment. Exclusions
|
||||
fail toward today's live layout: `radio="true"`, DASH URLs, `/series/` paths,
|
||||
and names carrying an episode marker (`S01E02`, `1x02`, "2 серия",
|
||||
"Season 3" — the season word list is shared with the title normalizer).
|
||||
Known accepted cost: "Star Wars: Episode 1" is skipped.
|
||||
|
||||
**Flow** (`m3u-vod-detail/` in `libs/playlist/m3u/feature-player`):
|
||||
|
||||
- `M3uVodDetailComponent` hosts `PortalDetailShellComponent` +
|
||||
`PortalInlinePlayerComponent`. **Watch-first**: clicking a channel keeps
|
||||
M3U zapping semantics — playback starts immediately, the About block
|
||||
renders below where the EPG zone would be; Escape/close reveals the Browse
|
||||
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) 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. 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
|
||||
and caches verdicts; the service only adds the release-year hint and a
|
||||
staleness guard keyed on `channel.id` so zapping mid-request drops the
|
||||
stale resolution.
|
||||
- No TMDB match keeps the thin provider presentation (entry name + logo) in
|
||||
the detail layout rather than jumping back to the EPG view.
|
||||
- External MPV/VLC users keep Browse: the m3u-state effects launch the
|
||||
external player on activation exactly as before, and
|
||||
`inlinePlayerAvailable=false` means the host never mounts an inline player.
|
||||
|
||||
**Out of scope (v1)**: series episodes (detection skips them), playback
|
||||
position persistence for M3U movies, clickable cast chips (no
|
||||
`actor/:personId` route in the M3U tree), and downloads.
|
||||
|
||||
## Interfaces
|
||||
|
||||
### Channel Interface
|
||||
|
||||
@@ -17,6 +17,12 @@ Related:
|
||||
cast, director, genres, rating, artwork) via a **field-level merge** — the
|
||||
provider stays authoritative for stream-related data and for any field
|
||||
TMDB cannot fill.
|
||||
- The M3U player is a consumer too: an entry recognized as a movie file opens
|
||||
in the VOD detail shell fed purely by an `enrichMovie` lookup (there is no
|
||||
provider payload to merge into). Gated by the additional
|
||||
`Settings.m3uVodDetails` toggle (default on) below the master switch; see
|
||||
"Movie Recognition (VOD Detail View)" in
|
||||
`docs/architecture/m3u-playlist-module.md`.
|
||||
- Enrichment is **opt-in** via `Settings > Metadata (TMDB)` because it sends
|
||||
movie/series titles to a third-party API. Default: disabled.
|
||||
- The detail view renders provider data **immediately**; enrichment runs
|
||||
@@ -109,12 +115,13 @@ Wrong metadata is worse than no metadata, so id resolution is conservative:
|
||||
"chapter two"), so a name mismatch alone says more about our inputs
|
||||
than about the id.
|
||||
|
||||
A 404 is the one hard verdict: the id is recorded as dead
|
||||
(`badProviderId:<id>` row), skipped next time, and the title search
|
||||
takes over. Transient failures (auth, rate limit, 5xx, offline) neither
|
||||
disable the id nor trigger a search — that request would hit the same
|
||||
outage, and a title match that did come back would be weaker evidence
|
||||
than the id already in hand.
|
||||
A 404 is the one hard verdict: the id is recorded as dead
|
||||
(`badProviderId:<id>` row), skipped next time, and the title search
|
||||
takes over. Transient failures (auth, rate limit, 5xx, offline) neither
|
||||
disable the id nor trigger a search — that request would hit the same
|
||||
outage, and a title match that did come back would be weaker evidence
|
||||
than the id already in hand.
|
||||
|
||||
2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized
|
||||
title. Normalization strips bracketed tags, quality markers (`4K`,
|
||||
`1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics,
|
||||
@@ -407,7 +414,10 @@ supports CORS so the PWA calls the API directly).
|
||||
`Settings.tmdb?: { enabled: boolean; apiKey?: string }`
|
||||
(`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has
|
||||
a "Metadata (TMDB)" section: enable toggle, optional API key override with a
|
||||
"check key" button (validates against `/configuration`), and a cache panel
|
||||
"check key" button (validates against `/configuration`), the M3U
|
||||
movie-recognition toggle (root-level `Settings.m3uVodDetails`, shown only
|
||||
while TMDB is enabled and bound via `[formControl]` because it is not part of
|
||||
the `tmdb` form group), and a cache panel
|
||||
showing the stored row count plus payload size with a button that drops the
|
||||
lot. Sizing is a full table scan, so it runs only once that section is the
|
||||
active one, and a failed read or clear says so instead of showing an empty
|
||||
@@ -544,27 +554,27 @@ since shipped.)
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
|
||||
The query is built to **match what the detail view searched with**, not
|
||||
just what the card displays. A title alone is weaker identity than the
|
||||
detail page had: without a year `pickConfidentMatch` requires a single
|
||||
exact title match, which common titles never satisfy, and the miss lands
|
||||
in the negative cache under a lookup key the detail view's hit can never
|
||||
be found at. Stalker rows carry those facts —
|
||||
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
|
||||
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
|
||||
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
|
||||
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
|
||||
every row falls back to when nothing says otherwise, and an embedded-VOD
|
||||
series is a `'movie'` activity row but a show on TMDB. That retry **drops
|
||||
the id**, which is only ever valid for the media type it was resolved
|
||||
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
|
||||
gets no retry — it is reached only on positive evidence (series category,
|
||||
`is_series`, or a non-empty episode array), and the gate cannot tell an
|
||||
adaptation sharing its show's name and year from the show itself.
|
||||
The query is built to **match what the detail view searched with**, not
|
||||
just what the card displays. A title alone is weaker identity than the
|
||||
detail page had: without a year `pickConfidentMatch` requires a single
|
||||
exact title match, which common titles never satisfy, and the miss lands
|
||||
in the negative cache under a lookup key the detail view's hit can never
|
||||
be found at. Stalker rows carry those facts —
|
||||
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
|
||||
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
|
||||
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
|
||||
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
|
||||
every row falls back to when nothing says otherwise, and an embedded-VOD
|
||||
series is a `'movie'` activity row but a show on TMDB. That retry **drops
|
||||
the id**, which is only ever valid for the media type it was resolved
|
||||
under: `/movie/<tv id>` resolves to an unrelated film. A `'tv'` verdict
|
||||
gets no retry — it is reached only on positive evidence (series category,
|
||||
`is_series`, or a non-empty episode array), and the gate cannot tell an
|
||||
adaptation sharing its show's name and year from the show itself.
|
||||
|
||||
Note the id in a stored Stalker entry is not a provider claim. Stalker
|
||||
portals never send one; its only source is a match this app already made
|
||||
and gated.
|
||||
Note the id in a stored Stalker entry is not a provider claim. Stalker
|
||||
portals never send one; its only source is a match this app already made
|
||||
and gated.
|
||||
|
||||
### Stalker backdrops on activity rows
|
||||
|
||||
@@ -588,7 +598,7 @@ negative-match TTL.
|
||||
|
||||
Only a **confirmed 404** is recorded. Transient failures (401, 429, 5xx,
|
||||
offline) leave no marker — they say nothing about the id. Neither does a
|
||||
title mismatch: that id exists and may be correct for a *different* item,
|
||||
title mismatch: that id exists and may be correct for a _different_ item,
|
||||
and since the row is keyed by id alone and shared across playlists,
|
||||
recording per-item mismatches here would deny the direct lookup to every
|
||||
other item that legitimately uses the same id.
|
||||
@@ -0,0 +1,99 @@
|
||||
<app-portal-detail-shell
|
||||
[title]="title()"
|
||||
[description]="overview()"
|
||||
[posterUrl]="posterUrl()"
|
||||
[backdropUrl]="backdropUrl()"
|
||||
[playbackActive]="playbackActive()"
|
||||
(closePlayerRequested)="closeInlinePlayback()"
|
||||
>
|
||||
<ng-template appDetailTags>
|
||||
@if (year()) {
|
||||
<span class="details__tag">{{ year() }}</span>
|
||||
}
|
||||
@if (genres()) {
|
||||
<span class="details__tag">{{ genres() }}</span>
|
||||
}
|
||||
@if (runtimeLabel()) {
|
||||
<span class="details__tag">{{ runtimeLabel() }}</span>
|
||||
}
|
||||
@if (rating()) {
|
||||
<span class="details__tag details__tag--rating">
|
||||
⭐ {{ rating() }}
|
||||
</span>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template appDetailMeta>
|
||||
@if (cast().length) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
|
||||
<div class="details__cast-strip">
|
||||
@for (
|
||||
member of cast();
|
||||
track member.tmdbPersonId
|
||||
? 'p' + member.tmdbPersonId
|
||||
: 'i' + $index
|
||||
) {
|
||||
<span
|
||||
class="details__cast-chip"
|
||||
[title]="
|
||||
member.character
|
||||
? member.name + ' — ' + member.character
|
||||
: member.name
|
||||
"
|
||||
>
|
||||
@if (member.profileUrl) {
|
||||
<img
|
||||
class="details__cast-avatar"
|
||||
[src]="member.profileUrl"
|
||||
[alt]="member.name"
|
||||
loading="lazy"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="details__cast-avatar details__cast-avatar--fallback"
|
||||
>
|
||||
{{ member.name.charAt(0) }}
|
||||
</span>
|
||||
}
|
||||
<span class="details__cast-name">{{
|
||||
member.name
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (directors()) {
|
||||
<div class="details__meta-item">
|
||||
<span class="label">{{ 'XTREAM.DIRECTOR' | translate }}</span>
|
||||
<span class="value">{{ directors() }}</span>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template appDetailActions>
|
||||
@if (canPlayInline() && !playbackActive()) {
|
||||
<button
|
||||
class="play-btn"
|
||||
data-test-id="m3u-vod-play"
|
||||
(click)="startPlayback()"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'XTREAM.PLAY' | translate }}
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerOverride()"
|
||||
(closed)="closeInlinePlayback()"
|
||||
(externalFallbackRequested)="externalFallbackRequested.emit($event)"
|
||||
/>
|
||||
}
|
||||
</app-portal-detail-shell>
|
||||
@@ -0,0 +1,26 @@
|
||||
@use '../../../../../../ui/styles/detail-view' as detail-view;
|
||||
|
||||
@include detail-view.base();
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
app-portal-detail-shell {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// The M3U player page has no route to go "back" to — the channel sidebar
|
||||
// next to the detail IS the navigation — so the hero's back arrow would
|
||||
// dead-end. Hidden here instead of a shell option: this host is the only
|
||||
// consumer without a parent route. `:host` scoping is load-bearing — a bare
|
||||
// `::ng-deep` would leak the rule globally and hide the portals' back arrow.
|
||||
:host ::ng-deep .hero__back-button {
|
||||
display: none;
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
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';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import type { M3uVodDetailComponent as M3uVodDetailComponentType } from './m3u-vod-detail.component';
|
||||
|
||||
// The component's import chain reaches video.js through the ui/playback
|
||||
// barrel (PortalInlinePlayerComponent → WebPlayerView → VjsPlayer); the CJS
|
||||
// bundle breaks under the ESM jest environment, so it is mocked before the
|
||||
// dynamic import below evaluates the chain.
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-detail-shell',
|
||||
standalone: true,
|
||||
template: '<ng-content />',
|
||||
})
|
||||
class StubPortalDetailShellComponent {
|
||||
readonly title = input<string>();
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
readonly backdropUrl = input<string>();
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
readonly backLabel = input<string>();
|
||||
readonly playbackActive = input(false);
|
||||
readonly backClicked = output<void>();
|
||||
readonly closePlayerRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-portal-inline-player',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubPortalInlinePlayerComponent {
|
||||
readonly playbackSessionKey = input.required<string>();
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly closed = output<void>();
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
const channel = (overrides: Partial<Channel> = {}): Channel =>
|
||||
({
|
||||
id: 'ch1',
|
||||
name: 'Dune (2021) 1080p',
|
||||
url: 'http://host/movie/user/pass/1.mkv',
|
||||
group: { title: 'Movies' },
|
||||
tvg: {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
logo: 'http://logo/dune.png',
|
||||
rec: '',
|
||||
},
|
||||
http: { referrer: '', 'user-agent': '', origin: '' },
|
||||
radio: '',
|
||||
...overrides,
|
||||
}) as Channel;
|
||||
|
||||
const playback = (
|
||||
overrides: Partial<ResolvedPortalPlayback> = {}
|
||||
): ResolvedPortalPlayback =>
|
||||
({
|
||||
streamUrl: 'http://host/movie/user/pass/1.mkv',
|
||||
title: 'Dune (2021) 1080p',
|
||||
thumbnail: 'http://logo/dune.png',
|
||||
isLive: true,
|
||||
...overrides,
|
||||
}) as ResolvedPortalPlayback;
|
||||
|
||||
describe('M3uVodDetailComponent', () => {
|
||||
let M3uVodDetailComponent: typeof M3uVodDetailComponentType;
|
||||
let fixture: ComponentFixture<M3uVodDetailComponentType>;
|
||||
let enrichMovie: jest.Mock;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ M3uVodDetailComponent } =
|
||||
await import('./m3u-vod-detail.component'));
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
await new Promise<void>((res) => setTimeout(res, 0));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
const create = async (inputs: {
|
||||
channel: Channel;
|
||||
playback?: ResolvedPortalPlayback | null;
|
||||
inlinePlayerAvailable?: boolean;
|
||||
volume?: number;
|
||||
}) => {
|
||||
fixture = TestBed.createComponent(M3uVodDetailComponent);
|
||||
fixture.componentRef.setInput('channel', inputs.channel);
|
||||
fixture.componentRef.setInput('playback', inputs.playback ?? null);
|
||||
fixture.componentRef.setInput('playbackSessionKey', 'live:p1:ch1');
|
||||
if (inputs.inlinePlayerAvailable !== undefined) {
|
||||
fixture.componentRef.setInput(
|
||||
'inlinePlayerAvailable',
|
||||
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);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [M3uVodDetailComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: {
|
||||
enrichMovie,
|
||||
isEnabled: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
// `set` replaces the whole imports list, so the real shell and
|
||||
// inline player never need importing here — a static (or even
|
||||
// dynamic) barrel import would mark those libs lazy-loaded for
|
||||
// the module-boundaries rule workspace-wide.
|
||||
.overrideComponent(M3uVodDetailComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
StubPortalDetailShellComponent,
|
||||
StubPortalInlinePlayerComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('starts in watch state — activation means play in M3U', async () => {
|
||||
await create({ channel: channel(), playback: playback() });
|
||||
|
||||
expect(fixture.componentInstance.playbackActive()).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-portal-inline-player')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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('forwards the resolved engine so the player never mounts a wrong one', async () => {
|
||||
await create({ channel: channel(), playback: playback() });
|
||||
fixture.componentRef.setInput('playerOverride', 'html5');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(inlinePlayerStub()?.playerOverride()).toBe('html5');
|
||||
});
|
||||
|
||||
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 () => {
|
||||
enrichMovie.mockResolvedValue({
|
||||
id: 42,
|
||||
title: 'Dune',
|
||||
overview: 'Spice.',
|
||||
poster_path: '/p.jpg',
|
||||
backdrop_path: '/b.jpg',
|
||||
release_date: '2021-09-15',
|
||||
runtime: 155,
|
||||
vote_average: 8.04,
|
||||
vote_count: 1000,
|
||||
genres: [{ id: 1, name: 'Sci-Fi' }],
|
||||
credits: {
|
||||
cast: [{ name: 'Timothée Chalamet', order: 0 }],
|
||||
crew: [{ name: 'Denis Villeneuve', job: 'Director' }],
|
||||
},
|
||||
});
|
||||
await create({ channel: channel(), playback: playback() });
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.title()).toBe('Dune');
|
||||
expect(component.overview()).toBe('Spice.');
|
||||
expect(component.posterUrl()).toContain('/p.jpg');
|
||||
expect(component.backdropUrl()).toContain('/b.jpg');
|
||||
expect(component.year()).toBe('2021');
|
||||
expect(component.genres()).toBe('Sci-Fi');
|
||||
expect(component.runtimeLabel()).toBe('2h 35m');
|
||||
expect(component.rating()).toBe('8.0');
|
||||
expect(component.cast().map((member) => member.name)).toEqual([
|
||||
'Timothée Chalamet',
|
||||
]);
|
||||
expect(component.directors()).toBe('Denis Villeneuve');
|
||||
});
|
||||
|
||||
it('keeps the provider presentation when TMDB has no match', async () => {
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
await create({ channel: channel(), playback: playback() });
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.title()).toBe('Dune (2021) 1080p');
|
||||
expect(component.posterUrl()).toBe('http://logo/dune.png');
|
||||
expect(component.backdropUrl()).toBeUndefined();
|
||||
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('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() });
|
||||
|
||||
fixture.componentInstance.closeInlinePlayback();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.playbackActive()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-portal-inline-player')
|
||||
).toBeNull();
|
||||
expect(fixture.componentInstance.canPlayInline()).toBe(true);
|
||||
|
||||
fixture.componentInstance.startPlayback();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.componentInstance.playbackActive()).toBe(true);
|
||||
});
|
||||
|
||||
it('re-enters watch when zapping to the next movie after a close', async () => {
|
||||
await create({ channel: channel(), playback: playback() });
|
||||
fixture.componentInstance.closeInlinePlayback();
|
||||
fixture.detectChanges();
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'channel',
|
||||
channel({ id: 'ch2', name: 'Alien' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await flush();
|
||||
|
||||
expect(fixture.componentInstance.playbackActive()).toBe(true);
|
||||
expect(enrichMovie).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never mounts an inline player for external MPV/VLC users', async () => {
|
||||
await create({
|
||||
channel: channel(),
|
||||
playback: playback(),
|
||||
inlinePlayerAvailable: false,
|
||||
});
|
||||
|
||||
expect(fixture.componentInstance.playbackActive()).toBe(false);
|
||||
expect(fixture.componentInstance.canPlayInline()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-portal-inline-player')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import type { PlaybackFallbackRequest } from '@iptvnator/playback/util';
|
||||
import {
|
||||
enrichedCast,
|
||||
tmdbBackdropUrl,
|
||||
tmdbPosterUrl,
|
||||
topCast,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Channel,
|
||||
ResolvedPortalPlayback,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
PortalDetailShellComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { M3uVodMetadataService } from './m3u-vod-metadata.service';
|
||||
|
||||
/**
|
||||
* VOD detail experience for an M3U entry recognized as a movie: the same
|
||||
* two-state shell the portals use, fed by TMDB metadata instead of a portal
|
||||
* payload. Watch-first — the parent activates a channel and playback starts
|
||||
* immediately (M3U zapping semantics), with the About block below where the
|
||||
* EPG zone would be; Escape/close reveals the Browse hero.
|
||||
*
|
||||
* The shell renders provider facts (entry name, logo) instantly; the TMDB
|
||||
* lookup patches the same view asynchronously, so a missing or failed match
|
||||
* simply leaves the thin provider presentation in place — no layout jump.
|
||||
*
|
||||
* External MPV/VLC users keep the Browse layout: the m3u-state effects
|
||||
* already launched the player on activation, so `inlinePlayerAvailable` is
|
||||
* false and this component never mounts an inline player for them.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-m3u-vod-detail',
|
||||
providers: [M3uVodMetadataService],
|
||||
imports: [
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
DetailTagsTemplateDirective,
|
||||
MatIcon,
|
||||
PortalDetailShellComponent,
|
||||
PortalInlinePlayerComponent,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './m3u-vod-detail.component.html',
|
||||
styleUrls: ['./m3u-vod-detail.component.scss'],
|
||||
})
|
||||
export class M3uVodDetailComponent {
|
||||
private readonly metadata = inject(M3uVodMetadataService);
|
||||
|
||||
readonly channel = input.required<Channel>();
|
||||
/** Parent-owned playback payload (headers/DRM already resolved). */
|
||||
readonly playback = input<ResolvedPortalPlayback | null>(null);
|
||||
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);
|
||||
/**
|
||||
* Engine the parent already resolved from settings. Without it the shared
|
||||
* player waits for its own asynchronous settings read and mounts Video.js
|
||||
* meanwhile, then swaps — the live layout passes the same value.
|
||||
*/
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const channel = this.channel();
|
||||
untracked(() => {
|
||||
this.playerDismissed.set(false);
|
||||
this.metadata.load(channel);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readonly tmdb = computed(() => this.metadata.state().details);
|
||||
|
||||
readonly title = computed(
|
||||
() => this.tmdb()?.title?.trim() || this.channel().name
|
||||
);
|
||||
readonly overview = computed(() => this.tmdb()?.overview?.trim() ?? '');
|
||||
readonly posterUrl = computed(
|
||||
() =>
|
||||
tmdbPosterUrl(this.tmdb()?.poster_path) ??
|
||||
this.channel().tvg?.logo?.trim() ??
|
||||
undefined
|
||||
);
|
||||
readonly backdropUrl = computed(
|
||||
() => tmdbBackdropUrl(this.tmdb()?.backdrop_path) ?? undefined
|
||||
);
|
||||
readonly year = computed(() => {
|
||||
const releaseDate = this.tmdb()?.release_date ?? '';
|
||||
return /^\d{4}/.test(releaseDate) ? releaseDate.slice(0, 4) : '';
|
||||
});
|
||||
readonly genres = computed(() =>
|
||||
(this.tmdb()?.genres ?? [])
|
||||
.map((genre) => genre.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
);
|
||||
readonly runtimeLabel = computed(() => {
|
||||
const runtime = this.tmdb()?.runtime;
|
||||
if (!runtime || runtime <= 0) {
|
||||
return '';
|
||||
}
|
||||
const hours = Math.floor(runtime / 60);
|
||||
const minutes = runtime % 60;
|
||||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||||
});
|
||||
readonly rating = computed(() => {
|
||||
const details = this.tmdb();
|
||||
const average = details?.vote_average ?? 0;
|
||||
const count = details?.vote_count ?? 0;
|
||||
return average > 0 && count > 0
|
||||
? (Math.round(average * 10) / 10).toFixed(1)
|
||||
: '';
|
||||
});
|
||||
readonly cast = computed(() => {
|
||||
const credits = this.tmdb()?.credits;
|
||||
return credits ? enrichedCast(topCast(credits)) : [];
|
||||
});
|
||||
readonly directors = computed(() =>
|
||||
(this.tmdb()?.credits?.crew ?? [])
|
||||
.filter((member) => member.job === 'Director')
|
||||
.map((member) => member.name)
|
||||
.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();
|
||||
return playback ? { ...playback, isLive: false } : null;
|
||||
});
|
||||
readonly playbackActive = computed(() => this.inlinePlayback() !== null);
|
||||
readonly canPlayInline = computed(
|
||||
() => this.inlinePlayerAvailable() && this.playback() !== null
|
||||
);
|
||||
|
||||
closeInlinePlayback(): void {
|
||||
this.playerDismissed.set(true);
|
||||
}
|
||||
|
||||
startPlayback(): void {
|
||||
this.playbackStarted.emit();
|
||||
this.playerDismissed.set(false);
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TmdbEnrichmentService } from '@iptvnator/services';
|
||||
import { M3uVodMetadataService } from './m3u-vod-metadata.service';
|
||||
|
||||
describe('M3uVodMetadataService', () => {
|
||||
let service: M3uVodMetadataService;
|
||||
let enrichMovie: jest.Mock;
|
||||
let isEnabled: jest.Mock;
|
||||
|
||||
/** Deferred promise so tests control exactly when a lookup settles. */
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
const flush = () => new Promise<void>((res) => setTimeout(res, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
enrichMovie = jest.fn();
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
M3uVodMetadataService,
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { enrichMovie, isEnabled },
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(M3uVodMetadataService);
|
||||
});
|
||||
|
||||
it('resolves a match and exposes the details', async () => {
|
||||
const details = { id: 42, title: 'Dune' };
|
||||
enrichMovie.mockResolvedValue(details);
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune (2021) 1080p' });
|
||||
expect(service.state().status).toBe('loading');
|
||||
|
||||
await flush();
|
||||
expect(service.state()).toEqual({
|
||||
lookupKey: 'ch1\u0000Dune (2021) 1080p',
|
||||
status: 'matched',
|
||||
details,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the raw name and the release-tag year to the resolver', () => {
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune (2021) 1080p' });
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledWith({
|
||||
title: 'Dune (2021) 1080p',
|
||||
year: 2021,
|
||||
});
|
||||
});
|
||||
|
||||
it('never reads a year that is part of the film name', () => {
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
|
||||
service.load({ id: 'ch1', name: '2001: A Space Odyssey' });
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledWith({
|
||||
title: '2001: A Space Odyssey',
|
||||
year: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports "none" when the resolver finds no confident match', async () => {
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
|
||||
service.load({ id: 'ch1', name: 'Some Obscure Movie' });
|
||||
await flush();
|
||||
|
||||
expect(service.state().status).toBe('none');
|
||||
expect(service.state().details).toBeNull();
|
||||
});
|
||||
|
||||
it('reports "none" when the lookup rejects', async () => {
|
||||
enrichMovie.mockRejectedValue(new Error('offline'));
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
await flush();
|
||||
|
||||
expect(service.state().status).toBe('none');
|
||||
});
|
||||
|
||||
it('drops a stale resolution after zapping to another channel', async () => {
|
||||
const first = deferred<{ id: number; title: string } | null>();
|
||||
const second = deferred<{ id: number; title: string } | null>();
|
||||
enrichMovie
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
service.load({ id: 'ch2', name: 'Alien' });
|
||||
|
||||
// The FIRST channel's answer arrives after the switch — must not land
|
||||
first.resolve({ id: 1, title: 'Dune' });
|
||||
await flush();
|
||||
expect(service.state().status).toBe('loading');
|
||||
expect(service.state().lookupKey).toContain('ch2');
|
||||
|
||||
second.resolve({ id: 2, title: 'Alien' });
|
||||
await flush();
|
||||
expect(service.state()).toEqual({
|
||||
lookupKey: 'ch2\u0000Alien',
|
||||
status: 'matched',
|
||||
details: { id: 2, title: 'Alien' },
|
||||
});
|
||||
});
|
||||
|
||||
it('re-requests when a shared id carries a different title', async () => {
|
||||
// `createChannel` falls back to the URL for a missing id, so two
|
||||
// entries pointing at one stream can share one — the lookup identity
|
||||
// is the id AND the title the request is actually built from.
|
||||
enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' });
|
||||
service.load({ id: 'same-id', name: 'Dune' });
|
||||
await flush();
|
||||
|
||||
service.load({ id: 'same-id', name: 'Alien' });
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledTimes(2);
|
||||
expect(service.state().status).toBe('loading');
|
||||
});
|
||||
|
||||
it('does not re-request the channel the state already tracks', async () => {
|
||||
enrichMovie.mockResolvedValue({ id: 1, title: 'Dune' });
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
await flush();
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
|
||||
expect(enrichMovie).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('settles as "none" without a request while TMDB is disabled', () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
|
||||
expect(service.state().status).toBe('none');
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reset returns to idle', async () => {
|
||||
enrichMovie.mockResolvedValue(null);
|
||||
service.load({ id: 'ch1', name: 'Dune' });
|
||||
await flush();
|
||||
|
||||
service.reset();
|
||||
|
||||
expect(service.state()).toEqual({
|
||||
lookupKey: null,
|
||||
status: 'idle',
|
||||
details: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { TmdbEnrichmentService, TmdbMovieDetails } from '@iptvnator/services';
|
||||
import { Channel, releaseTagYear } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export type M3uVodMetadataStatus = 'idle' | 'loading' | 'matched' | 'none';
|
||||
|
||||
export interface M3uVodMetadataState {
|
||||
/**
|
||||
* The lookup this state belongs to — the staleness anchor. Both the id
|
||||
* AND the title, because the title is what is actually looked up: an id
|
||||
* alone would assume it determines the name, and nothing guarantees
|
||||
* that (`createChannel` falls back to the URL for a missing id, so two
|
||||
* entries pointing at one stream would share it).
|
||||
*/
|
||||
lookupKey: string | null;
|
||||
status: M3uVodMetadataStatus;
|
||||
details: TmdbMovieDetails | null;
|
||||
}
|
||||
|
||||
const IDLE_STATE: M3uVodMetadataState = {
|
||||
lookupKey: null,
|
||||
status: 'idle',
|
||||
details: null,
|
||||
};
|
||||
|
||||
/** NUL keeps the two parts unambiguous whatever an entry is named. */
|
||||
function lookupKeyOf(channel: Pick<Channel, 'id' | 'name'>): string {
|
||||
return `${channel.id}\u0000${channel.name ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB lookup for an M3U entry recognized as a movie. Thin glue over
|
||||
* {@link TmdbEnrichmentService}: the resolver already normalizes titles
|
||||
* (brackets, quality tags, language prefixes) and caches verdicts, so the
|
||||
* only jobs here are the release-year hint and the staleness guard.
|
||||
*
|
||||
* Component-provided (not root): the state is scoped to one mounted detail
|
||||
* host, and dies with it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class M3uVodMetadataService {
|
||||
private readonly tmdb = inject(TmdbEnrichmentService);
|
||||
|
||||
private readonly stateSignal = signal<M3uVodMetadataState>(IDLE_STATE);
|
||||
readonly state = this.stateSignal.asReadonly();
|
||||
|
||||
/**
|
||||
* Resolve TMDB metadata for the channel. Zapping to another channel
|
||||
* while a request is in flight makes the stale resolution a no-op —
|
||||
* only the response for the channel the state currently tracks lands.
|
||||
*/
|
||||
load(channel: Pick<Channel, 'id' | 'name'>): void {
|
||||
const lookupKey = lookupKeyOf(channel);
|
||||
if (this.stateSignal().lookupKey === lookupKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.tmdb.isEnabled()) {
|
||||
this.stateSignal.set({ lookupKey, status: 'none', details: null });
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateSignal.set({ lookupKey, status: 'loading', details: null });
|
||||
|
||||
// `releaseTagYear` reads only bracketed/trailing release TAGS, never
|
||||
// a year that is part of the film's name ("2001: A Space Odyssey").
|
||||
void this.tmdb
|
||||
.enrichMovie({
|
||||
title: channel.name,
|
||||
year: releaseTagYear(channel.name),
|
||||
})
|
||||
.then(
|
||||
(details) => this.settle(lookupKey, details),
|
||||
() => this.settle(lookupKey, null)
|
||||
);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.stateSignal.set(IDLE_STATE);
|
||||
}
|
||||
|
||||
private settle(lookupKey: string, details: TmdbMovieDetails | null): void {
|
||||
if (this.stateSignal().lookupKey !== lookupKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateSignal.set({
|
||||
lookupKey,
|
||||
status: details ? 'matched' : 'none',
|
||||
details,
|
||||
});
|
||||
}
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
|
||||
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
|
||||
// bundle breaks under the ESM jest environment, so it is mocked before the
|
||||
// dynamic import evaluates the chain.
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
/**
|
||||
* The M3U movie recognition gate: which channels swap the player + EPG zone
|
||||
* for the VOD detail host. Separate from `video-player.component.spec.ts`
|
||||
* only because that file sits at the max-lines test budget; the harness here
|
||||
* is the minimal subset needed to render the layout branch.
|
||||
*/
|
||||
describe('VideoPlayerComponent — M3U movie recognition gate', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
|
||||
const playlistId = signal('playlist-1');
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const activePlaybackUrl = signal<string | null>(null);
|
||||
const channels = signal<Channel[]>([]);
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
|
||||
|
||||
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
const showCaptions = signal(false);
|
||||
const stripCountryPrefix = signal(false);
|
||||
const m3uVodDetails = signal<boolean | undefined>(true);
|
||||
const tmdbEnabled = signal(true);
|
||||
|
||||
const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectActive:
|
||||
return activeChannel;
|
||||
case selectActivePlaybackUrl:
|
||||
return activePlaybackUrl;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return channelsLoading;
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram;
|
||||
case selectActiveEpgProgram:
|
||||
return activeEpgProgram;
|
||||
default:
|
||||
return signal(null);
|
||||
}
|
||||
}),
|
||||
select: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectChannels:
|
||||
return channels$.asObservable();
|
||||
case selectActive:
|
||||
return activeChannel$.asObservable();
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram$.asObservable();
|
||||
default:
|
||||
return of(null);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
const liveChannel: Channel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
name: 'Sample TV',
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
tvg: {
|
||||
id: 'sample-tvg-id',
|
||||
logo: 'http://localhost/logo.png',
|
||||
name: 'Sample TV',
|
||||
},
|
||||
} as Channel;
|
||||
|
||||
const movieChannel: Channel = {
|
||||
...liveChannel,
|
||||
id: 'movie-1',
|
||||
name: 'Dune (2021) 1080p',
|
||||
url: 'http://host/movie/user/pass/1.mkv',
|
||||
tvg: { ...liveChannel.tvg, id: '' },
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
function syncStoreState(channel: Channel | null): void {
|
||||
activeChannel.set(channel);
|
||||
activeChannel$.next(channel);
|
||||
channels.set(channel ? [channel] : []);
|
||||
channels$.next(channel ? [channel] : []);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
syncStoreState(null);
|
||||
m3uVodDetails.set(true);
|
||||
tmdbEnabled.set(true);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
url: '/workspace/playlists/playlist-1/all',
|
||||
navigate: jest.fn(),
|
||||
currentNavigation: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
},
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: true,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({ playlist: { items: [] }, favorites: [] })
|
||||
),
|
||||
getPlaylistWithGlobalFavorites: jest.fn(() =>
|
||||
of({ playlist: { items: [] }, favorites: [] })
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [] })
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: {
|
||||
currentEpgPrograms$: epgPrograms$.asObservable(),
|
||||
getChannelMetadataForChannels: () => of(new Map()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: { resolvedPlaylistId: playlistId },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
stripCountryPrefix,
|
||||
m3uVodDetails,
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
epgUrl: signal<string[]>([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() =>
|
||||
of({
|
||||
player: player(),
|
||||
showCaptions: showCaptions(),
|
||||
} as Partial<Settings>)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled: () => tmdbEnabled() },
|
||||
},
|
||||
],
|
||||
})
|
||||
// The layout branch is the subject; everything below it renders
|
||||
// as inert unknown elements.
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
});
|
||||
|
||||
it('routes a recognized movie to the VOD detail host instead of the EPG zone', () => {
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-m3u-vod-detail')
|
||||
).not.toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.video-player')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the live layout for a live channel', () => {
|
||||
syncStoreState(liveChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-m3u-vod-detail')
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.epg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays off when the settings toggle is disabled', () => {
|
||||
m3uVodDetails.set(false);
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(false);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-m3u-vod-detail')
|
||||
).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.epg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('stays off while TMDB enrichment is not enabled', () => {
|
||||
tmdbEnabled.set(false);
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(false);
|
||||
});
|
||||
|
||||
it('treats a missing toggle value as enabled (default on)', () => {
|
||||
m3uVodDetails.set(undefined);
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.showMovieDetail()).toBe(true);
|
||||
});
|
||||
|
||||
describe('volume handed to each new player', () => {
|
||||
afterEach(() => localStorage.removeItem('volume'));
|
||||
|
||||
it('re-reads the shared volume bus on every channel change', () => {
|
||||
localStorage.setItem('volume', '0.4');
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
expect(component.volume()).toBe(0.4);
|
||||
|
||||
// The engines persist straight to localStorage and never call
|
||||
// back, so a volume set inside the player is only visible there.
|
||||
localStorage.setItem('volume', '0.15');
|
||||
syncStoreState(liveChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.volume()).toBe(0.15);
|
||||
});
|
||||
|
||||
it.each(['not-a-number', '', ' ', '5', '-1'])(
|
||||
'falls back to full volume for the stored value %p',
|
||||
(stored) => {
|
||||
localStorage.setItem('volume', stored);
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.volume()).toBe(1);
|
||||
}
|
||||
);
|
||||
|
||||
it('falls back to full volume when nothing is stored yet', () => {
|
||||
localStorage.removeItem('volume');
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
// `Number(null)` is 0 — a first run must not start muted.
|
||||
expect(component.volume()).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a stored silence', () => {
|
||||
localStorage.setItem('volume', '0');
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
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.
|
||||
// Pins the user-visible guarantee independently of what the
|
||||
// linkedSignal source happens to derive.
|
||||
localStorage.setItem('volume', '0.8');
|
||||
syncStoreState(movieChannel);
|
||||
fixture.detectChanges();
|
||||
expect(component.volume()).toBe(0.8);
|
||||
|
||||
localStorage.setItem('volume', '0.2');
|
||||
syncStoreState({ ...movieChannel, group: { title: 'New' } });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.volume()).toBe(0.2);
|
||||
});
|
||||
});
|
||||
});
|
||||
+121
-99
@@ -64,113 +64,135 @@
|
||||
</button>
|
||||
}
|
||||
@if (activeChannel(); as activeChannel) {
|
||||
@if (
|
||||
activeChannel.radio === 'true' ||
|
||||
shouldShowInlinePlayer(activeChannel)
|
||||
) {
|
||||
<div class="video-player">
|
||||
@if (activeChannel.radio === 'true') {
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
[icon]="activeChannel?.tvg?.logo!"
|
||||
[channelName]="displayChannelName()"
|
||||
[volume]="volume()"
|
||||
(volumeChange)="onInlineVolumeChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="inlinePlayerTitle()"
|
||||
[playback]="embeddedPlayback()"
|
||||
[playerOverride]="
|
||||
activeChannelIsDash()
|
||||
? dashPlayerOverride()
|
||||
: (playerSettings.player ?? null)
|
||||
"
|
||||
[volume]="volume()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsEpg && activeChannel.radio !== 'true') {
|
||||
@let canCollapseEpgPanel = shouldShowInlinePlayer(activeChannel);
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
@if (showMovieDetail()) {
|
||||
<app-m3u-vod-detail
|
||||
[channel]="activeChannel"
|
||||
[playback]="embeddedPlayback()"
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[inlinePlayerAvailable]="shouldShowInlinePlayer(activeChannel)"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerSettings.player ?? null"
|
||||
(playbackStarted)="refreshVolumeFromBus()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
} @else {
|
||||
@if (
|
||||
activeChannel.radio === 'true' ||
|
||||
shouldShowInlinePlayer(activeChannel)
|
||||
) {
|
||||
<div class="video-player">
|
||||
@if (activeChannel.radio === 'true') {
|
||||
<app-audio-player
|
||||
[url]="activeChannel.url"
|
||||
[icon]="activeChannel?.tvg?.logo!"
|
||||
[channelName]="displayChannelName()"
|
||||
[volume]="volume()"
|
||||
(volumeChange)="onInlineVolumeChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
|
||||
[title]="inlinePlayerTitle()"
|
||||
[playback]="embeddedPlayback()"
|
||||
[playerOverride]="
|
||||
activeChannelIsDash()
|
||||
? dashPlayerOverride()
|
||||
: (playerSettings.player ?? null)
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
[volume]="volume()"
|
||||
(externalFallbackRequested)="
|
||||
handleExternalFallbackRequest($event)
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (supportsEpg && activeChannel.radio !== 'true') {
|
||||
@let canCollapseEpgPanel =
|
||||
shouldShowInlinePlayer(activeChannel);
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="
|
||||
liveEpgPanelSummaryLabelKey()
|
||||
"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="
|
||||
archivePlaybackAvailable()
|
||||
"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="
|
||||
liveEpgPanelSummaryLabelKey()
|
||||
"
|
||||
[emptyReason]="liveEpgEmptyReason()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
(openEpgSettings)="openEpgSettings()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (showChannelNumberOverlay) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
extractDrmFromRaw,
|
||||
isDashChannel,
|
||||
isDashStreamUrl,
|
||||
isLikelyM3uMovie,
|
||||
isM3uCatchupPlaybackSupported,
|
||||
} from '@iptvnator/shared/m3u-utils';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
@@ -103,6 +105,7 @@ import {
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Channel,
|
||||
@@ -119,6 +122,7 @@ import {
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { M3uVodDetailComponent } from '../m3u-vod-detail/m3u-vod-detail.component';
|
||||
import { createM3uChannelPlaybackRequest } from './m3u-channel-playback-actions';
|
||||
|
||||
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
|
||||
@@ -128,6 +132,23 @@ const M3U_SIDEBAR_MIN_WIDTH = 200;
|
||||
const M3U_SIDEBAR_MAX_WIDTH = 600;
|
||||
const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
|
||||
/**
|
||||
* Shared `volume` bus the player engines and the audio player persist to.
|
||||
*
|
||||
* The empty cases must be rejected BEFORE `Number()` sees them: it maps both
|
||||
* `null` (nothing stored yet) and `''` to 0, which would silently start every
|
||||
* first-run playback muted.
|
||||
*/
|
||||
function readStoredVolume(): number {
|
||||
const stored = localStorage.getItem('volume')?.trim();
|
||||
if (!stored) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const parsed = Number(stored);
|
||||
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 1;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-video-player',
|
||||
imports: [
|
||||
@@ -137,6 +158,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
CommonModule,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
M3uVodDetailComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
@@ -162,6 +184,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly workspaceHeaderContext = inject(
|
||||
WorkspaceHeaderContextService
|
||||
@@ -212,6 +235,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
? VideoPlayer.ArtPlayer
|
||||
: VideoPlayer.Html5Player
|
||||
);
|
||||
/**
|
||||
* The active channel is a movie FILE (URL-shape heuristic) and the user
|
||||
* has TMDB enrichment plus the recognition toggle on — the content area
|
||||
* shows the VOD detail experience instead of the player + EPG zone.
|
||||
* Synchronous on purpose: the layout is chosen at activation, and the
|
||||
* TMDB lookup inside the detail host only patches metadata afterwards.
|
||||
*/
|
||||
readonly showMovieDetail = computed(() => {
|
||||
const channel = this.activeChannel();
|
||||
return (
|
||||
!!channel &&
|
||||
this.settingsStore.m3uVodDetails?.() !== false &&
|
||||
this.tmdbEnrichment.isEnabled() &&
|
||||
isLikelyM3uMovie(channel)
|
||||
);
|
||||
});
|
||||
/** Full multi-day programme window for the active channel (timeline). */
|
||||
readonly epgPrograms = toSignal(this.epgService.currentEpgPrograms$, {
|
||||
initialValue: [] as EpgProgram[],
|
||||
@@ -435,15 +474,34 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
showChannelNumberOverlay = false;
|
||||
private channelNumberTimeout?: number;
|
||||
|
||||
readonly volume = signal(1);
|
||||
/**
|
||||
* Volume handed to each new player instance, re-read from the shared
|
||||
* `volume` localStorage bus on every channel change.
|
||||
*
|
||||
* The bus is what the engines actually write to (they persist on
|
||||
* `volumechange` and never call back into this component), and every
|
||||
* channel switch mints a new source revision, which recreates the engine
|
||||
* component and re-reads this input. A plain constructor snapshot
|
||||
* therefore snapped playback back to the volume the page was opened
|
||||
* with as soon as the user zapped after adjusting it in the player.
|
||||
* Remote-control writes still `set()` this signal directly and store the
|
||||
* same value, so re-reading the bus per channel agrees with them.
|
||||
*
|
||||
* The source is the whole channel, not a derived key: `linkedSignal`
|
||||
* re-runs its computation whenever the source EXPRESSION invalidates —
|
||||
* the derived value's equality does not gate it (`producerRecomputeValue`
|
||||
* calls `computation()` right after re-evaluating `source()`). Selecting
|
||||
* the channel is therefore the trigger, which is exactly the intent, and
|
||||
* nothing here depends on ids being distinct — `createChannel` falls back
|
||||
* to the URL, so one stream listed in two groups shares an id. A re-read
|
||||
* is one idempotent localStorage hit, so an extra one costs nothing.
|
||||
*/
|
||||
readonly volume = linkedSignal({
|
||||
source: () => this.activeChannel(),
|
||||
computation: () => readStoredVolume(),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Initialize volume from localStorage in constructor
|
||||
const savedVolume = localStorage.getItem('volume');
|
||||
if (savedVolume !== null) {
|
||||
this.volume.set(Number(savedVolume));
|
||||
}
|
||||
|
||||
// React to settings changes
|
||||
effect(() => {
|
||||
this.playerSettings = {
|
||||
@@ -599,7 +657,10 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
untracked(() => {
|
||||
const remoteControl = this.remoteControlBridge;
|
||||
const activeChannel = this.activeChannel();
|
||||
if (!remoteControl?.updateRemoteControlStatus || !activeChannel) {
|
||||
if (
|
||||
!remoteControl?.updateRemoteControlStatus ||
|
||||
!activeChannel
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -666,7 +727,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const currentEpgProgram = epgProgram as
|
||||
EpgProgram | null | undefined;
|
||||
| EpgProgram
|
||||
| null
|
||||
| undefined;
|
||||
const currentIndex = channels.findIndex(
|
||||
(channel) => channel.url === activeChannel.url
|
||||
);
|
||||
@@ -1090,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);
|
||||
@@ -1142,7 +1215,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
// after Shaka's browser-support preflight fails — the DASH-forced
|
||||
// inline player is not audible then, so this check must precede the
|
||||
// DASH shortcut.
|
||||
if (isLiveExternalPlayerSession(this.externalPlayback.activeSession())) {
|
||||
if (
|
||||
isLiveExternalPlayerSession(this.externalPlayback.activeSession())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1152,8 +1227,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
|
||||
const player = this.playerSettings.player;
|
||||
return (
|
||||
!this.isExternalPlayer(player) &&
|
||||
player !== VideoPlayer.EmbeddedMpv
|
||||
!this.isExternalPlayer(player) && player !== VideoPlayer.EmbeddedMpv
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
playerAmbientMode: false,
|
||||
playerUpNextRail: true,
|
||||
vodAutoFailover: false,
|
||||
m3uVodDetails: true,
|
||||
streamFormat: StreamFormat.AutoStreamFormat,
|
||||
openStreamOnDoubleClick: false,
|
||||
language: Language.ENGLISH,
|
||||
@@ -226,6 +227,9 @@ export const SettingsStore = signalStore(
|
||||
vodAutoFailover:
|
||||
store.vodAutoFailover?.() ??
|
||||
DEFAULT_SETTINGS.vodAutoFailover,
|
||||
m3uVodDetails:
|
||||
store.m3uVodDetails?.() ??
|
||||
DEFAULT_SETTINGS.m3uVodDetails,
|
||||
streamFormat: store.streamFormat(),
|
||||
openStreamOnDoubleClick: store.openStreamOnDoubleClick(),
|
||||
language: store.language(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './tmdb-api.service';
|
||||
export * from './tmdb-cache.service';
|
||||
export * from './tmdb-config';
|
||||
export * from './tmdb-credits';
|
||||
export * from './tmdb-enrichment.service';
|
||||
export * from './tmdb-id-resolver.service';
|
||||
export * from './tmdb-episode-merge';
|
||||
|
||||
@@ -131,6 +131,14 @@ export interface Settings {
|
||||
* session, so this cannot loop.
|
||||
*/
|
||||
vodAutoFailover?: boolean;
|
||||
/**
|
||||
* Present M3U entries recognized as movie files (by URL shape) in the
|
||||
* VOD detail view with TMDB metadata instead of the EPG zone. On by
|
||||
* default; missing values mean enabled. Only takes effect while TMDB
|
||||
* enrichment itself is enabled — without it there is no metadata to
|
||||
* show, so recognition never runs.
|
||||
*/
|
||||
m3uVodDetails?: boolean;
|
||||
epgUrl: string[];
|
||||
streamFormat: StreamFormat;
|
||||
openStreamOnDoubleClick: boolean;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './lib/dash.utils';
|
||||
export * from './lib/kodiprop.utils';
|
||||
export * from './lib/m3u-vod-detection.util';
|
||||
export * from './lib/playback-media-extension.util';
|
||||
export * from './lib/playlist.utils';
|
||||
export * from './lib/catchup.utils';
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { hasEpisodeMarker, isLikelyM3uMovie } from './m3u-vod-detection.util';
|
||||
|
||||
const movie = (url: string, name = 'Some Movie', radio = '') => ({
|
||||
url,
|
||||
name,
|
||||
radio,
|
||||
});
|
||||
|
||||
describe('isLikelyM3uMovie', () => {
|
||||
describe('movie container extensions', () => {
|
||||
it.each([
|
||||
'http://host/films/Dune.Part.Two.2024.mkv',
|
||||
'http://host/films/Dune.mp4',
|
||||
'http://host/films/OLDBOY.MKV',
|
||||
'http://host/films/movie.avi',
|
||||
'http://host/films/movie.webm',
|
||||
'http://host/films/clip.m4v',
|
||||
])('recognizes %s', (url) => {
|
||||
expect(isLikelyM3uMovie(movie(url))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes a query-declared extension', () => {
|
||||
expect(isLikelyM3uMovie(movie('http://host/stream?ext=mp4'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'http://host/live/channel.ts',
|
||||
'http://host/live/channel.m3u8',
|
||||
'http://host/live/segment.m4s',
|
||||
'http://host/live/audio.mp3',
|
||||
'http://host/live/audio.aac',
|
||||
'http://host/live/channel',
|
||||
])('does not treat streaming/audio URL %s as a movie', (url) => {
|
||||
expect(isLikelyM3uMovie(movie(url))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xtream-style path segments', () => {
|
||||
it('recognizes /movie/ paths without a file extension', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/movie/user/pass/12345'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes HLS VOD under /movie/', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(
|
||||
movie('http://host/movie/user/pass/12345.m3u8')
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['movies', 'vod'])('recognizes /%s/ segment', (segment) => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie(`http://host/${segment}/12345`))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive about the segment', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/MOVIE/user/pass/1'))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not fire on a segment merely containing the word', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/moviestar-tv/live'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('treats /series/ as episodes, not movies, even with a movie container', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/series/user/pass/777.mkv'))
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exclusions', () => {
|
||||
it('never treats a radio entry as a movie', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/x.mp4', 'Jazz FM', 'true'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never treats a DASH URL as a movie, even under /movie/', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(movie('http://host/movie/stream.mpd'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('skips entries whose name carries an episode marker', () => {
|
||||
expect(
|
||||
isLikelyM3uMovie(
|
||||
movie('http://host/f/x.mkv', 'Breaking Bad S01E02')
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('handles null and missing urls', () => {
|
||||
expect(isLikelyM3uMovie(null)).toBe(false);
|
||||
expect(isLikelyM3uMovie(undefined)).toBe(false);
|
||||
expect(isLikelyM3uMovie(movie(''))).toBe(false);
|
||||
});
|
||||
|
||||
it('handles unparseable urls without throwing', () => {
|
||||
expect(isLikelyM3uMovie(movie('http://['))).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEpisodeMarker', () => {
|
||||
it.each([
|
||||
'Breaking Bad S01E02',
|
||||
'Breaking Bad s1.e2',
|
||||
'Breaking Bad S01 E02',
|
||||
'Breaking Bad 1x02',
|
||||
'The Boys Season 3',
|
||||
'The Boys season_02',
|
||||
'Sherlock Episode 5',
|
||||
'Dark Folge 3',
|
||||
'La Casa de Papel Episodio 2',
|
||||
'Шерлок 2 серия',
|
||||
'Во все тяжкие 1 сезон',
|
||||
'Пацаны 3-й сезон',
|
||||
'Эпизод 4 Шерлок',
|
||||
])('detects "%s"', (name) => {
|
||||
expect(hasEpisodeMarker(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'Dune Part Two',
|
||||
'4x4',
|
||||
'2001: A Space Odyssey',
|
||||
'1917',
|
||||
'Se7en',
|
||||
'Postseason Story',
|
||||
'The Fifth Season',
|
||||
'Друзья Оушена 13',
|
||||
'',
|
||||
])('does not fire on "%s"', (name) => {
|
||||
expect(hasEpisodeMarker(name)).toBe(false);
|
||||
});
|
||||
|
||||
// Documented cost of the conservative gate: a real film whose NAME
|
||||
// carries an episode word is skipped and keeps the current live-style
|
||||
// view. Safe direction — never mis-enrich a series episode as a film.
|
||||
it('skips "Star Wars: Episode 1" by design', () => {
|
||||
expect(hasEpisodeMarker('Star Wars: Episode 1')).toBe(true);
|
||||
});
|
||||
|
||||
it('handles null/undefined', () => {
|
||||
expect(hasEpisodeMarker(null)).toBe(false);
|
||||
expect(hasEpisodeMarker(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Channel,
|
||||
SEASON_WORD_ALTERNATIVES,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { isDashStreamUrl } from './dash.utils';
|
||||
import { getPlaybackMediaExtensionFromUrl } from './playback-media-extension.util';
|
||||
|
||||
/**
|
||||
* Heuristic that decides whether an M3U entry is a movie FILE rather than a
|
||||
* live stream, so the player can present the VOD detail experience (TMDB
|
||||
* metadata instead of the EPG zone).
|
||||
*
|
||||
* The decision must be synchronous — the layout is chosen the moment a
|
||||
* channel is activated, before any TMDB request — so it reads only what the
|
||||
* playlist itself states: the URL shape and the entry name. It is
|
||||
* deliberately conservative: a false negative keeps today's behavior, while
|
||||
* a false positive would swap the EPG zone away from a live channel.
|
||||
*/
|
||||
|
||||
/**
|
||||
* File containers that only ever carry finished assets. Streaming
|
||||
* packagings are deliberately absent: `.ts`/`.m3u8`/`.m4s` are how LIVE
|
||||
* channels are delivered, and `.mpd` (DASH) has its own routing path.
|
||||
* Audio extensions are absent too — an audio file is not a movie.
|
||||
*/
|
||||
const MOVIE_CONTAINER_EXTENSIONS = new Set([
|
||||
'avi',
|
||||
'asf',
|
||||
'divx',
|
||||
'flv',
|
||||
'm2ts',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'mov',
|
||||
'mp4',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'rm',
|
||||
'rmvb',
|
||||
'vob',
|
||||
'webm',
|
||||
'wmv',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Xtream-derived M3U exports address VOD as `/movie/<user>/<pass>/<id>`,
|
||||
* often without a file extension. The segment is a strong VOD signal on its
|
||||
* own, whatever the container (some panels serve HLS VOD under it).
|
||||
* `/series/` is the same panels' EPISODE namespace — those are series
|
||||
* content, which v1 does not recognize.
|
||||
*/
|
||||
const MOVIE_PATH_SEGMENTS = new Set(['movie', 'movies', 'vod']);
|
||||
const SERIES_PATH_SEGMENT = 'series';
|
||||
|
||||
/**
|
||||
* Episode markers in the entry name: "S01E01", "s1.e2", "1x02",
|
||||
* "Episode 5", "2 серия", "Season 3" (word list shared with the season
|
||||
* stripper in title-normalization). A marked entry is a series episode, not
|
||||
* a movie — v1 skips those rather than mis-enriching them as films.
|
||||
* Known cost: "Star Wars: Episode 1" is skipped too; that only keeps the
|
||||
* current live-style view, which is the safe direction.
|
||||
*
|
||||
* `(?:^|[^\p{L}])` guards instead of `\b`: JS word boundaries are
|
||||
* ASCII-only and never fire next to Cyrillic letters.
|
||||
*/
|
||||
const EPISODE_WORD_ALTERNATIVES =
|
||||
'episode|episodio|folge|серия|эпизод|bölüm|odcinek|aflevering';
|
||||
const SEASON_OR_EPISODE_WORD = `(?:${SEASON_WORD_ALTERNATIVES}|${EPISODE_WORD_ALTERNATIVES})`;
|
||||
|
||||
const SEASON_EPISODE_CODE =
|
||||
/(?:^|[^a-z0-9])s\d{1,2}[\s._-]*e\d{1,3}(?![a-z0-9])/i;
|
||||
// Requires a 2–3 digit episode part, so short film titles like "4x4" survive
|
||||
const CROSS_EPISODE_CODE = /(?:^|[^0-9])\d{1,2}x\d{2,3}(?![0-9])/;
|
||||
const WORD_FIRST_MARKER = new RegExp(
|
||||
`(?:^|[^\\p{L}])${SEASON_OR_EPISODE_WORD}[\\s._-]*\\d{1,3}(?!\\d)`,
|
||||
'iu'
|
||||
);
|
||||
const NUMBER_FIRST_MARKER = new RegExp(
|
||||
`(?:^|[^\\p{L}\\d])\\d{1,2}[\\s._-]*(?:st|nd|rd|th|й|и|я|ой|ои)?[\\s._-]*${SEASON_OR_EPISODE_WORD}(?:$|[^\\p{L}])`,
|
||||
'iu'
|
||||
);
|
||||
|
||||
export function hasEpisodeMarker(name: string | null | undefined): boolean {
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
SEASON_EPISODE_CODE.test(name) ||
|
||||
CROSS_EPISODE_CODE.test(name) ||
|
||||
WORD_FIRST_MARKER.test(name) ||
|
||||
NUMBER_FIRST_MARKER.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
function pathSegmentsOf(url: string): string[] {
|
||||
try {
|
||||
return new URL(url, 'http://iptvnator.local').pathname
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
.map((segment) => segment.toLowerCase());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyM3uMovie(
|
||||
channel: Pick<Channel, 'url' | 'name' | 'radio'> | null | undefined
|
||||
): boolean {
|
||||
const url = channel?.url;
|
||||
if (!url || channel.radio === 'true' || isDashStreamUrl(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasEpisodeMarker(channel.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const segments = pathSegmentsOf(url);
|
||||
if (segments.includes(SERIES_PATH_SEGMENT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
MOVIE_CONTAINER_EXTENSIONS.has(getPlaybackMediaExtensionFromUrl(url)) ||
|
||||
segments.some((segment) => MOVIE_PATH_SEGMENTS.has(segment))
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ class StubWebPlayerViewComponent {
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -23,6 +23,8 @@ class StubWebPlayerViewComponent {
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
[title]="title()"
|
||||
[mediaTitle]="playerMediaTitle()"
|
||||
[playback]="playback()"
|
||||
[volume]="volume()"
|
||||
[playerOverride]="playerOverride()"
|
||||
[startTime]="startTime()"
|
||||
[seriesNavigation]="seriesNavigation()"
|
||||
[alternativeSources]="alternativeSources()"
|
||||
|
||||
@@ -26,6 +26,8 @@ class StubWebPlayerViewComponent {
|
||||
readonly title = input('');
|
||||
readonly mediaTitle = input<unknown>(null);
|
||||
readonly playback = input<unknown>(null);
|
||||
readonly volume = input(1);
|
||||
readonly playerOverride = input<unknown>(null);
|
||||
readonly startTime = input(0);
|
||||
readonly seriesNavigation = input<unknown>(null);
|
||||
readonly alternativeSources = input<unknown[]>([]);
|
||||
|
||||
@@ -72,6 +72,20 @@ 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);
|
||||
/**
|
||||
* Engine the host already resolved. `WebPlayerViewComponent` otherwise
|
||||
* waits for its own asynchronous settings read and mounts Video.js
|
||||
* meanwhile — and the engine is part of the application token, so that
|
||||
* correction swaps the player under a running session. Hosts that hold
|
||||
* the settings synchronously pass them; `null` keeps the old behaviour.
|
||||
*/
|
||||
readonly playerOverride = input<VideoPlayer | null>(null);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
// Strip only live-channel titles — VOD/series titles ("Mission:
|
||||
// Impossible - Fallout") must never lose their leading segment.
|
||||
|
||||
Reference in new issue
Block a user