diff --git a/.changes/dashboard-no-rail-jump.md b/.changes/dashboard-no-rail-jump.md new file mode 100644 index 000000000..95223a4df --- /dev/null +++ b/.changes/dashboard-no-rail-jump.md @@ -0,0 +1,9 @@ +--- +type: perf +area: dashboard +--- + +The dashboard no longer jumps when it opens: placeholder rails that turned out +to be empty used to flash and collapse, pushing the rails below them up the +page. Rails now appear once, in place, and a placeholder shows only when a rail +takes noticeably long to load. diff --git a/.changes/playback-external-poll-after-exit.md b/.changes/playback-external-poll-after-exit.md new file mode 100644 index 000000000..87dbd7d37 --- /dev/null +++ b/.changes/playback-external-poll-after-exit.md @@ -0,0 +1,7 @@ +--- +type: fix +area: playback +--- + +Closing MPV or VLC within the first seconds of playback no longer leaves the +app checking the closed player's position in the background until it quits. diff --git a/.changes/playback-history-after-playback.md b/.changes/playback-history-after-playback.md new file mode 100644 index 000000000..0b6935611 --- /dev/null +++ b/.changes/playback-history-after-playback.md @@ -0,0 +1,6 @@ +--- +type: feature +area: playback +--- + +Channels, movies and episodes now join Recently Viewed and the dashboard's Continue Watching hero only after they have actually played for a couple of seconds, so streams that fail right away no longer clutter your history. diff --git a/.changes/playback-hybrid-player-controls.md b/.changes/playback-hybrid-player-controls.md new file mode 100644 index 000000000..9d2fb0174 --- /dev/null +++ b/.changes/playback-hybrid-player-controls.md @@ -0,0 +1,10 @@ +--- +type: feature +area: playback +highlight: Redesigned player controls +--- + +The built-in player controls have a new look: a lighter dock that sits +directly on the video, a round accent play button, the volume slider inline +on wide players, a remaining-time counter, and a timeline that shows the time +under your pointer before you seek. diff --git a/.changes/playback-player-settings-panel.md b/.changes/playback-player-settings-panel.md new file mode 100644 index 000000000..0ab266108 --- /dev/null +++ b/.changes/playback-player-settings-panel.md @@ -0,0 +1,11 @@ +--- +type: feature +area: playback +highlight: Player settings panel +--- + +Audio, subtitles, quality, speed and aspect ratio now live in one settings +panel behind a tune button. Wide players show subtitle and speed chips with +their current value and slide the panel in beside the video; small players +get a bottom sheet, and the tune button shows colored dots for anything +turned on or changed. diff --git a/.changes/playback-timeline-segments-model.md b/.changes/playback-timeline-segments-model.md new file mode 100644 index 000000000..cbcd8eff1 --- /dev/null +++ b/.changes/playback-timeline-segments-model.md @@ -0,0 +1,8 @@ +--- +type: internal +area: playback +--- + +The shared player timeline can draw chapter or programme segments with +per-segment progress and a titled hover label; no player supplies segments +yet, so the bar still renders as one segment. diff --git a/.changes/playback-up-next-card.md b/.changes/playback-up-next-card.md new file mode 100644 index 000000000..670036029 --- /dev/null +++ b/.changes/playback-up-next-card.md @@ -0,0 +1,9 @@ +--- +type: feature +area: playback +highlight: Up next in the player +--- + +In the last minutes of a series episode the player shows an "Up next" card +with the following episode's still, title and time remaining. One click +plays it, and the switch keeps you in fullscreen. diff --git a/.changes/portal-about-broken-poster.md b/.changes/portal-about-broken-poster.md new file mode 100644 index 000000000..cf9a12a88 --- /dev/null +++ b/.changes/portal-about-broken-poster.md @@ -0,0 +1,6 @@ +--- +type: fix +area: portal +--- + +The About section under the player no longer shows a broken image when a movie or series poster cannot be loaded; the poster is simply left out, as it is when the provider sends none. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05947a5a1..a57d6e28e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,10 @@ jobs: name: Initial bytes ratchet runs-on: ubuntu-latest timeout-minutes: 30 + # pull-requests: read lets the direction check read the PR's labels. + permissions: + contents: read + pull-requests: read steps: - name: Checkout code @@ -153,12 +157,24 @@ jobs: # the revision this one is measured against instead: the target # branch for a pull request, the previous head for a master push, # master for a manual dispatch. Any raised limit, widened tolerance - # or removed entry fails. + # or slack, or removed entry fails, unless a maintainer put the + # perf-baseline-increase label on the pull request. For a master + # push the label counts only when the push added exactly one + # first-parent commit (a squash or merge of one PR) and that + # commit's PR carries it: the check compares the whole push, so a + # multi-commit push cannot borrow one PR's label for another's + # increase. Labels are read from the API, not the event payload, + # so re-running this job after adding the label picks it up. - name: Refuse baseline increases against the previous revision env: EVENT_NAME: ${{ github.event_name }} BASE_REF: ${{ github.base_ref }} BEFORE_SHA: ${{ github.event.before }} + HEAD_SHA: ${{ github.sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPOSITORY: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} + INCREASE_LABEL: perf-baseline-increase run: | set -euo pipefail case "$EVENT_NAME" in @@ -178,9 +194,32 @@ jobs: esac git show "FETCH_HEAD:tools/performance/journey-baselines.json" > /tmp/base-journey-baselines.json 2>/dev/null || rm -f /tmp/base-journey-baselines.json + case "$EVENT_NAME" in + pull_request) + labels="$(gh api "repos/$REPOSITORY/issues/$PR_NUMBER/labels?per_page=100" --paginate --jq '.[].name')" + ;; + push) + parent="$(gh api "repos/$REPOSITORY/commits/$HEAD_SHA" --jq '.parents[0].sha')" + if [ "$parent" = "$BEFORE_SHA" ]; then + labels="$(gh api "repos/$REPOSITORY/commits/$HEAD_SHA/pulls?per_page=100" --paginate --jq '.[].labels[].name')" + else + echo "This push added more than one commit; the $INCREASE_LABEL label is not consulted." + labels="" + fi + ;; + *) + labels="" + ;; + esac + allow=() + if grep -qxF "$INCREASE_LABEL" <<< "$labels"; then + echo "The $INCREASE_LABEL label is set; weakened baselines are reported, not failed." + allow=(--allow-increase) + fi node tools/performance/check-baseline-direction.mjs \ --base /tmp/base-journey-baselines.json \ - --head tools/performance/journey-baselines.json + --head tools/performance/journey-baselines.json \ + "${allow[@]}" # The production configuration is what users download; measuring # any other build would ratchet a number nobody ships. @@ -190,8 +229,8 @@ jobs: CI: true NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false - # Fails when renderer.initialBytes exceeds the value committed in - # tools/performance/journey-baselines.json. Baselines only move + # Fails when renderer.initialBytes exceeds value + slack committed + # in tools/performance/journey-baselines.json. Baselines only move # down, with the printed measurement as evidence; the contract is # docs/architecture/performance-journeys.md. - name: Check renderer.initialBytes against the baseline @@ -427,16 +466,17 @@ jobs: --paginate --jq '.[] | .filename, (.previous_filename // empty)' | node tools/coverage/unit-coverage-scope.mjs --base FETCH_HEAD --github-output - # pnpm skips Electron's postinstall (it is not in - # onlyBuiltDependencies), so the binary is fetched on the first - # require('electron'). Specs that run SQLite under - # ELECTRON_RUN_AS_NODE resolve it that way while Tier A projects - # run concurrently, and one exec'ing it mid-extraction fails with - # ETXTBSY. Download and verify it once, before any test starts; - # the Tier A runner does the same, so this step only separates a - # download failure from test failures. Skipped suites need no - # binary. - - name: Download Electron binary + # pnpm only runs build scripts for allow-listed packages + # (pnpm-workspace.yaml), so electron's postinstall never fetches + # its binary. `require('electron')` then downloads it lazily, and + # parallel Jest workers and Tier A projects that spawn Electron + # race on the same download: one executes the half-written binary + # (ETXTBSY). Fetch it once before the Tier A suite, the only step + # here whose specs spawn Electron. Unlike a bare install.js, the + # helper also runs the binary, so a partial extraction fails here. + # The Tier A runner calls it too; this step only separates a + # download failure from test failures. + - name: Install the Electron binary if: steps.scope.outputs.run == 'true' run: node tools/testing/ensure-electron-binary.mjs diff --git a/apps/electron-backend-e2e/playwright.config.ts b/apps/electron-backend-e2e/playwright.config.ts index 9446117e2..5dd6cccd9 100644 --- a/apps/electron-backend-e2e/playwright.config.ts +++ b/apps/electron-backend-e2e/playwright.config.ts @@ -46,15 +46,27 @@ export default defineConfig({ /* Video on failure */ video: 'on-first-retry', }, + /* The mocks run as one node process, not through `nx run …:serve`: Nx + * starts its command in a detached process group, so Playwright's + * process-group kill missed it and the server kept its port after the run. + * See docs/architecture/xtream-mock-server.md#playwright-integration. */ webServer: [ { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, diff --git a/apps/electron-backend-e2e/playwright.journeys.config.ts b/apps/electron-backend-e2e/playwright.journeys.config.ts index 02f158d43..0d04e996a 100644 --- a/apps/electron-backend-e2e/playwright.journeys.config.ts +++ b/apps/electron-backend-e2e/playwright.journeys.config.ts @@ -6,9 +6,11 @@ import { defineConfig } from '@playwright/test'; * worker, no retries: every journey spawns its own Electron processes and * writes one summary per run. The Xtream mock serves both the M3U playlist * and the portal on a dedicated loopback port so a normal E2E server on - * 3211 cannot be reused by accident. Locally a server left behind by an - * earlier run on that port is reused (its fixtures are deterministic); CI - * always starts its own. + * 3211 cannot be reused by accident. The mock runs as one node process + * rather than through `nx run …:serve`, whose detached process group outlived + * the run and kept the port. Locally a server already on the port (e.g. one + * started by hand) is reused, since its fixtures are deterministic; CI always + * starts its own. */ const xtreamMockPort = process.env['IPTVNATOR_JOURNEY_XTREAM_MOCK_PORT'] ?? '3231'; @@ -24,11 +26,13 @@ export default defineConfig({ testIdAttribute: 'data-test-id', }, webServer: { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', cwd: workspaceRoot, env: { HOST: '127.0.0.1', + NODE_ENV: 'development', PORT: xtreamMockPort, + TSX_TSCONFIG_PATH: 'tsconfig.base.json', }, reuseExistingServer: !process.env['CI'], url: `http://127.0.0.1:${xtreamMockPort}/health`, diff --git a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts index fcf8086e9..57efa40a5 100644 --- a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts +++ b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts @@ -19,13 +19,16 @@ export default defineConfig({ testIdAttribute: 'data-test-id', }, webServer: { - command: 'pnpm nx run xtream-mock-server:serve', + // One node process (not `nx run`) so the run's kill reaches it. + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', cwd: workspaceRoot, env: { HOST: '127.0.0.1', IPTVNATOR_XTREAM_MOCK_CONTROL: '1', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: controlToken, + NODE_ENV: 'development', PORT: '3221', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', }, reuseExistingServer: false, url: 'http://127.0.0.1:3221/health', diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 2c65b3e85..0ff4ff951 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -49,6 +49,15 @@ "command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts" } }, + "benchmark-charset-parse": { + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --expose-gc --tsconfig tsconfig.json src/performance/charset-parse.benchmark.ts" + } + }, "journeys": { "dependsOn": ["electron-backend:build-performance"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts index 511f6f070..89deaa4de 100644 --- a/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts +++ b/apps/electron-backend-e2e/src/dash-clearkey.e2e.ts @@ -31,6 +31,7 @@ import { waitForM3uCatalog, workspaceRoot, } from './electron-test-fixtures'; +import { startAndConfirmPlayback } from './playable-stream-fixture'; /** * DASH + ClearKey playback in the real Electron runtime — the only automated @@ -541,7 +542,8 @@ for (const configuredPlayer of ['videojs', 'mpv', 'artplayer']) { buildDashPlaylist(fixtureServer.origin) ); const channel = channelItemByTitle(page, 'ClearKey DASH').first(); - await channel.click(); + // Recent history records the channel once it has really played. + await startAndConfirmPlayback(page, () => channel.click()); await channel.locator('.favorite-button').click(); await expect( channel.locator('.favorite-button mat-icon') diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 86a19d0d0..5dfb3e3e0 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -25,6 +25,10 @@ import { getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; test.describe('Dashboard Activation', () => { test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({ @@ -76,6 +80,7 @@ test.describe('Dashboard Activation', () => { return new RegExp(titles.join('|')); }; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -99,7 +104,10 @@ test.describe('Dashboard Activation', () => { ); const movieTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); - await playCurrentDetail(app.mainWindow); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -111,7 +119,9 @@ test.describe('Dashboard Activation', () => { ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await goToDashboard(app.mainWindow); @@ -176,11 +186,13 @@ test.describe('Dashboard Activation', () => { liveTitle ).locator('.rail__channel-now') ).toContainText(liveNowTitle(), { timeout: 30000 }); - await dashboardRailCardByTitle( - app.mainWindow, - 'dashboard-live-favorites-rail', - liveTitle - ).click(); + await startAndConfirmPlayback(app.mainWindow, () => + dashboardRailCardByTitle( + app.mainWindow, + 'dashboard-live-favorites-rail', + liveTitle + ).click() + ); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/favorites$/ ); diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts index 9c99fcad8..704a8b920 100644 --- a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -20,6 +20,10 @@ import { writeTemporaryM3uFile, } from './electron-test-fixtures'; import { fetchXtreamLiveFixture } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; /** * Issue #1458, second report: "all channels disappear after clearing the @@ -136,6 +140,7 @@ test.describe('Live channel rail collapse (#1458)', () => { CHANNELS ); const app: LaunchedElectronApp = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); @@ -143,9 +148,12 @@ test.describe('Live channel rail collapse (#1458)', () => { const playlistId = playlistIdFromUrl(app.mainWindow); expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); - await channelItemByTitle(app.mainWindow, 'Channel Alpha') - .first() - .click(); + // History records a channel only once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Channel Alpha') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await expect( diff --git a/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.spec.ts b/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.spec.ts new file mode 100644 index 000000000..88662478c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.spec.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + CHARSET_BENCHMARK_VARIANTS, + type CharsetBenchmarkVariant, + type CharsetCaseMeasurement, + compareCharsets, + formatCharsetReport, + medianOf, + summarizeSelfSamples, + variantOrderForRound, +} from './charset-parse-benchmark-report'; + +function measurement( + variant: CharsetBenchmarkVariant, + cpuMs: number[], + profileSamples: number, + workload = 'm3u parse' +): CharsetCaseMeasurement { + return { + workload, + variant, + inputBytes: 10, + inputLength: 10, + hasNonLatin1Characters: variant !== 'latin1', + wallClockMs: cpuMs.map((value) => value * 2), + cpuMs, + profileSamples, + topSelfFrames: [], + }; +} + +describe('charset parse benchmark report', () => { + it('rotates the starting variant every round', () => { + assert.deepEqual(variantOrderForRound(0), [ + 'latin1', + 'latin1-bom', + 'cyrillic', + ]); + assert.deepEqual(variantOrderForRound(1), [ + 'latin1-bom', + 'cyrillic', + 'latin1', + ]); + assert.deepEqual(variantOrderForRound(2), [ + 'cyrillic', + 'latin1', + 'latin1-bom', + ]); + assert.deepEqual(variantOrderForRound(3), variantOrderForRound(0)); + for (let round = 0; round < 6; round += 1) { + assert.deepEqual( + [...variantOrderForRound(round)].sort(), + [...CHARSET_BENCHMARK_VARIANTS].sort() + ); + } + }); + + it('takes the median of odd and even samples without mutating them', () => { + const values = [5, 1, 3]; + + assert.equal(medianOf(values), 3); + assert.deepEqual(values, [5, 1, 3]); + assert.equal(medianOf([4, 1, 3, 2]), 2.5); + assert.throws(() => medianOf([]), /empty sample/); + }); + + it('counts self samples per frame, most frequent first', () => { + const summary = summarizeSelfSamples( + { + nodes: [ + { + id: 1, + callFrame: { + functionName: '', + url: '', + lineNumber: -1, + }, + }, + { + id: 2, + callFrame: { + functionName: 'scanAttributes', + url: 'file:///repo/node_modules/iptv-playlist-parser/src/index.js', + lineNumber: 42, + }, + }, + ], + samples: [2, 2, 1, 2, 9], + }, + 2 + ); + + assert.equal(summary.total, 5); + assert.deepEqual(summary.top, [ + { frame: 'scanAttributes index.js:43', samples: 3 }, + { frame: '(anonymous)', samples: 1 }, + ]); + }); + + it('compares every variant with latin1 and flags only slowdowns seen by both signals', () => { + const [comparison] = compareCharsets([ + measurement('latin1', [10, 12, 11], 100), + measurement('latin1-bom', [16, 17, 18], 140), + measurement('cyrillic', [16, 17, 18], 160), + ]); + + assert.equal(comparison.workload, 'm3u parse'); + assert.deepEqual( + comparison.variants.map((row) => row.variant), + [...CHARSET_BENCHMARK_VARIANTS] + ); + const [latin1, bom, cyrillic] = comparison.variants; + assert.equal(latin1.cpuRatio, 1); + assert.equal(latin1.exceedsThreshold, false); + assert.equal(bom.cpuP50Ms, 17); + assert.equal(bom.wallP50Ms, 34); + assert.ok(Math.abs(bom.cpuRatio - 17 / 11) < 1e-9); + // CPU time is over 1.5x, but profile samples (1.4x) are not. + assert.equal(bom.exceedsThreshold, false); + assert.equal(cyrillic.sampleRatio, 1.6); + assert.equal(cyrillic.exceedsThreshold, true); + }); + + it('fails when a variant was not measured', () => { + assert.throws( + () => + compareCharsets([ + measurement('latin1', [1], 1), + measurement('cyrillic', [1], 1), + ]), + /Missing latin1-bom measurement for m3u parse/ + ); + }); + + it('formats one markdown row per workload and variant', () => { + const report = formatCharsetReport( + compareCharsets([ + measurement('latin1', [10], 100), + measurement('latin1-bom', [11], 100), + measurement('cyrillic', [20], 200), + ]) + ); + const lines = report.split('\n'); + + assert.equal(lines.length, 5); + assert.match(lines[0], /^\| Workload \| Input \|/); + assert.equal( + lines[4], + '| m3u parse | cyrillic | 40.0 | 20.0 | 200 | 2.00x | 2.00x | 2.00x | yes |' + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.ts b/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.ts new file mode 100644 index 000000000..867c95ed2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/charset-parse-benchmark-report.ts @@ -0,0 +1,206 @@ +/** Two-byte input slower than this ratio justifies parser changes (plan D1). */ +export const CHARSET_SLOWDOWN_THRESHOLD = 1.5; + +/** + * Input variants of the charset benchmark. `latin1-bom` is the `latin1` + * fixture behind a UTF-8 byte-order mark: same content, but V8 must store + * the decoded string as two-byte, which isolates the encoding effect from + * the content effect that Cyrillic titles have on ASCII-only regexes. + */ +export const CHARSET_BENCHMARK_VARIANTS = [ + 'latin1', + 'latin1-bom', + 'cyrillic', +] as const; + +export type CharsetBenchmarkVariant = + (typeof CHARSET_BENCHMARK_VARIANTS)[number]; + +export interface CpuProfileNode { + readonly id: number; + readonly callFrame: { + readonly functionName: string; + readonly url: string; + readonly lineNumber: number; + }; +} + +export interface CpuProfileLike { + readonly nodes: readonly CpuProfileNode[]; + readonly samples?: readonly number[]; +} + +export interface SelfSampleFrame { + readonly frame: string; + readonly samples: number; +} + +export interface CharsetCaseMeasurement { + readonly workload: string; + readonly variant: CharsetBenchmarkVariant; + readonly inputBytes: number; + readonly inputLength: number; + /** True when V8 must store the decoded input as a two-byte string. */ + readonly hasNonLatin1Characters: boolean; + readonly wallClockMs: readonly number[]; + readonly cpuMs: readonly number[]; + readonly profileSamples: number; + readonly topSelfFrames: readonly SelfSampleFrame[]; +} + +export interface CharsetVariantSummary { + readonly variant: CharsetBenchmarkVariant; + readonly wallP50Ms: number; + readonly cpuP50Ms: number; + readonly profileSamples: number; + readonly wallRatio: number; + readonly cpuRatio: number; + readonly sampleRatio: number; + /** Slower than the threshold on CPU time and on profile samples. */ + readonly exceedsThreshold: boolean; +} + +export interface CharsetComparison { + readonly workload: string; + readonly variants: readonly CharsetVariantSummary[]; +} + +/** + * Variant order for one benchmark round. The starting variant rotates every + * round so no input always runs first or last after a garbage collection. + */ +export function variantOrderForRound(round: number): CharsetBenchmarkVariant[] { + const variants = [...CHARSET_BENCHMARK_VARIANTS]; + const offset = round % variants.length; + return [...variants.slice(offset), ...variants.slice(0, offset)]; +} + +export function medianOf(values: readonly number[]): number { + if (values.length === 0) { + throw new Error('Cannot take the median of an empty sample'); + } + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 1 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2; +} + +/** Counts self samples per frame; the top frames explain where time went. */ +export function summarizeSelfSamples( + profile: CpuProfileLike, + limit = 8 +): { total: number; top: SelfSampleFrame[] } { + const nodesById = new Map(profile.nodes.map((node) => [node.id, node])); + const counts = new Map(); + const samples = profile.samples ?? []; + + for (const nodeId of samples) { + const frame = describeFrame(nodesById.get(nodeId)); + counts.set(frame, (counts.get(frame) ?? 0) + 1); + } + + return { total: samples.length, top: topFrames(counts, limit) }; +} + +export function topFrames( + counts: ReadonlyMap, + limit: number +): SelfSampleFrame[] { + return [...counts.entries()] + .map(([frame, samples]) => ({ frame, samples })) + .sort( + (left, right) => + right.samples - left.samples || + left.frame.localeCompare(right.frame) + ) + .slice(0, limit); +} + +export function compareCharsets( + measurements: readonly CharsetCaseMeasurement[] +): CharsetComparison[] { + const workloads = [...new Set(measurements.map((m) => m.workload))]; + + return workloads.map((workload) => { + const baseline = findCase(measurements, workload, 'latin1'); + const baselineWall = medianOf(baseline.wallClockMs); + const baselineCpu = medianOf(baseline.cpuMs); + + return { + workload, + variants: CHARSET_BENCHMARK_VARIANTS.map((variant) => { + const measurement = findCase(measurements, workload, variant); + const wallP50Ms = medianOf(measurement.wallClockMs); + const cpuP50Ms = medianOf(measurement.cpuMs); + const cpuRatio = cpuP50Ms / baselineCpu; + const sampleRatio = + measurement.profileSamples / baseline.profileSamples; + return { + variant, + wallP50Ms, + cpuP50Ms, + profileSamples: measurement.profileSamples, + wallRatio: wallP50Ms / baselineWall, + cpuRatio, + sampleRatio, + exceedsThreshold: + cpuRatio > CHARSET_SLOWDOWN_THRESHOLD && + sampleRatio > CHARSET_SLOWDOWN_THRESHOLD, + }; + }), + }; + }); +} + +export function formatCharsetReport( + comparisons: readonly CharsetComparison[] +): string { + const header = + '| Workload | Input | Wall P50 ms | CPU P50 ms | Samples | Wall ratio | CPU ratio | Sample ratio | Over 1.5x |'; + const divider = + '| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |'; + const rows = comparisons.flatMap((comparison) => + comparison.variants.map((row) => + [ + comparison.workload, + row.variant, + row.wallP50Ms.toFixed(1), + row.cpuP50Ms.toFixed(1), + String(row.profileSamples), + `${row.wallRatio.toFixed(2)}x`, + `${row.cpuRatio.toFixed(2)}x`, + `${row.sampleRatio.toFixed(2)}x`, + row.exceedsThreshold ? 'yes' : 'no', + ].join(' | ') + ) + ); + return [header, divider, ...rows.map((row) => `| ${row} |`)].join('\n'); +} + +function findCase( + measurements: readonly CharsetCaseMeasurement[], + workload: string, + variant: CharsetBenchmarkVariant +): CharsetCaseMeasurement { + const match = measurements.find( + (m) => m.workload === workload && m.variant === variant + ); + if (!match) { + throw new Error(`Missing ${variant} measurement for ${workload}`); + } + return match; +} + +function describeFrame(node: CpuProfileNode | undefined): string { + if (!node) { + return '(unknown)'; + } + const { functionName, url, lineNumber } = node.callFrame; + const name = functionName || '(anonymous)'; + if (!url) { + return name; + } + const file = url.slice(url.lastIndexOf('/') + 1); + return `${name} ${file}:${lineNumber + 1}`; +} diff --git a/apps/electron-backend-e2e/src/performance/charset-parse-workloads.ts b/apps/electron-backend-e2e/src/performance/charset-parse-workloads.ts new file mode 100644 index 000000000..8f0234121 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/charset-parse-workloads.ts @@ -0,0 +1,191 @@ +import { createPlaylistObject } from '@iptvnator/shared/m3u-utils'; +import { parse } from 'iptv-playlist-parser'; +import { resolve } from 'node:path'; + +import type { CharsetBenchmarkVariant } from './charset-parse-benchmark-report'; +import { SYNTHETIC_TITLE_VOCABULARY } from './synthetic-charset'; +import { createSyntheticM3uFixture, SYNTHETIC_M3U_SEED } from './synthetic-m3u'; +import { createSyntheticXmltvFixture } from './synthetic-xmltv'; + +export const ENTRY_COUNT = 50_000; +/** + * The EPG worker writes 64 KiB decoded chunks. The benchmark writes slices of + * one decoded string instead, so every slice keeps the input's one-byte or + * two-byte representation (decoding each chunk separately would make the + * BOM control one-byte after its first chunk) and no character is split. + */ +const XMLTV_CHUNK_CHARS = 64 * 1024; +const REPLACEMENT_CHARACTER = '\ufffd'; +const UTF8_BYTE_ORDER_MARK = Buffer.from([0xef, 0xbb, 0xbf]); + +type Variant = CharsetBenchmarkVariant; + +interface ParsedProgramme { + readonly title: readonly { readonly value: string }[]; +} + +interface StreamingEpgParserModule { + StreamingEpgParser: new ( + onChannels: (channels: unknown[]) => void, + onPrograms: (programs: ParsedProgramme[]) => void, + onProgress: (channels: number, programs: number) => void, + channelBatchSize?: number, + programBatchSize?: number + ) => { write(chunk: string): void; finish(): { totalPrograms: number } }; +} + +export interface Workload { + readonly name: string; + /** Prepares untimed input, then returns the timed operation. */ + prepare(variant: Variant): () => void; + input(variant: Variant): string; + /** Untimed check that the parser saw the fixture's exact titles. */ + verify(variant: Variant): void; +} + +export async function createWorkloads(): Promise { + const epgModule = (await import( + resolve( + __dirname, + '../../../electron-backend/src/app/workers/epg-streaming-parser.ts' + ) + )) as StreamingEpgParserModule; + const m3uBuffers = buffersPerVariant((charset) => + createSyntheticM3uFixture(ENTRY_COUNT, { charset }) + ); + const xmltvBuffers = buffersPerVariant((charset) => + createSyntheticXmltvFixture(ENTRY_COUNT, { charset }) + ); + const m3uInput = (variant: Variant) => + requireBuffer(m3uBuffers, variant).toString('utf8'); + const xmltvInput = (variant: Variant) => + requireBuffer(xmltvBuffers, variant).toString('utf8'); + + const verifyM3u = (variant: Variant) => { + const titles = titlesFor(variant); + const [first] = parse(m3uInput(variant)).items; + expectEqual( + first?.name, + `${titles.channel} ${SYNTHETIC_M3U_SEED}-000001` + ); + expectEqual(first?.group.title, `${titles.group} 001`); + }; + const parseXmltv = ( + variant: Variant, + onPrograms: (programs: ParsedProgramme[]) => void + ) => { + const chunks = sliceText(xmltvInput(variant), XMLTV_CHUNK_CHARS); + return () => { + const parser = new epgModule.StreamingEpgParser( + () => undefined, + onPrograms, + () => undefined, + 100, + 1000 + ); + for (const chunk of chunks) { + parser.write(chunk); + } + assertCount(parser.finish().totalPrograms); + }; + }; + + return [ + { + // PARSE_M3U phase of the main-process import. + name: 'm3u parse', + input: m3uInput, + verify: verifyM3u, + prepare(variant) { + const body = m3uInput(variant); + return () => assertCount(parse(body).items.length); + }, + }, + { + // NORMALIZE phase of the main-process import. + name: 'm3u normalize', + input: m3uInput, + verify: verifyM3u, + prepare(variant) { + const parsed = parse(m3uInput(variant)); + return () => + assertCount( + createPlaylistObject('benchmark', parsed, 'x', 'URL') + .count + ); + }, + }, + { + // EPG worker's saxes-based parser; UTF-8 decoding is excluded. + name: 'xmltv stream parse', + input: xmltvInput, + verify(variant) { + const titles: string[] = []; + parseXmltv(variant, (programs) => { + for (const programme of programs) { + titles.push(programme.title[0]?.value ?? ''); + } + })(); + const programme = titlesFor(variant).programme; + expectEqual(titles[0], `${programme} 000001-0001`); + expectEqual(titles.at(-1), `${programme} 000500-0100`); + if ( + titles.some((title) => + title.includes(REPLACEMENT_CHARACTER) + ) + ) { + throw new Error(`${variant} XMLTV titles contain U+FFFD`); + } + }, + prepare: (variant) => parseXmltv(variant, () => undefined), + }, + ]; +} + +function titlesFor(variant: Variant) { + return SYNTHETIC_TITLE_VOCABULARY[ + variant === 'cyrillic' ? 'cyrillic' : 'latin1' + ]; +} + +function sliceText(text: string, size: number): string[] { + const chunks: string[] = []; + for (let offset = 0; offset < text.length; offset += size) { + chunks.push(text.slice(offset, offset + size)); + } + return chunks; +} + +function expectEqual(actual: string | undefined, expected: string): void { + if (actual !== expected) { + throw new Error(`Expected "${expected}", parsed "${String(actual)}"`); + } +} + +function buffersPerVariant( + create: (charset: 'latin1' | 'cyrillic') => { body: string } +): Map { + const latin1 = Buffer.from(create('latin1').body, 'utf8'); + return new Map([ + ['latin1', latin1], + ['latin1-bom', Buffer.concat([UTF8_BYTE_ORDER_MARK, latin1])], + ['cyrillic', Buffer.from(create('cyrillic').body, 'utf8')], + ]); +} + +function requireBuffer( + buffers: Map, + variant: Variant +): Buffer { + const buffer = buffers.get(variant); + if (!buffer) { + throw new Error(`No fixture for ${variant}`); + } + return buffer; +} + +function assertCount(count: number): void { + if (count !== ENTRY_COUNT) { + throw new Error(`Expected ${ENTRY_COUNT} entries, parsed ${count}`); + } +} diff --git a/apps/electron-backend-e2e/src/performance/charset-parse.benchmark.ts b/apps/electron-backend-e2e/src/performance/charset-parse.benchmark.ts new file mode 100644 index 000000000..b524974a8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/charset-parse.benchmark.ts @@ -0,0 +1,198 @@ +/** + * Plan D1: does two-byte (non-Latin-1) input slow down playlist and EPG + * parsing? Runs each workload on the `latin1` and `cyrillic` synthetic + * fixtures (identical layout) and on `latin1-bom`, the latin1 bytes behind a + * UTF-8 byte-order mark, which forces V8's two-byte representation without + * changing content. Reports P50 wall-clock and process CPU time over the + * timed iterations, and CPU-profile sample counts from a separate profiled + * pass (sampled in-process through the inspector, like `node --cpu-prof`). + * + * pnpm nx run electron-backend-e2e:benchmark-charset-parse \ + * [--iterations=5] [--warmup=1] [--sampling-interval-us=100] \ + * [--output=/absolute/report.json] + * + * To measure with Electron's V8 instead of the Node on PATH, run from + * apps/electron-backend-e2e: + * + * TSX_TSCONFIG_PATH=tsconfig.json ELECTRON_RUN_AS_NODE=1 \ + * "$(node -p "require('electron')")" --expose-gc --import tsx \ + * src/performance/charset-parse.benchmark.ts + */ +import { writeFile } from 'node:fs/promises'; +import { Session } from 'node:inspector/promises'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { + CHARSET_BENCHMARK_VARIANTS, + type CharsetBenchmarkVariant, + type CharsetCaseMeasurement, + compareCharsets, + type CpuProfileLike, + formatCharsetReport, + summarizeSelfSamples, + topFrames, + variantOrderForRound, +} from './charset-parse-benchmark-report'; +import { + createWorkloads, + ENTRY_COUNT, + type Workload, +} from './charset-parse-workloads'; + +type Variant = CharsetBenchmarkVariant; + +async function main(): Promise { + const { values } = parseArgs({ + options: { + iterations: { type: 'string', default: '5' }, + warmup: { type: 'string', default: '1' }, + 'sampling-interval-us': { type: 'string', default: '100' }, + output: { type: 'string' }, + }, + }); + const iterations = positiveInteger(values.iterations, 'iterations'); + const warmup = nonNegativeInteger(values.warmup, 'warmup'); + const samplingIntervalUs = positiveInteger( + values['sampling-interval-us'], + 'sampling-interval-us' + ); + const workloads = await createWorkloads(); + const session = new Session(); + session.connect(); + await session.post('Profiler.enable'); + await session.post('Profiler.setSamplingInterval', { + interval: samplingIntervalUs, + }); + + const measurements: CharsetCaseMeasurement[] = []; + for (const workload of workloads) { + for (const variant of CHARSET_BENCHMARK_VARIANTS) { + workload.verify(variant); + } + const wall = new Map(); + const cpu = new Map(); + const profiles = new Map(); + // Variants alternate inside every round, starting with a different + // one each round, so JIT warm-up and heap growth favour no input. + // Rounds after the timed ones run under the profiler. + for (let round = 0; round < warmup + iterations * 2; round += 1) { + for (const variant of variantOrderForRound(round)) { + const run = workload.prepare(variant); + collectGarbage(); + const profiled = round >= warmup + iterations; + if (profiled) { + await session.post('Profiler.start'); + } + const cpuBefore = process.cpuUsage(); + const startedAt = performance.now(); + run(); + const elapsedMs = performance.now() - startedAt; + const cpuUsed = process.cpuUsage(cpuBefore); + if (profiled) { + const { profile } = await session.post('Profiler.stop'); + append(profiles, variant, profile); + } else if (round >= warmup) { + append(wall, variant, elapsedMs); + append( + cpu, + variant, + (cpuUsed.user + cpuUsed.system) / 1000 + ); + } + } + } + for (const variant of CHARSET_BENCHMARK_VARIANTS) { + measurements.push( + measurementFor(workload, variant, { wall, cpu, profiles }) + ); + } + } + session.disconnect(); + + const comparisons = compareCharsets(measurements); + process.stdout.write( + `Node ${process.version}, ${iterations} iterations after ${warmup} warm-up, ` + + `${ENTRY_COUNT} entries, sampling every ${samplingIntervalUs} µs\n\n` + + `${formatCharsetReport(comparisons)}\n\n` + ); + for (const measurement of measurements) { + process.stdout.write( + `${measurement.workload} [${measurement.variant}] non-Latin-1 input: ${ + measurement.hasNonLatin1Characters + }; top self frames: ${measurement.topSelfFrames + .slice(0, 5) + .map((frame) => `${frame.frame} (${frame.samples})`) + .join(', ')}\n` + ); + } + if (values.output) { + await writeFile( + resolve(values.output), + `${JSON.stringify({ node: process.version, iterations, warmup, samplingIntervalUs, comparisons, measurements }, null, 2)}\n` + ); + } +} + +interface CollectedSamples { + readonly wall: Map; + readonly cpu: Map; + readonly profiles: Map; +} + +function measurementFor( + workload: Workload, + variant: Variant, + collected: CollectedSamples +): CharsetCaseMeasurement { + const input = workload.input(variant); + const summaries = (collected.profiles.get(variant) ?? []).map((profile) => + summarizeSelfSamples(profile, Number.POSITIVE_INFINITY) + ); + const merged = new Map(); + for (const frame of summaries.flatMap((summary) => summary.top)) { + merged.set(frame.frame, (merged.get(frame.frame) ?? 0) + frame.samples); + } + return { + workload: workload.name, + variant, + inputBytes: Buffer.byteLength(input, 'utf8'), + inputLength: input.length, + // Latin-1 round-trip is lossless only when every code unit is <= 0xff. + hasNonLatin1Characters: + Buffer.from(input, 'latin1').toString('latin1') !== input, + wallClockMs: collected.wall.get(variant) ?? [], + cpuMs: collected.cpu.get(variant) ?? [], + profileSamples: summaries.reduce((sum, s) => sum + s.total, 0), + topSelfFrames: topFrames(merged, 10), + }; +} + +function append(map: Map, variant: Variant, value: T): void { + map.set(variant, [...(map.get(variant) ?? []), value]); +} + +function collectGarbage(): void { + (globalThis as { gc?: () => void }).gc?.(); +} + +function positiveInteger(value: string | undefined, name: string): number { + const parsed = nonNegativeInteger(value, name); + if (parsed < 1) { + throw new Error(`--${name} must be a positive integer`); + } + return parsed; +} + +function nonNegativeInteger(value: string | undefined, name: string): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`--${name} must be a non-negative integer`); + } + return parsed; +} + +main().catch((error: unknown) => { + process.stderr.write(`${String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/electron-backend-e2e/src/performance/synthetic-charset.ts b/apps/electron-backend-e2e/src/performance/synthetic-charset.ts new file mode 100644 index 000000000..12121324a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-charset.ts @@ -0,0 +1,48 @@ +/** + * Title vocabulary for the synthetic M3U and XMLTV performance fixtures. + * + * V8 keeps a string one-byte (Latin-1) while every character fits in a byte + * and stores the whole string as two-byte UTF-16 once a single character + * outside Latin-1 appears. `cyrillic` fixtures exercise that two-byte path. + * + * Every Cyrillic entry has exactly the UTF-16 length of its Latin entry, so + * both variants have the same line count and the same character layout; only + * the display titles (channel, group and programme titles) differ. + */ +export const SYNTHETIC_CHARSETS = ['latin1', 'cyrillic'] as const; + +export type SyntheticCharset = (typeof SYNTHETIC_CHARSETS)[number]; + +export interface SyntheticTitleVocabulary { + readonly channel: string; + readonly group: string; + readonly language: string; + readonly programme: string; +} + +export const SYNTHETIC_TITLE_VOCABULARY: Readonly< + Record +> = Object.freeze({ + latin1: Object.freeze({ + channel: 'Synthetic Channel', + group: 'Synthetic Group', + language: 'en', + programme: 'Synthetic Programme', + }), + cyrillic: Object.freeze({ + channel: 'Пробный телеканал', + group: 'Пробная рубрика', + language: 'ru', + programme: 'Синтетическая серия', + }), +}); + +export function resolveSyntheticCharset( + charset: SyntheticCharset | undefined +): SyntheticCharset { + const resolved = charset ?? 'latin1'; + if (!SYNTHETIC_CHARSETS.includes(resolved)) { + throw new Error(`Unsupported synthetic charset: ${String(charset)}`); + } + return resolved; +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts index 094f4f80d..aafc8063d 100644 --- a/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { describe, it } from 'node:test'; +import { SYNTHETIC_TITLE_VOCABULARY } from './synthetic-charset'; import { createSyntheticM3uFixture, SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, @@ -14,6 +15,12 @@ const GOLDEN_SHA256 = { 100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353', } as const; +const CYRILLIC_GOLDEN_SHA256 = { + 10_000: 'bc4f3c2a3d507db459dff2d4a723d8fa8729fb957cc436f062e703564201a20b', + 50_000: '4a3fc713548a7ab50c70dc0751eb5b2483122af9d01f7d3975f309dc820c6a04', + 100_000: '94727a8452920c07727812f4ec78b77deac353f5a46ee096620efba2296b7e2b', +} as const; + describe('synthetic M3U performance fixture', () => { it('generates each supported catalog deterministically with exact metadata', () => { assert.deepEqual( @@ -47,6 +54,72 @@ describe('synthetic M3U performance fixture', () => { } }); + it('keeps the latin1 option identical to the default fixture', () => { + const byDefault = createSyntheticM3uFixture(10_000); + const explicit = createSyntheticM3uFixture(10_000, { + charset: 'latin1', + }); + + assert.equal(byDefault.charset, 'latin1'); + assert.equal(explicit.body, byDefault.body); + assert.equal(explicit.sha256, GOLDEN_SHA256[10_000]); + // UTF-8 byte length equals UTF-16 length only for ASCII-only text. + assert.equal(byDefault.bytes, byDefault.body.length); + }); + + it('generates each cyrillic catalog deterministically', () => { + for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) { + const cyrillic = createSyntheticM3uFixture(channelCount, { + charset: 'cyrillic', + }); + + assert.equal(cyrillic.charset, 'cyrillic'); + assert.equal(cyrillic.channelCount, channelCount); + assert.equal(cyrillic.sha256, CYRILLIC_GOLDEN_SHA256[channelCount]); + } + }); + + it('keeps the cyrillic layout identical apart from titles', () => { + const latin = createSyntheticM3uFixture(10_000); + const cyrillic = createSyntheticM3uFixture(10_000, { + charset: 'cyrillic', + }); + + assert.equal(cyrillic.bytes, Buffer.byteLength(cyrillic.body, 'utf8')); + assert.equal( + cyrillic.sha256, + createHash('sha256').update(cyrillic.body, 'utf8').digest('hex') + ); + assert.equal(cyrillic.body.length, latin.body.length); + assert.equal(toLatinTitles(cyrillic.body), latin.body); + assertOnlyLoopbackUrls(cyrillic.body); + }); + + it('puts characters outside Latin-1 into every cyrillic channel entry', () => { + const lines = createSyntheticM3uFixture(10_000, { charset: 'cyrillic' }) + .body.trimEnd() + .split('\n'); + + for (const line of lines.filter((entry) => + entry.startsWith('#EXTINF:') + )) { + assert.ok(hasCharacterAbove(line, 0xff), line); + } + for (const line of lines.filter((entry) => entry.startsWith('http'))) { + assert.equal(hasCharacterAbove(line, 0x7f), false, line); + } + }); + + it('rejects unsupported charsets', () => { + assert.throws( + () => + createSyntheticM3uFixture(10_000, { + charset: 'greek' as never, + }), + /Unsupported synthetic charset/ + ); + }); + it('rejects unsupported channel counts', () => { assert.throws( () => createSyntheticM3uFixture(9_999), @@ -55,6 +128,29 @@ describe('synthetic M3U performance fixture', () => { }); }); +function toLatinTitles(body: string): string { + const latin = SYNTHETIC_TITLE_VOCABULARY.latin1; + const cyrillic = SYNTHETIC_TITLE_VOCABULARY.cyrillic; + return replaceEvery( + replaceEvery(body, cyrillic.group, latin.group), + cyrillic.channel, + latin.channel + ); +} + +function hasCharacterAbove(text: string, maxCodeUnit: number): boolean { + for (let index = 0; index < text.length; index += 1) { + if (text.charCodeAt(index) > maxCodeUnit) { + return true; + } + } + return false; +} + +function replaceEvery(text: string, search: string, replacement: string) { + return text.split(search).join(replacement); +} + function assertOnlyLoopbackUrls(body: string): void { const urls = body.match(/https?:\/\/[^\s"]+/g) ?? []; diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts index 1cdf8d18b..3c52d2b0d 100644 --- a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -1,5 +1,11 @@ import { createHash } from 'node:crypto'; +import { + resolveSyntheticCharset, + type SyntheticCharset, + SYNTHETIC_TITLE_VOCABULARY, +} from './synthetic-charset'; + export const SYNTHETIC_M3U_SEED = 240_724; export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [ @@ -15,13 +21,22 @@ export interface SyntheticM3uFixture { readonly body: string; readonly bytes: number; readonly channelCount: SyntheticM3uChannelCount; + readonly charset: SyntheticCharset; readonly sha256: string; } +export interface SyntheticM3uFixtureOptions { + /** Script of channel names and group titles; defaults to `latin1`. */ + readonly charset?: SyntheticCharset; +} + export function createSyntheticM3uFixture( - channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT + channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT, + options: SyntheticM3uFixtureOptions = {} ): SyntheticM3uFixture { assertSupportedChannelCount(channelCount); + const charset = resolveSyntheticCharset(options.charset); + const titles = SYNTHETIC_TITLE_VOCABULARY[charset]; const lines = new Array(1 + channelCount * 2); lines[0] = '#EXTM3U'; @@ -31,12 +46,12 @@ export function createSyntheticM3uFixture( const lineOffset = 1 + offset * 2; const stableIndex = String(index).padStart(6, '0'); - lines[lineOffset] = `#EXTINF:-1 group-title="Synthetic Group ${String( + lines[lineOffset] = `#EXTINF:-1 group-title="${titles.group} ${String( group ).padStart( 3, '0' - )}",Synthetic Channel ${SYNTHETIC_M3U_SEED}-${stableIndex}`; + )}",${titles.channel} ${SYNTHETIC_M3U_SEED}-${stableIndex}`; lines[lineOffset + 1] = `http://127.0.0.1/stream/${SYNTHETIC_M3U_SEED}/${index}`; } @@ -46,6 +61,7 @@ export function createSyntheticM3uFixture( body, bytes: Buffer.byteLength(body, 'utf8'), channelCount, + charset, sha256: createHash('sha256').update(body, 'utf8').digest('hex'), }); } diff --git a/apps/electron-backend-e2e/src/performance/synthetic-xmltv.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-xmltv.spec.ts new file mode 100644 index 000000000..4e59ffd10 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-xmltv.spec.ts @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { SYNTHETIC_TITLE_VOCABULARY } from './synthetic-charset'; +import { + createSyntheticXmltvFixture, + SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS, + SYNTHETIC_XMLTV_PROGRAMME_COUNT, + SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL, +} from './synthetic-xmltv'; + +const GOLDEN_SHA256 = { + latin1: { + 10_000: 'e9d0ca0f7b73efc5cdee42cb2c4ee5bb4564b274b5042b13f8cd0d965d5011bc', + 50_000: '08b955c84822b7eb0d825a73eef6a6abc88b6d372c2dd32ad25d3969d667fd49', + 100_000: + '932f0ba7089cab9e205a78142a245ebb497826ee7f1f4819603d77eba8e95a9e', + }, + cyrillic: { + 10_000: '5b0df408e90c77bebe296fe1cc7e97cec5b929ebc7ad21b7480a6849a29f912b', + 50_000: 'bb1a0ca9fe874ab847b062c768868d76da750366aaa680bc712cb4cb7bfbf809', + 100_000: + 'cd637f4a25d47592116417a64bbb8c5b48d87f5bb5b0b4693f7af6c7603a8859', + }, +} as const; + +describe('synthetic XMLTV performance fixture', () => { + it('generates each supported schedule deterministically', () => { + assert.deepEqual( + SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS, + [10_000, 50_000, 100_000] + ); + assert.equal(SYNTHETIC_XMLTV_PROGRAMME_COUNT, 50_000); + + for (const charset of ['latin1', 'cyrillic'] as const) { + for (const programmeCount of SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS) { + const fixture = createSyntheticXmltvFixture(programmeCount, { + charset, + }); + + assert.equal(fixture.charset, charset); + assert.equal(fixture.programmeCount, programmeCount); + assert.equal( + fixture.channelCount, + programmeCount / SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL + ); + assert.equal( + fixture.sha256, + GOLDEN_SHA256[charset][programmeCount] + ); + } + } + }); + + it('reports exact metadata and element counts', () => { + for (const charset of ['latin1', 'cyrillic'] as const) { + const fixture = createSyntheticXmltvFixture(10_000, { charset }); + + assert.equal( + fixture.bytes, + Buffer.byteLength(fixture.body, 'utf8') + ); + assert.equal( + fixture.sha256, + createHash('sha256').update(fixture.body, 'utf8').digest('hex') + ); + assert.equal(countOf(fixture.body, '\n')); + } + }); + + it('defaults to 50k programmes with an ASCII-only latin1 body', () => { + const fixture = createSyntheticXmltvFixture(); + + assert.equal(fixture.charset, 'latin1'); + assert.equal(fixture.programmeCount, 50_000); + // UTF-8 byte length equals UTF-16 length only for ASCII-only text. + assert.equal(fixture.bytes, fixture.body.length); + }); + + it('keeps the cyrillic layout identical apart from titles', () => { + const latin = createSyntheticXmltvFixture(10_000); + const cyrillic = createSyntheticXmltvFixture(10_000, { + charset: 'cyrillic', + }); + const latinTitles = SYNTHETIC_TITLE_VOCABULARY.latin1; + const cyrillicTitles = SYNTHETIC_TITLE_VOCABULARY.cyrillic; + + assert.equal(cyrillic.body.length, latin.body.length); + const translated = [ + [cyrillicTitles.channel, latinTitles.channel], + [cyrillicTitles.programme, latinTitles.programme], + [ + `lang="${cyrillicTitles.language}"`, + `lang="${latinTitles.language}"`, + ], + ].reduce( + (text, [search, replacement]) => + text.split(search).join(replacement), + cyrillic.body + ); + assert.equal(translated, latin.body); + const firstTitle = cyrillic.body.indexOf(''); + assert.ok(firstTitle > 0); + assert.ok( + cyrillic.body.charCodeAt(firstTitle + '<title lang="ru">'.length) > + 0xff + ); + }); + + it('schedules consecutive half-hour programmes per channel', () => { + const lines = createSyntheticXmltvFixture(10_000).body.split('\n'); + const firstProgramme = lines.find((line) => + line.includes('<programme ') + ); + + assert.equal( + firstProgramme, + ' <programme start="20260101000000 +0000" stop="20260101003000 +0000" channel="synthetic.000001"><title lang="en">Synthetic Programme 000001-0001Synthetic description for slot 1.Synthetic' + ); + assert.ok( + lines.includes( + ' Synthetic Programme 000100-0100Synthetic description for slot 100.Synthetic' + ) + ); + }); + + it('rejects unsupported programme counts and charsets', () => { + assert.throws( + () => createSyntheticXmltvFixture(12_345), + /Unsupported synthetic XMLTV programme count/ + ); + assert.throws( + () => + createSyntheticXmltvFixture(10_000, { + charset: 'arabic' as never, + }), + /Unsupported synthetic charset/ + ); + }); +}); + +function countOf(body: string, needle: string): number { + let count = 0; + for ( + let index = body.indexOf(needle); + index !== -1; + index = body.indexOf(needle, index + needle.length) + ) { + count += 1; + } + return count; +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-xmltv.ts b/apps/electron-backend-e2e/src/performance/synthetic-xmltv.ts new file mode 100644 index 000000000..e19e62771 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-xmltv.ts @@ -0,0 +1,138 @@ +import { createHash } from 'node:crypto'; + +import { + resolveSyntheticCharset, + type SyntheticCharset, + SYNTHETIC_TITLE_VOCABULARY, +} from './synthetic-charset'; +import { SYNTHETIC_M3U_SEED } from './synthetic-m3u'; + +export const SYNTHETIC_XMLTV_PROGRAMME_COUNT = 50_000; +export const SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL = 100; +export const SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS = [ + 10_000, + SYNTHETIC_XMLTV_PROGRAMME_COUNT, + 100_000, +] as const; + +/** 2026-01-01T00:00:00Z; every programme lasts 30 minutes. */ +const SCHEDULE_START_EPOCH_MS = Date.UTC(2026, 0, 1); +const PROGRAMME_DURATION_MS = 30 * 60 * 1000; + +export type SyntheticXmltvProgrammeCount = + (typeof SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS)[number]; + +export interface SyntheticXmltvFixture { + readonly body: string; + readonly bytes: number; + readonly channelCount: number; + readonly charset: SyntheticCharset; + readonly programmeCount: SyntheticXmltvProgrammeCount; + readonly sha256: string; +} + +export interface SyntheticXmltvFixtureOptions { + /** Script of channel display names and programme titles. */ + readonly charset?: SyntheticCharset; +} + +/** + * Deterministic XMLTV document: all `` entries first, then + * `SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL` consecutive programmes per + * channel. Descriptions, categories and attributes stay ASCII in every + * charset so only the titles decide the string encoding. + */ +export function createSyntheticXmltvFixture( + programmeCount: number = SYNTHETIC_XMLTV_PROGRAMME_COUNT, + options: SyntheticXmltvFixtureOptions = {} +): SyntheticXmltvFixture { + assertSupportedProgrammeCount(programmeCount); + const charset = resolveSyntheticCharset(options.charset); + const titles = SYNTHETIC_TITLE_VOCABULARY[charset]; + const channelCount = + programmeCount / SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL; + const lines: string[] = [ + '', + '', + ]; + + for (let offset = 0; offset < channelCount; offset += 1) { + const channelId = syntheticChannelId(offset); + lines.push( + ` ${ + titles.channel + } ${SYNTHETIC_M3U_SEED}-${stableNumber(offset + 1, 6)}` + ); + } + + const slotTimestamps = Array.from( + { length: SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL + 1 }, + (_, slot) => + xmltvTimestamp( + SCHEDULE_START_EPOCH_MS + slot * PROGRAMME_DURATION_MS + ) + ); + for (let offset = 0; offset < channelCount; offset += 1) { + const channelId = syntheticChannelId(offset); + const channelNumber = stableNumber(offset + 1, 6); + for ( + let slot = 0; + slot < SYNTHETIC_XMLTV_PROGRAMMES_PER_CHANNEL; + slot += 1 + ) { + lines.push( + ` ${ + titles.programme + } ${channelNumber}-${stableNumber( + slot + 1, + 4 + )}Synthetic description for slot ${ + slot + 1 + }.Synthetic` + ); + } + } + + lines.push(''); + const body = `${lines.join('\n')}\n`; + return Object.freeze({ + body, + bytes: Buffer.byteLength(body, 'utf8'), + channelCount, + charset, + programmeCount, + sha256: createHash('sha256').update(body, 'utf8').digest('hex'), + }); +} + +function syntheticChannelId(offset: number): string { + return `synthetic.${stableNumber(offset + 1, 6)}`; +} + +function stableNumber(value: number, width: number): string { + return String(value).padStart(width, '0'); +} + +function xmltvTimestamp(epochMs: number): string { + const iso = new Date(epochMs).toISOString(); + return `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}${iso.slice( + 11, + 13 + )}${iso.slice(14, 16)}${iso.slice(17, 19)} +0000`; +} + +function assertSupportedProgrammeCount( + programmeCount: number +): asserts programmeCount is SyntheticXmltvProgrammeCount { + if ( + !SUPPORTED_SYNTHETIC_XMLTV_PROGRAMME_COUNTS.some( + (supported) => supported === programmeCount + ) + ) { + throw new Error( + `Unsupported synthetic XMLTV programme count: ${programmeCount}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/playable-stream-fixture.ts b/apps/electron-backend-e2e/src/playable-stream-fixture.ts new file mode 100644 index 000000000..e161f457c --- /dev/null +++ b/apps/electron-backend-e2e/src/playable-stream-fixture.ts @@ -0,0 +1,159 @@ +import { expect, type Page, type Route } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { xtreamMockServer } from './electron-test-fixtures'; + +/** + * Local, deterministic media for flows that must really play. + * + * A channel, movie or episode is only recorded as recently viewed once its + * stream has advanced for two seconds, so history tests can no longer use + * unreachable URLs or the public HLS streams the portal mocks redirect to. + * These routes answer those requests from the renderer with fixtures that + * the Electron player actually decodes: + * - `.m3u8` → a one-segment HLS playlist over the H.264/AAC MPEG-TS clip; + * - `.ts` → that clip (6 s) directly; + * - anything else (mp4, mkv, avi, webm) → the VP8 WebM clip (30 s), which + * Chromium sniffs from its bytes regardless of the declared type. + */ +const SEGMENT_URL = 'https://playable-stream-fixture.test/segment.ts'; +const transportStream = readFileSync( + join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts') +); +const webmClip = readFileSync( + join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm') +); +const hlsPlaylist = [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:6,', + SEGMENT_URL, + '#EXT-X-ENDLIST', + '', +].join('\n'); + +/** Hosts whose streams tests play: fixtures, portal mocks, mock redirects. */ +const PLAYABLE_STREAM_HOSTS = [ + 'https://streams.example.test', + 'https://playable-stream-fixture.test', + // Stalker mock `create_link` answers and the Xtream mock redirect target. + 'https://test-streams.mux.dev', + 'https://devstreaming-cdn.apple.com', + 'https://playertest.longtailvideo.com', +]; +const XTREAM_MEDIA_PATH = /^\/(live|movie|series)\//; + +export async function routePlayableStreams(page: Page): Promise { + await page.route( + (url) => + PLAYABLE_STREAM_HOSTS.includes(url.origin) || + (url.origin === new URL(xtreamMockServer).origin && + XTREAM_MEDIA_PATH.test(url.pathname)), + (route) => fulfillPlayableStream(route) + ); +} + +/** + * Runs `start` and waits until a media element it brought up has played for + * `seconds` of real stream time — longer than the two seconds after which + * the app records the item. Elements already on the page are ignored, so a + * still-mounted previous player cannot satisfy the wait. + */ +export async function startAndConfirmPlayback( + page: Page, + start: () => Promise, + seconds = 2.5 +): Promise { + await page.evaluate(() => + document + .querySelectorAll('video, audio') + .forEach((media) => media.setAttribute('data-e2e-previous', '')) + ); + await start(); + await expect + .poll( + () => + page.evaluate((minimum) => { + const starts = (( + window as unknown as { + __e2eMediaStarts?: WeakMap; + } + ).__e2eMediaStarts ??= new WeakMap()); + return [ + ...document.querySelectorAll( + 'video:not([data-e2e-previous]), audio:not([data-e2e-previous])' + ), + ].some((media) => { + if (media.paused || media.readyState < 2) { + return false; + } + if (!starts.has(media)) { + starts.set(media, media.currentTime); + } + return ( + media.currentTime - (starts.get(media) ?? 0) >= + minimum + ); + }); + }, seconds), + { timeout: 45_000 } + ) + .toBe(true); +} + +async function fulfillPlayableStream(route: Route): Promise { + const pathname = new URL(route.request().url()).pathname; + if (pathname.endsWith('.m3u8')) { + await route.fulfill({ + status: 200, + contentType: 'application/vnd.apple.mpegurl', + body: hlsPlaylist, + }); + return; + } + if (pathname.endsWith('.ts')) { + await route.fulfill({ + status: 200, + contentType: 'video/mp2t', + body: transportStream, + }); + return; + } + await fulfillRange(route, webmClip, 'video/webm'); +} + +/** + * Chromium's media pipeline reads files through byte ranges; answering a + * Range request with the whole body would clamp every read to the start. + */ +async function fulfillRange( + route: Route, + body: Buffer, + contentType: string +): Promise { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = body.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, body.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + 'content-type': contentType, + ...(range + ? { 'content-range': `bytes ${start}-${end}/${body.length}` } + : {}), + }, + body: body.subarray(start, end + 1), + }); +} diff --git a/apps/electron-backend-e2e/src/player-theme.e2e.ts b/apps/electron-backend-e2e/src/player-theme.e2e.ts index d7df3f324..5933ea7bc 100644 --- a/apps/electron-backend-e2e/src/player-theme.e2e.ts +++ b/apps/electron-backend-e2e/src/player-theme.e2e.ts @@ -139,15 +139,20 @@ for (const engine of [ ) .toBeGreaterThan(0); } else { + // Past the two seconds after which the channel is recorded + // as recently viewed: the HTML5 pass reopens it from there. await expect - .poll(() => - app.mainWindow - .locator('app-web-player-view video') - .evaluate( - (video: HTMLVideoElement) => video.currentTime - ) + .poll( + () => + app.mainWindow + .locator('app-web-player-view video') + .evaluate( + (video: HTMLVideoElement) => + video.currentTime + ), + { timeout: 20000 } ) - .toBeGreaterThan(0); + .toBeGreaterThan(2.5); } const controls = app.mainWindow.locator( engine === 'native' @@ -159,6 +164,10 @@ for (const engine of [ exact: true, }); await expect(pause).toBeEnabled({ timeout: 20000 }); + if (engine !== 'native') { + // Seconds of playback let the controls auto-hide; reveal them. + await app.mainWindow.locator('app-web-player-view').hover(); + } await pause.click(); const play = controls.getByRole('button', { name: 'Play', diff --git a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts index dbf6e0271..024972a30 100644 --- a/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts +++ b/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts @@ -30,6 +30,10 @@ import { writeTemporaryM3uFile, xtreamMockServer, } from './electron-test-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; const xtreamStressUsername = 'stress'; const xtreamStressPassword = 'stress'; @@ -202,6 +206,7 @@ test.describe('Electron Playlist Switcher', () => { }, ]); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, localAPath); @@ -213,9 +218,12 @@ test.describe('Electron Playlist Switcher', () => { .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localAChannelName); - await channelItemByTitle(app.mainWindow, localAChannelName) - .first() - .click(); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, localAChannelName) + .first() + .click() + ); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localBDisplayName) @@ -223,9 +231,12 @@ test.describe('Electron Playlist Switcher', () => { .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localBChannelName); - await channelItemByTitle(app.mainWindow, localBChannelName) - .first() - .click(); + // Recorded as recently viewed once it has really played. + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, localBChannelName) + .first() + .click() + ); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localADisplayName) diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index d419d180e..a82a4f086 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -41,6 +41,10 @@ import { getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; +import { + routePlayableStreams, + startAndConfirmPlayback, +} from './playable-stream-fixture'; test.describe('Electron Recently Viewed', () => { test('keeps unified live detail open when re-clicking the active M3U recent item', async ({ @@ -58,14 +62,17 @@ test.describe('Electron Recently Viewed', () => { ] ); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Stable Recent Channel') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Stable Recent Channel') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); @@ -109,17 +116,22 @@ test.describe('Electron Recently Viewed', () => { }, ]); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Recent Channel One') - .first() - .click(); - await channelItemByTitle(app.mainWindow, 'Recent Channel Two') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Recent Channel One') + .first() + .click() + ); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Recent Channel Two') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await expect @@ -188,17 +200,22 @@ test.describe('Electron Recently Viewed', () => { ] ); const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); - await channelItemByTitle(app.mainWindow, 'Context Recent One') - .first() - .click(); - await channelItemByTitle(app.mainWindow, 'Context Recent Two') - .first() - .click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Context Recent One') + .first() + .click() + ); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, 'Context Recent Two') + .first() + .click() + ); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); @@ -247,6 +264,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Xtream Recent Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -261,7 +279,9 @@ test.describe('Electron Recently Viewed', () => { ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); - await channelItemByTitle(app.mainWindow, liveTitle).first().click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, liveTitle).first().click() + ); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow @@ -277,7 +297,9 @@ test.describe('Electron Recently Viewed', () => { // card, avoiding a race where the grid re-renders between title read and // a separate search-by-title click. const movieTitle = await clickFirstGridListCard(app.mainWindow); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -288,7 +310,9 @@ test.describe('Electron Recently Viewed', () => { seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect( @@ -362,6 +386,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Xtream Recent Detail Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addXtreamPortal(app.mainWindow, { @@ -377,7 +402,9 @@ test.describe('Electron Recently Viewed', () => { vodFixture.categoryName ); const movieTitle = await clickFirstGridListCard(app.mainWindow); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -388,7 +415,9 @@ test.describe('Electron Recently Viewed', () => { seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( @@ -455,6 +484,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Stalker Recent Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addStalkerPortal(app.mainWindow, { @@ -468,7 +498,9 @@ test.describe('Electron Recently Viewed', () => { await clickCategoryById(app.mainWindow, liveFixture.categoryId); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); - await channelItemByTitle(app.mainWindow, liveTitle).first().click(); + await startAndConfirmPlayback(app.mainWindow, () => + channelItemByTitle(app.mainWindow, liveTitle).first().click() + ); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow @@ -476,7 +508,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -484,7 +518,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect( @@ -559,6 +595,7 @@ test.describe('Electron Recently Viewed', () => { ); const portalTitle = 'Stalker Recent Detail Source'; const app = await launchElectronApp(dataDir); + await routePlayableStreams(app.mainWindow); try { await addStalkerPortal(app.mainWindow, { @@ -571,7 +608,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); - await playCurrentDetail(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playCurrentDetail(app.mainWindow) + ); await goBackFromDetail(app.mainWindow); await app.mainWindow @@ -579,7 +618,9 @@ test.describe('Electron Recently Viewed', () => { .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); - await playFirstSeriesEpisode(app.mainWindow); + await startAndConfirmPlayback(app.mainWindow, () => + playFirstSeriesEpisode(app.mainWindow) + ); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 156f0a71b..4e70c10a6 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -699,14 +699,25 @@ test.describe('Electron Settings', () => { await goToDashboard(app.mainWindow); await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + // The URL changes before the settings view is torn down and the + // dashboard renders. Wait for the dashboard itself, or the rail + // check passes vacuously and the "Dashboard" link below also + // matches the settings page's own "Dashboard" section link. + // Attached, not visible: with its only populated rail disabled + // the fixture's dashboard is empty and has no size. + await expect( + app.mainWindow.getByTestId('dashboard-page') + ).toBeAttached({ timeout: 20000 }); + await expect( + app.mainWindow.getByTestId('settings-section-dashboard') + ).toHaveCount(0); await expect( app.mainWindow.getByTestId('dashboard-recent-sources-rail') ).toHaveCount(0); await expect( - app.mainWindow.getByRole('link', { - name: 'Dashboard', - exact: true, - }) + app.mainWindow + .getByRole('link', { name: 'Dashboard', exact: true }) + .and(app.mainWindow.locator('.portal-rail-link')) ).toBeVisible(); } finally { await closeElectronApp(app); diff --git a/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts b/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts new file mode 100644 index 000000000..d06332fba --- /dev/null +++ b/apps/electron-backend/src/app/events/external-player-position-polling.spec.ts @@ -0,0 +1,197 @@ +/** + * Position polling of external players must not outlive the player. Both + * services wait before the first poll so the player can open its control + * channel; a player that exits during that wait must not leave an interval + * polling a dead socket or port for the rest of the app session. + */ +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('child_process', () => ({ + spawn: jest.fn(), +})); + +jest.mock('net', () => ({ + createConnection: jest.fn(), + createServer: jest.fn(), +})); + +jest.mock('../app', () => ({ + __esModule: true, + default: { + mainWindow: null, + }, +})); + +jest.mock('../services/store.service', () => ({ + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_PLAYER_PATH: 'MPV_PLAYER_PATH', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_PLAYER_PATH: 'VLC_PLAYER_PATH', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', + store: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +import { spawn, type ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import { createConnection, createServer } from 'net'; +import type { PlayerContentInfo } from '@iptvnator/shared/interfaces'; +import { + MPV_PLAYER_PATH, + MPV_REUSE_INSTANCE, + VLC_PLAYER_PATH, + VLC_REUSE_INSTANCE, + store, +} from '../services/store.service'; +import { openMpvPlayer } from './mpv-session.service'; +import { openVlcPlayer } from './vlc-session.service'; + +const spawnMock = spawn as unknown as jest.Mock; +const createConnectionMock = createConnection as unknown as jest.Mock; +const contentInfo: PlayerContentInfo = { + playlistId: 'playlist-1', + contentXtreamId: 7, + contentType: 'vod', +}; + +function createMockChildProcess(): ChildProcess { + return Object.assign(new EventEmitter(), { + exitCode: null, + killed: false, + kill: jest.fn(() => true), + signalCode: null, + stderr: null, + stdout: null, + unref: jest.fn(), + }) as unknown as ChildProcess; +} + +function exitProcess(proc: ChildProcess): void { + Object.defineProperty(proc, 'exitCode', { value: 0 }); + proc.emit('exit', 0); +} + +/** A control connection that never answers; each poll just records itself. */ +function installSilentConnection(): void { + createConnectionMock.mockImplementation(() => + Object.assign(new EventEmitter(), { + destroyed: false, + destroy: jest.fn(), + end: jest.fn(), + setEncoding: jest.fn(), + write: jest.fn(() => true), + }) + ); +} + +function mockStoreValues(values: Record): void { + (store.get as unknown as jest.Mock).mockImplementation( + (key: string, fallback?: unknown) => + key in values ? values[key] : fallback + ); +} + +async function openMpv(): Promise { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const opening = openMpvPlayer({ + title: 'Movie', + url: 'https://example.com/movie.mp4', + contentInfo, + }); + await jest.advanceTimersByTimeAsync(100); + await opening; + return proc; +} + +async function openVlc(): Promise { + const proc = createMockChildProcess(); + spawnMock.mockReturnValueOnce(proc); + const opening = openVlcPlayer({ + title: 'Movie', + url: 'https://example.com/movie.mp4', + contentInfo, + }); + while (spawnMock.mock.calls.length === 0) { + await jest.advanceTimersByTimeAsync(0); + } + proc.emit('spawn'); + await opening; + return proc; +} + +describe('external player position polling lifetime', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + jest.spyOn(console, 'error').mockImplementation(); + installSilentConnection(); + (createServer as unknown as jest.Mock).mockImplementation(() => ({ + unref: jest.fn(), + on: jest.fn(), + listen: (_port: number, _host: string, cb: () => void) => cb(), + address: () => ({ port: 43210 }), + close: (cb?: () => void) => cb?.(), + })); + mockStoreValues({ + [MPV_PLAYER_PATH]: '/usr/bin/mpv', + [MPV_REUSE_INSTANCE]: false, + [VLC_PLAYER_PATH]: '/usr/bin/vlc', + [VLC_REUSE_INSTANCE]: false, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('polls MPV once the start delay has passed', async () => { + const proc = await openMpv(); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(7_000); + + expect(createConnectionMock).toHaveBeenCalled(); + exitProcess(proc); + }); + + it('never polls MPV when it exits before the first poll', async () => { + const proc = await openMpv(); + exitProcess(proc); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(createConnectionMock).not.toHaveBeenCalled(); + }); + + it('polls VLC once the start delay has passed', async () => { + const proc = await openVlc(); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(4_000); + + expect(createConnectionMock).toHaveBeenCalledWith( + expect.objectContaining({ port: 43210 }) + ); + exitProcess(proc); + }); + + it('never polls VLC when it exits before the first poll', async () => { + const proc = await openVlc(); + exitProcess(proc); + createConnectionMock.mockClear(); + + await jest.advanceTimersByTimeAsync(30_000); + + expect(createConnectionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/mpv-session.service.ts b/apps/electron-backend/src/app/events/mpv-session.service.ts index b9809afcd..f4d67df9a 100644 --- a/apps/electron-backend/src/app/events/mpv-session.service.ts +++ b/apps/electron-backend/src/app/events/mpv-session.service.ts @@ -49,6 +49,9 @@ export interface OpenExternalPlayerRequest { const reusableMpvProcess = new MpvReusableProcess(); let positionPollingInterval: NodeJS.Timeout | null = null; +// The first poll waits for MPV to open its IPC socket. The handle is kept so +// a player that exits during that wait does not start an orphaned interval. +let positionPollingDelay: NodeJS.Timeout | null = null; function getMpvPath(options: PlayerPathOptions = {}): string { return ( @@ -120,6 +123,10 @@ async function getMpvProperty( } function stopPositionPolling(): void { + if (positionPollingDelay) { + clearTimeout(positionPollingDelay); + positionPollingDelay = null; + } if (positionPollingInterval) { clearInterval(positionPollingInterval); positionPollingInterval = null; @@ -137,7 +144,8 @@ function startPositionPolling( ): void { stopPositionPolling(); - setTimeout(() => { + positionPollingDelay = setTimeout(() => { + positionPollingDelay = null; positionPollingInterval = setInterval(async () => { try { const position = await getMpvProperty(socketPath, 'time-pos'); diff --git a/apps/electron-backend/src/app/events/vlc-session.service.ts b/apps/electron-backend/src/app/events/vlc-session.service.ts index 7ca52da0f..4c24dbb65 100644 --- a/apps/electron-backend/src/app/events/vlc-session.service.ts +++ b/apps/electron-backend/src/app/events/vlc-session.service.ts @@ -54,6 +54,9 @@ export interface OpenVlcPlayerRequest { const reusableVlcProcess = new VlcReusableProcess(); let vlcPollingInterval: NodeJS.Timeout | null = null; +// Kept so a player that exits before the first poll cannot leave an orphaned +// interval behind. +let vlcPollingDelay: NodeJS.Timeout | null = null; function getVlcPath(options: PlayerPathOptions = {}): string { return ( @@ -63,6 +66,10 @@ function getVlcPath(options: PlayerPathOptions = {}): string { } function stopVlcPositionPolling(): void { + if (vlcPollingDelay) { + clearTimeout(vlcPollingDelay); + vlcPollingDelay = null; + } if (vlcPollingInterval) { clearInterval(vlcPollingInterval); vlcPollingInterval = null; @@ -78,7 +85,8 @@ function startVlcPositionPolling( ): void { stopVlcPositionPolling(); - setTimeout(() => { + vlcPollingDelay = setTimeout(() => { + vlcPollingDelay = null; vlcPollingInterval = setInterval(async () => { try { const snapshot = await getVlcPlaybackSnapshot(port); diff --git a/apps/web-e2e/playwright.config.ts b/apps/web-e2e/playwright.config.ts index 0aa4c6a5d..2531cd588 100644 --- a/apps/web-e2e/playwright.config.ts +++ b/apps/web-e2e/playwright.config.ts @@ -66,14 +66,25 @@ export default defineConfig({ reuseExistingServer: reuseExistingWebServer, cwd: workspaceRoot, }, + /* The mocks run as one node process, not through `nx run …:serve`: + * Nx starts its command in a detached process group, so Playwright's + * process-group kill missed it and the server kept its port. */ { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, }, { - command: 'pnpm nx run xtream-mock-server:serve', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, diff --git a/apps/web-e2e/src/dash-clearkey.e2e.ts b/apps/web-e2e/src/dash-clearkey.e2e.ts index fcb0b5f06..1f2efc953 100644 --- a/apps/web-e2e/src/dash-clearkey.e2e.ts +++ b/apps/web-e2e/src/dash-clearkey.e2e.ts @@ -189,6 +189,19 @@ test('@web @m3u @dash ClearKey reopens from recent and favorites collections', a await importDashPlaylist(page); await page.getByText('1. ClearKey DASH').click(); await expectVideoPlaying(page); + // Recent history records the channel once it has played two seconds. + await expect + .poll( + () => + page + .locator('app-web-player-view video') + .first() + .evaluate( + (element: HTMLVideoElement) => element.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(2.5); const playlistUrl = page.url().replace(/\/all$/, ''); const channel = page.locator('.channel-list-item').filter({ hasText: '1. ClearKey DASH', diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index be2a49cd9..b14be957d 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -431,7 +431,7 @@ for (const theme of ['light', 'dark']) { const channels = Array.from( { length: 60 }, (_, index) => - `#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.m3u8` + `#EXTINF:-1 group-title="News",Station ${index + 1}\n${FIXTURE_HOST}/live-${index}.webm` ); await importPlaylist(page, ['#EXTM3U', ...channels].join('\n'), 60); await page.evaluate( @@ -462,6 +462,19 @@ for (const theme of ['light', 'dark']) { await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass( /active/ ); + // Recently viewed lists the station once it has played two seconds. + await expect + .poll( + () => + page + .locator('app-web-player-view video') + .first() + .evaluate( + (video: HTMLVideoElement) => video.currentTime + ), + { timeout: 20_000 } + ) + .toBeGreaterThan(2.5); await pressTab(page, browserName); const favorite = viewport.locator('.favorite-button').nth(1); await expect(favorite).toBeFocused(); diff --git a/apps/web-e2e/src/player-settings-panel.e2e.ts b/apps/web-e2e/src/player-settings-panel.e2e.ts new file mode 100644 index 000000000..0f399a397 --- /dev/null +++ b/apps/web-e2e/src/player-settings-panel.e2e.ts @@ -0,0 +1,217 @@ +import type { Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from './fixtures'; + +/** + * The shared controls' settings panel: on a wide player the speed chip opens + * the panel beside the video and a choice applies in place; on a compact + * player the tune button opens a bottom sheet that a tap on the video + * dismisses. Runs against the built-in HTML5 player and a local clip so no + * remote media is involved. + */ + +const FIXTURE_HOST = 'https://player-settings-fixture.local'; +const PLAYLIST = [ + '#EXTM3U', + '#EXTINF:-1 group-title="Movies",Settings Clip', + `${FIXTURE_HOST}/clip.mp4`, +].join('\n'); + +test.use({ serviceWorkers: 'block' }); + +async function serveClip(page: Page): Promise { + const clip = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + await page.route(`${FIXTURE_HOST}/**`, async (route) => { + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = clip.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, clip.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] ? Math.min(Number(range[2]), last) : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'content-type': 'video/webm', + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${clip.length}`, + } + : {}), + }, + body: clip.subarray(start, end + 1), + }); + }); +} + +async function selectHtml5Player(page: Page): Promise { + await page.goto('/workspace/settings/playback'); + const select = page.locator('[data-test-id="select-video-player"]'); + await expect(select).toBeVisible(); + const previous = await select.innerText(); + await select.click(); + await page + .getByRole('option', { name: 'HTML5 video player', exact: true }) + .click(); + if (!previous.includes('HTML5 video player')) { + const saveButton = page.getByRole('button', { name: 'Save changes' }); + await saveButton.click(); + await expect(saveButton).toBeHidden(); + } +} + +async function importPlaylist(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Raw m3u text/i }).click(); + await dialog.getByLabel('Insert m3u(8) playlist as text').fill(PLAYLIST); + await Promise.all([ + page.waitForURL(/\/workspace\/playlists\/.+\/all$/), + dialog.getByRole('button', { name: 'Import', exact: true }).click(), + ]); + await expect(page.getByText('1 channels')).toBeVisible(); +} + +async function startClip(page: Page) { + await page + .locator('[data-test-id="channel-item"]') + .filter({ hasText: 'Settings Clip' }) + .click(); + const view = page.locator('app-web-player-view'); + const video = view.locator('video'); + await expect + .poll(() => + video.evaluate( + (el: HTMLVideoElement) => + Number.isFinite(el.duration) && el.duration > 5 + ) + ) + .toBe(true); + // Reveal the dock and keep it revealed for the assertions below. + await view.hover(); + const controls = view.locator('app-player-controls'); + await controls.getByRole('button', { name: 'Pause', exact: true }).click(); + return { view, video, controls }; +} + +test('@web @playback settings panel opens from the speed chip and applies in place', async ({ + page, +}) => { + test.setTimeout(90_000); + // Chips and the side panel need a player of at least 960px. + await page.setViewportSize({ width: 1600, height: 1000 }); + await serveClip(page); + await selectHtml5Player(page); + await importPlaylist(page); + const { video, controls } = await startClip(page); + + const speedChip = controls.locator( + '[data-test-id="player-controls-speed-chip"]' + ); + await expect(speedChip).toContainText('1×'); + await expect( + controls.locator('[data-test-id="player-controls-settings-panel"]') + ).toHaveCount(0); + + await speedChip.click(); + const panel = controls.getByRole('dialog', { name: 'Settings' }); + await expect(panel).toBeVisible(); + await expect(panel).not.toHaveClass(/player-controls__settings--sheet/); + await expect(controls.locator('.player-controls__bar')).toHaveClass( + /player-controls__bar--panel-open/ + ); + // Chips fold away while the panel is open; the tune button lights up. + await expect(speedChip).toHaveCount(0); + await expect( + controls.locator('[data-test-id="player-controls-settings-button"]') + ).toHaveAttribute('aria-expanded', 'true'); + + await panel.getByRole('radio', { name: '1.5×', exact: true }).click(); + await expect + .poll(() => video.evaluate((el: HTMLVideoElement) => el.playbackRate)) + .toBe(1.5); + // The choice keeps the panel open for comparison… + await expect(panel).toBeVisible(); + + // …and Escape closes it; the chip now reads the modified value. + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(controls.locator('.player-controls__bar')).not.toHaveClass( + /player-controls__bar--panel-open/ + ); + await expect( + controls.locator('[data-test-id="player-controls-speed-chip"]') + ).toContainText('1.5×'); + await expect( + controls.locator('[data-test-id="player-controls-speed-chip"]') + ).toHaveClass(/player-controls__chip--modified/); +}); + +test('@web @playback compact player folds the chips into a tune button with a bottom sheet', async ({ + page, +}) => { + test.setTimeout(90_000); + await page.setViewportSize({ width: 900, height: 700 }); + await serveClip(page); + await selectHtml5Player(page); + await importPlaylist(page); + const { view, controls } = await startClip(page); + + await expect(controls.locator('.player-controls__bar')).toHaveClass( + /player-controls__bar--compact/ + ); + await expect( + controls.locator('[data-test-id="player-controls-speed-chip"]') + ).toHaveCount(0); + + const tune = controls.locator( + '[data-test-id="player-controls-settings-button"]' + ); + await tune.click(); + const sheet = controls.getByRole('dialog', { name: 'Settings' }); + await expect(sheet).toBeVisible(); + await expect(sheet).toHaveClass(/player-controls__settings--sheet/); + await expect(controls.locator('.player-controls__bar')).toHaveClass( + /player-controls__bar--sheet-open/ + ); + + await sheet.getByRole('radio', { name: '2×', exact: true }).click(); + await expect + .poll(() => + view + .locator('video') + .evaluate((el: HTMLVideoElement) => el.playbackRate) + ) + .toBe(2); + // A modified value shows as a violet dot once the sheet is closed. + await sheet.locator('[data-test-id="player-settings-close"]').click(); + await expect(sheet).toHaveCount(0); + await expect( + controls + .locator('[data-test-id="player-controls-settings-dots"]') + .locator('.player-controls__tune-dot--violet') + ).toHaveCount(1); + + // Reopen and dismiss by tapping the video: the tap closes the sheet + // instead of toggling playback. + await tune.click(); + await expect(sheet).toBeVisible(); + await view + .locator('video') + .click({ position: { x: 40, y: 40 }, force: true }); + await expect(sheet).toHaveCount(0); + await expect( + controls.getByRole('button', { name: 'Play', exact: true }) + ).toBeVisible(); +}); diff --git a/apps/web-e2e/src/xtream-series-playback.e2e.ts b/apps/web-e2e/src/xtream-series-playback.e2e.ts index ad1d0d432..76c610080 100644 --- a/apps/web-e2e/src/xtream-series-playback.e2e.ts +++ b/apps/web-e2e/src/xtream-series-playback.e2e.ts @@ -123,6 +123,54 @@ test.describe('@xtream inline series fullscreen', () => { expect(await fullscreenOwner()).not.toBeNull(); }); + test('shows the Up next card near the end and plays the next episode from it', async ({ + page, + request, + }) => { + await routeEpisodeClip(page); + await rewriteSeriesEpisodesToMp4(page); + await selectWebPlayer(page, 'HTML5 video player'); + const { playerView, video } = await playFirstSeriesEpisode( + page, + request + ); + await expect(playerView.locator('app-html-video-player')).toBeVisible({ + timeout: 15_000, + }); + await expect + .poll(() => + video.evaluate((el) => (el as HTMLVideoElement).readyState) + ) + .toBeGreaterThanOrEqual(1); + + // The 30 s fixture clip is inside the card's threshold from its + // first frame, so the card is up as soon as the duration is known. + const card = playerView.locator( + '[data-test-id="player-controls-up-next"]' + ); + await expect(card).toBeVisible(); + await expect(card).toContainText('Up next'); + await expect(card).toHaveAttribute('aria-label', /S01E02/); + + // The settings panel takes the same corner: the card yields to it. + await playerView.hover(); + await playerView + .locator('[data-test-id="player-controls-settings-button"]') + .click(); + await expect(card).toHaveCount(0); + await page.keyboard.press('Escape'); + await expect(card).toBeVisible(); + + // A click plays the next episode through the host's own path. + await card.click(); + await expect( + page.locator('app-portal-inline-player .player-shell__episode-meta') + ).toContainText('S01E02', { timeout: 15_000 }); + await expect(card).toHaveAttribute('aria-label', /S01E03/, { + timeout: 15_000, + }); + }); + test('switches episodes from the fullscreen episode panel without leaving fullscreen', async ({ page, request, diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 0877219e8..fff519f94 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -649,6 +649,10 @@ "SEARCH_IN": "البحث في {{title}}", "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SETTINGS": "الإعدادات", + "UP_NEXT": "التالي", + "UP_NEXT_IN": "خلال {{minutes}} دقيقة", + "CLOSE_SETTINGS": "إغلاق الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا", "SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا", @@ -1767,12 +1771,11 @@ "TITLE": "لوحة الأوامر", "SUBTITLE": "إجراءات عامة وسياقية", "SEARCH_PLACEHOLDER": "البحث في الأوامر والإعدادات", - "SEARCH_LABEL": "تصفية الأوامر", - "EMPTY": "لم يتم العثور على أوامر", + "SEARCH_LABEL": "البحث في الأوامر والإعدادات", + "EMPTY": "لم يتم العثور على أوامر أو إعدادات", "GROUP_GLOBAL": "الإجراءات العامة", "GROUP_PLAYLIST": "قائمة التشغيل هذه", "GROUP_VIEW": "العرض الحالي", - "GROUP_SECTION": "هذا القسم", "GROUP_RECENT": "المستخدمة مؤخرًا", "GROUP_SETTINGS": "الإعدادات" }, diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9cbc82ade..567d33401 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -649,6 +649,10 @@ "SEARCH_IN": "قلّب ف {{title}}", "CLEAR_SEARCH": "مسح البحث", "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SETTINGS": "الإعدادات", + "UP_NEXT": "اللي جاي", + "UP_NEXT_IN": "فـ {{minutes}} دقيقة", + "CLOSE_SETTINGS": "سد الإعدادات", "SUBTITLE_DELAY": "تأخير الترجمة", "SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل", "SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد", @@ -1767,12 +1771,11 @@ "TITLE": "لوحة الأوامر", "SUBTITLE": "إجراءات عامة وسياقية", "SEARCH_PLACEHOLDER": "قلب على الأوامر والإعدادات", - "SEARCH_LABEL": "فلتر الأوامر", - "EMPTY": "ما تلقاتش حتى أمر", + "SEARCH_LABEL": "قلب على الأوامر والإعدادات", + "EMPTY": "ما تلقاتش حتى أمر ولا إعداد", "GROUP_GLOBAL": "إجراءات عامة", "GROUP_PLAYLIST": "هاد قائمة التشغيل", "GROUP_VIEW": "العرض الحالي", - "GROUP_SECTION": "هاد القسم", "GROUP_RECENT": "مستعملة مؤخراً", "GROUP_SETTINGS": "الإعدادات" }, diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 0dbe5a074..ef639b363 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Пошук у {{title}}", "CLEAR_SEARCH": "Ачысціць пошук", "LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…", + "SETTINGS": "Налады", + "UP_NEXT": "Далей", + "UP_NEXT_IN": "праз {{minutes}} хв", + "CLOSE_SETTINGS": "Закрыць налады", "SUBTITLE_DELAY": "Затрымка субтытраў", "SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней", "SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней", @@ -1767,12 +1771,11 @@ "TITLE": "Палітра каманд", "SUBTITLE": "Глабальныя і кантэкстныя дзеянні", "SEARCH_PLACEHOLDER": "Шукаць каманды і налады", - "SEARCH_LABEL": "Фільтр каманд", - "EMPTY": "Каманд не знойдзена", + "SEARCH_LABEL": "Шукаць каманды і налады", + "EMPTY": "Каманд і налад не знойдзена", "GROUP_GLOBAL": "Глабальныя дзеянні", "GROUP_PLAYLIST": "Гэты плэйліст", "GROUP_VIEW": "Бягучы выгляд", - "GROUP_SECTION": "Гэты раздзел", "GROUP_RECENT": "Нядаўна выкарыстаныя", "GROUP_SETTINGS": "Налады" }, diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 86a474657..0a0909a99 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -649,6 +649,10 @@ "SEARCH_IN": "In {{title}} suchen", "CLEAR_SEARCH": "Suche löschen", "LOAD_SUBTITLE_FILE": "Untertiteldatei laden…", + "SETTINGS": "Einstellungen", + "UP_NEXT": "Als Nächstes", + "UP_NEXT_IN": "in {{minutes}} Min.", + "CLOSE_SETTINGS": "Einstellungen schließen", "SUBTITLE_DELAY": "Untertitelverzögerung", "SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen", "SUBTITLE_DELAY_INCREASE": "Untertitel später anzeigen", @@ -1767,12 +1771,11 @@ "TITLE": "Befehlspalette", "SUBTITLE": "Globale und kontextbezogene Aktionen", "SEARCH_PLACEHOLDER": "Befehle und Einstellungen durchsuchen", - "SEARCH_LABEL": "Befehle filtern", - "EMPTY": "Keine Befehle gefunden", + "SEARCH_LABEL": "Befehle und Einstellungen durchsuchen", + "EMPTY": "Keine Befehle oder Einstellungen gefunden", "GROUP_GLOBAL": "Globale Aktionen", "GROUP_PLAYLIST": "Diese Playlist", "GROUP_VIEW": "Aktuelle Ansicht", - "GROUP_SECTION": "Dieser Abschnitt", "GROUP_RECENT": "Kürzlich verwendet", "GROUP_SETTINGS": "Einstellungen" }, diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 546239853..020bb8240 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Αναζήτηση σε {{title}}", "CLEAR_SEARCH": "Καθαρισμός αναζήτησης", "LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…", + "SETTINGS": "Ρυθμίσεις", + "UP_NEXT": "Επόμενο", + "UP_NEXT_IN": "σε {{minutes}} λεπτά", + "CLOSE_SETTINGS": "Κλείσιμο ρυθμίσεων", "SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων", "SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα", "SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα", @@ -1767,12 +1771,11 @@ "TITLE": "Παλέτα εντολών", "SUBTITLE": "Καθολικές και συμφραζόμενες ενέργειες", "SEARCH_PLACEHOLDER": "Αναζήτηση εντολών και ρυθμίσεων", - "SEARCH_LABEL": "Φιλτράρισμα εντολών", - "EMPTY": "Δεν βρέθηκαν εντολές", + "SEARCH_LABEL": "Αναζήτηση εντολών και ρυθμίσεων", + "EMPTY": "Δεν βρέθηκαν εντολές ή ρυθμίσεις", "GROUP_GLOBAL": "Καθολικές ενέργειες", "GROUP_PLAYLIST": "Αυτή η λίστα αναπαραγωγής", "GROUP_VIEW": "Τρέχουσα προβολή", - "GROUP_SECTION": "Αυτή η ενότητα", "GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες", "GROUP_SETTINGS": "Ρυθμίσεις" }, diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 47985a3f1..85e0c5b1c 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Search in {{title}}", "CLEAR_SEARCH": "Clear search", "LOAD_SUBTITLE_FILE": "Load subtitle file…", + "SETTINGS": "Settings", + "UP_NEXT": "Up next", + "UP_NEXT_IN": "in {{minutes}} min", + "CLOSE_SETTINGS": "Close settings", "SUBTITLE_DELAY": "Subtitle delay", "SUBTITLE_DELAY_DECREASE": "Show subtitles earlier", "SUBTITLE_DELAY_INCREASE": "Show subtitles later", @@ -1767,12 +1771,11 @@ "TITLE": "Command palette", "SUBTITLE": "Global and contextual actions", "SEARCH_PLACEHOLDER": "Search commands and settings", - "SEARCH_LABEL": "Filter commands", - "EMPTY": "No commands found", + "SEARCH_LABEL": "Search commands and settings", + "EMPTY": "No commands or settings found", "GROUP_GLOBAL": "Global actions", "GROUP_PLAYLIST": "This playlist", "GROUP_VIEW": "Current view", - "GROUP_SECTION": "This section", "GROUP_RECENT": "Recently used", "GROUP_SETTINGS": "Settings" }, diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index ad7cdd9d5..89a8e80ca 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Buscar en {{title}}", "CLEAR_SEARCH": "Borrar búsqueda", "LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…", + "SETTINGS": "Ajustes", + "UP_NEXT": "A continuación", + "UP_NEXT_IN": "en {{minutes}} min", + "CLOSE_SETTINGS": "Cerrar ajustes", "SUBTITLE_DELAY": "Retardo de subtítulos", "SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes", "SUBTITLE_DELAY_INCREASE": "Mostrar subtítulos después", @@ -1767,12 +1771,11 @@ "TITLE": "Paleta de comandos", "SUBTITLE": "Acciones globales y contextuales", "SEARCH_PLACEHOLDER": "Buscar comandos y ajustes", - "SEARCH_LABEL": "Filtrar comandos", - "EMPTY": "No se encontraron comandos", + "SEARCH_LABEL": "Buscar comandos y ajustes", + "EMPTY": "No se encontraron comandos ni ajustes", "GROUP_GLOBAL": "Acciones globales", "GROUP_PLAYLIST": "Esta lista de reproducción", "GROUP_VIEW": "Vista actual", - "GROUP_SECTION": "Esta sección", "GROUP_RECENT": "Usados recientemente", "GROUP_SETTINGS": "Ajustes" }, diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d0f14997f..4fe6fde97 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Rechercher dans {{title}}", "CLEAR_SEARCH": "Effacer la recherche", "LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…", + "SETTINGS": "Réglages", + "UP_NEXT": "À suivre", + "UP_NEXT_IN": "dans {{minutes}} min", + "CLOSE_SETTINGS": "Fermer les réglages", "SUBTITLE_DELAY": "Décalage des sous-titres", "SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt", "SUBTITLE_DELAY_INCREASE": "Afficher les sous-titres plus tard", @@ -1767,12 +1771,11 @@ "TITLE": "Palette de commandes", "SUBTITLE": "Actions globales et contextuelles", "SEARCH_PLACEHOLDER": "Rechercher des commandes et des paramètres", - "SEARCH_LABEL": "Filtrer les commandes", - "EMPTY": "Aucune commande trouvée", + "SEARCH_LABEL": "Rechercher des commandes et des paramètres", + "EMPTY": "Aucune commande ni aucun paramètre trouvé", "GROUP_GLOBAL": "Actions globales", "GROUP_PLAYLIST": "Cette liste de lecture", "GROUP_VIEW": "Vue actuelle", - "GROUP_SECTION": "Cette section", "GROUP_RECENT": "Récemment utilisées", "GROUP_SETTINGS": "Paramètres" }, diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 8d695043c..4a2ef1e3c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Keresés itt: {{title}}", "CLEAR_SEARCH": "Keresés törlése", "LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…", + "SETTINGS": "Beállítások", + "UP_NEXT": "Következő", + "UP_NEXT_IN": "{{minutes}} perc múlva", + "CLOSE_SETTINGS": "Beállítások bezárása", "SUBTITLE_DELAY": "Felirat késleltetése", "SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban", "SUBTITLE_DELAY_INCREASE": "Feliratok megjelenítése később", @@ -1767,12 +1771,11 @@ "TITLE": "Parancspaletta", "SUBTITLE": "Globális és környezetfüggő műveletek", "SEARCH_PLACEHOLDER": "Parancsok és beállítások keresése", - "SEARCH_LABEL": "Parancsok szűrése", - "EMPTY": "Nincs találat a parancsok között", + "SEARCH_LABEL": "Parancsok és beállítások keresése", + "EMPTY": "Nincs találat a parancsok és beállítások között", "GROUP_GLOBAL": "Globális műveletek", "GROUP_PLAYLIST": "Ez a lejátszási lista", "GROUP_VIEW": "Aktuális nézet", - "GROUP_SECTION": "Aktuális szakasz", "GROUP_RECENT": "Legutóbb használt", "GROUP_SETTINGS": "Beállítások" }, diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index dcc928ee9..cbc932b07 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Cerca in {{title}}", "CLEAR_SEARCH": "Cancella ricerca", "LOAD_SUBTITLE_FILE": "Carica file sottotitoli…", + "SETTINGS": "Impostazioni", + "UP_NEXT": "Prossimo", + "UP_NEXT_IN": "tra {{minutes}} min", + "CLOSE_SETTINGS": "Chiudi impostazioni", "SUBTITLE_DELAY": "Ritardo sottotitoli", "SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima", "SUBTITLE_DELAY_INCREASE": "Mostra i sottotitoli dopo", @@ -1767,12 +1771,11 @@ "TITLE": "Tavolozza comandi", "SUBTITLE": "Azioni globali e contestuali", "SEARCH_PLACEHOLDER": "Cerca comandi e impostazioni", - "SEARCH_LABEL": "Filtra comandi", - "EMPTY": "Nessun comando trovato", + "SEARCH_LABEL": "Cerca comandi e impostazioni", + "EMPTY": "Nessun comando o impostazione trovati", "GROUP_GLOBAL": "Azioni globali", "GROUP_PLAYLIST": "Questa playlist", "GROUP_VIEW": "Vista corrente", - "GROUP_SECTION": "Questa sezione", "GROUP_RECENT": "Usati di recente", "GROUP_SETTINGS": "Impostazioni" }, diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 769c1b6f2..9535e8a7c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -649,6 +649,10 @@ "SEARCH_IN": "{{title}} 内を検索", "CLEAR_SEARCH": "検索をクリア", "LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…", + "SETTINGS": "設定", + "UP_NEXT": "次のエピソード", + "UP_NEXT_IN": "あと{{minutes}}分", + "CLOSE_SETTINGS": "設定を閉じる", "SUBTITLE_DELAY": "字幕の遅延", "SUBTITLE_DELAY_DECREASE": "字幕を早く表示", "SUBTITLE_DELAY_INCREASE": "字幕を遅く表示", @@ -1767,12 +1771,11 @@ "TITLE": "コマンドパレット", "SUBTITLE": "グローバルおよびコンテキストアクション", "SEARCH_PLACEHOLDER": "コマンドと設定を検索", - "SEARCH_LABEL": "コマンドを絞り込む", - "EMPTY": "コマンドが見つかりません", + "SEARCH_LABEL": "コマンドと設定を検索", + "EMPTY": "コマンドや設定が見つかりません", "GROUP_GLOBAL": "グローバルアクション", "GROUP_PLAYLIST": "このプレイリスト", "GROUP_VIEW": "現在のビュー", - "GROUP_SECTION": "現在のセクション", "GROUP_RECENT": "最近使用", "GROUP_SETTINGS": "設定" }, diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 6d6f364ed..25cfb076c 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -649,6 +649,10 @@ "SEARCH_IN": "{{title}}에서 검색", "CLEAR_SEARCH": "검색 지우기", "LOAD_SUBTITLE_FILE": "자막 파일 불러오기…", + "SETTINGS": "설정", + "UP_NEXT": "다음 에피소드", + "UP_NEXT_IN": "{{minutes}}분 후", + "CLOSE_SETTINGS": "설정 닫기", "SUBTITLE_DELAY": "자막 지연", "SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시", "SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시", @@ -1767,12 +1771,11 @@ "TITLE": "명령어 팔레트", "SUBTITLE": "전역 및 상황별 작업", "SEARCH_PLACEHOLDER": "명령어와 설정 검색", - "SEARCH_LABEL": "명령어 필터", - "EMPTY": "명령어를 찾을 수 없습니다", + "SEARCH_LABEL": "명령어와 설정 검색", + "EMPTY": "명령어나 설정을 찾을 수 없습니다", "GROUP_GLOBAL": "전역 작업", "GROUP_PLAYLIST": "이 재생목록", "GROUP_VIEW": "현재 화면", - "GROUP_SECTION": "현재 섹션", "GROUP_RECENT": "최근 사용", "GROUP_SETTINGS": "설정" }, diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 9750d29b1..cbaf49f3d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Zoeken in {{title}}", "CLEAR_SEARCH": "Zoekopdracht wissen", "LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…", + "SETTINGS": "Instellingen", + "UP_NEXT": "Hierna", + "UP_NEXT_IN": "over {{minutes}} min", + "CLOSE_SETTINGS": "Instellingen sluiten", "SUBTITLE_DELAY": "Ondertitelvertraging", "SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen", "SUBTITLE_DELAY_INCREASE": "Ondertitels later tonen", @@ -1767,12 +1771,11 @@ "TITLE": "Opdrachtenpalet", "SUBTITLE": "Algemene en contextuele acties", "SEARCH_PLACEHOLDER": "Zoek opdrachten en instellingen", - "SEARCH_LABEL": "Filter opdrachten", - "EMPTY": "Geen opdrachten gevonden", + "SEARCH_LABEL": "Zoek opdrachten en instellingen", + "EMPTY": "Geen opdrachten of instellingen gevonden", "GROUP_GLOBAL": "Algemene acties", "GROUP_PLAYLIST": "Deze afspeellijst", "GROUP_VIEW": "Huidige weergave", - "GROUP_SECTION": "Deze sectie", "GROUP_RECENT": "Recent gebruikt", "GROUP_SETTINGS": "Instellingen" }, diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index bb0aab0a8..b66656214 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Szukaj w {{title}}", "CLEAR_SEARCH": "Wyczyść wyszukiwanie", "LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…", + "SETTINGS": "Ustawienia", + "UP_NEXT": "Następny", + "UP_NEXT_IN": "za {{minutes}} min", + "CLOSE_SETTINGS": "Zamknij ustawienia", "SUBTITLE_DELAY": "Opóźnienie napisów", "SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej", "SUBTITLE_DELAY_INCREASE": "Pokaż napisy później", @@ -1767,12 +1771,11 @@ "TITLE": "Paleta poleceń", "SUBTITLE": "Akcje globalne i kontekstowe", "SEARCH_PLACEHOLDER": "Szukaj poleceń i ustawień", - "SEARCH_LABEL": "Filtruj polecenia", - "EMPTY": "Nie znaleziono poleceń", + "SEARCH_LABEL": "Szukaj poleceń i ustawień", + "EMPTY": "Nie znaleziono poleceń ani ustawień", "GROUP_GLOBAL": "Akcje globalne", "GROUP_PLAYLIST": "Ta lista odtwarzania", "GROUP_VIEW": "Bieżący widok", - "GROUP_SECTION": "Ta sekcja", "GROUP_RECENT": "Ostatnio używane", "GROUP_SETTINGS": "Ustawienia" }, diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 919c838f9..651645746 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Pesquisar em {{title}}", "CLEAR_SEARCH": "Limpar pesquisa", "LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…", + "SETTINGS": "Configurações", + "UP_NEXT": "A seguir", + "UP_NEXT_IN": "em {{minutes}} min", + "CLOSE_SETTINGS": "Fechar configurações", "SUBTITLE_DELAY": "Atraso das legendas", "SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo", "SUBTITLE_DELAY_INCREASE": "Mostrar legendas mais tarde", @@ -1767,12 +1771,11 @@ "TITLE": "Paleta de comandos", "SUBTITLE": "Ações globais e contextuais", "SEARCH_PLACEHOLDER": "Pesquisar comandos e configurações", - "SEARCH_LABEL": "Filtrar comandos", - "EMPTY": "Nenhum comando encontrado", + "SEARCH_LABEL": "Pesquisar comandos e configurações", + "EMPTY": "Nenhum comando ou configuração encontrado", "GROUP_GLOBAL": "Ações globais", "GROUP_PLAYLIST": "Esta playlist", "GROUP_VIEW": "Visualização atual", - "GROUP_SECTION": "Esta seção", "GROUP_RECENT": "Usados recentemente", "GROUP_SETTINGS": "Configurações" }, diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index cc40bc0fa..d34cb2520 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -649,6 +649,10 @@ "SEARCH_IN": "Поиск в {{title}}", "CLEAR_SEARCH": "Очистить поиск", "LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…", + "SETTINGS": "Настройки", + "UP_NEXT": "Далее", + "UP_NEXT_IN": "через {{minutes}} мин", + "CLOSE_SETTINGS": "Закрыть настройки", "SUBTITLE_DELAY": "Задержка субтитров", "SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше", "SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже", @@ -1767,12 +1771,11 @@ "TITLE": "Палитра команд", "SUBTITLE": "Глобальные и контекстные действия", "SEARCH_PLACEHOLDER": "Искать команды и настройки", - "SEARCH_LABEL": "Фильтровать команды", - "EMPTY": "Команды не найдены", + "SEARCH_LABEL": "Искать команды и настройки", + "EMPTY": "Команды и настройки не найдены", "GROUP_GLOBAL": "Глобальные действия", "GROUP_PLAYLIST": "Этот плейлист", "GROUP_VIEW": "Текущее представление", - "GROUP_SECTION": "Этот раздел", "GROUP_RECENT": "Недавно использованные", "GROUP_SETTINGS": "Настройки" }, diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 845e11f02..79b4d4fae 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -649,6 +649,10 @@ "SEARCH_IN": "{{title}} içinde ara", "CLEAR_SEARCH": "Aramayı temizle", "LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…", + "SETTINGS": "Ayarlar", + "UP_NEXT": "Sıradaki", + "UP_NEXT_IN": "{{minutes}} dk içinde", + "CLOSE_SETTINGS": "Ayarları kapat", "SUBTITLE_DELAY": "Altyazı gecikmesi", "SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster", "SUBTITLE_DELAY_INCREASE": "Altyazıları daha geç göster", @@ -1767,12 +1771,11 @@ "TITLE": "Komut paleti", "SUBTITLE": "Genel ve bağlamsal işlemler", "SEARCH_PLACEHOLDER": "Komutlarda ve ayarlarda ara", - "SEARCH_LABEL": "Komutları filtrele", - "EMPTY": "Komut bulunamadı", + "SEARCH_LABEL": "Komutlarda ve ayarlarda ara", + "EMPTY": "Komut veya ayar bulunamadı", "GROUP_GLOBAL": "Genel işlemler", "GROUP_PLAYLIST": "Bu oynatma listesi", "GROUP_VIEW": "Geçerli görünüm", - "GROUP_SECTION": "Bu bölüm", "GROUP_RECENT": "Son kullanılanlar", "GROUP_SETTINGS": "Ayarlar" }, diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index eca04c505..5a37e0824 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -649,6 +649,10 @@ "SEARCH_IN": "在 {{title}} 中搜索", "CLEAR_SEARCH": "清除搜索", "LOAD_SUBTITLE_FILE": "加载字幕文件…", + "SETTINGS": "设置", + "UP_NEXT": "接下来", + "UP_NEXT_IN": "{{minutes}} 分钟后", + "CLOSE_SETTINGS": "关闭设置", "SUBTITLE_DELAY": "字幕延迟", "SUBTITLE_DELAY_DECREASE": "提前显示字幕", "SUBTITLE_DELAY_INCREASE": "延后显示字幕", @@ -1767,12 +1771,11 @@ "TITLE": "命令面板", "SUBTITLE": "全局和上下文操作", "SEARCH_PLACEHOLDER": "搜索命令和设置", - "SEARCH_LABEL": "筛选命令", - "EMPTY": "未找到命令", + "SEARCH_LABEL": "搜索命令和设置", + "EMPTY": "未找到命令或设置", "GROUP_GLOBAL": "全局操作", "GROUP_PLAYLIST": "本播放列表", "GROUP_VIEW": "当前视图", - "GROUP_SECTION": "当前区域", "GROUP_RECENT": "最近使用", "GROUP_SETTINGS": "设置" }, diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 1e8f34b9d..080a98ff4 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -649,6 +649,10 @@ "SEARCH_IN": "在 {{title}} 中搜尋", "CLEAR_SEARCH": "清除搜尋", "LOAD_SUBTITLE_FILE": "載入字幕檔案…", + "SETTINGS": "設定", + "UP_NEXT": "接下來", + "UP_NEXT_IN": "{{minutes}} 分鐘後", + "CLOSE_SETTINGS": "關閉設定", "SUBTITLE_DELAY": "字幕延遲", "SUBTITLE_DELAY_DECREASE": "提前顯示字幕", "SUBTITLE_DELAY_INCREASE": "延後顯示字幕", @@ -1767,12 +1771,11 @@ "TITLE": "命令選擇區", "SUBTITLE": "全域與情境化操作", "SEARCH_PLACEHOLDER": "搜尋命令和設定", - "SEARCH_LABEL": "篩選命令", - "EMPTY": "找不到命令", + "SEARCH_LABEL": "搜尋命令和設定", + "EMPTY": "找不到命令或設定", "GROUP_GLOBAL": "全域操作", "GROUP_PLAYLIST": "此播放清單", "GROUP_VIEW": "目前畫面", - "GROUP_SECTION": "此區段", "GROUP_RECENT": "最近使用", "GROUP_SETTINGS": "設定" }, diff --git a/apps/website/README.md b/apps/website/README.md index 7754a7b35..b1d8ec882 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -163,6 +163,13 @@ Evergreen how-to posts live in the blog collection next to release notes channel ids that match no playlist `tvg-id` on purpose) into the isolated database through the settings, then right-click a channel of the M3U fixture and search the dialog. + The 0.23/0.24 feature articles use the separate `feature-guides` capture + group (`pnpm release:screenshots --group feature-guides --theme dark`). + Playback shots decode the generated local slate documented in + `tools/release/fixtures/README.md`; settings and library shots use the same + isolated demo profile. The multi-channel EPG image is the original image + attached to the v0.24.0 GitHub release, explicitly selected by the maintainer; + its provenance is recorded alongside the assets. It is not a mock capture. `tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks each guide for the FAQPage schema, a link to the download hub and the presence diff --git a/apps/website/public/blog/feature-guides/SOURCES.md b/apps/website/public/blog/feature-guides/SOURCES.md new file mode 100644 index 000000000..9cb1c8871 --- /dev/null +++ b/apps/website/public/blog/feature-guides/SOURCES.md @@ -0,0 +1,11 @@ +# Screenshot provenance + +- `multi-epg-v0-24.jpg`: original JPEG attached to the + [v0.24.0 release](https://github.com/4gray/iptvnator/releases/tag/v0.24.0), + reused at the maintainer's explicit request on 2026-09-27. + [Original attachment](https://github.com/user-attachments/assets/205dc53e-70cf-4c63-a49b-9b976fbeb5e5). + Downloaded without edits; 2648×1626. +- `screenshots/guide-*-dark.png`: actual Electron UI captured with isolated + fictional playlists by the `feature-guides` group in + `tools/release/screenshots.manifest.json`. Playback uses the original local + demo slate documented in `tools/release/fixtures/README.md`. diff --git a/apps/website/public/blog/feature-guides/multi-epg-v0-24.jpg b/apps/website/public/blog/feature-guides/multi-epg-v0-24.jpg new file mode 100644 index 000000000..07d137e13 Binary files /dev/null and b/apps/website/public/blog/feature-guides/multi-epg-v0-24.jpg differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-backup-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-backup-dark.png new file mode 100644 index 000000000..b6a8417d9 Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-backup-dark.png differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png new file mode 100644 index 000000000..b255c5c4c Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-library-watched-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-library-watched-dark.png new file mode 100644 index 000000000..a9186fa46 Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-library-watched-dark.png differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-player-subtitles-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-player-subtitles-dark.png new file mode 100644 index 000000000..805f9cca1 Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-player-subtitles-dark.png differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-stream-info-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-stream-info-dark.png new file mode 100644 index 000000000..eafc156c4 Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-stream-info-dark.png differ diff --git a/apps/website/public/blog/feature-guides/screenshots/guide-update-channel-dark.png b/apps/website/public/blog/feature-guides/screenshots/guide-update-channel-dark.png new file mode 100644 index 000000000..b179b6e46 Binary files /dev/null and b/apps/website/public/blog/feature-guides/screenshots/guide-update-channel-dark.png differ diff --git a/apps/website/src/content/blog/fullscreen-channel-episode-guide.mdx b/apps/website/src/content/blog/fullscreen-channel-episode-guide.mdx index 72ecd03ed..c2251b10f 100644 --- a/apps/website/src/content/blog/fullscreen-channel-episode-guide.mdx +++ b/apps/website/src/content/blog/fullscreen-channel-episode-guide.mdx @@ -3,6 +3,7 @@ title: Switch Channels and Episodes Without Leaving Fullscreen description: Open IPTVnator's fullscreen side panel with C or the left edge of the video. Search live channels, choose a season, and switch episodes while keeping the player fullscreen. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png tags: - guide - playback @@ -62,6 +63,10 @@ For M3U, **Page Up / Page Down** also let you step through channels without open panel. Those keys have a different job inside the separate programme Guide, where they move between days. +![Fullscreen video with the live-channel list open on the left](/iptvnator/blog/feature-guides/screenshots/guide-fullscreen-channels-dark.png) + +*Press C in player fullscreen to open the channel list. The pictured channels and video are local demo fixtures.* + ## Choose another episode Start an Xtream or Stalker series episode and open the panel the same way. Instead of a diff --git a/apps/website/src/content/blog/library-organization-guide.mdx b/apps/website/src/content/blog/library-organization-guide.mdx index 0f967a626..db4c04d76 100644 --- a/apps/website/src/content/blog/library-organization-guide.mdx +++ b/apps/website/src/content/blog/library-organization-guide.mdx @@ -3,13 +3,14 @@ title: "Keep Your Library Tidy: Sources, Favorites and Watched Titles" description: Review source availability, return from a favorite channel to its playlist, mark titles watched and simplify catalog views in IPTVnator without confusing hidden items with missing data. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-library-watched-dark.png tags: - guide draft: true faq: - q: Does an unavailable source mean my channels were deleted? a: No. Availability describes a connection check. A timeout, a temporary outage or an unverified account is different from deleting a saved source or losing its channels. - - q: Does source cleanup delete entries automatically? + - q: Does library watched state delete entries automatically? a: No. The desktop cleanup dialog checks sources and lets you review the selection before deleting. Confirmed expired or disabled accounts can be preselected; uncertain results need review. - q: What happens when I delete a source? a: Its saved favorites, history and playback progress are removed with it. Downloaded files are kept. Export a playlist backup first if you want a restorable copy of supported source state. @@ -83,6 +84,7 @@ Deleting a source also removes its saved favorites, history and playback progres Downloaded files are kept. Hiding a category or collapsing a panel is a better fit when your goal is simply to see less in the current view. + ## Keep watch state in step with what you finished For Xtream and Stalker movies, open the detail page and use **Mark as Watched** when you @@ -94,6 +96,10 @@ Series already have controls for marking a season or the whole series watched. U when you are catching the library up with your viewing, rather than opening every episode just to seek to its end. +![Movie detail page with the watched toggle active beside Play and Favorites](/iptvnator/blog/feature-guides/screenshots/guide-library-watched-dark.png) + +*The checked watched button marks this fictional demo movie as finished. Click it again to mark the movie as unwatched.* + ## Fit more covers on screen Under **Settings → General**, turn off **Show titles under covers** for a denser movie diff --git a/apps/website/src/content/blog/m3u-programme-guide.mdx b/apps/website/src/content/blog/m3u-programme-guide.mdx index 2fc5d7df6..7fc799d00 100644 --- a/apps/website/src/content/blog/m3u-programme-guide.mdx +++ b/apps/website/src/content/blog/m3u-programme-guide.mdx @@ -2,7 +2,9 @@ title: Browse the TV Guide Without Leaving Your Channel description: Keep watching while you compare channel schedules in IPTVnator's M3U programme guide. Open the grid, filter by group or favorites, and switch channels without closing it. pubDate: 2026-09-27 +updatedDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/multi-epg-v0-24.jpg tags: - guide - epg @@ -47,6 +49,10 @@ Opening Guide does not restart the current channel. The same player changes size room for the schedules. Collapse the preview strip when you want more space for the grid, then expand it when you want to see the picture again. +![Multi-channel EPG with the current channel playing above the programme grid](/iptvnator/blog/feature-guides/multi-epg-v0-24.jpg) + +*The multi-channel Guide in v0.24. The highlighted grid button in the top toolbar opens this view. Screenshot from the v0.24.0 release.* + ## Choose the channels you want to compare The rows come from **your playlist**, rather than every channel present in an XMLTV file. diff --git a/apps/website/src/content/blog/player-controls-guide.mdx b/apps/website/src/content/blog/player-controls-guide.mdx index 7ab281bc3..085b2a475 100644 --- a/apps/website/src/content/blog/player-controls-guide.mdx +++ b/apps/website/src/content/blog/player-controls-guide.mdx @@ -3,6 +3,7 @@ title: "Subtitles, Quality and Picture-in-Picture: Get More from the Player" description: Use IPTVnator's unified player controls to choose stream quality, load subtitles, adjust their timing and keep video visible in picture-in-picture. Learn why some options depend on the stream or player. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-player-subtitles-dark.png tags: - guide - playback @@ -73,6 +74,10 @@ supports your file rather than simply changing its filename extension. If the stream already includes subtitle tracks, you can choose one directly from the same menu. **Off** hides subtitles without removing the movie or changing the audio track. +![Subtitle menu in the unified player controls with the Load subtitle file action](/iptvnator/blog/feature-guides/screenshots/guide-player-subtitles-dark.png) + +*Open Subtitles from the player control bar to load a subtitle file. Playback here uses a local demonstration stream.* + ## Adjust timing and readability For a selected external subtitle in the web players, use **Show subtitles earlier** or diff --git a/apps/website/src/content/blog/playlist-backup-restore-guide.mdx b/apps/website/src/content/blog/playlist-backup-restore-guide.mdx index a335ca0ad..b731366a1 100644 --- a/apps/website/src/content/blog/playlist-backup-restore-guide.mdx +++ b/apps/website/src/content/blog/playlist-backup-restore-guide.mdx @@ -3,6 +3,7 @@ title: Back Up Your Playlists and Move to a New Computer description: Export and restore an IPTVnator playlist backup, understand which favorites and playback state it carries, and check what needs to be moved separately when changing computers. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png tags: - guide - troubleshooting @@ -45,6 +46,10 @@ Export the receiving computer's library too if it already has sources and favori care about. Restoring a snapshot can replace saved state for a matching source; preserving both backups gives you a record of each library before the import. +![Settings Backup with Import data and Export data actions](/iptvnator/blog/feature-guides/screenshots/guide-backup-dark.png) + +*Settings → Backup contains both actions: Export data creates the backup; Import data restores it.* + ## What travels in the file | Source or data | Included | Separate or rebuilt later | diff --git a/apps/website/src/content/blog/stable-nightly-updates-guide.mdx b/apps/website/src/content/blog/stable-nightly-updates-guide.mdx index c77ac1d33..37e8d4d8a 100644 --- a/apps/website/src/content/blog/stable-nightly-updates-guide.mdx +++ b/apps/website/src/content/blog/stable-nightly-updates-guide.mdx @@ -3,6 +3,7 @@ title: Stable or Nightly? How IPTVnator Updates Work description: Choose an update channel in the desktop app, understand what happens when you switch back to Stable, and find the build details that make a nightly bug report useful. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-update-channel-dark.png tags: - guide draft: true @@ -41,6 +42,10 @@ Nightly can be useful when an issue you reported has been fixed but the next sta has not arrived. Include the build you tested when replying to that issue: two people using “nightly” may be running different code. +![Settings About with the Stable and Nightly update-channel choices](/iptvnator/blog/feature-guides/screenshots/guide-update-channel-dark.png) + +*The update-channel selector lives in Settings → About. Choose a channel here, then save the change.* + ## Switch to Nightly 1. Export a playlist backup from **Settings → Backup** and keep the file somewhere you can diff --git a/apps/website/src/content/blog/stream-info-diagnostics-guide.mdx b/apps/website/src/content/blog/stream-info-diagnostics-guide.mdx index c20d23aab..124183f2f 100644 --- a/apps/website/src/content/blog/stream-info-diagnostics-guide.mdx +++ b/apps/website/src/content/blog/stream-info-diagnostics-guide.mdx @@ -3,6 +3,7 @@ title: What Stream Info Can Tell You About Playback Problems description: Read resolution, frame rate, bitrate, buffer and dropped frames in IPTVnator's Stream info panel, then collect a useful playback report without guessing from a single number. pubDate: 2026-09-27 author: 4gray +heroImage: /iptvnator/blog/feature-guides/screenshots/guide-stream-info-dark.png tags: - guide - playback @@ -45,6 +46,10 @@ The panel shows only values the current engine can report. A missing audio bitra source frame rate is not a zero reading. Immediately after starting, the panel may say there is no stream data yet. +![Stream info panel showing metadata for a playing local demonstration stream](/iptvnator/blog/feature-guides/screenshots/guide-stream-info-dark.png) + +*The information button opens Stream info over the video. These values describe the local demonstration stream used for this screenshot.* + ## Read the values in context | Field | What it helps you understand | diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts index ace0639fb..be5c04a67 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; describe('Xtream mock Nx serve environment', () => { @@ -26,3 +26,61 @@ describe('Xtream mock Nx serve environment', () => { } ); }); + +describe('Playwright mock-server launch', () => { + const appsDirectory = join(process.cwd(), 'apps'); + const expectedMockLaunches: Record = { + 'apps/electron-backend-e2e/playwright.config.ts': ['stalker', 'xtream'], + 'apps/electron-backend-e2e/playwright.journeys.config.ts': ['xtream'], + 'apps/electron-backend-e2e/playwright.xtream-performance.config.ts': [ + 'xtream', + ], + 'apps/web-e2e/playwright.config.ts': ['stalker', 'xtream'], + }; + const readSource = (configPath: string) => + readFileSync(join(process.cwd(), configPath), 'utf8'); + + it('knows every Playwright config that starts a mock server', () => { + const configsUsingMocks = readdirSync(appsDirectory, { + withFileTypes: true, + }) + .filter((entry) => entry.isDirectory()) + .flatMap((entry) => + readdirSync(join(appsDirectory, entry.name)) + .filter((name) => + /^playwright(\..+)?\.config\.ts$/.test(name) + ) + .map((name) => `apps/${entry.name}/${name}`) + ) + .filter((configPath) => + readSource(configPath).includes('-mock-server') + ); + + expect(configsUsingMocks.sort()).toEqual( + Object.keys(expectedMockLaunches).sort() + ); + }); + + // Nx starts a run-commands child in its own detached process group, and + // Playwright stops a webServer with a process-group SIGKILL, so a mock + // launched through Nx outlived the run and kept its port. + it.each(Object.entries(expectedMockLaunches))( + 'starts each mock in %s as a single node process', + (configPath, mocks) => { + const source = readSource(configPath); + + expect(source).not.toMatch(/\bnx\b[^'"`\n]*mock-server/); + for (const mock of mocks) { + expect(source).toContain( + `'node --import tsx apps/${mock}-mock-server/src/main.ts'` + ); + } + expect( + source.match(/node --import tsx apps\/[\w-]+-mock-server\//g) + ).toHaveLength(mocks.length); + expect( + source.match(/TSX_TSCONFIG_PATH: 'tsconfig\.base\.json'/g) + ).toHaveLength(mocks.length); + } + ); +}); diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 590e8bbfa..22753815b 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -1129,6 +1129,58 @@ This keeps: - series and VOD-as-series support intact - external MPV/VLC launches unchanged +## Recently Viewed Confirmation + +Selecting or resolving an item is not watching it. A channel, movie or series +becomes a recently viewed item — and with it the dashboard hero — only once +its stream has really played, so a stream that fails straight away never +reaches history. + +- Writers do not persist on selection. They hand the write to + `PlaybackHistoryGate` (`@iptvnator/playback/data-access`) with + `defer(target, commit)`, where the target is what the playback will be + known by: the host's `playbackSessionKey` (M3U) and/or stream URLs. The Stalker resolver defers + by the resolved (possibly temporary) link; the persisted row still stores + the portal `cmd`, never that link. Writers capture the item and its + playlist when they defer, so navigating meanwhile cannot misfile it; an + Xtream write confirmed after a switch to another playlist (only a slow + MPV/VLC launch can) is saved to its own playlist without reloading the + store's recent list, which belongs to the other playlist by then. +- Matching: a write deferred with a session key is confirmed only by that + same key — the same URL in two playlists must not let playback in one + (inline, or in MPV/VLC) record a failed attempt in the other. Writes + without one (portal resolvers) match any confirmation of their stream URL. + The global live tab defers with its own playlist-scoped session key, which + also survives a switch to catch-up, when the row plays inline; a row that + goes to MPV/VLC defers by URL, the only thing that launch confirms. +- `WebPlayerViewComponent` confirms its `playbackSessionKey`, `streamUrl` + and `playback.streamUrl` once the owned engine's reported position has + advanced by 2 seconds while playing (`PlaybackProgressConfirmation`). + Engines report `playing` (not paused, not seeking) with each time update, + so seeks of paused media do not count; neither do steps above 3 seconds + (seeks, a resume jump, live-edge catch-up), stalls or backwards jumps. A + report of a new stream never adds to the progress of the previous one; an + engine or format swap of the same stream keeps its progress. The radio + `AudioPlayerComponent` confirms the same way (with the host's session key + when given). +- MPV/VLC cannot report whether a live stream plays, so the gate itself + subscribes to the Electron external-player session updates and confirms a + session's `streamUrl` once it is `opened` or `playing`; a launch that ends + in `error` is not recorded. (Subscribing in the gate, which the first + deferred write creates, keeps it off the initial bundle.) That + confirmation carries no session key, so an "Open in MPV/VLC" recovery + launch is also confirmed by the `WebPlayerViewComponent` that requested + it, under its own session key, once the launch has opened. M3U keeps + recording on selection when MPV/VLC is the configured player. +- A confirmation commits every write matching it, once. Unconfirmed writes + are bounded (oldest dropped) and simply never commit. The global live tab + moves a confirmed row to the top of an open Recently Viewed list even if + another row was selected meanwhile. +- A committed write updates the source while the item keeps playing. Hosts + must not hand the player a new but identical playback for it: the M3U + host's `embeddedPlayback` also reads the playlist meta, so it is compared + by value — a new object would remount the engine and restart the stream. + ## Playback Position Saving The old dialog path saved playback positions from inside the removed Xtream diff --git a/docs/architecture/idle-work-audit-2026-09.md b/docs/architecture/idle-work-audit-2026-09.md new file mode 100644 index 000000000..9a6123c35 --- /dev/null +++ b/docs/architecture/idle-work-audit-2026-09.md @@ -0,0 +1,259 @@ +# Idle work audit (2026-09) + +One-off investigation for plan item D3 ("hidden work audit") of the +performance journeys plan. The question: what does IPTVnator do while the +user does nothing? This document records the measurement and the findings. +Nothing was changed. Every row marked **own thread** is a candidate for a +separate, benchmarked follow-up under the +[performance journeys](performance-journeys.md) process. + +**Historical results.** The capture ran on `8ebb7e342` (2026-09-26). The +cinematic rotating dashboard hero (#1725, `887ac64`) landed afterwards, so the +measured counts and the "all dashboard idle work" conclusions below describe +the dashboard without it. The hero is accounted for separately, from code +reading, under +[Added after the measurement](#added-after-the-measurement-rotating-hero). + +## Result in brief + +- **The main process starts no periodic work at idle.** Across four + two-minute windows its probes saw zero timers fired, zero outbound HTTP + requests and zero file writes. With a fresh profile they also saw zero IPC + and zero SQL. With recent live channels, the dashboard's renderer timers + caused 1–2 IPC calls and 2–4 SQL statements per window (see the table + below). Workers were not probed directly: the SQL trace covers the + database worker, and reading the code found worker timers only inside + running operations. The EPG refresh, download manager, source health + probe, auto-updater, remote-control server and connectivity guard are all + demand-driven or off by default. +- **All idle work is in the renderer, on the dashboard.** With a fresh + profile, two RxJS intervals (30 s and 60 s) cause 12 app-wide Angular + change-detection passes per two minutes. With recently watched live + channels, a third interval adds an IPC and SQL lookup about every + minute. It also triggers a 24-frame, full-document layout animation every + 30 s. +- **A minimized window does exactly the same work as a visible one.** The + main window sets `backgroundThrottling: false` + ([app.ts:125](../../apps/electron-backend/src/app/app.ts)). Chromium + therefore neither throttles timers nor reports the page as hidden. No + renderer code can pause itself when the window is minimized, because + `document.visibilityState` stays `visible`. + +The three worst offenders, in order: + +1. `backgroundThrottling: false` on the main window: every renderer timer, + rAF and CSS transition keeps running when minimized, and the page + visibility API is blind to minimization. +2. The dashboard live-EPG 30 s heartbeat with recent live cards: an IPC and + SQL batch about every minute, rebuilt rails, and a 0.4 s `width` + transition on progress bars. Each tick costs about 24 full-document + layouts plus GPU frames. This state has 17–76× the GPU time of the fresh + profile. +3. The always-on 30 s portal live-EPG heartbeat plus the 60 s source-expiry + tick: 12 app-wide change-detection passes per two minutes, even when there + is nothing to update. The 60 s tick also rebuilds the sources rail, which + schedules a scroll reset and re-observes every card. + +## Method + +- **Build.** `pnpm nx run electron-backend:build-e2e` on `8ebb7e342`. It + is unoptimized, with source maps and Angular dev mode. Dev mode runs every + change detection twice (the `checkNoChanges` pass), so per-firing + renderer costs below are upper bounds for a production build. Timer, rAF, + IPC, SQL, HTTP, layout and DOM-mutation counts do not depend on dev mode. + The Angular change-detection and template-update counts are dev-build + counts: a production build skips the `checkNoChanges` pass, so it performs + fewer template updates per tick. +- **Servers.** The Xtream mock ran on loopback port 3411 with its control + plane enabled. A second E2E worktree already held 3310. A throwaway loopback + HTTP server served a 60-channel M3U with `url-tvg` and logo URLs, and it + logged every request. +- **Profile.** A fresh data directory seeded through the E2E fixture + helpers: one M3U URL source and one Xtream source (mock `user1`). The + "recent live" variant also opened two M3U and two Xtream live channels + during seeding. +- **Measurement launch.** The app was relaunched against the seeded profile + with `IPTVNATOR_TRACE_STARTUP=1`, `IPTVNATOR_TRACE_IPC=1` and + `--remote-debugging-port=9422`, because port 9222 was held by another + Electron. The app landed on `/workspace/dashboard`. After a 20 s settle + window came 120 s visible and focused, then 120 s minimized. The measured + state was `isMinimized() === true` and `isVisible() === false`. +- **Main-process probe.** A bootstrap entry point, the same pattern as + `playlist-refresh-write-gate.ts`, wrapped these before requiring + `dist/apps/electron-backend/main.js`: + - `ipcMain.handle/on` + - `webContents.send/postMessage` + - global `setTimeout/setInterval/setImmediate` + - `http(s).request/get`, `fetch`, `net.request/fetch` + - `fs` write and rename calls + - `Worker.postMessage` + + SQL came from the existing trace channel on stdout. +- **Renderer probe.** Injected before any page script with + `Page.addScriptToEvaluateOnNewDocument`. It wrapped timers, rAF, + IndexedDB writes, `localStorage`/`sessionStorage`, fetch and XHR. It also + counted `Zone.prototype.runTask` per zone and source, and change-detection + ticks through `ng.ɵsetProfiler` (event `ChangeDetectionStart`). DOM + mutations came from a document-wide `MutationObserver`. Separately, a CDP + `Tracing` profile and `Performance.getMetrics` covered each window, and + `app.getAppMetrics()` gave per-process CPU. +- **Two passes.** An instrumented pass collected traces and a 1 kHz + main-process CPU profile. A light pass left the profiler, tracing and + network capture off, so its per-process CPU numbers are not inflated by the + instruments. The instrumented pass had browser-process CPU of about 2.5 s + per window and 550 wakeups/s, almost all of it the sampling profiler. + +Playwright pins every page it attaches to as visible: `Emulation.setFocusEmulationEnabled` +raises the page's capturer count. The minimized windows above were measured +through Playwright. That is faithful for this build, because +`backgroundThrottling: false` keeps the page visible anyway. Measuring a build +where the window can become hidden needs a launch without Playwright over raw +CDP. The follow-up PRs do that. + +Harness artifacts that were excluded: + +- The E2E fixture's own 60 Hz rAF frame counter (`startRendererFrameCapture`), + which was cancelled after launch. +- An extra `about:blank` navigation before the app URL, used to register + the renderer probe. +- The probe's own stdout tee. +- Playwright `evaluate` round-trips at window boundaries. + +The harness was a one-off and is deliberately not committed, so the figures +below cannot be rerun from the repository as is. They are observations from +this run, and the method above describes how to rebuild the harness. The +maintained way to measure is the journey harness in +[performance journeys](performance-journeys.md); an idle journey there is the +path to making these counters reproducible and guarded. + +## Measurements + +Counts per 120 s window. CPU is the light pass, taken from +`getAppMetrics().cpu.cumulativeCPUUsage` deltas. + +| Counter | Fresh, visible | Fresh, minimized | Recent live, visible | Recent live, minimized | +| --- | --- | --- | --- | --- | +| Renderer timer firings | 6 | 6 | 10 | 10 | +| Renderer rAF callbacks | 4 | 4 | 12 | 12 | +| Angular CD ticks | 12 | 12 | 29 | 30 | +| DOM mutation records | 0 | 0 | 12 | 12 | +| Layouts (`LayoutCount`) | 0 | 0 | 139 | 129 | +| IndexedDB / Storage writes | 0 / 0 | 0 / 0 | 0 / 0 | 0 / 0 | +| Renderer fetch/XHR, CDP network | 0 | 0 | 0 | 0 | +| IPC renderer→main (excluding trace echo) | 0 | 0 | 1 | 2 | +| IPC main→renderer | 0 | 0 | 0 | 0 | +| SQL statements (main) | 0 | 0 | 2 | 4 | +| Main timers fired / HTTP / fs writes | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | 0 / 0 / 0 | +| Renderer process CPU | 158 ms¹ | 11 ms | 308 ms | 463 ms | +| GPU process CPU | 19 ms | 7 ms | 332 ms | 530 ms | +| Browser (main) process CPU | 214 ms¹ | 67 ms | 180 ms | 281 ms | + +¹ The visible window came first, so it also absorbed a one-time post-startup +V8 incremental major GC and Playwright's evaluate calls. The trace shows 798 +`V8.GC_MC_INCREMENTAL` steps in the visible window and none in the minimized +one. Steady-state renderer cost for the fresh profile is closer to the +minimized figure. + +Per-firing costs, taken from the renderer trace (dev build): + +- Each 30 s or 60 s `TimerFire` is 1.6–2.2 ms, with rare outliers of 11–13 ms. +- The rail scroll-reset rAF pair is 0.4–1.5 ms. +- In the recent-live state, each 30 s tick adds about 24 consecutive frames, + each with a full-document `Layout` (30 dirty of 541 objects), `HitTest`, + `UpdateLayer` and two `IntersectionObserver` computations. That is about + 0.8–2 ms of renderer main-thread time per frame, or about 25–40 ms per tick. + The GPU adds 45–130 ms per tick. + +## Findings + +"Evidence" says whether the row was measured at idle or found by reading the +code. Costs are for the dev build unless noted. + +### Periodic work observed at idle + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [app.ts:125](../../apps/electron-backend/src/app/app.ts) `backgroundThrottling: false` | Disables Chromium background throttling and page-visibility changes for the main window. It was added without a stated reason in #1123. | Continuous (amplifier) | Makes every renderer row below cost the same while minimized. Minimized windows still ran 6–10 timers, 4–12 rAF and 129 layouts per 2 min. | **No.** Nothing on the idle path needs full-rate timers while minimized, and it defeats the keep-awake visibility gate (see the next section). | Measured: `isMinimized()` is true while `visibilityState` stays `visible` | **Own thread.** Find the playback or radio case that needed it, then enable throttling or toggle it per active playback through `webContents.setBackgroundThrottling`. Add a minimized idle counter to the journeys. | +| [dashboard-portal-live-epg.presenter.ts:52](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-portal-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` feeding the effect at :86 | Heartbeat that calls `DashboardPortalLiveEpgService.sync(wanted)` for Xtream and Stalker live cards | 30 s, always on while the dashboard is mounted | One app-wide CD tick, about 2 ms. With no portal live cards `wanted` is empty, so there is no IPC. | **No** when `wanted` is empty. The tick has nothing to do but still runs a full zone CD pass. | Measured: 4 per 2 min in every state | **Own thread.** Run the interval only while `wanted` is non-empty, outside the Angular zone, and pause on hidden. | +| [workspace-dashboard-rails.component.ts:345](../../libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts) `interval(SOURCE_EXPIRY_TICK_MS)`, read at :753 | Minute heartbeat for source-expiry badges. It recomputes `sourceCards`, which returns a new array. | 60 s, whenever the dashboard is mounted: `toSignal(interval(...))` subscribes at construction, so it also ticks with no recent sources or with that rail disabled; the rail rebuild only follows when recent sources exist | One CD tick of about 2 ms. The new `items` input also fires the rail effects: two chained rAF CD ticks and a `scrollTo(0)` from `scheduleResetToStart` ([dashboard-rail.component.ts:176–187, :317](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts)), and an IntersectionObserver re-observe of every card. | **Partly.** Badges must cross day boundaries, but a minute tick for day-granular badges is excessive. Returning a new array each time also resets a user-scrolled rail. | Measured: 2 timer and 4 rAF per 2 min | **Own thread** (#1722). Schedule the next badge boundary instead of polling, arm nothing when no badge can change, give `sourceCards` a structural `equal`, and reset the rail scroll only when card identity changes. | +| [dashboard-live-epg.presenter.ts:116–132](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `interval(LIVE_EPG_TICK_MS)` with `forkJoin(askScope…)` | "Now on air" lookup for hero, live-favourite and recent-live cards | 30 s, only when live cards exist | `GET_CURRENT_PROGRAMS_BATCH` IPC about every second tick, because of the 60 s program cache, running 2 SQL `SELECT`s. Every emission is a new `Map`, so the live rails rebuild. That adds rAF scroll resets, IO re-observe, and new progress widths (next row). | **Partly.** Progress and "now" titles are a feature. Rebuilding the rails when the programme did not change is not. | Measured: 1–2 IPC and 2–4 SQL per 2 min | **Own thread.** Emit only on programme change, tick on the next programme boundary instead of every 30 s, and pause on hidden. | +| [dashboard-rail.component.scss:256–271](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.scss) `.rail__art-progress i { transition: width 0.4s ease }` | Animates the live-programme progress bar each time its width binding changes | Every 30 s tick with live cards | About 24 full-document layouts and paints over 0.4 s: about 25–40 ms renderer and 45–130 ms GPU per tick. Twelve `` attribute mutations per 2 min. | **No.** A sub-pixel progress change does not need a layout-driven animation, and it runs while minimized. | Measured: trace shows one rAF, then 24 frames of Layout, HitTest and IO every 30 s | **Own thread.** Animate `transform: scaleX()` (compositor-only), or drop the transition for tick updates. | +| Eager components on every tick: [app.component.ts:54](../../apps/web/src/app/app.component.ts), [workspace-shell.component.ts:52](../../libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts), [epg-progress-panel.component.ts:67](../../libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.ts), [app-update-notification-panel.component.ts:106](../../apps/web/src/app/app-update-notification-panel.component.ts) | `ChangeDetectionStrategy.Eager` roots re-render their templates on every zone tick | Every tick above | Each of the four templates updated 24 times over the 12 ticks: twice per tick, because dev mode adds the `checkNoChanges` pass | **No** for idle. Nothing in them changes on a timer. | Measured through the `TemplateUpdateStart` profiler events | Fold into plan item C6 (OnPush/zoneless). No separate thread. | + +### Added after the measurement: rotating hero + +Not in the measured counts above; found by reading the code on master after +#1725. + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [dashboard-hero.component.scss:558](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss) `hero-dot-fill` and the `(animationend)="onRotationTick()"` at [dashboard-hero.component.html:233](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html) | The active rotation dot animates `width` 0 → 18 px over `HERO_ROTATION_MS` (8 s); its `animationend` advances the slide, which starts the next fill and the backdrop's 8 s `transform` transition | Continuous while the dashboard is visible with two or more hero slides, unless the rotation is paused or reduced motion is on | A layout pass on every animation frame, because `width` is a layout property (not measured) | **Partly.** The rotation is a feature. Animating a layout property for it is not, and it runs on an otherwise idle page. | Static | **Own thread.** Animate the fill with a compositor-only `transform: scaleX()`, keep the `animationend` advance, and decide whether rotation should pause on an idle or hidden dashboard. | +| [dashboard-live-epg.presenter.ts:121](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts) `heroLiveCandidates` (lookup) and :191 (pinned portal keys); limits in [dashboard-hero-slides.utils.ts:14–15](../../libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts) | Up to five hero live candidates (three favourites, two recent) join the 30 s XMLTV "now on air" lookup, and are pinned for the portal live-EPG queue even when their rails are hidden or scrolled away | 30 s XMLTV heartbeat while the hero is enabled and any candidate exists; portal sync on the same tick | More lookup keys per `GET_CURRENT_PROGRAMS_BATCH` IPC and SQL, and portal `get_short_epg`-style requests for pinned Xtream/Stalker candidates when stale (not measured) | **Yes** for the programme shown on the hero; the 30 s re-ask of unchanged programmes is not | Static | Covered by #1722: the clock re-asks only when a programme ended, a key has no answer, the answer is five minutes old, or the guide changed, and it pauses while hidden. | + +### Checked and not periodic at idle + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| EPG refresh ([epg.events.ts:176–178](../../apps/electron-backend/src/app/events/epg.events.ts)) | There is no scheduler. `FETCH_EPG` and `EPG_CHECK_FRESHNESS` (default `maxAgeHours` 12) run only when the renderer asks. | None | One `FETCH_EPG` at boot with no configured EPG URL. The M3U `url-tvg` was never requested. | Yes | Measured: 0 at idle | None | +| Download manager ([download-transfer.ts:289](../../apps/electron-backend/src/app/events/database/download-transfer.ts), [download-reconnect.ts:104](../../apps/electron-backend/src/app/events/database/download-reconnect.ts)) | Writes progress at most every 500 ms from stream `data` events, and has a 1 s reconnect sleep. It runs only while bytes flow or a reconnect is pending. | Event-driven | One `UPDATE downloads` plus a `DOWNLOADS_UPDATE_EVENT` per progress write during a transfer | Yes (real transfers) | Static; `DOWNLOADS_GET_LIST` once at boot | None | +| Source health probe ([source-health.service.ts:111–114](../../libs/portal/shared/data-access/src/lib/source-health.service.ts), #1592) | On-mount and manual checks. TTL 60 s, or 15 s when uncertain. The documented contract is demand-driven with no polling ([m3u-playlist-module.md](m3u-playlist-module.md#desktop-source-health)). | None | One Xtream `player_api.php` at boot (dashboard expiry check). No M3U probe on the dashboard. | Yes | Measured: 0 at idle | None | +| Auto-updater ([app-update.service.ts:291](../../apps/electron-backend/src/app/services/app-update.service.ts)) | One check at startup, packaged builds only, then only on request | None | — | Yes | Static; `APP_UPDATE:GET_STATUS` once at boot | None | +| Remote-control server ([http-server.ts:126](../../apps/electron-backend/src/app/server/http-server.ts)) | Listens only when the `remoteControl` setting is on (default off). The 2 s poll lives in the phone client and is served from memory. | None by default | — | Yes | Static; off in the profile | None | +| Connectivity guard ([host-connectivity-guard.ts:43](../../libs/shared/host-health/src/lib/host-connectivity-guard.ts)) | Compares against the clock lazily per request. The contract says no heartbeat ([host-connectivity-guard.md](host-connectivity-guard.md)). | None | — | Yes | Measured: 0 at idle | None | +| Playlist auto-refresh ([app.component.ts:261–294](../../apps/web/src/app/app.component.ts)) | One pass at startup for playlists with `autoRefresh` (default false) | Once | — | Yes | Static | None | +| PWA service worker ([app.config.ts:126–129](../../apps/web/src/app/app.config.ts), `ngsw-config.json`) | `registerWhenStable:30000`. It has asset groups only: no `dataGroups`, no periodic sync, and no `checkForUpdate` polling (`PwaService.checkUpdates` is never called). Disabled in Electron. | None | — | Yes | Static only; no PWA runtime capture was run | None | + +### Conditional periodic work (not active on idle `/workspace`) + +Found by reading the code. They run only on the listed route or state, so a +user who leaves that screen open pays them indefinitely, minimized included. + +| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up | +| --- | --- | --- | --- | --- | --- | --- | +| [mpv-session.service.ts:140–141](../../apps/electron-backend/src/app/events/mpv-session.service.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. | +| [vlc-session.service.ts:81–82](../../apps/electron-backend/src/app/events/vlc-session.service.ts) | External VLC position poll | 1.5 s delay, then 2 s | Up to three localhost TCP connections, then IPC | Yes while playing. The early-exit leak (orphaned 2 s poll) is **resolved by #1720**. | Static | Done (#1720). | +| [embedded-mpv-native.service.ts:1068](../../apps/electron-backend/src/app/services/embedded-mpv-native.service.ts) | Embedded MPV session snapshot poll | 500 ms while a session exists | Native snapshot, diff, and IPC only on change | Yes during playback. It is cleared when the last session closes. | Static | None | +| [embedded-mpv-session-controller.ts:197](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts) | Renderer-side bounds poll for a native-view embedded MPV session: compares `getBoundingClientRect()` with the last synced bounds | 500 ms while a native-view session is open (skipped for frame-copy) | One layout read outside the Angular zone; on drift a rAF and a `setEmbeddedMpvBounds` IPC | Yes during playback: a position-only layout shift would otherwise leave the native view misplaced | Static | Cover with the throttling thread: it kept its rate while minimized under `backgroundThrottling: false`. | +| [embedded-mpv-frame-pump.ts:212](../../apps/electron-backend/src/app/api/embedded-mpv-frame-pump.ts) (preload) | Frame-copy engine's frame pump: `pumpTick` re-arms itself with `requestAnimationFrame`, checks the shared-memory ring for a new frame, and uploads and draws it when one arrived | Every display frame while a frame-copy session is attached, paused included; stops only on detach. Opt-in (`IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY`) | A ring read per frame; a texture upload and draw per new frame | Yes while the video is on screen. A paused or minimized session gains nothing from 60 Hz polling. | Static. Under `backgroundThrottling: false`, rAF kept firing in a minimized window: the E2E fixture's own rAF counter ran at 60 Hz there. | Cover with the throttling thread (rAF stops in a hidden window once throttling is on), and consider stopping the pump while paused. | +| [embedded-mpv-controls.adapter.ts:252](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts) (frame-copy) and [embedded-mpv-player.component.ts:587](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts) (native view) | Elapsed-time heartbeat of an active embedded-MPV recording; the two intervals are mutually exclusive by engine | 1 s while a recording is active | A signal write and a CD tick | Yes while the elapsed time is on screen. A minimized window gains nothing from it. | Static. Under `backgroundThrottling: false` it kept its rate while minimized. | Cover with the throttling thread (throttled while hidden once #1724 lands). | +| [embedded-mpv-reconnect.ts:296](../../apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts) | Reconnect backoff | 2 s to 30 s, at most 6 attempts | Native reload | Yes | Static | None | +| [channel-list-container.component.ts:426, :431](../../libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts) | M3U list: re-queries current programmes and metadata for **all** channels in the list, plus a progress tick | 60 s / 30 s while an M3U list is mounted | IPC and SQL that grow with channel count (not measured) | **Partly.** Only visible rows need refreshing. | Static | **Own thread.** Measure on a 10k-channel list, then limit to the viewport and pause on hidden. | +| [epg-refresh-coordinator.service.ts:82](../../libs/portal/xtream/feature/src/lib/portal-channels-list/epg-refresh-coordinator.service.ts) | Xtream live: refreshes stale visible or tracked EPG entries | 60 s while live lists are registered | Xtream HTTP through main for stale entries only | Yes (already scoped) | Static | Pause on hidden once throttling allows it. | +| [stalker-watchdog.controller.ts:188](../../libs/portal/stalker/data-access/src/lib/stalker-watchdog.controller.ts) | Stalker `get_events` keep-alive | Portal `watchdog_timeout` (default 120 s) | A playlist row read and one portal HTTP request | Yes (portal protocol) | Static | None | +| [downloads.component.ts:223](../../libs/portal/downloads/feature/src/lib/downloads.component.ts), [recording-queue.component.ts:88](../../libs/portal/downloads/feature/src/lib/recording-queue.component.ts) | Recordings reload and elapsed-time tick | 15 s / 1 s while recordings are active and the page is open | `loadRecordings()` IPC and SQL / CD tick | **Partly.** Downloads are already push-based, so recordings could be too. | Static | **Own thread.** Push recording state like `onDownloadsUpdate`. | +| [controls-stream-stats.ts:42](../../libs/ui/playback/src/lib/player-controls/controls-stream-stats.ts) | Stream-info sampling | 1 s, only while the stream-info popover is open; `stop()` clears it when the popover closes | CD tick each | Yes (a closed popover costs nothing) | Static | None | +| 30 s EPG clocks in the Xtream, Stalker, EPG guide and unified live views | "Now" clocks for progress and the current programme | 30 s for as long as their route is open | CD tick each | Yes while visible | Static | Cover with the throttling thread (pause on hidden). | +| [empty-state.welcome-dashboard.scss:58](../../libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.welcome-dashboard.scss) (`gridMove`, keyframes in `empty-state.component.scss:169`) | First-run dashboard with no playlists: the welcome state's `::before` grid drifts via an infinite `transform` animation | Continuous (60 s loop) while the empty dashboard is shown; the reduced-motion block does not stop the pseudo-element | Compositor and GPU frames only, no main-thread layout (not measured) | **Partly.** Decorative; a first-run screen left open keeps the GPU busy, and reduced motion should stop it. | Static | **Own thread** (small): stop the `::before` animation under `prefers-reduced-motion`, and consider pausing it while hidden (throttling stops compositor frames in a hidden window once #1724 lands). | +| [video-player.component.ts:790](../../libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts) `epgNowMs` | M3U playlist view: refreshes "now" for EPG state, unconditionally, separate from the channel-list timers above | 30 s while an M3U playlist route is open, with or without an active channel | A signal write and an app-wide CD tick | **Partly.** Needed while a programme is shown; not with no channel selected. | Static | Cover with the throttling thread (pause on hidden), and start it only while a channel with EPG is active. | +| [portal-empty-state.component.scss:25](../../libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.scss) `portal-empty-state-float` | Xtream, Stalker and unified live views with nothing selected or no results: the empty-state icon floats on an infinite `transform` animation | Continuous (2.8 s loop) while the empty state is shown | Compositor and GPU frames only (not measured) | **Partly.** Decorative; an empty portal view left open keeps the GPU busy. | Static | **Own thread** (small, with the welcome grid): stop decorative loops under reduced motion; #1724 stops their frames in a hidden window. | + +Every infinite CSS animation in the renderer styles (`animation: … infinite`) +falls into one of three groups. **Loading indicators** (skeleton and list +shimmers, spinners, the refresh and download spinners, the EPG import spinner) +end with their loading state. **Playback and recording indicators** (live +pulses, the radio artwork and ring, the recording pulse and slide) run only +while something plays or records. **Decorative loops in idle states** are the +welcome grid and the portal empty-state icon listed above; they are the only +ones that keep running on a screen left alone. + +## Related observation + +[playback-keep-awake.service.ts](../../apps/web/src/app/services/playback-keep-awake.service.ts) +documents that "Visibility gates the lock in both modes: a minimized window +streaming audio in the background should not pin the display on". Under +`backgroundThrottling: false`, a minimized Electron window keeps +`visibilityState === 'visible'` and never fires `visibilitychange`, as +measured in this audit. So the Electron `powerSaveBlocker` likely stays held +for a minimized window playing video. Playback itself was not measured. Verify +this in the same thread as the first worst offender. + +## Not covered + +- Production (optimized, non-dev-mode) renderer costs. Timer, rAF, IPC, SQL, + network, layout and DOM-mutation counts carry over; the Angular + change-detection and template-update counts do not (production skips + `checkNoChanges`), and per-firing milliseconds are upper bounds. +- Windows and Linux. Throttling and occlusion behavior differ per platform. +- Idle during playback, and on routes other than the dashboard. The + conditional table above is from code reading only. +- A PWA runtime capture of the service worker. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 9ed67dbe5..9397e23f7 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -88,7 +88,8 @@ icons. The dock must never pair a dark fallback surface with inherited light app text. Loader/stall and transient feedback overlays own a light foreground and dark scrim because they cover video. Video viewports remain black in both themes and fullscreen; frame-copy and built-in shared controls keep their -existing light-on-dark overlay palette. +light-on-dark overlay palette — the fixed `--pc-*` token set of the shared +dock (accent blue, cyan, violet and a light text ramp), never the app theme. EPG timeline, list, empty states and programme details use the library-local `libs/ui/epg/src/lib/_epg-theme.scss` palette, based on app surfaces, separators, @@ -542,6 +543,30 @@ mirrors the row/card geometry it precedes (see Channel List Item and Cover Grids). The unified Favorites/Recent page gates this on `isLoading`, set only while its item list is empty. +### Pages of independently loading blocks: delayed skeletons + +The dashboard renders each rail as soon as its own data arrives, and several +rails resolve empty on a normal profile. A per-rail skeleton shown +immediately therefore flashed for a few tens of milliseconds and collapsed, +pulling every rail below it upwards (a layout shift of about 0.23 on each +launch with sources). Rail skeletons are gated per rail +(`createRailSkeletonGates` in +`libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts`): + +- a skeleton waits out a grace period (`DASHBOARD_RAIL_SKELETON_GRACE_MS`, + 300 ms) counted from when *that* rail started loading, since Xtream and + TMDB rails start after the local ones; +- it never appears above a rail that already shows cards: the placeholder + would push visible content down, and back up if the rail resolves empty, + while the real rail inserts at most once; +- once shown, it stays until its own rail finishes, so skeletons do not + vanish in a cascade when the first real rail arrives. + +The top block (the dashboard hero) keeps its immediate skeleton: it reserves +the space above everything else, where a late insertion would push the whole +page down. Use the same rules for any page that stacks independently loading +blocks. + ### Reload with content on screen: non-destructive indicator A reload of a list that is already rendered (the collection page's diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 86daacfcb..2667ceebf 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -189,6 +189,14 @@ other utility projects, including shared interface contracts, while `ui-playback` and feature hosts may depend on it to render and execute session-local recovery actions. +`libs/playback/data-access` is the `playback-data-access` Nx project, +imported through `@iptvnator/playback/data-access` (tags `scope:shared`, +`domain:playback`, `type:data-access`). It owns renderer playback state that +store, feature and player layers share — currently `PlaybackHistoryGate`, +which holds "recently viewed" writes until playback is confirmed. It is kept +out of `@iptvnator/services` on purpose: that barrel ships in the initial +bundle, and this project is only reached from lazy player and portal code. + ## Project Tags Every Nx project keeps one tag from each family in `project.json`: diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 2a698b00b..a6d59b474 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -276,6 +276,7 @@ counter: "renderer.initialBytes": { "value": 2739510, "unit": "bytes", + "slack": 4096, "updatedAt": "2026-09-26", "evidencePr": 1693, "measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes" @@ -288,7 +289,9 @@ counter: `tools/performance/check-journey-ratchet.mjs` compares a journey summary with that file: -- a counter above its `value` fails; counters are exact, there is no slack; +- a counter above `value + slack` fails; counters are exact, and `slack` + (default 0, in the entry's unit) is the only allowance, printed as "uses N + of S slack" whenever a measurement is above `value`; - a wall-clock entry carries `toleranceRatio` and fails above `value × toleranceRatio`; - a baseline with no measurement in the summary fails, so dropping a @@ -338,19 +341,48 @@ chunk-level identifier renaming shifts when a module enters or leaves call sites, eating about 320 of the bytes saved. Judge a small change by the `--stats-json` input sizes, not only by the counter. +Both effects are why `renderer.initialBytes` carries `"slack": 4096`. With a +zero allowance the +108 above failed every PR for hours on 2026-09-27, and +PRs growing the counter by 243 and 302 bytes, each through one service +change, had no way to pass. The +slack is fixed, not a ratio, and sits on top of `value`, which still only +moves down: growth accumulates at most 4 KiB past the last lowered baseline +before the job fails again, while a regression such as #1601's +35,435 bytes +fails as before. Lower `value` to the measured number as usual; the slack +stays and is not part of the evidence. + The job also refuses a weakened baselines file: `tools/performance/check-baseline-direction.mjs` compares `journey-baselines.json` with the revision the change is measured against (the target branch of a pull request, the previous head of a `master` push, `master` for a manual dispatch) and fails when any -entry's enforced limit (`value × toleranceRatio`) went up, a tolerance widened -or an entry disappeared, so a PR cannot grow the payload and raise the -baseline to match. Lowered limits and new entries pass. +entry's enforced limit (`value × toleranceRatio` or `value + slack`) went up, +a tolerance or slack widened or an entry disappeared, so a PR cannot grow the +payload and raise the baseline to match. A counter's `value` may not go up +either, even when narrower slack lowers its limit, and switching an entry +between counter and wall-clock (adding or removing `toleranceRatio`) counts +as a weakening too. Lowered limits and new entries pass. Baselines only move down. Lower `value` in the same PR as the change that earned it, set `updatedAt` and `evidencePr`, and paste the measurement output into the PR. Never raise a value to make a PR pass: if growth is a deliberate -trade-off, say so in the PR and let the maintainer decide. +trade-off (a framework upgrade, a feature that must be on the initial path), +raise `value` to the runner's measurement in the PR, make the case with the +per-file breakdown, and ask a maintainer to add the `perf-baseline-increase` +label. With the label the direction check prints the weakened entries as +`ALLOWED` and passes; the job reads labels from the API when it runs, so +re-run the job after the label is added. For a `master` push the label is +read from the pull request merged as the pushed commit, and only when the +push added exactly one first-parent commit: a squash or merge of a labelled +PR passes, while a direct push, or a push of several commits (which the check +compares as a whole), that raises a baseline still fails. +Only people with triage access can set labels, so the label is the +maintainer decision. + +A PR merged while this job is red makes every later PR fail it with the same +numbers until `master` is fixed: #1601 merged at +35,435 bytes and failed +the job for every PR until #1734. Treat the job as blocking before merging; +making it a required check is a maintainer decision. The runtime counters come from the `Performance journeys` job of the same workflow, on `ubuntu-latest` only. It runs `pnpm run perf:journeys` under @@ -382,6 +414,43 @@ in all eighteen runner iterations; the `spawnToFirstCardMs` P50 ranged from differ from a Mac (12 and 571 there, the fast path without the Linux-only `getWindowState` call), so take J1 baseline values from the runner only. +## Charset parse benchmark + +V8 stores a string as two-byte UTF-16 once one character falls outside +Latin-1, and substrings of such a string stay two-byte, even ASCII-only URL +lines. `src/performance/charset-parse.benchmark.ts` checks whether that slows +playlist and EPG parsing. It is a Node benchmark, not a journey, and is not +ratcheted: + +```bash +pnpm nx run electron-backend-e2e:benchmark-charset-parse --iterations=5 +``` + +It parses 50,000 M3U channels (`iptv-playlist-parser`, then +`createPlaylistObject`, the main-process `PARSE_M3U` and `NORMALIZE` phases) +and 50,000 XMLTV programmes (`StreamingEpgParser`, the EPG worker's parser). +Each workload runs on three inputs: `latin1` and `cyrillic` from the +synthetic generators (`charset` option of `synthetic-m3u.ts` and +`synthetic-xmltv.ts`, identical layout apart from titles), and `latin1-bom`, +the latin1 bytes behind a UTF-8 byte-order mark. The BOM forces two-byte +storage without changing content, which separates the encoding cost from +the effect that non-ASCII titles have on ASCII-only regexes. The XMLTV +parser receives 64 Ki-character slices of one decoded string rather than +per-chunk decoded buffers: slices keep the input's representation (a +per-chunk decode would make the BOM control one-byte after its first +chunk), and no multi-byte character is split. Before timing, an untimed +pass checks that the parsed titles match the fixture. + +The report gives P50 wall-clock and CPU time after one warm-up, plus +CPU-profile sample counts and top self frames from a separate profiled pass. +Inputs alternate within each round and the starting input rotates between +rounds. Prefer CPU time and samples on a busy machine. + +The 2026-09-27 measurement (plan item D1) found every workload under the 1.5x +threshold on Node 22 and inside Electron 43, so D2 regex prefilters were not +applied. Rerun the benchmark after changing either parser or when a user +reports slow imports of non-Latin playlists. + ## Adding a counter 1. Produce the value from the built output or from a deterministic probe, not @@ -412,3 +481,9 @@ differ from a Mac (12 and 571 there, the fast path without the Linux-only `node:test` (`pnpm nx run electron-backend-e2e:test-performance-harness`). 6. Validate a counter before it becomes a guardrail: one PR must show that lowering it moved wall-clock in the same journey. + +## Idle work + +The [idle work audit](idle-work-audit-2026-09.md) records what the app does +while the user does nothing, measured on the dashboard with the window visible +and minimized. Its **own thread** rows are candidate performance threads. diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index aa8762eed..a4d9c5eb4 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -287,9 +287,169 @@ It owns only transient presentation behavior: controller state; - `ControlsShortcuts` — document keyboard routing; - `ControlsSurface` — pointer/click/double-click surface interactions; -- `ControlsTimeline` — scrub state and timeline projections; and +- `ControlsTimeline` — scrub state and timeline projections; +- `ControlsTimelineHover` — the time under the pointer over the timeline; +- `app-player-timeline` — presentation of the timeline row (current time, + segment track, knob, hover label, remaining time / LIVE, recording + status); scrub `input`/`change` events go back to the controls component, + which owns reveal and seeking; +- `ControlsLayout` — the compact/wide dock mode from the host's width; +- `ControlsSettings` — the settings panel's groups, on/modified state and + open/close transitions (`controls-settings-groups.ts` holds the pure + group-availability rule); +- `app-player-settings-panel` — the panel / bottom sheet presentation; +- `ControlsUpNext` and `app-player-up-next-card` — the "Up next" card's + gate and presentation; and - `controls-view-model.ts` — derived display state. +### The dock + +The controls render as a **dock** (`.player-controls__bar`) with no surface +of its own: a timeline row above a three-column control row, sitting +directly on the video over the bottom scrim. The palette is a fixed set of +`--pc-*` custom properties on `:host` — accent blue `#4f8eff` for the +primary action and progress, cyan `#5cd6ff` for "something is on", violet +`#b599ff` for "a value was changed", and the `#e7ecf3` / `#9aa3b2` / +`#6b7384` text ramp. They are literal on purpose: the overlay is +theme-independent (see the UI guidelines' player theme boundary), and the +app's `--app-selection-color` is a different blue that would fight the video. + +- **Timeline row**: current time (`--pc-font-mono`, tabular) · drawn track + (`.player-controls__timeline-track` with one segment and an accent fill, + a white knob ringed in translucent blue) · remaining time as `−7:03` + (`formatRemainingTime`; the LIVE badge replaces it on live streams and + `--:--` stands in while no duration is known) · the recording status. + The `` stays as the interaction and accessibility + layer, invisible and full-size over the drawn track: dragging, arrow + keys, `aria-valuetext` and the focus ring (drawn on the track through + `:has(:focus-visible)`) all belong to it, so scrubbing semantics are + unchanged. Hovering the bar with a mouse shows a white marker and a + `1:40` label above the pointer (`ControlsTimelineHover`); touch never + hovers and a non-seekable timeline never labels. +- **Control row**: `minmax(0,1fr) auto minmax(0,1fr)`. Left: the volume + button, with the slider **inline** (72px) in the wide mode and behind + the hover/tap popover in the compact mode — inline, the button is a + plain mute toggle for every pointer type (`buttonClick(event, + { inlineSlider: true })`). Center: previous episode · −10s · **play** · + +10s · next episode. Right: the value chips, the `tune` button, + recording, picture-in-picture and fullscreen, end-aligned. +- **Play button** (`.player-controls__play`, `data-test-id + ="player-controls-play"`): a 52px filled accent circle with a white glyph, + not a Material icon button. Fills under a white glyph (play, active + `tune`) use `--pc-accent-blue-strong` `#3474e8` (4.4:1) rather than the + `#4f8eff` accent (3.2:1), and hover darkens to `#2a66d6` (5.3:1) without + scaling — `player-theme.e2e.ts` rasterizes the hovered and focused + states, and on a 1x Windows display the antialiased or resampled glyph + measured below 3:1 against the lighter fills. +- **Icon buttons** are 40px with a 12px radius (32px / 9px compact) through + Material's `--mat-icon-button-*` tokens; their hover is a flat + `rgba(255,255,255,.1)` layer. + +### Timeline segments + +The track is drawn as a row of segments, one flex item per segment with +`flex-grow` equal to its share of the duration and its own accent fill, so +a film's chapters or a catch-up recording's programmes read directly off +the bar. Each segment is placed absolutely at its time position (`left` = +start percent, `width` = share minus the 3px gap every segment but the +last keeps), so a drawn boundary sits exactly where the linear seek input +and the hover label change segment; a segment shorter than the gap +collapses instead of pushing its neighbours. The optional `timelineSegments` input +(`PlayerTimelineSegment { startSeconds, endSeconds, title }`) supplies +them; `normalizeTimelineSegments` (`controls-timeline-segments.ts`) clamps +to the duration, orders, drops empty and reversed entries, cuts overlaps at +the previous end and fills every gap with an untitled segment so the row +always covers `[0, duration]`. Without segments — every host today — the +row is one untitled segment, which is the plain bar. `ControlsTimeline` +owns the normalized list and the per-segment fill for the current scrub or +playback value; the hover label becomes `Chapter 2 · 12:40` over a titled +segment. Producers (EPG programmes for catch-up and timeshift playback, +mpv's chapter list) are separate follow-ups; the rendering, model and rules +are in place for them. + +### Up next card + +Near the end of a series episode the dock shows an **"Up next" card** +(`app-player-up-next-card`, `data-test-id="player-controls-up-next"`) in the +bottom-right corner above the controls: the next episode's still (or its +`S01E03` label as a tile), a 3px accent progress line when it was partly +watched, "Up next · in 7 min" and the title. The host supplies the item +through the optional `upNext` input (`PlayerUpNextItem { label, title, +thumbnailUrl, progressPercent }`); `ControlsUpNext` decides when it shows — +`seriesNavigation` capability, a finite duration with at +most `UP_NEXT_THRESHOLD_SECONDS` (8 min) left, not live, not `ended` (with +autoplay off nothing is scheduled, so no countdown), controls shown, +settings panel closed — and how many minutes remain (never below one). A +still that fails to load falls back to the label tile. A +click emits `nextEpisodeRequested` directly — not through the +transport's `canNextEpisode` guard, which is season-local — so the card +also works at a season's last episode. `PortalInlinePlayerComponent` +routes such a request through the Up Next rail selection +(`upNextEpisodeSelected`) whenever `seriesNavigation.canNext` is false, which +plays the next season's first episode; either path keeps fullscreen exactly +like the transport button. +The card is a glass surface that does not fade with the controls; the +compact dock uses a smaller variant without the trailing icon. + +Plumbing mirrors `mediaTitle`: `PortalInlinePlayerComponent.playerUpNext` +derives the item after the playing one from its `upNextEpisodes` input +(episodes only) → `WebPlayerViewComponent.upNext` → the four engine hosts → +`app-player-controls`. Movie and live hosts pass nothing. + +### Settings panel + +Every track, quality, speed and aspect choice lives behind one **`tune`** +button (`data-test-id="player-controls-settings-button"`) in a single +surface, `app-player-settings-panel` (`player-settings-panel.component.*`), +instead of five popovers. `ControlsMenuState` knows three menus — `volume`, +`settings`, `stats` — and `settingsFocus`, the group the panel was opened +for. `ControlsSettings` derives, from capabilities and state, which groups +exist (`getSettingsGroupAvailability`: audio needs more than one track, +subtitles a track or `externalSubtitles`, quality more than one level, +speed and aspect their capabilities), whether anything is on or changed, +and owns open/toggle/close; the `tune` button and the panel render only +while at least one group exists, and the availability reconciliation closes +the panel the moment the last group disappears. + +- **Roomy wide dock** (≥ 960px): two **value chips** precede `tune` — subtitles + (`closed_caption` + the selected track's label, or "Off") and speed + (`speed` + `1.25×`). Audio and aspect ratio have no chip: they are + panel-only. A chip click opens the panel **focused on its group** + (`settingsFocus`; the group scrolls into view and wears a brief ring); + right-click or long-press on the subtitle chip toggles subtitles without + opening anything (`ControlsSettings.toggleSubtitles`: the first embedded + track on, `-1` off; with no track to turn on it opens the group so the + file loader is reachable). While the panel is open the dock, title and + corner shift left by the panel's width (`--panel-open` modifiers, + `right: 370px`), the chips and the picture-in-picture / recording buttons + fold away, `tune` fills in the accent color, and fullscreen stays. +- **Compact dock, and wide docks below 960px**: no chips; `tune` carries **state dots** (5px, cyan when + subtitles are on or a non-default audio track is selected, violet when + speed, aspect or manual quality differ from their default) and the panel + opens as a **bottom sheet** (`--sheet` modifier: grip, two-column rows, + 24px segmented items) that replaces the dock while open. Picture-in- + picture and recording stay in the compact dock — the mock shows only + `tune` + fullscreen there, but those two are engine features a viewer + needs without opening anything. +- **Inside**: list groups (audio, subtitles, quality) use `menuitemradio` + rows with a check mark and a cyan selection; segmented groups (speed, + aspect) use `radio` items with a violet selection, and a selected default + (`1×`, the first aspect preset) stays neutral. The subtitle group carries + the load-file action and the delay / size / color sections that the + popover used to hold (same `player-controls-load-subtitle`, + `player-controls-subtitle-delay`, `player-controls-subtitle-style` test + ids). The panel is a `role="dialog"` with `tabindex="-1"`: opened from + the keyboard (the opener is `:focus-visible`) it takes focus, a pointer + open leaves focus alone (a focused control would capture Space from the + shortcuts), and closing with focus inside returns it to `tune`. While + the compact sheet replaces the dock, the dock is `inert`, so hidden + controls leave the tab order. A choice applies immediately and **keeps the panel open** — + `ControlsMenuSelection` no longer closes anything — so alternatives can be + compared against the running video; Escape, the close button, the `tune` + button, a click on the video surface or an outside pointerdown close it. +- **Colors** follow the color-as-state rule of the dock: cyan means "on", + violet means "changed", and neutral rows/items read as the default. + ### Stream info popover An `info` button in the **top-right corner** of the overlay opens a popover with @@ -360,13 +520,18 @@ Per engine: no info affordance, though its backends plumb the properties for parity. See [embedded-mpv-native.md](./embedded-mpv-native.md#stream-stats-properties). -### Top scrim +### Scrims `.player-controls__top-scrim` is a single pointer-transparent gradient at the -top of the player, mirroring the bottom bar's stops so both edges read as one -system. It renders whenever there is top chrome to back — the fullscreen media -title or the corner buttons — and fades with the controls without sliding (a -moving scrim edge is visible against video in a way a moving control is not). +top of the player (`max(28%, 112px)` tall), and +`.player-controls__bottom-scrim` its mirror behind the dock (55% tall, from +`rgba(4,7,11,.92)` at the edge through `.55` to transparent). Both read as +one system. The top one renders whenever there is top chrome to back — the +fullscreen media title or the corner buttons — the bottom one with the dock; +both fade with the controls without sliding (a moving scrim edge is visible +against video in a way a moving control is not). The dock itself has no +background: the bottom scrim is the only thing between the controls and the +picture. One element, not a background per consumer: the title and the corner overlap, and two gradients would darken the overlap twice. The title therefore carries @@ -688,7 +853,8 @@ instance owns shortcuts initially. Pointer, focus, or control interaction activates that instance through the normal reveal path. If the active instance becomes unavailable, playback shortcuts fall back to the most recently attached available instance; detaching the active instance also transfers ownership. -Escape remains a global dismissal action and closes popovers on every mounted +Escape remains a global dismissal action and closes popovers and the settings +panel on every mounted controls instance. Auto-hide pauses while the pointer is over the controls bar or keyboard focus @@ -882,24 +1048,41 @@ the last second (`wasTouchInteraction`). Three behaviors diverge from mouse: popover close (outside taps and other menu buttons dismiss it), and neither does the `focusout` of a pointer focus release. - **Coarse-pointer scrub sizing.** Under `@media (pointer: coarse)` the - timeline/volume sliders grow their input hit strip to 28px and the thumb to - 18px; the 4px visual track is unchanged. + timeline bar and the volume slider grow their hit strip to 28px and the + volume thumb to 16px; the drawn tracks are unchanged. -### Narrow-player layout +### Compact and wide layout -The controls host is a size query container (`player-controls`). At container -widths of 640px and below — phone-sized PWA viewports, but also small inline -players inside wide desktop windows — the single-row bar reflows to two rows: -the timeline takes a full-width first row, and the transport and actions -clusters split the second. The actions cluster's width is content-dependent -(volume, audio, subtitles, quality, speed, aspect, recording, PiP, and -fullscreen are all conditional), so in the narrow layout the cluster is -end-aligned, capped at the row width, and wraps when even a dedicated row cannot -hold it. Its popover anchors become static at this breakpoint so capability -panels position against the unclipped actions cluster and remain accessible -above every wrapped row. Icon buttons compact from 48px to 40px in this layout. -Between ~640px and the 720px viewport media query, the legacy single-row squeeze -(timeline absorbs the shrink) still applies. +The controls host is a size query container (`player-controls`), and the +dock has two modes split at **720px of container width**: `compact` at +719px and below — phone-sized PWA viewports, but also small inline players +inside wide desktop windows — and `wide` above. The split lives in two +places that must agree: the `@container player-controls (max-width: 719px)` +block in the stylesheet sizes the compact dock (14px gutters, 32px buttons, +36px play circle, 5px track), and `ControlsLayout` +(`COMPACT_LAYOUT_MAX_WIDTH`, a `ResizeObserver` on the host) drives the +template branches CSS cannot express — the inline volume slider versus +its popover. Without `ResizeObserver` (unit tests) the mode stays `wide`. + +A second threshold, `ROOMY_LAYOUT_MIN_WIDTH` (960px, `ControlsLayout.roomy`), +gates the wide dock's extras: the subtitle/speed chips and the settings +panel beside the video. Between 720px and 960px the dock stays wide (full +button sizes, inline volume) but folds the chips into `tune` with state +dots and opens settings as the bottom sheet, because the widest action row +(volume, series transport, two chips, tune/record/PiP/fullscreen) and the +dock beside a 370px panel do not fit there. The control row's side columns +are `minmax(min-content, 1fr)`, so if the actions still need more than half +of what the transport leaves, the transport slides off-centre instead of +the actions overlapping it or leaving the player. +Episode navigation stays in the compact transport: the series hosts rely on +those buttons, and the inline series player is often narrower than 720px. + +The actions cluster's width is content-dependent (audio, subtitles, quality, +speed, aspect, recording, PiP, and fullscreen are all conditional), so in +the compact layout the cluster is end-aligned, capped at the row width, and +wraps when the row cannot hold it. Its popover anchors become static at this +breakpoint so capability panels position against the unclipped actions +cluster and remain accessible above every wrapped row. When a volume-capable controller first attaches, an existing `localStorage` volume preference is applied before the first controller snapshot can reconcile @@ -1206,11 +1389,11 @@ regressions: ## Advanced subtitle support -The subtitle popover carries three capability-gated extensions beyond track -selection (#1408): loading an external subtitle file, adjusting the subtitle -timing offset, and styling subtitle text (size + color). Each is honest per -engine — an engine that cannot support a control simply never advertises the -capability, and the UI is not rendered. +The subtitles group of the settings panel carries three capability-gated +extensions beyond track selection (#1408): loading an external subtitle +file, adjusting the subtitle timing offset, and styling subtitle text (size ++ color). Each is honest per engine — an engine that cannot support a control +simply never advertises the capability, and the UI is not rendered. Contract surface: @@ -1221,11 +1404,12 @@ Contract surface: environment's picker), `setSubtitleDelay(seconds)`, and `setSubtitleStyle(style)`. -The subtitle menu stays reachable with an empty track list whenever +The subtitles group stays reachable with an empty track list whenever `externalSubtitles` is set — loading a file is what creates the first track. -Delay and style rows keep the popover open, because these settings are tuned -iteratively against the running video (`ControlsSubtitleSettings` owns those -interactions); the load action closes it because a file dialog opens on top. +Delay and style rows keep the panel open like every other choice, because +these settings are tuned iteratively against the running video +(`ControlsSubtitleSettings` owns those interactions); the file dialog the +load action opens sits on top of the still-open panel. Persistence: the style (size/color) is a cross-engine preference stored under the `subtitleStyle` localStorage key (`subtitle-style.ts`), the same mechanism @@ -1385,6 +1569,21 @@ libs/ui/playback/src/lib/player-controls/ ├── player-controls.component.scss ├── controls-feedback.ts ├── controls-format.utils.ts +├── controls-layout.ts +├── controls-timeline-hover.ts +├── controls-timeline-segments.ts +├── controls-settings.ts +├── controls-settings-groups.ts +├── controls-up-next.ts +├── player-timeline.component.ts +├── player-timeline.component.html +├── player-timeline.component.scss +├── player-up-next-card.component.ts +├── player-up-next-card.component.html +├── player-up-next-card.component.scss +├── player-settings-panel.component.ts +├── player-settings-panel.component.html +├── player-settings-panel.component.scss ├── controls-fullscreen.ts ├── controls-menu-selection.ts ├── controls-menu-state.ts diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index e1d6f2311..93cd022ef 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -364,7 +364,11 @@ form is the one specs should use. webServer: [ { command: webServerCommand /* web:serve */, url: baseURL }, { - command: 'pnpm nx run stalker-mock-server:serve', + command: 'node --import tsx apps/stalker-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`, reuseExistingServer: !process.env['CI'], }, @@ -374,12 +378,20 @@ webServer: [ Playwright waits for every server to be healthy before starting tests. If one is already running (e.g. in local dev), it reuses the existing instance. +The mock runs as a single `node` process instead of `pnpm nx run +stalker-mock-server:serve`, because Nx starts its command in a detached +process group that Playwright's process-group kill never reached, so the server +outlived the run. The +[Xtream mock's Playwright section](xtream-mock-server.md#playwright-integration) +has the details. + **`MOCK_PORT` relocates the whole run.** Playwright's health-check URL and the `MOCK_SERVER` constants in the specs read it, and `main.ts` resolves the server's port as `PORT`, then `MOCK_PORT`, then `3210` — so -`MOCK_PORT=3310 pnpm exec playwright test …` starts the Nx-managed mock on +`MOCK_PORT=3310 pnpm exec playwright test …` starts the mock on 3310 and points every spec at it, which is how two worktrees run E2E side by -side when one already holds 3210. This only works because the `serve` and +side when one already holds 3210. A hand-started Nx mock honours the same +knobs because the `serve` and `serve-with-watch` targets no longer pin `PORT` in `project.json`: an `env` entry in `nx:run-commands` overrides the shell (`{...process.env, ...env}`), so a pinned value silently discarded every override. `PORT=3310 pnpm nx run diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index c06c43c94..352f8cb79 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -75,7 +75,10 @@ Render rules: full-bleed. All rails and the hero are skipped. 3. The hero (`lib-dashboard-hero`) renders when it has at least one slide; see [Cinematic Hero](#cinematic-hero). It shows its own skeleton while - the first history load runs. + the first history load runs. An item enters recent history only after + its stream has really played (see "Recently Viewed Confirmation" in + `embedded-inline-playback.md`), so a channel that failed at once never + becomes a hero slide. 4. Each rail is emitted via `@if (cards.length > 0)`. Empty rails are hidden — there is no "empty widget" placeholder. 5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index ffcbd26af..ae5f36d17 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -550,13 +550,37 @@ The mock server is listed as a third `webServer` entry: ```typescript { - command: 'pnpm nx run xtream-mock-server:serve', - url: 'http://localhost:3211/health', + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + env: { + NODE_ENV: 'development', + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`, reuseExistingServer: !process.env['CI'], cwd: workspaceRoot, } ``` +Every mock-server `webServer` entry uses this launch form, never +`pnpm nx run …:serve`. The web and Electron E2E configs +(`apps/web-e2e/playwright.config.ts`, +`apps/electron-backend-e2e/playwright.config.ts`) start both the Stalker and +the Xtream mock. The journeys and Xtream benchmark configs start only the +Xtream mock, on their own loopback ports. The packaged and other performance +configs start no mock. +Playwright stops a `webServer` by sending `SIGKILL` to the process group it +spawned (`taskkill /T /F` on Windows), and Nx `run-commands` starts its command +in a detached process group of its own. The kill therefore reached only +`pnpm`/`nx`, and the `tsx` server was reparented and kept the port, which made +the next run fail with "…/health is already used" or, with +`reuseExistingServer`, silently reuse a stale server. `node --import tsx` keeps +the server a single process in Playwright's group. `TSX_TSCONFIG_PATH` stands in +for the serve target's `--tsconfig` flag and is required for the `@iptvnator/*` +path aliases. `project-config.spec.ts` pins which configs start which mock, +and fails if any of them launches a mock through Nx or a new config starts one +without being listed there. The `serve` targets remain the entry point +for starting a mock by hand. + ### Request Interception The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these: diff --git a/libs/playback/data-access/jest.config.ts b/libs/playback/data-access/jest.config.ts new file mode 100644 index 000000000..bb6df3689 --- /dev/null +++ b/libs/playback/data-access/jest.config.ts @@ -0,0 +1,22 @@ +export default { + displayName: 'playback-data-access', + preset: '../../../jest.preset.js', + setupFilesAfterEnv: ['/src/test-setup.ts'], + coverageDirectory: '../../../coverage/libs/playback/data-access', + transform: { + '^.+\\.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$', + }, + ], + }, + transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment', + ], + moduleFileExtensions: ['ts', 'js', 'html'], +}; diff --git a/libs/playback/data-access/project.json b/libs/playback/data-access/project.json new file mode 100644 index 000000000..263f324b9 --- /dev/null +++ b/libs/playback/data-access/project.json @@ -0,0 +1,21 @@ +{ + "name": "playback-data-access", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/playback/data-access/src", + "prefix": "lib", + "projectType": "library", + "tags": ["scope:shared", "domain:playback", "type:data-access"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/playback/data-access/jest.config.ts", + "tsConfig": "libs/playback/data-access/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libs/playback/data-access/src/index.ts b/libs/playback/data-access/src/index.ts new file mode 100644 index 000000000..d91e2e8dd --- /dev/null +++ b/libs/playback/data-access/src/index.ts @@ -0,0 +1 @@ +export * from './lib/playback-history-gate.service'; diff --git a/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts new file mode 100644 index 000000000..230418cd0 --- /dev/null +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.spec.ts @@ -0,0 +1,209 @@ +import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces'; +import { PlaybackHistoryGate } from './playback-history-gate.service'; + +describe('PlaybackHistoryGate', () => { + let gate: PlaybackHistoryGate; + + beforeEach(() => { + gate = new PlaybackHistoryGate(); + }); + + it('holds a write until its playback is confirmed', () => { + const commit = jest.fn(); + gate.defer( + { sessionKey: 'live:p1:c1', streamUrls: ['http://stream/1'] }, + commit + ); + + expect(commit).not.toHaveBeenCalled(); + + gate.confirm({ sessionKey: 'live:p1:c1' }); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('never commits a write whose stream is not confirmed', () => { + const failed = jest.fn(); + const played = jest.fn(); + gate.defer({ streamUrls: ['http://stream/failed'] }, failed); + gate.defer({ streamUrls: ['http://stream/played'] }, played); + + gate.confirm({ streamUrls: ['http://stream/played'] }); + + expect(failed).not.toHaveBeenCalled(); + expect(played).toHaveBeenCalledTimes(1); + }); + + it('does not let the same URL played in another playlist confirm a write', () => { + // Playlist A's attempt failed; the same stream then plays in B. + const failedInA = jest.fn(); + const playedInB = jest.fn(); + gate.defer( + { sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] }, + failedInA + ); + gate.defer( + { sessionKey: 'live:b:c9', streamUrls: ['http://shared/1'] }, + playedInB + ); + + gate.confirm({ + sessionKey: 'live:b:c9', + streamUrls: ['http://shared/1'], + }); + + expect(failedInA).not.toHaveBeenCalled(); + expect(playedInB).toHaveBeenCalledTimes(1); + }); + + it('does not let a URL-only confirmation commit a write deferred with a session key', () => { + // Playlist A's attempt failed; the same URL then opens in MPV/VLC, + // whose app-wide session confirmation carries no session key. + const failedInA = jest.fn(); + gate.defer( + { sessionKey: 'live:a:c1', streamUrls: ['http://shared/1'] }, + failedInA + ); + + gate.confirm({ streamUrls: ['http://shared/1'] }); + + expect(failedInA).not.toHaveBeenCalled(); + }); + + it('matches writes without a session key by stream URL', () => { + const portalWrite = jest.fn(); + const externalWrite = jest.fn(); + gate.defer({ streamUrls: ['http://portal/tmp'] }, portalWrite); + gate.defer({ streamUrls: ['http://portal/vod'] }, externalWrite); + + // An inline player (with its own key) and an MPV/VLC session. + gate.confirm({ + sessionKey: 'live:portal:9', + streamUrls: ['http://portal/tmp'], + }); + gate.confirm({ streamUrls: ['http://portal/vod'] }); + + expect(portalWrite).toHaveBeenCalledTimes(1); + expect(externalWrite).toHaveBeenCalledTimes(1); + }); + + it('commits each confirmed write once', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('commits every writer deferred for the same playback', () => { + const first = jest.fn(); + const second = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, first); + gate.defer({ streamUrls: ['http://stream/1'] }, second); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it('commits immediately when a write has nothing to match on', () => { + const commit = jest.fn(); + + gate.defer({ sessionKey: ' ', streamUrls: [undefined, null] }, commit); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('ignores confirmations with nothing to match on', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, commit); + + gate.confirm({ streamUrls: [undefined, ''] }); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('drops the oldest unconfirmed writes beyond its bound', () => { + const oldest = jest.fn(); + gate.defer({ streamUrls: ['http://stream/oldest'] }, oldest); + for (let index = 0; index < 20; index += 1) { + gate.defer({ streamUrls: [`http://stream/${index}`] }, jest.fn()); + } + + gate.confirm({ streamUrls: ['http://stream/oldest'] }); + + expect(oldest).not.toHaveBeenCalled(); + }); + + describe('MPV/VLC sessions', () => { + const originalElectron = window.electron; + let emit: (session: Partial) => void; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { + onExternalPlayerSessionUpdate: ( + callback: (session: ExternalPlayerSession) => void + ) => { + emit = (session) => + callback({ + streamUrl: 'http://vod/1.mkv', + ...session, + } as ExternalPlayerSession); + return () => undefined; + }, + }, + }); + gate = new PlaybackHistoryGate(); + }); + + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: originalElectron, + }); + }); + + it('confirms a stream once its external player has opened', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit); + + emit({ status: 'launching' }); + expect(commit).not.toHaveBeenCalled(); + + emit({ status: 'opened' }); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not confirm a launch that failed', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['http://vod/1.mkv'] }, commit); + + emit({ status: 'launching' }); + emit({ status: 'error' }); + + expect(commit).not.toHaveBeenCalled(); + }); + }); + + it('keeps committing the other writes when one throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const failing = jest.fn(() => { + throw new Error('db down'); + }); + const healthy = jest.fn(); + gate.defer({ streamUrls: ['http://stream/1'] }, failing); + gate.defer({ streamUrls: ['http://stream/1'] }, healthy); + + gate.confirm({ streamUrls: ['http://stream/1'] }); + + expect(healthy).toHaveBeenCalledTimes(1); + consoleError.mockRestore(); + }); +}); diff --git a/libs/playback/data-access/src/lib/playback-history-gate.service.ts b/libs/playback/data-access/src/lib/playback-history-gate.service.ts new file mode 100644 index 000000000..a3ba9e75b --- /dev/null +++ b/libs/playback/data-access/src/lib/playback-history-gate.service.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@angular/core'; + +/** What a playback is known by, for correlating a write with its playback. */ +export interface PlaybackHistoryTarget { + /** + * The playing host's `playbackSessionKey` (source and content scoped). + * A write deferred with one is only confirmed by that same key. + */ + readonly sessionKey?: string | null; + /** Stream URLs; what a write without a session key is matched by. */ + readonly streamUrls?: readonly (string | null | undefined)[]; +} + +interface NormalizedTarget { + readonly sessionKey: string | null; + readonly streamUrls: ReadonlySet; +} + +interface PendingHistoryWrite { + readonly target: NormalizedTarget; + readonly commit: () => void; +} + +/** + * Upper bound on unconfirmed writes. Each click on a channel that never + * plays leaves one behind; the oldest is dropped rather than kept forever. + */ +const MAX_PENDING_HISTORY_WRITES = 20; + +/** + * Holds "recently viewed" writes back until the stream really plays. + * + * The code that resolves a channel or movie is not the code that plays it: + * a Stalker link is resolved in the store, played by whichever view mounts + * the player, or handed to MPV/VLC. Writers therefore `defer` the write under + * what the playback will be known by, and whoever observes the playback + * `confirm`s it: the inline players once the stream has advanced for a + * couple of seconds, and the external-player session as soon as MPV/VLC is + * launched. A stream that fails before that point never reaches history or + * the dashboard hero. + * + * A write deferred with a session key is only confirmed by the same key: the + * same stream URL can sit in two playlists, and playing it in one — inline + * or in MPV/VLC, whose app-wide confirmation knows only the URL — must not + * record a failed attempt in the other. Writers that cannot know the + * playing host's key (portal resolvers, collection tabs) defer by stream + * URL, which any confirmation of that URL matches. + * Several writers may defer for the same playback; one confirmation commits + * all of them. A write with nothing to match on cannot be confirmed and is + * committed immediately, as before this gate existed. + */ +@Injectable({ providedIn: 'root' }) +export class PlaybackHistoryGate { + private pending: PendingHistoryWrite[] = []; + + constructor() { + // MPV/VLC cannot report whether a live stream plays, so a launch + // that opened is the confirmation. Subscribed here rather than in + // the app-wide external-playback service so the gate stays off the + // initial bundle: it is created by the first deferred write, which + // always precedes the launch it waits for. + window.electron?.onExternalPlayerSessionUpdate?.((session) => { + if (session.status === 'opened' || session.status === 'playing') { + this.confirm({ streamUrls: [session.streamUrl] }); + } + }); + } + + defer(target: PlaybackHistoryTarget, commit: () => void): void { + const normalized = normalizeTarget(target); + if (!normalized.sessionKey && normalized.streamUrls.size === 0) { + runCommit(commit); + return; + } + + this.pending.push({ target: normalized, commit }); + if (this.pending.length > MAX_PENDING_HISTORY_WRITES) { + this.pending.shift(); + } + } + + confirm(target: PlaybackHistoryTarget): void { + const confirmed = normalizeTarget(target); + const matched: PendingHistoryWrite[] = []; + const remaining: PendingHistoryWrite[] = []; + for (const write of this.pending) { + (matchesTarget(write.target, confirmed) ? matched : remaining).push( + write + ); + } + this.pending = remaining; + matched.forEach((write) => runCommit(write.commit)); + } +} + +function matchesTarget( + write: NormalizedTarget, + confirmed: NormalizedTarget +): boolean { + if (write.sessionKey) { + return write.sessionKey === confirmed.sessionKey; + } + return [...write.streamUrls].some((url) => confirmed.streamUrls.has(url)); +} + +function normalizeTarget(target: PlaybackHistoryTarget): NormalizedTarget { + return { + sessionKey: normalizeKey(target.sessionKey), + streamUrls: new Set( + (target.streamUrls ?? []) + .map(normalizeKey) + .filter((url): url is string => url !== null) + ), + }; +} + +function normalizeKey(key: string | null | undefined): string | null { + const trimmed = key?.trim() ?? ''; + return trimmed.length > 0 ? trimmed : null; +} + +function runCommit(commit: () => void): void { + try { + commit(); + } catch (error) { + // History is best effort: a failed write must not break playback or + // the other writes confirmed by the same stream. + console.error('Failed to record recently viewed item:', error); + } +} diff --git a/libs/playback/data-access/src/test-setup.ts b/libs/playback/data-access/src/test-setup.ts new file mode 100644 index 000000000..1ca0136b9 --- /dev/null +++ b/libs/playback/data-access/src/test-setup.ts @@ -0,0 +1,9 @@ +// Keep Zone's fakeAsync helpers while preserving the Angular 21 test scheduler. +import 'zone.js'; +import 'zone.js/testing'; +import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'; + +setupZonelessTestEnv({ + errorOnUnknownElements: true, + errorOnUnknownProperties: true, +}); diff --git a/libs/playback/data-access/tsconfig.json b/libs/playback/data-access/tsconfig.json new file mode 100644 index 000000000..8fcb75804 --- /dev/null +++ b/libs/playback/data-access/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "isolatedModules": true, + "target": "es2022", + "moduleResolution": "bundler", + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "emitDecoratorMetadata": false, + "module": "preserve", + "ignoreDeprecations": "6.0" + }, + "angularCompilerOptions": { + "enableI18nLegacyMessageIdFormat": false, + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/libs/playback/data-access/tsconfig.lib.json b/libs/playback/data-access/tsconfig.lib.json new file mode 100644 index 000000000..7fba372dc --- /dev/null +++ b/libs/playback/data-access/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../dist/out-tsc", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/playback/data-access/tsconfig.spec.json b/libs/playback/data-access/tsconfig.spec.json new file mode 100644 index 000000000..71dd0db03 --- /dev/null +++ b/libs/playback/data-access/tsconfig.spec.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": false, + "outDir": "../../../dist/out-tsc", + "module": "preserve", + "target": "es2016", + "types": ["jest", "node"], + "moduleResolution": "bundler" + }, + "files": ["src/test-setup.ts", "../../../global.d.ts"], + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts new file mode 100644 index 000000000..889b9f0ed --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player-recent-history.spec.ts @@ -0,0 +1,286 @@ +import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { of } from 'rxjs'; +import { EpgService } from '@iptvnator/epg/data-access'; +import { selectActivePlaylist } from '@iptvnator/m3u-state'; +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 { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { + PlaylistMeta, + Settings, + VideoPlayer, +} from '@iptvnator/shared/interfaces'; +import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component'; +import { + dataServiceMock, + epgServiceMock, + epgUrlSetting, + epgViewMode, + externalSession, + player, + playlistId, + playlistsServiceMock, + routerMock, + sampleChannel, + showCaptions, + storeMock, + stripCountryPrefix, + syncStoreState, + translateServiceProvider, +} from './video-player.spec-harness'; + +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', () => ({})); + +/** + * When a selected M3U channel becomes a recently viewed item (and with it + * the dashboard hero). Kept apart from `video-player.component.spec.ts`, + * which sits at the spec line budget; the template is reduced to nothing, + * because the players themselves are stood in for by the history gate. + */ +describe('VideoPlayerComponent — recently viewed history', () => { + let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent; + let fixture: ComponentFixture; + let component: VideoPlayerComponentInstance; + let gate: PlaybackHistoryGate; + const activePlaylist = signal | null>(null); + const harnessSelectSignal = storeMock.selectSignal.getMockImplementation()!; + + beforeAll(async () => { + ({ VideoPlayerComponent } = await import('./video-player.component')); + }); + + beforeEach(async () => { + syncStoreState(null); + playlistId.set('playlist-1'); + player.set(VideoPlayer.VideoJs); + showCaptions.set(false); + stripCountryPrefix.set(false); + externalSession.set(null); + storeMock.dispatch.mockClear(); + activePlaylist.set({ _id: 'playlist-1', recentlyViewed: [] }); + // The harness has no playlist meta; the history write updates it. + storeMock.selectSignal.mockImplementation(((selector: unknown) => + selector === selectActivePlaylist + ? activePlaylist + : harnessSelectSignal(selector)) as typeof harnessSelectSignal); + playlistsServiceMock.addM3uRecentlyViewed.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VideoPlayerComponent], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ id: playlistId(), view: 'all' }), + queryParams: of({}), + snapshot: { + data: { layout: 'workspace' }, + queryParams: {}, + }, + }, + }, + { provide: Router, useValue: routerMock }, + { provide: Store, useValue: storeMock }, + translateServiceProvider, + { provide: DataService, useValue: dataServiceMock }, + { + provide: RuntimeCapabilitiesService, + useValue: { + supportsEpg: false, + isElectron: false, + supportsRemoteControl: false, + }, + }, + { provide: PlaylistsService, useValue: playlistsServiceMock }, + { provide: EpgService, useValue: epgServiceMock }, + { + provide: PlaylistContextFacade, + useValue: { resolvedPlaylistId: playlistId }, + }, + { + provide: TmdbEnrichmentService, + useValue: { isEnabled: () => false }, + }, + { + provide: SettingsStore, + useValue: { + player, + showCaptions, + stripCountryPrefix, + m3uVodDetails: signal(true), + resolvedEpgViewMode: epgViewMode, + resolvedEpgOffsetMinutes: signal(0), + epgUrl: epgUrlSetting, + }, + }, + { + provide: StorageMap, + useValue: { + get: jest.fn(() => + of({ player: player() } as Partial) + ), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession: externalSession }, + }, + ], + }) + .overrideComponent(VideoPlayerComponent, { + set: { + imports: [], + template: + '', + }, + }) + .compileComponents(); + + gate = TestBed.inject(PlaybackHistoryGate); + fixture = TestBed.createComponent(VideoPlayerComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + afterEach(() => { + fixture?.destroy(); + storeMock.selectSignal.mockImplementation(harnessSelectSignal); + }); + + function select(channel = sampleChannel): void { + syncStoreState(channel); + fixture.detectChanges(); + } + + it('records an inline channel only once the player confirms it plays', () => { + select(); + + // Selected, but the stream has not played yet (or failed at once). + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + gate.confirm({ sessionKey: component.playbackSessionKey() }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ + source: 'm3u', + url: sampleChannel.url, + title: 'Sample TV', + }) + ); + }); + + it('keeps the playing source when recording updates the playlist', () => { + select(); + const playback = component.embeddedPlayback(); + expect(playback).not.toBeNull(); + + // What the confirmed write dispatches back into the store. + activePlaylist.set({ + _id: 'playlist-1', + recentlyViewed: [{ source: 'm3u', id: sampleChannel.url } as never], + }); + fixture.detectChanges(); + + // A new source object would remount the engine and restart the + // stream right after its first two seconds. + expect(component.embeddedPlayback()).toBe(playback); + }); + + it('never records a channel whose stream did not play', () => { + select(); + select({ + ...sampleChannel, + id: 'channel-2', + url: 'http://localhost/second.m3u8', + name: 'Second TV', + }); + + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: ['http://localhost/second.m3u8'], + }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ title: 'Second TV' }) + ); + }); + + it('records a second row of the same URL that plays after the first failed', () => { + // One stream listed twice in a playlist, under two channel ids. + select(); + select({ + ...sampleChannel, + id: 'channel-1-copy', + name: 'Sample TV HD', + }); + + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [sampleChannel.url], + }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledWith( + 'playlist-1', + expect.objectContaining({ title: 'Sample TV HD' }) + ); + }); + + it('records a radio station once the audio player confirms it', () => { + const radio = { ...sampleChannel, radio: 'true' }; + select(radio); + + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + // The same URL opened elsewhere (MPV/VLC, another playlist) does not. + gate.confirm({ streamUrls: [radio.url] }); + expect( + playlistsServiceMock.addM3uRecentlyViewed + ).not.toHaveBeenCalled(); + + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [radio.url], + }); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + }); + + it('records on selection with an external player, which cannot confirm', () => { + player.set(VideoPlayer.MPV); + fixture.detectChanges(); + + select(); + + expect(playlistsServiceMock.addM3uRecentlyViewed).toHaveBeenCalledTimes( + 1 + ); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index aa4710be4..1580b5338 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -106,10 +106,13 @@ } @else { diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index c2e29d313..39040c24d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -121,6 +121,7 @@ import { SettingsStore, TmdbEnrichmentService, } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { Channel, createDevLogger, @@ -238,6 +239,7 @@ export class VideoPlayerComponent private readonly hostElement = inject(ElementRef); private readonly dataService = inject(DataService); private readonly playlistsService = inject(PlaylistsService); + private readonly historyGate = inject(PlaybackHistoryGate); private readonly playlistContext = inject(PlaylistContextFacade); private readonly router = inject(Router); private readonly runtime = inject(RuntimeCapabilitiesService); @@ -487,7 +489,17 @@ export class VideoPlayerComponent epgParams: '', } as Channel; }); - readonly embeddedPlayback = computed(() => { + /** + * Compared by value: it also reads the playlist meta, which changes while + * a channel plays (the recently viewed write, a favourite toggle). A new + * but identical object would hand the player a new source and restart + * the stream. + */ + readonly embeddedPlayback = computed( + () => this.resolveEmbeddedPlayback(), + { equal: (a, b) => JSON.stringify(a) === JSON.stringify(b) } + ); + private resolveEmbeddedPlayback(): ResolvedPortalPlayback | null { const activeChannel = this.activeChannel(); const playbackTarget = this.playbackChannel(); @@ -530,7 +542,7 @@ export class VideoPlayerComponent // — extract lazily so they work without a re-import. drm: playbackTarget.drm ?? extractDrmFromRaw(playbackTarget.raw), }; - }); + } readonly sidebarStorageKey = computed(() => this.activeView() === 'groups' ? M3U_GROUPS_SIDEBAR_STORAGE_KEY @@ -838,13 +850,17 @@ export class VideoPlayerComponent return; } - const nextKey = `${playlistId}::${activeChannel.url}`; + // Channel identity too: two rows of one URL are separate + // attempts, confirmed under their own session keys. + const nextKey = `${playlistId}::${activeChannel.id}::${activeChannel.url}`; if (this.lastRecordedRecentKey === nextKey) { return; } this.lastRecordedRecentKey = nextKey; - void this.persistRecentlyViewedChannel(playlistId, activeChannel); + untracked(() => + this.recordRecentlyViewedChannel(playlistId, activeChannel) + ); }); effect(() => { @@ -1200,6 +1216,32 @@ export class VideoPlayerComponent ); } + /** + * Inline playback (video, radio, movie detail) records the channel only + * once it has really played, so a stream that fails right away never + * reaches history or the dashboard hero. MPV/VLC cannot report that for + * a live stream, so they keep recording on selection. + */ + private recordRecentlyViewedChannel( + playlistId: string, + channel: Channel + ): void { + const record = () => + void this.persistRecentlyViewedChannel(playlistId, channel); + if (channel.radio !== 'true' && !this.shouldShowInlinePlayer(channel)) { + record(); + return; + } + + this.historyGate.defer( + { + sessionKey: this.playbackSessionKey(), + streamUrls: [channel.url], + }, + record + ); + } + private async persistRecentlyViewedChannel( playlistId: string, channel: Channel diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts index 607e35535..f405f92d5 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.spec-stubs.ts @@ -97,6 +97,7 @@ export class StubAudioPlayerComponent { readonly channelName = input(''); readonly channelLogo = input(''); readonly volume = input(null); + readonly playbackSessionKey = input(null); readonly volumeChange = output(); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index aa91b7fe9..f3fce3fd1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -8,8 +8,10 @@ import { PORTAL_PLAYER, UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { ElectronStreamHeadersService } from '@iptvnator/ui/playback'; import { UnifiedLiveTimeshift } from './unified-live-catchup'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { UnifiedLiveSelectionGeneration } from './unified-live-selection-generation'; export interface UnifiedLiveSelection { @@ -71,6 +73,7 @@ export function createUnifiedLiveSelection(options: { const recentData = inject(UnifiedRecentDataService); const streamHeaders = inject(ElectronStreamHeadersService); const portalPlayer = inject(PORTAL_PLAYER); + const historyGate = inject(PlaybackHistoryGate); /** Stream URL of the radio playback whose header override this configured. */ let radioHeaderScopeUrl: string | null = null; @@ -119,6 +122,27 @@ export function createUnifiedLiveSelection(options: { }); }; + /** Set by `dispose`: the host is gone and must not be notified. */ + let disposed = false; + + /** + * Runs once the row has really played. That is seconds after selection, + * so the user may have moved on — the write still happened, and an open + * Recently Viewed list must still move the row to the top. + */ + const recordLivePlayback = async ( + item: UnifiedCollectionItem + ): Promise => { + try { + const updatedItem = await recentData.recordLivePlayback(item); + if (!disposed) { + options.onItemPlayed(updatedItem); + } + } catch { + // Keep playback/EPG visible even if history persistence fails. + } + }; + const close = (): void => { options.generation.next(); pendingActivation = null; @@ -223,14 +247,25 @@ export function createUnifiedLiveSelection(options: { void portalPlayer.openResolvedPlayback(detail.playback); } - try { - const updatedItem = await recentData.recordLivePlayback(item); - if (generation === options.generation.current()) { - options.onItemPlayed(updatedItem); - } - } catch { - // Keep playback/EPG visible even if history persistence fails. - } + // Selecting a channel is not watching it: the row moves to the + // top of Recently Viewed once its stream has really played. Played + // inline, the tab's playlist-scoped session key — the one its + // players confirm with — survives a switch to catch-up and does + // not match the same URL in another playlist. MPV/VLC can only + // confirm the launched URL, so an external row defers by URL. + const playsInline = !options.shouldOpenExternalPlayback( + detail, + true + ); + historyGate.defer( + { + sessionKey: playsInline + ? createUnifiedLivePlaybackSessionKey(item) + : null, + streamUrls: [detail.playback.streamUrl], + }, + () => void recordLivePlayback(item) + ); if ( generation === options.generation.current() && @@ -264,6 +299,7 @@ export function createUnifiedLiveSelection(options: { activate, close, dispose(): void { + disposed = true; // Invalidate a playback continuation still awaiting its header // IPC and drop any radio credentials owned by this tab. options.generation.next(); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index 24ec1f36a..54a2d27f1 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -58,6 +58,7 @@ activeDetail()?.playback?.title || '' " + [playbackSessionKey]="playbackSessionKey()" /> } @else { { window.electron = originalElectron; }); + /** What the mounted player reports once the stream has really played. */ + async function playStream(streamUrl: string): Promise { + TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: [streamUrl], + }); + await fixture.whenStable(); + } + it('renders controlled M3U EPG and records recent history on selection', async () => { const item = buildLiveItem('m3u'); streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ @@ -200,6 +210,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/m3u.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith( item, @@ -659,6 +671,8 @@ describe('UnifiedLiveTabComponent', () => { streamUrl: 'https://example.com/m3u.m3u8', }) ); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/m3u.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled(); @@ -712,6 +726,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/radio.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect( @@ -782,6 +798,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/jazz.mp3'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); expect( @@ -841,6 +859,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/xtream.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) @@ -874,6 +894,8 @@ describe('UnifiedLiveTabComponent', () => { fixture.detectChanges(); await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + await playStream('https://example.com/stalker.m3u8'); expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); const timeline = fixture.debugElement.query( By.directive(StubEpgTimelineComponent) @@ -897,6 +919,10 @@ describe('UnifiedLiveTabComponent', () => { component.autoOpenHandled, 'emit' ); + // Like the collection page: a handled auto-open is cleared. + component.autoOpenHandled.subscribe(() => + fixture.componentRef.setInput('autoOpenItem', null) + ); streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise); recentData.recordLivePlayback.mockResolvedValue({ diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts index 9c32c3d7a..4e8d81360 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.fullscreen-panel.spec.ts @@ -10,6 +10,7 @@ import { import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg'; import { ResizableDirective } from '@iptvnator/ui/components'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces'; import { PORTAL_PLAYER, @@ -21,6 +22,7 @@ import { } from '@iptvnator/portal/shared/data-access'; import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; +import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key'; import { StubAudioPlayerComponent, StubEpgTimelineComponent, @@ -173,6 +175,105 @@ describe('UnifiedLiveTabComponent fullscreen channel panel', () => { } ); + it('correlates an inline row with its session key, not its stream URL', async () => { + portalPlayer.isEmbeddedPlayer.mockReturnValue(true); + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + const gate = TestBed.inject(PlaybackHistoryGate); + + // The same URL played from another playlist does not confirm it. + gate.confirm({ + sessionKey: 'live:another-playlist:m3u-channel', + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).not.toHaveBeenCalled(); + + // The row's own player does, even after switching to catch-up. + gate.confirm({ + sessionKey: component.playbackSessionKey(), + streamUrls: ['https://example.com/archive.m3u8'], + }); + await fixture.whenStable(); + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + + it('records a row played in MPV/VLC from the URL its launch confirms', async () => { + const item = buildM3uLiveItem(); + streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }); + recentData.recordLivePlayback.mockResolvedValue(item); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + await fixture.whenStable(); + await component.onChannelSelected(component.channelsForList()[0]); + expect(portalPlayer.openResolvedPlayback).toHaveBeenCalled(); + + // The external session update carries only the launched URL. + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [item.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + }); + + it('moves a row that played to the top of Recently Viewed after another row was selected', async () => { + const first = buildM3uLiveItem(); + const second: UnifiedCollectionItem = { + ...first, + uid: 'm3u::pl-1::m3u-channel-2', + name: 'M3U Live 2', + channelId: 'm3u-channel-2', + tvgId: 'm3u-channel-2', + streamUrl: 'https://example.com/m3u-2.m3u8', + }; + streamResolver.resolveM3uPlaybackDetail.mockImplementation( + async (item: UnifiedCollectionItem) => ({ + epgMode: 'm3u', + playback: { streamUrl: item.streamUrl, title: item.name }, + epgPrograms: [], + }) + ); + recentData.recordLivePlayback.mockImplementation( + async (item: UnifiedCollectionItem) => item + ); + const played = jest.fn(); + component.itemPlayed.subscribe(played); + fixture.componentRef.setInput('items', [first, second]); + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + const row = (name: string) => + component + .channelsForList() + .find((channel) => channel.name === name)!; + + await component.onChannelSelected(row('M3U Live')); + await component.onChannelSelected(row('M3U Live 2')); + // The first row's playback is confirmed only now, after the switch. + TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: createUnifiedLivePlaybackSessionKey(first), + streamUrls: [first.streamUrl], + }); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(first); + expect(played).toHaveBeenCalledWith(first); + }); + it('keeps the current detail (and its fullscreen player) mounted while the next selection resolves', async () => { const first = buildM3uLiveItem(); const second: UnifiedCollectionItem = { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts index b383ad633..005fc0ef4 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.spec-stubs.ts @@ -101,6 +101,7 @@ export class StubAudioPlayerComponent { readonly icon = input(''); readonly url = input.required(); readonly channelName = input(''); + readonly playbackSessionKey = input(null); } @Component({ diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index ce6850aba..adec67f2d 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -5,6 +5,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { of } from 'rxjs'; import { PlaylistMeta, @@ -13,6 +14,13 @@ import { import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerPlayer } from './with-stalker-player.feature'; +/** What the player does once the resolved stream has really played. */ +function confirmPlayback(playback: { streamUrl: string }): void { + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: [playback.streamUrl], + }); +} + jest.mock('@iptvnator/portal/shared/util', () => ({ ...jest.requireActual('@iptvnator/portal/shared/util'), createLogger: () => ({ @@ -160,6 +168,7 @@ describe('withStalkerPlayer', () => { 'Movie Title', 'thumb.jpg' ); + confirmPlayback(playback); expect(dataService.sendIpcEvent).toHaveBeenNthCalledWith( 1, @@ -225,6 +234,7 @@ describe('withStalkerPlayer', () => { 1, 3000001 ); + confirmPlayback(playback); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( PLAYLIST._id, @@ -266,6 +276,7 @@ describe('withStalkerPlayer', () => { logo: 'jazz.png', category_id: '4001', }); + confirmPlayback(playback); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); expect(playlistService.addPortalRecentlyViewed).toHaveBeenCalledWith( @@ -372,6 +383,42 @@ describe('withStalkerPlayer', () => { category_id: '1001', }; + it('records a resolved channel only once its stream has played', async () => { + store.setSelectedContentType('itv'); + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=first' }, + }); + const failed = await store.resolveItvPlayback({ + ...CHANNEL, + use_http_tmp_link: '1', + }); + + // A link that never plays (dead stream, player error) is not a view. + expect( + playlistService.addPortalRecentlyViewed + ).not.toHaveBeenCalled(); + + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'http://cdn.example/tmp/10001.m3u8?tok=second' }, + }); + const played = await store.resolveItvPlayback({ + ...CHANNEL, + use_http_tmp_link: '1', + }); + confirmPlayback(played); + + expect(failed.streamUrl).not.toBe(played.streamUrl); + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledTimes(1); + expect( + playlistService.addPortalRecentlyViewed + ).toHaveBeenCalledWith( + PLAYLIST._id, + expect.objectContaining({ id: '10001', cmd: CHANNEL.cmd }) + ); + }); + it('plays an unflagged ITV channel straight from its static cmd', async () => { store.setSelectedContentType('itv'); @@ -520,7 +567,9 @@ describe('withStalkerPlayer', () => { dataService.sendIpcEvent.mockResolvedValueOnce(response); } - await store.resolveItvPlayback({ ...CHANNEL, ...flags }); + confirmPlayback( + await store.resolveItvPlayback({ ...CHANNEL, ...flags }) + ); expect( playlistService.addPortalRecentlyViewed diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts index 7d43ce4c1..6cff03cca 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.ts @@ -5,6 +5,7 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYER, createLogger } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { PlaylistMeta, ResolvedPortalPlayback, @@ -60,7 +61,8 @@ export function withStalkerPlayer() { portalRepair = inject(StalkerPortalRepairService), snackBar = inject(MatSnackBar), translate = inject(TranslateService), - ngrxStore = inject(Store) + ngrxStore = inject(Store), + historyGate = inject(PlaybackHistoryGate) ) => { const storeState = store as typeof store & StalkerPlayerFeatureStoreContract; @@ -122,7 +124,15 @@ export function withStalkerPlayer() { storeState.selectedContentType() ); + /** + * Resolving a link is not watching it: the write waits until + * the player (inline ≥2 s of progress, or a launched MPV/VLC) + * confirms `streamUrl`. The item and its portal are captured + * now, so switching portal or section meanwhile cannot + * misfile it. + */ const recordRecentlyViewed = ( + streamUrl: string, item: StalkerPlayableItem | null | undefined, cmd?: string, cover?: string, @@ -139,13 +149,12 @@ export function withStalkerPlayer() { cover, title ); - - if (typeof storeState.addToRecentlyViewed === 'function') { - storeState.addToRecentlyViewed(recentItem); - return; - } - - persistRecentlyViewed(playlistId, recentItem); + historyGate.defer({ streamUrls: [streamUrl] }, () => + persistRecentlyViewed(playlistId, { + ...recentItem, + added_at: Date.now(), + }) + ); }; const resolveVodPlaybackInternal = async ( @@ -157,9 +166,7 @@ export function withStalkerPlayer() { startTime?: number ): Promise => { const item = storeState.selectedItem() as - | StalkerPlayableItem - | null - | undefined; + StalkerPlayableItem | null | undefined; let cmdToUse = cmd ?? item?.cmd; if (!cmdToUse) { @@ -195,7 +202,13 @@ export function withStalkerPlayer() { } ); - recordRecentlyViewed(item, cmd, thumbnail, title); + recordRecentlyViewed( + streamUrl, + item, + cmd, + thumbnail, + title + ); const isEpisode = episodeNum !== undefined || episodeId !== undefined; @@ -285,6 +298,7 @@ export function withStalkerPlayer() { const portalOrigin = getStalkerPortalOrigin(playlist); recordRecentlyViewed( + streamUrl, item, item.cmd, item.logo ?? item.cover, @@ -350,6 +364,7 @@ export function withStalkerPlayer() { ); recordRecentlyViewed( + streamUrl, item, item.cmd, item.logo ?? item.cover, diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts index d8b238dd3..882e93d16 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-store.contracts.ts @@ -59,10 +59,6 @@ export interface StalkerSeriesFeatureStoreContract vodSeriesSeasonsResource: ResourceState; } -export interface StalkerRecentCallbackStoreContract { - addToRecentlyViewed?: (item: StalkerRecentlyViewedItem) => void; -} - export type StalkerRecentlyViewedItem = StalkerPortalItem & { id: string | number; title: string; @@ -74,8 +70,7 @@ export interface StalkerPlayerFeatureStoreContract Pick< StalkerSelectionStoreContract, 'selectedContentType' | 'selectedItem' - >, - StalkerRecentCallbackStoreContract {} + > {} export interface StalkerEpgFeatureStoreContract extends diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts index 2fc7c4932..5d8bd41f8 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -36,6 +36,7 @@ import { resolveSeriesPlaybackEpisodeState, type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; +import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token'; import { SerialDetailsPlaybackPositionState } from './serial-details-playback-position-state'; import { @@ -66,6 +67,7 @@ export class SerialDetailsPlaybackService { ); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly recordRecentItem = injectXtreamRecentHistory(); private readonly resumeTarget = inject(XTREAM_SERIES_RESUME_TARGET); private readonly seasonWatch = inject(SerialDetailsSeasonWatchService); @@ -209,9 +211,12 @@ export class SerialDetailsPlaybackService { return; } - this.addToRecentlyViewed(this.route.snapshot.params['serialId']); - const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); + this.recordRecentItem(streamUrl, { + xtreamId: this.route.snapshot.params['serialId'], + contentType: 'series', + backdropUrl: selectedItem.info?.backdrop_path?.[0], + }); const contentInfo: PlayerContentInfo = { playlistId: playlist.id, contentXtreamId: Number(episode.id), @@ -404,15 +409,6 @@ export class SerialDetailsPlaybackService { return this.bindings()?.selectedItem() ?? null; } - private addToRecentlyViewed(xtreamId: number): void { - this.xtreamStore.addRecentItem({ - xtreamId, - contentType: 'series', - playlist: this.xtreamStore.currentPlaylist, - backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0], - }); - } - private startPlayback( playback: ResolvedPortalPlayback, episodeState: SeriesPlaybackEpisodeState | null diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index 518bdafba..120846c99 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -26,6 +26,7 @@ import { import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { PlaybackPositionData } from '@iptvnator/shared/interfaces'; import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback'; import { BehaviorSubject, EMPTY, of } from 'rxjs'; @@ -564,12 +565,20 @@ describe('SerialDetailsComponent', () => { expect(constructEpisodeStreamUrl).toHaveBeenCalledWith( expect.objectContaining({ id: '1001' }) ); + // The series is a recent view only once the episode has played. + expect(addRecentItem).not.toHaveBeenCalled(); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['http://xtream.example/series/1001.mp4'], + }); expect(addRecentItem).toHaveBeenCalledWith({ xtreamId: '103', contentType: 'series', - playlist: currentPlaylist, + playlist: expect.any(Function), backdropUrl: undefined, }); + expect(addRecentItem.mock.calls[0][0].playlist()).toEqual( + currentPlaylist() + ); expect(openResolvedPlayback).toHaveBeenCalledWith( expect.objectContaining({ streamUrl: 'http://xtream.example/series/1001.mp4', @@ -923,7 +932,10 @@ describe('SerialDetailsComponent', () => { SerialDetailsPlaybackService ); const snackBar = TestBed.inject(MatSnackBar); - const seasonPosition = (contentXtreamId: number, episodeNumber: number) => ({ + const seasonPosition = ( + contentXtreamId: number, + episodeNumber: number + ) => ({ playlistId: 'xtream-1', contentXtreamId, contentType: 'episode' as const, @@ -958,9 +970,9 @@ describe('SerialDetailsComponent', () => { expect.objectContaining({ contentXtreamId: 1002 }), ]); expect(savePlaybackPosition).not.toHaveBeenCalled(); - expect( - playbackService.episodePlaybackPositions().get(1001) - ).toEqual(expect.objectContaining({ positionSeconds: 1200 })); + expect(playbackService.episodePlaybackPositions().get(1001)).toEqual( + expect.objectContaining({ positionSeconds: 1200 }) + ); expect( playbackService.episodePlaybackPositions().get(1002) ).toBeDefined(); @@ -1065,9 +1077,7 @@ describe('SerialDetailsComponent', () => { const consoleError = jest .spyOn(console, 'error') .mockImplementation(() => undefined); - savePlaybackPositionsBatch.mockRejectedValue( - new Error('batch failed') - ); + savePlaybackPositionsBatch.mockRejectedValue(new Error('batch failed')); fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 63c39333f..0ff0d85a3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -5,8 +5,15 @@ import { PORTAL_PLAYBACK_POSITIONS, PORTAL_PLAYER, } from '@iptvnator/portal/shared/util'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; +import { + PlaybackPositionRuntimeBridgeService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import type { PlaybackPositionData, PlayerContentInfo, @@ -28,6 +35,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { /** The bridge callback the service registers at construction. */ let positionListener: ((data: PlaybackPositionData) => void) | undefined; const addRecentItem = jest.fn(); + const xtreamDataSource = { + getContentByXtreamId: jest.fn(), + addRecentItem: jest.fn(), + }; const activeSession = signal(null); const closeSession = jest.fn().mockResolvedValue(undefined); const openResolvedPlayback = jest.fn(); @@ -81,6 +92,10 @@ describe('VodDetailsPlaybackService — external session ownership', () => { routeVodId.set(ROUTE_VOD_ID); positionListener = undefined; addRecentItem.mockClear(); + xtreamDataSource.getContentByXtreamId + .mockReset() + .mockResolvedValue({ id: 77 }); + xtreamDataSource.addRecentItem.mockReset().mockResolvedValue(undefined); closeSession.mockReset().mockResolvedValue(undefined); openResolvedPlayback .mockReset() @@ -105,6 +120,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { .mockReturnValue('https://example.com/route.mkv'), }, }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, { provide: PORTAL_EXTERNAL_PLAYBACK, useValue: { activeSession, closeSession }, @@ -147,6 +163,95 @@ describe('VodDetailsPlaybackService — external session ownership', () => { }); }); + it('records the movie as recently viewed only once its stream played', async () => { + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/broken.mkv', + title: 'Broken source', + }); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Working source', + }); + expect(addRecentItem).not.toHaveBeenCalled(); + + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/route.mkv'], + }); + + expect(addRecentItem).toHaveBeenCalledTimes(1); + const [recentItem] = addRecentItem.mock.calls[0]; + expect(recentItem).toEqual( + expect.objectContaining({ + xtreamId: ROUTE_VOD_ID, + contentType: 'movie', + }) + ); + expect(recentItem.playlist()).toEqual({ id: ROUTE_PLAYLIST }); + }); + + it('saves a confirmation that arrives after a playlist switch without touching the store', async () => { + // Only a slow MPV/VLC launch can confirm after the page is gone; the + // store's recent list belongs to the other playlist by then. + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Slow external launch', + }); + currentPlaylist.set({ id: 'playlist-switched-meanwhile' }); + + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/route.mkv'], + }); + await Promise.resolve(); + + expect(addRecentItem).not.toHaveBeenCalled(); + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + ROUTE_VOD_ID, + ROUTE_PLAYLIST, + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 77, + ROUTE_PLAYLIST, + undefined + ); + }); + + it.each([ + ['the API-only data source', false, 1], + ['the SQLite data source', true, 0], + ])( + 'keys a late, uncached write by Xtream id only for %s', + async (_label, sqlite: boolean, saves: number) => { + // A partial Electron bridge still selects the API-only source. + jest.spyOn( + TestBed.inject(RuntimeCapabilitiesService), + 'supportsXtreamSqliteDataSource', + 'get' + ).mockReturnValue(sqlite); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + await service.startResolvedPlayback({ + streamUrl: 'https://example.com/route.mkv', + title: 'Slow external launch', + }); + currentPlaylist.set({ id: 'playlist-switched-meanwhile' }); + + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/route.mkv'], + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves); + if (saves) { + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + ROUTE_VOD_ID, + ROUTE_PLAYLIST, + undefined + ); + } + } + ); + it('owns a session launched for the route’s own stream', () => { activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID)); @@ -341,6 +446,9 @@ describe('VodDetailsPlaybackService — external session ownership', () => { contentType: 'vod', }, }); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/alt.mkv'], + }); expect(addRecentItem).toHaveBeenCalled(); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 62529c89f..8a8967df8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -38,6 +38,7 @@ import { createExternalLaunchOwner, startRouteOwnedPlayback, } from './vod-details-external-launch-owner'; +import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { settleOwnedExternalLaunch } from './vod-details-external-launch'; import { resolveXtreamVodPlaybackPresentation } from './vod-details-playback-presentation'; import { isResumablePosition } from './vod-primary-action-position'; @@ -68,6 +69,7 @@ export class VodDetailsPlaybackService { private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); private readonly portalPlayer = inject(PORTAL_PLAYER); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly recordRecentItem = injectXtreamRecentHistory(); private readonly playbackPositionBridge = inject( PlaybackPositionRuntimeBridgeService ); @@ -377,15 +379,6 @@ export class VodDetailsPlaybackService { this.positionLoaded.set(true); } - private addToRecentlyViewed(): void { - this.xtreamStore.addRecentItem({ - xtreamId: this.bindings()?.vodId() ?? NaN, - contentType: 'movie', - playlist: this.xtreamStore.currentPlaylist, - backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], - }); - } - /** * The single inline-vs-external fork. Public so multi-source can switch * the playing source through exactly the same path a normal Play takes — @@ -469,7 +462,11 @@ export class VodDetailsPlaybackService { } // Same movie, different source: still a view. - this.addToRecentlyViewed(); + this.recordRecentItem(playback.streamUrl, { + xtreamId: this.bindings()?.vodId() ?? NaN, + contentType: 'movie', + backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], + }); return await this.applyPlayback(playback, isCurrent); } finally { this.pendingStart.settle(startId); diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts new file mode 100644 index 000000000..222935734 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -0,0 +1,85 @@ +import { inject, Injector, signal } from '@angular/core'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + type IXtreamDataSource, + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; + +export interface XtreamRecentItemRequest { + readonly xtreamId: number | string; + readonly contentType: 'movie' | 'series'; + readonly backdropUrl?: string; +} + +/** + * Records a movie or series as recently viewed once `streamUrl` has really + * played — inline for a couple of seconds, or launched in MPV/VLC — so a + * source that fails straight away never reaches history or the dashboard + * hero. The playlist is captured when playback starts, so navigating to + * another one meanwhile cannot misfile the item. + * + * A confirmation that arrives after the user switched to another playlist + * (only a slow MPV/VLC launch can: the inline player goes with the page) is + * saved to the captured playlist without touching the store, whose recent + * list belongs to the other playlist by then. + * + * Must run in an injection context. + */ +export function injectXtreamRecentHistory(): ( + streamUrl: string, + request: XtreamRecentItemRequest +) => void { + const gate = inject(PlaybackHistoryGate); + const store = inject(XtreamStore); + const injector = inject(Injector); + + return (streamUrl, request) => { + const playlist = signal(store.currentPlaylist()).asReadonly(); + gate.defer({ streamUrls: [streamUrl] }, () => { + const playlistId = playlist()?.id; + if (store.currentPlaylist()?.id === playlistId) { + store.addRecentItem({ ...request, playlist }); + } else if (playlistId) { + void saveWithoutListRefresh( + injector.get(XTREAM_DATA_SOURCE), + // The data source factory picks SQLite by this contract, + // not by a generic Electron bridge. + !injector.get(RuntimeCapabilitiesService) + .supportsXtreamSqliteDataSource, + playlistId, + request + ); + } + }); + }; +} + +/** + * The save half of `withRecentItems.addRecentItem` (same content lookup; + * the API-only data source keys cold content by its Xtream id), without + * reloading the store's recent list. Kept here rather than shared with the store: the + * store and its library barrel ship in the initial bundle, this path is + * only reached from lazy detail pages. + */ +async function saveWithoutListRefresh( + dataSource: IXtreamDataSource, + keysByXtreamId: boolean, + playlistId: string, + { xtreamId, contentType, backdropUrl }: XtreamRecentItemRequest +): Promise { + const id = Number(xtreamId); + if (!Number.isFinite(id) || id <= 0) { + return; + } + const content = await dataSource.getContentByXtreamId( + id, + playlistId, + contentType + ); + const contentId = content?.id ?? (keysByXtreamId ? id : null); + if (contentId != null) { + await dataSource.addRecentItem(contentId, playlistId, backdropUrl); + } +} diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts new file mode 100644 index 000000000..d033fe3a7 --- /dev/null +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.spec.ts @@ -0,0 +1,39 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { ContentAboutComponent } from './content-about.component'; + +describe('ContentAboutComponent', () => { + let fixture: ComponentFixture; + + const poster = (): HTMLImageElement | null => + (fixture.nativeElement as HTMLElement).querySelector('.about__poster'); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ContentAboutComponent, TranslateModule.forRoot()], + }).compileComponents(); + fixture = TestBed.createComponent(ContentAboutComponent); + fixture.componentRef.setInput('title', 'Movie Title'); + }); + + it('omits the poster when there is no URL', () => { + fixture.detectChanges(); + expect(poster()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Movie Title'); + }); + + it('drops a poster that fails to load and retries a new URL', () => { + fixture.componentRef.setInput('posterUrl', 'https://img.test/a.jpg'); + fixture.detectChanges(); + expect(poster()?.getAttribute('src')).toBe('https://img.test/a.jpg'); + + poster()?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + expect(poster()).toBeNull(); + expect(fixture.nativeElement.textContent).toContain('Movie Title'); + + fixture.componentRef.setInput('posterUrl', 'https://img.test/b.jpg'); + fixture.detectChanges(); + expect(poster()?.getAttribute('src')).toBe('https://img.test/b.jpg'); + }); +}); diff --git a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts index 449a89385..1d55c39f6 100644 --- a/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts +++ b/libs/ui/components/src/lib/portal-detail-shell/content-about.component.ts @@ -2,7 +2,9 @@ import { NgTemplateOutlet } from '@angular/common'; import { Component, TemplateRef, + computed, input, + signal, ChangeDetectionStrategy, } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; @@ -13,7 +15,7 @@ import { TranslateModule } from '@ngx-translate/core'; * stays reachable while the player occupies the hero position. * * Degradation rule: anything missing simply is not rendered — no "N/A" - * placeholders. Chips and credits are stamped from the host-provided + * placeholders. A poster URL that fails to load counts as missing. Chips and credits are stamped from the host-provided * *appDetailTags / *appDetailMeta templates, so the host's own @if guards apply. */ @Component({ @@ -24,12 +26,13 @@ import { TranslateModule } from '@ngx-translate/core';

{{ 'PORTALS.ABOUT' | translate }}

- @if (posterUrl()) { + @if (visiblePosterUrl(); as poster) { }
@@ -63,4 +66,11 @@ export class ContentAboutComponent { readonly description = input(); readonly tagsTemplate = input | null>(null); readonly metaTemplate = input | null>(null); + + // Keyed by URL so a different poster gets its own load attempt. + protected readonly failedPosterUrl = signal(undefined); + protected readonly visiblePosterUrl = computed(() => { + const url = this.posterUrl(); + return url && url !== this.failedPosterUrl() ? url : undefined; + }); } diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts index dcee640f3..a9b998aa4 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.spec.ts @@ -29,7 +29,9 @@ describe('ArtPlayerVideoSession', () => { player.currentTime = 45; player.duration = 120; player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 120 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 120, playing: false }, + ]); Object.defineProperty(player.video, 'error', { configurable: true, @@ -70,7 +72,7 @@ describe('ArtPlayerVideoSession', () => { it('uses the source-resolved duration for time updates', () => { const player = createPlayer(); - const times: Array<{ currentTime: number; duration: number }> = []; + const times: unknown[] = []; player.currentTime = 45; player.duration = Number.POSITIVE_INFINITY; const session = new ArtPlayerVideoSession({ @@ -87,7 +89,9 @@ describe('ArtPlayerVideoSession', () => { player.emit('video:timeupdate'); - expect(times).toEqual([{ currentTime: 45, duration: 135 }]); + expect(times).toEqual([ + { currentTime: 45, duration: 135, playing: false }, + ]); }); it('removes native and ArtPlayer listeners exactly on destroy', () => { diff --git a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts index 86570eb31..b3d2a852b 100644 --- a/libs/ui/playback/src/lib/art-player/art-player-video-session.ts +++ b/libs/ui/playback/src/lib/art-player/art-player-video-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; export interface ArtPlayerVideoSessionConfig { player: Artplayer; @@ -13,7 +14,7 @@ export interface ArtPlayerVideoSessionConfig { getDuration: () => number; persistSharedVolume: boolean; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -71,6 +72,7 @@ export class ArtPlayerVideoSession { this.config.emitTimeUpdate({ currentTime: player.currentTime, duration: this.config.getDuration(), + playing: !player.video.paused && !player.video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.html b/libs/ui/playback/src/lib/art-player/art-player.component.html index 793e8dede..918f3a631 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.html +++ b/libs/ui/playback/src/lib/art-player/art-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/art-player/art-player.component.ts b/libs/ui/playback/src/lib/art-player/art-player.component.ts index 1dc49464a..bc815ac91 100644 --- a/libs/ui/playback/src/lib/art-player/art-player.component.ts +++ b/libs/ui/playback/src/lib/art-player/art-player.component.ts @@ -20,6 +20,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -34,6 +35,7 @@ import { } from './art-player-setup'; import { ArtPlayerSourceSession } from './art-player-source-session'; import { ArtPlayerVideoSession } from './art-player-video-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugArtPlayer = createDevLogger('ArtPlayer'); @@ -59,13 +61,11 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges { readonly isLive = input(true); readonly interactionEnabled = input(true); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts index 6ff831007..268dea187 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateModule } from '@ngx-translate/core'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { AudioPlayerComponent } from './audio-player.component'; describe('AudioPlayerComponent', () => { @@ -176,6 +177,47 @@ describe('AudioPlayerComponent', () => { expect(store.dispatch).toHaveBeenCalledTimes(1); }); + it('records the station in history only after it has played', () => { + const audio = createComponent('https://example.com/station.mp3'); + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + { streamUrls: ['https://example.com/station.mp3'] }, + commit + ); + Object.defineProperty(audio, 'paused', { + configurable: true, + value: false, + }); + const playTo = (position: number) => { + Object.defineProperty(audio, 'currentTime', { + configurable: true, + value: position, + }); + audio.dispatchEvent(new Event('timeupdate')); + }; + + playTo(0); + playTo(1); + expect(commit).not.toHaveBeenCalled(); + + playTo(2); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not record a station that never advances', () => { + const audio = createComponent('https://example.com/dead.mp3'); + const commit = jest.fn(); + TestBed.inject(PlaybackHistoryGate).defer( + { streamUrls: ['https://example.com/dead.mp3'] }, + commit + ); + + audio.dispatchEvent(new Event('timeupdate')); + audio.dispatchEvent(new Event('timeupdate')); + + expect(commit).not.toHaveBeenCalled(); + }); + it('pauses the stream when the component is destroyed', () => { createComponent(); diff --git a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts index e49cc7ef7..34469d51a 100644 --- a/libs/ui/playback/src/lib/audio-player/audio-player.component.ts +++ b/libs/ui/playback/src/lib/audio-player/audio-player.component.ts @@ -22,6 +22,8 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; import { ChannelActions } from '@iptvnator/m3u-state'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation'; @Component({ selector: 'app-audio-player', @@ -156,7 +158,12 @@ import { ChannelActions } from '@iptvnator/m3u-state';
- + `, styleUrls: ['./audio-player.component.scss'], @@ -176,6 +183,8 @@ export class AudioPlayerComponent { readonly channelName = input(''); readonly externalVolume = input(null, { alias: 'volume' }); readonly dispatchAdjacentChannelAction = input(true); + /** The host's playback session key, for the history confirmation. */ + readonly playbackSessionKey = input(null); readonly channelSwitchRequested = output<'next' | 'previous'>(); readonly volumeChange = output(); @@ -198,6 +207,14 @@ export class AudioPlayerComponent { private destroyRef = inject(DestroyRef); private hostEl = inject(ElementRef); private fallbackVolume = 1; + /** Commits the deferred "recently viewed" write once the station plays. */ + private readonly historyConfirmation = new PlaybackHistoryConfirmation({ + gate: inject(PlaybackHistoryGate), + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.url()], + }), + }); constructor() { const saved = parseFloat(localStorage.getItem('volume') ?? '1'); @@ -268,6 +285,16 @@ export class AudioPlayerComponent { } } + onTimeUpdate(): void { + const audio = this.audioRef()?.nativeElement; + if (audio) { + this.historyConfirmation.record( + audio.currentTime, + !audio.paused && !audio.seeking + ); + } + } + play() { const audio = this.audioRef()?.nativeElement; if (!audio) return; diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html index 0e2d44191..aff35a474 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.html @@ -103,6 +103,7 @@ [playerSurface]="playerSurface()" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="showControls()" [shortcutsEnabled]="sharedShortcutsEnabled()" (previousEpisodeRequested)="requestPreviousEpisode()" diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts index ed2fef2da..b35a715c6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.ts @@ -26,7 +26,10 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { PlayerControlsComponent } from '../player-controls/player-controls.component'; -import type { PlayerMediaTitle } from '../player-controls/player-controls.model'; +import type { + PlayerMediaTitle, + PlayerUpNextItem, +} from '../player-controls/player-controls.model'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { EmbeddedMpvControlsAdapter } from './embedded-mpv-controls.adapter'; import { EmbeddedMpvDockPanelComponent } from './embedded-mpv-dock-panel.component'; @@ -53,6 +56,7 @@ import { subtitleTrackLabel, volumeIcon, } from './embedded-mpv-format.utils'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const RECORDING_MESSAGE_DISMISS_DELAY_MS = 5000; @@ -82,13 +86,11 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the root. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); @@ -563,6 +565,7 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy { this.timeUpdate.emit({ currentTime: session.positionSeconds, duration: session.durationSeconds ?? 0, + playing: session.status === 'playing', }); this.legacyInteractions.scheduleControlsHide(); }); diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts index d1a44277b..cc0352fcb 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.spec.ts @@ -4,8 +4,11 @@ describe('HtmlVideoElementSession', () => { it('owns native video events and detaches them idempotently', () => { const video = document.createElement('video'); const playbackIssues: unknown[] = []; - const timeUpdates: Array<{ currentTime: number; duration: number }> = - []; + const timeUpdates: Array<{ + currentTime: number; + duration: number; + playing?: boolean; + }> = []; const playbackEnded = jest.fn(); const removeEventListener = jest.spyOn(video, 'removeEventListener'); const session = new HtmlVideoElementSession({ @@ -40,7 +43,20 @@ describe('HtmlVideoElementSession', () => { video.dispatchEvent(new Event('ended')); expect(video.currentTime).toBe(18); - expect(timeUpdates).toEqual([{ currentTime: 18, duration: 90 }]); + // A paused element's position moves too (a seek): not playing. + expect(timeUpdates).toEqual([ + { currentTime: 18, duration: 90, playing: false }, + ]); + Object.defineProperty(video, 'paused', { + configurable: true, + value: false, + }); + video.dispatchEvent(new Event('timeupdate')); + expect(timeUpdates.at(-1)).toEqual({ + currentTime: 18, + duration: 90, + playing: true, + }); expect(playbackIssues[0]).toEqual( expect.objectContaining({ code: 'unsupported-container', diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts index 190fce46c..a443da253 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-element-session.ts @@ -5,6 +5,7 @@ import { classifyNativePlaybackIssue, createPlaybackSourceMetadata, } from '@iptvnator/playback/util'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -13,7 +14,7 @@ export interface HtmlVideoElementSessionConfig { getChannelUrl: () => string | undefined; getStartTime: () => number; emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void; - emitTimeUpdate: (value: { currentTime: number; duration: number }) => void; + emitTimeUpdate: (value: PlayerTimeUpdate) => void; emitPlaybackEnded: () => void; emitPlaybackStarted?: () => void; } @@ -62,6 +63,7 @@ export class HtmlVideoElementSession { this.config.emitTimeUpdate({ currentTime: video.currentTime, duration: video.duration, + playing: !video.paused && !video.seeking, }); }; diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html index 2e08db2f8..e554aaa6f 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.html @@ -12,6 +12,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index 9cbb37f06..b4a5ce6a5 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -29,6 +29,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -53,6 +54,7 @@ import { HtmlVideoPlayerControlsBridge, type HtmlVideoControlsSource, } from './html-video-player-controls.bridge'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer'); @@ -81,12 +83,10 @@ export class HtmlVideoPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly interactionEnabled = input(true); readonly showCaptions = input(false); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - @Output() timeUpdate = new EventEmitter<{ - currentTime: number; - duration: number; - }>(); + @Output() timeUpdate = new EventEmitter(); @Output() playbackIssue = new EventEmitter(); @Output() playbackEnded = new EventEmitter(); @Output() playbackStarted = new EventEmitter(); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts new file mode 100644 index 000000000..be7916a2a --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.spec.ts @@ -0,0 +1,75 @@ +import type { PlaybackHistoryTarget } from '@iptvnator/playback/data-access'; +import { PlaybackHistoryConfirmation } from './playback-history-confirmation'; + +describe('PlaybackHistoryConfirmation', () => { + const streamA: PlaybackHistoryTarget = { + sessionKey: 'live:p1:a', + streamUrls: ['http://stream/a'], + }; + const streamB: PlaybackHistoryTarget = { + sessionKey: 'live:p1:b', + streamUrls: ['http://stream/b'], + }; + let target: PlaybackHistoryTarget; + let revision: symbol; + let confirm: jest.Mock; + let confirmation: PlaybackHistoryConfirmation; + + const play = (...positions: number[]) => + positions.forEach((position) => confirmation.record(position)); + + beforeEach(() => { + target = streamA; + revision = Symbol('first'); + confirm = jest.fn(); + confirmation = new PlaybackHistoryConfirmation({ + gate: { confirm }, + target: () => target, + sourceRevision: () => revision, + }); + }); + + it('confirms what played', () => { + play(0, 1, 2); + + expect(confirm).toHaveBeenCalledWith(streamA); + }); + + it('does not credit a new stream with the previous stream progress', () => { + play(0, 1.5); + target = streamB; + play(0, 0.5); + + expect(confirm).not.toHaveBeenCalled(); + + play(1, 2); + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm).toHaveBeenCalledWith(streamB); + }); + + it('confirms a returning stream again after another one played', () => { + play(0, 1, 2); + target = streamB; + play(0, 1, 2); + target = streamA; + play(0, 1, 2); + + expect(confirm).toHaveBeenCalledTimes(3); + }); + + it('keeps progress across an engine swap of the same stream', () => { + play(0, 1.5); + revision = Symbol('fallback'); + play(30, 30.5); + + expect(confirm).toHaveBeenCalledTimes(1); + }); + + it('ignores position changes reported while not playing', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + + expect(confirm).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts new file mode 100644 index 000000000..ec8413ea0 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-history-confirmation.ts @@ -0,0 +1,49 @@ +import type { + PlaybackHistoryGate, + PlaybackHistoryTarget, +} from '@iptvnator/playback/data-access'; +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +export interface PlaybackHistoryConfirmationOptions { + readonly gate: Pick; + /** What is playing now: its session key and/or stream URLs. */ + readonly target: () => PlaybackHistoryTarget; + /** + * Engine/source generation within the same keys (engine switch, live + * format fallback, reload). A change restarts the position clock but + * keeps the progress already seen. + */ + readonly sourceRevision?: () => unknown; +} + +/** + * Confirms a player's current stream to the {@link PlaybackHistoryGate} once + * it has really played. What is playing is re-read on every report rather + * than tracked by an effect, so a report of a new stream can never be added + * to the progress of the one before it. + */ +export class PlaybackHistoryConfirmation { + private identity: string | null = null; + private sourceRevision: unknown = null; + private readonly progress = new PlaybackProgressConfirmation(() => + this.options.gate.confirm(this.options.target()) + ); + + constructor(private readonly options: PlaybackHistoryConfirmationOptions) {} + + /** @param playing see {@link PlaybackProgressConfirmation.record}. */ + record(position: number, playing?: boolean): void { + const identity = JSON.stringify(this.options.target()); + const sourceRevision = this.options.sourceRevision?.() ?? null; + if (identity !== this.identity) { + this.identity = identity; + this.sourceRevision = sourceRevision; + this.progress.reset(); + } else if (sourceRevision !== this.sourceRevision) { + this.sourceRevision = sourceRevision; + this.progress.rebase(); + } + + this.progress.record(position, playing); + } +} diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts new file mode 100644 index 000000000..991b0fc6d --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.spec.ts @@ -0,0 +1,109 @@ +import { PlaybackProgressConfirmation } from './playback-progress-confirmation'; + +describe('PlaybackProgressConfirmation', () => { + let onConfirmed: jest.Mock; + let confirmation: PlaybackProgressConfirmation; + + const play = (...positions: number[]) => + positions.forEach((position) => confirmation.record(position)); + + beforeEach(() => { + onConfirmed = jest.fn(); + confirmation = new PlaybackProgressConfirmation(onConfirmed); + }); + + it('confirms once the stream has advanced for two seconds', () => { + play(0, 0.5, 1, 1.5); + expect(onConfirmed).not.toHaveBeenCalled(); + + play(2); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms whole-second position reports (embedded MPV)', () => { + play(10, 10, 11, 11, 12); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms only once per stream', () => { + play(0, 1, 2, 3, 4, 5); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('does not confirm a stream that stalls at its first frames', () => { + play(0, 0.4, 0.4, 0.4, 0.4, 0.4); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('does not count a seek as watched time', () => { + // A VOD resume jumps straight to the saved position. + play(0, 1200, 1200.5); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('does not count short seeks of paused media', () => { + confirmation.record(0, false); + confirmation.record(1, false); + confirmation.record(2, false); + confirmation.record(2.5, false); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('counts only the steps reported while playing', () => { + confirmation.record(0, true); + confirmation.record(1.5, true); + // Paused, then seeked a second ahead: not watched. + confirmation.record(2.5, false); + confirmation.record(2.9, true); + + expect(onConfirmed).not.toHaveBeenCalled(); + + confirmation.record(3.5, true); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('does not count backwards jumps', () => { + play(5, 4, 3, 2, 1); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('ignores non-finite positions', () => { + play(0, Number.NaN, Number.POSITIVE_INFINITY, 1); + + expect(onConfirmed).not.toHaveBeenCalled(); + }); + + it('starts over after reset', () => { + play(0, 1.5); + confirmation.reset(); + play(100, 101); + + expect(onConfirmed).not.toHaveBeenCalled(); + + play(102); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('confirms again for a new stream after reset', () => { + play(0, 1, 2); + confirmation.reset(); + play(0, 1, 2); + + expect(onConfirmed).toHaveBeenCalledTimes(2); + }); + + it('keeps the progress across a rebase but not the position', () => { + play(0, 1.5); + confirmation.rebase(); + // The new engine's clock starts elsewhere; that jump is not counted. + play(50, 50.5); + + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts new file mode 100644 index 000000000..a6397013f --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/playback-progress-confirmation.ts @@ -0,0 +1,66 @@ +/** Seconds a stream must actually advance before it counts as watched. */ +export const PLAYBACK_CONFIRMATION_SECONDS = 2; + +/** + * Largest position step still counted as playback. Engines report every + * 250–500 ms, so anything larger is a seek (resume position, live-edge + * catch-up, ±10 s keys) rather than seconds the viewer has seen. + */ +const MAX_PLAYBACK_STEP_SECONDS = 3; + +/** + * Turns an engine's position reports into a single "this stream plays" + * signal. `playing` alone is not enough: a broken stream can fire it and + * stall or error a moment later, so only position that really advanced + * counts. Pauses, stalls, seeks and backwards jumps add nothing — including + * short seeks of paused media, which engines report as `playing: false`. + */ +export class PlaybackProgressConfirmation { + private lastPosition: number | null = null; + private progressedSeconds = 0; + private confirmed = false; + + constructor(private readonly onConfirmed: () => void) {} + + /** Starts over for a new stream. */ + reset(): void { + this.lastPosition = null; + this.progressedSeconds = 0; + this.confirmed = false; + } + + /** + * Keeps the progress but forgets the last position, for an engine or + * source swap within the same stream whose clock restarts elsewhere. + */ + rebase(): void { + this.lastPosition = null; + } + + /** + * @param playing whether the media was actually playing (not paused, not + * seeking) at this report; engines that cannot tell leave it undefined. + */ + record(position: number, playing?: boolean): void { + if (this.confirmed || !Number.isFinite(position)) { + return; + } + + const previous = this.lastPosition; + this.lastPosition = position; + if (previous === null || playing === false) { + return; + } + + const step = position - previous; + if (step <= 0 || step > MAX_PLAYBACK_STEP_SECONDS) { + return; + } + + this.progressedSeconds += step; + if (this.progressedSeconds >= PLAYBACK_CONFIRMATION_SECONDS) { + this.confirmed = true; + this.onConfirmed(); + } + } +} diff --git a/libs/ui/playback/src/lib/playback-history/player-time-update.ts b/libs/ui/playback/src/lib/playback-history/player-time-update.ts new file mode 100644 index 000000000..10b4a4000 --- /dev/null +++ b/libs/ui/playback/src/lib/playback-history/player-time-update.ts @@ -0,0 +1,12 @@ +/** Position report an engine emits on every media time update. */ +export interface PlayerTimeUpdate { + readonly currentTime: number; + readonly duration: number; + /** + * Whether the media was actually playing — not paused and not seeking. + * Seeks of paused media also move the position; only playing reports + * count towards the "really played" history confirmation. Left undefined + * by an engine that cannot tell. + */ + readonly playing?: boolean; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts index 95513efd2..30963461e 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.spec.ts @@ -1,4 +1,5 @@ import { + formatRemainingTime, formatTime, persistVolume, readStoredVolume, @@ -18,6 +19,20 @@ describe('controls format utilities', () => { expect(formatTime(3661)).toBe('1:01:01'); }); + it('formats the remaining time with a minus sign, rounding up', () => { + expect(formatRemainingTime(30, 600)).toBe('−9:30'); + expect(formatRemainingTime(599.2, 600)).toBe('−0:01'); + expect(formatRemainingTime(700, 600)).toBe('−0:00'); + expect(formatRemainingTime(-5, 60)).toBe('−1:00'); + }); + + it('has no remaining time without a finite positive duration', () => { + expect(formatRemainingTime(30, null)).toBeNull(); + expect(formatRemainingTime(30, undefined)).toBeNull(); + expect(formatRemainingTime(30, 0)).toBeNull(); + expect(formatRemainingTime(30, Number.POSITIVE_INFINITY)).toBeNull(); + }); + it('clamps stored volume reads and persists raw volume values', () => { localStorage.setItem('volume', '2'); expect(readStoredVolume()).toBe(1); diff --git a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts index 397611283..86b104650 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-format.utils.ts @@ -15,6 +15,29 @@ export function formatTime(value: number | null | undefined): string { return `${minutes}:${String(seconds).padStart(2, '0')}`; } +/** + * Time left until the end, as `−7:03`. The dock shows it in place of the + * total duration: it is the number a viewer actually wants at a glance. + * Null when there is no finite duration to count down from. + */ +export function formatRemainingTime( + positionSeconds: number, + durationSeconds: number | null | undefined +): string | null { + if ( + typeof durationSeconds !== 'number' || + !Number.isFinite(durationSeconds) || + durationSeconds <= 0 + ) { + return null; + } + const remaining = Math.max( + 0, + durationSeconds - Math.max(0, positionSeconds) + ); + return `−${formatTime(Math.ceil(remaining))}`; +} + export function volumeIcon(value: number): string { if (value <= 0) { return 'volume_off'; diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts new file mode 100644 index 000000000..e5dfe5700 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.spec.ts @@ -0,0 +1,127 @@ +import { + COMPACT_LAYOUT_MAX_WIDTH, + ControlsLayout, + ROOMY_LAYOUT_MIN_WIDTH, +} from './controls-layout'; + +type ResizeCallback = (entries: ResizeObserverEntry[]) => void; + +describe('ControlsLayout', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let callbacks: ResizeCallback[]; + let observed: Element[]; + let disconnected: number; + + function entry(width: number): ResizeObserverEntry { + return { + borderBoxSize: [{ inlineSize: width, blockSize: 100 }], + contentRect: { width } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry; + } + + beforeEach(() => { + callbacks = []; + observed = []; + disconnected = 0; + class FakeResizeObserver { + constructor(callback: ResizeCallback) { + callbacks.push(callback); + } + observe(target: Element): void { + observed.push(target); + } + disconnect(): void { + disconnected += 1; + } + unobserve(): void { + /* noop */ + } + } + globalThis.ResizeObserver = + FakeResizeObserver as unknown as typeof ResizeObserver; + }); + + afterEach(() => { + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('starts wide and observes the host', () => { + const layout = new ControlsLayout(); + const host = document.createElement('div'); + layout.attach(host); + + expect(layout.mode()).toBe('wide'); + expect(observed).toEqual([host]); + }); + + it('switches to compact at the breakpoint and back above it', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + + callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH)]); + expect(layout.mode()).toBe('compact'); + + callbacks[0]([entry(COMPACT_LAYOUT_MAX_WIDTH + 1)]); + expect(layout.mode()).toBe('wide'); + }); + + it('reports room for chips and the side panel only from the roomy width', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + expect(layout.roomy()).toBe(true); + + callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH - 1)]); + expect(layout.mode()).toBe('wide'); + expect(layout.roomy()).toBe(false); + + callbacks[0]([entry(ROOMY_LAYOUT_MIN_WIDTH)]); + expect(layout.roomy()).toBe(true); + + callbacks[0]([entry(400)]); + expect(layout.mode()).toBe('compact'); + expect(layout.roomy()).toBe(false); + }); + + it('uses the last entry of a batch and ignores zero widths', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + + callbacks[0]([entry(1200), entry(400)]); + expect(layout.mode()).toBe('compact'); + + callbacks[0]([entry(0)]); + expect(layout.mode()).toBe('compact'); + }); + + it('falls back to the content rect when border box sizes are missing', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + + callbacks[0]([ + { + contentRect: { width: 300 } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry, + ]); + expect(layout.mode()).toBe('compact'); + }); + + it('disconnects on dispose and on re-attach', () => { + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + layout.attach(document.createElement('div')); + expect(disconnected).toBe(1); + + layout.dispose(); + expect(disconnected).toBe(2); + }); + + it('stays wide without ResizeObserver support', () => { + globalThis.ResizeObserver = + undefined as unknown as typeof ResizeObserver; + const layout = new ControlsLayout(); + layout.attach(document.createElement('div')); + + expect(layout.mode()).toBe('wide'); + expect(callbacks).toHaveLength(0); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-layout.ts b/libs/ui/playback/src/lib/player-controls/controls-layout.ts new file mode 100644 index 000000000..9a8ac6574 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-layout.ts @@ -0,0 +1,83 @@ +import { signal } from '@angular/core'; + +/** + * Container width (px) below which the controls switch to the compact + * layout: smaller buttons, volume behind a popover, secondary actions + * folded away. Mirrors the `@container player-controls (max-width: 719px)` + * query in the stylesheet, which owns the purely visual sizing; the signal + * exists for the template branches CSS cannot express (which elements are + * rendered at all). + */ +export const COMPACT_LAYOUT_MAX_WIDTH = 719; + +/** + * Container width (px) from which the wide dock has room for its extras: + * the subtitle/speed value chips beside the action buttons, and the + * settings panel beside the video (the panel takes 370px of the dock). + * Between the compact breakpoint and this width the dock stays wide but + * folds the chips into `tune` and opens settings as a bottom sheet. + * Sized from the widest action row: volume + series transport + two chips + * + tune/record/PiP/fullscreen, and dock + panel with only tune/fullscreen. + */ +export const ROOMY_LAYOUT_MIN_WIDTH = 960; + +export type ControlsLayoutMode = 'compact' | 'wide'; + +/** + * Observes the controls host's own width and reports the layout mode. The + * host fills the player box (`inset: 0`), so its width is the player's width + * — a small inline player inside a wide desktop window is compact, a + * fullscreen phone is compact, and a windowed desktop player is wide. + * + * Without `ResizeObserver` (unit tests, very old runtimes) the mode stays + * `wide`, which renders every control and is the safe default. + */ +export class ControlsLayout { + readonly mode = signal('wide'); + /** Room for chips and the side panel; see {@link ROOMY_LAYOUT_MIN_WIDTH}. */ + readonly roomy = signal(true); + private observer: ResizeObserver | null = null; + + attach(host: HTMLElement): void { + this.detach(); + if (typeof ResizeObserver === 'undefined') { + return; + } + this.observer = new ResizeObserver((entries) => { + const entry = entries[entries.length - 1]; + if (!entry) { + return; + } + const width = + entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width; + this.applyWidth(width); + }); + this.observer.observe(host); + this.applyWidth(host.getBoundingClientRect().width); + } + + /** Exposed for hosts and tests that already know the width. */ + applyWidth(width: number): void { + if (!Number.isFinite(width) || width <= 0) { + return; + } + const next: ControlsLayoutMode = + width <= COMPACT_LAYOUT_MAX_WIDTH ? 'compact' : 'wide'; + if (this.mode() !== next) { + this.mode.set(next); + } + const roomy = width >= ROOMY_LAYOUT_MIN_WIDTH; + if (this.roomy() !== roomy) { + this.roomy.set(roomy); + } + } + + detach(): void { + this.observer?.disconnect(); + this.observer = null; + } + + dispose(): void { + this.detach(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index c692ec3e4..e4fdd2488 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -1,12 +1,8 @@ import { ControlsMenuSelection } from './controls-menu-selection'; -import { ControlsMenuState } from './controls-menu-state'; -import { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; describe('ControlsMenuSelection', () => { let commands: jest.Mocked; - let menus: ControlsMenuState; - let visibility: ControlsVisibility; let revealSticky: jest.Mock; let selection: ControlsMenuSelection; @@ -27,75 +23,35 @@ describe('ControlsMenuSelection', () => { toggleRecording: jest.fn(), togglePictureInPicture: jest.fn(), }; - menus = new ControlsMenuState(); - visibility = new ControlsVisibility(() => false); - jest.spyOn(visibility, 'scheduleHide'); revealSticky = jest.fn(); selection = new ControlsMenuSelection({ commands: () => commands, - menus, - visibility, revealSticky, }); }); - it('toggles a menu through the menu state', () => { - selection.toggle('audio'); - expect(menus.audioOpen()).toBe(true); - - selection.toggle('audio'); - expect(menus.audioOpen()).toBe(false); - }); - - it('selects an audio track: reveal sticky, command, close menu, reschedule hide', () => { - menus.open('audio'); + it('selects an audio track: reveal sticky, then the command', () => { selection.audioTrack(3); expect(revealSticky).toHaveBeenCalledTimes(1); expect(commands.setAudioTrack).toHaveBeenCalledWith(3); - expect(menus.audioOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalledTimes(1); }); - it('selects a subtitle track and closes the subtitle menu', () => { - menus.open('subtitle'); - selection.subtitleTrack(-1); + it.each([ + ['subtitleTrack', -1, 'setSubtitleTrack'], + ['qualityLevel', -1, 'setQualityLevel'], + ['speed', 1.5, 'setPlaybackSpeed'], + ['aspect', '16:9', 'setAspectRatio'], + ] as const)('routes %s to the engine command', (method, value, command) => { + (selection[method] as (arg: unknown) => void)(value); - expect(commands.setSubtitleTrack).toHaveBeenCalledWith(-1); - expect(menus.subtitleOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalled(); + expect(revealSticky).toHaveBeenCalledTimes(1); + expect(commands[command]).toHaveBeenCalledWith(value); }); - it('selects a quality level and closes the quality menu', () => { - menus.open('quality'); - selection.qualityLevel(-1); + it('opens the subtitle file picker', () => { + selection.externalSubtitle(); - expect(commands.setQualityLevel).toHaveBeenCalledWith(-1); - expect(menus.qualityOpen()).toBe(false); - expect(visibility.scheduleHide).toHaveBeenCalled(); - }); - - it('applies a playback speed and closes the speed menu', () => { - menus.open('speed'); - selection.speed(1.5); - - expect(commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5); - expect(menus.speedOpen()).toBe(false); - }); - - it('applies an aspect ratio and closes the aspect menu', () => { - menus.open('aspect'); - selection.aspect('16:9'); - - expect(commands.setAspectRatio).toHaveBeenCalledWith('16:9'); - expect(menus.aspectOpen()).toBe(false); - }); - - it('only closes the menu the selection belongs to', () => { - menus.open('volume'); - selection.speed(2); - - // Closing 'speed' must not blindly close every popover. - expect(menus.volumeOpen()).toBe(true); + expect(commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1); }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index 3a57ad9cf..e18c39127 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -1,58 +1,47 @@ -import type { ControlsMenuState } from './controls-menu-state'; -import type { ControlsVisibility } from './controls-visibility'; import type { PlayerControlsCommands } from './player-controls.model'; -type MenuKey = 'audio' | 'subtitle' | 'quality' | 'speed' | 'aspect'; - export interface MenuSelectionDeps { commands: () => PlayerControlsCommands; - menus: ControlsMenuState; - visibility: ControlsVisibility; - /** Reveal without rescheduling the auto-hide (kept open while choosing). */ + /** Reveal without rescheduling the auto-hide (the panel stays open). */ revealSticky: () => void; } /** - * Owns track/speed/aspect menu selection: reveal-without-hide, run the command, - * close the menu, then reschedule the auto-hide. Keeps the component lean while - * the template still binds to thin delegating methods. + * Owns track/quality/speed/aspect selection from the settings panel: reveal + * without hiding, then run the command. The panel deliberately stays open — + * a choice applies immediately and is judged against the running video, so + * closing it on every click would cost a reopen per comparison. */ export class ControlsMenuSelection { constructor(private readonly deps: MenuSelectionDeps) {} - toggle(menu: MenuKey): void { - this.deps.menus.toggle(menu); - } - audioTrack(trackId: number): void { - this.apply('audio', (c) => c.setAudioTrack(trackId)); + this.apply((c) => c.setAudioTrack(trackId)); } subtitleTrack(trackId: number): void { - this.apply('subtitle', (c) => c.setSubtitleTrack(trackId)); + this.apply((c) => c.setSubtitleTrack(trackId)); } - /** Opens the engine's subtitle file picker and closes the popover. */ + /** Opens the engine's subtitle file picker. */ externalSubtitle(): void { - this.apply('subtitle', (c) => c.addExternalSubtitleFile()); + this.apply((c) => c.addExternalSubtitleFile()); } qualityLevel(levelId: number): void { - this.apply('quality', (c) => c.setQualityLevel(levelId)); + this.apply((c) => c.setQualityLevel(levelId)); } speed(value: number): void { - this.apply('speed', (c) => c.setPlaybackSpeed(value)); + this.apply((c) => c.setPlaybackSpeed(value)); } aspect(value: string): void { - this.apply('aspect', (c) => c.setAspectRatio(value)); + this.apply((c) => c.setAspectRatio(value)); } - private apply(menu: MenuKey, run: (c: PlayerControlsCommands) => void): void { + private apply(run: (c: PlayerControlsCommands) => void): void { this.deps.revealSticky(); run(this.deps.commands()); - this.deps.menus.close(menu); - this.deps.visibility.scheduleHide(); } } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts index 7116e8c09..be43cab5c 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.spec.ts @@ -16,31 +16,43 @@ describe('ControlsMenuState', () => { expect(menus.volumeOpen()).toBe(true); expect(menus.anyOpen()).toBe(true); - menus.open('audio'); + menus.open('settings'); expect(menus.volumeOpen()).toBe(false); - expect(menus.audioOpen()).toBe(true); + expect(menus.settingsOpen()).toBe(true); - menus.toggle('audio'); - expect(menus.audioOpen()).toBe(false); + menus.toggle('settings'); + expect(menus.settingsOpen()).toBe(false); expect(menus.anyOpen()).toBe(false); }); it('closes all menus', () => { const menus = new ControlsMenuState(); - menus.open('speed'); + menus.open('settings'); menus.closeAll(); expect(menus.anyOpen()).toBe(false); }); - it.each([ - 'volume', - 'audio', - 'subtitle', - 'quality', - 'speed', - 'aspect', - 'stats', - ] as const)( + it('remembers the group the settings panel was opened for', () => { + const menus = new ControlsMenuState(); + + menus.openSettings('speed'); + expect(menus.settingsOpen()).toBe(true); + expect(menus.settingsFocus()).toBe('speed'); + + // Re-opening on another group keeps the panel and moves the focus. + menus.openSettings('subtitles'); + expect(menus.settingsOpen()).toBe(true); + expect(menus.settingsFocus()).toBe('subtitles'); + + menus.close('settings'); + expect(menus.settingsFocus()).toBeNull(); + + menus.openSettings('audio'); + menus.closeAll(); + expect(menus.settingsFocus()).toBeNull(); + }); + + it.each(['volume', 'settings', 'stats'] as const)( 'closes an open %s menu when it becomes unavailable', (menu) => { const menus = new ControlsMenuState(); @@ -48,11 +60,7 @@ describe('ControlsMenuState', () => { const changed = menus.reconcile({ volume: menu !== 'volume', - audio: menu !== 'audio', - subtitle: menu !== 'subtitle', - quality: menu !== 'quality', - speed: menu !== 'speed', - aspect: menu !== 'aspect', + settings: menu !== 'settings', stats: menu !== 'stats', }); @@ -63,37 +71,21 @@ describe('ControlsMenuState', () => { it('leaves an available menu open without reporting a change', () => { const menus = new ControlsMenuState(); - menus.open('speed'); + menus.open('settings'); expect( - menus.reconcile({ - volume: true, - audio: true, - subtitle: true, - quality: true, - speed: true, - aspect: true, - stats: true, - }) + menus.reconcile({ volume: true, settings: true, stats: true }) ).toBe(false); - expect(menus.speedOpen()).toBe(true); + expect(menus.settingsOpen()).toBe(true); }); it('closes every unavailable menu if state was made inconsistent', () => { const menus = new ControlsMenuState(); menus.volumeOpen.set(true); - menus.speedOpen.set(true); + menus.settingsOpen.set(true); expect( - menus.reconcile({ - volume: false, - audio: false, - subtitle: false, - quality: false, - speed: false, - aspect: false, - stats: false, - }) + menus.reconcile({ volume: false, settings: false, stats: false }) ).toBe(true); expect(menus.anyOpen()).toBe(false); }); @@ -106,12 +98,20 @@ describe('ControlsMenuState', () => { ] >([ ['volume', { volume: false }, {}], - ['audio', {}, { audioTracks: [] }], - ['subtitle', {}, { subtitleTracks: [] }], - ['quality', {}, { qualityLevels: [] }], - ['speed', { playbackSpeed: false }, {}], - ['aspect', { aspectRatio: false }, {}], ['stats', { streamStats: false }, {}], + [ + 'settings', + { + audioTracks: false, + subtitles: false, + externalSubtitles: false, + qualityLevels: false, + playbackSpeed: false, + aspectRatio: false, + }, + {}, + ], + ['settings', { playbackSpeed: false, aspectRatio: false }, {}], ])( 'maps runtime controller state to %s menu availability', (menu, capabilityOverrides, stateOverrides) => { @@ -123,32 +123,48 @@ describe('ControlsMenuState', () => { { ...DEFAULT_PLAYER_CAPABILITIES, volume: true, - audioTracks: true, - subtitles: true, - qualityLevels: true, + streamStats: true, playbackSpeed: true, aspectRatio: true, - streamStats: true, ...capabilityOverrides, }, - { - ...createEmptyControlsState(), - audioTracks: [ - { id: 1, label: 'English', selected: true }, - { id: 2, label: 'German', selected: false }, - ], - subtitleTracks: [ - { id: 1, label: 'English', selected: true }, - ], - qualityLevels: [ - { id: 0, label: '1080p', selected: false }, - { id: 1, label: '720p', selected: false }, - ], - ...stateOverrides, - } + { ...createEmptyControlsState(), ...stateOverrides } ); expect(menus.anyOpen()).toBe(false); } ); + + it('keeps the settings panel while any group still exists', () => { + const menus = new ControlsMenuState(); + menus.openSettings('speed'); + + menus.reconcileControllerAvailability( + true, + { + ...DEFAULT_PLAYER_CAPABILITIES, + playbackSpeed: true, + audioTracks: true, + }, + { + ...createEmptyControlsState(), + audioTracks: [{ id: 1, label: 'Only', selected: true }], + } + ); + + expect(menus.settingsOpen()).toBe(true); + }); + + it('closes every menu while controls are hidden', () => { + const menus = new ControlsMenuState(); + menus.open('settings'); + + menus.reconcileControllerAvailability( + false, + { ...DEFAULT_PLAYER_CAPABILITIES, playbackSpeed: true }, + createEmptyControlsState() + ); + + expect(menus.anyOpen()).toBe(false); + }); }); diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 387d0df5c..6568b10ef 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -1,18 +1,15 @@ import { computed, signal } from '@angular/core'; +import { + anySettingsGroupAvailable, + getSettingsGroupAvailability, + type SettingsGroup, +} from './controls-settings-groups'; import type { PlayerControlsCapabilities, PlayerControlsState, } from './player-controls.model'; -const CONTROL_MENUS = [ - 'volume', - 'audio', - 'subtitle', - 'quality', - 'speed', - 'aspect', - 'stats', -] as const; +const CONTROL_MENUS = ['volume', 'settings', 'stats'] as const; export type ControlsMenu = (typeof CONTROL_MENUS)[number]; export type ControlsMenuAvailability = Readonly>; @@ -24,49 +21,30 @@ function getControlsMenuAvailability( ): ControlsMenuAvailability { return { volume: showControls && capabilities.volume, - audio: + settings: showControls && - capabilities.audioTracks && - state.audioTracks.length > 1, - // External subtitle loading keeps the menu reachable with an empty - // track list — the "Load subtitle file…" action is how the first - // track appears. - subtitle: - showControls && - ((capabilities.subtitles && state.subtitleTracks.length > 0) || - capabilities.externalSubtitles), - quality: - showControls && - capabilities.qualityLevels && - state.qualityLevels.length > 1, - speed: showControls && capabilities.playbackSpeed, - aspect: showControls && capabilities.aspectRatio, + anySettingsGroupAvailable( + getSettingsGroupAvailability(capabilities, state) + ), stats: showControls && capabilities.streamStats, }; } /** * Tracks which menu/popover is currently open and exposes individual signals - * the template binds to. Only one menu can be open at a time. + * the template binds to. Only one menu can be open at a time. The settings + * panel is one menu: every track, quality, speed and aspect choice lives + * inside it, and `settingsFocus` names the group it was opened for (a chip + * click), so the panel can bring that group into view. */ export class ControlsMenuState { readonly volumeOpen = signal(false); - readonly audioOpen = signal(false); - readonly subtitleOpen = signal(false); - readonly qualityOpen = signal(false); - readonly speedOpen = signal(false); - readonly aspectOpen = signal(false); + readonly settingsOpen = signal(false); readonly statsOpen = signal(false); + readonly settingsFocus = signal(null); readonly anyOpen = computed( - () => - this.volumeOpen() || - this.audioOpen() || - this.subtitleOpen() || - this.qualityOpen() || - this.speedOpen() || - this.aspectOpen() || - this.statsOpen() + () => this.volumeOpen() || this.settingsOpen() || this.statsOpen() ); toggle(menu: ControlsMenu): void { @@ -84,18 +62,24 @@ export class ControlsMenuState { this.signalFor(menu).set(true); } + /** Opens the settings panel on one group (or wherever it was). */ + openSettings(group: SettingsGroup | null = null): void { + this.open('settings'); + this.settingsFocus.set(group); + } + close(menu: ControlsMenu): void { this.signalFor(menu).set(false); + if (menu === 'settings') { + this.settingsFocus.set(null); + } } closeAll(): void { this.volumeOpen.set(false); - this.audioOpen.set(false); - this.subtitleOpen.set(false); - this.qualityOpen.set(false); - this.speedOpen.set(false); - this.aspectOpen.set(false); + this.settingsOpen.set(false); this.statsOpen.set(false); + this.settingsFocus.set(null); } reconcile(availability: ControlsMenuAvailability): boolean { @@ -103,7 +87,7 @@ export class ControlsMenuState { for (const menu of CONTROL_MENUS) { const open = this.signalFor(menu); if (open() && !availability[menu]) { - open.set(false); + this.close(menu); changed = true; } } @@ -124,16 +108,8 @@ export class ControlsMenuState { switch (menu) { case 'volume': return this.volumeOpen; - case 'audio': - return this.audioOpen; - case 'subtitle': - return this.subtitleOpen; - case 'quality': - return this.qualityOpen; - case 'speed': - return this.speedOpen; - case 'aspect': - return this.aspectOpen; + case 'settings': + return this.settingsOpen; case 'stats': return this.statsOpen; } diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts new file mode 100644 index 000000000..c60d281c7 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings-groups.ts @@ -0,0 +1,47 @@ +import type { + PlayerControlsCapabilities, + PlayerControlsState, +} from './player-controls.model'; + +/** The groups of the settings panel, in the order they are rendered. */ +export const SETTINGS_GROUPS = [ + 'audio', + 'subtitles', + 'quality', + 'speed', + 'aspect', +] as const; + +export type SettingsGroup = (typeof SETTINGS_GROUPS)[number]; +export type SettingsGroupAvailability = Readonly< + Record +>; + +/** + * Which settings groups the current engine and stream offer. A group with + * nothing to choose from is not rendered, exactly like the popover buttons + * it replaces were not. + */ +export function getSettingsGroupAvailability( + capabilities: PlayerControlsCapabilities, + state: PlayerControlsState +): SettingsGroupAvailability { + return { + audio: capabilities.audioTracks && state.audioTracks.length > 1, + // External subtitle loading keeps the group reachable with an empty + // track list — the "Load subtitle file…" action is how the first + // track appears. + subtitles: + (capabilities.subtitles && state.subtitleTracks.length > 0) || + capabilities.externalSubtitles, + quality: capabilities.qualityLevels && state.qualityLevels.length > 1, + speed: capabilities.playbackSpeed, + aspect: capabilities.aspectRatio, + }; +} + +export function anySettingsGroupAvailable( + availability: SettingsGroupAvailability +): boolean { + return SETTINGS_GROUPS.some((group) => availability[group]); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts new file mode 100644 index 000000000..6b00bf2c3 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.spec.ts @@ -0,0 +1,194 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ControlsMenuState } from './controls-menu-state'; +import { ControlsSettings } from './controls-settings'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, +} from './player-controls.model'; + +describe('ControlsSettings', () => { + let state: WritableSignal; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let menus: ControlsMenuState; + let commands: jest.Mocked; + let reveal: jest.Mock; + let settings: ControlsSettings; + + const setState = (overrides: Partial) => + state.set({ ...createEmptyControlsState(), ...overrides }); + const setCapabilities = (overrides: Partial) => + capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + beforeEach(() => { + state = signal(createEmptyControlsState()); + capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES }); + showControls = signal(true); + menus = new ControlsMenuState(); + commands = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + reveal = jest.fn(); + settings = new ControlsSettings({ + state, + capabilities, + showControls, + menus, + commands: () => commands, + reveal, + }); + }); + + it('is unavailable without any group and cannot open', () => { + expect(settings.available()).toBe(false); + + settings.open('speed'); + settings.toggle(); + + expect(menus.settingsOpen()).toBe(false); + expect(settings.isOpen()).toBe(false); + expect(reveal).not.toHaveBeenCalled(); + }); + + it('opens on a group with a sticky reveal and toggles closed', () => { + setCapabilities({ playbackSpeed: true }); + + settings.open('speed'); + expect(settings.isOpen()).toBe(true); + expect(settings.focusGroup()).toBe('speed'); + expect(reveal).toHaveBeenCalledWith({ scheduleHide: false }); + + settings.toggle(); + expect(settings.isOpen()).toBe(false); + expect(settings.focusGroup()).toBeNull(); + }); + + it('is hidden with the controls even while the menu flag is set', () => { + setCapabilities({ playbackSpeed: true }); + settings.open(); + showControls.set(false); + + expect(settings.available()).toBe(false); + expect(settings.isOpen()).toBe(false); + }); + + it('derives on/modified state and the tune dots', () => { + setCapabilities({ + audioTracks: true, + subtitles: true, + playbackSpeed: true, + aspectRatio: true, + qualityLevels: true, + }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + qualityLevels: [ + { id: 0, label: '1080p', selected: false }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: true, + }); + expect(settings.dots()).toEqual({ cyan: false, violet: false }); + expect(settings.hasDots()).toBe(false); + expect(settings.subtitleLabel()).toBeNull(); + expect(settings.speedLabel()).toBe('1×'); + + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: false }, + { id: 2, label: 'German', selected: true }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + qualityLevels: [ + { id: 0, label: '1080p', selected: true }, + { id: 1, label: '720p', selected: false }, + ], + qualityAutoEnabled: false, + playbackSpeed: 1.25, + aspectRatio: '16:9', + }); + expect(settings.subtitlesOn()).toBe(true); + expect(settings.subtitleLabel()).toBe('Russian'); + expect(settings.audioModified()).toBe(true); + expect(settings.qualityModified()).toBe(true); + expect(settings.speedModified()).toBe(true); + expect(settings.speedLabel()).toBe('1.25×'); + expect(settings.aspectModified()).toBe(true); + expect(settings.dots()).toEqual({ cyan: true, violet: true }); + }); + + it('treats the first aspect preset as the unmodified default', () => { + setCapabilities({ aspectRatio: true }); + setState({ + aspectRatio: 'auto', + aspectPresets: [ + { value: 'auto', label: 'Auto' }, + { value: '16:9', label: '16:9' }, + ], + }); + expect(settings.defaultAspect()).toBe('auto'); + expect(settings.aspectModified()).toBe(false); + }); + + it('toggles subtitles from the chip without opening the panel', () => { + setCapabilities({ subtitles: true }); + setState({ + subtitleTracks: [ + { id: 7, label: 'English', selected: false }, + { id: 8, label: 'German', selected: false }, + ], + }); + const event = { preventDefault: jest.fn() } as unknown as Event; + + settings.onSubtitleChipContextMenu(event); + expect(event.preventDefault).toHaveBeenCalled(); + expect(commands.setSubtitleTrack).toHaveBeenCalledWith(7); + expect(menus.settingsOpen()).toBe(false); + + setState({ + subtitleTracks: [{ id: 7, label: 'English', selected: true }], + subtitlesEnabled: true, + }); + settings.toggleSubtitles(); + expect(commands.setSubtitleTrack).toHaveBeenLastCalledWith(-1); + }); + + it('opens the subtitles group instead when there is no track to turn on', () => { + setCapabilities({ externalSubtitles: true }); + + settings.toggleSubtitles(); + + expect(commands.setSubtitleTrack).not.toHaveBeenCalled(); + expect(settings.isOpen()).toBe(true); + expect(settings.focusGroup()).toBe('subtitles'); + }); + + it('ignores the subtitle toggle without a subtitle group', () => { + settings.toggleSubtitles(); + expect(commands.setSubtitleTrack).not.toHaveBeenCalled(); + expect(reveal).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-settings.ts b/libs/ui/playback/src/lib/player-controls/controls-settings.ts new file mode 100644 index 000000000..a7fb26361 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-settings.ts @@ -0,0 +1,164 @@ +import { Signal, computed } from '@angular/core'; +import { speedLabel } from './controls-format.utils'; +import type { ControlsMenuState } from './controls-menu-state'; +import { + anySettingsGroupAvailable, + getSettingsGroupAvailability, + type SettingsGroup, +} from './controls-settings-groups'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, +} from './player-controls.model'; + +export interface ControlsSettingsDeps { + state: Signal; + capabilities: Signal; + showControls: Signal; + menus: ControlsMenuState; + commands: () => PlayerControlsCommands; + reveal: (options?: { scheduleHide?: boolean }) => void; +} + +/** Colored state dots on the compact `tune` button. */ +export interface SettingsStateDots { + /** Something is on: subtitles enabled, a non-default audio track. */ + cyan: boolean; + /** A value was changed: speed, aspect ratio, manual quality. */ + violet: boolean; +} + +/** + * State behind the `tune` button, its chips and the settings panel: which + * groups exist, what is on or modified (the color-as-state system), and the + * open/close/toggle transitions. The panel itself only renders. + */ +export class ControlsSettings { + constructor(private readonly deps: ControlsSettingsDeps) {} + + readonly groups = computed(() => + getSettingsGroupAvailability( + this.deps.capabilities(), + this.deps.state() + ) + ); + + readonly available = computed( + () => + this.deps.showControls() && anySettingsGroupAvailable(this.groups()) + ); + + readonly isOpen = computed( + () => this.available() && this.deps.menus.settingsOpen() + ); + + readonly focusGroup = computed(() => this.deps.menus.settingsFocus()); + + readonly subtitlesOn = computed( + () => this.groups().subtitles && this.deps.state().subtitlesEnabled + ); + + /** The selected subtitle track's label, for the chip; null while off. */ + readonly subtitleLabel = computed(() => { + if (!this.subtitlesOn()) { + return null; + } + return ( + this.deps.state().subtitleTracks.find((track) => track.selected) + ?.label ?? null + ); + }); + + /** A track other than the first (the engine's default) is selected. */ + readonly audioModified = computed(() => { + if (!this.groups().audio) { + return false; + } + const tracks = this.deps.state().audioTracks; + const selected = tracks.findIndex((track) => track.selected); + return selected > 0; + }); + + readonly qualityModified = computed( + () => this.groups().quality && !this.deps.state().qualityAutoEnabled + ); + + readonly speedModified = computed( + () => this.groups().speed && this.deps.state().playbackSpeed !== 1 + ); + + readonly speedLabel = computed(() => + speedLabel(this.deps.state().playbackSpeed) + ); + + readonly defaultAspect = computed( + () => this.deps.state().aspectPresets[0]?.value ?? 'no' + ); + + readonly aspectModified = computed( + () => + this.groups().aspect && + this.deps.state().aspectRatio !== this.defaultAspect() + ); + + readonly dots = computed(() => ({ + cyan: this.subtitlesOn() || this.audioModified(), + violet: + this.speedModified() || + this.aspectModified() || + this.qualityModified(), + })); + + readonly hasDots = computed(() => this.dots().cyan || this.dots().violet); + + open(group: SettingsGroup | null = null): void { + if (!this.available()) { + return; + } + this.deps.reveal({ scheduleHide: false }); + this.deps.menus.openSettings(group); + } + + toggle(): void { + if (!this.available()) { + return; + } + this.deps.reveal(); + this.deps.menus.toggle('settings'); + } + + close(): void { + this.deps.menus.close('settings'); + this.deps.reveal(); + } + + /** + * Secondary action of the subtitle chip (right-click, long-press): + * flip subtitles without opening the panel. With no embedded track to + * turn on, the panel opens instead so the file loader is reachable. + */ + onSubtitleChipContextMenu(event: Event): void { + event.preventDefault(); + this.toggleSubtitles(); + } + + toggleSubtitles(): void { + if (!this.groups().subtitles) { + return; + } + const state = this.deps.state(); + if (state.subtitlesEnabled) { + this.deps.reveal(); + this.deps.commands().setSubtitleTrack(-1); + return; + } + const first = state.subtitleTracks[0]; + if (!first) { + this.open('subtitles'); + return; + } + this.deps.reveal(); + this.deps.commands().setSubtitleTrack(first.id); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts new file mode 100644 index 000000000..6ce9d5106 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.spec.ts @@ -0,0 +1,97 @@ +import { signal } from '@angular/core'; +import { + ControlsTimelineHover, + projectPointerToSeconds, +} from './controls-timeline-hover'; + +describe('projectPointerToSeconds', () => { + const rect = { left: 100, width: 200 }; + + it('maps the pointer across the bar to the duration', () => { + expect(projectPointerToSeconds(100, rect, 600)).toBe(0); + expect(projectPointerToSeconds(200, rect, 600)).toBe(300); + expect(projectPointerToSeconds(300, rect, 600)).toBe(600); + }); + + it('clamps positions outside the bar', () => { + expect(projectPointerToSeconds(20, rect, 600)).toBe(0); + expect(projectPointerToSeconds(999, rect, 600)).toBe(600); + }); + + it('returns null without a usable bar or duration', () => { + expect(projectPointerToSeconds(150, { left: 0, width: 0 }, 600)).toBe( + null + ); + expect(projectPointerToSeconds(150, rect, 0)).toBeNull(); + expect(projectPointerToSeconds(Number.NaN, rect, 600)).toBeNull(); + }); +}); + +describe('ControlsTimelineHover', () => { + function createHover(duration = 600, interactive = true) { + const durationSignal = signal(duration); + const interactiveSignal = signal(interactive); + const hover = new ControlsTimelineHover({ + duration: durationSignal, + interactive: interactiveSignal, + }); + const bar = document.createElement('div'); + bar.getBoundingClientRect = () => ({ left: 0, width: 400 }) as DOMRect; + return { hover, bar, durationSignal, interactiveSignal }; + } + + function pointer(clientX: number, pointerType = 'mouse'): PointerEvent { + return { clientX, pointerType } as PointerEvent; + } + + it('reports the hovered time, percent and label', () => { + const { hover, bar } = createHover(); + + hover.move(pointer(100), bar); + + expect(hover.seconds()).toBe(150); + expect(hover.percent()).toBe(25); + expect(hover.label()).toBe('2:30'); + }); + + it('clears on leave', () => { + const { hover, bar } = createHover(); + hover.move(pointer(100), bar); + + hover.clear(); + + expect(hover.seconds()).toBeNull(); + expect(hover.percent()).toBeNull(); + expect(hover.label()).toBeNull(); + }); + + it('ignores touch pointers and non-interactive timelines', () => { + const { hover, bar, interactiveSignal } = createHover(); + + hover.move(pointer(100, 'touch'), bar); + expect(hover.seconds()).toBeNull(); + + interactiveSignal.set(false); + hover.move(pointer(100), bar); + expect(hover.seconds()).toBeNull(); + }); + + it('reads the bar from the event target when none is passed', () => { + const { hover, bar } = createHover(); + + hover.move({ ...pointer(200), currentTarget: bar } as PointerEvent); + expect(hover.seconds()).toBe(300); + + hover.move({ ...pointer(200), currentTarget: null } as PointerEvent); + expect(hover.seconds()).toBeNull(); + }); + + it('drops a stale hover once the duration disappears', () => { + const { hover, bar, durationSignal } = createHover(); + hover.move(pointer(100), bar); + + durationSignal.set(0); + + expect(hover.percent()).toBeNull(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts new file mode 100644 index 000000000..6d2fa321c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-hover.ts @@ -0,0 +1,107 @@ +import { Signal, computed, signal } from '@angular/core'; +import { formatTime } from './controls-format.utils'; +import { + findTimelineSegment, + type TimelineSegmentView, +} from './controls-timeline-segments'; + +/** + * Projects a pointer's horizontal position over the timeline bar onto a + * playback time (seconds), clamped to the bar. Null when the bar has no + * width or the duration is unknown. + */ +export function projectPointerToSeconds( + clientX: number, + rect: Pick, + durationSeconds: number +): number | null { + if ( + !Number.isFinite(clientX) || + !Number.isFinite(rect.width) || + rect.width <= 0 || + !Number.isFinite(durationSeconds) || + durationSeconds <= 0 + ) { + return null; + } + const fraction = Math.min( + 1, + Math.max(0, (clientX - rect.left) / rect.width) + ); + return fraction * durationSeconds; +} + +export interface ControlsTimelineHoverDeps { + duration: Signal; + /** Whether the timeline accepts pointer interaction at all. */ + interactive: Signal; + /** Drawn segments, for the title in the label; none means time only. */ + segments?: Signal; +} + +/** + * Hover state of the timeline: the time under the pointer, its position as + * a percentage of the bar (for the marker and label), and the label text. + * Touch never hovers, so a coarse pointer leaves this empty. + */ +export class ControlsTimelineHover { + readonly seconds = signal(null); + + constructor(private readonly deps: ControlsTimelineHoverDeps) {} + + readonly percent = computed(() => { + const seconds = this.seconds(); + const duration = this.deps.duration(); + if (seconds === null || duration <= 0) { + return null; + } + return Math.min(100, Math.max(0, (seconds / duration) * 100)); + }); + + /** The titled segment under the pointer, if any. */ + readonly segmentTitle = computed(() => { + const seconds = this.seconds(); + const segments = this.deps.segments?.(); + if (seconds === null || !segments) { + return null; + } + return findTimelineSegment(segments, seconds)?.title ?? null; + }); + + /** `Chapter 2 · 12:40` over a titled segment, else the time alone. */ + readonly label = computed(() => { + const seconds = this.seconds(); + if (seconds === null) { + return null; + } + const time = formatTime(seconds); + const title = this.segmentTitle(); + return title ? `${title} \u00b7 ${time}` : time; + }); + + /** Bound to `pointermove` on the bar element itself (`currentTarget`). */ + move(event: PointerEvent, bar?: HTMLElement): void { + const target = bar ?? event.currentTarget; + if ( + !(target instanceof HTMLElement) || + !this.deps.interactive() || + event.pointerType === 'touch' + ) { + this.clear(); + return; + } + this.seconds.set( + projectPointerToSeconds( + event.clientX, + target.getBoundingClientRect(), + this.deps.duration() + ) + ); + } + + clear(): void { + if (this.seconds() !== null) { + this.seconds.set(null); + } + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts new file mode 100644 index 000000000..54baa007d --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.spec.ts @@ -0,0 +1,150 @@ +import { + findTimelineSegment, + normalizeTimelineSegments, + segmentFillPercent, +} from './controls-timeline-segments'; + +describe('normalizeTimelineSegments', () => { + it('renders one untitled segment without input or duration', () => { + expect(normalizeTimelineSegments(null, 600)).toEqual([ + expect.objectContaining({ + startSeconds: 0, + endSeconds: 600, + title: null, + share: 1, + }), + ]); + expect(normalizeTimelineSegments([], 600)).toHaveLength(1); + expect( + normalizeTimelineSegments( + [{ startSeconds: 0, endSeconds: 10, title: 'Intro' }], + 0 + ) + ).toEqual([ + expect.objectContaining({ startSeconds: 0, title: null, share: 1 }), + ]); + expect( + normalizeTimelineSegments([], Number.POSITIVE_INFINITY) + ).toHaveLength(1); + }); + + it('sorts, fills the gaps and shares the duration', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 300, endSeconds: 450, title: 'Chapter 2' }, + { startSeconds: 60, endSeconds: 300, title: 'Chapter 1' }, + ], + 600 + ); + + expect(segments).toEqual([ + expect.objectContaining({ + startSeconds: 0, + endSeconds: 60, + title: null, + share: 0.1, + }), + expect.objectContaining({ + startSeconds: 60, + endSeconds: 300, + title: 'Chapter 1', + share: 0.4, + }), + expect.objectContaining({ + startSeconds: 300, + endSeconds: 450, + title: 'Chapter 2', + share: 0.25, + }), + expect.objectContaining({ + startSeconds: 450, + endSeconds: 600, + title: null, + share: 0.25, + }), + ]); + expect( + segments.reduce((total, segment) => total + segment.share, 0) + ).toBeCloseTo(1); + }); + + it('places segments at their time positions with a gap after all but the last', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 0, endSeconds: 150, title: 'A' }, + { startSeconds: 150, endSeconds: 600, title: 'B' }, + ], + 600 + ); + + expect(segments.map((s) => s.startPercent)).toEqual([0, 25]); + expect(segments.map((s) => s.width)).toEqual([ + 'max(0px, calc(25% - 3px))', + '75%', + ]); + }); + + it('clamps to the duration, cuts overlaps and drops empty segments', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: -20, endSeconds: 100, title: ' A ' }, + { startSeconds: 50, endSeconds: 200, title: 'B' }, + { startSeconds: 200, endSeconds: 200, title: 'empty' }, + { startSeconds: 400, endSeconds: 300, title: 'reversed' }, + { startSeconds: 150, endSeconds: 180, title: 'swallowed' }, + { startSeconds: 500, endSeconds: 900, title: '' }, + ], + 600 + ); + + expect( + segments.map((s) => [s.startSeconds, s.endSeconds, s.title]) + ).toEqual([ + [0, 100, 'A'], + [100, 200, 'B'], + [200, 500, null], + [500, 600, null], + ]); + }); +}); + +describe('segmentFillPercent', () => { + const segment = { startSeconds: 100, endSeconds: 300 }; + + it('measures the played share of a segment and clamps outside it', () => { + expect(segmentFillPercent(segment, 50)).toBe(0); + expect(segmentFillPercent(segment, 150)).toBe(25); + expect(segmentFillPercent(segment, 300)).toBe(100); + expect(segmentFillPercent(segment, 999)).toBe(100); + }); + + it('is empty for degenerate segments and positions', () => { + expect(segmentFillPercent({ startSeconds: 5, endSeconds: 5 }, 5)).toBe( + 0 + ); + expect(segmentFillPercent(segment, Number.NaN)).toBe(0); + }); +}); + +describe('findTimelineSegment', () => { + const segments = normalizeTimelineSegments( + [ + { startSeconds: 0, endSeconds: 100, title: 'First' }, + { startSeconds: 100, endSeconds: 200, title: 'Second' }, + ], + 200 + ); + + it('returns the segment containing the time, the last one at the end', () => { + expect(findTimelineSegment(segments, 0)?.title).toBe('First'); + expect(findTimelineSegment(segments, 99.9)?.title).toBe('First'); + expect(findTimelineSegment(segments, 100)?.title).toBe('Second'); + expect(findTimelineSegment(segments, 200)?.title).toBe('Second'); + }); + + it('returns null for nothing to match', () => { + expect(findTimelineSegment([], 10)).toBeNull(); + expect(findTimelineSegment(segments, Number.NaN)).toBeNull(); + expect(findTimelineSegment(segments, -1)).toBeNull(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts new file mode 100644 index 000000000..b3003d000 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline-segments.ts @@ -0,0 +1,128 @@ +import type { PlayerTimelineSegment } from './player-controls.model'; + +/** A normalized segment as the track renders it. */ +export interface TimelineSegmentView { + startSeconds: number; + endSeconds: number; + title: string | null; + /** Share of the whole duration, 0..1. */ + share: number; + /** Left edge on the track, 0..100 — the same mapping the seek input uses. */ + startPercent: number; + /** CSS width: the share, minus the gap every segment but the last keeps. */ + width: string; +} + +/** Visual gap (px) after every segment except the last. */ +export const TIMELINE_SEGMENT_GAP_PX = 3; + +const WHOLE_TIMELINE: TimelineSegmentView = { + startSeconds: 0, + endSeconds: 0, + title: null, + share: 1, + startPercent: 0, + width: '100%', +}; + +/** + * Turns host-supplied segments (chapters, programmes) into a gapless, + * non-overlapping cover of `[0, duration]`, in playback order. Segments are + * clamped to the duration, empty and reversed ones dropped, overlaps cut at + * the previous segment's end, and every uncovered stretch — before the + * first, between two, after the last — becomes an untitled segment so the + * track always adds up to the full duration. Without a usable duration or + * without segments the whole timeline is one untitled segment, which is + * exactly the pre-segment rendering. + */ +export function normalizeTimelineSegments( + segments: readonly PlayerTimelineSegment[] | null | undefined, + durationSeconds: number +): TimelineSegmentView[] { + if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return [WHOLE_TIMELINE]; + } + const clamp = (value: number) => + Number.isFinite(value) + ? Math.min(durationSeconds, Math.max(0, value)) + : 0; + const ordered = (segments ?? []) + .map((segment) => ({ + startSeconds: clamp(segment.startSeconds), + endSeconds: clamp(segment.endSeconds), + title: segment.title?.trim() || null, + })) + .filter((segment) => segment.endSeconds > segment.startSeconds) + .sort((a, b) => a.startSeconds - b.startSeconds); + + const cover: Pick< + TimelineSegmentView, + 'startSeconds' | 'endSeconds' | 'title' + >[] = []; + let cursor = 0; + for (const segment of ordered) { + const startSeconds = Math.max(segment.startSeconds, cursor); + if (startSeconds >= segment.endSeconds) { + continue; + } + if (startSeconds > cursor) { + cover.push({ + startSeconds: cursor, + endSeconds: startSeconds, + title: null, + }); + } + cover.push({ ...segment, startSeconds }); + cursor = segment.endSeconds; + } + if (cursor < durationSeconds) { + cover.push({ + startSeconds: cursor, + endSeconds: durationSeconds, + title: null, + }); + } + return cover.map((segment, index) => { + const share = + (segment.endSeconds - segment.startSeconds) / durationSeconds; + const last = index === cover.length - 1; + return { + ...segment, + share, + startPercent: (segment.startSeconds / durationSeconds) * 100, + width: last + ? `${share * 100}%` + : `max(0px, calc(${share * 100}% - ${TIMELINE_SEGMENT_GAP_PX}px))`, + }; + }); +} + +/** How much of one segment the position has played through, 0..100. */ +export function segmentFillPercent( + segment: Pick, + positionSeconds: number +): number { + const length = segment.endSeconds - segment.startSeconds; + if (length <= 0 || !Number.isFinite(positionSeconds)) { + return 0; + } + const fraction = (positionSeconds - segment.startSeconds) / length; + return Math.min(100, Math.max(0, fraction * 100)); +} + +/** The segment containing `seconds`; the last one at the very end. */ +export function findTimelineSegment( + segments: readonly TimelineSegmentView[], + seconds: number +): TimelineSegmentView | null { + if (!Number.isFinite(seconds)) { + return null; + } + for (const segment of segments) { + if (seconds >= segment.startSeconds && seconds < segment.endSeconds) { + return segment; + } + } + const last = segments[segments.length - 1]; + return last && seconds >= last.endSeconds ? last : null; +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts index b6ac54130..985a62af5 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-timeline.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-timeline.ts @@ -1,5 +1,13 @@ import { Signal, computed, signal } from '@angular/core'; -import type { PlayerControlsState } from './player-controls.model'; +import { + normalizeTimelineSegments, + segmentFillPercent, + type TimelineSegmentView, +} from './controls-timeline-segments'; +import type { + PlayerControlsState, + PlayerTimelineSegment, +} from './player-controls.model'; /** * Owns the scrub state and timeline projections for the controls bar: the @@ -9,7 +17,12 @@ import type { PlayerControlsState } from './player-controls.model'; export class ControlsTimeline { readonly scrubPosition = signal(null); - constructor(private readonly state: Signal) {} + constructor( + private readonly state: Signal, + private readonly hostSegments: Signal< + readonly PlayerTimelineSegment[] | null + > = signal(null) + ) {} readonly duration = computed(() => { const duration = this.state().durationSeconds; @@ -32,6 +45,19 @@ export class ControlsTimeline { : 0; }); + /** The drawn track: host segments over the duration, else one segment. */ + readonly segments = computed(() => + normalizeTimelineSegments(this.hostSegments(), this.duration()) + ); + + /** Played share of one segment for the current (scrub or playback) value. */ + fillPercent(segment: TimelineSegmentView): number { + if (!this.state().canSeek) { + return 0; + } + return segmentFillPercent(segment, this.value()); + } + readEventValue(event: Event): number | null { return this.normalize(Number((event.target as HTMLInputElement).value)); } diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts new file mode 100644 index 000000000..69ba70311 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.spec.ts @@ -0,0 +1,142 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ControlsUpNext, UP_NEXT_THRESHOLD_SECONDS } from './controls-up-next'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import type { + PlayerControlsCapabilities, + PlayerControlsState, + PlayerUpNextItem, +} from './player-controls.model'; + +describe('ControlsUpNext', () => { + const next: PlayerUpNextItem = { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: null, + progressPercent: null, + }; + let item: WritableSignal; + let state: WritableSignal; + let capabilities: WritableSignal; + let showControls: WritableSignal; + let settingsOpen: WritableSignal; + let upNext: ControlsUpNext; + + const setState = (overrides: Partial) => + state.set({ ...createEmptyControlsState(), ...overrides }); + + beforeEach(() => { + item = signal(next); + state = signal(createEmptyControlsState()); + capabilities = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + seriesNavigation: true, + }); + showControls = signal(true); + settingsOpen = signal(false); + upNext = new ControlsUpNext({ + item, + state, + capabilities, + showControls, + settingsOpen, + }); + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - 7 * 60 - 3, + }); + }); + + it('shows the next episode inside the threshold with minutes left', () => { + expect(upNext.visible()).toBe(true); + expect(upNext.item()).toBe(next); + expect(upNext.remainingSeconds()).toBe(423); + expect(upNext.minutesLeft()).toBe(8); + }); + + it('stays hidden until the threshold and reports at least one minute', () => { + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS - 1, + }); + expect(upNext.visible()).toBe(false); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - UP_NEXT_THRESHOLD_SECONDS, + }); + expect(upNext.visible()).toBe(true); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1199.5, + }); + expect(upNext.minutesLeft()).toBe(1); + }); + + it('needs a supplied episode, a finite duration and a series-capable engine', () => { + item.set(null); + expect(upNext.visible()).toBe(false); + item.set(next); + + // The host's item is authoritative: at a season's last episode the + // transport cannot step forward, but the next season's first can. + setState({ + canNextEpisode: false, + durationSeconds: 1200, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(true); + + setState({ + canNextEpisode: true, + durationSeconds: null, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(false); + expect(upNext.remainingSeconds()).toBeNull(); + + setState({ + canNextEpisode: true, + isLive: true, + durationSeconds: 1200, + positionSeconds: 1100, + }); + expect(upNext.visible()).toBe(false); + + setState({ + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1100, + }); + capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); + expect(upNext.visible()).toBe(false); + }); + + it('does not count down once the episode has ended', () => { + setState({ + status: 'ended', + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200, + }); + expect(upNext.visible()).toBe(false); + }); + + it('yields to hidden controls and to the open settings panel', () => { + showControls.set(false); + expect(upNext.visible()).toBe(false); + showControls.set(true); + + settingsOpen.set(true); + expect(upNext.visible()).toBe(false); + settingsOpen.set(false); + expect(upNext.visible()).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/controls-up-next.ts b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts new file mode 100644 index 000000000..0b13db46f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-up-next.ts @@ -0,0 +1,69 @@ +import { Signal, computed } from '@angular/core'; +import type { + PlayerControlsCapabilities, + PlayerControlsState, + PlayerUpNextItem, +} from './player-controls.model'; + +/** The card appears once this little of the episode is left. */ +export const UP_NEXT_THRESHOLD_SECONDS = 8 * 60; + +export interface ControlsUpNextDeps { + item: Signal; + state: Signal; + capabilities: Signal; + showControls: Signal; + /** The settings panel covers the card's corner; it yields while open. */ + settingsOpen: Signal; +} + +/** + * When the "Up next" card shows: a series host supplied the next episode + * (possibly the first of the following season, which the transport's + * season-local `canNextEpisode` does not cover), the engine plays series, + * the episode has a known length, and no more than the threshold is left. Live streams and open-ended + * VOD never qualify because they have no remaining time to count down. + */ +export class ControlsUpNext { + constructor(private readonly deps: ControlsUpNextDeps) {} + + readonly remainingSeconds = computed(() => { + const { durationSeconds, positionSeconds } = this.deps.state(); + if ( + typeof durationSeconds !== 'number' || + !Number.isFinite(durationSeconds) || + durationSeconds <= 0 + ) { + return null; + } + return Math.max(0, durationSeconds - Math.max(0, positionSeconds)); + }); + + readonly item = computed(() => { + const item = this.deps.item(); + const remaining = this.remainingSeconds(); + const state = this.deps.state(); + if ( + !item || + remaining === null || + remaining > UP_NEXT_THRESHOLD_SECONDS || + state.isLive || + // An ended episode with autoplay off schedules no switch: a + // countdown would promise one. Autoplay replaces the playback. + state.status === 'ended' || + !this.deps.capabilities().seriesNavigation || + !this.deps.showControls() || + this.deps.settingsOpen() + ) { + return null; + } + return item; + }); + + readonly visible = computed(() => this.item() !== null); + + /** Whole minutes left, never below one while the card is showing. */ + readonly minutesLeft = computed(() => + Math.max(1, Math.ceil((this.remainingSeconds() ?? 0) / 60)) + ); +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts index 7c3dd81fc..d19b89966 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-volume-interactions.ts @@ -45,8 +45,14 @@ export class ControlsVolumeInteractions { this.deps.volume.hoverLeave(); } - buttonClick(event?: Event): void { + /** + * With the slider rendered inline next to the button (wide dock), there + * is no popover to open first: the button is plainly a mute toggle for + * every pointer type. + */ + buttonClick(event?: Event, options: { inlineSlider?: boolean } = {}): void { if ( + !options.inlineSlider && this.deps.wasTouchInteraction(event) && !this.deps.menus.volumeOpen() ) { diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index 0b23e2f27..d1318b98c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -108,21 +108,18 @@ describe('PlayerControlsComponent capability contract', () => { return event.defaultPrevented; } - it('keeps narrow-player popovers outside clipping scroll containers', () => { + it('lets the compact action cluster wrap instead of clipping or scrolling', () => { const narrowActionsRule = PLAYER_CONTROLS_STYLE_SOURCE.match( /\.player-controls__actions\s*\{([\s\S]*?)\}/g )?.at(-1) ?? ''; expect(PLAYER_CONTROLS_STYLE_SOURCE).toContain( - '@container player-controls (max-width: 640px)' + '@container player-controls (max-width: 719px)' ); expect(narrowActionsRule).toContain('flex-wrap: wrap'); expect(narrowActionsRule).toContain('overflow: visible'); expect(narrowActionsRule).not.toContain('overflow-x: auto'); - expect(PLAYER_CONTROLS_STYLE_SOURCE).toMatch( - /\.player-controls__actions \.player-controls__popover-anchor\s*\{\s*position:\s*static;/ - ); }); it('routes shortcuts to the player the user interacted with most recently', () => { @@ -236,8 +233,8 @@ describe('PlayerControlsComponent capability contract', () => { ], }); fixture.detectChanges(); - component.toggleMenu('audio'); - expect(component.menus.audioOpen()).toBe(true); + component.settings.open('audio'); + expect(component.menus.settingsOpen()).toBe(true); fake.state.set({ ...createEmptyControlsState(), @@ -251,8 +248,8 @@ describe('PlayerControlsComponent capability contract', () => { playbackSpeed: true, }); fixture.detectChanges(); - component.toggleMenu('speed'); - expect(component.menus.speedOpen()).toBe(true); + component.settings.open('speed'); + expect(component.menus.settingsOpen()).toBe(true); fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); fixture.detectChanges(); @@ -292,16 +289,16 @@ describe('PlayerControlsComponent capability contract', () => { const root = fixture.nativeElement as HTMLElement; const trigger = root.querySelector( - '[data-test-id="player-controls-quality"]' + '[data-test-id="player-controls-settings-button"]' ); expect(trigger).not.toBeNull(); trigger?.click(); fixture.detectChanges(); - expect(component.menus.qualityOpen()).toBe(true); + expect(component.menus.settingsOpen()).toBe(true); const entries = Array.from( root.querySelectorAll( - '.player-controls__track-popover .player-controls__track' + '[data-test-id="player-settings-quality"] .player-settings__option' ) ); expect(entries.map((entry) => entry.textContent?.trim())).toEqual([ @@ -314,9 +311,9 @@ describe('PlayerControlsComponent capability contract', () => { entries[2].click(); fixture.detectChanges(); expect(fake.commands.setQualityLevel).toHaveBeenCalledWith(1); - expect(component.menus.qualityOpen()).toBe(false); + // The panel stays open so the next level can be compared in place. + expect(component.menus.settingsOpen()).toBe(true); - component.toggleMenu('quality'); fake.state.set({ ...createEmptyControlsState(), qualityLevels: [{ id: 0, label: '1080p', selected: false }], @@ -324,7 +321,9 @@ describe('PlayerControlsComponent capability contract', () => { fixture.detectChanges(); expect(component.anyMenuOpen()).toBe(false); expect( - root.querySelector('[data-test-id="player-controls-quality"]') + root.querySelector( + '[data-test-id="player-controls-settings-button"]' + ) ).toBeNull(); }); @@ -348,7 +347,7 @@ describe('PlayerControlsComponent capability contract', () => { const root = fixture.nativeElement as HTMLElement; expect( - root.querySelector('.player-controls__timeline > input') + root.querySelector('.player-controls__slider--timeline') ).toBeNull(); expect( root.querySelector('.player-controls__live-badge') diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts new file mode 100644 index 000000000..57e0313fd --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.dock.spec.ts @@ -0,0 +1,567 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { COMPACT_LAYOUT_MAX_WIDTH } from './controls-layout'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +type ResizeCallback = (entries: ResizeObserverEntry[]) => void; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +/** + * The "Hybrid" dock: scrim backdrop, remaining time, timeline hover label, + * the accent play button, and the compact/wide layout branches. + */ +describe('PlayerControlsComponent dock', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let resizeCallbacks: ResizeCallback[]; + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const resizeTo = (width: number) => { + for (const callback of resizeCallbacks) { + callback([ + { + borderBoxSize: [{ inlineSize: width, blockSize: 300 }], + contentRect: { width } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry, + ]); + } + fixture.detectChanges(); + }; + + beforeEach(async () => { + resizeCallbacks = []; + class FakeResizeObserver { + constructor(callback: ResizeCallback) { + resizeCallbacks.push(callback); + } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } + globalThis.ResizeObserver = + FakeResizeObserver as unknown as typeof ResizeObserver; + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + PLAY: 'Play', + PAUSE: 'Pause', + PREVIOUS_EPISODE: 'Previous episode', + NEXT_EPISODE: 'Next episode', + BACK_10_SECONDS: 'Back 10 seconds', + FORWARD_10_SECONDS: 'Forward 10 seconds', + MUTE: 'Mute', + UNMUTE: 'Unmute', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + it('backs the dock with a bottom scrim that fades with the controls', () => { + const scrim = query('[data-test-id="player-controls-bottom-scrim"]'); + expect(scrim).not.toBeNull(); + expect(scrim?.classList).toContain( + 'player-controls__bottom-scrim--visible' + ); + expect(scrim?.getAttribute('aria-hidden')).toBe('true'); + + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-bottom-scrim"]') + ).toBeNull(); + }); + + it('renders the accent play button with Play/Pause names', () => { + const play = query('[data-test-id="player-controls-play"]'); + expect(play?.tagName).toBe('BUTTON'); + expect(play?.getAttribute('aria-label')).toBe('Play'); + expect(play?.querySelector('mat-icon')?.textContent).toBe('play_arrow'); + + setState({ status: 'playing' }); + fixture.detectChanges(); + expect(play?.getAttribute('aria-label')).toBe('Pause'); + expect(play?.querySelector('mat-icon')?.textContent).toBe('pause'); + + play?.click(); + expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1); + }); + + it('orders the wide transport as previous, back, play, forward, next', () => { + setCapabilities({ seek: true, seriesNavigation: true }); + setState({ canSeek: true, durationSeconds: 100 }); + fixture.detectChanges(); + + const labels = Array.from( + fixture.nativeElement.querySelectorAll( + '.player-controls__transport button' + ) as NodeListOf + ).map((button) => button.getAttribute('aria-label')); + expect(labels).toEqual([ + 'Previous episode', + 'Back 10 seconds', + 'Play', + 'Forward 10 seconds', + 'Next episode', + ]); + }); + + describe('timeline row', () => { + it('shows the remaining time instead of the total duration', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 600, + positionSeconds: 30, + }); + fixture.detectChanges(); + + expect(query('.player-controls__time--current')?.textContent).toBe( + '0:30' + ); + expect( + query('[data-test-id="player-controls-remaining-time"]') + ?.textContent + ).toBe('−9:30'); + expect(query('.player-controls__live-badge')).toBeNull(); + }); + + it('follows the scrub preview in the remaining time', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 600, + positionSeconds: 30, + }); + fixture.detectChanges(); + + const slider = query( + '.player-controls__slider--timeline' + ) as HTMLInputElement; + slider.value = '540'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + + expect( + query('[data-test-id="player-controls-remaining-time"]') + ?.textContent + ).toBe('−1:00'); + }); + + it('shows a placeholder for unknown durations and LIVE for live streams', () => { + setState({ canSeek: false, isLive: false }); + fixture.detectChanges(); + expect(query('.player-controls__time--end')?.textContent).toBe( + '--:--' + ); + + setState({ canSeek: false, isLive: true }); + fixture.detectChanges(); + expect(query('.player-controls__time--end')).toBeNull(); + expect(query('.player-controls__live-badge')).not.toBeNull(); + }); + + it('draws the progress fill and knob from the timeline progress', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 200, + positionSeconds: 50, + }); + fixture.detectChanges(); + + expect(query('.player-controls__timeline-fill')?.style.width).toBe( + '25%' + ); + expect(query('.player-controls__timeline-knob')?.style.left).toBe( + '25%' + ); + + setState({ + canSeek: false, + durationSeconds: 200, + positionSeconds: 50, + }); + fixture.detectChanges(); + expect(query('.player-controls__timeline-knob')).toBeNull(); + expect( + query('.player-controls__timeline-bar')?.classList + ).toContain('player-controls__timeline-bar--disabled'); + }); + + it('shows the hovered time above the bar and clears it on leave', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 0, + }); + fixture.detectChanges(); + + const bar = query('.player-controls__timeline-bar') as HTMLElement; + bar.getBoundingClientRect = () => + ({ left: 100, width: 200 }) as DOMRect; + + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 150, bubbles: true }) + ); + fixture.detectChanges(); + + const label = query( + '[data-test-id="player-controls-timeline-label"]' + ); + expect(label?.textContent?.trim()).toBe('1:40'); + expect(label?.style.getPropertyValue('--hover-x')).toBe('25%'); + expect(query('.player-controls__timeline-marker')?.style.left).toBe( + '25%' + ); + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + + bar.dispatchEvent( + new MouseEvent('pointerleave', { bubbles: true }) + ); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-timeline-label"]') + ).toBeNull(); + }); + + it('draws host segments proportionally with per-segment fills', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 150, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 100, endSeconds: 300, title: 'Chapter 2' }, + { startSeconds: 0, endSeconds: 100, title: 'Chapter 1' }, + ]); + fixture.detectChanges(); + + const segments = Array.from( + fixture.nativeElement.querySelectorAll( + '.player-controls__timeline-segment' + ) as NodeListOf + ); + expect( + segments.map((s) => s.dataset['segmentTitle'] ?? null) + ).toEqual(['Chapter 1', 'Chapter 2', null]); + // Positioned by time, so boundaries match the linear seek input. + expect(segments.map((s) => s.style.left)).toEqual([ + '0%', + '25%', + '75%', + ]); + expect(segments.at(-1)?.style.width).toBe('25%'); + expect( + segments.map( + (s) => + ( + s.querySelector( + '.player-controls__timeline-fill' + ) as HTMLElement + ).style.width + ) + ).toEqual(['100%', '25%', '0%']); + // The knob still reads the overall progress. + expect(query('.player-controls__timeline-knob')?.style.left).toBe( + '37.5%' + ); + }); + + it('names the hovered segment in the timeline label', () => { + setCapabilities({ seek: true }); + setState({ + canSeek: true, + durationSeconds: 400, + positionSeconds: 0, + }); + fixture.componentRef.setInput('timelineSegments', [ + { startSeconds: 0, endSeconds: 200, title: 'Intro' }, + ]); + fixture.detectChanges(); + + const bar = query('.player-controls__timeline-bar') as HTMLElement; + bar.getBoundingClientRect = () => + ({ left: 0, width: 400 }) as DOMRect; + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 100, bubbles: true }) + ); + fixture.detectChanges(); + expect( + query( + '[data-test-id="player-controls-timeline-label"]' + )?.textContent?.trim() + ).toBe('Intro \u00b7 1:40'); + + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 300, bubbles: true }) + ); + fixture.detectChanges(); + expect( + query( + '[data-test-id="player-controls-timeline-label"]' + )?.textContent?.trim() + ).toBe('5:00'); + }); + + it('does not hover-label a non-seekable timeline', () => { + setCapabilities({ seek: true }); + setState({ canSeek: false, durationSeconds: 400 }); + fixture.detectChanges(); + + const bar = query('.player-controls__timeline-bar') as HTMLElement; + bar.getBoundingClientRect = () => + ({ left: 0, width: 200 }) as DOMRect; + bar.dispatchEvent( + new MouseEvent('pointermove', { clientX: 50, bubbles: true }) + ); + fixture.detectChanges(); + + expect( + query('[data-test-id="player-controls-timeline-label"]') + ).toBeNull(); + }); + }); + + describe('up next card', () => { + const nearTheEnd = () => + setState({ + canSeek: true, + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 1200 - 5 * 60, + }); + + beforeEach(() => { + setCapabilities({ + seek: true, + seriesNavigation: true, + playbackSpeed: true, + }); + fixture.componentRef.setInput('upNext', { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: null, + progressPercent: null, + }); + fixture.detectChanges(); + }); + + it('appears within the last minutes and plays the next episode on click', () => { + setState({ + canSeek: true, + canNextEpisode: true, + durationSeconds: 1200, + positionSeconds: 60, + }); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + + nearTheEnd(); + fixture.detectChanges(); + const card = query('[data-test-id="player-controls-up-next"]'); + expect(card).not.toBeNull(); + expect(card?.textContent).toContain('The Third One'); + + const next = jest.fn(); + component.nextEpisodeRequested.subscribe(next); + card?.click(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('offers the next season across the season boundary', () => { + setState({ + canSeek: true, + canNextEpisode: false, + durationSeconds: 1200, + positionSeconds: 1200 - 5 * 60, + }); + fixture.detectChanges(); + const card = query('[data-test-id="player-controls-up-next"]'); + expect(card).not.toBeNull(); + // The transport's next button is disabled, but the card still + // hands the request to the host, which picks the next season. + expect( + ( + query( + '[data-test-id="player-controls-next-episode"]' + ) as HTMLButtonElement + ).disabled + ).toBe(true); + const next = jest.fn(); + component.nextEpisodeRequested.subscribe(next); + card?.click(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('yields to the settings panel and needs a next episode', () => { + nearTheEnd(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).not.toBeNull(); + + component.settings.open('speed'); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + component.settings.close(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).not.toBeNull(); + + fixture.componentRef.setInput('upNext', null); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-up-next"]') + ).toBeNull(); + }); + + it('uses the compact card on a compact dock', () => { + nearTheEnd(); + resizeTo(400); + expect( + query('[data-test-id="player-controls-up-next"]')?.closest( + '.player-controls__up-next--compact' + ) + ).not.toBeNull(); + }); + }); + + describe('layout modes', () => { + beforeEach(() => { + setCapabilities({ volume: true }); + fixture.detectChanges(); + }); + + it('starts wide with the volume slider inline', () => { + expect(component.isCompact()).toBe(false); + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--compact' + ); + expect(query('.player-controls__slider--inline')).not.toBeNull(); + expect(query('.player-controls__volume-popover')).toBeNull(); + }); + + it('mutes on a wide volume button click without opening a popover', () => { + component.volumeInteractions.hoverEnter(); + fixture.detectChanges(); + expect(query('.player-controls__volume-popover')).toBeNull(); + + (query('[aria-label="Mute"]') as HTMLButtonElement).dispatchEvent( + new MouseEvent('click', { bubbles: true }) + ); + fixture.detectChanges(); + + expect(fake.commands.setVolume).toHaveBeenCalledWith(0); + expect(query('[aria-label="Unmute"]')).not.toBeNull(); + }); + + it('moves the volume slider behind a popover once compact', () => { + resizeTo(COMPACT_LAYOUT_MAX_WIDTH); + + expect(component.isCompact()).toBe(true); + expect(query('.player-controls__bar')?.classList).toContain( + 'player-controls__bar--compact' + ); + expect(query('.player-controls__slider--inline')).toBeNull(); + expect(query('.player-controls__volume-popover')).toBeNull(); + + component.volumeInteractions.hoverEnter(); + fixture.detectChanges(); + expect(query('.player-controls__volume-popover')).not.toBeNull(); + + resizeTo(COMPACT_LAYOUT_MAX_WIDTH + 200); + expect(component.isCompact()).toBe(false); + expect(query('.player-controls__slider--inline')).not.toBeNull(); + }); + + it('keeps episode navigation reachable in the compact transport', () => { + setCapabilities({ volume: true, seriesNavigation: true }); + resizeTo(400); + + expect( + query('[data-test-id="player-controls-next-episode"]') + ).not.toBeNull(); + expect( + query('[data-test-id="player-controls-previous-episode"]') + ).not.toBeNull(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 2de53d6f3..60acd35e0 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -22,6 +22,9 @@
@@ -39,6 +42,9 @@
@@ -75,7 +83,10 @@
@if (streamStats.hasRows()) {
- @for (row of streamStats.rows(); track row.labelKey) { + @for ( + row of streamStats.rows(); + track row.labelKey + ) {
{{ row.labelKey | translate }}
{{ row.value }}
@@ -97,10 +108,27 @@ } @if (showControls()) { + +
-
- + - @if (capabilities().seriesNavigation) { - - - - } - - @if (capabilities().seek) { - - - - } -
- -
- @if (capabilities().seek) { - - } -
- {{ formatTime(timelineValue()) }} - @if (state().canSeek) { - {{ formatTime(timelineDuration()) }} - } @else if (state().isLive) { - +
+ @if (capabilities().volume) { +
- - LIVE - - } @else { - --:-- - } -
- @if (recordingStatusText(); as recordingStatus) { -
- {{ - isRecording() ? 'fiber_manual_record' : 'check_circle' - }} - {{ recordingStatus }} -
- } -
- -
- @if (capabilities().volume) { -
- - @if (menus.volumeOpen()) { - - } -
- } + } @else if (menus.volumeOpen()) { + + } +
+ } +
- @if (hasAudioTracks()) { -
+
+ @if (capabilities().seriesNavigation) { - @if (menus.audioOpen()) { - - } -
- } + } - @if (hasSubtitleTracks()) { -
+ @if (capabilities().seek) { + } + + + + @if (capabilities().seek) { + + } + + @if (capabilities().seriesNavigation) { + + } +
+ +
+ @if ( + settings.available() && layout.roomy() && !settings.isOpen() + ) { + @if (settings.groups().subtitles) { + + } + @if (settings.groups().speed) { + + } + } + + @if (settings.available()) { + + } + + @if (canRecord() && !(settings.isOpen() && layout.roomy())) { + - @if (menus.subtitleOpen()) { - - } -
- } + } - @if (hasQualityLevels()) { -
+ @if ( + capabilities().pictureInPicture && + !(settings.isOpen() && layout.roomy()) + ) { - @if (menus.qualityOpen()) { - - } -
- } - - @if (capabilities().playbackSpeed) { -
- - @if (menus.speedOpen()) { - - } -
- } + } - @if (capabilities().aspectRatio) { -
+ @if (capabilities().fullscreen) { - @if (menus.aspectOpen()) { - - } -
- } - - @if (canRecord()) { - - } - - @if (capabilities().pictureInPicture) { - - } - - @if (capabilities().fullscreen) { - - } + } +
+ + @if (settings.isOpen()) { + + } + + @if (upNextCard.item(); as item) { + + } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts index b8e7e24cf..20ca82b59 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -144,7 +144,9 @@ describe('PlayerControlsComponent interactions', () => { expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95); expect(component.feedback.current()?.label).toBe('95%'); - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: -100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: -100 }) + ); expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1); }); @@ -170,7 +172,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('keeps optimistic volume across capability and visibility changes', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); expect(component.displayVolume()).toBe(0.95); setState({ positionSeconds: 15, volume: 1 }); @@ -187,7 +191,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('reapplies persisted volume when the capability returns', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); fake.commands.setVolume.mockClear(); setCapabilities({ volume: false }); @@ -202,7 +208,9 @@ describe('PlayerControlsComponent interactions', () => { }); it('reconciles volume when the controller changes at the same value', () => { - component.volumeInteractions.wheel(new WheelEvent('wheel', { deltaY: 100 })); + component.volumeInteractions.wheel( + new WheelEvent('wheel', { deltaY: 100 }) + ); expect(component.displayVolume()).toBe(0.95); localStorage.removeItem('volume'); @@ -218,40 +226,40 @@ describe('PlayerControlsComponent interactions', () => { }); }); - describe('menu selections', () => { - it('applies a speed preset and closes the menu', () => { + describe('settings panel selections', () => { + it('applies a speed preset from the chip-opened panel and keeps it open', () => { setCapabilities({ playbackSpeed: true }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click(); + query('[data-test-id="player-controls-speed-chip"]')?.click(); fixture.detectChanges(); - expect(component.menus.speedOpen()).toBe(true); + expect(component.menus.settingsOpen()).toBe(true); + expect(component.menus.settingsFocus()).toBe('speed'); - const preset = queryAll('.player-controls__track').find((item) => - item.textContent?.includes('1.5×') - ); + const preset = queryAll( + '[data-test-id="player-settings-speed"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('1.5×')); preset?.click(); fixture.detectChanges(); expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5); - expect(component.menus.speedOpen()).toBe(false); + expect(component.menus.settingsOpen()).toBe(true); }); - it('applies an aspect preset and closes the menu', () => { + it('applies an aspect preset from the panel', () => { setCapabilities({ aspectRatio: true }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click(); + component.settings.open('aspect'); fixture.detectChanges(); - const preset = queryAll('.player-controls__track').find((item) => - item.textContent?.includes('16:9') - ); + const preset = queryAll( + '[data-test-id="player-settings-aspect"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('16:9')); preset?.click(); fixture.detectChanges(); expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9'); - expect(component.menus.aspectOpen()).toBe(false); }); it('disables subtitles via the Off entry', () => { @@ -262,22 +270,24 @@ describe('PlayerControlsComponent interactions', () => { }); fixture.detectChanges(); - query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click(); + query('[data-test-id="player-controls-subtitle-chip"]')?.click(); fixture.detectChanges(); + expect(component.menus.settingsFocus()).toBe('subtitles'); - queryAll('.player-controls__track')[0]?.click(); + queryAll( + '[data-test-id="player-settings-subtitles"] .player-settings__option' + )[0]?.click(); expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1); - expect(component.menus.subtitleOpen()).toBe(false); }); it('opening one menu closes the others', () => { - setCapabilities({ playbackSpeed: true, aspectRatio: true }); + setCapabilities({ playbackSpeed: true, volume: true }); fixture.detectChanges(); - component.toggleMenu('speed'); - component.toggleMenu('aspect'); - expect(component.menus.speedOpen()).toBe(false); - expect(component.menus.aspectOpen()).toBe(true); + component.settings.open('speed'); + component.toggleMenu('volume'); + expect(component.menus.settingsOpen()).toBe(false); + expect(component.menus.volumeOpen()).toBe(true); expect(component.anyMenuOpen()).toBe(true); }); }); @@ -339,17 +349,25 @@ describe('PlayerControlsComponent interactions', () => { it('keeps the controls visible while a menu is open, hides after close', () => { setCapabilities({ playbackSpeed: true }); fixture.detectChanges(); - component.toggleMenu('speed'); + component.settings.open('speed'); fixture.detectChanges(); jest.advanceTimersByTime(10000); fixture.detectChanges(); expect(component.controlsAreVisible()).toBe(true); + // A choice keeps the panel (and the controls) on screen… component.menuSelection.speed(1.25); fixture.detectChanges(); jest.advanceTimersByTime(10000); fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(true); + + // …closing it re-arms the auto-hide. + component.settings.close(); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); expect(component.controlsAreVisible()).toBe(false); }); @@ -403,21 +421,39 @@ describe('PlayerControlsComponent interactions', () => { }); it('opens the popover on the first tap instead of muting', () => { - component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch')); + component.volumeInteractions.buttonClick( + pointerTypedEvent('click', 'touch') + ); expect(component.menus.volumeOpen()).toBe(true); expect(fake.commands.setVolume).not.toHaveBeenCalled(); }); it('toggles mute on a tap while the popover is open', () => { - component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch')); - component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'touch')); + component.volumeInteractions.buttonClick( + pointerTypedEvent('click', 'touch') + ); + component.volumeInteractions.buttonClick( + pointerTypedEvent('click', 'touch') + ); expect(fake.commands.setVolume).toHaveBeenCalledWith(0); }); + it('mutes on a tap when the slider is already inline (wide dock)', () => { + component.volumeInteractions.buttonClick( + pointerTypedEvent('click', 'touch'), + { inlineSlider: true } + ); + + expect(fake.commands.setVolume).toHaveBeenCalledWith(0); + expect(component.menus.volumeOpen()).toBe(false); + }); + it('mutes directly on a mouse click without opening the popover', () => { - component.volumeInteractions.buttonClick(pointerTypedEvent('click', 'mouse')); + component.volumeInteractions.buttonClick( + pointerTypedEvent('click', 'mouse') + ); expect(fake.commands.setVolume).toHaveBeenCalledWith(0); expect(component.menus.volumeOpen()).toBe(false); @@ -440,7 +476,9 @@ describe('PlayerControlsComponent interactions', () => { component.volumeInteractions.buttonClick( pointerTypedEvent('click', 'touch') ); - component.volumeInteractions.hoverLeave(new FocusEvent('focusout')); + component.volumeInteractions.hoverLeave( + new FocusEvent('focusout') + ); jest.advanceTimersByTime(1000); expect(component.menus.volumeOpen()).toBe(true); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss index 6b11fa467..880a52c58 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -1,24 +1,40 @@ :host { - --pc-accent: #ffffff; - --pc-track: rgba(255, 255, 255, 0.3); - --pc-thumb-ring: rgba(255, 255, 255, 0.95); - --pc-panel-bg: rgba(18, 18, 20, 0.92); - --pc-panel-border: rgba(255, 255, 255, 0.16); - --pc-text: #ffffff; + // Overlay palette. Deliberately literal and theme-independent: the shared + // controls always sit on video, and the UI guidelines keep them on one + // light-on-dark palette in both app themes. The values are the design + // tokens of the "Hybrid" controls concept (accent blue = primary action + // and progress; cyan = something is on; violet = a value was changed). + --pc-accent-blue: #4f8eff; + // Fills that carry a white glyph (play, active tune) use deeper blues: + // white on #4f8eff is only 3.2:1, too close to the 3:1 floor once a + // 1x display antialiases the glyph. These give 4.4:1 and 5.3:1. + --pc-accent-blue-strong: #3474e8; + --pc-accent-blue-hover: #2a66d6; + --pc-accent-cyan: #5cd6ff; + --pc-accent-violet: #b599ff; + --pc-text: #e7ecf3; + --pc-text-secondary: #9aa3b2; + --pc-text-tertiary: #6b7384; --pc-text-dim: rgba(255, 255, 255, 0.82); + --pc-scrim-ink: 4, 7, 11; + --pc-track: rgba(255, 255, 255, 0.18); + --pc-hover: rgba(255, 255, 255, 0.1); + --pc-glass-bg: rgba(12, 16, 23, 0.72); + --pc-glass-border: rgba(255, 255, 255, 0.09); + --pc-font-mono: + ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; position: absolute; inset: 0; z-index: 2; display: block; // The control layer is transparent to the mouse so the video underneath - // stays interactive; only the bar/popovers opt back in to pointer events. + // stays interactive; only the dock/popovers opt back in to pointer events. pointer-events: none; color: var(--pc-text); - // Become a query container so popovers can size against the actual player - // box (the host fills the player via inset:0) instead of fixed pixels or - // the viewport — they grow to fit when there is room and shrink to scroll - // when the player is small, never spilling past its edges. + // Become a query container so the dock and popovers can size against the + // actual player box (the host fills the player via inset:0) instead of + // fixed pixels or the viewport. container-type: size; container-name: player-controls; } @@ -77,34 +93,59 @@ // footage. Deliberately one element rather than a background per consumer: two // overlapping gradients would darken the overlap twice. // -// The stops are the bottom bar's, mirrored, so the two edges of the player read -// as one system. +// The stops mirror the bottom scrim so the two edges of the player read as one +// system; the height follows the player so a tall fullscreen stage gets a +// proportionally deeper fade, never less than the chrome it backs. .player-controls__top-scrim { position: absolute; top: 0; right: 0; left: 0; z-index: 1; - // Mirrors the bar's 48px top padding + 64px min-height. - height: 112px; + height: max(28%, 112px); opacity: 0; // Purely decorative: clicks belong to the video surface underneath. pointer-events: none; background: linear-gradient( to bottom, - rgba(0, 0, 0, 0.78) 0%, - rgba(0, 0, 0, 0.45) 45%, + rgba(var(--pc-scrim-ink), 0.78) 0%, + rgba(var(--pc-scrim-ink), 0.45) 45%, transparent 100% ); // Fades with the controls, but does not slide: a moving scrim edge is // visible against video in a way a moving control is not. - transition: opacity 180ms ease-out; + transition: opacity 200ms ease-out; } .player-controls__top-scrim--visible { opacity: 1; } +// Backdrop of the dock. Same rule as the top scrim: one gradient for the +// timeline and the button row together, and no surface under the dock itself +// — the controls sit directly on the video. +.player-controls__bottom-scrim { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + height: 55%; + opacity: 0; + pointer-events: none; + background: linear-gradient( + to top, + rgba(var(--pc-scrim-ink), 0.92) 0%, + rgba(var(--pc-scrim-ink), 0.55) 45%, + rgba(var(--pc-scrim-ink), 0) 100% + ); + transition: opacity 200ms ease-out; +} + +.player-controls__bottom-scrim--visible { + opacity: 1; +} + .player-controls__title { position: absolute; top: 0; @@ -115,20 +156,20 @@ flex-direction: column; gap: 4px; // Bottom padding keeps the text inside the top scrim's opaque half. - padding: 20px 24px 40px; + padding: 20px 28px 40px; opacity: 0; // Purely informative: never intercept clicks meant for the video surface. pointer-events: none; transform: translateY(-8px); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms ease-out; } // Only when the corner button is actually rendered: keep the fullscreen title // from running underneath it (a long title ellipsizes at the full width). .player-controls__title:has(~ .player-controls__corner) { - padding-right: 64px; + padding-right: 72px; } .player-controls__title--visible { @@ -139,8 +180,8 @@ .player-controls__title-primary { overflow: hidden; color: var(--pc-text); - font-size: 1.15rem; - font-weight: 600; + font-size: 17px; + font-weight: 650; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; @@ -149,29 +190,29 @@ .player-controls__title-secondary { overflow: hidden; - color: var(--pc-text-dim); - font-size: 0.85rem; + color: var(--pc-text-secondary); + font-size: 12.5px; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.6); } -// Top-right corner chrome (stream-info button). It fades with the bottom bar +// Top-right corner chrome (stream-info button). It fades with the dock // but is its own layer: the title scrim above it is click-through, so the // button opts back into pointer events on its own. .player-controls__corner { position: absolute; - top: 8px; - right: 8px; + top: 16px; + right: 20px; z-index: 3; display: flex; opacity: 0; pointer-events: none; transform: translateY(-8px); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms ease-out; } .player-controls__corner--visible { @@ -180,34 +221,23 @@ transform: translateY(0); } +// The dock: timeline row above, control row below. No background of its own; +// the bottom scrim is the backdrop. .player-controls__bar { position: absolute; - right: 0; - bottom: 0; - left: 0; + right: 32px; + bottom: 22px; + left: 32px; z-index: 2; - display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 16px; - align-items: center; - align-content: end; - min-height: 64px; - // Generous top padding lets the scrim rise above the controls and fade out. - padding: 48px 16px 12px; + display: flex; + flex-direction: column; + gap: 12px; opacity: 0; pointer-events: none; transform: translateY(8px); - // True-overlay scrim: opaque-ish black at the bottom fading to fully - // transparent at the top, so controls read clearly over full-bleed video. - background: linear-gradient( - to top, - rgba(0, 0, 0, 0.78) 0%, - rgba(0, 0, 0, 0.45) 45%, - transparent 100% - ); transition: - opacity 180ms ease-out, - transform 200ms ease-out; + opacity 200ms ease-out, + transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); } .player-controls__bar--visible { @@ -216,94 +246,64 @@ transform: translateY(0); } +// The wide settings panel sits beside the video: the dock and the top chrome +// slide left so nothing is covered, in sync with the panel's own slide-in. +.player-controls__bar--panel-open { + right: 370px; +} + +.player-controls__title--panel-open { + right: 354px; +} + +.player-controls__corner--panel-open { + right: 374px; +} + +// A compact sheet replaces the dock while open; the sheet carries the +// controls' pointer events, and the video tap that closes it lands on the +// surface underneath. +.player-controls__bar--sheet-open { + opacity: 0; + pointer-events: none; +} + +// --- Control row ------------------------------------------------------------ + +// Each side column keeps at least its own content width: when the action +// cluster needs more than half of what the transport leaves, the transport +// slides off-centre instead of the actions overlapping it or leaving the +// player. Which extras exist at all is decided by `ControlsLayout`. +.player-controls__row { + display: grid; + grid-template-columns: minmax(min-content, 1fr) auto minmax( + min-content, + 1fr + ); + gap: 8px; + align-items: center; +} + +.player-controls__left { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + .player-controls__transport { display: flex; align-items: center; + justify-content: center; gap: 8px; } -.player-controls__timeline { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; -} - -.player-controls__time { - display: flex; - justify-content: space-between; - align-items: center; - gap: 12px; - color: var(--pc-text-dim); - font-size: 0.78rem; - font-variant-numeric: tabular-nums; -} - -.player-controls__recording-status { - display: flex; - align-items: center; - gap: 6px; - min-width: 0; - color: rgba(255, 255, 255, 0.78); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; -} - -.player-controls__recording-status mat-icon { - flex: 0 0 auto; - width: 16px; - height: 16px; - font-size: 16px; - color: #ffffff; -} - -.player-controls__recording-status span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.player-controls__recording-status--active { - color: var(--mat-sys-error, #ff5252); - font-weight: 700; -} - -.player-controls__recording-status--active mat-icon { - color: var(--mat-sys-error, #ff5252); -} - -.player-controls__live-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 4px; - color: #ffffff; - background: var(--mat-sys-error, #e53935); - font-size: 0.66rem; - font-weight: 700; - letter-spacing: 0.08em; -} - -.player-controls__live-dot { - width: 6px; - height: 6px; - border-radius: 50%; - background: currentColor; - animation: player-controls-live-pulse 1.6s ease-in-out infinite; -} - -@keyframes player-controls-live-pulse { - 50% { - opacity: 0.35; - } -} - .player-controls__actions { display: flex; align-items: center; - gap: 8px; + justify-content: flex-end; + gap: 4px; + min-width: 0; } .player-controls__popover-anchor { @@ -311,47 +311,208 @@ display: flex; } +.player-controls__volume { + align-items: center; + gap: 4px; +} + .player-controls__record-button--active { color: var(--mat-sys-error, #ff5252) !important; } -// Scope to :host — the controls render directly under the component host -// (`.player-controls-host`); there is no `.player-controls` wrapper element, so -// selectors prefixed with `.player-controls ` never matched (which is why the -// icons were only white on :hover, the one rule scoped to `.player-controls__bar`). -:host :is(button[mat-icon-button]) { - // Material drives the icon colour through MDC tokens, not plain `color`, - // so force both — otherwise the icons render in the dim theme colour. +// Value chips (subtitles, speed): the two settings a viewer changes mid-film. +// Neutral until something is on (cyan) or changed (violet). +.player-controls__chip { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; + height: 32px; + max-width: 132px; + padding: 0 11px; + color: var(--pc-text); + background: transparent; + border: 0; + border-radius: 10px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + transition: background 120ms ease-out; +} + +.player-controls__chip mat-icon { + flex: 0 0 auto; + width: 19px; + height: 19px; + font-size: 19px; +} + +.player-controls__chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__chip:hover, +.player-controls__chip:focus-visible { + background: var(--pc-hover); +} + +.player-controls__chip:focus-visible { + outline: 2px solid var(--pc-text); + outline-offset: 2px; +} + +.player-controls__chip--on { + color: var(--pc-accent-cyan); + background: rgba(92, 214, 255, 0.15); +} + +.player-controls__chip--modified { + color: var(--pc-accent-violet); + background: rgba(181, 153, 255, 0.16); +} + +.player-controls__chip--on:hover, +.player-controls__chip--on:focus-visible { + background: rgba(92, 214, 255, 0.24); +} + +.player-controls__chip--modified:hover, +.player-controls__chip--modified:focus-visible { + background: rgba(181, 153, 255, 0.26); +} + +// The settings button: filled in the accent while its panel is open, and +// carrying state dots where the chips are folded away (compact). +:host :is(.player-controls__tune) { + position: relative; +} + +:host :is(.player-controls__tune--active), +:host :is(.player-controls__tune--active:hover) { color: #ffffff; - --mdc-icon-button-icon-color: #ffffff; + background: var(--pc-accent-blue-strong); --mat-icon-button-icon-color: #ffffff; } -:host :is(button[mat-icon-button]) mat-icon { +.player-controls__tune-dots { + position: absolute; + bottom: 3px; + left: 50%; + display: flex; + gap: 4px; + transform: translateX(-50%); + pointer-events: none; +} + +.player-controls__tune-dot { + width: 5px; + height: 5px; + border-radius: 50%; +} + +.player-controls__tune-dot--cyan { + background: var(--pc-accent-cyan); +} + +.player-controls__tune-dot--violet { + background: var(--pc-accent-violet); +} + +// Primary action: a filled circle in the accent colour. Not a Material icon +// button on purpose — the ring and the fill are the design, and Material's +// state layer would tint them. +.player-controls__play { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 52px; + height: 52px; + margin: 0 6px; + padding: 0; + border: 0; + border-radius: 50%; color: #ffffff; + background: var(--pc-accent-blue-strong); + box-shadow: + 0 0 0 6px rgba(79, 142, 255, 0.18), + 0 6px 20px rgba(79, 142, 255, 0.35); + cursor: pointer; + transition: background 120ms ease-out; +} + +.player-controls__play mat-icon { + width: 30px; + height: 30px; + font-size: 30px; + color: #ffffff; +} + +// Hover darkens rather than lightens, and does not scale: the white glyph +// keeps its contrast against the fill, and a scaled glyph is resampled and +// blurred on 1x displays (the theme E2E rasterizes exactly this state). +.player-controls__play:hover:not(:disabled) { + background: var(--pc-accent-blue-hover); +} + +.player-controls__play:focus-visible { + outline: 2px solid #ffffff; + outline-offset: 3px; +} + +.player-controls__play:disabled { + opacity: 0.55; + cursor: default; +} + +// Scope to :host — the controls render directly under the component host +// (`.player-controls-host`); there is no `.player-controls` wrapper element. +:host :is(button[mat-icon-button]) { + width: 40px; + height: 40px; + padding: 8px; + // Material drives the icon colour and geometry through its tokens, not + // plain `color`/`width`, so set both — otherwise the icons render in the + // dim theme colour inside a 48px circle. + color: var(--pc-text); + --mat-icon-button-icon-color: var(--pc-text); + --mat-icon-button-state-layer-size: 40px; + --mat-icon-button-container-shape: 12px; + --mat-icon-button-hover-state-layer-opacity: 0; + border-radius: 12px; + transition: background 120ms ease-out; +} + +:host :is(button[mat-icon-button]) mat-icon { + color: var(--pc-text); } :host :is(button[mat-icon-button][disabled]) { // Keep disabled affordances visibly muted. - --mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38); - color: rgba(255, 255, 255, 0.38); + --mat-icon-button-icon-color: var(--pc-text-tertiary); + color: var(--pc-text-tertiary); } :host :is(button[mat-icon-button][disabled]) mat-icon { - color: rgba(255, 255, 255, 0.38); + color: var(--pc-text-tertiary); } :host :is(.player-controls__bar, .player-controls__corner) - :is(button[mat-icon-button]:hover) { + :is(button[mat-icon-button]:hover:not([disabled])) { color: #ffffff; - background: rgba(255, 255, 255, 0.16); + background: var(--pc-hover); } +// --- Sliders ---------------------------------------------------------------- + .player-controls__slider { --slider-progress: 0%; width: 100%; height: 18px; + margin: 0; appearance: none; -webkit-appearance: none; background: transparent; @@ -359,7 +520,7 @@ } .player-controls__slider:focus-visible { - outline: 2px solid var(--pc-accent); + outline: 2px solid var(--pc-text); outline-offset: 4px; border-radius: 4px; } @@ -369,7 +530,7 @@ border-radius: 999px; background: linear-gradient( to right, - var(--pc-accent) var(--slider-progress), + var(--pc-text) var(--slider-progress), var(--pc-track) var(--slider-progress) ); } @@ -377,13 +538,13 @@ .player-controls__slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; - width: 14px; - height: 14px; - margin-top: -5px; - border: 2px solid var(--pc-thumb-ring); + width: 12px; + height: 12px; + margin-top: -4px; + border: 0; border-radius: 50%; - background: var(--pc-accent); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); transition: transform 120ms ease-out; } @@ -398,7 +559,6 @@ .player-controls__slider:disabled::-webkit-slider-thumb { background: rgba(255, 255, 255, 0.45); - border-color: rgba(255, 255, 255, 0.6); box-shadow: none; } @@ -411,16 +571,16 @@ .player-controls__slider::-moz-range-progress { height: 4px; border-radius: 999px; - background: var(--pc-accent); + background: var(--pc-text); } .player-controls__slider::-moz-range-thumb { - width: 14px; - height: 14px; - border: 2px solid var(--pc-thumb-ring); + width: 12px; + height: 12px; + border: 0; border-radius: 50%; - background: var(--pc-accent); - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + background: #ffffff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55); transition: transform 120ms ease-out; } @@ -431,24 +591,30 @@ .player-controls__slider:disabled::-moz-range-thumb { background: rgba(255, 255, 255, 0.45); - border-color: rgba(255, 255, 255, 0.6); box-shadow: none; } +.player-controls__slider--inline { + width: 72px; + margin-left: 4px; +} + +// --- Popovers --------------------------------------------------------------- + .player-controls__panel { min-width: 0; color: var(--pc-text); - background: var(--pc-panel-bg); - border: 1px solid var(--pc-panel-border); + background: var(--pc-glass-bg); + border: 1px solid var(--pc-glass-border); border-radius: 14px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); - backdrop-filter: blur(18px); + backdrop-filter: blur(18px) saturate(1.3); } .player-controls__volume-popover { position: absolute; bottom: calc(100% + 6px); - right: 0; + left: 0; display: grid; grid-template-columns: 1fr; gap: 4px; @@ -481,29 +647,10 @@ white-space: nowrap; } -.player-controls__track-popover { - position: absolute; - bottom: calc(100% + 6px); - right: 0; - display: flex; - flex-direction: column; - min-width: 220px; - // Cap to the player box (minus a small gutter) so the panel never spills - // past the player's edges on small windows; falls back to the viewport - // where container units are unavailable. - max-width: min(420px, calc(100cqw - 24px), 80vw); - // Height grows to fit the list; only when it would exceed the room above - // the controls bar does the inner list start scrolling. 96px ≈ the controls - // bar height plus the 6px gap and a little breathing room. - max-height: min(calc(100cqh - 96px), 70vh); - padding: 10px 12px; - z-index: 3; -} - .player-controls__menu-title { flex: 0 0 auto; padding: 0 2px 6px; - color: rgba(255, 255, 255, 0.7); + color: var(--pc-text-tertiary); font-size: 0.7rem; font-weight: 700; letter-spacing: 0.08em; @@ -521,7 +668,7 @@ flex-direction: column; min-width: 240px; max-width: min(360px, calc(100cqw - 24px), 80vw); - max-height: min(calc(100cqh - 96px), 70vh); + max-height: min(calc(100cqh - 120px), 70vh); padding: 10px 12px; overflow-y: auto; scrollbar-width: thin; @@ -563,257 +710,98 @@ font-size: 0.8rem; } -.player-controls__track-list { - display: flex; - flex-direction: column; - gap: 4px; - // No fixed cap: the panel's max-height bounds the popover, and the title - // above stays pinned while this list takes the remaining space and scrolls - // only when the content genuinely exceeds it. - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; -} - -.player-controls__track { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 10px; - color: var(--pc-text); - background: transparent; - border: 0; - border-radius: 10px; - cursor: pointer; - font: inherit; - text-align: left; -} - -.player-controls__track span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.player-controls__track:hover, -.player-controls__track:focus-visible, -.player-controls__track--selected { - background: rgba(255, 255, 255, 0.16); -} - -.player-controls__track mat-icon { - color: #ffffff; - font-size: 18px; - width: 18px; - height: 18px; -} - -.player-controls__load-subtitle mat-icon { - color: rgba(255, 255, 255, 0.85); -} - -// Single scroll owner for the subtitle popover's list + delay/style sections: -// on short players (e.g. the 220px portal player) the panel's max-height cap -// must scroll ALL content — sections outside the list's own scroll area would -// otherwise clip or overflow into the controls bar. -.player-controls__subtitle-body { - display: flex; - flex-direction: column; - flex: 0 1 auto; - min-height: 0; - overflow-y: auto; - scrollbar-width: thin; -} - -// Inside the shared scroll owner the list must not nest a second scroll area. -.player-controls__subtitle-body .player-controls__track-list { - flex: none; - overflow-y: visible; -} - -.player-controls__subtitle-section { - flex: 0 0 auto; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.12); -} - -.player-controls__subtitle-section-title { - padding: 0 2px 6px; - color: rgba(255, 255, 255, 0.7); - font-size: 0.7rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - white-space: nowrap; -} - -.player-controls__subtitle-delay-row { - display: flex; - align-items: center; - gap: 4px; -} - -.player-controls__subtitle-delay-value { - min-width: 58px; - text-align: center; - font-variant-numeric: tabular-nums; - color: var(--pc-text); -} - -.player-controls__subtitle-preset-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 6px; - padding-bottom: 4px; -} - -.player-controls__subtitle-chip { - padding: 4px 10px; - color: var(--pc-text); - background: transparent; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 999px; - cursor: pointer; - font: inherit; - font-size: 0.8rem; -} - -.player-controls__subtitle-chip:hover, -.player-controls__subtitle-chip:focus-visible, -.player-controls__subtitle-chip--selected { - background: rgba(255, 255, 255, 0.16); - border-color: rgba(255, 255, 255, 0.6); -} - -.player-controls__subtitle-swatch { - width: 26px; - height: 26px; - border: 2px solid rgba(255, 255, 255, 0.35); - border-radius: 50%; - cursor: pointer; - background: var(--swatch-color, transparent); -} - -// The "engine default" swatch has no color of its own: render a diagonal -// slash so it reads as "no override". -.player-controls__subtitle-swatch--default { - background: - linear-gradient( - 135deg, - transparent 44%, - rgba(255, 255, 255, 0.7) 46%, - rgba(255, 255, 255, 0.7) 54%, - transparent 56% - ), - rgba(255, 255, 255, 0.08); -} - -.player-controls__subtitle-swatch:hover, -.player-controls__subtitle-swatch:focus-visible, -.player-controls__subtitle-swatch--selected { - border-color: #ffffff; -} - -@media (max-width: 720px) { - // Moderately tight viewports keep the single-row layout: the transport and - // action buttons hold their natural width while the middle timeline column - // (the seek slider) absorbs the shrink. Genuinely narrow players reflow to - // two rows via the container query below, which wins over this gap. - .player-controls__bar { - gap: 8px; - } - - .player-controls__actions { - min-width: 0; - } - - .player-controls__volume-popover, - .player-controls__track-popover { - right: 0; - left: auto; - } -} - -// Touch scrubbing needs a taller hit strip and thumb than the 18px mouse -// slider; the 4px visual track is unchanged — only the input's touch area and -// the thumb grow. +// Touch needs a taller hit strip and thumb than the 18px mouse slider; the +// drawn track is unchanged. The timeline row sizes its own strip. @media (pointer: coarse) { .player-controls__slider { height: 28px; } .player-controls__slider::-webkit-slider-thumb { - width: 18px; - height: 18px; - margin-top: -7px; + width: 16px; + height: 16px; + margin-top: -6px; } .player-controls__slider::-moz-range-thumb { - width: 18px; - height: 18px; + width: 16px; + height: 16px; } } -// Narrow players — phone-sized PWA viewports and small windowed/inline -// players — cannot hold transport + timeline + the variable-count actions -// cluster in one row (every action button from volume to fullscreen is -// conditional, so the cluster's width is content-dependent). Give the -// timeline its own full-width row and split the second row between the two -// button clusters. Container-based on purpose: a small inline player inside a -// wide desktop window needs the same treatment as a phone viewport. -@container player-controls (max-width: 640px) { - // Matches the bar's reduced padding below: on a small inline player two - // full-height scrims would tint most of the picture. +// Compact dock — phone-sized PWA viewports and small windowed/inline players. +// Keep this in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts, +// which decides what is rendered; this block only sizes it. Container-based +// on purpose: a small inline player inside a wide desktop window needs the +// same treatment as a phone viewport. +@container player-controls (max-width: 719px) { .player-controls__top-scrim { - height: 88px; + height: max(28%, 88px); + } + + .player-controls__title { + padding: 12px 16px 28px; + } + + .player-controls__corner { + top: 8px; + right: 8px; } .player-controls__bar { - grid-template-columns: auto minmax(0, 1fr); - grid-template-areas: - 'timeline timeline' - 'transport actions'; - gap: 0 8px; - padding: 36px 10px 8px; + right: 14px; + bottom: 10px; + left: 14px; + gap: 6px; + } + + .player-controls__row { + gap: 4px; } .player-controls__transport { - grid-area: transport; + gap: 4px; } - .player-controls__timeline { - grid-area: timeline; + .player-controls__play { + width: 36px; + height: 36px; + margin: 0 4px; + box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.18); + } + + .player-controls__play mat-icon { + width: 22px; + height: 22px; + font-size: 22px; } .player-controls__actions { - grid-area: actions; position: relative; - justify-self: end; - // When even a dedicated row cannot hold the cluster (worst case: - // series transport plus audio/subtitles/quality/speed/aspect/PiP), - // wrap the actions instead of making them a scroll container. A scroll - // container would clip every absolutely positioned capability panel. + // When even the row cannot hold the cluster (worst case: audio/ + // subtitles/quality/speed/aspect/PiP), wrap the actions instead of + // making them a scroll container. A scroll container would clip + // every absolutely positioned capability panel. flex-wrap: wrap; justify-content: flex-end; max-width: 100%; overflow: visible; } - // Anchor narrow-player panels to the whole actions cluster. This keeps the - // right-aligned panels inside the player and above every wrapped action row. - .player-controls__actions .player-controls__popover-anchor { - position: static; - } - // Compact icon buttons: wins back roughly one button of width per four. .player-controls__bar :is(button[mat-icon-button]) { - width: 40px; - height: 40px; - padding: 8px; - --mdc-icon-button-state-layer-size: 40px; + width: 32px; + height: 32px; + padding: 6px; + border-radius: 9px; + --mat-icon-button-state-layer-size: 32px; + --mat-icon-button-container-shape: 9px; + --mat-icon-button-icon-size: 20px; + } + + .player-controls__bar :is(button[mat-icon-button]) mat-icon { + width: 20px; + height: 20px; + font-size: 20px; } } diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts new file mode 100644 index 000000000..aafb3ff6b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.settings-panel.spec.ts @@ -0,0 +1,485 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + COMPACT_LAYOUT_MAX_WIDTH, + ROOMY_LAYOUT_MIN_WIDTH, +} from './controls-layout'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +type ResizeCallback = (entries: ResizeObserverEntry[]) => void; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setQualityLevel: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +/** + * The settings panel behind the `tune` button: chips on wide players, the + * panel beside the video, the compact bottom sheet, and the dock changes + * that come with each. + */ +describe('PlayerControlsComponent settings panel', () => { + const originalResizeObserver = globalThis.ResizeObserver; + let resizeCallbacks: ResizeCallback[]; + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + const queryAll = (selector: string) => + Array.from( + fixture.nativeElement.querySelectorAll( + selector + ) as NodeListOf + ); + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const resizeTo = (width: number) => { + for (const callback of resizeCallbacks) { + callback([ + { + borderBoxSize: [{ inlineSize: width, blockSize: 300 }], + contentRect: { width } as DOMRectReadOnly, + } as unknown as ResizeObserverEntry, + ]); + } + fixture.detectChanges(); + }; + + const withEverything = () => { + setCapabilities({ + volume: true, + audioTracks: true, + subtitles: true, + externalSubtitles: true, + playbackSpeed: true, + aspectRatio: true, + pictureInPicture: true, + fullscreen: true, + }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + subtitleTracks: [{ id: 5, label: 'Russian', selected: false }], + canPictureInPicture: true, + }); + fixture.detectChanges(); + }; + + beforeEach(async () => { + resizeCallbacks = []; + class FakeResizeObserver { + constructor(callback: ResizeCallback) { + resizeCallbacks.push(callback); + } + observe(): void { + /* noop */ + } + unobserve(): void { + /* noop */ + } + disconnect(): void { + /* noop */ + } + } + globalThis.ResizeObserver = + FakeResizeObserver as unknown as typeof ResizeObserver; + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + SETTINGS: 'Settings', + CLOSE_SETTINGS: 'Close settings', + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + PLAYBACK_SPEED: 'Playback speed', + AUDIO_TRACKS: 'Audio tracks', + ASPECT_RATIO: 'Aspect ratio', + ASPECT_DEFAULT: 'Default', + ENTER_PICTURE_IN_PICTURE: 'Enter picture-in-picture', + ENTER_FULLSCREEN: 'Enter fullscreen', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + globalThis.ResizeObserver = originalResizeObserver; + }); + + describe('wide dock', () => { + beforeEach(withEverything); + + it('shows subtitle and speed chips with their values and state colors', () => { + const subtitles = query( + '[data-test-id="player-controls-subtitle-chip"]' + ); + const speed = query('[data-test-id="player-controls-speed-chip"]'); + expect(subtitles?.textContent).toContain('Off'); + expect(subtitles?.classList).not.toContain( + 'player-controls__chip--on' + ); + expect(speed?.textContent).toContain('1×'); + expect(speed?.classList).not.toContain( + 'player-controls__chip--modified' + ); + + setState({ + audioTracks: fake.state().audioTracks, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + playbackSpeed: 1.25, + }); + fixture.detectChanges(); + + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ?.textContent + ).toContain('Russian'); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ?.classList + ).toContain('player-controls__chip--on'); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ?.textContent + ).toContain('1.25×'); + expect( + query('[data-test-id="player-controls-speed-chip"]')?.classList + ).toContain('player-controls__chip--modified'); + }); + + it('opens the panel on the chip group, shifts the dock and folds the chips away', () => { + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + + query('[data-test-id="player-controls-speed-chip"]')?.click(); + fixture.detectChanges(); + + const panel = query( + '[data-test-id="player-controls-settings-panel"]' + ); + expect(panel).not.toBeNull(); + expect(panel?.classList).not.toContain( + 'player-controls__settings--sheet' + ); + expect(panel?.getAttribute('role')).toBe('dialog'); + expect(panel?.getAttribute('aria-label')).toBe('Settings'); + expect( + query('[data-test-id="player-settings-speed"]')?.classList + ).toContain('player-settings__group--focused'); + expect(query('.player-controls__bar')?.classList).toContain( + 'player-controls__bar--panel-open' + ); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + expect(query('[aria-label="Enter picture-in-picture"]')).toBeNull(); + expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ?.classList + ).toContain('player-controls__tune--active'); + }); + + it('renders every available group in order and applies choices in place', () => { + component.settings.open(); + fixture.detectChanges(); + + expect( + queryAll('.player-settings__group').map((group) => + group.getAttribute('data-group') + ) + ).toEqual(['audio', 'subtitles', 'speed', 'aspect']); + + queryAll( + '[data-test-id="player-settings-audio"] .player-settings__option' + )[1]?.click(); + expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); + + const preset = queryAll( + '[data-test-id="player-settings-aspect"] .player-settings__seg-item' + ).find((item) => item.textContent?.includes('16:9')); + preset?.click(); + expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9'); + expect(component.settings.isOpen()).toBe(true); + }); + + it('closes from the panel button and from the tune button', () => { + component.settings.open(); + fixture.detectChanges(); + + query('[data-test-id="player-settings-close"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--panel-open' + ); + + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).not.toBeNull(); + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + }); + + it('toggles subtitles from the chip context menu without opening the panel', () => { + const chip = query( + '[data-test-id="player-controls-subtitle-chip"]' + ) as HTMLElement; + const event = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + }); + chip.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(5); + expect(component.settings.isOpen()).toBe(false); + }); + + it('closes the panel once no group is left', () => { + component.settings.open('audio'); + fixture.detectChanges(); + + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); + fixture.detectChanges(); + + expect(component.settings.isOpen()).toBe(false); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + }); + }); + + describe('wide dock without room for extras', () => { + beforeEach(() => { + withEverything(); + resizeTo(ROOMY_LAYOUT_MIN_WIDTH - 1); + }); + + it('stays wide but folds the chips into tune and opens a sheet', () => { + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--compact' + ); + expect(query('.player-controls__slider--inline')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + + setState({ + audioTracks: fake.state().audioTracks, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + }); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-dots"]') + ).not.toBeNull(); + + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-panel"]') + ?.classList + ).toContain('player-controls__settings--sheet'); + expect(query('.player-controls__bar')?.classList).toContain( + 'player-controls__bar--sheet-open' + ); + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--panel-open' + ); + }); + }); + + describe('compact dock', () => { + beforeEach(() => { + withEverything(); + resizeTo(COMPACT_LAYOUT_MAX_WIDTH); + }); + + it('folds the chips into the tune button with state dots', () => { + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-settings-dots"]') + ).toBeNull(); + + setState({ + audioTracks: fake.state().audioTracks, + subtitleTracks: [{ id: 5, label: 'Russian', selected: true }], + subtitlesEnabled: true, + playbackSpeed: 2, + }); + fixture.detectChanges(); + + const dots = query( + '[data-test-id="player-controls-settings-dots"]' + ); + expect( + dots?.querySelector('.player-controls__tune-dot--cyan') + ).not.toBeNull(); + expect( + dots?.querySelector('.player-controls__tune-dot--violet') + ).not.toBeNull(); + }); + + it('makes the hidden dock inert and moves keyboard focus into the sheet and back', async () => { + const tune = query( + '[data-test-id="player-controls-settings-button"]' + ) as HTMLButtonElement; + document.body.appendChild(fixture.nativeElement); + tune.focus(); + // jsdom has no keyboard modality: report the focused tune button + // as :focus-visible, as a keyboard focus would be. + const matches = tune.matches.bind(tune); + Object.defineProperty(tune, 'matches', { + configurable: true, + value: (selector: string) => + selector === ':focus-visible' || matches(selector), + }); + + tune.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + const sheet = query( + '[data-test-id="player-controls-settings-panel"]' + ) as HTMLElement; + expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe( + true + ); + expect(document.activeElement).toBe(sheet); + + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ); + fixture.detectChanges(); + await fixture.whenStable(); + await Promise.resolve(); + + expect( + query('[data-test-id="player-controls-settings-panel"]') + ).toBeNull(); + expect(query('.player-controls__bar')?.hasAttribute('inert')).toBe( + false + ); + expect(document.activeElement).toBe( + query('[data-test-id="player-controls-settings-button"]') + ); + fixture.nativeElement.remove(); + }); + + it('leaves focus alone when a pointer opens the sheet', async () => { + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(document.activeElement).not.toBe( + query('[data-test-id="player-controls-settings-panel"]') + ); + }); + + it('opens a bottom sheet that replaces the dock and keeps PiP reachable after close', () => { + query('[data-test-id="player-controls-settings-button"]')?.click(); + fixture.detectChanges(); + + const sheet = query( + '[data-test-id="player-controls-settings-panel"]' + ); + expect(sheet?.classList).toContain( + 'player-controls__settings--sheet' + ); + expect( + sheet?.querySelector('.player-settings__grip') + ).not.toBeNull(); + expect(query('.player-controls__bar')?.classList).toContain( + 'player-controls__bar--sheet-open' + ); + expect( + query('[data-test-id="player-controls-settings-dots"]') + ).toBeNull(); + + query('[data-test-id="player-settings-close"]')?.click(); + fixture.detectChanges(); + expect(query('.player-controls__bar')?.classList).not.toContain( + 'player-controls__bar--sheet-open' + ); + expect( + query('[aria-label="Enter picture-in-picture"]') + ).not.toBeNull(); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts index 5d31d59ee..1bd3e1856 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts @@ -77,6 +77,7 @@ describe('PlayerControlsComponent', () => { FORWARD_10_SECONDS: 'Forward 10 seconds', LIVE_STREAM: 'Live stream', PLAYBACK_POSITION: 'Playback position', + SETTINGS: 'Settings', MUTE: 'Mute', UNMUTE: 'Unmute', VOLUME: 'Volume', @@ -107,10 +108,15 @@ describe('PlayerControlsComponent', () => { describe('capability gating', () => { it('hides every optional control when no capability is enabled', () => { expect(query('[aria-label="Back 10 seconds"]')).toBeNull(); - expect(query('[aria-label="Audio tracks"]')).toBeNull(); - expect(query('[aria-label="Subtitles"]')).toBeNull(); - expect(query('[aria-label="Playback speed"]')).toBeNull(); - expect(query('[aria-label="Aspect ratio"]')).toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-chip"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).toBeNull(); expect(query('.player-controls__record-button')).toBeNull(); expect(query('[aria-label="Enter fullscreen"]')).toBeNull(); expect(query('[aria-label="Mute"]')).toBeNull(); @@ -137,8 +143,16 @@ describe('PlayerControlsComponent', () => { expect(query('.player-controls__record-button')).toBeNull(); }); - it('shows the audio menu only with the capability and more than one track', () => { + it('shows the settings button only with the capability and more than one audio track', () => { setCapabilities({ audioTracks: true }); + setState({ + audioTracks: [{ id: 1, label: 'English', selected: true }], + }); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).toBeNull(); + setState({ audioTracks: [ { id: 1, label: 'English', selected: true }, @@ -146,14 +160,23 @@ describe('PlayerControlsComponent', () => { ], }); fixture.detectChanges(); - expect(query('[aria-label="Audio tracks"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).not.toBeNull(); + expect(query('[aria-label="Settings"]')).not.toBeNull(); }); - it('shows speed and aspect controls per their capabilities', () => { + it('shows the speed chip and the settings button per their capabilities', () => { setCapabilities({ playbackSpeed: true, aspectRatio: true }); fixture.detectChanges(); - expect(query('[aria-label="Playback speed"]')).not.toBeNull(); - expect(query('[aria-label="Aspect ratio"]')).not.toBeNull(); + expect( + query('[data-test-id="player-controls-speed-chip"]') + ).not.toBeNull(); + // Aspect ratio has no chip of its own: it lives in the panel. + expect(query('[aria-label="Aspect ratio"]')).toBeNull(); + expect( + query('[data-test-id="player-controls-settings-button"]') + ).not.toBeNull(); }); it('shows the fullscreen button when supported', () => { @@ -201,10 +224,10 @@ describe('PlayerControlsComponent', () => { }); fixture.detectChanges(); - query('[aria-label="Audio tracks"]')?.click(); + query('[data-test-id="player-controls-settings-button"]')?.click(); fixture.detectChanges(); const items = fixture.nativeElement.querySelectorAll( - '.player-controls__track' + '[data-test-id="player-settings-audio"] .player-settings__option' ) as NodeListOf; items[1].click(); expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts index 7b0aab109..09585bc5e 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -61,7 +61,7 @@ describe('PlayerControlsComponent subtitle settings', () => { fixture.nativeElement.querySelector(selector) as HTMLElement | null; const openSubtitleMenu = () => { - fixture.componentInstance.toggleMenu('subtitle'); + fixture.componentInstance.settings.open('subtitles'); fixture.detectChanges(); }; @@ -98,7 +98,7 @@ describe('PlayerControlsComponent subtitle settings', () => { fixture.detectChanges(); }); - it('renders the subtitle button with zero tracks when external loading exists', () => { + it('renders the subtitle chip with zero tracks when external loading exists', () => { expect(query('[aria-label="Subtitles"]')).toBeNull(); setCapabilities({ externalSubtitles: true }); @@ -107,17 +107,17 @@ describe('PlayerControlsComponent subtitle settings', () => { expect(query('[aria-label="Subtitles"]')).not.toBeNull(); openSubtitleMenu(); // No track list entries: no Off row without a selectable track… - expect(query('.player-controls__track--selected')).toBeNull(); + expect(query('.player-settings__option--selected')).toBeNull(); // …but the load action is present. const load = query('[data-test-id="player-controls-load-subtitle"]'); expect(load).not.toBeNull(); load?.click(); expect(fake.commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1); fixture.detectChanges(); - // The pick closes the popover (a file dialog opens on top). + // The panel stays open: the loaded file appears as a track in it. expect( query('[data-test-id="player-controls-load-subtitle"]') - ).toBeNull(); + ).not.toBeNull(); }); it('hides the load action, delay, and style sections without the capabilities', () => { @@ -154,8 +154,7 @@ describe('PlayerControlsComponent subtitle settings', () => { ); expect(section).not.toBeNull(); expect( - section?.querySelector('.player-controls__subtitle-delay-value') - ?.textContent + section?.querySelector('.player-settings__delay-value')?.textContent ).toContain('+0.5 s'); ( @@ -199,7 +198,7 @@ describe('PlayerControlsComponent subtitle settings', () => { ) as HTMLElement; const chips = Array.from( style.querySelectorAll( - '.player-controls__subtitle-chip' + '.player-settings__seg-item' ) ); const largeChip = chips.find((chip) => @@ -222,7 +221,13 @@ describe('PlayerControlsComponent subtitle settings', () => { it('guards the new commands behind their capabilities', () => { const component = fixture.componentInstance; - component.loadExternalSubtitle(); + // Without a subtitle group the panel cannot open, and without the + // capability the load action is not rendered inside it. + component.settings.open('subtitles'); + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-load-subtitle"]') + ).toBeNull(); component.subtitleSettings.adjustDelay(0.5); component.subtitleSettings.resetDelay(); component.subtitleSettings.setSize(150); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts index 1c9553d9f..048b70920 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -205,7 +205,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { it('a click while a menu is open dismisses it instead of pausing', () => { jest.useFakeTimers(); - component.toggleMenu('speed'); + component.toggleMenu('settings'); expect(component.anyMenuOpen()).toBe(true); surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); @@ -216,7 +216,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { }); it('an outside pointerdown closes open menus', () => { - component.toggleMenu('audio'); + component.toggleMenu('settings'); expect(component.anyMenuOpen()).toBe(true); document.body.dispatchEvent( @@ -230,7 +230,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { const popoverChild = document.createElement('button'); controlsRoot.appendChild(popoverChild); document.body.appendChild(controlsRoot); - component.toggleMenu('audio'); + component.toggleMenu('settings'); popoverChild.dispatchEvent( new MouseEvent('pointerdown', { bubbles: true }) @@ -350,7 +350,7 @@ describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { ); it('Escape closes an open menu', () => { - component.toggleMenu('subtitle'); + component.toggleMenu('settings'); expect(component.anyMenuOpen()).toBe(true); pressKey('Escape'); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts index f40a5a654..a11cb97c1 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts @@ -56,7 +56,7 @@ describe('PlayerControlsComponent timeline scrubbing', () => { const currentTimeText = () => ( fixture.nativeElement.querySelector( - '.player-controls__time > span:first-child' + '.player-controls__time--current' ) as HTMLElement ).textContent; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts index daea28aef..477898bec 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -17,34 +17,45 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ControlsChromeInteractions } from './controls-chrome-interactions'; import { ControlsFeedback } from './controls-feedback'; import { ControlsFullscreen } from './controls-fullscreen'; +import { ControlsLayout } from './controls-layout'; import { ControlsMenuSelection } from './controls-menu-selection'; import { type ControlsMenu, ControlsMenuState } from './controls-menu-state'; +import { ControlsSettings } from './controls-settings'; import { ControlsShortcuts } from './controls-shortcuts'; import { ControlsStreamStats } from './controls-stream-stats'; import { ControlsSurface } from './controls-surface'; import { ControlsTimeline } from './controls-timeline'; +import { ControlsTimelineHover } from './controls-timeline-hover'; +import { ControlsUpNext } from './controls-up-next'; import { ControlsVisibility } from './controls-visibility'; import { createControlsViewModel } from './controls-view-model'; import { ControlsVolume } from './controls-volume'; import { ControlsVolumeInteractions } from './controls-volume-interactions'; import { ControlsSubtitleSettings } from './controls-subtitle-settings'; -import { formatTime, speedLabel } from './controls-format.utils'; +import { formatRemainingTime } from './controls-format.utils'; import type { PlayerController, PlayerMediaTitle, + PlayerTimelineSegment, + PlayerUpNextItem, } from './player-controls.model'; -import { - SUBTITLE_COLOR_PRESETS, - SUBTITLE_DELAY_STEP_SECONDS, - SUBTITLE_SIZE_PRESETS, - subtitleDelayLabel, -} from './subtitle-style'; +import { PlayerSettingsPanelComponent } from './player-settings-panel.component'; +import { PlayerTimelineComponent } from './player-timeline.component'; +import { PlayerUpNextCardComponent } from './player-up-next-card.component'; @Component({ selector: 'app-player-controls', templateUrl: './player-controls.component.html', styleUrl: './player-controls.component.scss', - imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe], + imports: [ + MatButtonModule, + MatIconModule, + MatTooltipModule, + TranslatePipe, + PlayerSettingsPanelComponent, + PlayerTimelineComponent, + PlayerUpNextCardComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'player-controls-host', @@ -68,12 +79,21 @@ export class PlayerControlsComponent implements OnDestroy { readonly showControls = input(true); readonly shortcutsEnabled = input(true); readonly mediaTitle = input(null); + /** Chapters / programmes drawn as track segments; null draws one. */ + readonly timelineSegments = input( + null + ); + /** The next episode, for the "Up next" card near the end of this one. */ + readonly upNext = input(null); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); readonly menus = new ControlsMenuState(); readonly feedback = new ControlsFeedback(); readonly anyMenuOpen = this.menus.anyOpen; private readonly shortcuts = new ControlsShortcuts(); + readonly layout = new ControlsLayout(); + /** Compact dock: narrow inline players and phone-sized viewports. */ + readonly isCompact = computed(() => this.layout.mode() === 'compact'); private readonly visibility = new ControlsVisibility(() => this.canHide()); private readonly fullscreen = new ControlsFullscreen( () => this.fullscreenTarget() ?? this.playerSurface(), @@ -105,8 +125,6 @@ export class PlayerControlsComponent implements OnDestroy { }); readonly menuSelection = new ControlsMenuSelection({ commands: () => this.controller().commands, - menus: this.menus, - visibility: this.visibility, revealSticky: () => this.reveal({ scheduleHide: false }), }); readonly volumeInteractions = new ControlsVolumeInteractions({ @@ -128,12 +146,35 @@ export class PlayerControlsComponent implements OnDestroy { readonly state = computed(() => this.controller().state()); readonly capabilities = computed(() => this.controller().capabilities()); + readonly settings = new ControlsSettings({ + state: this.state, + capabilities: this.capabilities, + showControls: this.showControls, + menus: this.menus, + commands: () => this.controller().commands, + reveal: (options) => this.reveal(options), + }); + readonly upNextCard = new ControlsUpNext({ + item: this.upNext, + state: this.state, + capabilities: this.capabilities, + showControls: this.showControls, + settingsOpen: this.settings.isOpen, + }); private readonly controllerVolume = computed(() => this.state().volume); - private readonly timeline = new ControlsTimeline(this.state); + readonly timeline = new ControlsTimeline(this.state, this.timelineSegments); readonly scrubPosition = this.timeline.scrubPosition; - readonly timelineDuration = this.timeline.duration; - readonly timelineValue = this.timeline.value; - readonly timelineProgress = this.timeline.progress; + readonly timelineHover = new ControlsTimelineHover({ + duration: this.timeline.duration, + interactive: computed( + () => this.capabilities().seek && this.state().canSeek + ), + segments: this.timeline.segments, + }); + /** `−7:03` while a finite duration is known; the dock prefers it to the total. */ + readonly remainingTimeText = computed(() => + formatRemainingTime(this.timeline.value(), this.timeline.duration()) + ); readonly displayVolume = this.volume.value; readonly isFullscreen = this.fullscreen.isFullscreen; @@ -175,9 +216,6 @@ export class PlayerControlsComponent implements OnDestroy { readonly isPaused = this.vm.isPaused; readonly isPlaying = this.vm.isPlaying; readonly canTogglePlay = this.vm.canTogglePlay; - readonly hasAudioTracks = this.vm.hasAudioTracks; - readonly hasSubtitleTracks = this.vm.hasSubtitleTracks; - readonly hasQualityLevels = this.vm.hasQualityLevels; readonly canRecord = this.vm.canRecord; readonly isRecording = this.vm.isRecording; readonly recordingStatusText = this.vm.recordingStatusText; @@ -189,6 +227,7 @@ export class PlayerControlsComponent implements OnDestroy { readonly controlsAreVisible = this.vm.controlsAreVisible; readonly hideCursor = this.vm.hideCursor; constructor() { + this.layout.attach(this.host); this.shortcuts.attach({ isAvailable: () => this.shortcutsEnabled() && this.showControls(), hostElement: () => this.host, @@ -282,6 +321,7 @@ export class PlayerControlsComponent implements OnDestroy { }); } ngOnDestroy(): void { + this.layout.dispose(); this.shortcuts.detach(); this.feedback.dispose(); this.visibility.dispose(); @@ -290,12 +330,6 @@ export class PlayerControlsComponent implements OnDestroy { this.surface.dispose(); this.streamStats.dispose(); } - formatTime = formatTime; - speedLabel = speedLabel; - subtitleDelayLabel = subtitleDelayLabel; - readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS; - readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS; - readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS; togglePlay(): void { this.reveal(); if (!this.canTogglePlay()) { @@ -350,12 +384,6 @@ export class PlayerControlsComponent implements OnDestroy { this.reveal(); } - loadExternalSubtitle(): void { - if (!this.capabilities().externalSubtitles) { - return; - } - this.menuSelection.externalSubtitle(); - } toggleRecording(): void { if (!this.canRecord()) { return; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts index 218824d2f..e4997b56a 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -3,12 +3,7 @@ import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces'; import type { PlayerStreamStatsSource } from './player-stream-stats.model'; export type PlayerStatus = - | 'idle' - | 'loading' - | 'playing' - | 'paused' - | 'ended' - | 'error'; + 'idle' | 'loading' | 'playing' | 'paused' | 'ended' | 'error'; /** Which controls an engine supports. A control is only rendered when its flag is true. */ export interface PlayerControlsCapabilities { @@ -62,6 +57,31 @@ export interface PlayerMediaTitle { secondary?: string | null; } +/** + * One stretch of the timeline the track draws as its own segment — a chapter + * of a film, a programme of a catch-up recording. Hosts pass display-ready + * titles; the controls clamp, order and gap-fill the list themselves. + */ +export interface PlayerTimelineSegment { + startSeconds: number; + endSeconds: number; + /** Shown in the hover label as `Title · 12:40`; null for a plain gap. */ + title: string | null; +} + +/** + * The episode after the current one, for the "Up next" card. Series hosts + * build it from their episode list; the controls decide when to show it. + */ +export interface PlayerUpNextItem { + /** `S01E03`. */ + label: string; + title: string; + thumbnailUrl: string | null; + /** Watch progress in percent (0–100); null when never started. */ + progressPercent: number | null; +} + export interface PlayerPreset { value: T; label: string; diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html new file mode 100644 index 000000000..f58ac738b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.html @@ -0,0 +1,439 @@ +@if (mode() === 'sheet') { + +} +
+
{{ title() }}
+ +
+ +
+ @if (groups().audio) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.AUDIO_TRACKS' | translate }} +
+
+ @for (track of state().audioTracks; track track.id) { + + } +
+
+ } + + @if (groups().subtitles) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }} +
+
+ @if ( + capabilities().subtitles && + state().subtitleTracks.length > 0 + ) { + + } + @for (track of state().subtitleTracks; track track.id) { + + } + @if (capabilities().externalSubtitles) { + + } +
+ + @if (capabilities().subtitleDelay) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' | translate }} +
+
+ + {{ + subtitleDelayLabel(state().subtitleDelaySeconds) + }} + + +
+
+ } + + @if (capabilities().subtitleStyle) { +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' | translate }} +
+
+ @for ( + preset of subtitleSizePresets; + track preset.value + ) { + + } +
+
+ {{ 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' | translate }} +
+
+ @for ( + preset of subtitleColorPresets; + track preset.label + ) { + + } +
+
+ } +
+ } + + @if (groups().quality) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.QUALITY' | translate }} +
+
+ + @for (level of state().qualityLevels; track level.id) { + + } +
+
+ } + + @if (groups().speed) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED' | translate }} +
+
+ @for (preset of state().speedPresets; track preset.value) { + + } +
+
+ } + + @if (groups().aspect) { +
+
+ + {{ 'EMBEDDED_MPV.PLAYER.ASPECT_RATIO' | translate }} +
+
+ @for (preset of state().aspectPresets; track preset.value) { + + } +
+
+ } +
diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss new file mode 100644 index 000000000..071d856d8 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.scss @@ -0,0 +1,448 @@ +// The settings panel / bottom sheet. Reads the `--pc-*` palette that the +// controls host declares and places itself inside that host (the +// `player-controls` container): beside the video as a panel, or rising from +// the bottom edge as a sheet. +:host { + position: absolute; + top: 16px; + right: 16px; + bottom: 16px; + z-index: 4; + width: 330px; + max-width: calc(100cqw - 32px); + animation: player-settings-panel-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); + display: flex; + flex-direction: column; + gap: 18px; + box-sizing: border-box; + padding: 18px 16px; + color: var(--pc-text, #e7ecf3); + background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72)); + border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09)); + border-radius: 20px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(18px) saturate(1.3); + overflow: hidden; + pointer-events: auto; +} + +:host(:focus) { + outline: none; +} + +:host(:focus-visible) { + box-shadow: + 0 0 0 2px var(--pc-text, #e7ecf3), + 0 12px 32px rgba(0, 0, 0, 0.45); +} + +:host(.player-settings--sheet) { + top: auto; + right: 8px; + bottom: 0; + left: 8px; + width: auto; + max-width: none; + max-height: min(70%, calc(100cqh - 24px)); + animation-name: player-settings-sheet-in; + gap: 9px; + padding: 10px 14px 12px; + border-bottom: 0; + border-radius: 16px 16px 0 0; +} + +.player-settings__grip { + flex: 0 0 auto; + align-self: center; + width: 36px; + height: 4px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.25); +} + +.player-settings__header { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.player-settings__title { + overflow: hidden; + font-size: 16px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +:host(.player-settings--sheet) .player-settings__title { + font-size: 13px; +} + +:host :is(button[mat-icon-button]) { + width: 32px; + height: 32px; + padding: 4px; + color: var(--pc-text, #e7ecf3); + --mat-icon-button-icon-color: var(--pc-text, #e7ecf3); + --mat-icon-button-state-layer-size: 32px; + --mat-icon-button-container-shape: 10px; + --mat-icon-button-icon-size: 20px; + --mat-icon-button-hover-state-layer-opacity: 0; + border-radius: 10px; +} + +:host :is(button[mat-icon-button]) mat-icon { + width: 20px; + height: 20px; + font-size: 20px; + color: var(--pc-text, #e7ecf3); +} + +:host :is(button[mat-icon-button]:hover:not([disabled])) { + background: var(--pc-hover, rgba(255, 255, 255, 0.1)); +} + +:host :is(button[mat-icon-button][disabled]), +:host :is(button[mat-icon-button][disabled]) mat-icon { + color: var(--pc-text-tertiary, #6b7384); + --mat-icon-button-icon-color: var(--pc-text-tertiary, #6b7384); +} + +:host(.player-settings--sheet) .player-settings__close { + width: 26px; + height: 26px; + padding: 3px; + --mat-icon-button-state-layer-size: 26px; +} + +// Single scroll owner: on a short player the whole content scrolls, never +// one group inside another. +.player-settings__body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 18px; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; +} + +:host(.player-settings--sheet) .player-settings__body { + gap: 9px; +} + +.player-settings__group { + display: flex; + flex: 0 0 auto; + flex-direction: column; + gap: 6px; + border-radius: 12px; + transition: box-shadow 220ms ease-out; +} + +// The group a chip opened the panel for: a brief ring, no layout change. +.player-settings__group--focused { + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.12); +} + +.player-settings__heading { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px 4px; + color: var(--pc-text-tertiary, #6b7384); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.player-settings__dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.player-settings__dot--cyan { + background: var(--pc-accent-cyan, #5cd6ff); +} + +.player-settings__dot--violet { + background: var(--pc-accent-violet, #b599ff); +} + +.player-settings__dot--neutral { + background: rgba(255, 255, 255, 0.25); +} + +.player-settings__options { + display: flex; + flex-direction: column; + gap: 2px; +} + +.player-settings__option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 36px; + padding: 0 12px; + color: var(--pc-text, #e7ecf3); + background: transparent; + border: 0; + border-radius: 10px; + cursor: pointer; + font: inherit; + font-size: 14px; + text-align: left; +} + +.player-settings__option span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-settings__option mat-icon { + flex: 0 0 auto; + width: 18px; + height: 18px; + font-size: 18px; +} + +// "Off" / "Auto" rows read as the neutral choice until selected. +.player-settings__option--default { + color: var(--pc-text-secondary, #9aa3b2); +} + +.player-settings__option--action { + color: var(--pc-text-secondary, #9aa3b2); +} + +.player-settings__option:hover, +.player-settings__option:focus-visible { + background: var(--pc-hover, rgba(255, 255, 255, 0.1)); +} + +.player-settings__option--selected { + color: var(--pc-accent-cyan, #5cd6ff); + background: rgba(92, 214, 255, 0.14); + font-weight: 600; +} + +.player-settings__option:focus-visible { + outline: 2px solid var(--pc-text, #e7ecf3); + outline-offset: -2px; +} + +.player-settings__seg { + display: flex; + flex-wrap: wrap; + gap: 2px; + padding: 3px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); +} + +.player-settings__seg-item { + flex: 1 1 auto; + height: 30px; + padding: 0 8px; + color: var(--pc-text-secondary, #9aa3b2); + background: transparent; + border: 0; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 500; + white-space: nowrap; +} + +.player-settings__seg-item--default { + color: var(--pc-text, #e7ecf3); +} + +.player-settings__seg-item:hover, +.player-settings__seg-item:focus-visible { + background: var(--pc-hover, rgba(255, 255, 255, 0.1)); +} + +.player-settings__seg-item--selected { + color: var(--pc-accent-violet, #b599ff); + background: rgba(181, 153, 255, 0.2); + font-weight: 650; +} + +// The default value selected is "nothing changed": neutral, not violet. +.player-settings__seg-item--default.player-settings__seg-item--selected { + color: var(--pc-text, #e7ecf3); + background: rgba(255, 255, 255, 0.1); +} + +.player-settings__seg-item:focus-visible { + outline: 2px solid var(--pc-text, #e7ecf3); + outline-offset: -2px; +} + +.player-settings__subsection { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 6px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.player-settings__subheading { + padding: 0 4px; + color: var(--pc-text-tertiary, #6b7384); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.player-settings__delay-row { + display: flex; + align-items: center; + gap: 4px; +} + +.player-settings__delay-value { + min-width: 58px; + color: var(--pc-text, #e7ecf3); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.player-settings__swatches { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + padding: 2px 4px 4px; +} + +.player-settings__swatch { + width: 26px; + height: 26px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-radius: 50%; + cursor: pointer; + background: var(--swatch-color, transparent); +} + +// The "engine default" swatch has no color of its own: render a diagonal +// slash so it reads as "no override". +.player-settings__swatch--default { + background: + linear-gradient( + 135deg, + transparent 44%, + rgba(255, 255, 255, 0.7) 46%, + rgba(255, 255, 255, 0.7) 54%, + transparent 56% + ), + rgba(255, 255, 255, 0.08); +} + +.player-settings__swatch:hover, +.player-settings__swatch:focus-visible, +.player-settings__swatch--selected { + border-color: #ffffff; +} + +// Sheet density: two-column rows (heading | control) so five groups fit a +// phone-height player without scrolling in the common case. +:host(.player-settings--sheet) .player-settings__group { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 4px 10px; + align-items: start; +} + +// The heading column is narrow: let "Playback speed" wrap onto two lines +// instead of running under the control. +:host(.player-settings--sheet) .player-settings__heading { + align-items: flex-start; + min-width: 0; + padding: 6px 0 0; + font-size: 10px; + line-height: 1.25; + white-space: normal; +} + +:host(.player-settings--sheet) .player-settings__heading .player-settings__dot { + flex: 0 0 auto; + margin-top: 4px; +} + +:host(.player-settings--sheet) .player-settings__dot { + width: 5px; + height: 5px; +} + +:host(.player-settings--sheet) .player-settings__options { + flex-direction: row; + flex-wrap: wrap; + gap: 2px; + padding: 3px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.06); +} + +:host(.player-settings--sheet) .player-settings__option { + flex: 1 1 auto; + justify-content: center; + height: 24px; + padding: 0 8px; + border-radius: 8px; + font-size: 11.5px; +} + +:host(.player-settings--sheet) .player-settings__option mat-icon { + display: none; +} + +:host(.player-settings--sheet) .player-settings__seg-item { + height: 24px; + font-size: 11.5px; +} + +:host(.player-settings--sheet) .player-settings__subsection { + grid-column: 1 / -1; + margin-top: 2px; + padding-top: 6px; +} + +@keyframes player-settings-panel-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes player-settings-sheet-in { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + :host { + animation: none; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts new file mode 100644 index 000000000..deb45bdda --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-settings-panel.component.ts @@ -0,0 +1,162 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + afterNextRender, + computed, + effect, + inject, + input, + output, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { ControlsMenuSelection } from './controls-menu-selection'; +import type { ControlsSettings } from './controls-settings'; +import type { SettingsGroup } from './controls-settings-groups'; +import type { ControlsSubtitleSettings } from './controls-subtitle-settings'; +import type { PlayerController } from './player-controls.model'; +import { + SUBTITLE_COLOR_PRESETS, + SUBTITLE_DELAY_STEP_SECONDS, + SUBTITLE_SIZE_PRESETS, + subtitleDelayLabel, +} from './subtitle-style'; + +export type PlayerSettingsPanelMode = 'panel' | 'sheet'; + +/** + * The settings surface behind the dock's `tune` button: audio, subtitles + * (tracks, file loading, delay, size and color), quality, speed and aspect + * ratio in one place. Wide players show it as a right-hand panel beside the + * video, compact players as a bottom sheet. It only renders state and + * forwards choices to the collaborators the controls component owns. + */ +@Component({ + selector: 'app-player-settings-panel', + templateUrl: './player-settings-panel.component.html', + styleUrl: './player-settings-panel.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'player-settings', + role: 'dialog', + tabindex: '-1', + '[class.player-settings--sheet]': 'mode() === "sheet"', + '[attr.aria-label]': 'title()', + '(focusin)': 'focusInside = true', + '(focusout)': 'onFocusOut($event)', + }, +}) +export class PlayerSettingsPanelComponent implements OnDestroy { + private readonly host: HTMLElement = + inject>(ElementRef).nativeElement; + readonly controller = input.required(); + readonly settings = input.required(); + readonly selection = input.required(); + readonly subtitleSettings = input.required(); + readonly mode = input('panel'); + readonly title = input(''); + readonly closeRequested = output(); + + readonly state = computed(() => this.controller().state()); + readonly capabilities = computed(() => this.controller().capabilities()); + readonly groups = computed(() => this.settings().groups()); + readonly focusGroup = computed(() => this.settings().focusGroup()); + readonly defaultAspect = computed(() => this.settings().defaultAspect()); + + readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS; + readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS; + readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS; + readonly subtitleDelayLabel = subtitleDelayLabel; + + /** The control that opened the panel, and whether the keyboard did. */ + private readonly opener = document.activeElement; + private readonly openedByKeyboard = + this.opener instanceof HTMLElement && + this.opener !== document.body && + !!this.opener.closest('.player-controls-host') && + matchesFocusVisible(this.opener); + focusInside = false; + /** Recorded after render: on destroy the panel is already detached. */ + private controlsHost: Element | null = null; + + constructor() { + // Keyboard users land inside the dialog; a pointer open leaves focus + // alone (a focused control would capture Space from the shortcuts). + afterNextRender(() => { + this.controlsHost = this.host.closest('.player-controls-host'); + if (this.openedByKeyboard) { + this.host.focus({ preventScroll: true }); + this.focusInside = this.host.contains(document.activeElement); + } + }); + // A chip click opens the panel on its group: bring that group into + // view so a long audio list cannot push the speed row off-screen. + effect(() => { + const group = this.focusGroup(); + if (!group) { + return; + } + queueMicrotask(() => this.scrollGroupIntoView(group)); + }); + } + + onFocusOut(event: FocusEvent): void { + const next = event.relatedTarget; + this.focusInside = next instanceof Node && this.host.contains(next); + } + + /** + * Closing with focus inside (Escape, the close button by keyboard) + * returns it to the `tune` button rather than dropping it on the page. + * The chip that may have opened the panel is re-rendered on close, so + * `tune` is the stable target. Tracked by flag, not `activeElement`: + * the view's DOM is already detached when this hook runs. + */ + ngOnDestroy(): void { + if (!this.focusInside) { + return; + } + const controls = this.controlsHost; + queueMicrotask(() => + controls + ?.querySelector('.player-controls__tune') + ?.focus({ preventScroll: true }) + ); + } + + isFocused(group: SettingsGroup): boolean { + return this.focusGroup() === group; + } + + loadExternalSubtitle(): void { + if (!this.capabilities().externalSubtitles) { + return; + } + this.selection().externalSubtitle(); + } + + private scrollGroupIntoView(group: SettingsGroup): void { + const element = this.host.querySelector(`[data-group="${group}"]`); + if ( + element instanceof HTMLElement && + typeof element.scrollIntoView === 'function' + ) { + element.scrollIntoView({ block: 'nearest' }); + } + } +} + +function matchesFocusVisible(element: Element | null): boolean { + try { + return ( + element instanceof HTMLElement && element.matches(':focus-visible') + ); + } catch { + return false; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.html b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html new file mode 100644 index 000000000..b28e252c4 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.html @@ -0,0 +1,98 @@ +{{ + formatTime(timeline().value()) +}} +@if (seekable()) { +
+ + @if (canSeek()) { + + } + @if (hover().percent() !== null) { + + + } + +
+} @else { +
+} +@if (canSeek() && remaining(); as remaining) { + {{ remaining }} +} @else if (isLive()) { + + + LIVE + +} @else { + --:-- +} +@if (recordingStatus(); as status) { +
+ {{ + recording() ? 'fiber_manual_record' : 'check_circle' + }} + {{ status }} +
+} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss new file mode 100644 index 000000000..bd6275d44 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.scss @@ -0,0 +1,233 @@ +// The dock's timeline row. Reads the `--pc-*` palette the controls host +// declares; the host is the `player-controls` size container queried below. +:host { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.player-controls__time { + flex: 0 0 auto; + color: var(--pc-text); + font-family: var(--pc-font-mono); + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.player-controls__time--end { + color: var(--pc-text-secondary); +} + +.player-controls__timeline-spacer { + flex: 1 1 auto; +} + +// Pointer target of the bar. Taller than the 6px track so it is easy to hit; +// the transparent range input on top owns dragging and the keyboard. +.player-controls__timeline-bar { + position: relative; + flex: 1 1 auto; + min-width: 0; + height: 18px; + cursor: pointer; +} + +.player-controls__timeline-bar--disabled { + cursor: default; +} + +.player-controls__timeline-track { + position: absolute; + top: 50%; + right: 0; + left: 0; + height: 6px; + transform: translateY(-50%); +} + +// Segments sit at their exact time positions (`left` = start, `width` = +// share, minus the 3px gap at each segment's end), so a drawn boundary is +// where the linear seek input and the hover label change segment. A segment +// shorter than the gap simply collapses rather than shifting its neighbours. +.player-controls__timeline-segment { + position: absolute; + top: 0; + bottom: 0; + overflow: hidden; + border-radius: 3px; + background: var(--pc-track); +} + +.player-controls__timeline-fill { + height: 100%; + background: var(--pc-accent-blue); + border-radius: inherit; +} + +.player-controls__timeline-knob { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 0 0 5px rgba(79, 142, 255, 0.3); + pointer-events: none; + transition: transform 120ms ease-out; +} + +.player-controls__timeline-bar:hover .player-controls__timeline-knob, +.player-controls__timeline-bar:has(.player-controls__slider:focus-visible) + .player-controls__timeline-knob { + transform: scale(1.15); +} + +.player-controls__timeline-marker { + position: absolute; + top: 50%; + width: 2px; + height: 12px; + margin: -6px 0 0 -1px; + border-radius: 1px; + background: #ffffff; + pointer-events: none; +} + +.player-controls__timeline-label { + --hover-x: 0%; + position: absolute; + bottom: calc(100% + 2px); + // Centered on the pointer, but never past the bar's ends. + left: clamp(28px, var(--hover-x), calc(100% - 28px)); + transform: translateX(-50%); + padding: 5px 8px; + color: var(--pc-text); + background: rgba(12, 16, 23, 0.85); + border-radius: 6px; + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; + pointer-events: none; +} + +.player-controls__recording-status { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--pc-text-dim); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.player-controls__recording-status mat-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 16px; + color: #ffffff; +} + +.player-controls__recording-status span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__recording-status--active { + color: var(--mat-sys-error, #ff5252); + font-weight: 700; +} + +.player-controls__recording-status--active mat-icon { + color: var(--mat-sys-error, #ff5252); +} + +.player-controls__live-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 4px; + color: #ffffff; + background: var(--mat-sys-error, #e53935); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.player-controls__live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: player-controls-live-pulse 1.6s ease-in-out infinite; +} + +@keyframes player-controls-live-pulse { + 50% { + opacity: 0.35; + } +} + +// The range input is the interaction and accessibility layer over the drawn +// track: invisible and full-size. Its focus ring is drawn on the track +// instead, because an outline on a transparent element is invisible. +.player-controls__slider--timeline { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + appearance: none; + -webkit-appearance: none; + cursor: pointer; +} + +.player-controls__slider--timeline:disabled { + cursor: default; +} + +.player-controls__timeline-bar:has(.player-controls__slider:focus-visible) + .player-controls__timeline-track { + outline: 2px solid var(--pc-text); + outline-offset: 5px; + border-radius: 4px; +} + +// Touch scrubbing needs a taller hit strip; the 6px track is unchanged. +@media (pointer: coarse) { + .player-controls__timeline-bar { + height: 28px; + } +} + +// Keep in sync with `COMPACT_LAYOUT_MAX_WIDTH` in controls-layout.ts. +@container player-controls (max-width: 719px) { + :host { + gap: 10px; + } + + .player-controls__time { + font-size: 13px; + } + + .player-controls__timeline-track { + height: 5px; + } + + .player-controls__timeline-knob { + width: 11px; + height: 11px; + margin: -5.5px 0 0 -5.5px; + box-shadow: 0 0 0 4px rgba(79, 142, 255, 0.3); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts new file mode 100644 index 000000000..383014364 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-timeline.component.ts @@ -0,0 +1,44 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { formatTime } from './controls-format.utils'; +import type { ControlsTimeline } from './controls-timeline'; +import type { ControlsTimelineHover } from './controls-timeline-hover'; + +/** + * The dock's timeline row: current time, the drawn segment track with its + * knob and hover label, the remaining time (or LIVE / `--:--`), and the + * recording status. Presentation only — scrub state lives in + * {@link ControlsTimeline}, and the range input's `input`/`change` events + * go back to the controls component, which owns reveal and seeking. + */ +@Component({ + selector: 'app-player-timeline', + templateUrl: './player-timeline.component.html', + styleUrl: './player-timeline.component.scss', + imports: [MatIconModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'player-controls__timeline' }, +}) +export class PlayerTimelineComponent { + readonly timeline = input.required(); + readonly hover = input.required(); + /** The engine can seek at all; without it the track is not drawn. */ + readonly seekable = input(false); + /** Seeking is possible right now. */ + readonly canSeek = input(false); + readonly isLive = input(false); + /** `−7:03`, or null without a finite duration. */ + readonly remaining = input(null); + readonly recordingStatus = input(null); + readonly recording = input(false); + readonly scrubInput = output(); + readonly scrubCommit = output(); + + readonly formatTime = formatTime; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html new file mode 100644 index 000000000..b70e9151a --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.html @@ -0,0 +1,52 @@ + diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss new file mode 100644 index 000000000..1aaede1e7 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.scss @@ -0,0 +1,161 @@ +// Glass card, bottom-right above the dock, inside the controls host (the +// `player-controls` container) whose `--pc-*` palette it reads. It does not +// fade with the controls: near the end of an episode it is worth seeing. +:host { + position: absolute; + right: 32px; + bottom: 136px; + z-index: 3; + display: block; + max-width: calc(100cqw - 64px); + pointer-events: auto; + animation: player-up-next-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +:host(.player-up-next--compact) { + right: 12px; + bottom: 92px; + max-width: calc(100cqw - 24px); +} + +@keyframes player-up-next-in { + from { + opacity: 0; + transform: translateX(24px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + :host { + animation: none; + } +} + +.player-up-next__button { + display: flex; + align-items: center; + gap: 12px; + max-width: 320px; + padding: 8px 14px 8px 8px; + color: var(--pc-text, #e7ecf3); + background: var(--pc-glass-bg, rgba(12, 16, 23, 0.72)); + border: 1px solid var(--pc-glass-border, rgba(255, 255, 255, 0.09)); + border-radius: 14px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(18px) saturate(1.3); + cursor: pointer; + font: inherit; + text-align: left; + transition: + background 120ms ease-out, + transform 120ms ease-out; +} + +.player-up-next__button:hover { + background: rgba(12, 16, 23, 0.84); + transform: translateY(-1px); +} + +.player-up-next__button:focus-visible { + outline: 2px solid var(--pc-text, #e7ecf3); + outline-offset: 2px; +} + +.player-up-next__thumb { + position: relative; + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + width: 88px; + height: 50px; + overflow: hidden; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); +} + +.player-up-next__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +// No still: the episode label as a large numeral tile, so the card still +// looks designed without artwork. +.player-up-next__placeholder { + color: var(--pc-text-secondary, #9aa3b2); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.player-up-next__progress { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background: rgba(255, 255, 255, 0.18); +} + +.player-up-next__progress-bar { + display: block; + height: 100%; + background: var(--pc-accent-blue, #4f8eff); +} + +.player-up-next__meta { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.player-up-next__eyebrow { + color: var(--pc-text-secondary, #9aa3b2); + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + white-space: nowrap; +} + +.player-up-next__title { + overflow: hidden; + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-up-next__icon { + flex: 0 0 auto; + width: 22px; + height: 22px; + font-size: 22px; + color: var(--pc-text-secondary, #9aa3b2); +} + +:host(.player-up-next--compact) .player-up-next__button { + gap: 10px; + max-width: 240px; + padding: 5px 10px 5px 5px; + border-radius: 10px; +} + +:host(.player-up-next--compact) .player-up-next__thumb { + width: 56px; + height: 32px; + border-radius: 6px; +} + +:host(.player-up-next--compact) .player-up-next__eyebrow { + font-size: 9px; +} + +:host(.player-up-next--compact) .player-up-next__title { + font-size: 11.5px; +} diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts new file mode 100644 index 000000000..693a0883f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.spec.ts @@ -0,0 +1,109 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { PlayerUpNextCardComponent } from './player-up-next-card.component'; + +describe('PlayerUpNextCardComponent', () => { + let fixture: ComponentFixture; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlayerUpNextCardComponent, TranslateModule.forRoot()], + }).compileComponents(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + UP_NEXT: 'Up next', + UP_NEXT_IN: 'in {{minutes}} min', + }, + }, + }); + translate.use('en'); + fixture = TestBed.createComponent(PlayerUpNextCardComponent); + fixture.componentRef.setInput('item', { + label: 'S01E03', + title: 'The Third One', + thumbnailUrl: 'https://img.example/still.jpg', + progressPercent: 40, + }); + fixture.componentRef.setInput('minutesLeft', 7); + fixture.detectChanges(); + }); + + afterEach(() => fixture.destroy()); + + it('renders the still, progress, countdown and title, and names itself', () => { + expect(query('.player-up-next__image')?.getAttribute('src')).toBe( + 'https://img.example/still.jpg' + ); + expect(query('.player-up-next__progress-bar')?.style.width).toBe('40%'); + expect( + query('.player-up-next__eyebrow')?.textContent?.replace(/\s+/g, ' ') + ).toContain('Up next · in 7 min'); + expect(query('.player-up-next__title')?.textContent?.trim()).toBe( + 'The Third One' + ); + expect( + query('[data-test-id="player-controls-up-next"]')?.getAttribute( + 'aria-label' + ) + ).toBe('Up next: S01E03 – The Third One'); + expect(query('.player-up-next__icon')).not.toBeNull(); + }); + + it('falls back to the label tile without a still and hides the icon when compact', () => { + fixture.componentRef.setInput('item', { + label: 'S02E01', + title: '', + thumbnailUrl: null, + progressPercent: null, + }); + fixture.componentRef.setInput('compact', true); + fixture.detectChanges(); + + expect(query('.player-up-next__image')).toBeNull(); + expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe( + 'S02E01' + ); + expect(query('.player-up-next__progress')).toBeNull(); + expect(query('.player-up-next__title')?.textContent?.trim()).toBe( + 'S02E01' + ); + expect(query('.player-up-next__icon')).toBeNull(); + expect(fixture.nativeElement.classList).toContain( + 'player-up-next--compact' + ); + }); + + it('falls back to the label tile when the still fails, and retries a new URL', () => { + query('.player-up-next__image')?.dispatchEvent(new Event('error')); + fixture.detectChanges(); + expect(query('.player-up-next__image')).toBeNull(); + expect(query('.player-up-next__placeholder')?.textContent?.trim()).toBe( + 'S01E03' + ); + + fixture.componentRef.setInput('item', { + label: 'S01E04', + title: 'Four', + thumbnailUrl: 'https://img.example/other.jpg', + progressPercent: null, + }); + fixture.detectChanges(); + expect(query('.player-up-next__image')?.getAttribute('src')).toBe( + 'https://img.example/other.jpg' + ); + }); + + it('emits on click', () => { + const selected = jest.fn(); + fixture.componentInstance.selected.subscribe(selected); + + query('[data-test-id="player-controls-up-next"]')?.click(); + + expect(selected).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts new file mode 100644 index 000000000..0eb751042 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-up-next-card.component.ts @@ -0,0 +1,45 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import type { PlayerUpNextItem } from './player-controls.model'; + +/** + * The "Up next" card in the player's bottom-right corner: thumbnail, the + * minutes left, the next episode's label and title. One click plays it + * through the host's ordinary next-episode path, so fullscreen survives. + */ +@Component({ + selector: 'app-player-up-next-card', + templateUrl: './player-up-next-card.component.html', + styleUrl: './player-up-next-card.component.scss', + imports: [MatIconModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'player-up-next', + '[class.player-up-next--compact]': 'compact()', + }, +}) +export class PlayerUpNextCardComponent { + readonly item = input.required(); + readonly minutesLeft = input.required(); + readonly compact = input(false); + readonly selected = output(); + + /** The still that failed to load; a new URL gets its own attempt. */ + private readonly failedThumbnail = signal(null); + readonly thumbnail = computed(() => { + const url = this.item().thumbnailUrl; + return url && url !== this.failedThumbnail() ? url : null; + }); + + onThumbnailError(): void { + this.failedThumbnail.set(this.item().thumbnailUrl); + } +} diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts index 1aa24df4b..3fd6fbe35 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-episode-panel.spec.ts @@ -24,6 +24,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts index 40064d879..a3effea62 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts index 4d6e9124c..695d138e4 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts @@ -22,6 +22,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); @@ -115,6 +116,69 @@ describe('PortalInlinePlayerComponent up next rail', () => { }, ]; + it('hands the episode after the playing one to the player as "up next"', async () => { + await setup(true); + fixture.componentRef.setInput('playback', seriesPlayback); + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + fixture.detectChanges(); + + expect(component.playerUpNext()).toEqual({ + label: 'S01E03', + title: 'Episode 3', + thumbnailUrl: null, + progressPercent: null, + }); + + // The last episode of the list has nothing after it. + fixture.componentRef.setInput('upNextEpisodes', [ + { ...upNextItems[1], isPlaying: true }, + ]); + fixture.detectChanges(); + expect(component.playerUpNext()).toBeNull(); + + // Only episodes get a card: a movie never has a "next". + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + fixture.componentRef.setInput('playback', { + ...seriesPlayback, + contentInfo: { + ...seriesPlayback.contentInfo, + contentType: 'movie', + }, + }); + fixture.detectChanges(); + expect(component.playerUpNext()).toBeNull(); + }); + + it('plays the next season through the rail path once the season is over', async () => { + await setup(true); + fixture.componentRef.setInput('playback', seriesPlayback); + fixture.componentRef.setInput('upNextEpisodes', upNextItems); + const selected = jest.fn(); + const next = jest.fn(); + component.upNextEpisodeSelected.subscribe(selected); + component.nextEpisodeRequested.subscribe(next); + + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: false, + autoplayEnabled: false, + }); + fixture.detectChanges(); + component.onNextEpisodeRequested(); + expect(selected).toHaveBeenCalledWith(upNextItems[1]); + expect(next).not.toHaveBeenCalled(); + + fixture.componentRef.setInput('seriesNavigation', { + canPrevious: true, + canNext: true, + autoplayEnabled: false, + }); + fixture.detectChanges(); + component.onNextEpisodeRequested(); + expect(next).toHaveBeenCalledTimes(1); + expect(selected).toHaveBeenCalledTimes(1); + }); + /** Renders, then feeds a stage size as the ResizeObserver would. */ function renderWithStage(width: number, height: number): void { fixture.detectChanges(); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index 387a7ed98..02896ff8a 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -77,6 +77,7 @@ [streamUrl]="streamUrl()" [title]="title()" [mediaTitle]="playerMediaTitle()" + [upNext]="playerUpNext()" [playback]="playback()" [volume]="volume()" [playerOverride]="playerOverride()" diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts index f34f6ecb7..1dc63ad30 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts @@ -25,6 +25,7 @@ class StubWebPlayerViewComponent { readonly streamUrl = input.required(); readonly title = input(''); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly playback = input(null); readonly volume = input(1); readonly playerOverride = input(null); diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 3b810ba97..c8e4bc432 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -28,7 +28,7 @@ import type { PlaybackFallbackRequest } from '@iptvnator/playback/util'; import type { PlaybackDiagnosticCode } from '@iptvnator/playback/util'; import { SettingsStore } from '@iptvnator/services'; import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils'; -import type { PlayerMediaTitle } from '../player-controls'; +import type { PlayerMediaTitle, PlayerUpNextItem } from '../player-controls'; import { FULLSCREEN_CHANNEL_PANEL, type FullscreenChannelPanelContext, @@ -224,6 +224,28 @@ export class PortalInlinePlayerComponent { readonly upNextRailItems = computed( () => this.upNextEpisodes() ?? [] ); + /** The rail entry after the playing one, across season boundaries. */ + private readonly upNextRailNext = computed(() => { + const items = this.upNextEpisodes() ?? []; + const playing = items.findIndex((item) => item.isPlaying); + const next = playing >= 0 ? items[playing + 1] : undefined; + return next && this.playback()?.contentInfo?.contentType === 'episode' + ? next + : null; + }); + /** The episode after the playing one, for the controls' "Up next" card. */ + readonly playerUpNext = computed(() => { + const next = this.upNextRailNext(); + if (!next) { + return null; + } + return { + label: next.label, + title: next.title, + thumbnailUrl: next.thumbnailUrl, + progressPercent: next.progressPercent, + }; + }); private readonly fullscreenEpisodePanelTemplate = viewChild< TemplateRef @@ -323,6 +345,14 @@ export class PortalInlinePlayerComponent { } onNextEpisodeRequested(): void { + // Season-local navigation ends at a season's last episode; the Up + // next card can still offer the next season's first, which only + // the rail selection path knows how to play. + const next = this.upNextRailNext(); + if (this.seriesNavigation()?.canNext === false && next) { + this.upNextEpisodeSelected.emit(next); + return; + } this.nextEpisodeRequested.emit(); } diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html index 582657c9a..331a38777 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.html @@ -14,6 +14,7 @@ [playerSurface]="playerRoot" [fullscreenTarget]="fullscreenTarget()" [mediaTitle]="mediaTitle()" + [upNext]="upNext()" [showControls]="interactionEnabled()" [shortcutsEnabled]="interactionEnabled()" (previousEpisodeRequested)="previousEpisodeRequested.emit()" diff --git a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts index ca208b60e..33cc796b8 100644 --- a/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts +++ b/libs/ui/playback/src/lib/vjs-player/vjs-player.component.ts @@ -24,6 +24,7 @@ import { type LegacyPlayerShortcuts, PlayerControlsComponent, type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, WebVideoControlsAdapter, } from '../player-controls'; @@ -50,6 +51,7 @@ import { getVideoJsTechVideo, } from './vjs-player.types'; import { VjsVideoElementSession } from './vjs-video-element-session'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; const debugVjsPlayer = createDevLogger('VjsPlayer'); @@ -77,13 +79,11 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { readonly interactionEnabled = input(true); readonly showCaptions = input(false); readonly mediaTitle = input(null); + readonly upNext = input(null); /** See `PlayerControlsComponent.fullscreenTarget`; null keeps the shell. */ readonly fullscreenTarget = input(null); - readonly timeUpdate = output<{ - currentTime: number; - duration: number; - }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackEnded = output(); readonly playbackStarted = output(); @@ -280,6 +280,7 @@ export class VjsPlayerComponent implements OnInit, OnChanges, OnDestroy { this.timeUpdate.emit({ currentTime: this.player.currentTime() ?? 0, duration: this.player.duration() ?? 0, + playing: !this.player.paused() && !this.player.seeking(), }); }; diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts index ab005aa84..1593036e2 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.spec.ts @@ -83,6 +83,38 @@ describe('ExternalPlaybackRecoveryCoordinator', () => { coordinator.destroy(); }); + it.each([ + ['an opened launch', session({ status: 'opened' }), 1], + ['a launch that failed', session({ status: 'error' }), 0], + ['a launch of another player', session({ player: 'vlc' }), 0], + ])( + 'reports %s to the view', + async (_label, launched: ExternalPlayerSession, calls: number) => { + const activeSession = signal(null); + const onLaunched = jest.fn(); + const coordinator = new ExternalPlaybackRecoveryCoordinator( + { + activeSession, + visibleSession: activeSession, + closeSession: jest.fn(), + dismissActiveSession: jest.fn(), + }, + onLaunched + ); + coordinator.syncSession('content-a'); + + coordinator.request('mpv', jest.fn(), (trackLaunch) => { + trackLaunch(Promise.resolve(launched)); + return true; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(onLaunched).toHaveBeenCalledTimes(calls); + coordinator.destroy(); + } + ); + it('closes a closable error before retrying another external target', async () => { const previous = session({ id: 'uncertain-process', diff --git a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts index ad9ab6f14..53f1a7eee 100644 --- a/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts +++ b/libs/ui/playback/src/lib/web-player-view/external-playback-recovery-coordinator.ts @@ -21,8 +21,16 @@ export class ExternalPlaybackRecoveryCoordinator { readonly states = this.recovery.states; readonly pending = this.recovery.pending; + /** + * @param onLaunched runs once a launch this view requested has opened in + * MPV/VLC — still owned by the current intent — e.g. to record it as + * recently viewed under the view's own playback session. + */ constructor( - private readonly externalPlayback: PortalExternalPlayback | null + private readonly externalPlayback: PortalExternalPlayback | null, + private readonly onLaunched: ( + session: ExternalPlayerSession + ) => void = () => undefined ) {} observe(session: ExternalPlayerSession | null): void { @@ -93,7 +101,13 @@ export class ExternalPlaybackRecoveryCoordinator { void launch.then( (session) => { if (session) { - this.recovery.confirm(intent, session); + if ( + this.recovery.confirm(intent, session) && + (session.status === 'opened' || + session.status === 'playing') + ) { + this.onLaunched(session); + } } else { this.recovery.fail(intent); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts new file mode 100644 index 000000000..2639def1a --- /dev/null +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.history.spec.ts @@ -0,0 +1,199 @@ +import { + ComponentFixture, + DeferBlockBehavior, + TestBed, +} from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, signal } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { StorageMap } from '@ngx-pwa/local-storage'; +import { TranslateModule } from '@ngx-translate/core'; +import { + PlaybackDiagnosticCode, + PlaybackDiagnosticSource, +} from '@iptvnator/playback/util'; +import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { + STORE_KEY, + VideoPlayer, + type ExternalPlayerSession, +} from '@iptvnator/shared/interfaces'; +import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; +import { of } from 'rxjs'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; +import { ElectronStreamHeadersService } from './electron-stream-headers.service'; +import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; + +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', () => ({})); + +describe('WebPlayerViewComponent playback history', () => { + let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; + let fixture: ComponentFixture; + let gate: PlaybackHistoryGate; + + beforeAll(async () => { + ({ WebPlayerViewComponent } = + await import('./web-player-view.component')); + }); + + beforeEach(async () => { + const externalSession = signal(null); + await TestBed.configureTestingModule({ + deferBlockBehavior: DeferBlockBehavior.Playthrough, + imports: [WebPlayerViewComponent, TranslateModule.forRoot()], + providers: [ + { + provide: StorageMap, + useValue: { + get: (key: string) => + of( + key === STORE_KEY.Settings + ? { player: VideoPlayer.VideoJs } + : undefined + ), + }, + }, + { + provide: RuntimeCapabilitiesService, + useValue: { supportsManagedExternalPlayers: true }, + }, + { + provide: ElectronStreamHeadersService, + useValue: { apply: () => null, clear: jest.fn() }, + }, + { + provide: SettingsStore, + useValue: { + showCaptions: () => false, + webPlayerSharedControls: () => false, + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { + activeSession: externalSession, + visibleSession: externalSession, + dismissActiveSession: jest.fn(), + closeSession: jest.fn(), + }, + }, + ], + }) + .overrideComponent(WebPlayerViewComponent, { + set: { + imports: [ + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, + ], + // The diagnostic panel itself is not under test here. + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }, + }) + .compileComponents(); + + gate = TestBed.inject(PlaybackHistoryGate); + fixture = TestBed.createComponent(WebPlayerViewComponent); + fixture.componentRef.setInput('streamUrl', 'https://example.com/a'); + fixture.componentRef.setInput('title', 'Channel A'); + fixture.componentRef.setInput('playbackSessionKey', 'live:p1:a'); + await render(); + }); + + afterEach(() => fixture.destroy()); + + it('records a channel once its stream has played for two seconds', () => { + const commit = jest.fn(); + gate.defer({ sessionKey: 'live:p1:a' }, commit); + + playTo(0, 1); + expect(commit).not.toHaveBeenCalled(); + + playTo(2); + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('confirms by stream URL for writers that only know the link', () => { + const commit = jest.fn(); + gate.defer({ streamUrls: ['https://example.com/a'] }, commit); + + playTo(0, 1, 2); + + expect(commit).toHaveBeenCalledTimes(1); + }); + + it('does not record a channel whose stream fails before playing', () => { + const commit = jest.fn(); + gate.defer({ sessionKey: 'live:p1:a' }, commit); + + playTo(0); + vjs().playbackIssue.emit({ + code: PlaybackDiagnosticCode.MediaDecodeError, + source: PlaybackDiagnosticSource.Vhs, + sourceUrl: 'https://example.com/a', + container: 'm3u8', + mimeType: 'application/x-mpegURL', + player: 'videojs', + audioCodecs: [], + videoCodecs: [], + }); + fixture.detectChanges(); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('does not count seeks of paused media as watched time', () => { + const commit = jest.fn(); + gate.defer({ sessionKey: 'live:p1:a' }, commit); + + [0, 1, 2, 3].forEach((currentTime) => + vjs().timeUpdate.emit({ currentTime, duration: 0, playing: false }) + ); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('does not credit the next channel with the previous one', async () => { + const next = jest.fn(); + playTo(0, 1.5); + + fixture.componentRef.setInput('streamUrl', 'https://example.com/b'); + fixture.componentRef.setInput('playbackSessionKey', 'live:p1:b'); + gate.defer({ sessionKey: 'live:p1:b' }, next); + await render(); + playTo(0, 0.5); + + expect(next).not.toHaveBeenCalled(); + + playTo(1, 2); + expect(next).toHaveBeenCalledTimes(1); + }); + + async function render(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function playTo(...positions: number[]): void { + positions.forEach((currentTime) => + vjs().timeUpdate.emit({ currentTime, duration: 0 }) + ); + } + + function vjs(): StubVjsPlayerComponent { + return fixture.debugElement.query(By.directive(StubVjsPlayerComponent)) + .componentInstance as StubVjsPlayerComponent; + } +}); diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html index 58c30608f..5b204c4a9 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.html @@ -7,6 +7,7 @@ [options]="options" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [interactionEnabled]="playbackInteractionEnabled()" @@ -35,6 +36,7 @@ [channel]="$any(channel)" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [isLive]="resolvedIsLive()" @@ -64,6 +66,7 @@ [channel]="$any(channel)" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [volume]="volume()" [showCaptions]="showCaptions()" [isLive]="resolvedIsLive()" @@ -93,6 +96,7 @@ [playback]="resolvedPlayback()" [fullscreenTarget]="fullscreenSurface" [mediaTitle]="resolvedMediaTitle()" + [upNext]="upNext()" [recordingFolder]="recordingFolder()" [recordingMetadata]="recordingMetadata()" [seriesNavigation]="seriesNavigation()" diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts index 81d391b7d..7cbf5a49d 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts @@ -1,4 +1,3 @@ -import { Component, input, output, signal } from '@angular/core'; import { ComponentFixture, DeferBlockBehavior, @@ -13,12 +12,7 @@ import { VodSourceRowComponent } from '@iptvnator/ui/components'; import { StorageMap } from '@ngx-pwa/local-storage'; import { TranslateModule } from '@ngx-translate/core'; import { of } from 'rxjs'; -import { - VideoPlayer, - type EmbeddedMpvSupport, - type RecordingStartMetadata, - type RecordingStoppedEvent, -} from '@iptvnator/shared/interfaces'; +import { VideoPlayer } from '@iptvnator/shared/interfaces'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { ErrorDetails, ErrorTypes } from 'hls.js'; import type { WebPlayerViewComponent as WebPlayerViewComponentInstance } from './web-player-view.component'; @@ -35,7 +29,13 @@ import { getDiagnosticMeta, getDiagnosticTitleKey, } from '../playback-diagnostic-panel/playback-diagnostic-view.util'; -import { StubFullscreenChannelPanelComponent } from './web-player-view.spec-stubs'; +import { + StubArtPlayerComponent, + StubEmbeddedMpvPlayerComponent, + StubFullscreenChannelPanelComponent, + StubHtmlVideoPlayerComponent, + StubVjsPlayerComponent, +} from './web-player-view.spec-stubs'; jest.unstable_mockModule('video.js', () => ({ default: jest.fn(), @@ -45,88 +45,6 @@ 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-vjs-player', - template: '
', -}) -class StubVjsPlayerComponent { - readonly options = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-html-video-player', - template: '
', -}) -class StubHtmlVideoPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-art-player', - template: '
', -}) -class StubArtPlayerComponent { - readonly channel = input(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly volume = input(1); - readonly showCaptions = input(false); - readonly isLive = input(true); - readonly interactionEnabled = input(true); - readonly startTime = input(0); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackIssue = output(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); -} - -@Component({ - selector: 'app-embedded-mpv-player', - template: '
', -}) -class StubEmbeddedMpvPlayerComponent { - /** Read by the view's channel-panel gate (`EmbeddedMpvEngineReporter`). */ - readonly support = signal(null); - readonly playback = input.required(); - readonly fullscreenTarget = input(null); - readonly mediaTitle = input(null); - readonly recordingFolder = input(''); - readonly recordingMetadata = input(null); - readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); - readonly playbackEnded = output(); - readonly previousEpisodeRequested = output(); - readonly nextEpisodeRequested = output(); - readonly recordingStopped = output(); -} - describe('WebPlayerViewComponent', () => { let WebPlayerViewComponent: typeof import('./web-player-view.component').WebPlayerViewComponent; let fixture: ComponentFixture; diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 75be963e1..b5947f51e 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -23,6 +23,7 @@ import { } from '@iptvnator/playback/util'; import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; import { VideoPlayer, type Channel, @@ -36,8 +37,11 @@ import { EmbeddedMpvPlayerComponent } from '../embedded-mpv-player/embedded-mpv- import { FullscreenChannelPanelComponent } from '../fullscreen-channel-panel/fullscreen-channel-panel.component'; import { HtmlVideoPlayerComponent } from '../html-video-player/html-video-player.component'; import { PlaybackDiagnosticPanelComponent } from '../playback-diagnostic-panel/playback-diagnostic-panel.component'; +import { PlaybackHistoryConfirmation } from '../playback-history/playback-history-confirmation'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; import { type PlayerMediaTitle, + PlayerUpNextItem, WEB_PLAYER_SHARED_CONTROLS, } from '../player-controls'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; @@ -116,8 +120,16 @@ export class WebPlayerViewComponent implements OnDestroy { optional: true, }); private readonly recoverySession = new PlaybackRecoverySession(); + private readonly historyGate = inject(PlaybackHistoryGate); private readonly externalRecovery = new ExternalPlaybackRecoveryCoordinator( - this.externalPlayback + this.externalPlayback, + // "Open in MPV/VLC" after an inline failure is still this session's + // view; the app-wide session confirmation carries only the URL. + (session) => + this.historyGate.confirm({ + sessionKey: this.playbackSessionKey(), + streamUrls: [session.streamUrl], + }) ); private readonly applicationHandoff = new WebPlayerApplicationHandoffCoordinator( @@ -134,6 +146,8 @@ export class WebPlayerViewComponent implements OnDestroy { readonly playerOverride = input(null); readonly seriesNavigation = input(null); readonly mediaTitle = input(null); + /** Next episode for the shared controls' "Up next" card; series hosts only. */ + readonly upNext = input(null); readonly alternativeSources = input([]); /** Channel/EPG snapshot for the embedded-MPV recording tracker. */ readonly recordingMetadata = input(null); @@ -213,6 +227,15 @@ export class WebPlayerViewComponent implements OnDestroy { readonly resolvedIsLive = this.applicationState.isLive; readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; readonly playbackApplicationToken = this.applicationState.token; + /** Commits deferred "recently viewed" writes once this stream plays. */ + private readonly historyConfirmation = new PlaybackHistoryConfirmation({ + gate: this.historyGate, + target: () => ({ + sessionKey: this.playbackSessionKey(), + streamUrls: [this.streamUrl(), this.playback()?.streamUrl], + }), + sourceRevision: () => this.playbackSourceRevisionToken(), + }); readonly playbackExternallyTransferable = computed(() => isPlaybackExternallyTransferable(this.resolvedPlayback()) ); @@ -355,7 +378,7 @@ export class WebPlayerViewComponent implements OnDestroy { } handleTimeUpdate( - event: { currentTime: number; duration: number }, + event: PlayerTimeUpdate, ownership: PlaybackApplicationOwnership ): void { if ( @@ -377,6 +400,7 @@ export class WebPlayerViewComponent implements OnDestroy { } this.recoverySession.recordTimeUpdate(event, ownership.isLive); + this.historyConfirmation.record(event.currentTime, event.playing); this.timeUpdate.emit(event); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts index fb57beae0..400585d11 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts @@ -5,6 +5,7 @@ import type { RecordingStartMetadata, RecordingStoppedEvent, } from '@iptvnator/shared/interfaces'; +import type { PlayerTimeUpdate } from '../playback-history/player-time-update'; /** * Player stand-ins for WebPlayerViewComponent specs. They mirror the real @@ -20,12 +21,13 @@ export class StubVjsPlayerComponent { readonly options = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -41,13 +43,14 @@ export class StubHtmlVideoPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -63,13 +66,14 @@ export class StubArtPlayerComponent { readonly channel = input(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly volume = input(1); readonly showCaptions = input(false); readonly isLive = input(true); readonly interactionEnabled = input(true); readonly startTime = input(0); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackIssue = output(); readonly playbackStarted = output(); readonly playbackEnded = output(); @@ -95,10 +99,11 @@ export class StubEmbeddedMpvPlayerComponent { readonly playback = input.required(); readonly fullscreenTarget = input(null); readonly mediaTitle = input(null); + readonly upNext = input(null); readonly recordingFolder = input(''); readonly recordingMetadata = input(null); readonly seriesNavigation = input(null); - readonly timeUpdate = output<{ currentTime: number; duration: number }>(); + readonly timeUpdate = output(); readonly playbackEnded = output(); readonly previousEpisodeRequested = output(); readonly nextEpisodeRequested = output(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts new file mode 100644 index 000000000..2e2b3814a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.spec.ts @@ -0,0 +1,128 @@ +import { + EnvironmentInjector, + createEnvironmentInjector, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + DEFAULT_DASHBOARD_RAILS_SETTINGS, + type DashboardRailsSettings, +} from '@iptvnator/shared/interfaces'; +import { + createDashboardRailSkeletons, + type DashboardRailSkeletonHost, +} from './dashboard-rail-skeletons'; +import { DASHBOARD_RAIL_SKELETON_GRACE_MS as GRACE } from './dashboard-skeleton-grace'; + +function fakeHost() { + const s = { + rails: signal({ + ...DEFAULT_DASHBOARD_RAILS_SETTINGS, + continueWatching: true, + liveFavorites: true, + recentlyWatchedLive: true, + favoriteMoviesAndSeries: true, + recentSources: true, + xtreamRecentlyAdded: true, + tmdbRecommendations: true, + tmdbTrending: true, + }), + recentLive: signal([]), + sources: signal([]), + liveFavoritesLoading: signal(false), + playlistsLoaded: signal(true), + xtreamPlaylists: signal(0), + xtreamLoading: signal(false), + trendingLoading: signal(false), + }; + const empty = () => []; + const host: DashboardRailSkeletonHost = { + dashboardRails: () => s.rails(), + continueWatchingCards: empty, + liveFavoriteCards: empty, + recentLiveCards: () => s.recentLive(), + favoriteMoviesAndSeriesCards: empty, + sourceCards: () => s.sources(), + xtreamRecentlyAddedCards: empty, + recommendationCards: empty, + trendingCards: empty, + showLiveFavoritesSkeleton: () => s.liveFavoritesLoading(), + showRecentContentSkeleton: () => false, + xtreamPlaylistCount: () => s.xtreamPlaylists(), + data: { + playlistsLoaded: () => s.playlistsLoaded(), + xtreamRecentlyAddedLoading: () => s.xtreamLoading(), + }, + recommendationsService: { loading: () => false }, + trendingService: { loading: () => s.trendingLoading() }, + }; + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const gates = injector.runInContext(() => + createDashboardRailSkeletons(host) + ); + const settle = (ms = 0) => { + TestBed.tick(); + jest.advanceTimersByTime(ms); + TestBed.tick(); + }; + return { s, gates, settle }; +} + +describe('createDashboardRailSkeletons', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('suppresses the live favorites skeleton once recently watched live, below it, has cards', () => { + const { s, gates, settle } = fakeHost(); + s.recentLive.set([{}]); + s.liveFavoritesLoading.set(true); + settle(GRACE); + + expect(gates.liveFavorites()).toBe(false); + }); + + it('shows the live favorites skeleton on a slow load when nothing below has cards', () => { + const { s, gates, settle } = fakeHost(); + s.liveFavoritesLoading.set(true); + settle(GRACE); + + expect(gates.liveFavorites()).toBe(true); + }); + + it('treats sources as loading until playlists have loaded', () => { + const { s, gates, settle } = fakeHost(); + s.playlistsLoaded.set(false); + settle(GRACE); + expect(gates.sources()).toBe(true); + + s.playlistsLoaded.set(true); + settle(); + expect(gates.sources()).toBe(false); + }); + + it('never shows the Xtream skeleton without an Xtream playlist', () => { + const { s, gates, settle } = fakeHost(); + s.xtreamLoading.set(true); + settle(GRACE); + expect(gates.xtreamRecentlyAdded()).toBe(false); + + s.xtreamLoading.set(false); + settle(); + s.xtreamPlaylists.set(1); + s.xtreamLoading.set(true); + settle(GRACE); + expect(gates.xtreamRecentlyAdded()).toBe(true); + }); + + it('ignores loading rails whose rail setting is disabled', () => { + const { s, gates, settle } = fakeHost(); + s.rails.update((rails) => ({ ...rails, tmdbTrending: false })); + s.trendingLoading.set(true); + settle(GRACE); + + expect(gates.tmdbTrending()).toBe(false); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts new file mode 100644 index 000000000..f7d1513a0 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail-skeletons.ts @@ -0,0 +1,141 @@ +import type { Signal } from '@angular/core'; +import type { DashboardRailsSettings } from '@iptvnator/shared/interfaces'; +import { createRailSkeletonGates } from './dashboard-skeleton-grace'; + +type Count = () => { readonly length: number }; + +/** + * What the dashboard exposes for skeleton decisions; the rails component + * satisfies it structurally, so the rail order and conditions live here + * rather than in that (already oversized) component. + */ +export interface DashboardRailSkeletonHost { + readonly dashboardRails: () => DashboardRailsSettings; + readonly continueWatchingCards: Count; + readonly liveFavoriteCards: Count; + readonly recentLiveCards: Count; + readonly favoriteMoviesAndSeriesCards: Count; + readonly sourceCards: Count; + readonly xtreamRecentlyAddedCards: Count; + readonly recommendationCards: Count; + readonly trendingCards: Count; + readonly showLiveFavoritesSkeleton: () => boolean; + readonly showRecentContentSkeleton: () => boolean; + readonly xtreamPlaylistCount: () => number; + readonly data: { + readonly playlistsLoaded: () => boolean; + readonly xtreamRecentlyAddedLoading: () => boolean; + }; + readonly recommendationsService: { readonly loading: () => boolean }; + readonly trendingService: { readonly loading: () => boolean }; +} + +export type DashboardRailSkeletonKey = + | 'continueWatching' + | 'liveFavorites' + | 'recentLive' + | 'favoriteVod' + | 'recentContent' + | 'sources' + | 'xtreamRecentlyAdded' + | 'tmdbRecommendations' + | 'tmdbTrending'; + +/** + * Skeleton gates for the dashboard rails, listed in template order (the + * no-skeleton-above-visible-cards rule depends on it). Rails without their + * own skeleton are listed with `loading: false` so they still count as + * "rendered below" for the rails above them. Must be created in an + * injection context. + */ +export function createDashboardRailSkeletons( + host: DashboardRailSkeletonHost +): Record> { + const rails = () => host.dashboardRails(); + const none = () => false; + + return createRailSkeletonGates([ + [ + 'continueWatching', + { + loading: none, + rendered: () => + rails().continueWatching && + host.continueWatchingCards().length > 0, + }, + ], + [ + 'liveFavorites', + { + loading: () => host.showLiveFavoritesSkeleton(), + rendered: () => + rails().liveFavorites && + !host.showLiveFavoritesSkeleton() && + host.liveFavoriteCards().length > 0, + }, + ], + [ + 'recentLive', + { + loading: none, + rendered: () => + rails().recentlyWatchedLive && + host.recentLiveCards().length > 0, + }, + ], + [ + 'favoriteVod', + { + loading: none, + rendered: () => + rails().favoriteMoviesAndSeries && + host.favoriteMoviesAndSeriesCards().length > 0, + }, + ], + [ + 'recentContent', + { loading: () => host.showRecentContentSkeleton(), rendered: none }, + ], + [ + 'sources', + { + loading: () => + rails().recentSources && !host.data.playlistsLoaded(), + rendered: () => + rails().recentSources && host.sourceCards().length > 0, + }, + ], + [ + 'xtreamRecentlyAdded', + { + loading: () => + rails().xtreamRecentlyAdded && + host.xtreamPlaylistCount() > 0 && + host.data.xtreamRecentlyAddedLoading(), + rendered: () => + rails().xtreamRecentlyAdded && + host.xtreamRecentlyAddedCards().length > 0, + }, + ], + [ + 'tmdbRecommendations', + { + loading: () => + rails().tmdbRecommendations && + host.recommendationsService.loading(), + rendered: () => + rails().tmdbRecommendations && + host.recommendationCards().length > 0, + }, + ], + [ + 'tmdbTrending', + { + loading: () => + rails().tmdbTrending && host.trendingService.loading(), + rendered: () => + rails().tmdbTrending && host.trendingCards().length > 0, + }, + ], + ]); +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts new file mode 100644 index 000000000..69c9bcc86 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.spec.ts @@ -0,0 +1,156 @@ +import { + EnvironmentInjector, + createEnvironmentInjector, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + DASHBOARD_RAIL_SKELETON_GRACE_MS as GRACE, + createRailSkeletonGates, +} from './dashboard-skeleton-grace'; + +function rail(loading = false, rendered = false) { + const state = { loading: signal(loading), rendered: signal(rendered) }; + return { + state, + entry: { + loading: () => state.loading(), + rendered: () => state.rendered(), + }, + }; +} + +describe('createRailSkeletonGates', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + /** Top rail `upper`, then `lower` below it, like two dashboard rails. */ + function setup(graceMs?: number) { + const upper = rail(); + const lower = rail(); + const injector = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector) + ); + const gates = injector.runInContext(() => + createRailSkeletonGates( + [ + ['upper', upper.entry], + ['lower', lower.entry], + ] as const, + graceMs + ) + ); + const settle = (ms = 0) => { + TestBed.tick(); + jest.advanceTimersByTime(ms); + TestBed.tick(); + }; + return { upper, lower, gates, injector, settle }; + } + + it('never shows a skeleton for a rail that resolves within the grace period', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE - 1); + upper.state.loading.set(false); + settle(GRACE); + + expect(gates.upper()).toBe(false); + }); + + it('shows the skeleton for a rail still loading after the grace period, while nothing below has cards', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + + expect(gates.upper()).toBe(true); + + upper.state.loading.set(false); + settle(); + expect(gates.upper()).toBe(false); + }); + + it('measures the grace period from each rail’s own loading start', () => { + const { lower, gates, settle } = setup(); + // The page has been up for a while before this rail starts loading. + settle(GRACE * 3); + lower.state.loading.set(true); + settle(GRACE - 1); + + expect(gates.lower()).toBe(false); + + lower.state.loading.set(false); + settle(GRACE); + expect(gates.lower()).toBe(false); + }); + + it('does not insert a skeleton above a rail that already shows cards', () => { + const { upper, lower, gates, settle } = setup(); + lower.state.rendered.set(true); + upper.state.loading.set(true); + settle(GRACE * 2); + + expect(gates.upper()).toBe(false); + }); + + it('keeps a shown skeleton until its own rail finishes, even when a rail below renders', () => { + const { upper, lower, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + expect(gates.upper()).toBe(true); + + lower.state.rendered.set(true); + settle(); + expect(gates.upper()).toBe(true); + + upper.state.loading.set(false); + settle(); + expect(gates.upper()).toBe(false); + }); + + it('starts a fresh grace period when a rail loads again', () => { + const { upper, gates, settle } = setup(); + upper.state.loading.set(true); + settle(GRACE); + upper.state.loading.set(false); + settle(); + upper.state.loading.set(true); + settle(GRACE - 1); + + expect(gates.upper()).toBe(false); + settle(1); + expect(gates.upper()).toBe(true); + }); + + it('clears pending timers when the dashboard is destroyed', () => { + const { upper, gates, injector, settle } = setup(); + upper.state.loading.set(true); + settle(); + expect(jest.getTimerCount()).toBe(1); + + injector.destroy(); + + expect(jest.getTimerCount()).toBe(0); + jest.advanceTimersByTime(GRACE * 2); + expect(gates.upper()).toBe(false); + }); + + it('shows immediately with a zero grace period, still respecting rails below', () => { + const { upper, lower, gates, settle } = setup(0); + upper.state.loading.set(true); + settle(); + expect(gates.upper()).toBe(true); + + const second = setup(0); + second.lower.state.rendered.set(true); + second.upper.state.loading.set(true); + second.settle(); + expect(second.gates.upper()).toBe(false); + expect(lower.state.rendered()).toBe(false); + }); + + it('needs an injection context', () => { + expect(() => createRailSkeletonGates([])).toThrow(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts new file mode 100644 index 000000000..264cf652d --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts @@ -0,0 +1,100 @@ +import { + DestroyRef, + effect, + inject, + signal, + untracked, + type Signal, +} from '@angular/core'; + +/** + * How long a dashboard rail may keep loading, counted from the moment that + * rail started loading, before its skeleton may appear. + * + * The rails render as soon as their own data arrives, and on a normal profile + * several resolve empty within a few tens of milliseconds. An immediate + * skeleton per loading rail therefore flashed and collapsed, pulling every + * rail below it upwards: a layout shift of about 0.23 on each launch with + * sources (the "good" CLS threshold is 0.1). The hero keeps its immediate + * skeleton: it reserves the top of the page. + */ +export const DASHBOARD_RAIL_SKELETON_GRACE_MS = 300; + +/** One rail, in template order. */ +export interface DashboardRailSkeletonEntry { + /** True while this rail's data is loading and a skeleton would help. */ + readonly loading: () => boolean; + /** True once this rail renders real cards. */ + readonly rendered: () => boolean; +} + +/** + * Per-rail skeleton visibility. A rail's skeleton appears only when + * + * 1. the rail has been loading for the grace period, measured from when + * *this* rail started loading (rails such as Xtream or TMDB begin their + * requests after the local ones), and + * 2. no rail below it already shows real cards: inserting a placeholder + * above visible content would push it down, and pull it back up if the + * rail resolves empty, whereas the real rail inserts at most once. + * + * Once shown, a skeleton stays until its rail stops loading, so skeletons do + * not disappear in a cascade when the first real rail arrives. + * + * Must be created in an injection context; timers are cleared on destroy. + */ +export function createRailSkeletonGates( + entries: readonly (readonly [Key, DashboardRailSkeletonEntry])[], + graceMs = DASHBOARD_RAIL_SKELETON_GRACE_MS +): Record> { + const gates = {} as Record>; + const timers = new Set>(); + inject(DestroyRef).onDestroy(() => { + timers.forEach(clearTimeout); + timers.clear(); + }); + + entries.forEach(([key, entry], index) => { + const below = entries.slice(index + 1).map(([, other]) => other); + const renderedBelow = () => below.some((other) => other.rendered()); + const shown = signal(false); + let timer: ReturnType | null = null; + const stopTimer = () => { + if (timer !== null) { + clearTimeout(timer); + timers.delete(timer); + timer = null; + } + }; + + effect(() => { + const loading = entry.loading(); + untracked(() => { + if (!loading) { + stopTimer(); + shown.set(false); + return; + } + if (shown() || timer !== null) { + return; + } + if (graceMs <= 0) { + shown.set(!renderedBelow()); + return; + } + timer = setTimeout(() => { + if (timer !== null) timers.delete(timer); + timer = null; + if (entry.loading() && !renderedBelow()) { + shown.set(true); + } + }, graceMs); + timers.add(timer); + }); + }); + + gates[key] = shown.asReadonly(); + }); + + return gates; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 3e4c2f682..a842cb511 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -52,7 +52,7 @@ liveEpg.setVisibleCards('favorites', $event) " /> - } @else if (showLiveFavoritesSkeleton()) { + } @else if (railSkeletons.liveFavorites()) {