diff --git a/.changes/workspace-dashboard-cinematic-hero.md b/.changes/workspace-dashboard-cinematic-hero.md new file mode 100644 index 000000000..bb59bcfd4 --- /dev/null +++ b/.changes/workspace-dashboard-cinematic-hero.md @@ -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. diff --git a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts index d0e6df701..b02d42fe3 100644 --- a/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts +++ b/apps/electron-backend-e2e/src/backup-roundtrip.e2e.ts @@ -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; diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index af7ae8137..eea9a1df4 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { - 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; diff --git a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts index 9fe311cb1..91387fc9d 100644 --- a/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts +++ b/apps/electron-backend-e2e/src/journeys/journey-renderer-gate-client.ts @@ -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; } diff --git a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts index 93eb52f1e..4e59103d8 100644 --- a/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/launch-journey-app.ts @@ -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, diff --git a/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts new file mode 100644 index 000000000..34684c01d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts @@ -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 = {}, + frozenAtEpochMs: Record = {} +) { + 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)['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)[ + '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')]); +}); diff --git a/apps/electron-backend-e2e/src/performance/journey-main-counters.ts b/apps/electron-backend-e2e/src/performance/journey-main-counters.ts new file mode 100644 index 000000000..e71d37a4b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/journey-main-counters.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>; + readonly frozenAtEpochMs: Readonly>; +} + +export async function readJourneyMainCounters( + electronApp: ElectronApplication, + gateKey: string +): Promise { + return electronApp.evaluate( + async (_electron, input) => { + const gate = (globalThis as unknown as Record)[ + input.gateKey + ] as + | { invokeHandler?: (channel: string) => Promise } + | 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 { + 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 { + 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 +): JourneyMainCountersState { + const snapshot = value as Partial | 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 }; +} diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts index 77a461533..e203d4540 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate-client.spec.ts @@ -16,6 +16,7 @@ function gate( gatedEpochMs: 1_020, gatedMethod: 'loadFile', passThroughLoads: 0, + readyToShowHeldOnBlank: 1, releasedEpochMs: 1_150, timedOut: false, ...overrides, diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs index bac5ee866..be80ae151 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.cjs @@ -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 }); } diff --git a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts index 12cef8643..a6e44978d 100644 --- a/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts +++ b/apps/electron-backend-e2e/src/performance/journey-renderer-gate.spec.ts @@ -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; release(): GateState; state: GateState; } +interface FakeIpcMain { + handle(channel: string, listener: (...args: unknown[]) => unknown): void; +} + interface GateModule { GATE_KEY: string; installJourneyRendererGate( browserWindow: { prototype: Record }, target: Record, - 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 { + 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; + }, + {}, + { 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; + }, + {}, + { 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; + }, + {}, + { timeoutMs: 60_000 } + ); + await assert.rejects( + api.invokeHandler('performance:read-counters'), + /journey-ipc-handler-tap-missing/ + ); + api.release(); +}); diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts index 4820f1c60..0e67e6e2c 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.spec.ts @@ -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); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts index 14343fc0a..b2ce47508 100644 --- a/apps/electron-backend-e2e/src/performance/launch-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/launch-journey-record.ts @@ -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 > = 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, diff --git a/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts b/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts new file mode 100644 index 000000000..9f4728465 --- /dev/null +++ b/apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts @@ -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 { + 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 { + return (await readVisibleSidebarCategories(page)) + .map((category) => category.name) + .filter(Boolean); +} diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index ee9eae415..e275e1e03 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -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 diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index 5b711f2ba..930e64ce2 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -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> { + 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', { diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index 898f09368..a21aace33 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -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; diff --git a/apps/electron-backend/src/app/services/debug-trace.spec.ts b/apps/electron-backend/src/app/services/debug-trace.spec.ts index c9346f2fd..bd3bbc958 100644 --- a/apps/electron-backend/src/app/services/debug-trace.spec.ts +++ b/apps/electron-backend/src/app/services/debug-trace.spec.ts @@ -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) { + 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); + }); +}); diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index 87c08da74..d9b37eb5e 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -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] ` 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?.()); + } +} diff --git a/apps/electron-backend/src/app/services/main-sql-statement-count.spec.ts b/apps/electron-backend/src/app/services/main-sql-statement-count.spec.ts new file mode 100644 index 000000000..144bcf271 --- /dev/null +++ b/apps/electron-backend/src/app/services/main-sql-statement-count.spec.ts @@ -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; + 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); + }); +}); diff --git a/apps/electron-backend/src/app/services/main-sql-statement-count.ts b/apps/electron-backend/src/app/services/main-sql-statement-count.ts new file mode 100644 index 000000000..30fab438a --- /dev/null +++ b/apps/electron-backend/src/app/services/main-sql-statement-count.ts @@ -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) + ); + }); +} diff --git a/apps/electron-backend/src/app/services/performance-counters.spec.ts b/apps/electron-backend/src/app/services/performance-counters.spec.ts new file mode 100644 index 000000000..653fe0b2e --- /dev/null +++ b/apps/electron-backend/src/app/services/performance-counters.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/performance-counters.ts b/apps/electron-backend/src/app/services/performance-counters.ts new file mode 100644 index 000000000..378ba99e4 --- /dev/null +++ b/apps/electron-backend/src/app/services/performance-counters.ts @@ -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>; + /** Epoch milliseconds at which each frozen counter was taken. */ + readonly frozenAtEpochMs: Readonly>; +} + +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): Record { + return Object.fromEntries( + [...entries].sort(([left], [right]) => left.localeCompare(right)) + ); +} + +export function createPerformanceCounterRegistry( + isEnabled: () => boolean, + readEpochMs: () => number = Date.now +): PerformanceCounterRegistry { + const counters = new Map(); + const frozenAtEpochMs = new Map(); + + 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, + 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 + ); + }); +} diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts index 03e41d8f9..54d076987 100644 --- a/apps/electron-backend/src/app/startup/deferred-events.ts +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -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 { 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); diff --git a/apps/electron-backend/src/app/workers/database-worker-progress-throttle.spec.ts b/apps/electron-backend/src/app/workers/database-worker-progress-throttle.spec.ts index d0258f4e3..12487093f 100644 --- a/apps/electron-backend/src/app/workers/database-worker-progress-throttle.spec.ts +++ b/apps/electron-backend/src/app/workers/database-worker-progress-throttle.spec.ts @@ -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', () => ({ diff --git a/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.spec.ts b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.spec.ts new file mode 100644 index 000000000..ed92ad41f --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.spec.ts @@ -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; + 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(); + } + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts new file mode 100644 index 000000000..1f79cccc4 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts @@ -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, + 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; + const restores = [ + ...STATEMENT_EXECUTION_METHODS.map((method) => + wrapExecution(statementPrototype, method, record) + ), + wrapExecution( + connection as unknown as Record, + '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; + return type === DB_WORKER_SQL_STATEMENTS_MESSAGE_TYPE && + Number.isSafeInteger(count) && + (count as number) > 0 + ? (count as number) + : null; +} diff --git a/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.wiring.spec.ts b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.wiring.spec.ts new file mode 100644 index 000000000..bbaf39ab8 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-sql-statement-count.wiring.spec.ts @@ -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; + 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>, + 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']); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts b/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts index b32236964..950995719 100644 --- a/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts @@ -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', () => ({ diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 2aa403a03..732b80312 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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; diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 122213d7a..cff5b79cc 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -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 { if (db) { return db; @@ -70,6 +91,9 @@ export async function getWorkerDatabase(): Promise { ? (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'); diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 73b780635..3f8404593 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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); } diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index ba613ca64..46154bf7b 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -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', () => ({ diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 0961a0f9b..c7b20c4b6 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -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(); }); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 4514539d0..0877219e8 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index aebd003cf..9cbc82ade 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 6956affeb..0dbe5a074 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2fde7bcdc..86a474657 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index bd7a280ee..546239853 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a9c69ed3a..47985a3f1 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e1d9fa9a5..ad7cdd9d5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e62de7adb..d0f14997f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 792e33eb4..8d695043c 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index ce173034f..dcc928ee9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 74d34ae6f..769c1b6f2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 72ce5ee18..6d6f364ed 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ed51d72d8..9750d29b1 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 084c14c4a..bb0aab0a8 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f0c246995..919c838f9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 4ea1b1456..cc40bc0fa 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index aed2740a3..845e11f02 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 51cdc5518..eca04c505 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 66b5135d4..1e8f34b9d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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", diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 7a737f876..2a698b00b 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -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 diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 088dec70c..4b8e5e92d 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -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 diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 94f64cea8..74a9e72a8 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 0ff903425..ff50576c9 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -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`, diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 995e2ba63..352f8cb79 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -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 `` 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. diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index 1e9f60f55..1d88fc503 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -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=` relocates it. The startup trace reports the outcome as `compile-cache` diff --git a/libs/shared/database/README.md b/libs/shared/database/README.md index d9fe92911..c1b2e0383 100644 --- a/libs/shared/database/README.md +++ b/libs/shared/database/README.md @@ -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` diff --git a/libs/shared/database/src/index.ts b/libs/shared/database/src/index.ts index 9df4dff04..26cfa5705 100644 --- a/libs/shared/database/src/index.ts +++ b/libs/shared/database/src/index.ts @@ -7,3 +7,4 @@ export * from './lib/schema'; export * from './lib/connection'; export * from './lib/path-utils'; +export * from './lib/connection-observer'; diff --git a/libs/shared/database/src/lib/connection-observer.spec.ts b/libs/shared/database/src/lib/connection-observer.spec.ts new file mode 100644 index 000000000..0cc845b34 --- /dev/null +++ b/libs/shared/database/src/lib/connection-observer.spec.ts @@ -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]]); + }); +}); diff --git a/libs/shared/database/src/lib/connection-observer.ts b/libs/shared/database/src/lib/connection-observer.ts new file mode 100644 index 000000000..8eac84dad --- /dev/null +++ b/libs/shared/database/src/lib/connection-observer.ts @@ -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); +} diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 99ef8e184..934ba84bc 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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; @@ -1228,6 +1229,7 @@ export async function initDatabase( ? (message?: unknown) => traceSqlStatement(message) : undefined, }); + notifyDatabaseConnectionOpened(sqlite); if (isSqlTraceEnabled()) { traceSql('sql-main', 'open', { diff --git a/libs/shared/database/src/lib/testing/connection-upgrade.ts b/libs/shared/database/src/lib/testing/connection-upgrade.ts index 94cdaa19a..dcfaa1c47 100644 --- a/libs/shared/database/src/lib/testing/connection-upgrade.ts +++ b/libs/shared/database/src/lib/testing/connection-upgrade.ts @@ -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'); } diff --git a/libs/workspace/dashboard/feature/README.md b/libs/workspace/dashboard/feature/README.md index 8c8fe5acd..72d0f8144 100644 --- a/libs/workspace/dashboard/feature/README.md +++ b/libs/workspace/dashboard/feature/README.md @@ -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. diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts new file mode 100644 index 000000000..ebb72ab5d --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -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>; + let favorites: ReturnType>; + let addedItems: ReturnType>; + let candidates: ReturnType>; + let liveDetails: jest.Mock; + let tmdbEnabled: ReturnType>; + let tmdbLanguage: ReturnType>; + let getExtras: jest.Mock; + const positions = new Map([ + [ + 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', + }); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts new file mode 100644 index 000000000..9321cac0a --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -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>({}); + private readonly tmdbExtras = signal< + ReadonlyMap + >(new Map()); + private readonly requestedTmdbKeys = new Set(); + + /** 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(() => { + 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 { + 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 | 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 | undefined +): DashboardHeroAction { + return { + labelKey: 'WORKSPACE.DASHBOARD.HERO_DETAILS', + icon: 'info', + link, + state, + testId: 'dashboard-hero-primary-action', + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts new file mode 100644 index 000000000..e5e9f4119 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts @@ -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 => ({ + continueItems: [], + live: null, + reserveLive: false, + favorites: [], + recentlyAdded: [], + mostRecent: null, + ...overrides, +}); + +const summary = (sources: ReturnType) => + 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')) + ); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts new file mode 100644 index 000000000..22dae216f --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts @@ -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 +): 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(); + 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(); + 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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts index e750713da..cec4be3b4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.spec.ts @@ -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); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts index d01d9af63..44d146fa6 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-tmdb.service.ts @@ -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), }; } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html new file mode 100644 index 000000000..49f632790 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html @@ -0,0 +1,255 @@ +@if (activeSlide(); as active) { +
+ + + + @for (slide of [active]; track slide.id) { +
+

+ {{ slide.typeLabelKey | translate }} + + {{ slide.reasonLabelKey | translate }} +

+ +

{{ slide.title }}

+ +

+ @if (slide.episodeBadge) { + {{ slide.episodeBadge }} + } + @if (slide.rating) { + ★ {{ slide.rating }} + } + @for (genre of slide.genres; track genre) { + {{ genre }} + } + @if (slide.category) { + {{ slide.category }} + } + @if (slide.timeRange) { + {{ + slide.timeRange + }} + } + @if (slide.year) { + {{ slide.year }} + } + @if (slide.source) { + {{ + slide.source + }} + } +

+ + @if (slide.programmeTitle) { +

+ {{ slide.programmeTitle }} +

+ } + @if (slide.description) { +

{{ slide.description }}

+ } + + @if (slide.progress !== null) { + + + + } + + +
+ } + + @if (slides().length > 1) { +
+ @if (canRotate()) { + + } +
+ @for (slide of slides(); track slide.id) { + + } +
+
+ } +
+} @else if (presenter.loading()) { + +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss new file mode 100644 index 000000000..408d15802 --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss @@ -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; + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts new file mode 100644 index 000000000..c05b4250f --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts @@ -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; + let slides: ReturnType>; + 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( + '[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(); + }); +}); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts new file mode 100644 index 000000000..a92e5fd0c --- /dev/null +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts @@ -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('button')[next]?.focus(); + } +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts index 6d80b7aca..6f210ac20 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.utils.ts @@ -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; - 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++) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts index 6b5d53e2b..1296dc64a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.spec.ts @@ -43,6 +43,8 @@ describe('DashboardLiveEpgPresenter', () => { let getCurrentProgramsForChannels: jest.Mock; let playlists: ReturnType>; let recentItems: ReturnType>; + let favoriteLiveItems: ReturnType>; + let recentLiveItems: ReturnType>; let dashboardRails: ReturnType< typeof signal >; @@ -74,6 +76,8 @@ describe('DashboardLiveEpgPresenter', () => { ]); recentItems = signal([]); + favoriteLiveItems = signal([]); + recentLiveItems = signal([]); 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([ + ['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', () => { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts index 70baea7e6..019ba3e04 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.presenter.ts @@ -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(() => + 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() } ); - private readonly rails = computed(() => - normalizeDashboardRailsSettings(this.settingsStore.dashboardRails?.()) - ); - - /** The live row behind the hero panel, when that rail shows one. */ - private readonly heroLiveItem = computed(() => { - 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( () => { 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): 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), + }; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index 98526cabb..d674e27a9 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -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 : []), ]; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts index baa0a5a75..6002e34a1 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-playback.utils.ts @@ -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, + position: PlaybackPositionData | null, + translate: (key: string, params: Record) => 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; +} diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 34db676c1..3e4c2f682 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -10,198 +10,12 @@ (addPlaylistClicked)="onAddPlaylist($event)" /> } @else { - + @if (dashboardRails().hero) { - @if (hero(); as item) { - - - @if (item.backdropUrl; as backdropUrl) { - - } - - - - @if (item.posterUrl; as posterUrl) { - - } @else { - - {{ item.icon }} - - } - - - -

{{ item.title }}

-

{{ item.subtitle }}

- - @if ( - item.episodeBadge || - item.rating || - (item.genres && item.genres.length > 0) - ) { - - @if (item.episodeBadge) { - {{ item.episodeBadge }} - } - @if (item.rating) { - ★ {{ item.rating }} - } - @for (genre of item.genres; track genre) { - {{ genre }} - } - - } - - @if ( - item.contentType === 'live' && - (item.nowPlayingTitle || - item.nowPlayingTimeRange || - (item.nowPlayingProgress !== null && - item.nowPlayingProgress !== undefined)) - ) { - - - LIVE - @if (item.nowPlayingTimeRange) { - {{ - item.nowPlayingTimeRange - }} - } - - @if (item.nowPlayingTitle) { - {{ - item.nowPlayingTitle - }} - } - - - } - - @if ( - item.watchProgress !== null && - item.watchProgress !== undefined - ) { - - - @if (item.remainingLabel) { - {{ - item.remainingLabel.key - | translate - : item.remainingLabel.params - }} - · - } - {{ - 'WORKSPACE.DASHBOARD.PERCENT_WATCHED' - | translate - : { value: item.watchProgress } - }} - - } - - - play_arrow - {{ - 'WORKSPACE.DASHBOARD.CONTINUE_WATCHING' - | translate - }} - -
-
- } @else if (data.globalRecentLoading()) { - - } + } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss index ccf9ddf99..077d7a999 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.scss @@ -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; - } } diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts index 74feeb38a..6d16a412c 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.spec.ts @@ -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([]); }); }); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 4a6edd308..7a7f8aa86 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -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>({}); 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(() => { - const item = this.heroRecentItem(); - return item?.type === 'live' ? this.toRecentCard(item) : null; - }); - - readonly hero = computed(() => { - 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(() => { 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, diff --git a/tsconfig.base.json b/tsconfig.base.json index c28a42972..b5eb9accb 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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" ],