From 31c2cb3bbc93aea793c0a0ca3a36a08bfebb746e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:55:58 +0200 Subject: [PATCH 1/3] test(e2e): read sidebar categories in one snapshot inside polls (#1728) The "allows restoring live categories after every category is hidden" Electron test failed intermittently on shard 1 across unrelated PRs and master. The app hid every category correctly (the failure screenshot shows "No categories available"), but readVisibleSidebarCategoryNames looped over count() with per-row nth(index) reads. When the sidebar removed a row between isVisible() and textContent(), textContent() auto-waited for the missing element, so the expect.poll predicate never returned and the poll timed out with "waiting on the predicate" instead of retrying. Read the visible rows with a single evaluateAll() snapshot in a shared sidebar-categories.e2e-support.ts helper, use it for the category picker in category-management and backup-roundtrip too, and record the rule in the validation map. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../src/backup-roundtrip.e2e.ts | 35 ++++------- .../src/category-management.e2e.ts | 62 +++---------------- .../src/sidebar-categories.e2e-support.ts | 43 +++++++++++++ docs/architecture/validation-map.md | 7 +++ 4 files changed, 70 insertions(+), 77 deletions(-) create mode 100644 apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts 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/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/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`, From 1d9a563d1a486fcc9aa56d6cc94b46b97518a47e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:01:26 +0200 Subject: [PATCH 2/3] test(performance): count startup phases and SQL statements for the J1 launch journey (#1715) * test(performance): count startup phases and SQL statements for the J1 launch journey Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process keeps named counters and registers a main-only performance:read-counters IPC handler; without the flag nothing is counted and the handler does not exist. - debug-trace.ts owns the registry; traceStartupPhase replaces the trace('startup', ...) sites and counts main.startupPhases. - The database worker counts executed statements through better-sqlite3's Statement prototype (the verbose callback expands every statement and made bulk inserts 2-4x slower) and posts the count over its message port, flushed before every other worker message. The main-thread shared connection is counted through a new connection observer in the shared database library. - The first main window freezes main.modulesRegisteredBeforeWindow at creation and main.sqlStatementsBeforeReadyToShow at ready-to-show. - The journey gate drops the ready-to-show that Electron emits for the about:blank detour, so the app sees the real document's first paint, and taps the counters handler; the J1 record reads both counters after the renderer probe completes. Co-Authored-By: Claude Opus 5.5 * test(database): require one SQL statement per exec during initialization The performance capture counts one exec call as one statement, because SQL cannot be split reliably in the counter (trigger bodies contain semicolons). The historical-upgrade driver now wraps exec on every connection initDatabase opens and fails on a batch, so that counting assumption holds for the fresh profile and all historical schemas. Documents the definition in the counter and the architecture docs. Co-Authored-By: Claude Opus 5.5 * test(performance): count SQL statements only for the launch journey Codex review: the M3U import, refresh-cancellation and Xtream benchmarks also run with IPTVNATOR_PERF_CAPTURE=1, so the statement hook wrapped every row of their bulk inserts and changed what they measure. SQL counting now also needs IPTVNATOR_PERF_COUNT_SQL=1, which only the launch journey sets; a harness test fails if another source sets it. Startup phases, the window snapshot and the read handler stay on the capture flag. Without SQL counting no ready-to-show listener is attached, so a zero is never reported for statements nobody counted. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../journeys/journey-renderer-gate-client.ts | 2 + .../src/journeys/launch-journey-app.ts | 23 ++- .../performance/journey-main-counters.spec.ts | 147 +++++++++++++ .../src/performance/journey-main-counters.ts | 138 +++++++++++++ .../journey-renderer-gate-client.spec.ts | 1 + .../src/performance/journey-renderer-gate.cjs | 68 +++++- .../performance/journey-renderer-gate.spec.ts | 115 ++++++++++- .../performance/launch-journey-record.spec.ts | 36 +++- .../src/performance/launch-journey-record.ts | 39 +++- apps/electron-backend/src/app/app.ts | 29 ++- .../services/database-worker-client.spec.ts | 57 ++++++ .../app/services/database-worker-client.ts | 17 ++ .../src/app/services/debug-trace.spec.ts | 95 +++++++++ .../src/app/services/debug-trace.ts | 39 ++++ .../services/main-sql-statement-count.spec.ts | 85 ++++++++ .../app/services/main-sql-statement-count.ts | 38 ++++ .../app/services/performance-counters.spec.ts | 193 ++++++++++++++++++ .../src/app/services/performance-counters.ts | 129 ++++++++++++ .../src/app/startup/deferred-events.ts | 18 +- .../database-worker-progress-throttle.spec.ts | 1 + ...atabase-worker-sql-statement-count.spec.ts | 177 ++++++++++++++++ .../database-worker-sql-statement-count.ts | 157 ++++++++++++++ ...-worker-sql-statement-count.wiring.spec.ts | 176 ++++++++++++++++ ...ase-worker-zero-delay-cancellation.spec.ts | 1 + .../src/app/workers/database-worker.types.ts | 2 + .../app/workers/database.worker-connection.ts | 26 ++- .../src/app/workers/database.worker.ts | 2 + .../worker-performance-cancellation.spec.ts | 4 + apps/electron-backend/src/main.ts | 59 +++--- docs/architecture/performance-journeys.md | 67 +++++- docs/architecture/sqlite-db-worker.md | 23 +++ docs/development/electron-debugging.md | 3 +- libs/shared/database/README.md | 3 + libs/shared/database/src/index.ts | 1 + .../src/lib/connection-observer.spec.ts | 30 +++ .../database/src/lib/connection-observer.ts | 26 +++ libs/shared/database/src/lib/connection.ts | 2 + .../src/lib/testing/connection-upgrade.ts | 32 +++ tsconfig.base.json | 3 + 39 files changed, 1991 insertions(+), 73 deletions(-) create mode 100644 apps/electron-backend-e2e/src/performance/journey-main-counters.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/journey-main-counters.ts create mode 100644 apps/electron-backend/src/app/services/main-sql-statement-count.spec.ts create mode 100644 apps/electron-backend/src/app/services/main-sql-statement-count.ts create mode 100644 apps/electron-backend/src/app/services/performance-counters.spec.ts create mode 100644 apps/electron-backend/src/app/services/performance-counters.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-sql-statement-count.spec.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-sql-statement-count.wiring.spec.ts create mode 100644 libs/shared/database/src/lib/connection-observer.spec.ts create mode 100644 libs/shared/database/src/lib/connection-observer.ts 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/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/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/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/tsconfig.base.json b/tsconfig.base.json index 7bcd05ff4..e708d951e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -143,6 +143,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" ], From 887ac64d18131c8720701ed1ed15cdc5cd9a28ee Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:27:47 +0200 Subject: [PATCH 3/3] feat(workspace): cinematic rotating dashboard hero (#1725) --- .../workspace-dashboard-cinematic-hero.md | 7 + apps/web/src/assets/i18n/ar.json | 13 +- apps/web/src/assets/i18n/ary.json | 13 +- apps/web/src/assets/i18n/by.json | 13 +- apps/web/src/assets/i18n/de.json | 13 +- apps/web/src/assets/i18n/el.json | 13 +- apps/web/src/assets/i18n/en.json | 13 +- apps/web/src/assets/i18n/es.json | 13 +- apps/web/src/assets/i18n/fr.json | 13 +- apps/web/src/assets/i18n/hu.json | 13 +- apps/web/src/assets/i18n/it.json | 13 +- apps/web/src/assets/i18n/ja.json | 13 +- apps/web/src/assets/i18n/ko.json | 13 +- apps/web/src/assets/i18n/nl.json | 13 +- apps/web/src/assets/i18n/pl.json | 13 +- apps/web/src/assets/i18n/pt.json | 13 +- apps/web/src/assets/i18n/ru.json | 13 +- apps/web/src/assets/i18n/tr.json | 13 +- apps/web/src/assets/i18n/zh.json | 13 +- apps/web/src/assets/i18n/zhtw.json | 13 +- docs/architecture/tmdb-metadata-enrichment.md | 18 +- docs/architecture/workspace-dashboard.md | 62 +- libs/workspace/dashboard/feature/README.md | 8 +- .../dashboard-hero-slides.presenter.spec.ts | 383 ++++++++++ .../rails/dashboard-hero-slides.presenter.ts | 372 ++++++++++ .../rails/dashboard-hero-slides.utils.spec.ts | 226 ++++++ .../lib/rails/dashboard-hero-slides.utils.ts | 152 ++++ .../rails/dashboard-hero-tmdb.service.spec.ts | 64 +- .../lib/rails/dashboard-hero-tmdb.service.ts | 25 +- .../lib/rails/dashboard-hero.component.html | 255 +++++++ .../lib/rails/dashboard-hero.component.scss | 670 ++++++++++++++++++ .../rails/dashboard-hero.component.spec.ts | 265 +++++++ .../src/lib/rails/dashboard-hero.component.ts | 173 +++++ .../src/lib/rails/dashboard-hero.utils.ts | 65 +- .../dashboard-live-epg.presenter.spec.ts | 85 ++- .../lib/rails/dashboard-live-epg.presenter.ts | 82 ++- .../src/lib/rails/dashboard-live-epg.utils.ts | 16 +- .../src/lib/rails/dashboard-playback.utils.ts | 25 + .../workspace-dashboard-rails.component.html | 196 +---- .../workspace-dashboard-rails.component.scss | 594 +--------------- ...orkspace-dashboard-rails.component.spec.ts | 61 +- .../workspace-dashboard-rails.component.ts | 163 +---- 42 files changed, 3152 insertions(+), 1062 deletions(-) create mode 100644 .changes/workspace-dashboard-cinematic-hero.md create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.utils.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.html create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.scss create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.spec.ts create mode 100644 libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero.component.ts 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/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/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/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 51f61c67c..c06c43c94 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,12 +73,14 @@ 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. +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. 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"`, @@ -89,6 +91,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: @@ -109,7 +152,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/ @@ -161,12 +205,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/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,