mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/channel-history-playback-check-0b3bd9
# Conflicts: # docs/architecture/workspace-dashboard.md
This commit is contained in:
commit
9a2f1fb7e8
85 files changed
+5216
-1215
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: feature
|
||||
area: workspace
|
||||
highlight: Cinematic dashboard hero
|
||||
---
|
||||
|
||||
The dashboard hero is now a full-width cinematic banner that rotates between the title you were watching, a favorite channel on air right now, and a few favorites and new arrivals. With TMDB enabled it shows the backdrop, rating, genres and plot; hover, focus or the pause button stop the rotation.
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import { readVisibleSidebarCategories } from './sidebar-categories.e2e-support';
|
||||
|
||||
/**
|
||||
* Full backup round-trip through the real UI, DB worker and IPC stack:
|
||||
@@ -237,32 +238,18 @@ async function pickVisibleCategoryWithContent(
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
const category = (
|
||||
await readVisibleSidebarCategories(page)
|
||||
).find(
|
||||
(candidate) =>
|
||||
candidate.id &&
|
||||
candidate.name &&
|
||||
candidate.itemCount > 0
|
||||
);
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const category = categories.nth(index);
|
||||
const id =
|
||||
(
|
||||
await category.getAttribute('data-category-id')
|
||||
)?.trim() ?? '';
|
||||
const name =
|
||||
(
|
||||
await category
|
||||
.locator('.nav-item-label')
|
||||
.textContent()
|
||||
)?.trim() ?? '';
|
||||
const countText =
|
||||
(
|
||||
await category.locator('.item-count').textContent()
|
||||
)?.trim() ?? '';
|
||||
|
||||
if (id && name && (Number.parseInt(countText, 10) || 0) > 0) {
|
||||
picked = { id, name };
|
||||
return true;
|
||||
}
|
||||
if (category) {
|
||||
picked = { id: category.id, name: category.name };
|
||||
return true;
|
||||
}
|
||||
|
||||
picked = null;
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
waitForSourceRowIdle,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
readVisibleSidebarCategories,
|
||||
readVisibleSidebarCategoryNames,
|
||||
} from './sidebar-categories.e2e-support';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
test.describe('Electron Xtream Category Management', () => {
|
||||
@@ -591,31 +595,6 @@ async function expectVisibleSidebarCategoryNames(
|
||||
}
|
||||
}
|
||||
|
||||
async function readVisibleSidebarCategoryNames(page: Page): Promise<string[]> {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item'
|
||||
);
|
||||
const actualNames: string[] = [];
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const category = categories.nth(index);
|
||||
if (!(await category.isVisible())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryName =
|
||||
(await category.locator('.nav-item-label').textContent())?.trim() ??
|
||||
'';
|
||||
|
||||
if (categoryName) {
|
||||
actualNames.push(categoryName);
|
||||
}
|
||||
}
|
||||
|
||||
return actualNames;
|
||||
}
|
||||
|
||||
function stringArraysEqual(left: string[], right: string[]): boolean {
|
||||
return (
|
||||
left.length === right.length &&
|
||||
@@ -634,35 +613,12 @@ async function pickSidebarCategory(
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
const candidates = (
|
||||
await readVisibleSidebarCategories(page)
|
||||
).filter(
|
||||
(candidate) =>
|
||||
candidate.id && candidate.name && candidate.itemCount > 0
|
||||
);
|
||||
const count = await categories.count();
|
||||
const candidates: Array<{
|
||||
id: string;
|
||||
itemCount: number;
|
||||
name: string;
|
||||
}> = [];
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const category = categories.nth(index);
|
||||
const id =
|
||||
(await category.getAttribute('data-category-id'))?.trim() ??
|
||||
'';
|
||||
const name =
|
||||
(
|
||||
await category.locator('.nav-item-label').textContent()
|
||||
)?.trim() ?? '';
|
||||
const countText =
|
||||
(
|
||||
await category.locator('.item-count').textContent()
|
||||
)?.trim() ?? '';
|
||||
const itemCount = Number.parseInt(countText, 10) || 0;
|
||||
|
||||
if (id && name && itemCount > 0) {
|
||||
candidates.push({ id, itemCount, name });
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
preferredCandidate = null;
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
readonly timedOut: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import {
|
||||
assertJourneyMainCounters,
|
||||
readJourneyMainCounters,
|
||||
} from '../performance/journey-main-counters';
|
||||
import {
|
||||
createLaunchJourneyProbeOptions,
|
||||
installJourneyRendererProbe,
|
||||
@@ -124,9 +128,17 @@ export async function measureLaunchJourney(
|
||||
);
|
||||
try {
|
||||
await cp(templateDirectory, dataDirectory, { recursive: true });
|
||||
// IPTVNATOR_PERF_CAPTURE turns on the main-process counters and
|
||||
// their read handler, IPTVNATOR_PERF_COUNT_SQL the SQL statement
|
||||
// count behind main.sqlStatementsBeforeReadyToShow; only this journey
|
||||
// sets it. See journey-main-counters.ts.
|
||||
const env = buildElectronLaunchEnvironment(
|
||||
dataDirectory,
|
||||
launchOptions({ IPTVNATOR_TRACE_IPC: '1' })
|
||||
launchOptions({
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
IPTVNATOR_PERF_COUNT_SQL: '1',
|
||||
IPTVNATOR_TRACE_IPC: '1',
|
||||
})
|
||||
);
|
||||
const args = buildElectronLaunchArgs([
|
||||
'-r',
|
||||
@@ -188,6 +200,14 @@ export async function measureLaunchJourney(
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
// Read after the probe finished, so both frozen counters exist.
|
||||
const mainCounters = assertJourneyMainCounters(
|
||||
await readJourneyMainCounters(
|
||||
electronApp,
|
||||
JOURNEY_RENDERER_GATE_KEY
|
||||
),
|
||||
gate
|
||||
);
|
||||
if (ipc.installedEpochMs > renderer.installed.epochMs) {
|
||||
throw new Error('journey-main-ipc-capture-installed-late');
|
||||
}
|
||||
@@ -198,6 +218,7 @@ export async function measureLaunchJourney(
|
||||
electronVersion,
|
||||
gate,
|
||||
ipc,
|
||||
mainCounters,
|
||||
pid: electronApp.process().pid ?? -1,
|
||||
renderer,
|
||||
spawnEpochMs,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertJourneyMainCounters,
|
||||
JOURNEY_MAIN_COUNTER,
|
||||
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
} from './journey-main-counters';
|
||||
|
||||
const gate = { gatedEpochMs: 1_020, releasedEpochMs: 1_150 };
|
||||
|
||||
function snapshot(
|
||||
counters: Record<string, number> = {},
|
||||
frozenAtEpochMs: Record<string, number> = {}
|
||||
) {
|
||||
return {
|
||||
counters: {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 40,
|
||||
'main.sqlStatementsBeforeReadyToShow': 12,
|
||||
'main.startupPhases': 9,
|
||||
...counters,
|
||||
},
|
||||
frozenAtEpochMs: {
|
||||
'main.modulesRegisteredBeforeWindow': 1_010,
|
||||
'main.sqlStatementsBeforeReadyToShow': 1_300,
|
||||
...frozenAtEpochMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('mirrors the channel and counter names of the app', () => {
|
||||
assert.equal(
|
||||
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
'performance:read-counters'
|
||||
);
|
||||
assert.deepEqual(Object.values(JOURNEY_MAIN_COUNTER).sort(), [
|
||||
'main.modulesRegisteredBeforeWindow',
|
||||
'main.sqlStatements',
|
||||
'main.sqlStatementsBeforeReadyToShow',
|
||||
'main.startupPhases',
|
||||
]);
|
||||
});
|
||||
|
||||
test('accepts a snapshot frozen at window creation and after the release', () => {
|
||||
const value = snapshot();
|
||||
assert.deepEqual(assertJourneyMainCounters(value, gate), value);
|
||||
});
|
||||
|
||||
test('accepts zero statements before ready-to-show with no running total', () => {
|
||||
const value = snapshot({ 'main.sqlStatementsBeforeReadyToShow': 0 });
|
||||
delete (value.counters as Record<string, number>)['main.sqlStatements'];
|
||||
assert.equal(
|
||||
assertJourneyMainCounters(value, gate).counters[
|
||||
'main.sqlStatementsBeforeReadyToShow'
|
||||
],
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed or incomplete snapshots', () => {
|
||||
for (const value of [
|
||||
null,
|
||||
{ counters: {} },
|
||||
{ counters: { 'main.sqlStatements': -1 }, frozenAtEpochMs: {} },
|
||||
{ counters: { 'main.sqlStatements': 1.5 }, frozenAtEpochMs: {} },
|
||||
{ counters: [], frozenAtEpochMs: {} },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertJourneyMainCounters(value, gate),
|
||||
/malformed/
|
||||
);
|
||||
}
|
||||
const unfrozen = snapshot();
|
||||
delete (unfrozen.frozenAtEpochMs as Record<string, number>)[
|
||||
'main.sqlStatementsBeforeReadyToShow'
|
||||
];
|
||||
assert.throws(
|
||||
() => assertJourneyMainCounters(unfrozen, gate),
|
||||
/not-frozen: main.sqlStatementsBeforeReadyToShow/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(snapshot(), {
|
||||
gatedEpochMs: null,
|
||||
releasedEpochMs: 1_150,
|
||||
}),
|
||||
/gate-incomplete/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects snapshots that were not frozen at the moments they claim', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({}, { 'main.modulesRegisteredBeforeWindow': 1_030 }),
|
||||
gate
|
||||
),
|
||||
/window-after-first-load/
|
||||
);
|
||||
// ready-to-show of about:blank, before the real document was released.
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({}, { 'main.sqlStatementsBeforeReadyToShow': 1_100 }),
|
||||
gate
|
||||
),
|
||||
/ready-to-show-before-release/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({ 'main.sqlStatements': 11 }),
|
||||
gate
|
||||
),
|
||||
/total-below-frozen: main.sqlStatements/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({ 'main.startupPhases': 1 }),
|
||||
gate
|
||||
),
|
||||
/total-below-frozen: main.startupPhases/
|
||||
);
|
||||
});
|
||||
|
||||
test('only the launch journey opts into SQL statement counting', () => {
|
||||
// The import benchmarks also run with IPTVNATOR_PERF_CAPTURE=1; the SQL
|
||||
// hook wraps every row of a bulk insert, so they must not enable it.
|
||||
const sourceRoot = resolve(__dirname, '..');
|
||||
const files = readdirSync(sourceRoot, { recursive: true })
|
||||
.map(String)
|
||||
.filter(
|
||||
(file) => /\.(ts|cjs)$/.test(file) && !/\.spec\.ts$/.test(file)
|
||||
);
|
||||
const optedIn = files
|
||||
.filter((file) =>
|
||||
readFileSync(join(sourceRoot, file), 'utf8').includes(
|
||||
'IPTVNATOR_PERF_COUNT_SQL'
|
||||
)
|
||||
)
|
||||
.map((file) => relative(sourceRoot, join(sourceRoot, file)));
|
||||
assert.deepEqual(optedIn, [join('journeys', 'launch-journey-app.ts')]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ElectronApplication } from '@playwright/test';
|
||||
|
||||
import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client';
|
||||
|
||||
/**
|
||||
* Test-side reader for the main-process performance counters
|
||||
* (`apps/electron-backend/src/app/services/performance-counters.ts`). The app
|
||||
* registers `performance:read-counters` only with IPTVNATOR_PERF_CAPTURE=1
|
||||
* and the preload does not expose it, so the journey calls the registered
|
||||
* handler from the main process through the gate's `ipcMain.handle` tap
|
||||
* (`journey-renderer-gate.cjs`).
|
||||
*/
|
||||
|
||||
/** Literal of `PERFORMANCE_COUNTERS_READ_CHANNEL`. */
|
||||
export const JOURNEY_PERFORMANCE_COUNTERS_CHANNEL = 'performance:read-counters';
|
||||
|
||||
/** Literals of `PERFORMANCE_COUNTER`. */
|
||||
export const JOURNEY_MAIN_COUNTER = {
|
||||
MODULES_REGISTERED_BEFORE_WINDOW: 'main.modulesRegisteredBeforeWindow',
|
||||
SQL_STATEMENTS: 'main.sqlStatements',
|
||||
SQL_STATEMENTS_BEFORE_READY_TO_SHOW: 'main.sqlStatementsBeforeReadyToShow',
|
||||
STARTUP_PHASES: 'main.startupPhases',
|
||||
} as const;
|
||||
|
||||
export interface JourneyMainCountersState {
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
readonly frozenAtEpochMs: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export async function readJourneyMainCounters(
|
||||
electronApp: ElectronApplication,
|
||||
gateKey: string
|
||||
): Promise<unknown> {
|
||||
return electronApp.evaluate(
|
||||
async (_electron, input) => {
|
||||
const gate = (globalThis as unknown as Record<string, unknown>)[
|
||||
input.gateKey
|
||||
] as
|
||||
| { invokeHandler?: (channel: string) => Promise<unknown> }
|
||||
| undefined;
|
||||
if (typeof gate?.invokeHandler !== 'function') {
|
||||
throw new Error('journey-main-counters-gate-missing');
|
||||
}
|
||||
const snapshot = await gate.invokeHandler(input.channel);
|
||||
return JSON.parse(JSON.stringify(snapshot ?? null)) as unknown;
|
||||
},
|
||||
{ channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL, gateKey }
|
||||
);
|
||||
}
|
||||
|
||||
function isCountRecord(value: unknown): value is Record<string, number> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(
|
||||
(entry) => Number.isSafeInteger(entry) && (entry as number) >= 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isEpochRecord(value: unknown): value is Record<string, number> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(
|
||||
(entry) => typeof entry === 'number' && entry > 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a snapshot only when it proves the ordering its counters claim:
|
||||
* the startup phases were frozen when the window was created (before the
|
||||
* gate saw its first load), and the SQL count at a `ready-to-show` that came
|
||||
* after the gate released the real document.
|
||||
*/
|
||||
export function assertJourneyMainCounters(
|
||||
value: unknown,
|
||||
gate: Pick<JourneyRendererGateState, 'gatedEpochMs' | 'releasedEpochMs'>
|
||||
): JourneyMainCountersState {
|
||||
const snapshot = value as Partial<JourneyMainCountersState> | null;
|
||||
if (
|
||||
!snapshot ||
|
||||
!isCountRecord(snapshot.counters) ||
|
||||
!isEpochRecord(snapshot.frozenAtEpochMs)
|
||||
) {
|
||||
throw new Error('journey-main-counters-malformed');
|
||||
}
|
||||
const { counters, frozenAtEpochMs } = snapshot;
|
||||
const frozen = [
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
];
|
||||
for (const name of frozen) {
|
||||
if (
|
||||
counters[name] === undefined ||
|
||||
frozenAtEpochMs[name] === undefined
|
||||
) {
|
||||
throw new Error(`journey-main-counters-not-frozen: ${name}`);
|
||||
}
|
||||
}
|
||||
if (gate.gatedEpochMs === null || gate.releasedEpochMs === null) {
|
||||
throw new Error('journey-main-counters-gate-incomplete');
|
||||
}
|
||||
if (
|
||||
frozenAtEpochMs[JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW] >
|
||||
gate.gatedEpochMs
|
||||
) {
|
||||
throw new Error('journey-main-counters-window-after-first-load');
|
||||
}
|
||||
if (
|
||||
frozenAtEpochMs[
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
] < gate.releasedEpochMs
|
||||
) {
|
||||
throw new Error('journey-main-counters-ready-to-show-before-release');
|
||||
}
|
||||
const running: Array<[string, string]> = [
|
||||
[
|
||||
JOURNEY_MAIN_COUNTER.STARTUP_PHASES,
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
],
|
||||
[
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
],
|
||||
];
|
||||
for (const [total, part] of running) {
|
||||
if ((counters[total] ?? 0) < counters[part]) {
|
||||
throw new Error(
|
||||
`journey-main-counters-total-below-frozen: ${total}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { counters, frozenAtEpochMs };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function gate(
|
||||
gatedEpochMs: 1_020,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
releasedEpochMs: 1_150,
|
||||
timedOut: false,
|
||||
...overrides,
|
||||
|
||||
@@ -14,9 +14,61 @@
|
||||
* test calls `globalThis.__iptvnatorJourneyGate.release()`. A safety timeout
|
||||
* releases the gate on its own and records that it did, so a broken test
|
||||
* cannot hang the app; the journey treats a timed-out gate as invalid.
|
||||
*
|
||||
* The detour must not change what the app measures. Electron emits
|
||||
* `ready-to-show` for the first paint of a hidden window, and `about:blank`
|
||||
* paints too: the app would show the window and freeze its
|
||||
* `ready-to-show` counters before its own document exists. The gate
|
||||
* therefore drops `ready-to-show` while the window is on `about:blank`;
|
||||
* Electron emits it again for the real document's first paint, because the
|
||||
* window is still hidden, which is the moment production sees.
|
||||
*
|
||||
* With `ipcMain` passed in, the gate also keeps the listeners registered
|
||||
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
|
||||
* main-process handler that the preload does not expose (the renderer
|
||||
* bridge stays unchanged). The registration itself is passed through.
|
||||
*/
|
||||
const GATE_KEY = '__iptvnatorJourneyGate';
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const BLANK_URL = 'about:blank';
|
||||
const TAPPED_IPC_CHANNELS = ['performance:read-counters'];
|
||||
|
||||
function isShowingBlank(window) {
|
||||
try {
|
||||
return window.webContents.getURL() === BLANK_URL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function holdReadyToShowWhileBlank(window, state) {
|
||||
const originalEmit = window.emit;
|
||||
if (typeof originalEmit !== 'function') return;
|
||||
window.emit = function gatedEmit(eventName, ...args) {
|
||||
if (eventName === 'ready-to-show' && isShowingBlank(window)) {
|
||||
state.readyToShowHeldOnBlank += 1;
|
||||
return false;
|
||||
}
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
function tapIpcHandlers(ipcMain, channels) {
|
||||
const handlers = new Map();
|
||||
const originalHandle = ipcMain.handle;
|
||||
ipcMain.handle = function tappedHandle(channel, listener) {
|
||||
const result = originalHandle.call(this, channel, listener);
|
||||
if (channels.includes(channel)) handlers.set(channel, listener);
|
||||
return result;
|
||||
};
|
||||
return async function invokeHandler(channel, ...args) {
|
||||
const listener = handlers.get(channel);
|
||||
if (!listener) {
|
||||
throw new Error(`journey-ipc-handler-not-registered: ${channel}`);
|
||||
}
|
||||
return listener({ frameId: -1, sender: null }, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
@@ -27,6 +79,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
gatedEpochMs: null,
|
||||
gatedMethod: null,
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 0,
|
||||
releasedEpochMs: null,
|
||||
timedOut: false,
|
||||
};
|
||||
@@ -41,7 +94,13 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
releaseGate();
|
||||
}
|
||||
}, timeoutMs);
|
||||
const invokeHandler = options.ipcMain
|
||||
? tapIpcHandlers(options.ipcMain, TAPPED_IPC_CHANNELS)
|
||||
: async (channel) => {
|
||||
throw new Error(`journey-ipc-handler-tap-missing: ${channel}`);
|
||||
};
|
||||
const api = {
|
||||
invokeHandler,
|
||||
release() {
|
||||
if (state.releasedEpochMs === null) {
|
||||
state.releasedEpochMs = now();
|
||||
@@ -68,8 +127,9 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
}
|
||||
state.gatedEpochMs = now();
|
||||
state.gatedMethod = method;
|
||||
holdReadyToShowWhileBlank(this, state);
|
||||
try {
|
||||
await this.webContents.loadURL('about:blank');
|
||||
await this.webContents.loadURL(BLANK_URL);
|
||||
state.blankLoadedEpochMs = now();
|
||||
} catch (error) {
|
||||
state.errors.push(
|
||||
@@ -83,7 +143,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = { GATE_KEY, installJourneyRendererGate };
|
||||
module.exports = { GATE_KEY, installJourneyRendererGate, TAPPED_IPC_CHANNELS };
|
||||
|
||||
if (
|
||||
process.versions &&
|
||||
@@ -91,6 +151,6 @@ if (
|
||||
!process.env['IPTVNATOR_JOURNEY_GATE_MANUAL']
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { BrowserWindow } = require('electron');
|
||||
installJourneyRendererGate(BrowserWindow, globalThis);
|
||||
const { BrowserWindow, ipcMain } = require('electron');
|
||||
installJourneyRendererGate(BrowserWindow, globalThis, { ipcMain });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
interface GateState {
|
||||
@@ -7,22 +8,33 @@ interface GateState {
|
||||
gatedEpochMs: number | null;
|
||||
gatedMethod: string | null;
|
||||
passThroughLoads: number;
|
||||
readyToShowHeldOnBlank: number;
|
||||
releasedEpochMs: number | null;
|
||||
timedOut: boolean;
|
||||
}
|
||||
|
||||
interface GateApi {
|
||||
invokeHandler(channel: string, ...args: unknown[]): Promise<unknown>;
|
||||
release(): GateState;
|
||||
state: GateState;
|
||||
}
|
||||
|
||||
interface FakeIpcMain {
|
||||
handle(channel: string, listener: (...args: unknown[]) => unknown): void;
|
||||
}
|
||||
|
||||
interface GateModule {
|
||||
GATE_KEY: string;
|
||||
installJourneyRendererGate(
|
||||
browserWindow: { prototype: Record<string, unknown> },
|
||||
target: Record<string, unknown>,
|
||||
options?: { now?: () => number; timeoutMs?: number }
|
||||
options?: {
|
||||
ipcMain?: FakeIpcMain;
|
||||
now?: () => number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
): GateApi;
|
||||
TAPPED_IPC_CHANNELS: string[];
|
||||
}
|
||||
|
||||
// The e2e project compiles to CommonJS, so the hook is loaded with require.
|
||||
@@ -139,3 +151,104 @@ test('records a failed about:blank navigation and still loads after release', as
|
||||
api.release();
|
||||
assert.equal(await load, 'loaded:index.html');
|
||||
});
|
||||
|
||||
function createEmittingBrowserWindow(log: string[]) {
|
||||
class EmittingBrowserWindow extends EventEmitter {
|
||||
url = '';
|
||||
webContents = {
|
||||
getURL: () => this.url,
|
||||
loadURL: async (url: string) => {
|
||||
this.url = url;
|
||||
log.push(`webContents.loadURL:${url}`);
|
||||
},
|
||||
};
|
||||
async loadFile(file: string): Promise<void> {
|
||||
this.url = `file:///${file}`;
|
||||
log.push(`loadFile:${file}`);
|
||||
}
|
||||
}
|
||||
return EmittingBrowserWindow;
|
||||
}
|
||||
|
||||
test('holds ready-to-show while the window shows about:blank, then lets the real one through', async () => {
|
||||
const log: string[] = [];
|
||||
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
EmittingBrowserWindow as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
const window = new EmittingBrowserWindow();
|
||||
window.once('ready-to-show', () => log.push('app:ready-to-show'));
|
||||
const load = window.loadFile('index.html');
|
||||
await settle();
|
||||
// Electron's first paint of about:blank.
|
||||
assert.equal(window.emit('ready-to-show'), false);
|
||||
window.emit('did-finish-load');
|
||||
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
||||
|
||||
api.release();
|
||||
await load;
|
||||
window.emit('ready-to-show');
|
||||
|
||||
assert.deepEqual(log, [
|
||||
'webContents.loadURL:about:blank',
|
||||
'loadFile:index.html',
|
||||
'app:ready-to-show',
|
||||
]);
|
||||
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
|
||||
const registered: string[] = [];
|
||||
const ipcMain: FakeIpcMain = {
|
||||
handle(channel) {
|
||||
registered.push(channel);
|
||||
},
|
||||
};
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
createFakeBrowserWindow([]) as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ ipcMain, timeoutMs: 60_000 }
|
||||
);
|
||||
assert.deepEqual(gateModule.TAPPED_IPC_CHANNELS, [
|
||||
'performance:read-counters',
|
||||
]);
|
||||
await assert.rejects(
|
||||
api.invokeHandler('performance:read-counters'),
|
||||
/journey-ipc-handler-not-registered: performance:read-counters/
|
||||
);
|
||||
|
||||
ipcMain.handle('performance:read-counters', (event, ...args) => ({
|
||||
args,
|
||||
sender: (event as { sender: unknown }).sender,
|
||||
}));
|
||||
ipcMain.handle('db:other', () => 'other');
|
||||
|
||||
assert.deepEqual(registered, ['performance:read-counters', 'db:other']);
|
||||
assert.deepEqual(await api.invokeHandler('performance:read-counters', 1), {
|
||||
args: [1],
|
||||
sender: null,
|
||||
});
|
||||
await assert.rejects(api.invokeHandler('db:other'), /not-registered/);
|
||||
api.release();
|
||||
});
|
||||
|
||||
test('refuses handler calls when no ipcMain was tapped', async () => {
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
createFakeBrowserWindow([]) as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
await assert.rejects(
|
||||
api.invokeHandler('performance:read-counters'),
|
||||
/journey-ipc-handler-tap-missing/
|
||||
);
|
||||
api.release();
|
||||
});
|
||||
@@ -67,10 +67,23 @@ function measurement(
|
||||
gatedEpochMs: 1_020,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
releasedEpochMs: 1_150,
|
||||
timedOut: false,
|
||||
},
|
||||
ipc,
|
||||
mainCounters: {
|
||||
counters: {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 61,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'main.startupPhases': 9,
|
||||
},
|
||||
frozenAtEpochMs: {
|
||||
'main.modulesRegisteredBeforeWindow': 1_010,
|
||||
'main.sqlStatementsBeforeReadyToShow': 1_250,
|
||||
},
|
||||
},
|
||||
pid: 4242,
|
||||
renderer,
|
||||
spawnEpochMs: 1_000,
|
||||
@@ -78,12 +91,14 @@ function measurement(
|
||||
};
|
||||
}
|
||||
|
||||
test('maps the probe and IPC capture to exact counters and spawn-relative wall-clock', () => {
|
||||
test('maps the probe, IPC capture and main counters to exact counters and spawn-relative wall-clock', () => {
|
||||
const record = toLaunchIterationRecord(2, false, measurement());
|
||||
assert.equal(record.index, 2);
|
||||
assert.equal(record.warmup, false);
|
||||
assert.equal(record.pid, 4242);
|
||||
assert.deepEqual(record.counters, {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'renderer.domMutationsToFirstCard': 480,
|
||||
'renderer.ipcCallsToFirstCard': 14,
|
||||
'renderer.layoutShiftScore': 0.123,
|
||||
@@ -99,12 +114,21 @@ test('maps the probe and IPC capture to exact counters and spawn-relative wall-c
|
||||
});
|
||||
assert.deepEqual(record.evidence['longTaskDurationsMs'], [71.3, 120]);
|
||||
assert.equal(record.evidence['ipcCallsAfterFirstCard'], 3);
|
||||
assert.deepEqual(record.evidence['mainCountersAtRead'], {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 61,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'main.startupPhases': 9,
|
||||
});
|
||||
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
firstCard: 2_600.04,
|
||||
firstCardPaint: 2_650,
|
||||
loadEventEnd: 1_400.26,
|
||||
mainIpcCaptureInstalled: 1_100,
|
||||
mainProcessStart: 900,
|
||||
mainReadyToShow: 1_250,
|
||||
mainWindowCreated: 1_010,
|
||||
rendererGateBlankLoaded: 1_050,
|
||||
rendererGateReleased: 1_150,
|
||||
rendererProbeInstalled: 1_200,
|
||||
@@ -144,8 +168,14 @@ test('rejects measurements whose clocks or probes are inconsistent', () => {
|
||||
});
|
||||
|
||||
test('names the counters the harness cannot measure yet', () => {
|
||||
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS).sort(), [
|
||||
'main.sqlStatementsBeforeReadyToShow',
|
||||
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [
|
||||
'renderer.cdTicksToFirstCard',
|
||||
]);
|
||||
});
|
||||
|
||||
test('never reports a measured counter as unavailable', () => {
|
||||
const record = toLaunchIterationRecord(0, false, measurement());
|
||||
for (const name of Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS)) {
|
||||
assert.equal(name in record.counters, false, name);
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,24 @@
|
||||
import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client';
|
||||
import {
|
||||
JOURNEY_MAIN_COUNTER,
|
||||
type JourneyMainCountersState,
|
||||
} from './journey-main-counters';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import type { JourneyIterationRecord } from './journey-summary';
|
||||
|
||||
/**
|
||||
* Maps one measured launch (renderer probe + main IPC capture) to the
|
||||
* journey summary's iteration record for J1 "Launch to usable".
|
||||
* Maps one measured launch (renderer probe, main IPC capture and main-process
|
||||
* counters) to the journey summary's iteration record for J1 "Launch to
|
||||
* usable".
|
||||
*/
|
||||
export const LAUNCH_JOURNEY_ID = 'launch';
|
||||
|
||||
export const LAUNCH_JOURNEY_COUNTER = {
|
||||
MODULES_REGISTERED_BEFORE_WINDOW:
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
SQL_STATEMENTS_BEFORE_READY_TO_SHOW:
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
|
||||
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
@@ -28,8 +37,6 @@ export const LAUNCH_JOURNEY_WALL_CLOCK = {
|
||||
export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
'main.sqlStatementsBeforeReadyToShow':
|
||||
'SQL statements are only visible as worker stdout trace lines, which are forwarded asynchronously; plan item A2 adds a countable channel.',
|
||||
'renderer.cdTicksToFirstCard':
|
||||
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
|
||||
});
|
||||
@@ -38,6 +45,7 @@ export interface LaunchJourneyMeasurement {
|
||||
readonly electronVersion: string;
|
||||
readonly gate: JourneyRendererGateState;
|
||||
readonly ipc: JourneyMainIpcCaptureState;
|
||||
readonly mainCounters: JourneyMainCountersState;
|
||||
readonly pid: number;
|
||||
readonly renderer: JourneyRendererProbeState;
|
||||
readonly spawnEpochMs: number;
|
||||
@@ -52,7 +60,7 @@ export function toLaunchIterationRecord(
|
||||
warmup: boolean,
|
||||
measurement: LaunchJourneyMeasurement
|
||||
): JourneyIterationRecord {
|
||||
const { ipc, renderer, spawnEpochMs } = measurement;
|
||||
const { ipc, mainCounters, renderer, spawnEpochMs } = measurement;
|
||||
if (renderer.terminal === null || renderer.navigation === null) {
|
||||
throw new Error('launch-journey-record-incomplete-probe');
|
||||
}
|
||||
@@ -75,6 +83,14 @@ export function toLaunchIterationRecord(
|
||||
}
|
||||
return Object.freeze({
|
||||
counters: Object.freeze({
|
||||
[LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW]:
|
||||
mainCounters.counters[
|
||||
LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW
|
||||
],
|
||||
[LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW]:
|
||||
mainCounters.counters[
|
||||
LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
],
|
||||
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
|
||||
renderer.counters.domMutations,
|
||||
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
|
||||
@@ -90,6 +106,15 @@ export function toLaunchIterationRecord(
|
||||
firstCardPaint: renderer.firstCardPaintEpochMs,
|
||||
loadEventEnd: renderer.navigation.loadEventEndEpochMs,
|
||||
mainIpcCaptureInstalled: ipc.installedEpochMs,
|
||||
mainReadyToShow:
|
||||
mainCounters.frozenAtEpochMs[
|
||||
LAUNCH_JOURNEY_COUNTER
|
||||
.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
],
|
||||
mainWindowCreated:
|
||||
mainCounters.frozenAtEpochMs[
|
||||
LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW
|
||||
],
|
||||
mainProcessStart: ipc.processStartEpochMs,
|
||||
rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs,
|
||||
rendererGateReleased: measurement.gate.releasedEpochMs,
|
||||
@@ -102,6 +127,10 @@ export function toLaunchIterationRecord(
|
||||
pathname: renderer.terminal.pathname,
|
||||
}),
|
||||
ipcCallsAfterFirstCard: ipc.callsAfterSentinel,
|
||||
// Running totals when the counters were read, after the first card.
|
||||
mainCountersAtRead: mainCounters.counters,
|
||||
rendererGateReadyToShowHeldOnBlank:
|
||||
measurement.gate.readyToShowHeldOnBlank,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
|
||||
observedTarget: renderer.capabilities.observedTarget,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export interface SidebarCategorySnapshot {
|
||||
id: string;
|
||||
itemCount: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const VISIBLE_SIDEBAR_CATEGORIES =
|
||||
'app-workspace-context-panel .category-item:visible';
|
||||
|
||||
/**
|
||||
* Reads the visible sidebar categories in one DOM snapshot.
|
||||
*
|
||||
* Do not loop over `count()` with per-row `nth(index)` reads here: those
|
||||
* locator calls auto-wait, so a row the sidebar removes between the count and
|
||||
* the read (for example right after hiding categories) blocks the call until
|
||||
* the surrounding `expect.poll` times out instead of letting the poll retry.
|
||||
*/
|
||||
export async function readVisibleSidebarCategories(
|
||||
page: Page
|
||||
): Promise<SidebarCategorySnapshot[]> {
|
||||
return page.locator(VISIBLE_SIDEBAR_CATEGORIES).evaluateAll((rows) =>
|
||||
rows.map((row) => ({
|
||||
id: row.getAttribute('data-category-id')?.trim() ?? '',
|
||||
itemCount:
|
||||
Number.parseInt(
|
||||
row.querySelector('.item-count')?.textContent?.trim() ?? '',
|
||||
10
|
||||
) || 0,
|
||||
name:
|
||||
row.querySelector('.nav-item-label')?.textContent?.trim() ?? '',
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function readVisibleSidebarCategoryNames(
|
||||
page: Page
|
||||
): Promise<string[]> {
|
||||
return (await readVisibleSidebarCategories(page))
|
||||
.map((category) => category.name)
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -7,11 +7,15 @@ import { join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { rendererAppName, rendererAppPort } from './constants';
|
||||
import {
|
||||
isStartupTraceEnabled,
|
||||
isPerformanceCaptureEnabled,
|
||||
isRendererConsoleTraceEnabled,
|
||||
isSqlStatementCountEnabled,
|
||||
isWindowTraceEnabled,
|
||||
performanceCounters,
|
||||
trace,
|
||||
traceStartupPhase,
|
||||
} from './services/debug-trace';
|
||||
import { attachMainWindowPerformanceCounters } from './services/performance-counters';
|
||||
import {
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
@@ -135,19 +139,14 @@ export async function clearElectronServiceWorkerStorage(
|
||||
storages: ['serviceworkers', 'cachestorage'],
|
||||
});
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'electron-service-worker-storage:cleared');
|
||||
}
|
||||
traceStartupPhase('electron-service-worker-storage:cleared');
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear Electron service worker storage:', error);
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace(
|
||||
'startup',
|
||||
'electron-service-worker-storage:clear-failed',
|
||||
error
|
||||
);
|
||||
}
|
||||
traceStartupPhase(
|
||||
'electron-service-worker-storage:clear-failed',
|
||||
() => error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -522,6 +521,14 @@ export default class App {
|
||||
...App.getPlatformTitleBarOptions(),
|
||||
});
|
||||
App.mainWindow.setMenu(null);
|
||||
attachMainWindowPerformanceCounters(
|
||||
App.mainWindow,
|
||||
performanceCounters,
|
||||
{
|
||||
capture: isPerformanceCaptureEnabled(),
|
||||
sqlStatements: isSqlStatementCountEnabled(),
|
||||
}
|
||||
);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
// Seeds the F11 tracker's fullscreen state now, while no transition
|
||||
|
||||
@@ -183,6 +183,63 @@ describe('DatabaseWorkerClient', () => {
|
||||
await expect(requestPromise).resolves.toBe(resultIdentity);
|
||||
});
|
||||
|
||||
describe('SQL statement counts', () => {
|
||||
const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE';
|
||||
const originalCapture = process.env[PERF_CAPTURE_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCapture === undefined) {
|
||||
delete process.env[PERF_CAPTURE_ENV];
|
||||
} else {
|
||||
process.env[PERF_CAPTURE_ENV] = originalCapture;
|
||||
}
|
||||
});
|
||||
|
||||
async function emitCountsDuringRequest(
|
||||
counts: unknown[]
|
||||
): Promise<Record<string, number>> {
|
||||
const client = createClient();
|
||||
const requestPromise = client.request('DB_GET_APP_STATE', {
|
||||
key: 'counts',
|
||||
});
|
||||
const worker = mockWorkerInstances[0];
|
||||
worker.emit('message', { type: 'ready' });
|
||||
await flushPromises();
|
||||
const request = worker.postMessage.mock.calls[0][0];
|
||||
|
||||
for (const count of counts) {
|
||||
worker.emit('message', {
|
||||
type: 'performance-sql-statements',
|
||||
count,
|
||||
});
|
||||
}
|
||||
worker.emit('message', {
|
||||
type: 'response',
|
||||
requestId: request.requestId,
|
||||
success: true,
|
||||
result: 'state',
|
||||
});
|
||||
|
||||
await expect(requestPromise).resolves.toBe('state');
|
||||
const { performanceCounters } = await import('./debug-trace');
|
||||
return performanceCounters.read().counters;
|
||||
}
|
||||
|
||||
it('adds worker statement counts to main.sqlStatements with capture on', async () => {
|
||||
process.env[PERF_CAPTURE_ENV] = '1';
|
||||
|
||||
await expect(
|
||||
emitCountsDuringRequest([3, 4, 0, -1, 'x'])
|
||||
).resolves.toEqual({ 'main.sqlStatements': 7 });
|
||||
});
|
||||
|
||||
it('counts nothing without the capture flag', async () => {
|
||||
delete process.env[PERF_CAPTURE_ENV];
|
||||
|
||||
await expect(emitCountsDuringRequest([3])).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
it('turns serialized worker errors into rejected Error instances', async () => {
|
||||
const client = createClient();
|
||||
const requestPromise = client.request('DB_DELETE_PLAYLIST', {
|
||||
|
||||
@@ -11,10 +11,16 @@ import type {
|
||||
} from '../workers/database-worker.types';
|
||||
import {
|
||||
isDbTraceEnabled,
|
||||
performanceCounters,
|
||||
roundTraceDuration,
|
||||
summarizeForTrace,
|
||||
trace,
|
||||
} from './debug-trace';
|
||||
import { PERFORMANCE_COUNTER } from './performance-counters';
|
||||
import {
|
||||
DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE,
|
||||
readSqlStatementsMessageCount,
|
||||
} from '../workers/database-worker-sql-statement-count';
|
||||
import { resolveWorkerRuntimeBootstrap } from '../workers/worker-runtime-paths';
|
||||
|
||||
type PendingRequest = {
|
||||
@@ -222,6 +228,17 @@ export class DatabaseWorkerClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE) {
|
||||
const count = readSqlStatementsMessageCount(message);
|
||||
if (count !== null) {
|
||||
performanceCounters.increment(
|
||||
PERFORMANCE_COUNTER.SQL_STATEMENTS,
|
||||
count
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRequest = this.pendingRequests.get(message.requestId);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
|
||||
@@ -66,3 +66,98 @@ describe('debug trace redaction', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('startup phase counting', () => {
|
||||
const FLAGS = ['IPTVNATOR_PERF_CAPTURE', 'IPTVNATOR_TRACE_STARTUP'];
|
||||
const original = FLAGS.map((name) => [name, process.env[name]] as const);
|
||||
|
||||
afterEach(() => {
|
||||
for (const [name, value] of original) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
async function runPhases(env: Record<string, string>) {
|
||||
for (const name of FLAGS) {
|
||||
delete process.env[name];
|
||||
}
|
||||
Object.assign(process.env, env);
|
||||
const log = jest.spyOn(console, 'log').mockImplementation(() => {
|
||||
/* silenced */
|
||||
});
|
||||
const payload = jest.fn(() => ({ source: 'did-start-loading' }));
|
||||
const { performanceCounters, traceStartupPhase } =
|
||||
await import('./debug-trace');
|
||||
|
||||
traceStartupPhase('bootstrap-app');
|
||||
traceStartupPhase('deferred-events:start', payload);
|
||||
|
||||
return {
|
||||
counters: performanceCounters.read().counters,
|
||||
lines: log.mock.calls.map((call) => String(call[0])),
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
it('neither counts nor traces nor builds payloads by default', async () => {
|
||||
const result = await runPhases({});
|
||||
|
||||
expect(result.counters).toEqual({});
|
||||
expect(result.lines).toEqual([]);
|
||||
expect(result.payload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts every phase with IPTVNATOR_PERF_CAPTURE=1 without tracing', async () => {
|
||||
const result = await runPhases({ IPTVNATOR_PERF_CAPTURE: '1' });
|
||||
|
||||
expect(result.counters).toEqual({ 'main.startupPhases': 2 });
|
||||
expect(result.lines).toEqual([]);
|
||||
expect(result.payload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the startup trace lines unchanged when tracing is on', async () => {
|
||||
const result = await runPhases({ IPTVNATOR_TRACE_STARTUP: '1' });
|
||||
|
||||
expect(result.counters).toEqual({});
|
||||
expect(result.lines).toEqual([
|
||||
'[IPTVnator Trace][startup] bootstrap-app',
|
||||
'[IPTVnator Trace][startup] deferred-events:start {"source":"did-start-loading"}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQL statement count opt-in', () => {
|
||||
const FLAGS = ['IPTVNATOR_PERF_CAPTURE', 'IPTVNATOR_PERF_COUNT_SQL'];
|
||||
const original = FLAGS.map((name) => [name, process.env[name]] as const);
|
||||
|
||||
afterEach(() => {
|
||||
for (const [name, value] of original) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, false],
|
||||
[{ IPTVNATOR_PERF_CAPTURE: '1' }, false],
|
||||
[{ IPTVNATOR_PERF_COUNT_SQL: '1' }, false],
|
||||
[{ IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_COUNT_SQL: '1' }, true],
|
||||
])('needs both flags: %j -> %s', async (env, expected) => {
|
||||
for (const name of FLAGS) {
|
||||
delete process.env[name];
|
||||
}
|
||||
Object.assign(process.env, env);
|
||||
const { isSqlStatementCountEnabled } = await import('./debug-trace');
|
||||
|
||||
expect(isSqlStatementCountEnabled()).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
redactSensitiveData,
|
||||
summarizeSqlStatementForTrace,
|
||||
} from '@iptvnator/shared/logging';
|
||||
import {
|
||||
createPerformanceCounterRegistry,
|
||||
PERFORMANCE_COUNTER,
|
||||
} from './performance-counters';
|
||||
|
||||
const TRACE_ENV_TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const TRACE_PREFIX = '[IPTVnator Trace]';
|
||||
@@ -59,6 +63,26 @@ export function isPerformanceCaptureEnabled(): boolean {
|
||||
return readFlag('IPTVNATOR_PERF_CAPTURE');
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL statement counting wraps every statement execution, including each
|
||||
* row of a bulk insert, so it has its own opt-in on top of the capture flag:
|
||||
* only the launch journey sets it, and the import benchmarks that also run
|
||||
* with IPTVNATOR_PERF_CAPTURE=1 keep measuring the unwrapped workload.
|
||||
*/
|
||||
export function isSqlStatementCountEnabled(): boolean {
|
||||
return (
|
||||
isPerformanceCaptureEnabled() && readFlag('IPTVNATOR_PERF_COUNT_SQL')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide performance counters (see `performance-counters.ts`). Every
|
||||
* call is a no-op unless `IPTVNATOR_PERF_CAPTURE=1`.
|
||||
*/
|
||||
export const performanceCounters = createPerformanceCounterRegistry(
|
||||
isPerformanceCaptureEnabled
|
||||
);
|
||||
|
||||
export function isDbTraceEnabled(): boolean {
|
||||
return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB');
|
||||
}
|
||||
@@ -179,3 +203,18 @@ export function trace(scope: string, message: string, payload?: unknown): void {
|
||||
export function traceSqlStatement(scope: string, sql: unknown): void {
|
||||
trace(scope, 'query', summarizeSqlStatementForTrace(sql));
|
||||
}
|
||||
|
||||
/**
|
||||
* One main-process startup phase: counted under `main.startupPhases` when
|
||||
* performance capture is on, traced as `[startup] <phase>` when startup
|
||||
* tracing is on. The payload is a thunk so it is only built for the trace.
|
||||
*/
|
||||
export function traceStartupPhase(
|
||||
phase: string,
|
||||
payload?: () => unknown
|
||||
): void {
|
||||
performanceCounters.increment(PERFORMANCE_COUNTER.STARTUP_PHASES);
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', phase, payload?.());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import * as shared from '@iptvnator/shared/database';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { countMainProcessSqlStatements } from './main-sql-statement-count';
|
||||
import { createPerformanceCounterRegistry } from './performance-counters';
|
||||
|
||||
const ENV_NAMES = ['IPTVNATOR_E2E_DATA_DIR', 'IPTVNATOR_TRACE_SQL'] as const;
|
||||
const EXECUTION_METHODS = ['run', 'get', 'all', 'iterate'] as const;
|
||||
|
||||
describe('main-process SQL statement count', () => {
|
||||
const originalEnv = ENV_NAMES.map((name) => [name, process.env[name]]);
|
||||
const statementPrototype = (() => {
|
||||
const probe = new Database(':memory:');
|
||||
const prototype = Object.getPrototypeOf(
|
||||
probe.prepare('SELECT 1')
|
||||
) as Record<string, unknown>;
|
||||
probe.close();
|
||||
return prototype;
|
||||
})();
|
||||
const originalMethods = EXECUTION_METHODS.map(
|
||||
(method) => [method, statementPrototype[method]] as const
|
||||
);
|
||||
let dataDirectory: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
for (const [method, original] of originalMethods) {
|
||||
statementPrototype[method] = original;
|
||||
}
|
||||
for (const [name, value] of originalEnv) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name as string];
|
||||
} else {
|
||||
process.env[name as string] = value;
|
||||
}
|
||||
}
|
||||
if (dataDirectory) {
|
||||
rmSync(dataDirectory, { force: true, recursive: true });
|
||||
dataDirectory = null;
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers no connection observer without the capture flag', () => {
|
||||
const setObserver = jest.fn();
|
||||
|
||||
countMainProcessSqlStatements(
|
||||
createPerformanceCounterRegistry(() => true),
|
||||
false,
|
||||
setObserver
|
||||
);
|
||||
|
||||
expect(setObserver).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('counts every statement of the shared connection, as the sql-main trace does', async () => {
|
||||
dataDirectory = mkdtempSync(join(tmpdir(), 'iptvnator-main-sql-'));
|
||||
process.env['IPTVNATOR_E2E_DATA_DIR'] = dataDirectory;
|
||||
process.env['IPTVNATOR_TRACE_SQL'] = '1';
|
||||
const log = jest.spyOn(console, 'log').mockImplementation(() => {
|
||||
/* silenced */
|
||||
});
|
||||
const registry = createPerformanceCounterRegistry(() => true);
|
||||
|
||||
countMainProcessSqlStatements(
|
||||
registry,
|
||||
true,
|
||||
shared.setDatabaseConnectionObserver
|
||||
);
|
||||
try {
|
||||
await shared.initDatabase();
|
||||
} finally {
|
||||
shared.setDatabaseConnectionObserver(null);
|
||||
shared.closeDatabase();
|
||||
}
|
||||
|
||||
const traced = log.mock.calls.filter((call) =>
|
||||
String(call[0]).startsWith('[IPTVnator Trace][sql-main] query')
|
||||
).length;
|
||||
const counted = registry.read().counters['main.sqlStatements'];
|
||||
expect(traced).toBeGreaterThan(0);
|
||||
expect(counted).toBe(traced);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
setDatabaseConnectionObserver,
|
||||
type DatabaseConnectionObserver,
|
||||
} from '@iptvnator/shared/database/connection-observer';
|
||||
import { countSqlStatementExecutions } from '../workers/database-worker-sql-statement-count';
|
||||
import { PERFORMANCE_COUNTER } from './performance-counters';
|
||||
import type { PerformanceCounterRegistry } from './performance-counters';
|
||||
|
||||
/**
|
||||
* With IPTVNATOR_PERF_CAPTURE=1 and IPTVNATOR_PERF_COUNT_SQL=1, counts the
|
||||
* SQL statements the main process
|
||||
* executes into `main.sqlStatements`, next to the database worker's
|
||||
* statements that `DatabaseWorkerClient` adds. The shared connection
|
||||
* (`initDatabase`, the `sql-main` trace) runs schema creation and migrations
|
||||
* on the main thread before the first paint, so a worker-only count would
|
||||
* miss them.
|
||||
*
|
||||
* The hook is installed when that connection opens, before its first
|
||||
* statement, so better-sqlite3 is not loaded any earlier than without the
|
||||
* flag. It wraps the `Statement` prototype of this process, which also
|
||||
* counts statements of any other main-process connection opened later.
|
||||
*/
|
||||
export function countMainProcessSqlStatements(
|
||||
registry: PerformanceCounterRegistry,
|
||||
enabled: boolean,
|
||||
setObserver: (
|
||||
observer: DatabaseConnectionObserver | null
|
||||
) => void = setDatabaseConnectionObserver
|
||||
): void {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
setObserver((connection) => {
|
||||
countSqlStatementExecutions(connection, () =>
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
attachMainWindowPerformanceCounters,
|
||||
createPerformanceCounterRegistry,
|
||||
PERFORMANCE_COUNTER,
|
||||
PERFORMANCE_COUNTERS_READ_CHANNEL,
|
||||
registerPerformanceCountersHandler,
|
||||
} from './performance-counters';
|
||||
|
||||
function createRegistry(enabled = true, epochs = [1_000, 2_000, 3_000]) {
|
||||
let flag = enabled;
|
||||
const clock = [...epochs];
|
||||
const registry = createPerformanceCounterRegistry(
|
||||
() => flag,
|
||||
() => clock.shift() ?? -1
|
||||
);
|
||||
return {
|
||||
registry,
|
||||
setEnabled(value: boolean) {
|
||||
flag = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('performance counter registry', () => {
|
||||
it('counts nothing while capture is disabled', () => {
|
||||
const { registry } = createRegistry(false);
|
||||
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS, 4);
|
||||
registry.freeze(
|
||||
PERFORMANCE_COUNTER.SQL_STATEMENTS,
|
||||
PERFORMANCE_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
);
|
||||
|
||||
expect(registry.read()).toEqual({ counters: {}, frozenAtEpochMs: {} });
|
||||
});
|
||||
|
||||
it('adds only positive safe integers', () => {
|
||||
const { registry } = createRegistry();
|
||||
|
||||
registry.increment('main.sqlStatements');
|
||||
registry.increment('main.sqlStatements', 3);
|
||||
for (const invalid of [0, -2, 1.5, Number.NaN, 2 ** 53]) {
|
||||
registry.increment('main.sqlStatements', invalid);
|
||||
}
|
||||
|
||||
expect(registry.read().counters).toEqual({ 'main.sqlStatements': 4 });
|
||||
});
|
||||
|
||||
it('freezes a counter once, with the epoch it was taken at', () => {
|
||||
const { registry } = createRegistry();
|
||||
registry.increment('main.sqlStatements', 5);
|
||||
|
||||
registry.freeze('main.sqlStatements', 'main.frozen');
|
||||
registry.increment('main.sqlStatements', 2);
|
||||
registry.freeze('main.sqlStatements', 'main.frozen');
|
||||
|
||||
expect(registry.read()).toEqual({
|
||||
counters: { 'main.frozen': 5, 'main.sqlStatements': 7 },
|
||||
frozenAtEpochMs: { 'main.frozen': 1_000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes a counter that was never incremented as zero', () => {
|
||||
const { registry } = createRegistry();
|
||||
|
||||
registry.freeze('main.sqlStatements', 'main.frozen');
|
||||
|
||||
expect(registry.read().counters).toEqual({ 'main.frozen': 0 });
|
||||
});
|
||||
|
||||
it('returns sorted copies that later increments do not change', () => {
|
||||
const { registry } = createRegistry();
|
||||
registry.increment('main.b');
|
||||
registry.increment('main.a');
|
||||
|
||||
const snapshot = registry.read();
|
||||
registry.increment('main.a');
|
||||
|
||||
expect(Object.keys(snapshot.counters)).toEqual(['main.a', 'main.b']);
|
||||
expect(snapshot.counters['main.a']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance:read-counters handler', () => {
|
||||
it('is not registered without the capture flag', () => {
|
||||
const ipcMain = { handle: jest.fn() };
|
||||
const { registry } = createRegistry();
|
||||
|
||||
expect(
|
||||
registerPerformanceCountersHandler(ipcMain, registry, false)
|
||||
).toBe(false);
|
||||
expect(ipcMain.handle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the registry snapshot when the flag is on', async () => {
|
||||
const ipcMain = { handle: jest.fn() };
|
||||
const { registry } = createRegistry();
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES, 2);
|
||||
|
||||
expect(
|
||||
registerPerformanceCountersHandler(ipcMain, registry, true)
|
||||
).toBe(true);
|
||||
expect(ipcMain.handle).toHaveBeenCalledTimes(1);
|
||||
const [channel, handler] = ipcMain.handle.mock.calls[0];
|
||||
expect(channel).toBe(PERFORMANCE_COUNTERS_READ_CHANNEL);
|
||||
expect(channel).toBe('performance:read-counters');
|
||||
expect(await handler({})).toEqual({
|
||||
counters: { 'main.startupPhases': 2 },
|
||||
frozenAtEpochMs: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('main window performance counters', () => {
|
||||
const BOTH_FLAGS = { capture: true, sqlStatements: true };
|
||||
|
||||
it('attaches nothing without the capture flag', () => {
|
||||
const window = new EventEmitter();
|
||||
const { registry } = createRegistry();
|
||||
|
||||
attachMainWindowPerformanceCounters(window, registry, {
|
||||
capture: false,
|
||||
sqlStatements: false,
|
||||
});
|
||||
|
||||
expect(window.listenerCount('ready-to-show')).toBe(0);
|
||||
expect(registry.read().counters).toEqual({});
|
||||
});
|
||||
|
||||
it('freezes only the startup phases when SQL is not counted', () => {
|
||||
const window = new EventEmitter();
|
||||
const { registry } = createRegistry();
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES, 2);
|
||||
|
||||
attachMainWindowPerformanceCounters(window, registry, {
|
||||
capture: true,
|
||||
sqlStatements: false,
|
||||
});
|
||||
window.emit('ready-to-show');
|
||||
|
||||
expect(window.listenerCount('ready-to-show')).toBe(0);
|
||||
expect(registry.read().counters).toEqual({
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.startupPhases': 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes startup phases at creation and SQL at ready-to-show', () => {
|
||||
const window = new EventEmitter();
|
||||
const { registry } = createRegistry();
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES, 2);
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS, 3);
|
||||
|
||||
attachMainWindowPerformanceCounters(window, registry, BOTH_FLAGS);
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES);
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS, 4);
|
||||
window.emit('ready-to-show');
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS, 9);
|
||||
|
||||
expect(registry.read()).toEqual({
|
||||
counters: {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 16,
|
||||
'main.sqlStatementsBeforeReadyToShow': 7,
|
||||
'main.startupPhases': 3,
|
||||
},
|
||||
frozenAtEpochMs: {
|
||||
'main.modulesRegisteredBeforeWindow': 1_000,
|
||||
'main.sqlStatementsBeforeReadyToShow': 2_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the first window values when a window is re-created', () => {
|
||||
const first = new EventEmitter();
|
||||
const second = new EventEmitter();
|
||||
const { registry } = createRegistry();
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES, 2);
|
||||
attachMainWindowPerformanceCounters(first, registry, BOTH_FLAGS);
|
||||
first.emit('ready-to-show');
|
||||
|
||||
registry.increment(PERFORMANCE_COUNTER.STARTUP_PHASES, 5);
|
||||
registry.increment(PERFORMANCE_COUNTER.SQL_STATEMENTS, 5);
|
||||
attachMainWindowPerformanceCounters(second, registry, BOTH_FLAGS);
|
||||
second.emit('ready-to-show');
|
||||
|
||||
expect(registry.read().counters).toMatchObject({
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatementsBeforeReadyToShow': 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { IpcMain } from 'electron';
|
||||
|
||||
/**
|
||||
* Named main-process counters for the performance journeys
|
||||
* (docs/architecture/performance-journeys.md). Everything here is inert
|
||||
* unless `IPTVNATOR_PERF_CAPTURE=1`: nothing is counted, no listener is
|
||||
* attached and no IPC handler is registered. Only type imports from
|
||||
* `electron`, so the database worker and the preload can load
|
||||
* `debug-trace.ts`, which owns the process-wide registry.
|
||||
*/
|
||||
export const PERFORMANCE_COUNTERS_READ_CHANNEL = 'performance:read-counters';
|
||||
|
||||
export const PERFORMANCE_COUNTER = {
|
||||
/** Running total of startup trace phases (`traceStartupPhase`). */
|
||||
STARTUP_PHASES: 'main.startupPhases',
|
||||
/** Running total of SQL statements run by main and the DB worker. */
|
||||
SQL_STATEMENTS: 'main.sqlStatements',
|
||||
/** `STARTUP_PHASES` when the first main window was created. */
|
||||
MODULES_REGISTERED_BEFORE_WINDOW: 'main.modulesRegisteredBeforeWindow',
|
||||
/** `SQL_STATEMENTS` when the first main window emitted `ready-to-show`. */
|
||||
SQL_STATEMENTS_BEFORE_READY_TO_SHOW: 'main.sqlStatementsBeforeReadyToShow',
|
||||
} as const;
|
||||
|
||||
export interface PerformanceCountersSnapshot {
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
/** Epoch milliseconds at which each frozen counter was taken. */
|
||||
readonly frozenAtEpochMs: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface PerformanceCounterRegistry {
|
||||
/** Adds a positive safe integer to a running counter. */
|
||||
increment(name: string, by?: number): void;
|
||||
/**
|
||||
* Copies the current value of `source` into `target` once; later calls
|
||||
* keep the first value, so a re-created window cannot move it.
|
||||
*/
|
||||
freeze(source: string, target: string): void;
|
||||
read(): PerformanceCountersSnapshot;
|
||||
}
|
||||
|
||||
function sortedRecord(entries: Map<string, number>): Record<string, number> {
|
||||
return Object.fromEntries(
|
||||
[...entries].sort(([left], [right]) => left.localeCompare(right))
|
||||
);
|
||||
}
|
||||
|
||||
export function createPerformanceCounterRegistry(
|
||||
isEnabled: () => boolean,
|
||||
readEpochMs: () => number = Date.now
|
||||
): PerformanceCounterRegistry {
|
||||
const counters = new Map<string, number>();
|
||||
const frozenAtEpochMs = new Map<string, number>();
|
||||
|
||||
return {
|
||||
increment(name, by = 1) {
|
||||
if (!Number.isSafeInteger(by) || by < 1 || !isEnabled()) {
|
||||
return;
|
||||
}
|
||||
counters.set(name, (counters.get(name) ?? 0) + by);
|
||||
},
|
||||
freeze(source, target) {
|
||||
if (frozenAtEpochMs.has(target) || !isEnabled()) {
|
||||
return;
|
||||
}
|
||||
counters.set(target, counters.get(source) ?? 0);
|
||||
frozenAtEpochMs.set(target, readEpochMs());
|
||||
},
|
||||
read() {
|
||||
return {
|
||||
counters: sortedRecord(counters),
|
||||
frozenAtEpochMs: sortedRecord(frozenAtEpochMs),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers `performance:read-counters` only when capture is enabled. The
|
||||
* preload does not expose the channel, so the renderer bridge is unchanged;
|
||||
* the journey harness reads it from the main process.
|
||||
*/
|
||||
export function registerPerformanceCountersHandler(
|
||||
ipcMain: Pick<IpcMain, 'handle'>,
|
||||
registry: PerformanceCounterRegistry,
|
||||
enabled: boolean
|
||||
): boolean {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
ipcMain.handle(PERFORMANCE_COUNTERS_READ_CHANNEL, () => registry.read());
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MainWindowPerformanceCounterFlags {
|
||||
/** IPTVNATOR_PERF_CAPTURE=1. */
|
||||
readonly capture: boolean;
|
||||
/** IPTVNATOR_PERF_COUNT_SQL=1 as well; SQL is not counted otherwise. */
|
||||
readonly sqlStatements: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Freezes the window-relative counters: the startup phases that ran before
|
||||
* this window existed and, when SQL is counted, the database statements that
|
||||
* ran before its first `ready-to-show`. Without SQL counting no listener is
|
||||
* attached, so a zero is never reported for statements nobody counted. Call
|
||||
* right after the window is constructed.
|
||||
*/
|
||||
export function attachMainWindowPerformanceCounters(
|
||||
window: { once(event: 'ready-to-show', listener: () => void): unknown },
|
||||
registry: PerformanceCounterRegistry,
|
||||
flags: MainWindowPerformanceCounterFlags
|
||||
): void {
|
||||
if (!flags.capture) {
|
||||
return;
|
||||
}
|
||||
registry.freeze(
|
||||
PERFORMANCE_COUNTER.STARTUP_PHASES,
|
||||
PERFORMANCE_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW
|
||||
);
|
||||
if (!flags.sqlStatements) {
|
||||
return;
|
||||
}
|
||||
window.once('ready-to-show', () => {
|
||||
registry.freeze(
|
||||
PERFORMANCE_COUNTER.SQL_STATEMENTS,
|
||||
PERFORMANCE_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import StalkerEvents from '../events/stalker.events';
|
||||
import XtreamEvents from '../events/xtream.events';
|
||||
import { registerStreamProbeHandlers } from '../events/stream-probe';
|
||||
import { registerConnectivityGuardHandlers } from '../events/connectivity-guard.events';
|
||||
import { isStartupTraceEnabled, trace } from '../services/debug-trace';
|
||||
import { traceStartupPhase } from '../services/debug-trace';
|
||||
import { AppUpdateService } from '../services/app-update.service';
|
||||
import {
|
||||
onAppUpdateChannelChange,
|
||||
@@ -115,21 +115,15 @@ export function bootstrapDeferredEvents(
|
||||
export async function finishStartupAfterFirstLoad(): Promise<void> {
|
||||
await initDatabase();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'init-database:done');
|
||||
}
|
||||
traceStartupPhase('init-database:done');
|
||||
|
||||
await resetStaleDownloads();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'reset-stale-downloads:done');
|
||||
}
|
||||
traceStartupPhase('reset-stale-downloads:done');
|
||||
|
||||
await reconcileStaleRecordings();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'reconcile-stale-recordings:done');
|
||||
}
|
||||
traceStartupPhase('reconcile-stale-recordings:done');
|
||||
}
|
||||
|
||||
let fixPathScheduled = false;
|
||||
@@ -153,9 +147,7 @@ export function scheduleDeferredFixPath(): void {
|
||||
import('fix-path')
|
||||
.then(({ default: fixPath }) => {
|
||||
fixPath();
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'fix-path:done');
|
||||
}
|
||||
traceStartupPhase('fix-path:done');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('fix-path failed:', error);
|
||||
|
||||
@@ -91,6 +91,7 @@ describe('database worker progress throttle wiring', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.doMock('../database/operations/content.operations', () => ({
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import {
|
||||
countSqlStatementExecutions,
|
||||
createSqlStatementCountReporter,
|
||||
DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE,
|
||||
readSqlStatementsMessageCount,
|
||||
type DbWorkerSqlStatementsMessage,
|
||||
} from './database-worker-sql-statement-count';
|
||||
|
||||
/** Runs every execution path the worker's connection uses. */
|
||||
function runWorkload(db: Database.Database): void {
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.prepare(
|
||||
'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, secret TEXT)'
|
||||
).run();
|
||||
const insert = db.prepare('INSERT INTO items (name, secret) VALUES (?, ?)');
|
||||
db.transaction((rows: string[]) => {
|
||||
for (const name of rows) {
|
||||
insert.run(name, 'statement-count-secret');
|
||||
}
|
||||
})(['a', 'b', 'c']);
|
||||
db.prepare('SELECT * FROM items WHERE id = ?').get(1);
|
||||
db.prepare('SELECT * FROM items').all();
|
||||
for (const row of db.prepare('SELECT id FROM items').iterate()) {
|
||||
void row;
|
||||
}
|
||||
db.exec('DELETE FROM items WHERE id = 3');
|
||||
try {
|
||||
// Fails before execution, like a repeated column migration.
|
||||
db.exec('ALTER TABLE items ADD COLUMN name TEXT');
|
||||
} catch {
|
||||
// Expected: duplicate column.
|
||||
}
|
||||
try {
|
||||
db.prepare('INSERT INTO items (id, name) VALUES (1, ?)').get('x');
|
||||
} catch {
|
||||
// Expected: get() on a statement that returns no data.
|
||||
}
|
||||
}
|
||||
|
||||
describe('database worker SQL statement count', () => {
|
||||
const connections: Database.Database[] = [];
|
||||
const restores: Array<() => void> = [];
|
||||
|
||||
function open(options?: Database.Options): Database.Database {
|
||||
const db = new Database(':memory:', options);
|
||||
connections.push(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const restore of restores.splice(0).reverse()) {
|
||||
restore();
|
||||
}
|
||||
for (const db of connections.splice(0)) {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('counts exactly what the SQL trace callback sees, without SQL text', () => {
|
||||
const traced: string[] = [];
|
||||
runWorkload(open({ verbose: (sql) => traced.push(String(sql)) }));
|
||||
|
||||
let counted = 0;
|
||||
const db = open();
|
||||
restores.push(
|
||||
countSqlStatementExecutions(db, () => {
|
||||
counted += 1;
|
||||
})
|
||||
);
|
||||
runWorkload(db);
|
||||
|
||||
// pragma, CREATE, BEGIN, 3 inserts, COMMIT, get, all, iterate, exec
|
||||
expect(traced).toHaveLength(11);
|
||||
expect(counted).toBe(traced.length);
|
||||
});
|
||||
|
||||
it('wraps once and restores the original methods', () => {
|
||||
const db = open();
|
||||
const statementPrototype = Object.getPrototypeOf(
|
||||
db.prepare('SELECT 1')
|
||||
) as Record<string, unknown>;
|
||||
const originalRun = statementPrototype['run'];
|
||||
let counted = 0;
|
||||
const record = () => {
|
||||
counted += 1;
|
||||
};
|
||||
|
||||
const restore = countSqlStatementExecutions(db, record);
|
||||
const second = countSqlStatementExecutions(db, record);
|
||||
db.prepare('SELECT 1').get();
|
||||
second();
|
||||
restore();
|
||||
db.prepare('SELECT 1').get();
|
||||
|
||||
expect(counted).toBe(1);
|
||||
expect(statementPrototype['run']).toBe(originalRun);
|
||||
});
|
||||
|
||||
it('coalesces statements into one message per flush', () => {
|
||||
const posted: DbWorkerSqlStatementsMessage[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const reporter = createSqlStatementCountReporter(
|
||||
(message) => posted.push(message),
|
||||
(callback) => scheduled.push(callback)
|
||||
);
|
||||
|
||||
reporter.record();
|
||||
reporter.record();
|
||||
reporter.record();
|
||||
expect(scheduled).toHaveLength(1);
|
||||
expect(posted).toEqual([]);
|
||||
|
||||
scheduled[0]();
|
||||
reporter.flush();
|
||||
|
||||
expect(posted).toEqual([
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('posts pending statements before a synchronous response', () => {
|
||||
const order: string[] = [];
|
||||
const scheduled: Array<() => void> = [];
|
||||
const reporter = createSqlStatementCountReporter(
|
||||
(message) => order.push(`count:${message.count}`),
|
||||
(callback) => scheduled.push(callback)
|
||||
);
|
||||
|
||||
reporter.record();
|
||||
reporter.record();
|
||||
// The worker's postMessage wrapper flushes before every message.
|
||||
reporter.flush();
|
||||
order.push('response');
|
||||
scheduled[0]();
|
||||
reporter.record();
|
||||
|
||||
expect(order).toEqual(['count:2', 'response']);
|
||||
expect(scheduled).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('flushes on its own at the end of the current turn', async () => {
|
||||
const posted: DbWorkerSqlStatementsMessage[] = [];
|
||||
const reporter = createSqlStatementCountReporter((message) =>
|
||||
posted.push(message)
|
||||
);
|
||||
|
||||
reporter.record();
|
||||
reporter.record();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(posted).toEqual([
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts only well-formed count messages', () => {
|
||||
expect(
|
||||
readSqlStatementsMessageCount({
|
||||
type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE,
|
||||
count: 7,
|
||||
})
|
||||
).toBe(7);
|
||||
for (const invalid of [
|
||||
null,
|
||||
'performance-sql-statements',
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE },
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 0 },
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: -1 },
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 1.5 },
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: '3' },
|
||||
{ type: 'response', count: 3 },
|
||||
]) {
|
||||
expect(readSqlStatementsMessageCount(invalid)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Counts the SQL statements the database worker executes and reports the
|
||||
* count to the main process, where `main.sqlStatements` is kept (see
|
||||
* `services/performance-counters.ts`). Only active with
|
||||
* `IPTVNATOR_PERF_CAPTURE=1` and `IPTVNATOR_PERF_COUNT_SQL=1`, which only the
|
||||
* launch journey sets: the import benchmarks run with the capture flag alone
|
||||
* and keep measuring unwrapped statements.
|
||||
*
|
||||
* The count travels over the worker's message port, which is ordered with
|
||||
* the worker's responses, instead of the stdout trace lines Node forwards
|
||||
* asynchronously. Only a number crosses the port: no SQL text and no bound
|
||||
* values.
|
||||
*
|
||||
* Statements are counted at the execution methods of better-sqlite3's
|
||||
* `Statement` prototype rather than through the `verbose` callback behind
|
||||
* the SQL trace: with a callback, better-sqlite3 expands every statement's
|
||||
* SQL and calls into JavaScript with it, which made a 200,000-row insert
|
||||
* two to four times slower and would distort the import benchmarks that run
|
||||
* with the same flag. One call of `run`, `get`, `all` or `iterate` that
|
||||
* returns normally is one statement, which includes pragmas and the
|
||||
* BEGIN/COMMIT that `db.transaction()` prepares internally. One `exec` call
|
||||
* also counts as one: SQL cannot be split into statements reliably here
|
||||
* (trigger bodies contain semicolons), so callers pass one statement per
|
||||
* call. The database worker never calls `exec`, and the shared connection's
|
||||
* historical-upgrade test (`libs/shared/database/src/lib/testing/
|
||||
* connection-upgrade.ts`) fails on a batch. Calls that throw are not
|
||||
* counted: the SQL trace skips the
|
||||
* ones that fail before execution (a migration's `ALTER TABLE` for a column
|
||||
* that already exists), and they did no work.
|
||||
*/
|
||||
export const DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE =
|
||||
'performance-sql-statements';
|
||||
|
||||
export interface DbWorkerSqlStatementsMessage {
|
||||
type: typeof DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SqlStatementCountReporter {
|
||||
/** Counts one statement; it is posted on the next flush. */
|
||||
record(): void;
|
||||
/** Posts the statements recorded since the last flush, if any. */
|
||||
flush(): void;
|
||||
}
|
||||
|
||||
const STATEMENT_EXECUTION_METHODS = ['run', 'get', 'all', 'iterate'] as const;
|
||||
const COUNTED = Symbol.for('iptvnator.sqlStatementCount.counted');
|
||||
|
||||
type ExecutionMethod = ((...args: unknown[]) => unknown) & {
|
||||
[COUNTED]?: true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Coalesces counts into few messages. A flush is queued as a microtask, so
|
||||
* a bulk write posts one message rather than one per row; the worker also
|
||||
* flushes synchronously before posting any other message, so a response can
|
||||
* never overtake the statements that produced it.
|
||||
*/
|
||||
export function createSqlStatementCountReporter(
|
||||
post: (message: DbWorkerSqlStatementsMessage) => void,
|
||||
schedule: (callback: () => void) => void = queueMicrotask
|
||||
): SqlStatementCountReporter {
|
||||
let pending = 0;
|
||||
let scheduled = false;
|
||||
|
||||
const flush = (): void => {
|
||||
scheduled = false;
|
||||
if (pending === 0) {
|
||||
return;
|
||||
}
|
||||
const count = pending;
|
||||
pending = 0;
|
||||
post({ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count });
|
||||
};
|
||||
|
||||
return {
|
||||
record() {
|
||||
pending += 1;
|
||||
if (!scheduled) {
|
||||
scheduled = true;
|
||||
schedule(flush);
|
||||
}
|
||||
},
|
||||
flush,
|
||||
};
|
||||
}
|
||||
|
||||
function wrapExecution(
|
||||
owner: Record<string, unknown>,
|
||||
method: string,
|
||||
record: () => void
|
||||
): (() => void) | null {
|
||||
const original = owner[method] as ExecutionMethod | undefined;
|
||||
if (typeof original !== 'function' || original[COUNTED]) {
|
||||
return null;
|
||||
}
|
||||
const counted: ExecutionMethod = function countedExecution(
|
||||
this: unknown,
|
||||
...args: unknown[]
|
||||
) {
|
||||
const result = original.apply(this, args);
|
||||
record();
|
||||
return result;
|
||||
};
|
||||
counted[COUNTED] = true;
|
||||
owner[method] = counted;
|
||||
return () => {
|
||||
if (owner[method] === counted) {
|
||||
owner[method] = original;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the execution methods of the connection's statements (shared by
|
||||
* every statement of this worker, since they have one prototype) and the
|
||||
* connection's own `exec`. Idempotent, so reopening the connection does not
|
||||
* count twice. Returns a function that removes the wrappers it installed.
|
||||
*/
|
||||
export function countSqlStatementExecutions(
|
||||
connection: BetterSqlite3.Database,
|
||||
record: () => void
|
||||
): () => void {
|
||||
const statementPrototype = Object.getPrototypeOf(
|
||||
connection.prepare('SELECT 1')
|
||||
) as Record<string, unknown>;
|
||||
const restores = [
|
||||
...STATEMENT_EXECUTION_METHODS.map((method) =>
|
||||
wrapExecution(statementPrototype, method, record)
|
||||
),
|
||||
wrapExecution(
|
||||
connection as unknown as Record<string, unknown>,
|
||||
'exec',
|
||||
record
|
||||
),
|
||||
];
|
||||
return () => {
|
||||
for (const restore of restores) {
|
||||
restore?.();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** The count of a well-formed message, or null for anything else. */
|
||||
export function readSqlStatementsMessageCount(message: unknown): number | null {
|
||||
if (typeof message !== 'object' || message === null) {
|
||||
return null;
|
||||
}
|
||||
const { type, count } = message as Partial<DbWorkerSqlStatementsMessage>;
|
||||
return type === DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE &&
|
||||
Number.isSafeInteger(count) &&
|
||||
(count as number) > 0
|
||||
? (count as number)
|
||||
: null;
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { MessageChannel, type MessagePort } from 'node:worker_threads';
|
||||
import { DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE } from './database-worker-sql-statement-count';
|
||||
|
||||
/**
|
||||
* Pins the worker wiring of `database-worker-sql-statement-count.ts`: with
|
||||
* IPTVNATOR_PERF_CAPTURE=1 and IPTVNATOR_PERF_COUNT_SQL=1 the real worker
|
||||
* connection reports its statements over the parent port, with the capture
|
||||
* flag alone (the import benchmarks) or without flags it reports nothing,
|
||||
* and the worker flushes the count before any response it posts.
|
||||
*/
|
||||
const ENV_NAMES = [
|
||||
'IPTVNATOR_PERF_CAPTURE',
|
||||
'IPTVNATOR_PERF_COUNT_SQL',
|
||||
'IPTVNATOR_E2E_DATA_DIR',
|
||||
] as const;
|
||||
const EXECUTION_METHODS = ['run', 'get', 'all', 'iterate'] as const;
|
||||
|
||||
describe('database worker SQL statement count wiring', () => {
|
||||
const originalEnv = ENV_NAMES.map((name) => [name, process.env[name]]);
|
||||
const statementPrototype = (() => {
|
||||
const probe = new Database(':memory:');
|
||||
const prototype = Object.getPrototypeOf(
|
||||
probe.prepare('SELECT 1')
|
||||
) as Record<string, unknown>;
|
||||
probe.close();
|
||||
return prototype;
|
||||
})();
|
||||
const originalMethods = EXECUTION_METHODS.map(
|
||||
(method) => [method, statementPrototype[method]] as const
|
||||
);
|
||||
let ports: MessagePort[] = [];
|
||||
let dataDirectory: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
for (const port of ports) {
|
||||
port.close();
|
||||
}
|
||||
ports = [];
|
||||
// The connection wraps the shared Statement prototype; keep other
|
||||
// spec files in this Jest worker unaffected.
|
||||
for (const [method, original] of originalMethods) {
|
||||
statementPrototype[method] = original;
|
||||
}
|
||||
for (const [name, value] of originalEnv) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name as string];
|
||||
} else {
|
||||
process.env[name as string] = value;
|
||||
}
|
||||
}
|
||||
if (dataDirectory) {
|
||||
rmSync(dataDirectory, { force: true, recursive: true });
|
||||
dataDirectory = null;
|
||||
}
|
||||
jest.restoreAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
function connectPorts(): { messages: unknown[]; workerPort: MessagePort } {
|
||||
const channel = new MessageChannel();
|
||||
ports.push(channel.port1, channel.port2);
|
||||
const messages: unknown[] = [];
|
||||
channel.port2.on('message', (message) => messages.push(message));
|
||||
jest.doMock('worker_threads', () => ({
|
||||
...jest.requireActual('worker_threads'),
|
||||
parentPort: channel.port1,
|
||||
workerData: {},
|
||||
}));
|
||||
return { messages, workerPort: channel.port1 };
|
||||
}
|
||||
|
||||
async function openConnection(
|
||||
flags: Partial<Record<(typeof ENV_NAMES)[number], string>>,
|
||||
expectedMessages: number
|
||||
) {
|
||||
dataDirectory = mkdtempSync(join(tmpdir(), 'iptvnator-sql-count-'));
|
||||
delete process.env['IPTVNATOR_PERF_CAPTURE'];
|
||||
delete process.env['IPTVNATOR_PERF_COUNT_SQL'];
|
||||
Object.assign(process.env, flags);
|
||||
process.env['IPTVNATOR_E2E_DATA_DIR'] = dataDirectory;
|
||||
const { messages } = connectPorts();
|
||||
const connection = await import('./database.worker-connection');
|
||||
await connection.getWorkerDatabase();
|
||||
connection.flushWorkerSqlStatementCount();
|
||||
connection.closeWorkerDatabase();
|
||||
// Port delivery is asynchronous: wait for the expected messages, and
|
||||
// a few more turns so an unexpected extra message is still seen.
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (messages.length < expectedMessages && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return messages;
|
||||
}
|
||||
|
||||
it('reports the connection setup statements when SQL counting is on', async () => {
|
||||
const messages = await openConnection(
|
||||
{ IPTVNATOR_PERF_CAPTURE: '1', IPTVNATOR_PERF_COUNT_SQL: '1' },
|
||||
2
|
||||
);
|
||||
|
||||
// Seven PRAGMAs on open, then `PRAGMA optimize` on close.
|
||||
expect(messages).toEqual([
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 7 },
|
||||
{ type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, count: 1 },
|
||||
]);
|
||||
expect(JSON.stringify(messages)).not.toMatch(/PRAGMA|journal_mode/i);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['without flags', {}],
|
||||
[
|
||||
'with the capture flag alone, as the import benchmarks run',
|
||||
{ IPTVNATOR_PERF_CAPTURE: '1' },
|
||||
],
|
||||
])(
|
||||
'reports nothing and leaves better-sqlite3 alone %s',
|
||||
async (_label, flags) => {
|
||||
const messages = await openConnection(flags, 0);
|
||||
|
||||
expect(messages).toEqual([]);
|
||||
for (const [method, original] of originalMethods) {
|
||||
expect(statementPrototype[method]).toBe(original);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it('flushes the count before the worker posts a response', async () => {
|
||||
const { messages, workerPort } = connectPorts();
|
||||
let pendingStatements = 0;
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(() => {
|
||||
if (pendingStatements > 0) {
|
||||
workerPort.postMessage({
|
||||
type: DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE,
|
||||
count: pendingStatements,
|
||||
});
|
||||
pendingStatements = 0;
|
||||
}
|
||||
}),
|
||||
getWorkerDatabase: jest.fn(async () => {
|
||||
pendingStatements = 2;
|
||||
return {};
|
||||
}),
|
||||
}));
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
await import('./database.worker');
|
||||
ports[1].postMessage({
|
||||
type: 'request',
|
||||
operation: 'DB_GET_APP_STATE',
|
||||
payload: { key: 'sql-count-wiring' },
|
||||
requestId: 'request-sql-count',
|
||||
});
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (
|
||||
!messages.some(
|
||||
(message) => (message as { type?: string }).type === 'response'
|
||||
)
|
||||
) {
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error('database worker did not respond');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
expect(
|
||||
messages.map((message) => (message as { type: string }).type)
|
||||
).toEqual(['ready', DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE, 'response']);
|
||||
});
|
||||
});
|
||||
@@ -82,6 +82,7 @@ describe('database worker zero-delay cancellation', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.doMock('../database/operations/content.operations', () => ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WorkerPerformanceCaptureResult } from './worker-performance-capture';
|
||||
import type { DbWorkerSqlStatementsMessage } from './database-worker-sql-statement-count';
|
||||
|
||||
export const DB_WORKER_OPERATIONS = [
|
||||
'DB_HAS_CATEGORIES',
|
||||
@@ -177,4 +178,5 @@ export type DbWorkerMessage =
|
||||
| DbWorkerReadyMessage
|
||||
| DbWorkerEventMessage
|
||||
| DbWorkerPerformanceCancelReceivedMessage
|
||||
| DbWorkerSqlStatementsMessage
|
||||
| DbWorkerResponseMessage;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
import * as schema from '@iptvnator/shared/database/schema';
|
||||
import { getIptvnatorDatabasePath } from '@iptvnator/shared/database/path-utils';
|
||||
import { workerData } from 'worker_threads';
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import type { AppDatabase } from '../database/database.types';
|
||||
import {
|
||||
getNativeModuleSearchPaths,
|
||||
@@ -10,10 +10,15 @@ import {
|
||||
registerNativeModuleSearchPaths,
|
||||
} from './worker-runtime-paths';
|
||||
import {
|
||||
isSqlStatementCountEnabled,
|
||||
isSqlTraceEnabled,
|
||||
trace,
|
||||
traceSqlStatement,
|
||||
} from '../services/debug-trace';
|
||||
import {
|
||||
countSqlStatementExecutions,
|
||||
createSqlStatementCountReporter,
|
||||
} from './database-worker-sql-statement-count';
|
||||
|
||||
let drizzleFactory:
|
||||
| (typeof import('drizzle-orm/better-sqlite3'))['drizzle']
|
||||
@@ -59,6 +64,22 @@ const Database = loadBetterSqlite3();
|
||||
let db: AppDatabase | null = null;
|
||||
let sqlite: BetterSqlite3.Database | null = null;
|
||||
|
||||
// IPTVNATOR_PERF_CAPTURE=1 with IPTVNATOR_PERF_COUNT_SQL=1 only: the main
|
||||
// process keeps the running total.
|
||||
const sqlStatementCount = isSqlStatementCountEnabled()
|
||||
? createSqlStatementCountReporter((message) =>
|
||||
parentPort?.postMessage(message)
|
||||
)
|
||||
: null;
|
||||
|
||||
/**
|
||||
* Posts the statements counted since the last flush. The worker calls this
|
||||
* before every other message so a response never overtakes its statements.
|
||||
*/
|
||||
export function flushWorkerSqlStatementCount(): void {
|
||||
sqlStatementCount?.flush();
|
||||
}
|
||||
|
||||
export async function getWorkerDatabase(): Promise<AppDatabase> {
|
||||
if (db) {
|
||||
return db;
|
||||
@@ -70,6 +91,9 @@ export async function getWorkerDatabase(): Promise<AppDatabase> {
|
||||
? (sql: string) => traceSqlStatement('sql-worker', sql)
|
||||
: undefined,
|
||||
});
|
||||
if (sqlStatementCount) {
|
||||
countSqlStatementExecutions(sqlite, sqlStatementCount.record);
|
||||
}
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('busy_timeout = 5000');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { migrateAppPlaylists } from '../database/operations/playlist-migration.operations';
|
||||
import {
|
||||
closeWorkerDatabase,
|
||||
flushWorkerSqlStatementCount,
|
||||
getWorkerDatabase,
|
||||
} from './database.worker-connection';
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
@@ -218,6 +219,7 @@ function serializeError(error: unknown) {
|
||||
}
|
||||
|
||||
function postMessage(message: DbWorkerMessage): void {
|
||||
flushWorkerSqlStatementCount();
|
||||
parentPort?.postMessage(message);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ describe('worker cancellation while performance capture arms', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.doMock('../database/operations/content.operations', () => ({
|
||||
@@ -219,6 +220,7 @@ describe('worker cancellation while performance capture arms', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.doMock('../database/operations/content.operations', () => ({
|
||||
@@ -288,6 +290,7 @@ describe('worker cancellation while performance capture arms', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
jest.doMock('../database/operations/playlist.operations', () => ({
|
||||
@@ -353,6 +356,7 @@ describe('database worker performance control messages', () => {
|
||||
}));
|
||||
jest.doMock('./database.worker-connection', () => ({
|
||||
closeWorkerDatabase: jest.fn(),
|
||||
flushWorkerSqlStatementCount: jest.fn(),
|
||||
getWorkerDatabase,
|
||||
}));
|
||||
jest.doMock('./database-worker-post-gc-heap', () => ({
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
// Select persistence before eager imports (notably electron-conf) cache userData.
|
||||
import './app/services/electron-profile-bootstrap';
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import App from './app/app';
|
||||
import PlaylistOpenEvents from './app/events/playlist-open.events';
|
||||
import SquirrelEvents from './app/events/squirrel.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import {
|
||||
isPerformanceCaptureEnabled,
|
||||
isSqlStatementCountEnabled,
|
||||
isStartupTraceEnabled,
|
||||
performanceCounters,
|
||||
traceStartupPhase,
|
||||
} from './app/services/debug-trace';
|
||||
import { registerPerformanceCountersHandler } from './app/services/performance-counters';
|
||||
import { countMainProcessSqlStatements } from './app/services/main-sql-statement-count';
|
||||
import { readCompileCacheOutcome } from './app/services/compile-cache';
|
||||
import { applyElectronNetworkDefaults } from './app/util/network-defaults';
|
||||
import { registerStaticHeaderShims } from './app/services/request-header-overrides.service';
|
||||
@@ -35,9 +43,12 @@ import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service';
|
||||
|
||||
app.setName('iptvnator');
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'compile-cache', readCompileCacheOutcome());
|
||||
}
|
||||
traceStartupPhase('compile-cache', () => readCompileCacheOutcome());
|
||||
// Before anything can open the shared database connection.
|
||||
countMainProcessSqlStatements(
|
||||
performanceCounters,
|
||||
isSqlStatementCountEnabled()
|
||||
);
|
||||
|
||||
// Before the first portal, playlist or update request leaves this process.
|
||||
applyElectronNetworkDefaults((line) => {
|
||||
@@ -92,9 +103,7 @@ export default class Main {
|
||||
}
|
||||
|
||||
static bootstrapApp() {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-app');
|
||||
}
|
||||
traceStartupPhase('bootstrap-app');
|
||||
App.main(app, BrowserWindow);
|
||||
}
|
||||
|
||||
@@ -106,9 +115,13 @@ export default class Main {
|
||||
* still guarantees the handlers exist before any renderer invoke).
|
||||
*/
|
||||
static async bootstrapAppEvents() {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-events:start');
|
||||
}
|
||||
traceStartupPhase('bootstrap-events:start');
|
||||
// Only with IPTVNATOR_PERF_CAPTURE=1; the preload never exposes it.
|
||||
registerPerformanceCountersHandler(
|
||||
ipcMain,
|
||||
performanceCounters,
|
||||
isPerformanceCaptureEnabled()
|
||||
);
|
||||
|
||||
const windowCloseGuard = bootstrapWindowCloseGuard((listener) =>
|
||||
App.onMainWindowCreated(listener)
|
||||
@@ -131,14 +144,14 @@ export default class Main {
|
||||
windowCloseGuard,
|
||||
}),
|
||||
onTrigger: (source) => {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'deferred-events:start', { source });
|
||||
}
|
||||
traceStartupPhase('deferred-events:start', () => ({
|
||||
source,
|
||||
}));
|
||||
},
|
||||
onDone: (durationMs) => {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'deferred-events:done', { durationMs });
|
||||
}
|
||||
traceStartupPhase('deferred-events:done', () => ({
|
||||
durationMs,
|
||||
}));
|
||||
},
|
||||
// The window is open by now; without this a missing chunk would
|
||||
// only show up as an unhandled rejection with no context.
|
||||
@@ -147,9 +160,7 @@ export default class Main {
|
||||
'Deferred main-process startup failed; portal, EPG, database and download handlers are unavailable:',
|
||||
error
|
||||
);
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'deferred-events:failed', error);
|
||||
}
|
||||
traceStartupPhase('deferred-events:failed', () => error);
|
||||
},
|
||||
});
|
||||
deferredEvents = deferred;
|
||||
@@ -170,9 +181,7 @@ export default class Main {
|
||||
|
||||
await module.finishStartupAfterFirstLoad();
|
||||
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'bootstrap-events:done');
|
||||
}
|
||||
traceStartupPhase('bootstrap-events:done');
|
||||
|
||||
// Hydrate process.env.PATH from the user's login shell now — after
|
||||
// the window has loaded and IPC handlers are live. Fire-and-forget
|
||||
@@ -239,9 +248,7 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => {
|
||||
|
||||
// Bootstrap app events after Electron app is ready
|
||||
app.whenReady().then(async () => {
|
||||
if (isStartupTraceEnabled()) {
|
||||
trace('startup', 'app.whenReady');
|
||||
}
|
||||
traceStartupPhase('app.whenReady');
|
||||
await Main.bootstrapAppEvents();
|
||||
});
|
||||
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "تلفزيون مباشر شوهد مؤخرًا",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "الأفلام والمسلسلات المفضلة",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "ينتهي خلال {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "منتهي"
|
||||
"SOURCE_EXPIRED": "منتهي",
|
||||
"HERO_LABEL": "مميز",
|
||||
"HERO_FAVORITE_CHANNEL": "قناة مفضلة",
|
||||
"HERO_FAVORITE": "مفضل",
|
||||
"HERO_RECENTLY_ADDED": "أضيف مؤخرًا",
|
||||
"HERO_CONTINUE": "متابعة",
|
||||
"HERO_WATCH_LIVE": "شاهد مباشرة",
|
||||
"HERO_DETAILS": "التفاصيل",
|
||||
"HERO_SLIDE_POSITION": "{{index}} من {{count}}",
|
||||
"HERO_SHOW_SLIDE": "عرض {{title}}",
|
||||
"HERO_PAUSE": "إيقاف العرض مؤقتًا",
|
||||
"HERO_PLAY": "تشغيل العرض"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "التلفزة المباشرة المتشافة مؤخراً",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "الأفلام والسلاسل المفضلة",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "كتسالي ف {{days}} يوم",
|
||||
"SOURCE_EXPIRED": "سالات"
|
||||
"SOURCE_EXPIRED": "سالات",
|
||||
"HERO_LABEL": "مختارين",
|
||||
"HERO_FAVORITE_CHANNEL": "قناة مفضلة",
|
||||
"HERO_FAVORITE": "مفضل",
|
||||
"HERO_RECENTLY_ADDED": "تزاد جديد",
|
||||
"HERO_CONTINUE": "كمّل",
|
||||
"HERO_WATCH_LIVE": "تفرج مباشر",
|
||||
"HERO_DETAILS": "التفاصيل",
|
||||
"HERO_SLIDE_POSITION": "{{index}} من {{count}}",
|
||||
"HERO_SHOW_SLIDE": "وري {{title}}",
|
||||
"HERO_PAUSE": "وقف العرض",
|
||||
"HERO_PLAY": "شغّل العرض"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Нядаўна прагледжанае жывое ТБ",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Любімыя фільмы і серыялы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Скончыцца праз {{days}} дз.",
|
||||
"SOURCE_EXPIRED": "Скончыўся"
|
||||
"SOURCE_EXPIRED": "Скончыўся",
|
||||
"HERO_LABEL": "Рэкамендаванае",
|
||||
"HERO_FAVORITE_CHANNEL": "Абраны канал",
|
||||
"HERO_FAVORITE": "Абранае",
|
||||
"HERO_RECENTLY_ADDED": "Нядаўна дададзена",
|
||||
"HERO_CONTINUE": "Працягнуць",
|
||||
"HERO_WATCH_LIVE": "Глядзець эфір",
|
||||
"HERO_DETAILS": "Падрабязней",
|
||||
"HERO_SLIDE_POSITION": "{{index}} з {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Паказаць {{title}}",
|
||||
"HERO_PAUSE": "Прыпыніць паказ",
|
||||
"HERO_PLAY": "Аднавіць паказ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Kürzlich angesehenes Live-TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorisierte Filme und Serien",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Läuft in {{days}} T. ab",
|
||||
"SOURCE_EXPIRED": "Abgelaufen"
|
||||
"SOURCE_EXPIRED": "Abgelaufen",
|
||||
"HERO_LABEL": "Empfohlen",
|
||||
"HERO_FAVORITE_CHANNEL": "Lieblingssender",
|
||||
"HERO_FAVORITE": "Favorit",
|
||||
"HERO_RECENTLY_ADDED": "Kürzlich hinzugefügt",
|
||||
"HERO_CONTINUE": "Fortsetzen",
|
||||
"HERO_WATCH_LIVE": "Live ansehen",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} von {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} anzeigen",
|
||||
"HERO_PAUSE": "Diashow anhalten",
|
||||
"HERO_PLAY": "Diashow abspielen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Ζωντανή TV που παρακολουθήθηκε πρόσφατα",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Αγαπημένες ταινίες και σειρές",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Λήγει σε {{days}} ημ.",
|
||||
"SOURCE_EXPIRED": "Έληξε"
|
||||
"SOURCE_EXPIRED": "Έληξε",
|
||||
"HERO_LABEL": "Προτεινόμενα",
|
||||
"HERO_FAVORITE_CHANNEL": "Αγαπημένο κανάλι",
|
||||
"HERO_FAVORITE": "Αγαπημένο",
|
||||
"HERO_RECENTLY_ADDED": "Προστέθηκε πρόσφατα",
|
||||
"HERO_CONTINUE": "Συνέχεια",
|
||||
"HERO_WATCH_LIVE": "Δείτε ζωντανά",
|
||||
"HERO_DETAILS": "Λεπτομέρειες",
|
||||
"HERO_SLIDE_POSITION": "{{index}} από {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Εμφάνιση {{title}}",
|
||||
"HERO_PAUSE": "Παύση προβολής",
|
||||
"HERO_PLAY": "Αναπαραγωγή προβολής"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recently watched live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favorite movies & series",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expires in {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expired"
|
||||
"SOURCE_EXPIRED": "Expired",
|
||||
"HERO_LABEL": "Featured",
|
||||
"HERO_FAVORITE_CHANNEL": "Favorite channel",
|
||||
"HERO_FAVORITE": "Favorite",
|
||||
"HERO_RECENTLY_ADDED": "Recently added",
|
||||
"HERO_CONTINUE": "Continue",
|
||||
"HERO_WATCH_LIVE": "Watch live",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} of {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Show {{title}}",
|
||||
"HERO_PAUSE": "Pause slideshow",
|
||||
"HERO_PLAY": "Play slideshow"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV en directo vista recientemente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Películas y series favoritas",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Caduca en {{days}} d",
|
||||
"SOURCE_EXPIRED": "Caducada"
|
||||
"SOURCE_EXPIRED": "Caducada",
|
||||
"HERO_LABEL": "Destacado",
|
||||
"HERO_FAVORITE_CHANNEL": "Canal favorito",
|
||||
"HERO_FAVORITE": "Favorito",
|
||||
"HERO_RECENTLY_ADDED": "Añadido recientemente",
|
||||
"HERO_CONTINUE": "Continuar",
|
||||
"HERO_WATCH_LIVE": "Ver en directo",
|
||||
"HERO_DETAILS": "Detalles",
|
||||
"HERO_SLIDE_POSITION": "{{index}} de {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostrar {{title}}",
|
||||
"HERO_PAUSE": "Pausar presentación",
|
||||
"HERO_PLAY": "Reproducir presentación"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV en direct récemment regardée",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Films et séries favoris",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expire dans {{days}} j",
|
||||
"SOURCE_EXPIRED": "Expiré"
|
||||
"SOURCE_EXPIRED": "Expiré",
|
||||
"HERO_LABEL": "À la une",
|
||||
"HERO_FAVORITE_CHANNEL": "Chaîne favorite",
|
||||
"HERO_FAVORITE": "Favori",
|
||||
"HERO_RECENTLY_ADDED": "Ajouté récemment",
|
||||
"HERO_CONTINUE": "Reprendre",
|
||||
"HERO_WATCH_LIVE": "Regarder en direct",
|
||||
"HERO_DETAILS": "Détails",
|
||||
"HERO_SLIDE_POSITION": "{{index}} sur {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Afficher {{title}}",
|
||||
"HERO_PAUSE": "Mettre le diaporama en pause",
|
||||
"HERO_PLAY": "Lancer le diaporama"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Legutóbb nézett élő TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Kedvenc filmek és sorozatok",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} nap múlva lejár",
|
||||
"SOURCE_EXPIRED": "Lejárt"
|
||||
"SOURCE_EXPIRED": "Lejárt",
|
||||
"HERO_LABEL": "Kiemelt",
|
||||
"HERO_FAVORITE_CHANNEL": "Kedvenc csatorna",
|
||||
"HERO_FAVORITE": "Kedvenc",
|
||||
"HERO_RECENTLY_ADDED": "Nemrég hozzáadva",
|
||||
"HERO_CONTINUE": "Folytatás",
|
||||
"HERO_WATCH_LIVE": "Élő adás nézése",
|
||||
"HERO_DETAILS": "Részletek",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} megjelenítése",
|
||||
"HERO_PAUSE": "Diavetítés szüneteltetése",
|
||||
"HERO_PLAY": "Diavetítés lejátszása"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV in diretta guardata di recente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Film e serie preferiti",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Scade tra {{days}} g",
|
||||
"SOURCE_EXPIRED": "Scaduto"
|
||||
"SOURCE_EXPIRED": "Scaduto",
|
||||
"HERO_LABEL": "In evidenza",
|
||||
"HERO_FAVORITE_CHANNEL": "Canale preferito",
|
||||
"HERO_FAVORITE": "Preferito",
|
||||
"HERO_RECENTLY_ADDED": "Aggiunto di recente",
|
||||
"HERO_CONTINUE": "Riprendi",
|
||||
"HERO_WATCH_LIVE": "Guarda in diretta",
|
||||
"HERO_DETAILS": "Dettagli",
|
||||
"HERO_SLIDE_POSITION": "{{index}} di {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostra {{title}}",
|
||||
"HERO_PAUSE": "Metti in pausa la presentazione",
|
||||
"HERO_PLAY": "Avvia la presentazione"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近視聴したライブ TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "お気に入りの映画とシリーズ",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "あと{{days}}日で期限切れ",
|
||||
"SOURCE_EXPIRED": "期限切れ"
|
||||
"SOURCE_EXPIRED": "期限切れ",
|
||||
"HERO_LABEL": "おすすめ",
|
||||
"HERO_FAVORITE_CHANNEL": "お気に入りチャンネル",
|
||||
"HERO_FAVORITE": "お気に入り",
|
||||
"HERO_RECENTLY_ADDED": "最近追加",
|
||||
"HERO_CONTINUE": "続きを再生",
|
||||
"HERO_WATCH_LIVE": "ライブで見る",
|
||||
"HERO_DETAILS": "詳細",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} を表示",
|
||||
"HERO_PAUSE": "スライドショーを一時停止",
|
||||
"HERO_PLAY": "スライドショーを再生"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "최근 시청한 라이브 TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "즐겨찾기한 영화 및 시리즈",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}}일 후 만료",
|
||||
"SOURCE_EXPIRED": "만료됨"
|
||||
"SOURCE_EXPIRED": "만료됨",
|
||||
"HERO_LABEL": "추천",
|
||||
"HERO_FAVORITE_CHANNEL": "즐겨찾는 채널",
|
||||
"HERO_FAVORITE": "즐겨찾기",
|
||||
"HERO_RECENTLY_ADDED": "최근 추가됨",
|
||||
"HERO_CONTINUE": "이어 보기",
|
||||
"HERO_WATCH_LIVE": "실시간 보기",
|
||||
"HERO_DETAILS": "상세 정보",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} 보기",
|
||||
"HERO_PAUSE": "슬라이드쇼 일시정지",
|
||||
"HERO_PLAY": "슬라이드쇼 재생"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Recent bekeken live-tv",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favoriete films en reeksen",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Verloopt over {{days}} d",
|
||||
"SOURCE_EXPIRED": "Verlopen"
|
||||
"SOURCE_EXPIRED": "Verlopen",
|
||||
"HERO_LABEL": "Uitgelicht",
|
||||
"HERO_FAVORITE_CHANNEL": "Favoriete zender",
|
||||
"HERO_FAVORITE": "Favoriet",
|
||||
"HERO_RECENTLY_ADDED": "Recent toegevoegd",
|
||||
"HERO_CONTINUE": "Verder kijken",
|
||||
"HERO_WATCH_LIVE": "Live kijken",
|
||||
"HERO_DETAILS": "Details",
|
||||
"HERO_SLIDE_POSITION": "{{index}} van {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} tonen",
|
||||
"HERO_PAUSE": "Diavoorstelling pauzeren",
|
||||
"HERO_PLAY": "Diavoorstelling afspelen"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Ostatnio oglądana telewizja na żywo",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Ulubione filmy i seriale",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Wygasa za {{days}} dn.",
|
||||
"SOURCE_EXPIRED": "Wygasło"
|
||||
"SOURCE_EXPIRED": "Wygasło",
|
||||
"HERO_LABEL": "Polecane",
|
||||
"HERO_FAVORITE_CHANNEL": "Ulubiony kanał",
|
||||
"HERO_FAVORITE": "Ulubione",
|
||||
"HERO_RECENTLY_ADDED": "Ostatnio dodane",
|
||||
"HERO_CONTINUE": "Kontynuuj",
|
||||
"HERO_WATCH_LIVE": "Oglądaj na żywo",
|
||||
"HERO_DETAILS": "Szczegóły",
|
||||
"HERO_SLIDE_POSITION": "{{index}} z {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Pokaż {{title}}",
|
||||
"HERO_PAUSE": "Wstrzymaj pokaz",
|
||||
"HERO_PLAY": "Odtwórz pokaz"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "TV ao vivo assistida recentemente",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Filmes e séries favoritos",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Expira em {{days}} d",
|
||||
"SOURCE_EXPIRED": "Expirada"
|
||||
"SOURCE_EXPIRED": "Expirada",
|
||||
"HERO_LABEL": "Em destaque",
|
||||
"HERO_FAVORITE_CHANNEL": "Canal favorito",
|
||||
"HERO_FAVORITE": "Favorito",
|
||||
"HERO_RECENTLY_ADDED": "Adicionado recentemente",
|
||||
"HERO_CONTINUE": "Continuar",
|
||||
"HERO_WATCH_LIVE": "Assistir ao vivo",
|
||||
"HERO_DETAILS": "Detalhes",
|
||||
"HERO_SLIDE_POSITION": "{{index}} de {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Mostrar {{title}}",
|
||||
"HERO_PAUSE": "Pausar apresentação",
|
||||
"HERO_PLAY": "Reproduzir apresentação"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Недавно просмотренное Live TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Избранные фильмы и сериалы",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "Истекает через {{days}} дн.",
|
||||
"SOURCE_EXPIRED": "Истёк"
|
||||
"SOURCE_EXPIRED": "Истёк",
|
||||
"HERO_LABEL": "Рекомендуемое",
|
||||
"HERO_FAVORITE_CHANNEL": "Избранный канал",
|
||||
"HERO_FAVORITE": "Избранное",
|
||||
"HERO_RECENTLY_ADDED": "Недавно добавлено",
|
||||
"HERO_CONTINUE": "Продолжить",
|
||||
"HERO_WATCH_LIVE": "Смотреть эфир",
|
||||
"HERO_DETAILS": "Подробнее",
|
||||
"HERO_SLIDE_POSITION": "{{index}} из {{count}}",
|
||||
"HERO_SHOW_SLIDE": "Показать {{title}}",
|
||||
"HERO_PAUSE": "Приостановить показ",
|
||||
"HERO_PLAY": "Возобновить показ"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "Son izlenen canlı TV",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "Favori filmler ve diziler",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} gün içinde sona erer",
|
||||
"SOURCE_EXPIRED": "Süresi doldu"
|
||||
"SOURCE_EXPIRED": "Süresi doldu",
|
||||
"HERO_LABEL": "Öne çıkanlar",
|
||||
"HERO_FAVORITE_CHANNEL": "Favori kanal",
|
||||
"HERO_FAVORITE": "Favori",
|
||||
"HERO_RECENTLY_ADDED": "Yeni eklendi",
|
||||
"HERO_CONTINUE": "Devam et",
|
||||
"HERO_WATCH_LIVE": "Canlı izle",
|
||||
"HERO_DETAILS": "Ayrıntılar",
|
||||
"HERO_SLIDE_POSITION": "{{index}} / {{count}}",
|
||||
"HERO_SHOW_SLIDE": "{{title}} göster",
|
||||
"HERO_PAUSE": "Slayt gösterisini duraklat",
|
||||
"HERO_PLAY": "Slayt gösterisini oynat"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近观看的直播电视",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "收藏的电影和剧集",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天后到期",
|
||||
"SOURCE_EXPIRED": "已过期"
|
||||
"SOURCE_EXPIRED": "已过期",
|
||||
"HERO_LABEL": "精选",
|
||||
"HERO_FAVORITE_CHANNEL": "收藏频道",
|
||||
"HERO_FAVORITE": "收藏",
|
||||
"HERO_RECENTLY_ADDED": "最近添加",
|
||||
"HERO_CONTINUE": "继续观看",
|
||||
"HERO_WATCH_LIVE": "观看直播",
|
||||
"HERO_DETAILS": "详情",
|
||||
"HERO_SLIDE_POSITION": "第 {{index}} 项,共 {{count}} 项",
|
||||
"HERO_SHOW_SLIDE": "显示 {{title}}",
|
||||
"HERO_PAUSE": "暂停轮播",
|
||||
"HERO_PLAY": "播放轮播"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -1563,7 +1563,18 @@
|
||||
"RECENTLY_WATCHED_LIVE_TV": "最近觀看的直播電視",
|
||||
"FAVORITE_MOVIES_AND_SERIES": "收藏的電影和影集",
|
||||
"SOURCE_EXPIRES_IN_DAYS": "{{days}} 天後到期",
|
||||
"SOURCE_EXPIRED": "已過期"
|
||||
"SOURCE_EXPIRED": "已過期",
|
||||
"HERO_LABEL": "精選",
|
||||
"HERO_FAVORITE_CHANNEL": "收藏頻道",
|
||||
"HERO_FAVORITE": "收藏",
|
||||
"HERO_RECENTLY_ADDED": "最近新增",
|
||||
"HERO_CONTINUE": "繼續觀看",
|
||||
"HERO_WATCH_LIVE": "觀看直播",
|
||||
"HERO_DETAILS": "詳細資訊",
|
||||
"HERO_SLIDE_POSITION": "第 {{index}} 項,共 {{count}} 項",
|
||||
"HERO_SHOW_SLIDE": "顯示 {{title}}",
|
||||
"HERO_PAUSE": "暫停輪播",
|
||||
"HERO_PLAY": "播放輪播"
|
||||
},
|
||||
"SHELL": {
|
||||
"BRAND_ALT": "IPTVnator",
|
||||
|
||||
@@ -63,7 +63,8 @@ longer in the DOM, and a source card has a non-empty client rect. Counters are
|
||||
frozen at that microtask checkpoint, so bridge calls and mutations issued
|
||||
later in the same task are included and everything after it is not.
|
||||
|
||||
Three test-side pieces are injected; production code is not changed:
|
||||
Three test-side pieces are injected. The app itself only contributes the
|
||||
main-process counters below, which exist only with `IPTVNATOR_PERF_CAPTURE=1`:
|
||||
|
||||
- `journey-renderer-gate.cjs` is loaded into the main process with `-r`, the
|
||||
mechanism Playwright uses for its own loader. Playwright resolves
|
||||
@@ -72,7 +73,15 @@ Three test-side pieces are injected; production code is not changed:
|
||||
registered afterwards would race the first document. The gate makes the
|
||||
first `loadFile` navigate to `about:blank` and holds the real load until
|
||||
the test releases it. A 15 s safety timeout releases it on its own and the
|
||||
iteration is then invalid.
|
||||
iteration is then invalid. Electron emits `ready-to-show` for the first
|
||||
paint of a hidden window, and `about:blank` paints too, so the gate drops
|
||||
that event while the window shows `about:blank`; otherwise the app would
|
||||
show a blank window and freeze its `ready-to-show` counter before its own
|
||||
document exists. Electron emits the event again for the real document's
|
||||
first paint because the window is still hidden, which is the moment
|
||||
production sees. The gate also keeps the listener the app registers with
|
||||
`ipcMain.handle('performance:read-counters')`, so the test can call it from
|
||||
the main process.
|
||||
- `journey-renderer-probe.ts` is registered with `addInitScript` on that
|
||||
`about:blank` page, so it runs at the start of the real document. It
|
||||
records that it ran while the document was still `loading` with zero
|
||||
@@ -92,12 +101,60 @@ Three test-side pieces are injected; production code is not changed:
|
||||
| `renderer.layoutShiftScore` | Sum of `layout-shift` entries with `hadRecentInput === false`, rounded to three decimals (a shift of 0.0001 flips in and out of the cutoff between runs; the CLS "good" threshold is 0.1, so three decimals keep the counter exact without hiding anything a user could see). The cutoff is sampled in a timer queued from the first `requestAnimationFrame` after the terminal batch, that is after the frame that paints the card has been committed; entries delivered live after the terminal batch are buffered and filtered by the same cutoff. |
|
||||
| `renderer.longTasks` | `longtask` entries over 50 ms up to that same cutoff, which includes the task that rendered the card. The count depends on machine speed, so it is evidence until a run shows it is stable on the CI runner. |
|
||||
|
||||
#### Main-process counters
|
||||
|
||||
With `IPTVNATOR_PERF_CAPTURE=1`, which the journey sets,
|
||||
`apps/electron-backend/src/app/services/debug-trace.ts` keeps named counters
|
||||
in the main process (`services/performance-counters.ts`) and `main.ts`
|
||||
registers the `performance:read-counters` IPC handler. Without the flag
|
||||
nothing is counted, no listener is attached and the handler does not exist;
|
||||
the preload never exposes the channel. SQL statements are counted only with
|
||||
`IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement
|
||||
execution: the journey sets both, while the M3U, refresh and Xtream
|
||||
benchmarks run with the capture flag alone and keep measuring unwrapped
|
||||
statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes,
|
||||
`journey-main-counters.ts` calls the handler through `electronApp.evaluate`
|
||||
and the gate's tap.
|
||||
|
||||
| Counter | Source |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `main.modulesRegisteredBeforeWindow` | `main.startupPhases`, one per `traceStartupPhase` call (the phases printed as `[startup]` trace lines), frozen right after the first main window is constructed. |
|
||||
| `main.sqlStatementsBeforeReadyToShow` | `main.sqlStatements`, frozen at the first main window's `ready-to-show`. It counts the statements of the main-thread connection (`sql-main`, schema creation and migrations) and of the database worker, which posts its count over its message port ([DB worker](sqlite-db-worker.md)). |
|
||||
|
||||
Main-thread statements are counted synchronously. The worker flushes its
|
||||
count before every other message it posts, so every worker statement whose
|
||||
response the main process has handled is included. The worker count is
|
||||
ordered against the worker's responses, not against wall-clock: statements
|
||||
whose count is still in flight when `ready-to-show` is dispatched are not.
|
||||
One call of `run`, `get`, `all`, `iterate` or `exec` that returns normally is
|
||||
one statement; on the launch workloads this matches the number of SQL trace
|
||||
lines exactly. An `exec` with several statements would count as one, so the
|
||||
shared connection passes one statement per call, and its historical-upgrade
|
||||
test fails on a batch.
|
||||
|
||||
`main.sqlStatementsBeforeReadyToShow` is not yet deterministic. The main
|
||||
thread runs the shared connection's schema creation and migrations (about 90
|
||||
statements on the J1 profile) in one synchronous block after the load event,
|
||||
and `ready-to-show` is dispatched after it. The stale-download and
|
||||
stale-recording recovery that follows (one statement each) races the event,
|
||||
so iterations differ by two and the summary marks the counter
|
||||
`stable: false`. The database worker runs no statement before the first
|
||||
paint.
|
||||
Each frozen counter carries its epoch. The record refuses an iteration whose
|
||||
window counter was frozen after the gate saw the first load, or whose
|
||||
`ready-to-show` counter was frozen before the gate released the real
|
||||
document. Running totals at read time are kept under
|
||||
`evidence.mainCountersAtRead`, the freeze epochs under
|
||||
`evidence.epochs.mainWindowCreated` and `evidence.epochs.mainReadyToShow`, and
|
||||
the number of dropped blank `ready-to-show` events under
|
||||
`evidence.rendererGateReadyToShowHeldOnBlank`.
|
||||
|
||||
Counters are exact: the summary carries the value shared by every measured
|
||||
iteration. When iterations disagree, the summary reports the maximum and marks
|
||||
the counter `stable: false` under `counterStability`; such a counter is not
|
||||
promoted to a guardrail until it is deterministic.
|
||||
|
||||
Two counters from the plan are listed under `unavailable` with the reason
|
||||
One counter from the plan is listed under `unavailable` with the reason
|
||||
instead of being faked:
|
||||
|
||||
- `renderer.cdTicksToFirstCard`: the `electron-performance` build optimizes
|
||||
@@ -105,10 +162,6 @@ instead of being faked:
|
||||
`window.ng` and `ɵsetProfiler` is unavailable. The probe checks this at the
|
||||
terminal moment and the record refuses a build where the hook exists but was
|
||||
not counted.
|
||||
- `main.sqlStatementsBeforeReadyToShow`: SQL statements are only visible as
|
||||
worker-thread trace lines on stdout, which Node forwards asynchronously, so
|
||||
they cannot be ordered against `ready-to-show`. Plan item A2 adds a channel
|
||||
that can be counted.
|
||||
|
||||
### Wall-clock
|
||||
|
||||
|
||||
@@ -444,6 +444,29 @@ first, this receipt remains distinct from the later authoritative
|
||||
exposing the pending request. Disabled profiling performs no receipt clock or
|
||||
transport work, and `DatabaseWorkerClient.cancel()` remains fire-and-return.
|
||||
|
||||
With `IPTVNATOR_PERF_CAPTURE=1` and `IPTVNATOR_PERF_COUNT_SQL=1`, the worker
|
||||
connection also counts the SQL statements it executes and posts `performance-sql-statements` messages that
|
||||
carry only a positive count, never SQL text or bound values. Counts are
|
||||
coalesced per microtask and flushed before every other worker message, so a
|
||||
response never overtakes the statements that produced it.
|
||||
`DatabaseWorkerClient` adds them to the main-process `main.sqlStatements`
|
||||
counter and settles nothing. Statements are counted by wrapping the
|
||||
execution methods of better-sqlite3's `Statement` prototype and the
|
||||
connection's `exec`, not through the `verbose` callback: a callback makes
|
||||
better-sqlite3 expand every statement's SQL, which made bulk inserts two to
|
||||
four times slower. Counting still adds a JavaScript call per row of a bulk
|
||||
insert, so it needs `IPTVNATOR_PERF_COUNT_SQL` on top of the capture flag:
|
||||
only the launch journey sets it, and the import benchmarks that run with the
|
||||
capture flag measure unwrapped statements. A call that throws is not counted, which matches the SQL
|
||||
trace for statements that fail before execution. One `exec` call counts as
|
||||
one statement, so initialization passes one statement per call; the
|
||||
historical-upgrade test enforces it. The main process counts its
|
||||
own shared connection the same way (`services/main-sql-statement-count.ts`,
|
||||
through the shared library's connection observer). Without the flag both
|
||||
connections are opened unchanged. See
|
||||
`workers/database-worker-sql-statement-count.ts` and
|
||||
[performance journeys](performance-journeys.md).
|
||||
|
||||
## Renderer Contract
|
||||
|
||||
The preload bridge keeps the existing database methods but adds scoped worker
|
||||
|
||||
@@ -733,15 +733,15 @@ since shipped.)
|
||||
does not latch and retries instead. Same gating as trending: TMDB
|
||||
opt-in + Electron DB worker, deferred behind the dashboard's own data.
|
||||
- **Hero extras**: `DashboardHeroTmdbService`
|
||||
(`libs/workspace/dashboard/feature`) patches the hero card with a TMDB
|
||||
backdrop (when the activity row has none), a rating badge and up to two
|
||||
genre chips — resolved through the enrichment facade, so items already
|
||||
opened in a detail view come from the SQLite cache without network.
|
||||
Results are memoized per lookup identity for the session. The hero renders
|
||||
immediately from provider data; extras appear when resolved. Series
|
||||
heroes additionally show the tracked "S{n}·E{n}" badge from the playback
|
||||
position (no TMDB involved); the watch-progress bar is limited to
|
||||
movie/series heroes.
|
||||
(`libs/workspace/dashboard/feature`) patches each movie/series hero slide
|
||||
with a TMDB backdrop (when the activity row has none), a rating badge, up
|
||||
to two genre chips, the overview and the release/first-air year — resolved
|
||||
through the enrichment facade, so items already opened in a detail view
|
||||
come from the SQLite cache without network. Results are memoized per
|
||||
lookup identity for the session. Slides render immediately from provider
|
||||
data; extras appear when resolved and disappear when TMDB is turned off.
|
||||
Series slides additionally show the tracked "S{n}·E{n}" badge from the
|
||||
playback position (no TMDB involved). Live slides never query TMDB.
|
||||
|
||||
The query is built to **match what the detail view searched with**, not
|
||||
just what the card displays. A title alone is weaker identity than the
|
||||
|
||||
@@ -181,6 +181,13 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
|
||||
Use atomized E2E targets when available, for example
|
||||
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
|
||||
|
||||
Inside `expect.poll`, read a changing list in one DOM snapshot
|
||||
(`allTextContents()` or `evaluateAll()`, as in
|
||||
`apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts`), not by
|
||||
looping over `count()` with per-row `nth(index)` reads. Those reads auto-wait,
|
||||
so a row removed mid-loop hangs the predicate until the poll times out instead
|
||||
of letting it retry.
|
||||
|
||||
Playwright coverage is measured semantically by tags and critical journeys, not
|
||||
by a source-line percentage. E2E reports should use tags such as `@critical`,
|
||||
`@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,
|
||||
|
||||
@@ -37,7 +37,7 @@ Core implementation:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Hero — Continue Watching (most recent item) │
|
||||
│ Hero — rotating cinematic banner (resume · live · discovery) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ Continue Watching · See all → │
|
||||
│ [poster][poster][poster][poster] →→ │
|
||||
@@ -73,15 +73,17 @@ Render rules:
|
||||
slow rail does not hide already available content.
|
||||
2. `hasPlaylists() === false` → render `<app-empty-state [type]="'welcome-dashboard'">`
|
||||
full-bleed. All rails and the hero are skipped.
|
||||
3. `hero()` = `globalRecentItems()[0]`. If present, render the hero panel.
|
||||
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 the hero.
|
||||
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. 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. The continue-watching hero prefers a stored Xtream `backdrop_url`; when it
|
||||
is missing the UI falls back to a blurred poster treatment instead of
|
||||
showing a flat panel.
|
||||
5. Hero slides prefer a stored `backdrop_url`, then the TMDB backdrop; when
|
||||
both are missing the poster becomes a blurred wash plus key art on the
|
||||
right instead of a flat panel.
|
||||
6. Live favorites are promoted into their own live rail; movie/series
|
||||
favorites render in a separate `Favorite movies & series` rail
|
||||
(`favoriteMoviesAndSeriesCards`, `data-test-id="dashboard-favorite-vod-rail"`,
|
||||
@@ -92,6 +94,47 @@ Render rules:
|
||||
favorites. This avoids first-paint partial counts such as a single Stalker
|
||||
favorite appearing before M3U favorites finish resolving.
|
||||
|
||||
## Cinematic Hero
|
||||
|
||||
`DashboardHeroComponent` renders a full-bleed banner: it cancels the page's
|
||||
`--dashboard-gutter`/top padding and the centred `--dashboard-max-width`
|
||||
(the page host is the `dashboard` inline-size container), keeps
|
||||
`clamp(320px, 42vh, 520px)` so the first rail starts above the fold, and uses
|
||||
`--app-content-bg` as its scrim so it dissolves into the page in both themes.
|
||||
|
||||
Slides (`pickDashboardHeroSources`, at most four, stable order, each title
|
||||
once):
|
||||
|
||||
1. the newest unfinished movie/series (`isPortalPlaybackWatched` rows skip);
|
||||
2. a live channel with a programme on air — the first of
|
||||
`selectDashboardHeroLiveCandidates` (up to three favourites, then two
|
||||
recently watched channels) whose EPG answer has a title;
|
||||
3. one favourite movie/series and one Xtream recently-added title;
|
||||
4. remaining places round-robin over the next items of those lists;
|
||||
5. only when nothing qualifies, the newest history row of any kind (a
|
||||
detail action: it can be a finished title).
|
||||
|
||||
While live candidates exist but none has answered yet, one place stays
|
||||
reserved for the live slide, so its late arrival never evicts a slide the
|
||||
user may be viewing.
|
||||
|
||||
The live candidates are derived and pinned by `DashboardLiveEpgPresenter`
|
||||
itself (XMLTV lookup and portal queue), independent of the live rails, so the
|
||||
slide works with those rails hidden. Actions: a resume slide keeps the resume
|
||||
handoff and adds a detail-only "Details" when a series episode can resume;
|
||||
discovery slides open the detail page; live slides open the channel. TMDB
|
||||
extras (backdrop, rating, genres, overview, year) come from
|
||||
`DashboardHeroTmdbService` per featured title and vanish when TMDB is off.
|
||||
|
||||
Rotation is the active dot's CSS fill animation (8 s); its `animationend`
|
||||
advances. Hover, focus inside the hero and the pause button pause it; an
|
||||
explicit Play clears the hover/focus pause until they re-arm; under
|
||||
`prefers-reduced-motion` nothing auto-advances. The active slide is tracked
|
||||
by id, so a late live slide never moves the user off the current one. Test
|
||||
hooks: `dashboard-hero`, `dashboard-hero-slide` (`data-hero-kind`),
|
||||
`dashboard-hero-dot`, `dashboard-hero-pause`,
|
||||
`dashboard-hero-primary-action`, `dashboard-hero-secondary-action`.
|
||||
|
||||
## Rail Contract
|
||||
|
||||
`DashboardRailComponent` is purely presentational:
|
||||
@@ -112,7 +155,8 @@ Render rules:
|
||||
|
||||
1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`.
|
||||
2. It derives the dashboard surface via `computed()`:
|
||||
1. `hero` — first item of `globalRecentItems()`.
|
||||
1. The hero slides — built by `DashboardHeroSlidesPresenter`, see
|
||||
[Cinematic Hero](#cinematic-hero).
|
||||
2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series
|
||||
cover cards. Portal playback positions are bulk-loaded per playlist so
|
||||
hero and cards can show progress, remaining time, and series season/
|
||||
@@ -164,12 +208,12 @@ Render rules:
|
||||
card counts as visible. Cards that leave the list are reported gone
|
||||
at once.
|
||||
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
|
||||
visible keys of both rails with the pinned hero key and calls
|
||||
visible keys of both rails with the pinned hero keys and calls
|
||||
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
|
||||
on every change, on the 30 s tick, and on a display-offset change.
|
||||
It is reached through `DashboardLiveEpgPresenter`, which derives the
|
||||
portal rows itself from the enabled rails and pins the hero, so the
|
||||
page component only forwards what a rail can see. The queue lives in
|
||||
portal rows itself from the enabled rails and pins the hero's live
|
||||
candidates, so the page component only forwards what a rail can see. The queue lives in
|
||||
the root service, so leaving the dashboard hands the wanted set back
|
||||
(`sync([])` on destroy); otherwise the queue would keep asking for
|
||||
cards on a page that is gone.
|
||||
|
||||
@@ -30,7 +30,8 @@ IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
|
||||
- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
|
||||
- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
|
||||
- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
|
||||
- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset. It also keeps count-only main-process counters (startup phases, database worker SQL statements, and their values at main-window creation and `ready-to-show`) and registers the main-only `performance:read-counters` IPC handler, which the preload does not expose; see [performance journeys](../architecture/performance-journeys.md)
|
||||
- `IPTVNATOR_PERF_COUNT_SQL=1`, together with `IPTVNATOR_PERF_CAPTURE=1`, also counts every SQL statement of the main-process and database-worker connections for `main.sqlStatementsBeforeReadyToShow`; it wraps each statement execution, including every row of a bulk insert, so only the launch journey sets it and the import benchmarks leave it unset
|
||||
- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
|
||||
- `IPTVNATOR_DISABLE_COMPILE_CACHE=1` disables the main-process V8 compile cache; `IPTVNATOR_COMPILE_CACHE_DIR=<dir>` relocates it. The startup trace reports the outcome as `compile-cache`
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ import { content, categories, playlists, type Content } from '@iptvnator/shared/
|
||||
- `closeDatabase()` - Close connection
|
||||
- `getDatabasePath()` - Get database file path
|
||||
|
||||
### Connection observer (`connection-observer.ts`)
|
||||
- `setDatabaseConnectionObserver(observer | null)` - Called by `initDatabase` with each connection it opens, before any statement runs on it. The Electron main process registers one only with `IPTVNATOR_PERF_CAPTURE=1`, to count main-thread SQL statements. The module has no runtime dependencies and is also importable as `@iptvnator/shared/database/connection-observer`.
|
||||
|
||||
## Database Location
|
||||
|
||||
The SQLite database is stored at: `~/.iptvnator/databases/iptvnator.db`
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
export * from './lib/schema';
|
||||
export * from './lib/connection';
|
||||
export * from './lib/path-utils';
|
||||
export * from './lib/connection-observer';
|
||||
@@ -0,0 +1,30 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import {
|
||||
notifyDatabaseConnectionOpened,
|
||||
setDatabaseConnectionObserver,
|
||||
} from './connection-observer';
|
||||
|
||||
describe('database connection observer', () => {
|
||||
afterEach(() => {
|
||||
setDatabaseConnectionObserver(null);
|
||||
});
|
||||
|
||||
it('does nothing when no observer is registered', () => {
|
||||
expect(() =>
|
||||
notifyDatabaseConnectionOpened({} as Database.Database)
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('passes each opened connection to the registered observer until removed', () => {
|
||||
const observer = jest.fn();
|
||||
const first = { name: 'first' } as unknown as Database.Database;
|
||||
const second = { name: 'second' } as unknown as Database.Database;
|
||||
|
||||
setDatabaseConnectionObserver(observer);
|
||||
notifyDatabaseConnectionOpened(first);
|
||||
setDatabaseConnectionObserver(null);
|
||||
notifyDatabaseConnectionOpened(second);
|
||||
|
||||
expect(observer.mock.calls).toEqual([[first]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export type DatabaseConnectionObserver = (
|
||||
connection: Database.Database
|
||||
) => void;
|
||||
|
||||
let observer: DatabaseConnectionObserver | null = null;
|
||||
|
||||
/**
|
||||
* Registers a callback that `initDatabase` calls with each connection it
|
||||
* opens, before any statement runs on it; `null` removes it. The Electron
|
||||
* main process uses it with IPTVNATOR_PERF_CAPTURE=1 to count main-thread
|
||||
* SQL statements. This module has no runtime dependencies, so registering
|
||||
* the observer does not load better-sqlite3.
|
||||
*/
|
||||
export function setDatabaseConnectionObserver(
|
||||
next: DatabaseConnectionObserver | null
|
||||
): void {
|
||||
observer = next;
|
||||
}
|
||||
|
||||
export function notifyDatabaseConnectionOpened(
|
||||
connection: Database.Database
|
||||
): void {
|
||||
observer?.(connection);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@iptvnator/shared/logging';
|
||||
import * as schema from './schema';
|
||||
import { getIptvnatorDatabasePath } from './path-utils';
|
||||
import { notifyDatabaseConnectionOpened } from './connection-observer';
|
||||
|
||||
export type DatabaseInstance = BetterSQLite3Database<typeof schema>;
|
||||
|
||||
@@ -1228,6 +1229,7 @@ export async function initDatabase(
|
||||
? (message?: unknown) => traceSqlStatement(message)
|
||||
: undefined,
|
||||
});
|
||||
notifyDatabaseConnectionOpened(sqlite);
|
||||
|
||||
if (isSqlTraceEnabled()) {
|
||||
traceSql('sql-main', 'open', {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { getTableConfig } from 'drizzle-orm/sqlite-core';
|
||||
import { closeDatabase, getDatabasePath, initDatabase } from '../connection';
|
||||
import { setDatabaseConnectionObserver } from '../connection-observer';
|
||||
import * as currentSchema from '../schema';
|
||||
|
||||
const tables = [
|
||||
@@ -38,6 +39,31 @@ function seed(sqlite: Database.Database) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The performance capture counts one `exec` call as one SQL statement
|
||||
* (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts),
|
||||
* so initialization must pass exactly one statement per `exec`. better-sqlite3
|
||||
* refuses to prepare a string with a second statement; other prepare errors
|
||||
* are left to `exec` itself (for example an idempotent ALTER TABLE).
|
||||
*/
|
||||
function requireSingleStatementExec(): string[] {
|
||||
const batches: string[] = [];
|
||||
setDatabaseConnectionObserver((connection) => {
|
||||
const exec = connection.exec;
|
||||
connection.exec = function singleStatementExec(sql: string) {
|
||||
try {
|
||||
connection.prepare(sql);
|
||||
} catch (error) {
|
||||
if (error instanceof RangeError) {
|
||||
batches.push(sql.replace(/\s+/g, ' ').slice(0, 120));
|
||||
}
|
||||
}
|
||||
return exec.call(this, sql);
|
||||
};
|
||||
});
|
||||
return batches;
|
||||
}
|
||||
|
||||
function snapshot(sqlite: Database.Database) {
|
||||
return tables.map((table) => {
|
||||
const columns = sqlite.pragma(`table_info(${table})`) as {
|
||||
@@ -98,6 +124,7 @@ async function main() {
|
||||
console.warn = (...args) => warnings.push(args);
|
||||
console.log = () => undefined;
|
||||
const databasePath = getDatabasePath();
|
||||
const execBatches = requireSingleStatementExec();
|
||||
|
||||
if (fixture === 'fresh') {
|
||||
await initDatabase();
|
||||
@@ -157,6 +184,11 @@ async function main() {
|
||||
[],
|
||||
'Initialization must not silently skip failed migrations'
|
||||
);
|
||||
assert.deepEqual(
|
||||
execBatches,
|
||||
[],
|
||||
'Initialization must pass one SQL statement per exec call'
|
||||
);
|
||||
process.stdout.write('upgrade verified');
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ contracts on its own.
|
||||
The dashboard renders a surface only when the matching setting is enabled. Data
|
||||
rails also require the underlying data slice to have at least one item.
|
||||
|
||||
- `hero` shows the large top banner for the most recent global item. When that
|
||||
item is a live TV channel, the hero looks up the current XMLTV programme and
|
||||
displays the programme title, time range, and EPG progress bar when data is
|
||||
available.
|
||||
- `hero` shows the full-width cinematic banner that rotates between the
|
||||
newest unfinished title, a favourite (or recent) channel with a programme on
|
||||
air, and favourite / recently added titles. Contract:
|
||||
`docs/architecture/workspace-dashboard.md#cinematic-hero`.
|
||||
- `continueWatching` shows recent movies and series from
|
||||
`DashboardDataService.globalRecentVodItems()` using cover cards with playback
|
||||
progress when a saved resume position is available.
|
||||
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { Subject } from 'rxjs';
|
||||
import type {
|
||||
PlaybackPositionData,
|
||||
PortalActivityItem,
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import {
|
||||
DashboardHeroTmdbService,
|
||||
type DashboardHeroTmdbExtras,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import type { DashboardHeroLiveCandidate } from './dashboard-hero-slides.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||
|
||||
const series: PortalRecentItem = {
|
||||
id: 1,
|
||||
title: 'Big Pharma',
|
||||
type: 'movie',
|
||||
watch_kind: 'series',
|
||||
source: 'stalker',
|
||||
playlist_id: 'p1',
|
||||
playlist_name: 'rucolor',
|
||||
category_id: '1',
|
||||
xtream_id: 11,
|
||||
poster_url: 'https://img/pharma-poster.jpg',
|
||||
viewed_at: '2026-09-02',
|
||||
};
|
||||
const watchedMovie: PortalRecentItem = {
|
||||
...series,
|
||||
id: 2,
|
||||
title: 'Finished Film',
|
||||
watch_kind: 'movie',
|
||||
xtream_id: 12,
|
||||
viewed_at: '2026-09-01',
|
||||
};
|
||||
const channel: PortalFavoriteItem = {
|
||||
id: 3,
|
||||
title: 'Match TV',
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p2',
|
||||
category_id: '5',
|
||||
xtream_id: 33,
|
||||
poster_url: 'https://img/match-logo.png',
|
||||
added_at: '2026-08-01',
|
||||
};
|
||||
const favoriteFilm: PortalFavoriteItem = {
|
||||
id: 4,
|
||||
title: 'Burnley',
|
||||
type: 'series',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p2',
|
||||
playlist_name: 'http://user:secret@4kgood.org:8080',
|
||||
category_id: '9',
|
||||
xtream_id: 44,
|
||||
release_year: 2023,
|
||||
added_at: '2026-08-02',
|
||||
};
|
||||
const import1: PortalAddedItem = {
|
||||
id: 5,
|
||||
title: 'Parallel Stories',
|
||||
type: 'movie',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p3',
|
||||
category_id: '2',
|
||||
xtream_id: 55,
|
||||
backdrop_url: 'https://img/parallel-wide.jpg',
|
||||
added_at: '2026-09-03',
|
||||
};
|
||||
|
||||
const onAir: DashboardLiveEpgDetails = {
|
||||
nowPlayingTitle: 'Football: farewell match',
|
||||
nowPlayingTimeRange: '18:50 – 20:55',
|
||||
nowPlayingProgress: 35,
|
||||
nowPlayingDescription: 'Live from Moscow.',
|
||||
nowPlayingCategory: 'Sport',
|
||||
};
|
||||
|
||||
describe('DashboardHeroSlidesPresenter', () => {
|
||||
let recentItems: ReturnType<typeof signal<PortalRecentItem[]>>;
|
||||
let favorites: ReturnType<typeof signal<PortalFavoriteItem[]>>;
|
||||
let addedItems: ReturnType<typeof signal<PortalAddedItem[]>>;
|
||||
let candidates: ReturnType<typeof signal<DashboardHeroLiveCandidate[]>>;
|
||||
let liveDetails: jest.Mock;
|
||||
let tmdbEnabled: ReturnType<typeof signal<boolean>>;
|
||||
let tmdbLanguage: ReturnType<typeof signal<string>>;
|
||||
let getExtras: jest.Mock;
|
||||
const positions = new Map<string | number, PlaybackPositionData>([
|
||||
[
|
||||
1,
|
||||
{
|
||||
contentXtreamId: 111,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 11,
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 3,
|
||||
positionSeconds: 600,
|
||||
durationSeconds: 1800,
|
||||
},
|
||||
],
|
||||
[
|
||||
2,
|
||||
{
|
||||
contentXtreamId: 12,
|
||||
contentType: 'vod',
|
||||
positionSeconds: 5900,
|
||||
durationSeconds: 6000,
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
function create(): DashboardHeroSlidesPresenter {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DashboardHeroSlidesPresenter,
|
||||
{
|
||||
provide: DashboardDataService,
|
||||
useValue: {
|
||||
globalRecentLoading: signal(false),
|
||||
globalRecentItems: recentItems,
|
||||
globalRecentVodItems: () =>
|
||||
recentItems().filter((i) => i.type !== 'live'),
|
||||
globalFavoriteItems: favorites,
|
||||
xtreamRecentlyAddedItems: addedItems,
|
||||
getPlaybackPositionForItem: (
|
||||
item: PortalActivityItem
|
||||
) => positions.get(item.id) ?? null,
|
||||
getRecentItemLink: (item: PortalActivityItem) => [
|
||||
'/recent',
|
||||
String(item.id),
|
||||
],
|
||||
getRecentItemNavigationState: () => ({ resume: true }),
|
||||
getRecentItemDetailNavigationState: () => ({
|
||||
resume: false,
|
||||
}),
|
||||
getRecentItemResumeNavigation: (
|
||||
item: PortalActivityItem
|
||||
) => (item.id === 1 ? { link: [], state: {} } : null),
|
||||
getGlobalFavoriteLink: (item: PortalActivityItem) => [
|
||||
'/favorite',
|
||||
String(item.id),
|
||||
],
|
||||
getGlobalFavoriteNavigationState: () => ({ fav: true }),
|
||||
getRecentlyAddedLink: (item: PortalActivityItem) => [
|
||||
'/added',
|
||||
String(item.id),
|
||||
],
|
||||
getRecentlyAddedNavigationState: () => ({
|
||||
added: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardLiveEpgPresenter,
|
||||
useValue: {
|
||||
heroLiveCandidates: candidates,
|
||||
heroDetailsFor: liveDetails,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: DashboardHeroTmdbService,
|
||||
useValue: {
|
||||
isEnabled: () => tmdbEnabled(),
|
||||
keyFor: (item: PortalActivityItem) =>
|
||||
`${tmdbLanguage()}//${item.title}`,
|
||||
getExtras,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
onLangChange: new Subject(),
|
||||
instant: (key: string, params?: object) =>
|
||||
params ? `${key} ${JSON.stringify(params)}` : key,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(DashboardHeroSlidesPresenter);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
recentItems = signal([series, watchedMovie]);
|
||||
favorites = signal([channel, favoriteFilm]);
|
||||
addedItems = signal([import1]);
|
||||
candidates = signal([{ origin: 'favorite', item: channel }]);
|
||||
liveDetails = jest.fn(() => onAir);
|
||||
tmdbEnabled = signal(false);
|
||||
tmdbLanguage = signal('en-US');
|
||||
getExtras = jest.fn().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('builds the rotation from resume, live, favourite and import slides', () => {
|
||||
const slides = create().slides();
|
||||
|
||||
expect(slides.map((slide) => [slide.kind, slide.title])).toEqual([
|
||||
['continue', 'Big Pharma'],
|
||||
['live', 'Match TV'],
|
||||
['favorite', 'Burnley'],
|
||||
['added', 'Parallel Stories'],
|
||||
]);
|
||||
// A (nearly) finished film is not something to continue.
|
||||
expect(slides.some((slide) => slide.title === 'Finished Film')).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes an unfinished series and offers a detail-only way in', () => {
|
||||
const [resume] = create().slides();
|
||||
|
||||
expect(resume).toMatchObject({
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
episodeBadge:
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE {"season":1,"episode":3}',
|
||||
source: 'rucolor',
|
||||
progress: 33,
|
||||
// No 16:9 backdrop: the poster becomes the blurred stage.
|
||||
backdropSource: 'poster',
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
link: ['/recent', '1'],
|
||||
state: { resume: true },
|
||||
remainingLabel: {
|
||||
key: 'WORKSPACE.DASHBOARD.REMAINING_MINUTES',
|
||||
params: { minutes: 20 },
|
||||
},
|
||||
},
|
||||
secondaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
state: { resume: false },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a favourite channel with its programme on air now', () => {
|
||||
const live = create().slides()[1];
|
||||
|
||||
expect(live).toMatchObject({
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL',
|
||||
programmeTitle: 'Football: farewell match',
|
||||
description: 'Live from Moscow.',
|
||||
category: 'Sport',
|
||||
timeRange: '18:50 – 20:55',
|
||||
progress: 35,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
|
||||
link: ['/favorite', '3'],
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the live slide out until a candidate has a programme on air', () => {
|
||||
liveDetails.mockReturnValue(null);
|
||||
const presenter = create();
|
||||
|
||||
expect(presenter.slides().map((slide) => slide.kind)).not.toContain(
|
||||
'live'
|
||||
);
|
||||
});
|
||||
|
||||
it('opens discovery slides on their detail page, with a safe source label', () => {
|
||||
const slides = create().slides();
|
||||
|
||||
// The stored name is a URL with credentials: only its host shows.
|
||||
expect(slides[2].source).toBe('4kgood.org:8080');
|
||||
expect(slides[2]).toMatchObject({
|
||||
year: 2023,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
link: ['/favorite', '4'],
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
expect(slides[3]).toMatchObject({
|
||||
backdropSource: 'backdrop',
|
||||
primaryAction: { link: ['/added', '5'], state: { added: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('patches TMDB extras in, and drops them when TMDB is switched off', async () => {
|
||||
tmdbEnabled.set(true);
|
||||
const extras: DashboardHeroTmdbExtras = {
|
||||
backdropUrl: 'https://tmdb/pharma-wide.jpg',
|
||||
rating: '7.5',
|
||||
genres: ['Drama'],
|
||||
overview: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
};
|
||||
getExtras.mockImplementation((item: PortalActivityItem) =>
|
||||
Promise.resolve(item.title === 'Big Pharma' ? extras : null)
|
||||
);
|
||||
const presenter = create();
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getExtras).toHaveBeenCalledTimes(3);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropUrl: 'https://tmdb/pharma-wide.jpg',
|
||||
backdropSource: 'backdrop',
|
||||
rating: '7.5',
|
||||
genres: ['Drama'],
|
||||
description: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
});
|
||||
|
||||
tmdbEnabled.set(false);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropSource: 'poster',
|
||||
rating: null,
|
||||
description: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the extras again when the TMDB language changes', async () => {
|
||||
tmdbEnabled.set(true);
|
||||
getExtras.mockImplementation((item: PortalActivityItem) =>
|
||||
Promise.resolve(
|
||||
item.title === 'Big Pharma'
|
||||
? {
|
||||
backdropUrl: null,
|
||||
rating: '7.5',
|
||||
genres: [
|
||||
tmdbLanguage() === 'en-US' ? 'Drama' : 'Драма',
|
||||
],
|
||||
overview: `plot in ${tmdbLanguage()}`,
|
||||
year: 2024,
|
||||
}
|
||||
: null
|
||||
)
|
||||
);
|
||||
const presenter = create();
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
expect(presenter.slides()[0].description).toBe('plot in en-US');
|
||||
|
||||
tmdbLanguage.set('ru-RU');
|
||||
TestBed.tick();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(getExtras).toHaveBeenCalledTimes(6);
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
description: 'plot in ru-RU',
|
||||
genres: ['Драма'],
|
||||
});
|
||||
});
|
||||
|
||||
it('opens a finished fallback title on its details, never "Continue"', () => {
|
||||
recentItems.set([watchedMovie]);
|
||||
favorites.set([]);
|
||||
addedItems.set([]);
|
||||
candidates.set([]);
|
||||
const [fallback] = create().slides();
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
kind: 'recent',
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
state: { resume: false },
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the generated stage when an image fails to load', () => {
|
||||
const presenter = create();
|
||||
presenter.markImageFailed('https://img/pharma-poster.jpg');
|
||||
|
||||
expect(presenter.slides()[0]).toMatchObject({
|
||||
backdropUrl: undefined,
|
||||
backdropSource: 'fallback',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
Injectable,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { DashboardDataService } from '@iptvnator/workspace/dashboard/data-access';
|
||||
import {
|
||||
DashboardHeroTmdbService,
|
||||
type DashboardHeroTmdbExtras,
|
||||
} from './dashboard-hero-tmdb.service';
|
||||
import {
|
||||
dashboardHeroHue,
|
||||
resolveDashboardHeroArtwork,
|
||||
type DashboardHeroAction,
|
||||
type DashboardHeroSlide,
|
||||
} from './dashboard-hero.utils';
|
||||
import {
|
||||
dashboardHeroItemKey,
|
||||
pickDashboardHeroSources,
|
||||
type DashboardHeroSource,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import type { DashboardLiveEpgDetails } from './dashboard-live-epg.utils';
|
||||
import {
|
||||
buildDashboardEpisodeBadge,
|
||||
formatRemainingLabel,
|
||||
playbackProgressPercent,
|
||||
} from './dashboard-playback.utils';
|
||||
|
||||
const TYPE_LABEL_KEYS = {
|
||||
live: 'WORKSPACE.DASHBOARD.TYPE_LIVE',
|
||||
movie: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
|
||||
series: 'WORKSPACE.DASHBOARD.TYPE_SERIES',
|
||||
} as const;
|
||||
|
||||
const PROVIDER_LABEL_KEYS = {
|
||||
xtream: 'WORKSPACE.DASHBOARD.XTREAM',
|
||||
stalker: 'WORKSPACE.DASHBOARD.STALKER',
|
||||
m3u: 'WORKSPACE.DASHBOARD.M3U',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Builds the cinematic hero's rotation slides from the dashboard data:
|
||||
* which titles are featured (`pickDashboardHeroSources`), their artwork,
|
||||
* TMDB extras, playback progress, live programme and actions.
|
||||
*
|
||||
* Component-provided next to the hero; the live programme answers come from
|
||||
* the page's `DashboardLiveEpgPresenter`, which already pins the hero's live
|
||||
* candidates. TMDB extras are fetched per featured title after first paint,
|
||||
* memoized by `DashboardHeroTmdbService`, and vanish immediately when the
|
||||
* user opts out of TMDB mid-session.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardHeroSlidesPresenter {
|
||||
private readonly data = inject(DashboardDataService);
|
||||
private readonly liveEpg = inject(DashboardLiveEpgPresenter);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
|
||||
private readonly failedImages = signal<Record<string, true>>({});
|
||||
private readonly tmdbExtras = signal<
|
||||
ReadonlyMap<string, DashboardHeroTmdbExtras | null>
|
||||
>(new Map());
|
||||
private readonly requestedTmdbKeys = new Set<string>();
|
||||
|
||||
/** First history load still running and nothing to feature yet. */
|
||||
readonly loading = computed(
|
||||
() => this.data.globalRecentLoading() && this.slides().length === 0
|
||||
);
|
||||
|
||||
/** The first candidate channel with a programme on air right now. */
|
||||
private readonly liveSlide = computed(() => {
|
||||
for (const candidate of this.liveEpg.heroLiveCandidates()) {
|
||||
const details = this.liveEpg.heroDetailsFor(candidate.item);
|
||||
if (details?.nowPlayingTitle) {
|
||||
return { candidate, details };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
private readonly sources = computed(() =>
|
||||
pickDashboardHeroSources({
|
||||
continueItems: this.data
|
||||
.globalRecentVodItems()
|
||||
.filter(
|
||||
(item) =>
|
||||
!isPortalPlaybackWatched(
|
||||
this.data.getPlaybackPositionForItem(item)
|
||||
)
|
||||
),
|
||||
live: this.liveSlide()?.candidate ?? null,
|
||||
reserveLive: this.liveEpg.heroLiveCandidates().length > 0,
|
||||
favorites: this.data
|
||||
.globalFavoriteItems()
|
||||
.filter(
|
||||
(item) => item.type === 'movie' || item.type === 'series'
|
||||
),
|
||||
recentlyAdded: this.data.xtreamRecentlyAddedItems(),
|
||||
mostRecent: this.data.globalRecentItems()[0] ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
readonly slides = computed<DashboardHeroSlide[]>(() => {
|
||||
this.languageTick();
|
||||
const liveDetails = this.liveSlide()?.details ?? null;
|
||||
return this.sources().map((source) =>
|
||||
this.toSlide(source, liveDetails)
|
||||
);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.heroTmdb.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
// Keys read here, tracked: they carry the TMDB language, so a
|
||||
// language change loads the localized overview and genres.
|
||||
const requests = this.sources()
|
||||
.map((source) => source.item)
|
||||
.filter((item) => item.type !== 'live')
|
||||
.map((item) => ({ item, key: this.heroTmdb.keyFor(item) }));
|
||||
untracked(() =>
|
||||
requests.forEach(({ item, key }) =>
|
||||
this.loadTmdbExtras(item, key)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
markImageFailed(url: string): void {
|
||||
this.failedImages.update((state) =>
|
||||
state[url] ? state : { ...state, [url]: true }
|
||||
);
|
||||
}
|
||||
|
||||
private loadTmdbExtras(item: PortalActivityItem, key: string): void {
|
||||
if (this.requestedTmdbKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
this.requestedTmdbKeys.add(key);
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
this.tmdbExtras.update((state) => new Map(state).set(key, extras));
|
||||
});
|
||||
}
|
||||
|
||||
private toSlide(
|
||||
source: DashboardHeroSource,
|
||||
liveDetails: DashboardLiveEpgDetails | null
|
||||
): DashboardHeroSlide {
|
||||
const item = source.item;
|
||||
const isLive = item.type === 'live';
|
||||
// Single reactive read, gated on the TMDB opt-in
|
||||
const extras =
|
||||
!isLive && this.heroTmdb.isEnabled()
|
||||
? (this.tmdbExtras().get(this.heroTmdb.keyFor(item)) ?? null)
|
||||
: null;
|
||||
const details =
|
||||
source.kind === 'live'
|
||||
? liveDetails
|
||||
: isLive
|
||||
? this.liveEpg.heroDetailsFor(item)
|
||||
: null;
|
||||
const position = isLive
|
||||
? null
|
||||
: this.data.getPlaybackPositionForItem(item);
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url || extras?.backdropUrl || null,
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
this.failedImages()
|
||||
);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
id: `${source.kind}:${dashboardHeroItemKey(item)}`,
|
||||
kind: source.kind,
|
||||
contentType: item.type,
|
||||
title: item.title,
|
||||
// Watch kind: a Stalker embedded-VOD show routes as a movie
|
||||
typeLabelKey:
|
||||
TYPE_LABEL_KEYS[
|
||||
isLive
|
||||
? 'live'
|
||||
: (resolvePortalActivityWatchKind(item) ?? item.type)
|
||||
],
|
||||
reasonLabelKey: this.reasonLabelKey(source),
|
||||
episodeBadge: buildDashboardEpisodeBadge(
|
||||
item,
|
||||
position,
|
||||
(key, params) => this.translate.instant(key, params)
|
||||
),
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
year: extras?.year ?? item.release_year ?? null,
|
||||
source: playlistDisplayLabel(
|
||||
item.playlist_name,
|
||||
this.translate.instant(
|
||||
item.source
|
||||
? PROVIDER_LABEL_KEYS[item.source]
|
||||
: 'WORKSPACE.DASHBOARD.PROVIDER'
|
||||
)
|
||||
),
|
||||
programmeTitle: details?.nowPlayingTitle ?? null,
|
||||
category: details?.nowPlayingCategory ?? null,
|
||||
timeRange: details?.nowPlayingTimeRange ?? null,
|
||||
description: isLive
|
||||
? (details?.nowPlayingDescription ?? null)
|
||||
: (extras?.overview ?? null),
|
||||
progress: isLive
|
||||
? (details?.nowPlayingProgress ?? null)
|
||||
: playbackProgressPercent(position),
|
||||
accentHue: dashboardHeroHue(item.title),
|
||||
...this.actionsFor(source, position),
|
||||
};
|
||||
}
|
||||
|
||||
private reasonLabelKey(source: DashboardHeroSource): string {
|
||||
switch (source.kind) {
|
||||
case 'continue':
|
||||
return 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING';
|
||||
case 'live':
|
||||
return source.origin === 'favorite'
|
||||
? 'WORKSPACE.DASHBOARD.HERO_FAVORITE_CHANNEL'
|
||||
: 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
|
||||
case 'favorite':
|
||||
return 'WORKSPACE.DASHBOARD.HERO_FAVORITE';
|
||||
case 'added':
|
||||
return 'WORKSPACE.DASHBOARD.HERO_RECENTLY_ADDED';
|
||||
case 'recent':
|
||||
return 'WORKSPACE.DASHBOARD.RECENTLY_WATCHED';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume slides keep the hero's resume handoff (a saved series episode
|
||||
* auto-plays) and offer "Details" as the detail-only way in; discovery
|
||||
* and fallback slides open the detail page; live slides open the channel.
|
||||
*/
|
||||
private actionsFor(
|
||||
source: DashboardHeroSource,
|
||||
position: PlaybackPositionData | null
|
||||
): Pick<DashboardHeroSlide, 'primaryAction' | 'secondaryAction'> {
|
||||
switch (source.kind) {
|
||||
case 'live':
|
||||
return {
|
||||
primaryAction:
|
||||
source.origin === 'favorite'
|
||||
? watchLiveAction(
|
||||
this.data.getGlobalFavoriteLink(source.item),
|
||||
this.data.getGlobalFavoriteNavigationState(
|
||||
source.item
|
||||
)
|
||||
)
|
||||
: watchLiveAction(
|
||||
this.data.getRecentItemLink(source.item),
|
||||
this.data.getRecentItemNavigationState(
|
||||
source.item
|
||||
)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'favorite':
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
this.data.getGlobalFavoriteLink(source.item),
|
||||
this.data.getGlobalFavoriteNavigationState(source.item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'added':
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
this.data.getRecentlyAddedLink(source.item),
|
||||
this.data.getRecentlyAddedNavigationState(source.item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
case 'continue':
|
||||
case 'recent': {
|
||||
const item = source.item;
|
||||
const link = this.data.getRecentItemLink(item);
|
||||
const state = this.data.getRecentItemNavigationState(item);
|
||||
if (item.type === 'live') {
|
||||
return {
|
||||
primaryAction: watchLiveAction(link, state),
|
||||
secondaryAction: null,
|
||||
};
|
||||
}
|
||||
// The fallback row can be a finished title (the resume
|
||||
// candidates skip those): nothing to continue, open details.
|
||||
if (source.kind === 'recent') {
|
||||
return {
|
||||
primaryAction: detailsAction(
|
||||
link,
|
||||
this.data.getRecentItemDetailNavigationState(item)
|
||||
),
|
||||
secondaryAction: null,
|
||||
};
|
||||
}
|
||||
const canResume =
|
||||
this.data.getRecentItemResumeNavigation(item) !== null;
|
||||
return {
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
icon: 'play_arrow',
|
||||
link,
|
||||
state,
|
||||
remainingLabel: formatRemainingLabel(position),
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
},
|
||||
secondaryAction: canResume
|
||||
? {
|
||||
...detailsAction(
|
||||
link,
|
||||
this.data.getRecentItemDetailNavigationState(
|
||||
item
|
||||
)
|
||||
),
|
||||
testId: 'dashboard-hero-secondary-action',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function watchLiveAction(
|
||||
link: string[],
|
||||
state: Record<string, unknown> | undefined
|
||||
): DashboardHeroAction {
|
||||
return {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_WATCH_LIVE',
|
||||
icon: 'play_arrow',
|
||||
link,
|
||||
state,
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
};
|
||||
}
|
||||
|
||||
function detailsAction(
|
||||
link: string[],
|
||||
state: Record<string, unknown> | undefined
|
||||
): DashboardHeroAction {
|
||||
return {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS',
|
||||
icon: 'info',
|
||||
link,
|
||||
state,
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
dashboardHeroItemKey,
|
||||
HERO_SLIDE_LIMIT,
|
||||
pickDashboardHeroSources,
|
||||
selectDashboardHeroLiveCandidates,
|
||||
type DashboardHeroSourceInput,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
|
||||
const base = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series',
|
||||
title: string
|
||||
) => ({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: id,
|
||||
source: 'xtream' as const,
|
||||
});
|
||||
const recent = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series' = 'movie'
|
||||
): PortalRecentItem => ({
|
||||
...base(id, type, `recent-${id}`),
|
||||
viewed_at: '2026-09-01',
|
||||
});
|
||||
const favorite = (
|
||||
id: number,
|
||||
type: 'live' | 'movie' | 'series' = 'movie'
|
||||
): PortalFavoriteItem => ({
|
||||
...base(id, type, `favorite-${id}`),
|
||||
added_at: '2026-09-01',
|
||||
});
|
||||
const added = (id: number): PortalAddedItem => ({
|
||||
...base(id, 'movie', `added-${id}`),
|
||||
added_at: '2026-09-01',
|
||||
});
|
||||
|
||||
const input = (
|
||||
overrides: Partial<DashboardHeroSourceInput>
|
||||
): DashboardHeroSourceInput => ({
|
||||
continueItems: [],
|
||||
live: null,
|
||||
reserveLive: false,
|
||||
favorites: [],
|
||||
recentlyAdded: [],
|
||||
mostRecent: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const summary = (sources: ReturnType<typeof pickDashboardHeroSources>) =>
|
||||
sources.map((source) => `${source.kind}:${source.item.title}`);
|
||||
|
||||
describe('pickDashboardHeroSources', () => {
|
||||
it('leads with the title to resume, then live, a favourite and an import', () => {
|
||||
const live = favorite(50, 'live');
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1), recent(2)],
|
||||
live: { origin: 'favorite', item: live },
|
||||
favorites: [favorite(10), favorite(11)],
|
||||
recentlyAdded: [added(20), added(21)],
|
||||
})
|
||||
)
|
||||
)
|
||||
).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:favorite-50',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills the remaining places round-robin and never exceeds the limit', () => {
|
||||
const sources = pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1), recent(2), recent(3)],
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
);
|
||||
|
||||
expect(summary(sources)).toEqual([
|
||||
'continue:recent-1',
|
||||
'favorite:favorite-10',
|
||||
'continue:recent-2',
|
||||
'continue:recent-3',
|
||||
]);
|
||||
expect(sources.length).toBeLessThanOrEqual(HERO_SLIDE_LIMIT);
|
||||
});
|
||||
|
||||
it('features a title once even when it is both unfinished and a favourite', () => {
|
||||
const shared = recent(1);
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [shared],
|
||||
favorites: [{ ...shared, added_at: 'x' }, favorite(10)],
|
||||
})
|
||||
)
|
||||
)
|
||||
).toEqual(['continue:recent-1', 'favorite:favorite-10']);
|
||||
});
|
||||
|
||||
it('keeps the order stable when the live slide arrives late', () => {
|
||||
const without = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1)],
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
)
|
||||
);
|
||||
const withLive = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
continueItems: [recent(1)],
|
||||
live: { origin: 'recent', item: recent(60, 'live') },
|
||||
favorites: [favorite(10)],
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(without).toEqual(['continue:recent-1', 'favorite:favorite-10']);
|
||||
expect(withLive).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:recent-60',
|
||||
'favorite:favorite-10',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a place for a pending live slide so its arrival evicts nothing', () => {
|
||||
const lists = {
|
||||
continueItems: [recent(1), recent(2)],
|
||||
favorites: [favorite(10)],
|
||||
recentlyAdded: [added(20)],
|
||||
};
|
||||
const pending = summary(
|
||||
pickDashboardHeroSources(input({ ...lists, reserveLive: true }))
|
||||
);
|
||||
const arrived = summary(
|
||||
pickDashboardHeroSources(
|
||||
input({
|
||||
...lists,
|
||||
reserveLive: true,
|
||||
live: { origin: 'favorite', item: favorite(50, 'live') },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(pending).toEqual([
|
||||
'continue:recent-1',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
// Every slide shown before the live answer is still there after it.
|
||||
expect(arrived).toEqual([
|
||||
'continue:recent-1',
|
||||
'live:favorite-50',
|
||||
'favorite:favorite-10',
|
||||
'added:added-20',
|
||||
]);
|
||||
expect(
|
||||
summary(pickDashboardHeroSources(input({ ...lists })))
|
||||
).toHaveLength(HERO_SLIDE_LIMIT);
|
||||
});
|
||||
|
||||
it('falls back to the newest history row only when nothing else qualifies', () => {
|
||||
const channel = recent(70, 'live');
|
||||
expect(
|
||||
summary(pickDashboardHeroSources(input({ mostRecent: channel })))
|
||||
).toEqual(['recent:recent-70']);
|
||||
expect(
|
||||
summary(
|
||||
pickDashboardHeroSources(
|
||||
input({ mostRecent: channel, favorites: [favorite(10)] })
|
||||
)
|
||||
)
|
||||
).toEqual(['favorite:favorite-10']);
|
||||
expect(pickDashboardHeroSources(input({}))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectDashboardHeroLiveCandidates', () => {
|
||||
it('prefers favourites, adds recent channels and skips duplicates', () => {
|
||||
const candidates = selectDashboardHeroLiveCandidates(
|
||||
[
|
||||
favorite(1, 'live'),
|
||||
favorite(2, 'live'),
|
||||
favorite(3, 'live'),
|
||||
favorite(4, 'live'),
|
||||
],
|
||||
[recent(2, 'live'), recent(5, 'live'), recent(6, 'live')]
|
||||
);
|
||||
|
||||
expect(
|
||||
candidates.map(({ origin, item }) => `${origin}:${item.id}`)
|
||||
).toEqual(['favorite:1', 'favorite:2', 'favorite:3', 'recent:5']);
|
||||
});
|
||||
|
||||
it('ignores non-live rows', () => {
|
||||
expect(
|
||||
selectDashboardHeroLiveCandidates([favorite(1, 'movie')], [])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboardHeroItemKey', () => {
|
||||
it('identifies a title across the lists it can come from', () => {
|
||||
expect(dashboardHeroItemKey(recent(1))).toBe(
|
||||
dashboardHeroItemKey(favorite(1))
|
||||
);
|
||||
expect(dashboardHeroItemKey(recent(1))).not.toBe(
|
||||
dashboardHeroItemKey(recent(1, 'series'))
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type {
|
||||
PortalActivityItem,
|
||||
PortalAddedItem,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Slides the cinematic hero rotates through, at most. */
|
||||
export const HERO_SLIDE_LIMIT = 4;
|
||||
|
||||
/** Auto-advance interval; the active dot fills over the same duration. */
|
||||
export const HERO_ROTATION_MS = 8_000;
|
||||
|
||||
const HERO_LIVE_FAVORITE_CANDIDATES = 3;
|
||||
const HERO_LIVE_RECENT_CANDIDATES = 2;
|
||||
|
||||
/**
|
||||
* Why a title is on the hero, which decides its eyebrow and actions:
|
||||
* - `continue` — unfinished movie/series from the recent history
|
||||
* - `live` — a favourite (or recently watched) channel with a programme on air
|
||||
* - `favorite` / `added` — discovery slides from favourites and Xtream imports
|
||||
* - `recent` — last-resort fallback: the most recent item of any kind, so a
|
||||
* history with only guide-less live channels still gets a hero
|
||||
*/
|
||||
export type DashboardHeroSlideKind =
|
||||
'continue' | 'live' | 'favorite' | 'added' | 'recent';
|
||||
|
||||
export type DashboardHeroLiveCandidate =
|
||||
| { readonly origin: 'favorite'; readonly item: PortalFavoriteItem }
|
||||
| { readonly origin: 'recent'; readonly item: PortalRecentItem };
|
||||
|
||||
export type DashboardHeroSource =
|
||||
| { readonly kind: 'continue' | 'recent'; readonly item: PortalRecentItem }
|
||||
| ({ readonly kind: 'live' } & DashboardHeroLiveCandidate)
|
||||
| { readonly kind: 'favorite'; readonly item: PortalFavoriteItem }
|
||||
| { readonly kind: 'added'; readonly item: PortalAddedItem };
|
||||
|
||||
export interface DashboardHeroSourceInput {
|
||||
/** Unfinished movies/series, most recent first. */
|
||||
readonly continueItems: readonly PortalRecentItem[];
|
||||
/** The channel picked for the live slide, when one has a programme on air. */
|
||||
readonly live: DashboardHeroLiveCandidate | null;
|
||||
/**
|
||||
* Channels exist that could still fill the live slide (their EPG answer
|
||||
* may be pending). Its place is then kept free, so the slide arriving
|
||||
* late never pushes another one out from under the user.
|
||||
*/
|
||||
readonly reserveLive: boolean;
|
||||
/** Favourite movies/series. */
|
||||
readonly favorites: readonly PortalFavoriteItem[];
|
||||
readonly recentlyAdded: readonly PortalAddedItem[];
|
||||
/** The newest history row, used only when nothing else qualifies. */
|
||||
readonly mostRecent: PortalRecentItem | null;
|
||||
}
|
||||
|
||||
/** One title, whichever list it came from. */
|
||||
export function dashboardHeroItemKey(
|
||||
item: Pick<PortalActivityItem, 'playlist_id' | 'type' | 'xtream_id' | 'id'>
|
||||
): string {
|
||||
return `${item.playlist_id}::${item.type}::${item.xtream_id ?? item.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels that may fill the live slide, in preference order: favourites
|
||||
* first (the slide is "your channel is on air"), then the channels watched
|
||||
* most recently. The live EPG presenter pins exactly these so their
|
||||
* programmes are looked up even when their rails are hidden or scrolled.
|
||||
*/
|
||||
export function selectDashboardHeroLiveCandidates(
|
||||
favoriteLive: readonly PortalFavoriteItem[],
|
||||
recentLive: readonly PortalRecentItem[]
|
||||
): DashboardHeroLiveCandidate[] {
|
||||
const seen = new Set<string>();
|
||||
const candidates: DashboardHeroLiveCandidate[] = [];
|
||||
const add = (candidate: DashboardHeroLiveCandidate) => {
|
||||
const key = dashboardHeroItemKey(candidate.item);
|
||||
if (candidate.item.type !== 'live' || seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
favoriteLive
|
||||
.slice(0, HERO_LIVE_FAVORITE_CANDIDATES)
|
||||
.forEach((item) => add({ origin: 'favorite', item }));
|
||||
recentLive
|
||||
.slice(0, HERO_LIVE_RECENT_CANDIDATES)
|
||||
.forEach((item) => add({ origin: 'recent', item }));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rotation mix: the title to resume first, then a channel on air now,
|
||||
* then one favourite and one recent import; remaining places go to the next
|
||||
* unfinished title, favourite and import in turn. A title never appears
|
||||
* twice, and the order is stable so a slide arriving late (the live slide
|
||||
* waits for its EPG answer) slots in without reshuffling the rest. While
|
||||
* live candidates exist, one place stays reserved for the live slide, so
|
||||
* its arrival never evicts a slide the user may be viewing.
|
||||
*/
|
||||
export function pickDashboardHeroSources(
|
||||
input: DashboardHeroSourceInput
|
||||
): DashboardHeroSource[] {
|
||||
const seen = new Set<string>();
|
||||
const sources: DashboardHeroSource[] = [];
|
||||
const limit =
|
||||
input.reserveLive && !input.live
|
||||
? HERO_SLIDE_LIMIT - 1
|
||||
: HERO_SLIDE_LIMIT;
|
||||
const push = (source: DashboardHeroSource | null) => {
|
||||
if (!source || sources.length >= limit) {
|
||||
return;
|
||||
}
|
||||
const key = dashboardHeroItemKey(source.item);
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
sources.push(source);
|
||||
};
|
||||
const continueAt = (index: number) => {
|
||||
const item = input.continueItems[index];
|
||||
return item ? ({ kind: 'continue', item } as const) : null;
|
||||
};
|
||||
const favoriteAt = (index: number) => {
|
||||
const item = input.favorites[index];
|
||||
return item ? ({ kind: 'favorite', item } as const) : null;
|
||||
};
|
||||
const addedAt = (index: number) => {
|
||||
const item = input.recentlyAdded[index];
|
||||
return item ? ({ kind: 'added', item } as const) : null;
|
||||
};
|
||||
|
||||
push(continueAt(0));
|
||||
push(input.live ? { kind: 'live', ...input.live } : null);
|
||||
push(favoriteAt(0));
|
||||
push(addedAt(0));
|
||||
// Ends once every list has run out; duplicates only skip a place.
|
||||
for (let index = 1; sources.length < limit; index++) {
|
||||
const next = [continueAt(index), favoriteAt(index), addedAt(index)];
|
||||
if (next.every((source) => source === null)) {
|
||||
break;
|
||||
}
|
||||
next.forEach(push);
|
||||
}
|
||||
|
||||
if (sources.length === 0 && input.mostRecent) {
|
||||
push({ kind: 'recent', item: input.mostRecent });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
@@ -11,7 +11,10 @@ describe('DashboardHeroTmdbService', () => {
|
||||
backdrop_path: '/serial.jpg',
|
||||
vote_average: 7.4,
|
||||
vote_count: 12,
|
||||
genres: [{ id: 1, name: 'Drama' }, { id: 2, name: 'Comedy' }],
|
||||
genres: [
|
||||
{ id: 1, name: 'Drama' },
|
||||
{ id: 2, name: 'Comedy' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,6 +56,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
} as unknown as DashboardHeroTmdbItem;
|
||||
|
||||
let isEnabled: jest.Mock;
|
||||
let language: jest.Mock;
|
||||
let enrichMovie: jest.Mock;
|
||||
let enrichTv: jest.Mock;
|
||||
|
||||
@@ -61,7 +65,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
providers: [
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled, enrichMovie, enrichTv },
|
||||
useValue: { isEnabled, language, enrichMovie, enrichTv },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -70,6 +74,7 @@ describe('DashboardHeroTmdbService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
isEnabled = jest.fn().mockReturnValue(true);
|
||||
language = jest.fn().mockReturnValue('en-US');
|
||||
enrichMovie = jest.fn().mockResolvedValue(null);
|
||||
enrichTv = jest.fn().mockResolvedValue(tvDetails);
|
||||
});
|
||||
@@ -88,6 +93,57 @@ describe('DashboardHeroTmdbService', () => {
|
||||
expect(enrichMovie).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps the backdrop, rating, genres, overview and year for the slide', async () => {
|
||||
enrichTv.mockResolvedValue({
|
||||
...tvDetails,
|
||||
genres: [
|
||||
{ id: 1, name: 'Drama' },
|
||||
{ id: 2, name: 'Comedy' },
|
||||
{ id: 3, name: 'Crime' },
|
||||
],
|
||||
overview: ' A pharmacist enters big business. ',
|
||||
first_air_date: '2024-11-18',
|
||||
});
|
||||
const service = createService();
|
||||
|
||||
await expect(
|
||||
service.getExtras({ title: 'Big Pharma', type: 'series' })
|
||||
).resolves.toEqual({
|
||||
backdropUrl: expect.stringContaining('/serial.jpg'),
|
||||
rating: '7.4',
|
||||
genres: ['Drama', 'Comedy'],
|
||||
overview: 'A pharmacist enters big business.',
|
||||
year: 2024,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the extras again for another TMDB language', async () => {
|
||||
const service = createService();
|
||||
const item: DashboardHeroTmdbItem = { title: 'Serial', type: 'series' };
|
||||
|
||||
await service.getExtras(item);
|
||||
const englishKey = service.keyFor(item);
|
||||
await service.getExtras(item);
|
||||
expect(enrichTv).toHaveBeenCalledTimes(1);
|
||||
|
||||
language.mockReturnValue('ru-RU');
|
||||
expect(service.keyFor(item)).not.toBe(englishKey);
|
||||
await service.getExtras(item);
|
||||
expect(enrichTv).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reports a missing overview and year as null', async () => {
|
||||
const service = createService();
|
||||
|
||||
const extras = await service.getExtras({
|
||||
title: 'No plot',
|
||||
type: 'series',
|
||||
});
|
||||
|
||||
expect(extras?.overview).toBeNull();
|
||||
expect(extras?.year).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to a TV lookup for movie-typed items without a movie match', async () => {
|
||||
const service = createService();
|
||||
|
||||
@@ -256,7 +312,9 @@ describe('DashboardHeroTmdbService', () => {
|
||||
},
|
||||
} as DashboardHeroTmdbItem;
|
||||
|
||||
expect(service.keyFor(stalkerItem)).not.toBe(service.keyFor(remake));
|
||||
expect(service.keyFor(stalkerItem)).not.toBe(
|
||||
service.keyFor(remake)
|
||||
);
|
||||
|
||||
await service.getExtras(stalkerItem);
|
||||
await service.getExtras(remake);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { TmdbEnrichmentService, tmdbBackdropUrl } from '@iptvnator/services';
|
||||
import {
|
||||
TmdbEnrichmentService,
|
||||
extractYear,
|
||||
tmdbBackdropUrl,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
DashboardTmdbAttempt,
|
||||
DashboardTmdbLookupItem,
|
||||
@@ -12,6 +16,10 @@ export interface DashboardHeroTmdbExtras {
|
||||
readonly backdropUrl: string | null;
|
||||
readonly rating: string | null;
|
||||
readonly genres: readonly string[];
|
||||
/** TMDB plot, in the enrichment language; null when TMDB has none */
|
||||
readonly overview: string | null;
|
||||
/** Release (movie) or first-air (series) year */
|
||||
readonly year: number | null;
|
||||
}
|
||||
|
||||
/** Everything the hero lookup reads off an activity row */
|
||||
@@ -20,7 +28,8 @@ export type DashboardHeroTmdbItem = DashboardTmdbLookupItem;
|
||||
const MAX_HERO_GENRES = 2;
|
||||
|
||||
/**
|
||||
* Best-effort TMDB extras for the hero card (backdrop, rating, genres).
|
||||
* Best-effort TMDB extras for the hero slides (backdrop, rating, genres,
|
||||
* overview, year).
|
||||
* Goes through the enrichment facade, so items already opened in a detail
|
||||
* view resolve from the SQLite cache without network. Results are memoized
|
||||
* per lookup identity for the session — dashboard revisits skip the IPC
|
||||
@@ -45,10 +54,13 @@ export class DashboardHeroTmdbService {
|
||||
|
||||
/**
|
||||
* Identity of the lookup for an item — the memo key, and the staleness
|
||||
* guard callers compare against while a request is in flight.
|
||||
* guard callers compare against while a request is in flight. The TMDB
|
||||
* language is part of it: the overview and genre names are localized,
|
||||
* so a language change must load them again. Reactive when read inside
|
||||
* a computed or effect (settings signal underneath).
|
||||
*/
|
||||
keyFor(item: DashboardHeroTmdbItem): string {
|
||||
return dashboardTmdbLookupKey(item);
|
||||
return `${this.enrichment.language()}//${dashboardTmdbLookupKey(item)}`;
|
||||
}
|
||||
|
||||
getExtras(
|
||||
@@ -103,6 +115,9 @@ function toHeroExtras(details: {
|
||||
vote_average?: number;
|
||||
vote_count?: number;
|
||||
genres?: { name?: string }[];
|
||||
overview?: string;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
}): DashboardHeroTmdbExtras {
|
||||
const rating =
|
||||
(details.vote_count ?? 0) > 0 && details.vote_average
|
||||
@@ -116,5 +131,7 @@ function toHeroExtras(details: {
|
||||
.map((genre) => genre.name)
|
||||
.filter((name): name is string => Boolean(name))
|
||||
.slice(0, MAX_HERO_GENRES),
|
||||
overview: details.overview?.trim() || null,
|
||||
year: extractYear(details.release_date ?? details.first_air_date),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
@if (activeSlide(); as active) {
|
||||
<section
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
aria-roledescription="carousel"
|
||||
[attr.aria-label]="'WORKSPACE.DASHBOARD.HERO_LABEL' | translate"
|
||||
[class.hero--paused]="paused()"
|
||||
[class.hero--rotating]="canRotate()"
|
||||
(mouseenter)="setHovered(true)"
|
||||
(mouseleave)="setHovered(false)"
|
||||
(focusin)="onFocusIn()"
|
||||
(focusout)="onFocusOut($event)"
|
||||
>
|
||||
<!-- Every slide's backdrop stays mounted so switching crossfades
|
||||
and the next image is already decoded. -->
|
||||
<div class="hero__stage" aria-hidden="true">
|
||||
@for (slide of slides(); track slide.id) {
|
||||
<div
|
||||
class="hero__backdrop"
|
||||
[class.hero__backdrop--active]="$index === activeIndex()"
|
||||
[class.hero__backdrop--poster]="
|
||||
slide.backdropSource === 'poster'
|
||||
"
|
||||
[class.hero__backdrop--live]="slide.contentType === 'live'"
|
||||
[style.--hero-hue]="slide.accentHue"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop-fallback"
|
||||
[style.background]="
|
||||
slide.contentType === 'live'
|
||||
? null
|
||||
: slide.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (slide.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__backdrop-image"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
decoding="async"
|
||||
[attr.loading]="
|
||||
$index === activeIndex() ? 'eager' : 'lazy'
|
||||
"
|
||||
(error)="presenter.markImageFailed(backdropUrl)"
|
||||
/>
|
||||
<!-- No 16:9 backdrop: the sharp poster (or channel
|
||||
logo) becomes key art on the right. -->
|
||||
@if (slide.backdropSource === 'poster') {
|
||||
<img
|
||||
class="hero__backdrop-keyart"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
decoding="async"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="hero__scrim"></span>
|
||||
</div>
|
||||
|
||||
@for (slide of [active]; track slide.id) {
|
||||
<div
|
||||
class="hero__content"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-test-id="dashboard-hero-slide"
|
||||
[attr.data-hero-kind]="slide.kind"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_SLIDE_POSITION'
|
||||
| translate
|
||||
: {
|
||||
index: activeIndex() + 1,
|
||||
count: slides().length,
|
||||
}
|
||||
"
|
||||
[attr.aria-live]="canRotate() && !paused() ? 'off' : 'polite'"
|
||||
>
|
||||
<p class="hero__eyebrow">
|
||||
<span
|
||||
class="hero__type"
|
||||
[class.hero__type--live]="slide.contentType === 'live'"
|
||||
>{{ slide.typeLabelKey | translate }}</span
|
||||
>
|
||||
<span class="hero__eyebrow-sep" aria-hidden="true">·</span>
|
||||
<span>{{ slide.reasonLabelKey | translate }}</span>
|
||||
</p>
|
||||
|
||||
<h1 class="hero__title">{{ slide.title }}</h1>
|
||||
|
||||
<p class="hero__meta" data-test-id="dashboard-hero-badges">
|
||||
@if (slide.episodeBadge) {
|
||||
<span class="hero__pill">{{ slide.episodeBadge }}</span>
|
||||
}
|
||||
@if (slide.rating) {
|
||||
<span class="hero__pill hero__pill--rating"
|
||||
>★ {{ slide.rating }}</span
|
||||
>
|
||||
}
|
||||
@for (genre of slide.genres; track genre) {
|
||||
<span class="hero__pill">{{ genre }}</span>
|
||||
}
|
||||
@if (slide.category) {
|
||||
<span class="hero__pill">{{ slide.category }}</span>
|
||||
}
|
||||
@if (slide.timeRange) {
|
||||
<span class="hero__pill hero__pill--time">{{
|
||||
slide.timeRange
|
||||
}}</span>
|
||||
}
|
||||
@if (slide.year) {
|
||||
<span class="hero__pill">{{ slide.year }}</span>
|
||||
}
|
||||
@if (slide.source) {
|
||||
<span class="hero__pill hero__pill--source">{{
|
||||
slide.source
|
||||
}}</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (slide.programmeTitle) {
|
||||
<p
|
||||
class="hero__programme"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
>
|
||||
{{ slide.programmeTitle }}
|
||||
</p>
|
||||
}
|
||||
@if (slide.description) {
|
||||
<p class="hero__description">{{ slide.description }}</p>
|
||||
}
|
||||
|
||||
@if (slide.progress !== null) {
|
||||
<span
|
||||
class="hero__progress"
|
||||
[class.hero__progress--live]="
|
||||
slide.contentType === 'live'
|
||||
"
|
||||
role="progressbar"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
[attr.aria-valuenow]="slide.progress"
|
||||
>
|
||||
<i [style.width.%]="slide.progress"></i>
|
||||
</span>
|
||||
}
|
||||
|
||||
<div class="hero__actions">
|
||||
@let primary = slide.primaryAction;
|
||||
<a
|
||||
class="hero__button hero__button--primary"
|
||||
[attr.data-test-id]="primary.testId"
|
||||
[routerLink]="primary.link"
|
||||
[state]="primary.state ?? {}"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
primary.icon
|
||||
}}</mat-icon>
|
||||
<span>{{ primary.labelKey | translate }}</span>
|
||||
@if (primary.remainingLabel; as remaining) {
|
||||
<span class="hero__button-detail"
|
||||
>·
|
||||
{{
|
||||
remaining.key | translate: remaining.params
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
</a>
|
||||
@if (slide.secondaryAction; as secondary) {
|
||||
<a
|
||||
class="hero__button hero__button--secondary"
|
||||
[attr.data-test-id]="secondary.testId"
|
||||
[routerLink]="secondary.link"
|
||||
[state]="secondary.state ?? {}"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
secondary.icon
|
||||
}}</mat-icon>
|
||||
<span>{{ secondary.labelKey | translate }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (slides().length > 1) {
|
||||
<div class="hero__controls">
|
||||
@if (canRotate()) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero__pause"
|
||||
data-test-id="dashboard-hero-pause"
|
||||
[attr.aria-pressed]="userPaused()"
|
||||
[attr.aria-label]="
|
||||
(userPaused()
|
||||
? 'WORKSPACE.DASHBOARD.HERO_PLAY'
|
||||
: 'WORKSPACE.DASHBOARD.HERO_PAUSE'
|
||||
) | translate
|
||||
"
|
||||
(click)="togglePaused()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
userPaused() ? 'play_arrow' : 'pause'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
class="hero__dots"
|
||||
role="group"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_LABEL' | translate
|
||||
"
|
||||
>
|
||||
@for (slide of slides(); track slide.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="hero__dot"
|
||||
data-test-id="dashboard-hero-dot"
|
||||
[class.hero__dot--active]="$index === activeIndex()"
|
||||
[attr.aria-current]="
|
||||
$index === activeIndex() ? 'true' : null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'WORKSPACE.DASHBOARD.HERO_SHOW_SLIDE'
|
||||
| translate: { title: slide.title }
|
||||
"
|
||||
[attr.tabindex]="$index === activeIndex() ? 0 : -1"
|
||||
(click)="show($index)"
|
||||
(keydown)="onDotKeydown($event, $index)"
|
||||
>
|
||||
<span
|
||||
class="hero__dot-fill"
|
||||
(animationend)="onRotationTick()"
|
||||
></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else if (presenter.loading()) {
|
||||
<section
|
||||
class="hero hero--skeleton"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
data-test-id="dashboard-hero-skeleton"
|
||||
>
|
||||
<div class="hero__content">
|
||||
<span class="hero__skeleton hero__skeleton--eyebrow"></span>
|
||||
<span class="hero__skeleton hero__skeleton--title"></span>
|
||||
<span class="hero__skeleton hero__skeleton--meta"></span>
|
||||
<span class="hero__skeleton hero__skeleton--button"></span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
// Cinematic dashboard hero. Bleeds to the edges of the workspace content
|
||||
// area (the page's `--dashboard-gutter` padding and the centred max-width
|
||||
// are both cancelled out), keeps a height ceiling so the first rail still
|
||||
// starts above the fold, and dissolves into the page background instead of
|
||||
// sitting in a raised card.
|
||||
//
|
||||
// The scrim is the page background itself (`--app-content-bg`), so the
|
||||
// light theme gets a light scrim with dark text and the dark theme the
|
||||
// reverse, with the same geometry.
|
||||
|
||||
// The host renders nothing when there is no slide and no skeleton; with
|
||||
// `display: contents` it then adds no flex gap to the rails page either.
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.hero {
|
||||
// Offset of the rails' content edge from the content-area edge: the page
|
||||
// gutter plus whatever the centred max-width leaves on either side.
|
||||
// `100cqw` is the rails page host (the `dashboard` size container).
|
||||
--hero-inset: calc(
|
||||
var(--dashboard-gutter, 28px) +
|
||||
max(0px, (100cqw - var(--dashboard-max-width, 1440px)) / 2)
|
||||
);
|
||||
--hero-scrim: var(--app-content-bg, #161a22);
|
||||
--hero-heading: var(--app-heading-color, #d8dce8);
|
||||
--hero-body: color-mix(
|
||||
in srgb,
|
||||
var(--app-heading-color, #d8dce8) 72%,
|
||||
var(--hero-scrim)
|
||||
);
|
||||
--hero-pill-bg: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 12%,
|
||||
transparent
|
||||
);
|
||||
// Gold in the dark theme, a deeper amber on the light one: mixed with
|
||||
// the theme's heading colour rather than detected per theme.
|
||||
--hero-rating: color-mix(
|
||||
in srgb,
|
||||
#f5b400 60%,
|
||||
var(--app-heading-color, #d8dce8)
|
||||
);
|
||||
|
||||
position: relative;
|
||||
// A floor, not a fixed height: the slide sits in normal flow at the
|
||||
// bottom, so an unusually full slide (two-line title, programme,
|
||||
// synopsis, wrapped pills) grows the banner instead of losing its top.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
min-height: clamp(320px, 42vh, 520px);
|
||||
// Flush with the content area's top and sides; the rails page's own
|
||||
// top padding and gutters are cancelled. The negative bottom margin
|
||||
// tucks the first rail into the fade (the gradient does the spacing).
|
||||
margin: calc(-1 * var(--dashboard-top-gutter, 24px))
|
||||
calc(-1 * var(--hero-inset)) -12px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
color: var(--hero-heading);
|
||||
}
|
||||
|
||||
// ── Backdrops ──────────────────────────────────────────────────────────────
|
||||
.hero__stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 700ms ease;
|
||||
|
||||
&--active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__backdrop-fallback,
|
||||
.hero__backdrop-image {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero__backdrop-image {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center 22%;
|
||||
transform: scale(1.04);
|
||||
transition: transform 8s ease-out;
|
||||
|
||||
.hero__backdrop--active & {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// No 16:9 backdrop (Xtream/Stalker often lack one): the poster, blurred and
|
||||
// scaled, washes its colours across the whole stage…
|
||||
.hero__backdrop--poster .hero__backdrop-image {
|
||||
filter: blur(48px) saturate(1.4);
|
||||
transform: scale(1.35);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.hero__backdrop--poster.hero__backdrop--active .hero__backdrop-image {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
|
||||
// …and the sharp poster fills the right-hand side as key art, faded into
|
||||
// that wash, so the slide still shows the actual title artwork.
|
||||
.hero__backdrop-keyart {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: min(46%, 640px);
|
||||
object-fit: cover;
|
||||
object-position: center 18%;
|
||||
-webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
|
||||
mask-image: linear-gradient(90deg, transparent 0%, #000 45%);
|
||||
}
|
||||
|
||||
// Live channels: a logo is not a backdrop. The stage is a title-tinted
|
||||
// glow with a hint of the live accent; the logo, when there is one, sits
|
||||
// on the right as the slide's artwork over a soft wash of its own colours.
|
||||
.hero__backdrop--live {
|
||||
.hero__backdrop-fallback {
|
||||
background:
|
||||
radial-gradient(
|
||||
55% 120% at 78% 30%,
|
||||
hsl(var(--hero-hue, 220) 60% 45% / 0.45),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
40% 90% at 100% 100%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-live-color, #ff8f8f) 28%,
|
||||
transparent
|
||||
),
|
||||
transparent 70%
|
||||
),
|
||||
var(--hero-scrim);
|
||||
}
|
||||
|
||||
&.hero__backdrop--active .hero__backdrop-image,
|
||||
.hero__backdrop-image {
|
||||
inset: 10% 6% 10% auto;
|
||||
width: 40%;
|
||||
height: 80%;
|
||||
object-fit: contain;
|
||||
filter: blur(56px) saturate(1.6);
|
||||
opacity: 0.5;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hero__backdrop-keyart {
|
||||
top: 50%;
|
||||
right: calc(var(--hero-inset) + 8%);
|
||||
width: min(22%, 240px);
|
||||
height: 50%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
transform: translateY(-50%);
|
||||
filter: drop-shadow(0 12px 32px rgba(0, 0, 0, 0.45));
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 96%, transparent) 0%,
|
||||
color-mix(in srgb, var(--hero-scrim) 85%, transparent) 30%,
|
||||
color-mix(in srgb, var(--hero-scrim) 35%, transparent) 60%,
|
||||
transparent 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 30%, transparent) 0%,
|
||||
transparent 22%,
|
||||
transparent 55%,
|
||||
var(--hero-scrim) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// ── Slide content ──────────────────────────────────────────────────────────
|
||||
.hero__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
// Top padding keeps clear of the title bar edge when the slide grows.
|
||||
margin-left: var(--hero-inset);
|
||||
padding: 72px 0 34px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
max-width: min(560px, 55%);
|
||||
animation: hero-content-in 420ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
|
||||
}
|
||||
|
||||
@keyframes hero-content-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--hero-body);
|
||||
}
|
||||
|
||||
.hero__type--live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow: 0 0 8px currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__eyebrow-sep {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 3.4vw, 44px);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--hero-heading);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero__pill {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--hero-pill-bg);
|
||||
color: var(--hero-heading);
|
||||
white-space: nowrap;
|
||||
|
||||
&--rating {
|
||||
color: var(--hero-rating);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&--time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&--source {
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--hero-body);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__programme {
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: var(--hero-heading);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero__description {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
color: var(--hero-body);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.hero__progress {
|
||||
display: block;
|
||||
width: 220px;
|
||||
max-width: 100%;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 18%,
|
||||
transparent
|
||||
);
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--live i {
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hero__button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
padding: 0 18px 0 14px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background: var(--app-selection-color, #78adff);
|
||||
color: var(--app-selection-on-color, #071225);
|
||||
box-shadow: 0 6px 18px -8px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.6));
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-selection-color, #78adff) 88%,
|
||||
#fff
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 12%,
|
||||
transparent
|
||||
);
|
||||
color: var(--hero-heading);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 14%, transparent);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
&:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__button-detail {
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// ── Rotation controls ──────────────────────────────────────────────────────
|
||||
.hero__controls {
|
||||
position: absolute;
|
||||
right: var(--hero-inset);
|
||||
bottom: 34px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 999px;
|
||||
// Keeps the dots legible over bright key art in both themes.
|
||||
background: color-mix(in srgb, var(--hero-scrim) 55%, transparent);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.hero__pause {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--hero-body);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hero-pill-bg);
|
||||
color: var(--hero-heading);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__dots {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// 6px dots inside a 20px hit area; the active one stretches into a pill
|
||||
// whose fill runs for one rotation interval.
|
||||
.hero__dot {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
grid-area: 1 / 1;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 32%,
|
||||
transparent
|
||||
);
|
||||
transition:
|
||||
width 0.25s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 55%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline-offset: -2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
width: 30px;
|
||||
|
||||
&::before {
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__dot-fill {
|
||||
grid-area: 1 / 1;
|
||||
justify-self: start;
|
||||
// Aligns with the 18px pill centred in the 30px button.
|
||||
margin-left: 6px;
|
||||
width: 0;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--hero-heading);
|
||||
pointer-events: none;
|
||||
|
||||
.hero__dot--active & {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.hero--rotating .hero__dot--active & {
|
||||
animation: hero-dot-fill var(--hero-rotation-ms, 8000ms) linear both;
|
||||
}
|
||||
|
||||
.hero--paused .hero__dot--active & {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-dot-fill {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Skeleton ───────────────────────────────────────────────────────────────
|
||||
.hero--skeleton {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--app-widget-bg, #1e2330) 0%,
|
||||
var(--hero-scrim) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero__skeleton {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 0%,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 16%, transparent) 50%,
|
||||
color-mix(in srgb, var(--app-on-surface, #e6e1e5) 8%, transparent) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: hero-skeleton-shimmer 1.6s linear infinite;
|
||||
|
||||
&--eyebrow {
|
||||
width: 180px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
&--title {
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&--meta {
|
||||
width: 260px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&--button {
|
||||
width: 190px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hero-skeleton-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Narrow content area ────────────────────────────────────────────────────
|
||||
@container dashboard (max-width: 720px) {
|
||||
.hero__content {
|
||||
margin-right: var(--hero-inset);
|
||||
// Clears the rotation controls, which move to the top here.
|
||||
padding: 64px 0 28px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.hero__controls {
|
||||
top: 16px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.hero__scrim {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--hero-scrim) 80%, transparent) 0%,
|
||||
color-mix(in srgb, var(--hero-scrim) 40%, transparent) 100%
|
||||
),
|
||||
linear-gradient(180deg, transparent 30%, var(--hero-scrim) 100%);
|
||||
}
|
||||
|
||||
.hero__backdrop-keyart {
|
||||
width: 70%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero__backdrop,
|
||||
.hero__backdrop-image {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hero__content,
|
||||
.hero__skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { DashboardHeroComponent } from './dashboard-hero.component';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import type { DashboardHeroSlide } from './dashboard-hero.utils';
|
||||
|
||||
const slide = (id: string, title: string): DashboardHeroSlide => ({
|
||||
id,
|
||||
kind: 'continue',
|
||||
contentType: 'movie',
|
||||
title,
|
||||
typeLabelKey: 'WORKSPACE.DASHBOARD.TYPE_MOVIE',
|
||||
reasonLabelKey: 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING',
|
||||
episodeBadge: null,
|
||||
rating: null,
|
||||
genres: [],
|
||||
year: null,
|
||||
source: 'Source',
|
||||
programmeTitle: null,
|
||||
category: null,
|
||||
timeRange: null,
|
||||
description: null,
|
||||
progress: null,
|
||||
accentHue: 200,
|
||||
backdropSource: 'fallback',
|
||||
fallbackBackdropBackground: 'none',
|
||||
fallbackPosterBackground: 'none',
|
||||
hasBackdrop: false,
|
||||
primaryAction: {
|
||||
labelKey: 'WORKSPACE.DASHBOARD.HERO_CONTINUE',
|
||||
icon: 'play_arrow',
|
||||
link: ['/workspace', id],
|
||||
testId: 'dashboard-hero-primary-action',
|
||||
},
|
||||
secondaryAction: null,
|
||||
});
|
||||
|
||||
describe('DashboardHeroComponent', () => {
|
||||
let fixture: ComponentFixture<DashboardHeroComponent>;
|
||||
let slides: ReturnType<typeof signal<DashboardHeroSlide[]>>;
|
||||
let reducedMotion: boolean;
|
||||
|
||||
const host = () => fixture.nativeElement as HTMLElement;
|
||||
const activeTitle = () =>
|
||||
host().querySelector('[data-test-id=dashboard-hero-slide] h1')
|
||||
?.textContent;
|
||||
const dots = () =>
|
||||
Array.from(
|
||||
host().querySelectorAll<HTMLButtonElement>(
|
||||
'[data-test-id=dashboard-hero-dot]'
|
||||
)
|
||||
);
|
||||
const finishActiveDot = () => {
|
||||
const fill = host().querySelector(
|
||||
'.hero__dot--active .hero__dot-fill'
|
||||
) as HTMLElement;
|
||||
fill.dispatchEvent(new Event('animationend'));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
|
||||
function render(): void {
|
||||
window.matchMedia = jest.fn().mockReturnValue({
|
||||
matches: reducedMotion,
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [DashboardHeroComponent, TranslateModule.forRoot()],
|
||||
providers: [provideRouter([])],
|
||||
});
|
||||
TestBed.overrideComponent(DashboardHeroComponent, {
|
||||
set: {
|
||||
providers: [
|
||||
{
|
||||
provide: DashboardHeroSlidesPresenter,
|
||||
useValue: {
|
||||
slides,
|
||||
loading: signal(false),
|
||||
markImageFailed: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
fixture = TestBed.createComponent(DashboardHeroComponent);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
slides = signal([
|
||||
slide('a', 'First'),
|
||||
slide('b', 'Second'),
|
||||
slide('c', 'Third'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('advances when the active dot finishes its rotation interval', () => {
|
||||
render();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
|
||||
finishActiveDot();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
});
|
||||
|
||||
it('does not advance while the pointer or focus is inside the hero', () => {
|
||||
render();
|
||||
const section = host().querySelector(
|
||||
'[data-test-id=dashboard-hero]'
|
||||
) as HTMLElement;
|
||||
|
||||
section.dispatchEvent(new Event('mouseenter'));
|
||||
fixture.detectChanges();
|
||||
expect(section.classList).toContain('hero--paused');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
section.dispatchEvent(new Event('mouseleave'));
|
||||
fixture.detectChanges();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('lets the user pause and resume the rotation', () => {
|
||||
render();
|
||||
const pause = host().querySelector(
|
||||
'[data-test-id=dashboard-hero-pause]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
expect(pause.getAttribute('aria-pressed')).toBe('true');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('resumes on Play even while the button keeps the pointer and focus', () => {
|
||||
render();
|
||||
const section = host().querySelector(
|
||||
'[data-test-id=dashboard-hero]'
|
||||
) as HTMLElement;
|
||||
const pause = host().querySelector(
|
||||
'[data-test-id=dashboard-hero-pause]'
|
||||
) as HTMLButtonElement;
|
||||
section.dispatchEvent(new Event('mouseenter'));
|
||||
section.dispatchEvent(new FocusEvent('focusin'));
|
||||
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
pause.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(section.classList).not.toContain('hero--paused');
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('switches slides from the dots, with roving arrow keys', () => {
|
||||
render();
|
||||
|
||||
dots()[2].click();
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('Third');
|
||||
expect(dots()[2].getAttribute('aria-current')).toBe('true');
|
||||
expect(dots()[2].tabIndex).toBe(0);
|
||||
expect(dots()[0].tabIndex).toBe(-1);
|
||||
|
||||
dots()[2].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('First');
|
||||
});
|
||||
|
||||
it('keeps the current slide when a slide arrives late', () => {
|
||||
render();
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
slides.set([
|
||||
slide('a', 'First'),
|
||||
slide('live', 'Live channel'),
|
||||
slide('b', 'Second'),
|
||||
slide('c', 'Third'),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Second');
|
||||
expect(dots()).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('keeps the first slide when a slide arrives ahead of it untouched', () => {
|
||||
slides.set([slide('fav', 'Favourite'), slide('added', 'Import')]);
|
||||
render();
|
||||
expect(activeTitle()).toBe('Favourite');
|
||||
|
||||
slides.set([
|
||||
slide('live', 'Live channel'),
|
||||
slide('fav', 'Favourite'),
|
||||
slide('added', 'Import'),
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Favourite');
|
||||
});
|
||||
|
||||
it('shows the slide now at the same position when the active one goes', () => {
|
||||
render();
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
|
||||
slides.set([slide('a', 'First'), slide('c', 'Third')]);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(activeTitle()).toBe('Third');
|
||||
});
|
||||
|
||||
it('never auto-rotates under reduced motion, but the dots still work', () => {
|
||||
reducedMotion = true;
|
||||
render();
|
||||
|
||||
expect(
|
||||
host().querySelector('[data-test-id=dashboard-hero-pause]')
|
||||
).toBeNull();
|
||||
finishActiveDot();
|
||||
expect(activeTitle()).toBe('First');
|
||||
|
||||
dots()[1].click();
|
||||
fixture.detectChanges();
|
||||
expect(activeTitle()).toBe('Second');
|
||||
});
|
||||
|
||||
it('renders a single slide without rotation controls', () => {
|
||||
slides.set([slide('a', 'Only')]);
|
||||
render();
|
||||
|
||||
expect(activeTitle()).toBe('Only');
|
||||
expect(dots()).toHaveLength(0);
|
||||
expect(
|
||||
host()
|
||||
.querySelector('[data-test-id=dashboard-hero-primary-action]')
|
||||
?.getAttribute('href')
|
||||
).toBe('/workspace/a');
|
||||
});
|
||||
|
||||
it('renders nothing when there is nothing to feature', () => {
|
||||
slides.set([]);
|
||||
render();
|
||||
|
||||
expect(
|
||||
host().querySelector('[data-test-id=dashboard-hero]')
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
linkedSignal,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { DashboardHeroSlidesPresenter } from './dashboard-hero-slides.presenter';
|
||||
import { HERO_ROTATION_MS } from './dashboard-hero-slides.utils';
|
||||
import type { DashboardHeroSlide } from './dashboard-hero.utils';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/**
|
||||
* Cinematic dashboard hero: a full-bleed backdrop that rotates between an
|
||||
* unfinished title, a favourite channel on air now and a few discovery
|
||||
* picks (see `pickDashboardHeroSources`).
|
||||
*
|
||||
* Rotation is driven by the active dot's CSS fill animation: its
|
||||
* `animationend` advances the slide, so pausing (hover, focus inside the
|
||||
* hero, the pause button) is just `animation-play-state: paused` and resumes
|
||||
* where it stopped. With `prefers-reduced-motion` there is no animation and
|
||||
* therefore no auto-advance; the dots still switch slides.
|
||||
*
|
||||
* The active slide is tracked by id, so a slide that arrives late (the live
|
||||
* slide waits for its EPG answer) never yanks the user off the current one.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'lib-dashboard-hero',
|
||||
imports: [MatIcon, RouterLink, TranslatePipe],
|
||||
templateUrl: './dashboard-hero.component.html',
|
||||
styleUrl: './dashboard-hero.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [DashboardHeroSlidesPresenter],
|
||||
host: {
|
||||
'[style.--hero-rotation-ms]': 'rotationMs + "ms"',
|
||||
},
|
||||
})
|
||||
export class DashboardHeroComponent {
|
||||
readonly presenter = inject(DashboardHeroSlidesPresenter);
|
||||
readonly slides = this.presenter.slides;
|
||||
readonly rotationMs = HERO_ROTATION_MS;
|
||||
|
||||
/**
|
||||
* The slide on screen, by id. Pinned to the first slide as soon as one
|
||||
* exists, so a late slide inserted ahead of it cannot take its place;
|
||||
* if the active slide itself disappears, the one now at its position
|
||||
* takes over.
|
||||
*/
|
||||
private readonly activeId = linkedSignal<
|
||||
DashboardHeroSlide[],
|
||||
string | null
|
||||
>({
|
||||
source: this.slides,
|
||||
computation: (slides, previous) => {
|
||||
const currentId = previous?.value ?? null;
|
||||
if (currentId && slides.some((slide) => slide.id === currentId)) {
|
||||
return currentId;
|
||||
}
|
||||
const previousIndex =
|
||||
previous?.source.findIndex((slide) => slide.id === currentId) ??
|
||||
-1;
|
||||
const index = Math.min(
|
||||
Math.max(previousIndex, 0),
|
||||
slides.length - 1
|
||||
);
|
||||
return slides[index]?.id ?? null;
|
||||
},
|
||||
});
|
||||
private readonly hovered = signal(false);
|
||||
private readonly focusWithin = signal(false);
|
||||
readonly userPaused = signal(false);
|
||||
readonly reducedMotion = signal(false);
|
||||
|
||||
readonly activeIndex = computed(() => {
|
||||
const index = this.slides().findIndex(
|
||||
(slide) => slide.id === this.activeId()
|
||||
);
|
||||
return index >= 0 ? index : 0;
|
||||
});
|
||||
|
||||
readonly activeSlide = computed(
|
||||
() => this.slides()[this.activeIndex()] ?? null
|
||||
);
|
||||
|
||||
readonly canRotate = computed(
|
||||
() => this.slides().length > 1 && !this.reducedMotion()
|
||||
);
|
||||
|
||||
readonly paused = computed(
|
||||
() => this.userPaused() || this.hovered() || this.focusWithin()
|
||||
);
|
||||
|
||||
constructor() {
|
||||
const media =
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia(REDUCED_MOTION_QUERY)
|
||||
: null;
|
||||
if (media) {
|
||||
this.reducedMotion.set(media.matches);
|
||||
const onChange = (event: MediaQueryListEvent) =>
|
||||
this.reducedMotion.set(event.matches);
|
||||
media.addEventListener('change', onChange);
|
||||
inject(DestroyRef).onDestroy(() =>
|
||||
media.removeEventListener('change', onChange)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
show(index: number): void {
|
||||
const slide = this.slides()[index];
|
||||
if (slide) {
|
||||
this.activeId.set(slide.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** The active dot's fill animation finished: time for the next slide. */
|
||||
onRotationTick(): void {
|
||||
if (!this.canRotate() || this.paused()) {
|
||||
return;
|
||||
}
|
||||
this.show((this.activeIndex() + 1) % this.slides().length);
|
||||
}
|
||||
|
||||
togglePaused(): void {
|
||||
if (!this.userPaused()) {
|
||||
this.userPaused.set(true);
|
||||
return;
|
||||
}
|
||||
// An explicit Play wins over the implicit pauses: the pointer and
|
||||
// the focus are on this very button, so they would otherwise keep
|
||||
// the rotation stopped. They re-arm on the next enter / focus move.
|
||||
this.userPaused.set(false);
|
||||
this.hovered.set(false);
|
||||
this.focusWithin.set(false);
|
||||
}
|
||||
|
||||
setHovered(hovered: boolean): void {
|
||||
this.hovered.set(hovered);
|
||||
}
|
||||
|
||||
onFocusIn(): void {
|
||||
this.focusWithin.set(true);
|
||||
}
|
||||
|
||||
onFocusOut(event: FocusEvent): void {
|
||||
const host = event.currentTarget as HTMLElement | null;
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!host || !next || !host.contains(next)) {
|
||||
this.focusWithin.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Roving arrows between the dots, like a tab list. */
|
||||
onDotKeydown(event: KeyboardEvent, index: number): void {
|
||||
const count = this.slides().length;
|
||||
const step =
|
||||
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
if (step === 0 || count < 2) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const next = (index + step + count) % count;
|
||||
this.show(next);
|
||||
const group = (event.currentTarget as HTMLElement).parentElement;
|
||||
group?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,49 @@
|
||||
import type { CollectionContentType } from '@iptvnator/portal/shared/util';
|
||||
import type { DashboardRemainingLabel } from './dashboard-playback.utils';
|
||||
import type { DashboardHeroSlideKind } from './dashboard-hero-slides.utils';
|
||||
|
||||
export interface DashboardHeroModel {
|
||||
readonly backdropUrl?: string;
|
||||
readonly backdropSource: DashboardHeroBackdropSource;
|
||||
readonly contentType?: CollectionContentType;
|
||||
readonly fallbackBackdropBackground: string;
|
||||
readonly fallbackPosterBackground: string;
|
||||
readonly hasBackdrop: boolean;
|
||||
/** A hero call to action: a router link, never an in-place side effect. */
|
||||
export interface DashboardHeroAction {
|
||||
readonly labelKey: string;
|
||||
readonly icon: string;
|
||||
readonly link: string[];
|
||||
readonly posterUrl?: string;
|
||||
readonly state?: Record<string, unknown>;
|
||||
readonly subtitle: string;
|
||||
readonly title: string;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating?: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres?: readonly string[];
|
||||
/** "S2·E5" for series with a tracked episode position */
|
||||
readonly episodeBadge?: string | null;
|
||||
/** 0-100 watched, when a resume position is known. */
|
||||
readonly watchProgress?: number | null;
|
||||
/** Appended as "· 22m left" on a resume action. */
|
||||
readonly remainingLabel?: DashboardRemainingLabel | null;
|
||||
readonly nowPlayingTitle?: string | null;
|
||||
readonly nowPlayingTimeRange?: string | null;
|
||||
readonly nowPlayingProgress?: number | null;
|
||||
readonly testId: string;
|
||||
}
|
||||
|
||||
/** One rotation slide of the cinematic dashboard hero. */
|
||||
export interface DashboardHeroSlide extends DashboardHeroArtwork {
|
||||
/** Stable across data refreshes: the active slide is tracked by it. */
|
||||
readonly id: string;
|
||||
readonly kind: DashboardHeroSlideKind;
|
||||
readonly contentType: CollectionContentType;
|
||||
readonly title: string;
|
||||
/** "Movie" / "Series" / "Live" */
|
||||
readonly typeLabelKey: string;
|
||||
/** Why the slide is here: "Continue watching", "Favourite channel"… */
|
||||
readonly reasonLabelKey: string;
|
||||
readonly episodeBadge: string | null;
|
||||
/** TMDB vote average ("8.1"), patched in async when available */
|
||||
readonly rating: string | null;
|
||||
/** Up to two TMDB genre names, patched in async when available */
|
||||
readonly genres: readonly string[];
|
||||
readonly year: number | null;
|
||||
/** Source name (playlist label), never a raw URL or credentials */
|
||||
readonly source: string;
|
||||
/** Live: programme on air now, its guide category and "HH:mm – HH:mm" */
|
||||
readonly programmeTitle: string | null;
|
||||
readonly category: string | null;
|
||||
readonly timeRange: string | null;
|
||||
/** TMDB overview for titles, guide synopsis for live programmes */
|
||||
readonly description: string | null;
|
||||
/** 0-100: watched share, or elapsed share of the live programme */
|
||||
readonly progress: number | null;
|
||||
/** Title-derived hue for the live slide's glow (0-359) */
|
||||
readonly accentHue: number;
|
||||
readonly primaryAction: DashboardHeroAction;
|
||||
readonly secondaryAction: DashboardHeroAction | null;
|
||||
}
|
||||
|
||||
export type DashboardHeroBackdropSource = 'backdrop' | 'poster' | 'fallback';
|
||||
@@ -101,6 +119,11 @@ function buildFallbackBackground(
|
||||
return `linear-gradient(135deg, hsl(${hue}, ${saturationA}%, ${lightnessA}%) 0%, hsl(${h2}, ${saturationB}%, ${lightnessB}%) 100%)`;
|
||||
}
|
||||
|
||||
/** Stable hue for a title, shared by every generated fallback surface. */
|
||||
export function dashboardHeroHue(text: string): number {
|
||||
return calculateHue(text || 'placeholder');
|
||||
}
|
||||
|
||||
function calculateHue(text: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
|
||||
+67
-18
@@ -43,6 +43,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
let getCurrentProgramsForChannels: jest.Mock;
|
||||
let playlists: ReturnType<typeof signal<PlaylistMeta[]>>;
|
||||
let recentItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let favoriteLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let recentLiveItems: ReturnType<typeof signal<PortalActivityItem[]>>;
|
||||
let dashboardRails: ReturnType<
|
||||
typeof signal<typeof DEFAULT_DASHBOARD_RAILS_SETTINGS>
|
||||
>;
|
||||
@@ -74,6 +76,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
]);
|
||||
|
||||
recentItems = signal<PortalActivityItem[]>([]);
|
||||
favoriteLiveItems = signal<PortalActivityItem[]>([]);
|
||||
recentLiveItems = signal<PortalActivityItem[]>([]);
|
||||
dashboardRails = signal({ ...DEFAULT_DASHBOARD_RAILS_SETTINGS });
|
||||
portal = {
|
||||
connect: jest.fn(),
|
||||
@@ -96,8 +100,8 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
playlists,
|
||||
// The presenter also derives the portal source list.
|
||||
globalRecentItems: recentItems,
|
||||
globalFavoriteLiveItems: signal([]),
|
||||
globalRecentLiveItems: signal([]),
|
||||
globalFavoriteLiveItems: favoriteLiveItems,
|
||||
globalRecentLiveItems: recentLiveItems,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -177,30 +181,75 @@ describe('DashboardLiveEpgPresenter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('pins the hero row only while the hero rail shows one', () => {
|
||||
const heroLive = {
|
||||
id: 'x-7',
|
||||
title: 'Hero channel',
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: 7,
|
||||
} as PortalActivityItem;
|
||||
recentItems.set([heroLive]);
|
||||
it('pins the hero live candidates only while the hero is enabled', () => {
|
||||
const live = (id: number, title: string) =>
|
||||
({
|
||||
id: `x-${id}`,
|
||||
title,
|
||||
type: 'live',
|
||||
source: 'xtream',
|
||||
playlist_id: 'p',
|
||||
category_id: '1',
|
||||
xtream_id: id,
|
||||
}) as PortalActivityItem;
|
||||
favoriteLiveItems.set([live(7, 'Favourite channel')]);
|
||||
recentLiveItems.set([live(8, 'Recent channel'), live(7, 'Dup')]);
|
||||
TestBed.tick();
|
||||
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith(['xtream::p::7']);
|
||||
// Favourites first, then recent channels, each channel once.
|
||||
expect(
|
||||
presenter
|
||||
.heroLiveCandidates()
|
||||
.map(({ origin, item }) => [origin, item.title])
|
||||
).toEqual([
|
||||
['favorite', 'Favourite channel'],
|
||||
['recent', 'Recent channel'],
|
||||
]);
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([
|
||||
'xtream::p::7',
|
||||
'xtream::p::8',
|
||||
]);
|
||||
|
||||
// With the rail hidden nothing is pinned: the first portal row is
|
||||
// then a favourite, and pinning it would keep asking for a card
|
||||
// nobody can see.
|
||||
// With the hero hidden nothing is pinned: a rail card nobody can
|
||||
// see must not keep the portal queue busy.
|
||||
dashboardRails.set({
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
hero: false,
|
||||
});
|
||||
TestBed.tick();
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([null]);
|
||||
expect(presenter.heroLiveCandidates()).toEqual([]);
|
||||
expect(portal.setPinnedKeys).toHaveBeenLastCalledWith([]);
|
||||
});
|
||||
|
||||
it('looks up hero candidates even when no live rail is connected', () => {
|
||||
getCurrentProgramsForChannels.mockImplementation(() =>
|
||||
of(
|
||||
new Map<string, EpgProgram | null>([
|
||||
['ard.de', program('Tagesschau')],
|
||||
])
|
||||
)
|
||||
);
|
||||
const channel = {
|
||||
id: 'ard-hd',
|
||||
title: 'Das Erste HD',
|
||||
type: 'live',
|
||||
source: 'm3u',
|
||||
playlist_id: 'a',
|
||||
category_id: '',
|
||||
xtream_id: 'ard-hd',
|
||||
epg_lookup_key: 'ard.de',
|
||||
} as PortalActivityItem;
|
||||
favoriteLiveItems.set([channel]);
|
||||
|
||||
setup([]);
|
||||
|
||||
expect(getCurrentProgramsForChannels).toHaveBeenCalledWith(
|
||||
['ard.de'],
|
||||
expect.objectContaining({ sourceUrls: [guideA] })
|
||||
);
|
||||
expect(presenter.heroDetailsFor(channel)?.nowPlayingTitle).toBe(
|
||||
'Tagesschau'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the portal answer and forwards what the portal presenter owns', () => {
|
||||
|
||||
@@ -44,6 +44,10 @@ import {
|
||||
type DashboardLiveEpgLookupGroup,
|
||||
} from './dashboard-live-epg.utils';
|
||||
import { RAIL_ITEM_LIMIT } from './dashboard-rail.utils';
|
||||
import {
|
||||
selectDashboardHeroLiveCandidates,
|
||||
type DashboardHeroLiveCandidate,
|
||||
} from './dashboard-hero-slides.utils';
|
||||
|
||||
type ScopeAnswer = {
|
||||
readonly scopeKey: string;
|
||||
@@ -105,9 +109,34 @@ export class DashboardLiveEpgPresenter {
|
||||
return byPlaylistId;
|
||||
});
|
||||
|
||||
private readonly rails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
/**
|
||||
* Channels that may fill the hero's live slide. Looked up and pinned
|
||||
* here, independent of the rails, so the slide works with the live rails
|
||||
* hidden and never waits for a rail to scroll a card into view.
|
||||
*/
|
||||
readonly heroLiveCandidates = computed<DashboardHeroLiveCandidate[]>(() =>
|
||||
this.rails().hero
|
||||
? selectDashboardHeroLiveCandidates(
|
||||
this.data.globalFavoriteLiveItems(),
|
||||
this.data.globalRecentLiveItems()
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
private readonly heroLiveCards = computed(() =>
|
||||
this.heroLiveCandidates().map(({ item }) =>
|
||||
buildDashboardLiveEpgCard(item)
|
||||
)
|
||||
);
|
||||
|
||||
private readonly lookupGroups = computed(() =>
|
||||
buildLiveEpgLookupGroups(this.cards()?.() ?? [], (card) =>
|
||||
this.sourceUrlsForCard(card)
|
||||
buildLiveEpgLookupGroups(
|
||||
[...this.heroLiveCards(), ...(this.cards()?.() ?? [])],
|
||||
(card) => this.sourceUrlsForCard(card)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -131,25 +160,14 @@ export class DashboardLiveEpgPresenter {
|
||||
{ initialValue: new Map<string, EpgProgram | null>() }
|
||||
);
|
||||
|
||||
private readonly rails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
/** The live row behind the hero panel, when that rail shows one. */
|
||||
private readonly heroLiveItem = computed<PortalActivityItem | null>(() => {
|
||||
const hero = this.data.globalRecentItems()[0] ?? null;
|
||||
return this.rails().hero && hero?.type === 'live' ? hero : null;
|
||||
});
|
||||
|
||||
// The Xtream/Stalker live rows behind the hero and the two live rails.
|
||||
// Their programmes come from the portal, asked for lazily per visible
|
||||
// card; M3U rows stay on the XMLTV batch above.
|
||||
private readonly portalItems = computed<readonly PortalActivityItem[]>(
|
||||
() => {
|
||||
const rails = this.rails();
|
||||
const hero = this.heroLiveItem();
|
||||
return [
|
||||
...(hero ? [hero] : []),
|
||||
...this.heroLiveCandidates().map(({ item }) => item),
|
||||
...(rails.liveFavorites
|
||||
? this.data
|
||||
.globalFavoriteLiveItems()
|
||||
@@ -167,18 +185,17 @@ export class DashboardLiveEpgPresenter {
|
||||
constructor() {
|
||||
this.portal.connect(this.portalItems);
|
||||
// The hero sits at the top of the page and is never scrolled into
|
||||
// view, so its key is wanted regardless of what the rails report.
|
||||
// Only the hero: the first entry of `portalItems` is a favourite
|
||||
// when that rail is hidden, and pinning it would keep asking for a
|
||||
// card nobody can see.
|
||||
// view, so its candidates are wanted regardless of what the rails
|
||||
// report. Only those: a rail card nobody can see stays unpinned.
|
||||
effect(() => {
|
||||
const hero = this.heroLiveItem();
|
||||
const heroKey = hero ? buildDashboardPortalLiveEpgKey(hero) : null;
|
||||
untracked(() => this.portal.setPinnedKeys([heroKey]));
|
||||
const keys = this.heroLiveCandidates().map(({ item }) =>
|
||||
buildDashboardPortalLiveEpgKey(item)
|
||||
);
|
||||
untracked(() => this.portal.setPinnedKeys(keys));
|
||||
});
|
||||
}
|
||||
|
||||
/** The live cards whose rails are enabled, hero included. */
|
||||
/** The live cards whose rails are enabled (hero candidates are added here). */
|
||||
connect(cards: Signal<readonly DashboardRailCard[]>): void {
|
||||
this.cards.set(cards);
|
||||
}
|
||||
@@ -206,6 +223,11 @@ export class DashboardLiveEpgPresenter {
|
||||
});
|
||||
}
|
||||
|
||||
/** Current programme of a hero live candidate, or `null`. */
|
||||
heroDetailsFor(item: PortalActivityItem): DashboardLiveEpgDetails | null {
|
||||
return this.detailsFor(buildDashboardLiveEpgCard(item));
|
||||
}
|
||||
|
||||
/** `null` when nothing is known about the card's current programme. */
|
||||
detailsFor(card: DashboardRailCard | null): DashboardLiveEpgDetails | null {
|
||||
if (!card) {
|
||||
@@ -285,3 +307,19 @@ function mergeAnswers(
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** The fields the EPG lookups read, for a live row that has no rail card. */
|
||||
function buildDashboardLiveEpgCard(
|
||||
item: PortalActivityItem
|
||||
): DashboardRailCard {
|
||||
return {
|
||||
id: `hero-live-${item.playlist_id}-${item.xtream_id ?? item.id}`,
|
||||
title: item.title,
|
||||
icon: 'live_tv',
|
||||
contentType: 'live',
|
||||
link: [],
|
||||
epgLookupKey: item.epg_lookup_key,
|
||||
epgPlaylistId: item.playlist_id,
|
||||
liveEpgSourceKey: buildDashboardPortalLiveEpgKey(item),
|
||||
};
|
||||
}
|
||||
@@ -74,6 +74,10 @@ export interface DashboardLiveEpgDetails {
|
||||
readonly nowPlayingTitle: string | null;
|
||||
readonly nowPlayingTimeRange: string | null;
|
||||
readonly nowPlayingProgress: number | null;
|
||||
/** Programme synopsis; set only when the guide has one (hero slide). */
|
||||
readonly nowPlayingDescription?: string;
|
||||
/** Guide category ("Sport"); set only when the guide has one. */
|
||||
readonly nowPlayingCategory?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,6 +94,8 @@ export function buildDashboardLiveEpgDetails(
|
||||
return null;
|
||||
}
|
||||
|
||||
const description = program.desc?.trim();
|
||||
const category = program.category?.trim();
|
||||
const details: DashboardLiveEpgDetails = {
|
||||
nowPlayingTitle: program.title?.trim() || null,
|
||||
nowPlayingTimeRange: formatEpgTimeRange(program, offsetMinutes),
|
||||
@@ -97,6 +103,8 @@ export function buildDashboardLiveEpgDetails(
|
||||
program,
|
||||
epgProviderClockMs(nowMs, offsetMinutes)
|
||||
),
|
||||
...(description ? { nowPlayingDescription: description } : {}),
|
||||
...(category ? { nowPlayingCategory: category } : {}),
|
||||
};
|
||||
|
||||
return details.nowPlayingTitle ||
|
||||
@@ -204,17 +212,19 @@ export function buildLiveEpgLookupGroups(
|
||||
|
||||
type DashboardLiveEpgRailSettings = Pick<
|
||||
DashboardRailsSettings,
|
||||
'hero' | 'liveFavorites' | 'recentlyWatchedLive'
|
||||
'liveFavorites' | 'recentlyWatchedLive'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The rails' live cards whose rails are enabled. Hero candidates are not
|
||||
* passed here: `DashboardLiveEpgPresenter` derives and pins them itself.
|
||||
*/
|
||||
export function buildLiveEpgCardsForEnabledRails(
|
||||
rails: DashboardLiveEpgRailSettings,
|
||||
heroLiveCard: DashboardRailCard | null,
|
||||
liveFavoriteCards: readonly DashboardRailCard[],
|
||||
recentLiveCards: readonly DashboardRailCard[]
|
||||
): DashboardRailCard[] {
|
||||
return [
|
||||
...(rails.hero && heroLiveCard ? [heroLiveCard] : []),
|
||||
...(rails.liveFavorites ? liveFavoriteCards : []),
|
||||
...(rails.recentlyWatchedLive ? recentLiveCards : []),
|
||||
];
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
resolvePortalActivityWatchKind,
|
||||
type PlaybackPositionData,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { GlobalRecentItem } from '@iptvnator/workspace/dashboard/data-access';
|
||||
|
||||
export interface DashboardRemainingLabel {
|
||||
@@ -83,3 +88,23 @@ export function formatRemainingLabel(
|
||||
params: { hours, minutes },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on the
|
||||
* WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes as a
|
||||
* movie but still names the episode it is on.
|
||||
*/
|
||||
export function buildDashboardEpisodeBadge(
|
||||
item: Pick<PortalActivityItem, 'type' | 'watch_kind'>,
|
||||
position: PlaybackPositionData | null,
|
||||
translate: (key: string, params: Record<string, number>) => string
|
||||
): string | null {
|
||||
return resolvePortalActivityWatchKind(item) === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? translate('WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE', {
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
})
|
||||
: null;
|
||||
}
|
||||
+5
-191
@@ -10,198 +10,12 @@
|
||||
(addPlaylistClicked)="onAddPlaylist($event)"
|
||||
/>
|
||||
} @else {
|
||||
<!-- Hero: real card if a recent item resolved, skeleton while still
|
||||
loading the first global recent item, nothing if no recent items
|
||||
exist. Each block below renders as soon as ITS data is ready —
|
||||
no longer pinned on the slowest rail's I/O. -->
|
||||
<!-- Hero: rotating cinematic banner. Renders its own skeleton while
|
||||
the first history load runs, and nothing when there is nothing
|
||||
to feature. Each block below renders as soon as ITS data is
|
||||
ready — no longer pinned on the slowest rail's I/O. -->
|
||||
@if (dashboardRails().hero) {
|
||||
@if (hero(); as item) {
|
||||
<a
|
||||
class="hero"
|
||||
data-test-id="dashboard-hero"
|
||||
[class.hero--with-image]="
|
||||
!!item.posterUrl || !!item.backdropUrl
|
||||
"
|
||||
[class.hero--has-backdrop]="item.hasBackdrop"
|
||||
[class.hero--has-default-backdrop]="
|
||||
item.backdropSource === 'fallback'
|
||||
"
|
||||
[class.hero--live]="item.contentType === 'live'"
|
||||
[routerLink]="item.link"
|
||||
[state]="item.state ?? {}"
|
||||
>
|
||||
<span
|
||||
class="hero__backdrop"
|
||||
aria-hidden="true"
|
||||
[style.backgroundImage]="
|
||||
item.backdropUrl
|
||||
? 'url(' + item.backdropUrl + ')'
|
||||
: item.fallbackBackdropBackground
|
||||
"
|
||||
></span>
|
||||
@if (item.backdropUrl; as backdropUrl) {
|
||||
<img
|
||||
class="hero__image-probe"
|
||||
[src]="backdropUrl"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(backdropUrl)"
|
||||
/>
|
||||
}
|
||||
<span class="hero__vignette" aria-hidden="true"></span>
|
||||
|
||||
<span class="hero__poster">
|
||||
@if (item.posterUrl; as posterUrl) {
|
||||
<img
|
||||
[src]="posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
(error)="markHeroImageFailed(posterUrl)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="hero__poster-fallback"
|
||||
[style.backgroundImage]="
|
||||
item.fallbackPosterBackground
|
||||
"
|
||||
>
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<span class="hero__body">
|
||||
<h1 class="hero__title">{{ item.title }}</h1>
|
||||
<p class="hero__subtitle">{{ item.subtitle }}</p>
|
||||
|
||||
@if (
|
||||
item.episodeBadge ||
|
||||
item.rating ||
|
||||
(item.genres && item.genres.length > 0)
|
||||
) {
|
||||
<span
|
||||
class="hero__badges"
|
||||
data-test-id="dashboard-hero-badges"
|
||||
>
|
||||
@if (item.episodeBadge) {
|
||||
<span
|
||||
class="hero__badge hero__badge--episode"
|
||||
>{{ item.episodeBadge }}</span
|
||||
>
|
||||
}
|
||||
@if (item.rating) {
|
||||
<span
|
||||
class="hero__badge hero__badge--rating"
|
||||
>★ {{ item.rating }}</span
|
||||
>
|
||||
}
|
||||
@for (genre of item.genres; track genre) {
|
||||
<span class="hero__badge">{{ genre }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (
|
||||
item.contentType === 'live' &&
|
||||
(item.nowPlayingTitle ||
|
||||
item.nowPlayingTimeRange ||
|
||||
(item.nowPlayingProgress !== null &&
|
||||
item.nowPlayingProgress !== undefined))
|
||||
) {
|
||||
<span
|
||||
class="hero__live-epg"
|
||||
data-test-id="dashboard-hero-live-epg"
|
||||
>
|
||||
<span class="hero__live-epg-topline">
|
||||
<span class="hero__live-epg-chip"
|
||||
>LIVE</span
|
||||
>
|
||||
@if (item.nowPlayingTimeRange) {
|
||||
<span class="hero__live-epg-time">{{
|
||||
item.nowPlayingTimeRange
|
||||
}}</span>
|
||||
}
|
||||
</span>
|
||||
@if (item.nowPlayingTitle) {
|
||||
<span class="hero__live-epg-title">{{
|
||||
item.nowPlayingTitle
|
||||
}}</span>
|
||||
}
|
||||
<span
|
||||
class="hero__live-epg-progress"
|
||||
[class.hero__live-epg-progress--idle]="
|
||||
item.nowPlayingProgress === null ||
|
||||
item.nowPlayingProgress === undefined
|
||||
"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i
|
||||
[style.width.%]="
|
||||
item.nowPlayingProgress ?? 0
|
||||
"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (
|
||||
item.watchProgress !== null &&
|
||||
item.watchProgress !== undefined
|
||||
) {
|
||||
<span class="hero__progress" aria-hidden="true">
|
||||
<span
|
||||
class="hero__progress-bar"
|
||||
[style.width.%]="item.watchProgress"
|
||||
></span>
|
||||
</span>
|
||||
<span class="hero__progress-meta">
|
||||
@if (item.remainingLabel) {
|
||||
<span>{{
|
||||
item.remainingLabel.key
|
||||
| translate
|
||||
: item.remainingLabel.params
|
||||
}}</span>
|
||||
<span class="hero__progress-meta-sep"
|
||||
>·</span
|
||||
>
|
||||
}
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.PERCENT_WATCHED'
|
||||
| translate
|
||||
: { value: item.watchProgress }
|
||||
}}</span>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span class="hero__cta">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.DASHBOARD.CONTINUE_WATCHING'
|
||||
| translate
|
||||
}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
} @else if (data.globalRecentLoading()) {
|
||||
<section
|
||||
class="rails-page__skeleton-hero"
|
||||
aria-hidden="true"
|
||||
aria-busy="true"
|
||||
>
|
||||
<div class="rails-page__skeleton-hero-poster"></div>
|
||||
<div class="rails-page__skeleton-hero-body">
|
||||
<div class="rails-page__skeleton-hero-title"></div>
|
||||
<div
|
||||
class="rails-page__skeleton-hero-title rails-page__skeleton-hero-title--short"
|
||||
></div>
|
||||
<div class="rails-page__skeleton-hero-subtitle"></div>
|
||||
<div class="rails-page__skeleton-hero-cta"></div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
<lib-dashboard-hero />
|
||||
}
|
||||
|
||||
<!-- Continue watching (movies + series) -->
|
||||
|
||||
+12
-582
@@ -1,8 +1,14 @@
|
||||
:host {
|
||||
// Read by the full-bleed hero to cancel the gutters and centring out.
|
||||
--dashboard-gutter: 28px;
|
||||
--dashboard-top-gutter: 24px;
|
||||
--dashboard-max-width: 1440px;
|
||||
|
||||
display: block;
|
||||
padding: 24px 28px 40px;
|
||||
padding: var(--dashboard-top-gutter) var(--dashboard-gutter) 40px;
|
||||
box-sizing: border-box;
|
||||
min-height: 100%;
|
||||
container: dashboard / inline-size;
|
||||
}
|
||||
|
||||
:host(.rails-page-host--empty) {
|
||||
@@ -14,7 +20,7 @@
|
||||
}
|
||||
|
||||
.rails-page {
|
||||
max-width: 1440px;
|
||||
max-width: var(--dashboard-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -32,504 +38,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// ── Hero ────────────────────────────────────────────────────────────────────
|
||||
// Netflix/Apple TV-style hero: blurred poster backdrop + vignette + sharp 2:3
|
||||
// poster card + text block. Mirrors content-hero fallback (no dedicated
|
||||
// backdrop asset available, so we reuse the poster blurred + scaled).
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, var(--hero-poster-width)) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
background: var(--app-widget-header-bg, #232840);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
0 12px 32px -18px var(--app-selection-glow, rgba(120, 173, 255, 0.4));
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
// Tighter than v0.21 (was 160px). Matches the v0.22 mockup's 150px
|
||||
// poster intent, freeing space for the progress + remaining label
|
||||
// without growing total hero height.
|
||||
--hero-poster-width: 140px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.2),
|
||||
0 20px 40px -20px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.55));
|
||||
}
|
||||
|
||||
// No-image fallback keeps the accent gradient so the hero is never flat.
|
||||
&:not(.hero--with-image) {
|
||||
background:
|
||||
var(
|
||||
--app-accent-gradient,
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(99, 102, 241, 0.12) 0%,
|
||||
rgba(168, 85, 247, 0.07) 100%
|
||||
)
|
||||
),
|
||||
var(--app-widget-header-bg, #232840);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 0: backdrop. Two modes:
|
||||
// (1) Real backdrop (`.hero--has-backdrop`): sharp wide image with no blur.
|
||||
// (2) Blurred-poster fallback: heavy blur + scale so the poster's colours
|
||||
// wash across the full panel without the portrait edges showing.
|
||||
.hero__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center 25%;
|
||||
background-repeat: no-repeat;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
|
||||
// Fallback: blurred poster.
|
||||
filter: blur(40px) saturate(1.5);
|
||||
transform: scale(1.5);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero--has-backdrop .hero__backdrop {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.hero--has-default-backdrop .hero__backdrop {
|
||||
filter: none;
|
||||
transform: none;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.hero__image-probe {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
clip-path: inset(50%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Layer 1: vignette. Scrim behind the poster card (contrast) and on the
|
||||
// right edge (text legibility). Middle stays mostly transparent so the
|
||||
// backdrop colours show through. Darkens further when a sharp backdrop is
|
||||
// present, since real photography has more focal variation than a blur wash.
|
||||
.hero__vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 55%,
|
||||
transparent
|
||||
)
|
||||
0%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 10%,
|
||||
transparent
|
||||
)
|
||||
45%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 65%,
|
||||
transparent
|
||||
)
|
||||
100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
transparent 55%,
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--app-widget-header-bg, #232840) 35%,
|
||||
transparent
|
||||
)
|
||||
100%
|
||||
);
|
||||
}
|
||||
|
||||
.hero--has-backdrop .hero__vignette {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.55) 0%,
|
||||
rgba(0, 0, 0, 0.2) 35%,
|
||||
rgba(0, 0, 0, 0.78) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0) 40%,
|
||||
rgba(0, 0, 0, 0.45) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Layer 2: sharp poster in native 2:3 aspect (no more 16:9 crop).
|
||||
.hero__poster {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: var(--hero-poster-width);
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--app-widget-bg, #1e2330);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.1));
|
||||
box-shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.55);
|
||||
align-self: center;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.hero__poster-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: radial-gradient(
|
||||
circle at 50% 40%,
|
||||
var(--app-selection-surface, rgba(120, 173, 255, 0.2)) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 56px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--app-selection-color, #78adff);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
// Live channel logos (small square images) get destroyed by the default
|
||||
// `object-fit: cover` poster treatment. The `.hero--live` modifier centers
|
||||
// the logo with breathing room over a tinted backdrop. The blurred
|
||||
// `.hero__backdrop` already provides the ambient color wash for the wider hero.
|
||||
.hero--live .hero__poster {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 50% 35%,
|
||||
var(--app-selection-surface, rgba(120, 173, 255, 0.18)) 0%,
|
||||
transparent 65%
|
||||
),
|
||||
var(--app-widget-bg, #1e2330);
|
||||
|
||||
img {
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
padding: 22% 18%;
|
||||
filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.45));
|
||||
}
|
||||
}
|
||||
|
||||
.hero__body {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
// Display-weight tracking: looser than -0.02em which crushes glyphs at
|
||||
// this size (~28px). Matches Apple TV+ "Up Next" card scale.
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
letter-spacing: 0.002em;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.hero__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
background: rgba(128, 138, 160, 0.14);
|
||||
border: 1px solid rgba(128, 138, 160, 0.22);
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
&--rating {
|
||||
color: #ffd166;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #ffd166;
|
||||
}
|
||||
}
|
||||
|
||||
&--episode {
|
||||
color: #7ddb8a;
|
||||
|
||||
.hero--with-image & {
|
||||
color: #7ddb8a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: min(440px, 100%);
|
||||
margin-top: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__live-epg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 0 0 auto;
|
||||
font-family: var(
|
||||
--font-mono,
|
||||
ui-monospace,
|
||||
'SF Mono',
|
||||
'JetBrains Mono',
|
||||
Menlo,
|
||||
monospace
|
||||
);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
color: var(--app-live-color, #ff8f8f);
|
||||
text-transform: uppercase;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
box-shadow: 0 0 7px var(--app-live-color, #ff8f8f);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-time {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-muted-color, #8891a4);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-title {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.25;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__live-epg-progress {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--app-live-color, #ff8f8f);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
&--idle i {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 22%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Watch-progress visualisation. Appears between the subtitle and the CTA
|
||||
// only when a resume position is known — VOD/series only (M3U and live
|
||||
// channels have no playback positions in the schema).
|
||||
.hero__progress {
|
||||
display: block;
|
||||
position: relative;
|
||||
margin-top: 12px;
|
||||
height: 4px;
|
||||
width: min(320px, 70%);
|
||||
border-radius: 999px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #e6e1e5) 14%,
|
||||
transparent
|
||||
);
|
||||
overflow: hidden;
|
||||
|
||||
.hero--with-image & {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
border-radius: 999px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.hero__progress-meta {
|
||||
margin-top: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
color: var(--app-body-color, #8b93a8);
|
||||
letter-spacing: 0.005em;
|
||||
|
||||
.hero--with-image & {
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
}
|
||||
|
||||
.hero__progress-meta-sep {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.hero__cta {
|
||||
margin-top: 14px;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 20px 10px 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--app-selection-color, #78adff);
|
||||
color: var(--app-selection-on-color, #071225);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.005em;
|
||||
// Explicit min-height anchors the text baseline regardless of font-smoothing
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 4px 12px -4px
|
||||
var(--app-selection-glow, rgba(120, 173, 255, 0.5));
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading skeleton ────────────────────────────────────────────────────────
|
||||
// Geometry mirrors the real hero + rails so the layout doesn't shift when
|
||||
// data arrives. All shimmer targets share the `%skeleton-shimmer` placeholder
|
||||
@@ -555,61 +63,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hero ghost ──────────────────────────────────────────────────────────────
|
||||
.rails-page__skeleton-hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
padding: 20px;
|
||||
border-radius: 18px;
|
||||
background: var(--app-widget-header-bg, #232840);
|
||||
border: 1px solid var(--app-widget-header-border, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-poster {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 160px;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: 12px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-title {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 60%;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-title--short {
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-subtitle {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 45%;
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-cta {
|
||||
@extend %skeleton-shimmer;
|
||||
width: 160px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
// ── Rail ghost ──────────────────────────────────────────────────────────────
|
||||
.rails-page__skeleton-rail {
|
||||
display: flex;
|
||||
@@ -643,10 +96,6 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rails-page__skeleton-hero-poster,
|
||||
.rails-page__skeleton-hero-title,
|
||||
.rails-page__skeleton-hero-subtitle,
|
||||
.rails-page__skeleton-hero-cta,
|
||||
.rails-page__skeleton-header,
|
||||
.rails-page__skeleton-card {
|
||||
animation: none;
|
||||
@@ -657,37 +106,18 @@
|
||||
// ── Responsive ──────────────────────────────────────────────────────────────
|
||||
@media (max-width: 720px) {
|
||||
:host {
|
||||
padding: 16px 16px 32px;
|
||||
--dashboard-gutter: 16px;
|
||||
--dashboard-top-gutter: 16px;
|
||||
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.rails-page {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
--hero-poster-width: 120px;
|
||||
grid-template-columns: var(--hero-poster-width) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
// Skeleton hero mirrors real hero at this breakpoint.
|
||||
.rails-page__skeleton-hero {
|
||||
grid-template-columns: 120px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-hero-poster {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rails-page__skeleton-card {
|
||||
flex: 0 0 min(var(--cover-rail-width, 172px), 140px);
|
||||
width: min(var(--cover-rail-width, 172px), 140px);
|
||||
}
|
||||
|
||||
.hero__title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
+36
-25
@@ -289,6 +289,30 @@ describe('EPG enrichment helpers', () => {
|
||||
nowPlayingProgress: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('adds the synopsis and category only when the guide has them', () => {
|
||||
const now = START_S * 1000;
|
||||
|
||||
expect(
|
||||
buildDashboardLiveEpgDetails(
|
||||
baseProgram({
|
||||
desc: ' Live from Moscow. ',
|
||||
category: 'Sport',
|
||||
}),
|
||||
now
|
||||
)
|
||||
).toMatchObject({
|
||||
nowPlayingDescription: 'Live from Moscow.',
|
||||
nowPlayingCategory: 'Sport',
|
||||
});
|
||||
|
||||
const bare = buildDashboardLiveEpgDetails(
|
||||
baseProgram({ desc: ' ', category: null }),
|
||||
now
|
||||
);
|
||||
expect(bare).not.toHaveProperty('nowPlayingDescription');
|
||||
expect(bare).not.toHaveProperty('nowPlayingCategory');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,29 +568,7 @@ describe('Live rail helpers', () => {
|
||||
expect(first).toBe('a::movie::10|b::series::20');
|
||||
});
|
||||
|
||||
it('omits hero cards from live EPG lookup sources when the hero rail is disabled', () => {
|
||||
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
|
||||
const favorite = channelCard({
|
||||
id: 'favorite',
|
||||
epgLookupKey: 'favorite',
|
||||
});
|
||||
const recent = channelCard({ id: 'recent', epgLookupKey: 'recent' });
|
||||
|
||||
expect(
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
hero: false,
|
||||
},
|
||||
hero,
|
||||
[favorite],
|
||||
[recent]
|
||||
).map((card) => card.id)
|
||||
).toEqual(['favorite', 'recent']);
|
||||
});
|
||||
|
||||
it('omits disabled live rails from live EPG lookup sources', () => {
|
||||
const hero = channelCard({ id: 'hero', epgLookupKey: 'hero' });
|
||||
const favorite = channelCard({
|
||||
id: 'favorite',
|
||||
epgLookupKey: 'favorite',
|
||||
@@ -578,13 +580,22 @@ describe('Live rail helpers', () => {
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
liveFavorites: false,
|
||||
recentlyWatchedLive: false,
|
||||
},
|
||||
hero,
|
||||
[favorite],
|
||||
[recent]
|
||||
).map((card) => card.id)
|
||||
).toEqual(['hero']);
|
||||
).toEqual(['recent']);
|
||||
expect(
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
{
|
||||
...DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
liveFavorites: false,
|
||||
recentlyWatchedLive: false,
|
||||
},
|
||||
[favorite],
|
||||
[recent]
|
||||
)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+11
-152
@@ -4,7 +4,6 @@ import {
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
@@ -13,15 +12,11 @@ import {
|
||||
isStalkerAccountPlaylist,
|
||||
isXtreamAccountPlaylist,
|
||||
normalizeDashboardRailsSettings,
|
||||
type PlaybackPositionData,
|
||||
playlistDisplayLabel,
|
||||
resolvePortalActivityWatchKind,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { isPortalPlaybackWatched } from '@iptvnator/portal/shared/util';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
@@ -55,20 +50,18 @@ import {
|
||||
resolveSourceExpiryBadge,
|
||||
SOURCE_EXPIRY_TICK_MS,
|
||||
} from '@iptvnator/workspace/dashboard/data-access';
|
||||
import type { DashboardHeroTmdbExtras } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardHeroTmdbService } from './dashboard-hero-tmdb.service';
|
||||
import { DashboardRailComponent } from './dashboard-rail.component';
|
||||
import type {
|
||||
DashboardRailCard,
|
||||
DashboardRailActionSelection,
|
||||
} from './dashboard-rail.component';
|
||||
import type { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import type { DashboardHeroModel } from './dashboard-hero.utils';
|
||||
import { DashboardPortalLiveEpgPresenter } from './dashboard-portal-live-epg.presenter';
|
||||
import { resolveDashboardHeroArtwork } from './dashboard-hero.utils';
|
||||
import { DashboardHeroComponent } from './dashboard-hero.component';
|
||||
import { buildLiveEpgCardsForEnabledRails } from './dashboard-live-epg.utils';
|
||||
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
|
||||
import {
|
||||
buildDashboardEpisodeBadge,
|
||||
buildPlaybackPositionReloadKey,
|
||||
formatRemainingLabel,
|
||||
isContinueWatchingRecentItem,
|
||||
@@ -94,11 +87,9 @@ import type {
|
||||
@Component({
|
||||
selector: 'lib-workspace-dashboard-rails',
|
||||
imports: [
|
||||
DashboardHeroComponent,
|
||||
DashboardRailComponent,
|
||||
EmptyStateComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
RouterLink,
|
||||
TranslatePipe,
|
||||
],
|
||||
templateUrl: './workspace-dashboard-rails.component.html',
|
||||
@@ -130,7 +121,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private readonly shellActions = inject(WORKSPACE_SHELL_ACTIONS);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly heroTmdb = inject(DashboardHeroTmdbService);
|
||||
private readonly sourceExpiry = inject(DashboardSourceExpiryService);
|
||||
readonly trendingService = inject(DashboardTrendingService);
|
||||
readonly recommendationsService = inject(DashboardRecommendationsService);
|
||||
@@ -143,77 +133,10 @@ export class WorkspaceDashboardRailsComponent {
|
||||
readonly skeletonSlots = SKELETON_CARDS_PER_RAIL;
|
||||
readonly skeletonRails = SKELETON_RAILS;
|
||||
readonly liveRailTitleKeyForSource = liveRailTitleKeyForSource;
|
||||
readonly failedHeroImages = signal<Record<string, true>>({});
|
||||
readonly dashboardRails = computed(() =>
|
||||
normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.())
|
||||
);
|
||||
|
||||
private readonly heroRecentItem = computed(
|
||||
() => this.data.globalRecentItems()[0] ?? null
|
||||
);
|
||||
|
||||
/** TMDB extras for the current hero item, patched in after first paint */
|
||||
private readonly heroTmdbExtras = signal<{
|
||||
key: string;
|
||||
extras: DashboardHeroTmdbExtras | null;
|
||||
} | null>(null);
|
||||
|
||||
private readonly heroLiveCard = computed<DashboardRailCard | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
return item?.type === 'live' ? this.toRecentCard(item) : null;
|
||||
});
|
||||
|
||||
readonly hero = computed<DashboardHeroModel | null>(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single reactive read, gated on the TMDB opt-in so cached
|
||||
// extras vanish immediately when the user opts out mid-session
|
||||
const extrasState = this.heroTmdbExtras();
|
||||
const extras =
|
||||
this.heroTmdb.isEnabled() &&
|
||||
extrasState?.key === this.heroTmdbKey(item)
|
||||
? extrasState.extras
|
||||
: null;
|
||||
const artwork = resolveDashboardHeroArtwork(
|
||||
{
|
||||
backdropUrl: item.backdrop_url || (extras?.backdropUrl ?? null),
|
||||
posterUrl: item.poster_url,
|
||||
title: item.title,
|
||||
},
|
||||
this.failedHeroImages()
|
||||
);
|
||||
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const liveEpgDetails =
|
||||
item.type === 'live'
|
||||
? this.liveEpg.detailsFor(this.heroLiveCard())
|
||||
: null;
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
|
||||
return {
|
||||
...artwork,
|
||||
contentType: item.type,
|
||||
icon: this.typeIcon(item.type),
|
||||
link: this.data.getRecentItemLink(item),
|
||||
state: this.data.getRecentItemNavigationState(item),
|
||||
subtitle: this.buildHeroSubtitle(item),
|
||||
title: item.title,
|
||||
rating: extras?.rating ?? null,
|
||||
genres: extras?.genres ?? [],
|
||||
episodeBadge,
|
||||
watchProgress:
|
||||
item.type === 'live' ? null : playbackProgressPercent(position),
|
||||
remainingLabel:
|
||||
item.type === 'live' ? null : formatRemainingLabel(position),
|
||||
nowPlayingTitle: liveEpgDetails?.nowPlayingTitle ?? null,
|
||||
nowPlayingTimeRange: liveEpgDetails?.nowPlayingTimeRange ?? null,
|
||||
nowPlayingProgress: liveEpgDetails?.nowPlayingProgress ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
readonly continueWatchingBaseCards = computed<DashboardRailCard[]>(() => {
|
||||
this.languageTick();
|
||||
return this.data
|
||||
@@ -255,12 +178,11 @@ export class WorkspaceDashboardRailsComponent {
|
||||
})
|
||||
);
|
||||
|
||||
// The live cards whose rails are enabled; the presenter looks their
|
||||
// programmes up per XMLTV source scope.
|
||||
// The live cards whose rails are enabled; the presenter adds the hero's
|
||||
// live candidates and looks the programmes up per XMLTV source scope.
|
||||
private readonly enabledLiveCards = computed(() =>
|
||||
buildLiveEpgCardsForEnabledRails(
|
||||
this.dashboardRails(),
|
||||
this.heroLiveCard(),
|
||||
this.liveFavoriteCards(),
|
||||
this.recentLiveCards()
|
||||
)
|
||||
@@ -405,27 +327,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
untracked(() => void this.data.reloadPlaybackPositions());
|
||||
});
|
||||
|
||||
// TMDB extras for the hero (backdrop/rating/genres) — async after
|
||||
// first paint, staleness-guarded against hero changes in flight.
|
||||
effect(() => {
|
||||
const item = this.heroRecentItem();
|
||||
if (!item || (item.type !== 'movie' && item.type !== 'series')) {
|
||||
return;
|
||||
}
|
||||
const key = this.heroTmdbKey(item);
|
||||
untracked(() => {
|
||||
if (this.heroTmdbExtras()?.key === key) {
|
||||
return;
|
||||
}
|
||||
void this.heroTmdb.getExtras(item).then((extras) => {
|
||||
const current = untracked(() => this.heroRecentItem());
|
||||
if (current && this.heroTmdbKey(current) === key) {
|
||||
this.heroTmdbExtras.set({ key, extras });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Subscription-expiry badges for the source cards. Xtream rides the
|
||||
// shared PortalStatusService cache; Stalker reads the import-time
|
||||
// snapshot (memoized per playlist), so this stays cheap on re-entry.
|
||||
@@ -478,12 +379,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
this.shellActions.openAddPlaylistDialog(type);
|
||||
}
|
||||
|
||||
markHeroImageFailed(url: string): void {
|
||||
this.failedHeroImages.update((state) =>
|
||||
state[url] ? state : { ...state, [url]: true }
|
||||
);
|
||||
}
|
||||
|
||||
onSourceActionSelected(selection: DashboardRailActionSelection): void {
|
||||
const playlist = this.data
|
||||
.playlists()
|
||||
@@ -576,46 +471,14 @@ export class WorkspaceDashboardRailsComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the source name under the hero title. Provider kind (Xtream /
|
||||
* Stalker / M3U) and content kind (movie / series) are the app's own
|
||||
* taxonomy, not a property of the title, and the badges row already
|
||||
* says "S1·E5"; a stored playlist name can be a pasted URL with
|
||||
* credentials or a MAC, so it goes through `playlistDisplayLabel`.
|
||||
*/
|
||||
private buildHeroSubtitle(item: GlobalRecentItem): string {
|
||||
return playlistDisplayLabel(
|
||||
item.playlist_name,
|
||||
this.data.getRecentItemProviderLabel(item)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "S1·E5" for an item whose progress is tracked per episode. Keyed on
|
||||
* the WATCH kind: a Stalker embedded-VOD / lazy `is_series` show routes
|
||||
* as a movie but still names the episode it is on.
|
||||
*/
|
||||
private buildEpisodeBadge(
|
||||
item: GlobalRecentItem,
|
||||
position: PlaybackPositionData | null
|
||||
): string | null {
|
||||
return resolvePortalActivityWatchKind(item) === 'series' &&
|
||||
position?.seasonNumber != null &&
|
||||
position?.episodeNumber != null
|
||||
? this.translate.instant(
|
||||
'WORKSPACE.DASHBOARD.SEASON_EPISODE_BADGE',
|
||||
{
|
||||
season: position.seasonNumber,
|
||||
episode: position.episodeNumber,
|
||||
}
|
||||
)
|
||||
: null;
|
||||
}
|
||||
|
||||
private toRecentCard(item: GlobalRecentItem): DashboardRailCard {
|
||||
const position = this.data.getPlaybackPositionForItem(item);
|
||||
const watchProgress = playbackProgressPercent(position);
|
||||
const episodeBadge = this.buildEpisodeBadge(item, position);
|
||||
const episodeBadge = buildDashboardEpisodeBadge(
|
||||
item,
|
||||
position,
|
||||
(key, params) => this.translate.instant(key, params)
|
||||
);
|
||||
return {
|
||||
id: this.recentCardId(item),
|
||||
title: item.title,
|
||||
@@ -686,10 +549,6 @@ export class WorkspaceDashboardRailsComponent {
|
||||
};
|
||||
}
|
||||
|
||||
private heroTmdbKey(item: GlobalRecentItem): string {
|
||||
return this.heroTmdb.keyFor(item);
|
||||
}
|
||||
|
||||
private toTrendingCard(item: DashboardTrendingItem): DashboardRailCard {
|
||||
const subtitle = [
|
||||
item.year !== null ? String(item.year) : null,
|
||||
|
||||
@@ -146,6 +146,9 @@
|
||||
"@iptvnator/shared/database/path-utils": [
|
||||
"libs/shared/database/src/lib/path-utils.ts"
|
||||
],
|
||||
"@iptvnator/shared/database/connection-observer": [
|
||||
"libs/shared/database/src/lib/connection-observer.ts"
|
||||
],
|
||||
"@iptvnator/workspace/dashboard/feature": [
|
||||
"libs/workspace/dashboard/feature/src/index.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user