From 5f55553f410f1b43fb670928c4e24c356ec9b56e Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:37:22 +0200 Subject: [PATCH 1/3] test(e2e): wait for the persisted Xtream import status, not only the overlay (#1897) * test(e2e): wait for the persisted Xtream import status, not only the overlay waitForXtreamImportToFinish gave the loading overlay 5 s to appear and then treated "no overlay" as finished. The overlay appears only once the portal's account check answers; a freshly started mock generates the large catalog on that first request, so J4's seed sometimes closed the app while the series import was still running. The template then had no series rows, and J4's query for "system" (series titles only) returned an empty list on the first launch after seeding. On an Xtream route the wait now also requires the import status the app persists to read completed for live, movie and series. The new xtream-import-wait.e2e.ts holds get_account_info past the overlay window and fails without the fix. Co-Authored-By: Claude Opus 5.5 * test(e2e): judge the Xtream catalog complete the way the app does Review follow-ups on #1897: - A refresh deletes the catalog and re-imports it without resetting the persisted import statuses, so `completed` alone proves nothing after one. A type now counts as imported when its status reads completed and it has rows, the same rule the app uses before reusing its cache; a type without categories (a live-only panel) counts by status, and a portal without any category is not imported yet. A second spec refreshes a portal behind the slowed account check. Today the refresh keeps its preparation overlay up across that check, so it passes with the earlier wait as well; it guards the helper's contract. - The proxy is closed in its own finally, so a failed launch or close no longer leaves it listening. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../src/electron-test-fixtures.ts | 12 +- .../src/xtream-import-wait.e2e.ts | 174 ++++++++++++++++++ .../src/xtream-import-wait.ts | 117 ++++++++++++ docs/architecture/performance-journeys.md | 21 ++- 4 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts create mode 100644 apps/electron-backend-e2e/src/xtream-import-wait.ts diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index f4fe0cb3f..bc8f0e6d1 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -33,6 +33,10 @@ import { type ElectronExitConfirmationOptions, prepareElectronApplication, } from './electron-process-lifecycle'; +import { + waitForXtreamImportCompleted, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -1769,7 +1773,13 @@ export async function waitForXtreamImportToFinish(page: Page): Promise { try { await overlay.waitFor({ state: 'visible', timeout: 5000 }); } catch { - // The overlay may already be gone by the time the test reaches this point. + // The overlay may already be gone, or not shown yet: it appears only + // once the portal has answered, so the import status decides below. + } + + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + if (playlistId) { + await waitForXtreamImportCompleted(page, playlistId); } await expect(overlay).toHaveCount(0, { timeout: 30000 }); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts new file mode 100644 index 000000000..3397e6fff --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.e2e.ts @@ -0,0 +1,174 @@ +import { createServer, request as forwardRequest } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + launchElectronApp, + openSources, + refreshSource, + resetMockServers, + test, + waitForXtreamCatalog, + xtreamMockServer, +} from './electron-test-fixtures'; +import { + readXtreamImportState, + xtreamPlaylistIdFromUrl, +} from './xtream-import-wait'; + +/** + * Longer than the 5 s the catalog wait gives the loading overlay to appear. + * The overlay appears only once the account check has answered, which a mock + * generating a large catalog on its first request can take this long to do. + */ +const ACCOUNT_INFO_DELAY_MS = 7_000; +/** Keeps series importing after the movie cards are already visible. */ +const SERIES_LIST_DELAY_MS = 3_000; + +interface DelayingProxy { + readonly origin: string; + /** Holds the account check and the series list from now on. */ + slowDown(): void; + close(): Promise; +} + +function portalDelay(url: URL): number { + if (!url.pathname.endsWith('/player_api.php')) { + return 0; + } + const action = url.searchParams.get('action'); + if (action === null || action === 'get_account_info') { + return ACCOUNT_INFO_DELAY_MS; + } + return action === 'get_series' ? SERIES_LIST_DELAY_MS : 0; +} + +/** Forwards GET requests to `target`, delayed by `portalDelay` once slowed. */ +async function startDelayingProxy( + target: string, + slow: boolean +): Promise { + const upstream = new URL(target); + let delaying = slow; + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://proxy.invalid'); + setTimeout( + () => { + const forwarded = forwardRequest( + { + headers: { ...request.headers, host: upstream.host }, + hostname: upstream.hostname, + method: request.method, + path: request.url, + port: upstream.port, + }, + (answer) => { + response.writeHead( + answer.statusCode ?? 502, + answer.headers + ); + answer.pipe(response); + } + ); + forwarded.on('error', () => response.destroy()); + forwarded.end(); + }, + delaying ? portalDelay(url) : 0 + ); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + return { + origin: `http://127.0.0.1:${port}`, + slowDown: () => { + delaying = true; + }, + close: () => + new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }), + }; +} + +/** + * Closing the app right after the wait must not interrupt the import: the next + * launch reuses a type only when its status reads `completed` and rows exist, + * and global search finds nothing of a type whose rows are missing. + */ +async function expectCatalogComplete(page: Page): Promise { + const playlistId = xtreamPlaylistIdFromUrl(page.url()); + expect(playlistId).not.toBeNull(); + const imported = { + status: 'completed', + hasCategories: true, + hasContent: true, + }; + expect(await readXtreamImportState(page, playlistId as string)).toEqual({ + live: imported, + movie: imported, + series: imported, + }); +} + +test.describe('Electron Xtream catalog wait', () => { + test('@xtream @electron waits for every type when the portal answers after the overlay window', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, true); + try { + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + name: 'Slow account check portal', + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); + + test('@xtream @electron waits for the re-import after a refresh, whose statuses still read completed', async ({ + dataDir, + request, + }) => { + test.setTimeout(150_000); + await resetMockServers(request, ['xtream']); + const proxy = await startDelayingProxy(xtreamMockServer, false); + try { + const app = await launchElectronApp(dataDir); + try { + const title = 'Slow refresh portal'; + await addXtreamPortal(app.mainWindow, { + name: title, + serverUrl: proxy.origin, + }); + await waitForXtreamCatalog(app.mainWindow); + + // A refresh deletes the catalog and re-imports it, leaving + // the three statuses at `completed` from the first import. + proxy.slowDown(); + await openSources(app.mainWindow); + await refreshSource(app.mainWindow, title, { confirm: true }); + await waitForXtreamCatalog(app.mainWindow); + await expectCatalogComplete(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } finally { + await proxy.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/xtream-import-wait.ts b/apps/electron-backend-e2e/src/xtream-import-wait.ts new file mode 100644 index 000000000..9bcf78d69 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-import-wait.ts @@ -0,0 +1,117 @@ +import type { Page } from '@playwright/test'; + +/** Content types an Xtream import persists, in the app's status keys. */ +export const XTREAM_IMPORT_TYPES = ['live', 'movie', 'series'] as const; + +export type XtreamImportType = (typeof XTREAM_IMPORT_TYPES)[number]; + +/** What the database holds for one content type of a portal. */ +export interface XtreamImportTypeState { + /** `xtream-import-status::` in `app_state`. */ + readonly status: string | null; + readonly hasCategories: boolean; + readonly hasContent: boolean; +} + +export type XtreamImportState = Record; + +const XTREAM_ROUTE_PLAYLIST_ID = /\/workspace\/xtreams\/([^/?#]+)/; +/** Category types use the plural `movies`; content and status use `movie`. */ +const CATEGORY_TYPE: Record = { + live: 'live', + movie: 'movies', + series: 'series', +}; +const POLL_MS = 100; +const DEFAULT_TIMEOUT_MS = 60_000; + +/** The portal id of an `/workspace/xtreams//…` URL, or null. */ +export function xtreamPlaylistIdFromUrl(url: string): string | null { + const match = XTREAM_ROUTE_PLAYLIST_ID.exec(url); + return match ? decodeURIComponent(match[1]) : null; +} + +/** Status, category and content presence of every type, read through the bridge. */ +export async function readXtreamImportState( + page: Page, + playlistId: string +): Promise { + return page.evaluate( + async ({ id, types, categoryType }) => { + const entries = await Promise.all( + types.map(async (type) => [ + type, + { + status: await window.electron.dbGetAppState( + `xtream-import-status:${id}:${type}` + ), + hasCategories: await window.electron.dbHasCategories( + id, + categoryType[type] + ), + hasContent: await window.electron.dbHasContent( + id, + type + ), + }, + ]) + ); + return Object.fromEntries(entries); + }, + { + id: playlistId, + types: [...XTREAM_IMPORT_TYPES], + categoryType: CATEGORY_TYPE, + } + ) as Promise; +} + +/** + * Whether the portal's catalog is complete, judged the way the app judges it + * on its next start: it reuses a type only when the status reads `completed` + * and rows exist. A type without categories is empty on the portal (a + * live-only panel), so its status alone counts; but a portal without any + * category has not been imported yet, or a refresh has just deleted it. The + * status alone is not enough either: a refresh deletes the catalog and + * re-imports it without resetting the status. + */ +export function isXtreamImportComplete(state: XtreamImportState): boolean { + const types = XTREAM_IMPORT_TYPES.map((type) => state[type]); + return ( + types.some(({ hasCategories }) => hasCategories) && + types.every( + ({ status, hasCategories, hasContent }) => + status === 'completed' && (hasContent || !hasCategories) + ) + ); +} + +/** + * Waits until the portal's catalog is complete (`isXtreamImportComplete`). + * + * The loading overlay alone cannot prove that: it appears only once the + * portal has answered the account check and the import session starts. When + * that answer takes longer than the caller waits for the overlay (a mock + * generating a large catalog on its first request does), a check for "no + * overlay" passes before anything was imported, and closing the app then + * interrupts the import. A `cancelled` or `failed` status does not end the + * wait: the app writes no status when a retry starts, so it may be left over + * from an earlier attempt that is being retried now. + */ +export async function waitForXtreamImportCompleted( + page: Page, + playlistId: string, + timeoutMs = DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + let state = await readXtreamImportState(page, playlistId); + while (!isXtreamImportComplete(state)) { + if (Date.now() >= deadline) { + throw new Error( + `xtream-import-not-completed: ${JSON.stringify(state)}` + ); + } + await page.waitForTimeout(POLL_MS); + state = await readXtreamImportState(page, playlistId); + } +} diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 12d9c575b..655482899 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1048,9 +1048,24 @@ was unchanged from passing launches (the same 119 statements before the first key, the usual 2 for the query, none afterwards), and every failure traced was the first launch after seeding. The journey fails such an iteration instead of measuring it, so a full run occasionally fails J4. -The cause is in the app, not the harness, and is not fixed here. No J4 -baseline exists yet; J4 counters join the ratchet once three runner runs -agree. + +The cause was the seed, not the query. The portal's import starts only +after its account check answers, and a freshly started mock generates the +`large` catalog on that first request (up to about 10 s on a busy machine). +`waitForXtreamImportToFinish` gave the loading overlay 5 s to appear and +then accepted "no overlay" as finished, so the seed closed the app while +the series import was still running. The template then held 4,000 live +channels and 4,000 movies but no series, and `system` matches only series. +A run stops at its first failed iteration, which is why the failure always +showed at the first launch after seeding. The wait now also requires the +catalog to be complete the way the app judges it on its next start +(`apps/electron-backend-e2e/src/xtream-import-wait.ts`): every type's +persisted status (`xtream-import-status::`) reads `completed` and +the type has rows, unless the portal has no categories of that type. Rows +are required because a refresh deletes the catalog without resetting the +status. `xtream-import-wait.e2e.ts` holds the account check past the overlay +window and fails without it. No J4 baseline exists yet; J4 counters join the +ratchet once three runner runs agree. ## `renderer.initialBytes` From a9f4ae38ea3c56d8fc10601c60a082f53759efdb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:37:27 +0200 Subject: [PATCH 2/3] perf(database): rebuild the title index once per profile, not again on the second launch (#1900) upgradeContentTitleFtsTokenizer recreates and rebuilds content_title_fts and records migration:content-title-fts-remove-diacritics:v1, but not migration:content-title-fts-trigram:v1. On a new profile the tokenizer upgrade therefore did the first rebuild, and on the second launch ensureContentTitleFts found its own marker missing and rebuilt the whole index again, synchronously inside initDatabase on the main thread, right after the first catalog import. A trigram rebuild costs about 4.5 s per million titles (sqlite3 3.51, synthetic titles). Every J4 iteration paid it too, since each launches a copy of a freshly seeded profile. The upgrade's transaction now records both markers. The skipped-release upgrade harness counts rebuilds across every initialization of a profile and requires exactly one; it fails for all six cases without the fix. Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../database-title-index-second-launch.md | 6 ++++ docs/architecture/sqlite-db-worker.md | 4 ++- docs/architecture/vod-multi-source.md | 5 +++- .../src/lib/connection-migrations.spec.ts | 7 ++++- libs/shared/database/src/lib/connection.ts | 22 ++++++++------ .../src/lib/testing/connection-upgrade.ts | 30 +++++++++++++++++++ 6 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 .changes/database-title-index-second-launch.md diff --git a/.changes/database-title-index-second-launch.md b/.changes/database-title-index-second-launch.md new file mode 100644 index 000000000..5ce1b76b4 --- /dev/null +++ b/.changes/database-title-index-second-launch.md @@ -0,0 +1,6 @@ +--- +type: perf +area: database +--- + +The second launch after setting up a new library no longer rebuilds the whole title search index before the app opens, which took several seconds on large catalogs (about 4.5 s per million titles). diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index baa435811..582a0b429 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -550,7 +550,9 @@ Xtream-only row shape: as `Café` can still reach rows stored as either `Café` or `Cafe` before the worker's accent-insensitive ranking step. Existing databases rebuild this index once through the `migration:content-title-fts-trigram:v1` app-state - marker before legacy content cleanup/normalization migrations run; fresh + marker before legacy content cleanup/normalization migrations run (the + diacritics rebuild records that marker too, so it is never rebuilt twice; + see [VOD multi-source](vod-multi-source.md#short-titles-and-unicode)); fresh inserts, deletes, and title updates stay synchronized through SQLite triggers. 2. `source_type = "m3u"` rows come from M3U playlist payloads stored in `playlists.payload`. The worker uses SQL `payload LIKE` against channel diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 11c13119b..604aafbb7 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -833,7 +833,10 @@ titles ("Amélie" → "amelie") against an index built from the raw one — with folding, every accented title was invisible to the FTS path, and two identical `Amélie` entries produced no candidates at all. The tokenizer is fixed at CREATE time, so existing databases are recreated and rebuilt once behind -`migration:content-title-fts-remove-diacritics:v1`. +`migration:content-title-fts-remove-diacritics:v1`. That rebuild also records +`migration:content-title-fts-trigram:v1`, the plain rebuild it supersedes; +before it did, a new profile rebuilt the whole index a second time on its +second launch, on the main thread, right after the first catalog import. `remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp table first and does nothing when the runtime rejects it, leaving the working diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index 977a13780..28d81c1f7 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -358,7 +358,12 @@ describe('content title FTS tokenizer upgrade', () => { expect.stringContaining("tokenize='trigram remove_diacritics 1'") ); expect(rebuild.run).toHaveBeenCalled(); - expect(marker.run).toHaveBeenCalled(); + // The rebuild also covers the plain index migration; without its + // record the next launch rebuilds the whole index again. + expect(marker.run.mock.calls).toEqual([ + ['migration:content-title-fts-remove-diacritics:v1'], + ['migration:content-title-fts-trigram:v1'], + ]); // One transaction, so a rejected CREATE rolls the drop back and the // working index survives. expect(transaction).toHaveBeenCalled(); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 03036b764..5f026114b 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -755,15 +755,19 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean { ) .run(); - sqliteDb - .prepare( - `INSERT INTO app_state (key, value, updated_at) - VALUES (?, 'done', datetime('now')) - ON CONFLICT(key) DO UPDATE SET - value = excluded.value, - updated_at = excluded.updated_at` - ) - .run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY); + // The rebuild is also all that `ensureContentTitleFts` does, so + // its record is written too. Without it the next launch rebuilds + // the whole index again, on the main thread, on the first launch + // after a fresh install has imported its catalog. + const markDone = sqliteDb.prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ); + markDone.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY); + markDone.run(CONTENT_TITLE_FTS_MIGRATION_KEY); }); executeMigration(); diff --git a/libs/shared/database/src/lib/testing/connection-upgrade.ts b/libs/shared/database/src/lib/testing/connection-upgrade.ts index faa162cee..2a6b071ff 100644 --- a/libs/shared/database/src/lib/testing/connection-upgrade.ts +++ b/libs/shared/database/src/lib/testing/connection-upgrade.ts @@ -71,6 +71,30 @@ function seed(sqlite: Database.Database) { } } +const titleIndexRebuilds: string[] = []; + +/** + * Every full rebuild of the title FTS index goes through `prepare`. A profile + * needs one; another on a later launch re-indexes the whole catalog on the + * main thread before the app can read anything. + */ +function countTitleIndexRebuilds(connection: Database.Database): void { + const prepare = connection.prepare; + connection.prepare = function countingPrepare( + this: Database.Database, + sql: string + ) { + if ( + /content_title_fts\(content_title_fts\)\s+VALUES\s+\('rebuild'\)/.test( + sql + ) + ) { + titleIndexRebuilds.push(sql); + } + return prepare.call(this, sql); + } as typeof connection.prepare; +} + /** * The performance capture counts one `exec` call as one SQL statement * (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts), @@ -81,6 +105,7 @@ function seed(sqlite: Database.Database) { function requireSingleStatementExec(): string[] { const batches: string[] = []; setDatabaseConnectionObserver((connection) => { + countTitleIndexRebuilds(connection); const exec = connection.exec; connection.exec = function singleStatementExec(sql: string) { try { @@ -230,6 +255,11 @@ async function main() { [], 'Initialization must pass one SQL statement per exec call' ); + assert.equal( + titleIndexRebuilds.length, + 1, + 'The title index must be rebuilt once per profile, not on every launch' + ); process.stdout.write('upgrade verified'); } From 6fe684af5190a1e75032cd655d862c43d9c36396 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:15:18 +0200 Subject: [PATCH 3/3] fix(workspace): slide the content and header into place when a source opens (#1895) * fix(workspace): slide the content and header into place when a source opens Opening an Xtream portal from the dashboard inserted the 323 px context panel and the header Back in the frame that swaps the page: the content pane jumped right by the panel's width and the switcher, search and actions by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222 for the content pane, 0.011 for the header, scored with the pane's distance), plus 0.0001 when the category search and sort buttons appeared with the categories. LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout applies at once and only a transform animates, so nothing waits and no layout shift is recorded. The content surface covers the strip it uncovers while sliding, Back fades in, and reduced motion keeps the jump. The category search and sort buttons render disabled while the list loads. Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3 0.001, J4 0. Co-Authored-By: Claude Opus 5.5 * perf(journeys): lower J2 layoutShiftScore to 0 The slides land on the runner: run 38147072417 (d2cb9445d) reads open-source/renderer.layoutShiftScore 0 with no shift recorded in all 5 measured iterations (J1 0 / 0, J3 0.001, J4 0 unchanged). Ratchet the baseline from 0.233 to 0 and record the perf:compositing evidence for the slide in the shell contract. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/workspace-layout-slide.md | 9 + docs/architecture/performance-journeys.md | 42 +++- docs/architecture/workspace-shell.md | 63 +++++- .../workspace-context-panel.component.html | 8 +- .../workspace-context-panel.component.spec.ts | 29 +++ .../workspace-context-panel.component.ts | 19 +- .../workspace-shell-header.component.html | 8 +- .../workspace-shell-header.component.scss | 13 ++ .../workspace-shell-header.component.spec.ts | 2 + .../workspace-shell-header.component.ts | 15 ++ .../layout-slide.directive.spec.ts | 187 ++++++++++++++++++ .../workspace-shell/layout-slide.directive.ts | 178 +++++++++++++++++ .../workspace-shell.component.html | 4 + .../workspace-shell.component.scss | 14 ++ .../workspace-shell.component.spec.ts | 6 + .../workspace-shell.component.ts | 2 + tools/performance/journey-baselines.json | 8 +- 17 files changed, 587 insertions(+), 20 deletions(-) create mode 100644 .changes/workspace-layout-slide.md create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/layout-slide.directive.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/layout-slide.directive.ts diff --git a/.changes/workspace-layout-slide.md b/.changes/workspace-layout-slide.md new file mode 100644 index 000000000..1e59813ba --- /dev/null +++ b/.changes/workspace-layout-slide.md @@ -0,0 +1,9 @@ +--- +type: fix +area: workspace +--- + +Opening a source from the dashboard no longer makes the page jump: the content +slides aside for the category panel, and the header controls slide over for +the Back button. The category list's search and sort buttons are in place +while the list loads. diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 655482899..cfbaecc47 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1288,15 +1288,49 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to | `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) | | `open-source/main.mockHttpRequestsToSettled` | 1 | identical | | `open-source/renderer.ipcCallsToFirstPage` | 17 | identical | -| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run | +| `open-source/renderer.layoutShiftScore` | 0 | 0.221, then 0.222; 0.233 since #1814; 0 since #1895 (see below) | | `playback/renderer.httpRequestsToPlaying` | 2 | identical | | `playback/renderer.layoutShiftScore` | 0.001 | identical | `open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233 in every iteration of every `master` run from 84aef83a6 (#1814, page Back -buttons moved into the header) on, so its value is 0.233 with `evidenceRun` -37372780064 (b78224376). The 0.011 that #1814 added is not explained yet; -lowering it back is a separate change. +buttons moved into the header) on, so its value was 0.233 with `evidenceRun` +37372780064 (b78224376). Since #1895 it is 0 (`evidenceRun` 38147072417: +all 5 measured iterations on the runner read 0 with no shift recorded). + +Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window; +the score of a shift is the area of every node that moved times the largest +distance any of them moved, as a fraction of the viewport): + +| Shift, ms after the click | Nodes that moved | Score | +| ------------------------- | ---------------------------------------------------------- | ------ | +| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 | +| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 | +| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 | + +The first two are one frame: the frame that replaces the dashboard with the +portal page also inserts the 323 px context panel the dashboard has no room +for, and the history Back that #1814 shows once an in-app previous entry +exists. The header items move only 44 px, but a shift is scored with the +largest distance in it, the content pane's 323 px, which is why Back added +0.011 rather than about 0.0015. The third is the category search and sort +buttons appearing when the categories arrive. The skeletons of the category +list and the grid are replaced by content without any shift. Nothing was +hidden behind the startup overlay: the "Syncing playlist" overlay of the +cached catalog load flashes for about two frames after the shift and covers +no further one. + +The content frame and the header controls now slide into place instead of +jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout +applies in the same frame and only a transform animates, which Chromium does +not record as a layout shift. The category search and sort buttons render +disabled while the categories load. Locally (macOS, 2026-10-11) and on the runner +J2 then read 0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001 +and J4 0; +`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style +changes) and the wall-clock entries did not move. Under +`prefers-reduced-motion` there is no slide, so a user with that preference +still sees the jump that the journey no longer measures. Being deterministic is not the same as being validated. Principle 3 of the plan promotes a counter to a guardrail once a PR has shown that lowering it diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a161a3eee..243842ad3 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -127,6 +127,57 @@ synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check cannot pass on a page with nothing to clip. +### Layout slides + +When a route change adds or removes the context panel, the content frame +moves by the panel's width (323 px on desktop), and when Back or the phone +drawer toggle appears or leaves, the header controls after it move by the +slot's width (44 px). Both happen in the frame that swaps the page, so they +used to be one jump: a layout shift of 0.233 in the open-source journey, +0.222 of it the content frame (see +[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)). + +`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns +these jumps into a 180 ms slide, the duration of the context panel's own +width transition. The new layout applies at once; each target starts +translated back to the position it had and animates its transform to none +(FLIP), so nothing waits for the animation and Chromium records no layout +shift for a transform. The shell's `.workspace-body` slides +`.workspace-content-frame` when `showContextPanel` changes; the header slides +the drawer toggle, switcher, search and action groups when its leading +navigation (Back, drawer toggle) changes. + +1. **Previous positions.** `offsetLeft` values, which ignore transforms, are + recorded after every render in which the slide key or the record key + changed (the header's record key covers the other inputs that move its + controls: settings route, no playlists, parental lock, shortcuts) and + when the host or a target resizes (window resize, a context panel folding + or being dragged wider). A slide therefore starts where the target was + last laid out, and a target that has no recorded position or did not move + is left alone. +2. **The uncovered strip.** The frame is narrower beside the panel than + without it, so the side it moves away from would show the body colour + during the slide. While a slide runs the target carries + `layout-slide-active` and the uncovered widths as `--layout-slide-start` + and `--layout-slide-end`; the frame's stylesheet extends the content + surface over that strip with an `::after` below the scroller. +3. **Back fades in** over the same 180 ms, so the switcher still sliding away + from its slot does not draw over the arrow. +4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation: + the jump (and its layout shift) remains. +5. **Compositing.** While it runs, each slid target is its own composited + layer (`ActiveTransformAnimation`): the frame at its width plus the + covered strip (1220×664 CSS pixels at 1280×720), the header controls a + few small ones. No synthesized clip mask appears, because no ancestor of + the frame clips with a radius ([content surface + corner](#content-surface-corner)), and the layers go away when the slide + ends: `perf:compositing` reads the same layer and mask counts on every + route as before the slides (#1895). +6. **Not covered.** A slide does not change what the route renders. Going + back from a portal to the dashboard still shows one frame with the new + dashboard below the old page before the old page leaves; that jump is a + routing issue, not a layout slide. + `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services: @@ -215,9 +266,10 @@ return logic (`backClicked`). When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live -TV), where a disabled arrow would stay for the whole session. The cost is one -shift of the switcher and search when Back first appears or leaves, which -happens only at the start of the history and together with a route change. +TV), where a disabled arrow would stay for the whole session. When Back first +appears or leaves, which happens only at the start of the history and together +with a route change, the switcher and search slide by the slot's width instead +of jumping (see [Layout slides](#layout-slides)). There is no Forward button: Stalker inline details are store state, not history entries, so Forward would skip them. @@ -263,7 +315,10 @@ Xtream and Stalker category panels preserve provider/server category order by default. The panel header exposes a sort menu next to category search with `Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active, synthetic "all categories" entries stay pinned before sorted provider -categories. +categories. Search and sort render disabled while the categories load (beside +the disabled Manage categories button), so the header row has its final width +under the skeleton instead of growing by two buttons when the list arrives; +they are hidden only once a load ended with no categories. On live sections the category panel can be folded away independently of the channels list; the folded panel is reachable as a popover from the channels diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html index fc4b6440b..d6ae3db4e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.html @@ -3,12 +3,15 @@

{{ title() | translate }}

- @if (canSearchCategories()) { + + @if (showCategoryListActions()) { } - @if (canSearchCategories()) { + @if (showCategoryListActions()) {