Merge remote-tracking branch 'origin/master' into ci/prefetch-electron-binary

# Conflicts:
#	.github/workflows/ci.yml
This commit is contained in:
4gray committed 2026-09-29 07:08:56 +02:00
commit 48cc3134cc
205 files changed
+11774 -1981

No files matched your search

+9
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+9
View File
@@ -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.
+6
View File
@@ -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.
+54 -14
View File
@@ -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
+14 -2
View File
@@ -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,
@@ -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`,
@@ -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',
+9
View File
@@ -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",
@@ -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')
@@ -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$/
);
@@ -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(
@@ -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 |'
);
});
});
@@ -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<string, number>();
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<string, number>,
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}`;
}
@@ -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<Workload[]> {
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<Variant, Buffer> {
const latin1 = Buffer.from(create('latin1').body, 'utf8');
return new Map<Variant, Buffer>([
['latin1', latin1],
['latin1-bom', Buffer.concat([UTF8_BYTE_ORDER_MARK, latin1])],
['cyrillic', Buffer.from(create('cyrillic').body, 'utf8')],
]);
}
function requireBuffer(
buffers: Map<Variant, Buffer>,
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}`);
}
}
@@ -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<void> {
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<Variant, number[]>();
const cpu = new Map<Variant, number[]>();
const profiles = new Map<Variant, CpuProfileLike[]>();
// 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<Variant, number[]>;
readonly cpu: Map<Variant, number[]>;
readonly profiles: Map<Variant, CpuProfileLike[]>;
}
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<string, number>();
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<T>(map: Map<Variant, T[]>, 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;
});
@@ -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<SyntheticCharset, SyntheticTitleVocabulary>
> = 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;
}
@@ -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) ?? [];
@@ -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<string>(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'),
});
}
@@ -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, '<channel '), 100);
assert.equal(countOf(fixture.body, '<programme '), 10_000);
assert.ok(fixture.body.startsWith('<?xml version="1.0"'));
assert.ok(fixture.body.endsWith('</tv>\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('<title lang="ru">');
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-0001</title><desc lang="en">Synthetic description for slot 1.</desc><category lang="en">Synthetic</category></programme>'
);
assert.ok(
lines.includes(
' <programme start="20260103013000 +0000" stop="20260103020000 +0000" channel="synthetic.000100"><title lang="en">Synthetic Programme 000100-0100</title><desc lang="en">Synthetic description for slot 100.</desc><category lang="en">Synthetic</category></programme>'
)
);
});
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;
}
@@ -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 `<channel>` 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[] = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<tv generator-info-name="iptvnator-synthetic">',
];
for (let offset = 0; offset < channelCount; offset += 1) {
const channelId = syntheticChannelId(offset);
lines.push(
` <channel id="${channelId}"><display-name lang="${titles.language}">${
titles.channel
} ${SYNTHETIC_M3U_SEED}-${stableNumber(offset + 1, 6)}</display-name></channel>`
);
}
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(
` <programme start="${slotTimestamps[slot]}" stop="${
slotTimestamps[slot + 1]
}" channel="${channelId}"><title lang="${titles.language}">${
titles.programme
} ${channelNumber}-${stableNumber(
slot + 1,
4
)}</title><desc lang="en">Synthetic description for slot ${
slot + 1
}.</desc><category lang="en">Synthetic</category></programme>`
);
}
}
lines.push('</tv>');
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}`
);
}
}
@@ -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<void> {
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<void>,
seconds = 2.5
): Promise<void> {
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<Element, number>;
}
).__e2eMediaStarts ??= new WeakMap());
return [
...document.querySelectorAll<HTMLMediaElement>(
'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<void> {
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<void> {
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),
});
}
@@ -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',
@@ -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)
+66 -25
View File
@@ -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(
+15 -4
View File
@@ -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);
@@ -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<string, unknown>): void {
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
key in values ? values[key] : fallback
);
}
async function openMpv(): Promise<ChildProcess> {
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<ChildProcess> {
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();
});
});
@@ -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');
@@ -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);
+13 -2
View File
@@ -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,
+13
View File
@@ -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',
+14 -1
View File
@@ -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();
@@ -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<void> {
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<void> {
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<void> {
await page.goto('/');
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Raw m3u text/i }).click();
await dialog.getByLabel('Insert m3u(8) playlist as text').fill(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();
});
@@ -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,
+6 -3
View File
@@ -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": "الإعدادات"
},
+6 -3
View File
@@ -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": "الإعدادات"
},
+6 -3
View File
@@ -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": "Налады"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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": "Ρυθμίσεις"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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": "設定"
},
+6 -3
View File
@@ -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": "설정"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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": "Настройки"
},
+6 -3
View File
@@ -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"
},
+6 -3
View File
@@ -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": "设置"
},
+6 -3
View File
@@ -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": "設定"
},
+7
View File
@@ -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
@@ -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`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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 |
@@ -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
@@ -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 |
@@ -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<string, string[]> = {
'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);
}
);
});
@@ -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
@@ -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 `<i>` 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.
+26 -1
View File
@@ -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
@@ -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`:
+80 -5
View File
@@ -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.
+231 -32
View File
@@ -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 `<input type="range">` 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
+15 -3
View File
@@ -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
+4 -1
View File
@@ -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
+26 -2
View File
@@ -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:
+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: 'playback-data-access',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/playback/data-access',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/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'],
};
+21
View File
@@ -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"
}
}
}
+1
View File
@@ -0,0 +1 @@
export * from './lib/playback-history-gate.service';
@@ -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<ExternalPlayerSession>) => 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();
});
});
@@ -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<string>;
}
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);
}
}
@@ -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,
});
+29
View File
@@ -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"
}
]
}
@@ -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"]
}
@@ -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"
]
}
@@ -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<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let gate: PlaybackHistoryGate;
const activePlaylist = signal<Partial<PlaylistMeta> | 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<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: externalSession },
},
],
})
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [],
template:
'<ng-template #fullscreenChannelPanel></ng-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
);
});
});
@@ -106,10 +106,13 @@
<app-audio-player
[url]="activeChannel.url"
[icon]="
$safeNavigationMigration(activeChannel?.tvg?.logo)!
$safeNavigationMigration(
activeChannel?.tvg?.logo
)!
"
[channelName]="displayChannelName()"
[volume]="volume()"
[playbackSessionKey]="playbackSessionKey()"
(volumeChange)="onInlineVolumeChange($event)"
/>
} @else {
@@ -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<HTMLElement>);
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<ResolvedPortalPlayback | null>(() => {
/**
* 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<ResolvedPortalPlayback | null>(
() => 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
@@ -97,6 +97,7 @@ export class StubAudioPlayerComponent {
readonly channelName = input('');
readonly channelLogo = input('');
readonly volume = input<number | null>(null);
readonly playbackSessionKey = input<string | null>(null);
readonly volumeChange = output<number>();
}
@@ -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<void> => {
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();
@@ -58,6 +58,7 @@
activeDetail()?.playback?.title ||
''
"
[playbackSessionKey]="playbackSessionKey()"
/>
} @else {
<app-web-player-view
@@ -27,6 +27,7 @@ import {
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PlaybackHistoryGate } from '@iptvnator/playback/data-access';
import { EpgItem, EpgProgram, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
PORTAL_PLAYER,
@@ -157,6 +158,15 @@ describe('UnifiedLiveTabComponent', () => {
window.electron = originalElectron;
});
/** What the mounted player reports once the stream has really played. */
async function playStream(streamUrl: string): Promise<void> {
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({
@@ -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 = {
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more