Merge remote-tracking branch 'origin/master' into ci/prefetch-electron-binary
# Conflicts: # .github/workflows/ci.yml
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "الإعدادات"
|
||||
},
|
||||
|
||||
@@ -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": "الإعدادات"
|
||||
},
|
||||
|
||||
@@ -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": "Налады"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Ρυθμίσεις"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "設定"
|
||||
},
|
||||
|
||||
@@ -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": "설정"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Настройки"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "设置"
|
||||
},
|
||||
|
||||
@@ -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": "設定"
|
||||
},
|
||||
|
||||
@@ -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`.
|
||||
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
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.
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
|
||||

|
||||
|
||||
*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 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.
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
|
||||

|
||||
|
||||
*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.
|
||||
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||