From 497b6076fa2dc4d70ca216d3bc11b5de3f46ed32 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:49:07 +0200 Subject: [PATCH 01/20] ci(e2e): shard the Electron Playwright suite per OS (#1700) Run the sequential Electron E2E suite as three Playwright shards per OS (one runner each) and summarize all shards of an OS in one follow-up job. The semantic summary script accepts a directory of shard reports, merges them and refuses to write when a shard is missing, duplicated or malformed, or when an explicit input does not exist. Slowest shard per OS in the final run: ubuntu 12.5 min (was 26), macOS 13.7 min (was 34), Windows 24.5 min (was 35). Co-Authored-By: Claude Fable 5.1 --- .github/workflows/e2e-tests.yaml | 83 ++++- docs/architecture/validation-map.md | 10 + package.json | 2 +- tools/coverage/e2e-semantic-summary.mjs | 104 +++++- tools/coverage/e2e-shard-reports.mjs | 148 ++++++++ tools/coverage/e2e-shard-reports.test.mjs | 407 ++++++++++++++++++++++ 6 files changed, 728 insertions(+), 26 deletions(-) create mode 100644 tools/coverage/e2e-shard-reports.mjs create mode 100644 tools/coverage/e2e-shard-reports.test.mjs diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 74ff1e198..dd4031464 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -37,11 +37,13 @@ permissions: jobs: electron-e2e-tests: - name: Electron E2E on ${{ matrix.os }} + name: Electron E2E on ${{ matrix.os }} (${{ matrix.shard }}/${{ matrix.shard-total }}) runs-on: ${{ matrix.os }} - # macOS's sequential Electron suite exceeded 45m while still passing - # tests; retain the full suite with room for setup and retries. - timeout-minutes: ${{ matrix.os == 'macos-latest' && 60 || 45 }} + # The sequential Electron suite is split into Playwright shards (one + # runner each, split by spec file). Measured on 2026-09-26, a third of + # the suite is 6-12 minutes of test time on top of 3-7 minutes of + # setup, so 30 minutes leaves room for retries on every OS. + timeout-minutes: 30 env: IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true @@ -49,6 +51,8 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] + shard: [1, 2, 3] + shard-total: [3] steps: - uses: actions/checkout@v7 @@ -68,7 +72,10 @@ jobs: - name: Build Backend run: pnpm nx build electron-backend + # Process-lifecycle checks are independent of the shard split; + # run them once per OS. - name: Verify Electron process cleanup + if: matrix.shard == 1 run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts - name: Install Playwright Browsers @@ -76,29 +83,81 @@ jobs: - name: Run Electron E2E Tests (Linux) if: runner.os == 'Linux' - run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e + run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - name: Run Electron E2E Tests (Windows/Mac) if: runner.os != 'Linux' - run: pnpm nx run electron-backend-e2e:e2e + run: pnpm nx run electron-backend-e2e:e2e -- --shard=${{ matrix.shard }}/${{ matrix.shard-total }} env: CI: true - - name: Summarize Electron E2E semantic coverage - if: always() - run: pnpm run coverage:e2e:summary -- --project=electron-backend-e2e - + # Each shard's results.json carries config.shard; the summary job + # below merges the shards of one OS into a single semantic summary. - name: Upload Electron Test Results if: always() uses: actions/upload-artifact@v7 with: - name: playwright-report-electron-${{ matrix.os }} + name: playwright-report-electron-${{ matrix.os }}-${{ matrix.shard }} path: | dist/playwright-report/electron-backend-e2e/ dist/test-results/electron-backend-e2e/ - coverage/e2e/ + retention-days: 7 + + electron-e2e-summary: + name: Electron E2E summary + runs-on: ubuntu-latest + needs: electron-e2e-tests + # Summarize failed shards too, but not a cancelled (superseded) run. + if: ${{ !cancelled() }} + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v7 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + + # One download per OS keeps each OS's shards in their own directory + # (the artifact paths inside the shards are identical). + - name: Download shard reports (Ubuntu) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-ubuntu-latest-* + path: dist/e2e-shards/ubuntu-latest + + - name: Download shard reports (macOS) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-macos-latest-* + path: dist/e2e-shards/macos-latest + + - name: Download shard reports (Windows) + uses: actions/download-artifact@v8 + with: + pattern: playwright-report-electron-windows-latest-* + path: dist/e2e-shards/windows-latest + + # The script fails when a shard's results.json is missing or + # duplicated, so a partial run is never summarized as complete. + - name: Summarize Electron E2E semantic coverage per OS + run: | + status=0 + for os in ubuntu-latest macos-latest windows-latest; do + echo "## Electron E2E on $os" >> "$GITHUB_STEP_SUMMARY" + node tools/coverage/e2e-semantic-summary.mjs --project=electron-backend-e2e --input="dist/e2e-shards/$os" --output-dir="coverage/e2e/$os" || status=1 + done + exit "$status" + + - name: Upload Electron semantic summaries + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-semantic-summary-electron + path: coverage/e2e/ retention-days: 7 web-e2e-tests: diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 9a22c587c..0e0e6e767 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -135,6 +135,16 @@ After an E2E run, generate the semantic summary with: pnpm run coverage:e2e:summary ``` +CI runs the Electron suite as three Playwright shards per OS +(`--shard=/3`, split by spec file because the suite is sequential). Each +shard uploads `playwright-report-electron--`; the follow-up +`Electron E2E summary` job downloads the shards of each OS into their own +directory and runs the summary per OS with `--input=` and +`--output-dir=coverage/e2e/`. A directory input merges every +`results.json` beneath it and fails when a shard is missing or duplicated, so +the summary never reports a partial run as complete. Tests for that merge live +in `tools/coverage/e2e-shard-reports.test.mjs` (`pnpm run coverage:tools:test`). + For local investigation only, Chromium browser V8 coverage can be explored with: ```bash diff --git a/package.json b/package.json index aa1379a0f..9254f378f 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", - "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs", + "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", diff --git a/tools/coverage/e2e-semantic-summary.mjs b/tools/coverage/e2e-semantic-summary.mjs index cac4b1dd0..3b7a112dc 100644 --- a/tools/coverage/e2e-semantic-summary.mjs +++ b/tools/coverage/e2e-semantic-summary.mjs @@ -11,6 +11,13 @@ import { import path from 'node:path'; import process from 'node:process'; +import { + describeShardReports, + findPlaywrightJsonReports, + loadPlaywrightReports, + verifyShardReports, +} from './e2e-shard-reports.mjs'; + const workspaceRoot = process.cwd(); const args = process.argv.slice(2); const projectArg = valueFor('--project'); @@ -18,7 +25,12 @@ const inputArg = valueFor('--input'); const policy = JSON.parse( readFileSync(path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'), 'utf8') ); -const outputDir = path.join(workspaceRoot, policy.reporting.e2eSummaryDir); +const outputDirArg = valueFor('--output-dir'); +const outputDir = path.resolve( + workspaceRoot, + outputDirArg ?? policy.reporting.e2eSummaryDir +); +const outputDirLabel = outputDirArg ?? policy.reporting.e2eSummaryDir; function valueFor(flag) { const prefixed = args.find((arg) => arg.startsWith(`${flag}=`)); @@ -57,8 +69,19 @@ function tagsFromTitle(title) { ); } -function collectFromPlaywrightJson(filePath, projectName) { - const report = JSON.parse(readFileSync(filePath, 'utf8')); +function fail(message) { + console.error(`e2e-semantic-summary: ${message}`); + if (process.env.GITHUB_STEP_SUMMARY) { + writeFileSync( + process.env.GITHUB_STEP_SUMMARY, + `\n> **E2E semantic summary not written:** ${message}\n`, + { flag: 'a' } + ); + } + process.exit(1); +} + +function collectFromPlaywrightJson(report, projectName) { const tests = []; function walkSuite(suite, inheritedFile) { @@ -137,16 +160,58 @@ function defaultInputFor(projectName) { return path.join(workspaceRoot, 'dist/test-results', projectName, 'results.json'); } -function collectTests(projectName) { +/** + * `--output-dir` overrides the policy's summary directory so several runs + * (one per OS in CI) can be summarized side by side in one job. + * + * `--input` may name one Playwright JSON report or a directory that holds the + * `results.json` of every shard (as downloaded from the per-shard CI + * artifacts). An explicit input that does not exist, a directory without any + * report, or an incomplete or duplicated shard set aborts instead of writing + * a partial summary. Only the implicit default falls back to scanning the + * spec sources. + */ +function resolveReportPaths(projectName) { const inputPath = inputArg ? path.resolve(workspaceRoot, inputArg) : defaultInputFor(projectName); - if (existsSync(inputPath)) { - return collectFromPlaywrightJson(inputPath, projectName); + if (!existsSync(inputPath)) { + if (inputArg) { + fail(`Playwright JSON report input does not exist: ${inputPath}`); + } + return []; + } + if (!statSync(inputPath).isDirectory()) { + return [inputPath]; + } + const found = findPlaywrightJsonReports(inputPath); + if (found.length === 0) { + fail(`no Playwright JSON reports (results.json) found under ${inputPath}`); + } + return found; +} + +function collectTests(projectName) { + const reportPaths = resolveReportPaths(projectName); + if (reportPaths.length === 0) { + return { tests: collectFromSource(projectName), reports: [] }; } - return collectFromSource(projectName); + const reports = loadPlaywrightReports(reportPaths); + const verification = verifyShardReports(reports); + if (!verification.ok) { + fail( + `${projectName} reports do not form one complete run: ${verification.problems.join('; ')}` + ); + } + + return { + tests: reports.flatMap((entry) => + collectFromPlaywrightJson(entry.report, projectName) + ), + reports, + }; } function statusCounts(tests) { @@ -174,7 +239,7 @@ function journeyMatches(journey, tests) { ); } -function markdownFor(projectName, tests) { +function markdownFor(projectName, tests, reportsLabel) { const counts = statusCounts(tests); const countsText = Object.entries(counts) .map(([status, count]) => `${status}: ${count}`) @@ -197,6 +262,8 @@ function markdownFor(projectName, tests) { Source: ${tests.some((test) => test.status === 'not-run') ? 'spec source scan' : 'Playwright JSON report'} +Reports: ${reportsLabel} + Total tracked tests: ${tests.length} Statuses: ${countsText || 'none'} @@ -216,12 +283,23 @@ ${journeys || '| _none_ | _n/a_ | 0 | missing |'} } const projects = projectArg ? [projectArg] : ['web-e2e', 'electron-backend-e2e']; -const allTests = projects.flatMap((projectName) => collectTests(projectName)); +const collected = projects.map((projectName) => ({ + projectName, + ...collectTests(projectName), +})); +const allTests = collected.flatMap((entry) => entry.tests); +const reportsLabel = collected + .map((entry) => + projectArg + ? describeShardReports(entry.reports) + : `${entry.projectName}: ${describeShardReports(entry.reports)}` + ) + .join('; '); mkdirSync(outputDir, { recursive: true }); if (projectArg) { - const content = markdownFor(projectArg, allTests); + const content = markdownFor(projectArg, allTests, reportsLabel); writeFileSync(path.join(outputDir, `${projectArg}-semantic-summary.md`), content); writeFileSync( path.join(outputDir, `${projectArg}-semantic-summary.json`), @@ -230,9 +308,9 @@ if (projectArg) { if (process.env.GITHUB_STEP_SUMMARY) { writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' }); } - console.log(`Wrote ${policy.reporting.e2eSummaryDir}/${projectArg}-semantic-summary.md`); + console.log(`Wrote ${outputDirLabel}/${projectArg}-semantic-summary.md`); } else { - const content = markdownFor(undefined, allTests); + const content = markdownFor(undefined, allTests, reportsLabel); writeFileSync(path.join(outputDir, 'semantic-summary.md'), content); writeFileSync( path.join(outputDir, 'semantic-summary.json'), @@ -241,5 +319,5 @@ if (projectArg) { if (process.env.GITHUB_STEP_SUMMARY) { writeFileSync(process.env.GITHUB_STEP_SUMMARY, `\n${content}\n`, { flag: 'a' }); } - console.log(`Wrote ${policy.reporting.e2eSummaryDir}/semantic-summary.md`); + console.log(`Wrote ${outputDirLabel}/semantic-summary.md`); } diff --git a/tools/coverage/e2e-shard-reports.mjs b/tools/coverage/e2e-shard-reports.mjs new file mode 100644 index 000000000..fe0ace957 --- /dev/null +++ b/tools/coverage/e2e-shard-reports.mjs @@ -0,0 +1,148 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; + +/** + * Helpers for reading one or more Playwright JSON reports (`results.json`). + * + * The Electron E2E workflow runs the suite as several Playwright shards, one + * per runner. Each shard writes its own `results.json` that carries + * `config.shard = { current, total }` and only that shard's tests. A semantic + * summary is only honest when every shard is present exactly once, so callers + * verify the set before merging. + */ + +export const playwrightReportFileName = 'results.json'; + +/** Recursively lists every `results.json` below `rootDir`, sorted by path. */ +export function findPlaywrightJsonReports(rootDir) { + if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) { + return []; + } + const found = []; + for (const entry of readdirSync(rootDir)) { + const fullPath = path.join(rootDir, entry); + if (statSync(fullPath).isDirectory()) { + found.push(...findPlaywrightJsonReports(fullPath)); + } else if (entry === playwrightReportFileName) { + found.push(fullPath); + } + } + return found.sort(); +} + +/** + * Parses each report and extracts its shard descriptor: `null` when the run + * was not sharded (`config.shard` absent or `null`). A descriptor that is + * present but malformed is kept as `malformedShard` so verification rejects + * it instead of mistaking a partial run for a complete unsharded one. + */ +export function loadPlaywrightReports(reportPaths) { + return reportPaths.map((reportPath) => { + const report = JSON.parse(readFileSync(reportPath, 'utf8')); + const shard = report.config?.shard ?? null; + const wellFormed = + shard !== null && + Number.isInteger(shard.current) && + Number.isInteger(shard.total); + return { + path: reportPath, + report, + shard: wellFormed ? { current: shard.current, total: shard.total } : null, + malformedShard: shard !== null && !wellFormed, + }; + }); +} + +/** + * Checks that the loaded reports form exactly one complete run: either a + * single unsharded report, or every shard `1..total` exactly once. + */ +export function verifyShardReports(reports) { + const problems = []; + if (reports.length === 0) { + problems.push('no Playwright JSON reports were provided'); + return { ok: false, problems }; + } + + const malformed = reports.filter((entry) => entry.malformedShard); + if (malformed.length > 0) { + problems.push(`malformed config.shard descriptor: ${describePaths(malformed)}`); + } + + const unsharded = reports.filter( + (entry) => entry.shard === null && !entry.malformedShard + ); + const sharded = reports.filter((entry) => entry.shard !== null); + + if (unsharded.length > 0 && sharded.length > 0) { + problems.push( + `mixed sharded and unsharded reports: ${describePaths(unsharded)} carry no shard descriptor` + ); + } + if (unsharded.length > 1) { + problems.push( + `${unsharded.length} unsharded reports would count every test more than once: ${describePaths(unsharded)}` + ); + } + + if (sharded.length > 0) { + const totals = new Set(sharded.map((entry) => entry.shard.total)); + if (totals.size > 1) { + problems.push( + `shard totals disagree: ${Array.from(totals).sort().join(', ')}` + ); + } else { + const [total] = totals; + const seen = new Map(); + for (const entry of sharded) { + const list = seen.get(entry.shard.current) ?? []; + list.push(entry); + seen.set(entry.shard.current, list); + } + const missing = []; + for (let index = 1; index <= total; index += 1) { + if (!seen.has(index)) { + missing.push(`${index}/${total}`); + } + } + if (missing.length > 0) { + problems.push(`missing shards: ${missing.join(', ')}`); + } + for (const [current, entries] of seen) { + if (entries.length > 1) { + problems.push( + `shard ${current}/${total} appears ${entries.length} times: ${describePaths(entries)}` + ); + } + if (current < 1 || current > total) { + problems.push(`shard ${current}/${total} is outside 1..${total}`); + } + } + } + } + + return { ok: problems.length === 0, problems }; +} + +/** Human-readable label for the summary, e.g. `3/3 shards (1/3, 2/3, 3/3)`. */ +export function describeShardReports(reports) { + if (reports.length === 0) { + return 'none'; + } + const sharded = reports.filter((entry) => entry.shard !== null); + if (sharded.length === 0) { + return reports.length === 1 + ? '1 report (unsharded)' + : `${reports.length} reports (unsharded)`; + } + const total = Math.max(...sharded.map((entry) => entry.shard.total)); + const labels = sharded + .map((entry) => entry.shard) + .sort((left, right) => left.current - right.current) + .map((shard) => `${shard.current}/${shard.total}`); + return `${sharded.length}/${total} shards (${labels.join(', ')})`; +} + +function describePaths(entries) { + return entries.map((entry) => entry.path).join(', '); +} diff --git a/tools/coverage/e2e-shard-reports.test.mjs b/tools/coverage/e2e-shard-reports.test.mjs new file mode 100644 index 000000000..b7a58a867 --- /dev/null +++ b/tools/coverage/e2e-shard-reports.test.mjs @@ -0,0 +1,407 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, it } from 'node:test'; + +import { + describeShardReports, + findPlaywrightJsonReports, + loadPlaywrightReports, + verifyShardReports, +} from './e2e-shard-reports.mjs'; + +const toolsDir = path.dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = path.resolve(toolsDir, '../..'); +const summaryScript = path.join(toolsDir, 'e2e-semantic-summary.mjs'); +const temporaryRoots = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeTemporaryDir(prefix) { + const root = mkdtempSync(path.join(tmpdir(), prefix)); + temporaryRoots.push(root); + return root; +} + +function playwrightReport({ shard, file, titles, status = 'passed' }) { + return { + config: { shard }, + suites: [ + { + file, + specs: titles.map((title) => ({ + title, + tags: [], + tests: [{ results: [{ status }] }], + })), + }, + ], + errors: [], + stats: {}, + }; +} + +function writeReport(root, relativeDir, report) { + const directory = path.join(root, relativeDir); + mkdirSync(directory, { recursive: true }); + const reportPath = path.join(directory, 'results.json'); + writeFileSync(reportPath, JSON.stringify(report)); + return reportPath; +} + +function entry(shard, reportPath = `report-${shard?.current ?? 'single'}.json`) { + return { path: reportPath, shard, report: {} }; +} + +describe('findPlaywrightJsonReports', () => { + it('lists every nested results.json in sorted order', () => { + const root = makeTemporaryDir('iptvnator-shard-find-'); + const second = writeReport(root, 'b-shard-2/dist/test-results/x', {}); + const first = writeReport(root, 'a-shard-1/dist/test-results/x', {}); + writeFileSync(path.join(root, 'a-shard-1', 'other.json'), '{}'); + + assert.deepEqual(findPlaywrightJsonReports(root), [first, second]); + }); + + it('returns nothing for a missing directory or a file path', () => { + const root = makeTemporaryDir('iptvnator-shard-find-'); + const reportPath = writeReport(root, 'single', {}); + + assert.deepEqual(findPlaywrightJsonReports(path.join(root, 'nope')), []); + assert.deepEqual(findPlaywrightJsonReports(reportPath), []); + }); +}); + +describe('loadPlaywrightReports', () => { + it('extracts a shard descriptor and flags malformed ones', () => { + const root = makeTemporaryDir('iptvnator-shard-load-'); + const sharded = writeReport(root, 'sharded', { + config: { shard: { current: 2, total: 3 } }, + }); + const unsharded = writeReport(root, 'unsharded', { config: { shard: null } }); + const malformed = writeReport(root, 'malformed', { + config: { shard: { current: '2', total: 3 } }, + }); + + const loaded = loadPlaywrightReports([sharded, unsharded, malformed]); + + assert.deepEqual(loaded[0].shard, { current: 2, total: 3 }); + assert.equal(loaded[0].malformedShard, false); + assert.equal(loaded[1].shard, null); + assert.equal(loaded[1].malformedShard, false); + assert.equal(loaded[2].shard, null); + assert.equal(loaded[2].malformedShard, true); + }); +}); + +describe('verifyShardReports', () => { + it('accepts a single unsharded report', () => { + assert.deepEqual(verifyShardReports([entry(null)]), { ok: true, problems: [] }); + }); + + it('accepts a complete shard set in any order', () => { + const reports = [ + entry({ current: 3, total: 3 }), + entry({ current: 1, total: 3 }), + entry({ current: 2, total: 3 }), + ]; + + assert.deepEqual(verifyShardReports(reports), { ok: true, problems: [] }); + }); + + it('rejects a malformed shard descriptor even as the only report', () => { + const result = verifyShardReports([ + { ...entry(null, 'broken.json'), malformedShard: true }, + ]); + + assert.equal(result.ok, false); + assert.deepEqual(result.problems, [ + 'malformed config.shard descriptor: broken.json', + ]); + }); + + it('rejects an empty set', () => { + const result = verifyShardReports([]); + + assert.equal(result.ok, false); + assert.match(result.problems.join('\n'), /no Playwright JSON reports/); + }); + + it('names missing shards', () => { + const result = verifyShardReports([ + entry({ current: 1, total: 3 }), + entry({ current: 3, total: 3 }), + ]); + + assert.equal(result.ok, false); + assert.deepEqual(result.problems, ['missing shards: 2/3']); + }); + + it('rejects duplicate shards even when the set looks complete', () => { + const result = verifyShardReports([ + entry({ current: 1, total: 2 }, 'a.json'), + entry({ current: 1, total: 2 }, 'b.json'), + entry({ current: 2, total: 2 }, 'c.json'), + ]); + + assert.equal(result.ok, false); + assert.deepEqual(result.problems, ['shard 1/2 appears 2 times: a.json, b.json']); + }); + + it('rejects disagreeing shard totals', () => { + const result = verifyShardReports([ + entry({ current: 1, total: 2 }), + entry({ current: 2, total: 3 }), + ]); + + assert.equal(result.ok, false); + assert.deepEqual(result.problems, ['shard totals disagree: 2, 3']); + }); + + it('rejects mixed sharded and unsharded reports and repeated unsharded runs', () => { + const mixed = verifyShardReports([ + entry({ current: 1, total: 1 }), + entry(null, 'plain.json'), + ]); + const repeated = verifyShardReports([entry(null, 'a.json'), entry(null, 'b.json')]); + + assert.equal(mixed.ok, false); + assert.match(mixed.problems.join('\n'), /mixed sharded and unsharded.*plain\.json/); + assert.equal(repeated.ok, false); + assert.match(repeated.problems.join('\n'), /2 unsharded reports.*a\.json, b\.json/); + }); +}); + +describe('describeShardReports', () => { + it('labels empty, unsharded and sharded sets', () => { + assert.equal(describeShardReports([]), 'none'); + assert.equal(describeShardReports([entry(null)]), '1 report (unsharded)'); + assert.equal( + describeShardReports([ + entry({ current: 2, total: 3 }), + entry({ current: 1, total: 3 }), + ]), + '2/3 shards (1/3, 2/3)' + ); + }); +}); + +describe('e2e-semantic-summary CLI', () => { + function makeWorkspace() { + const root = makeTemporaryDir('iptvnator-e2e-summary-'); + mkdirSync(path.join(root, 'tools/coverage'), { recursive: true }); + copyFileSync( + path.join(repositoryRoot, 'tools/coverage/coverage-policy.json'), + path.join(root, 'tools/coverage/coverage-policy.json') + ); + return root; + } + + function runSummary(root, args) { + const stepSummary = path.join(root, 'step-summary.md'); + const result = spawnSync( + process.execPath, + [summaryScript, '--project=electron-backend-e2e', ...args], + { cwd: root, encoding: 'utf8', env: { ...process.env, GITHUB_STEP_SUMMARY: stepSummary } } + ); + return { + ...result, + stepSummary: existsSync(stepSummary) ? readFileSync(stepSummary, 'utf8') : '', + summaryPath: path.join(root, 'coverage/e2e/electron-backend-e2e-semantic-summary.md'), + jsonPath: path.join(root, 'coverage/e2e/electron-backend-e2e-semantic-summary.json'), + }; + } + + it('merges a complete shard directory into one summary', () => { + const root = makeWorkspace(); + const shards = path.join(root, 'shards'); + writeReport( + shards, + 'electron-ubuntu-1/dist/test-results/electron-backend-e2e', + playwrightReport({ + shard: { current: 1, total: 3 }, + file: 'src/smoke.e2e.ts', + titles: ['boots @critical @electron'], + }) + ); + writeReport( + shards, + 'electron-ubuntu-2/dist/test-results/electron-backend-e2e', + playwrightReport({ + shard: { current: 2, total: 3 }, + file: 'src/search.e2e.ts', + titles: ['finds @search', 'sorts @search'], + status: 'failed', + }) + ); + writeReport( + shards, + 'electron-ubuntu-3/dist/test-results/electron-backend-e2e', + playwrightReport({ + shard: { current: 3, total: 3 }, + file: 'src/xtream.e2e.ts', + titles: ['browses @xtream'], + }) + ); + + const result = runSummary(root, ['--input=shards']); + + assert.equal(result.status, 0, result.stderr); + const markdown = readFileSync(result.summaryPath, 'utf8'); + assert.match(markdown, /Source: Playwright JSON report/); + assert.match(markdown, /Reports: 3\/3 shards \(1\/3, 2\/3, 3\/3\)/); + assert.match(markdown, /Total tracked tests: 4/); + assert.match(markdown, /\| @search \| 2 \|/); + assert.match(markdown, /\| Workspace search across providers \| @search \| 2 \| failing \|/); + assert.match(markdown, /\| Electron app starts and renders workspace \| @critical \| 1 \| covered \|/); + const tests = JSON.parse(readFileSync(result.jsonPath, 'utf8')); + assert.deepEqual( + tests.map((test) => test.file).sort(), + ['src/search.e2e.ts', 'src/search.e2e.ts', 'src/smoke.e2e.ts', 'src/xtream.e2e.ts'] + ); + assert.match(result.stepSummary, /Reports: 3\/3 shards/); + }); + + it('refuses to summarize an incomplete shard set', () => { + const root = makeWorkspace(); + const shards = path.join(root, 'shards'); + writeReport( + shards, + 'shard-1', + playwrightReport({ + shard: { current: 1, total: 3 }, + file: 'src/smoke.e2e.ts', + titles: ['boots @critical'], + }) + ); + writeReport( + shards, + 'shard-3', + playwrightReport({ + shard: { current: 3, total: 3 }, + file: 'src/xtream.e2e.ts', + titles: ['browses @xtream'], + }) + ); + + const result = runSummary(root, ['--input=shards']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /missing shards: 2\/3/); + assert.match(result.stepSummary, /not written.*missing shards: 2\/3/); + assert.equal(existsSync(result.summaryPath), false); + }); + + it('refuses an explicit input path that does not exist', () => { + const root = makeWorkspace(); + + const result = runSummary(root, ['--input=dist/e2e-shards']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /input does not exist/); + assert.match(result.stepSummary, /not written.*input does not exist/); + assert.equal(existsSync(result.summaryPath), false); + }); + + it('refuses a single report whose shard descriptor is malformed', () => { + const root = makeWorkspace(); + writeReport(root, 'shards/one', { + config: { shard: { current: 'x', total: 3 } }, + suites: [], + }); + + const result = runSummary(root, ['--input=shards']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /malformed config\.shard descriptor/); + assert.equal(existsSync(result.summaryPath), false); + }); + + it('refuses a report directory without any results.json', () => { + const root = makeWorkspace(); + mkdirSync(path.join(root, 'shards/empty'), { recursive: true }); + + const result = runSummary(root, ['--input=shards']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /no Playwright JSON reports/); + assert.equal(existsSync(result.summaryPath), false); + }); + + it('still accepts a single report file and labels it unsharded', () => { + const root = makeWorkspace(); + const reportPath = writeReport( + root, + 'dist/test-results/electron-backend-e2e', + playwrightReport({ + shard: null, + file: 'src/smoke.e2e.ts', + titles: ['boots @critical'], + }) + ); + + const explicit = runSummary(root, [`--input=${reportPath}`]); + assert.equal(explicit.status, 0, explicit.stderr); + assert.match(readFileSync(explicit.summaryPath, 'utf8'), /Reports: 1 report \(unsharded\)/); + + const implicit = runSummary(root, []); + assert.equal(implicit.status, 0, implicit.stderr); + assert.match(readFileSync(implicit.summaryPath, 'utf8'), /Total tracked tests: 1/); + }); + + it('writes into --output-dir instead of the policy directory', () => { + const root = makeWorkspace(); + writeReport( + root, + 'dist/test-results/electron-backend-e2e', + playwrightReport({ + shard: null, + file: 'src/smoke.e2e.ts', + titles: ['boots @critical'], + }) + ); + + const result = runSummary(root, ['--output-dir=coverage/e2e/macos-latest']); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(result.summaryPath), false); + const summaryPath = path.join( + root, + 'coverage/e2e/macos-latest/electron-backend-e2e-semantic-summary.md' + ); + assert.match(readFileSync(summaryPath, 'utf8'), /Total tracked tests: 1/); + assert.match(result.stdout, /Wrote coverage\/e2e\/macos-latest\//); + }); + + it('falls back to the spec source scan when no report exists', () => { + const root = makeWorkspace(); + mkdirSync(path.join(root, 'apps/electron-backend-e2e/src'), { recursive: true }); + writeFileSync( + path.join(root, 'apps/electron-backend-e2e/src/smoke.e2e.ts'), + "test('boots @critical', async () => {});\n" + ); + + const result = runSummary(root, []); + + assert.equal(result.status, 0, result.stderr); + const markdown = readFileSync(result.summaryPath, 'utf8'); + assert.match(markdown, /Source: spec source scan/); + assert.match(markdown, /Reports: none/); + assert.match(markdown, /Statuses: not-run: 1/); + }); +}); From e39c854a41c3152bd9a1bc2023d9e575e03f074f Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:51:19 +0200 Subject: [PATCH 02/20] perf(electron): load the main-process startup wiring after the window starts loading (#1702) Registering IPC handlers costs 0.4 ms; evaluating the modules behind them (axios, drizzle-orm, better-sqlite3, electron-updater, fix-path) before the window could load was the real cost. main.ts now keeps only the pre-paint wiring and loads the rest as the deferred-events.js chunk inside the main window's did-start-loading listener, where the import and its registrations complete before any renderer invoke can arrive. Interleaved A/B on the performance build: app.whenReady 323 -> 265 ms, did-finish-load 499 -> 447 ms; J1 journey spawnToDidFinishLoad ~405 -> ~365 ms with identical counters. Packaging ships the chunk explicitly, verify:package-layout requires it, and the benchmark build identity hashes it. Co-Authored-By: Claude Fable 5.1 --- .changes/electron-deferred-startup-wiring.md | 8 + .../xtream-benchmark-report.fixtures.ts | 10 + .../xtream-build-identity-schema.ts | 6 + .../performance/xtream-build-identity.spec.ts | 7 + .../src/performance/xtream-build-identity.ts | 7 + apps/electron-backend/project.json | 6 +- .../app/startup/deferred-bootstrap.spec.ts | 145 +++++++++++++ .../src/app/startup/deferred-bootstrap.ts | 88 ++++++++ .../src/app/startup/deferred-events.ts | 178 +++++++++++++++ apps/electron-backend/src/main.ts | 203 +++++++----------- apps/electron-backend/webpack.config.cjs | 21 ++ docs/development/electron-debugging.md | 20 +- .../verify-electron-package-layout.mjs | 4 +- 13 files changed, 565 insertions(+), 138 deletions(-) create mode 100644 .changes/electron-deferred-startup-wiring.md create mode 100644 apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts create mode 100644 apps/electron-backend/src/app/startup/deferred-bootstrap.ts create mode 100644 apps/electron-backend/src/app/startup/deferred-events.ts create mode 100644 apps/electron-backend/webpack.config.cjs diff --git a/.changes/electron-deferred-startup-wiring.md b/.changes/electron-deferred-startup-wiring.md new file mode 100644 index 000000000..126d72aad --- /dev/null +++ b/.changes/electron-deferred-startup-wiring.md @@ -0,0 +1,8 @@ +--- +type: perf +area: electron +--- + +The desktop app now opens its window before it prepares the portal, program +guide, download, player and update machinery, and does that preparation while +the window is already loading, so the first screen appears sooner. diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts index 9f5fd9870..0ab7b7c23 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -62,6 +62,16 @@ export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = { 'database-worker-source-map' ), }, + deferredEvents: { + javascript: buildFile( + 'dist/apps/electron-backend/deferred-events.js', + 'deferred-events-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/deferred-events.js.map', + 'deferred-events-source-map' + ), + }, launcher: { javascript: buildFile( 'dist/apps/electron-backend/main.js', diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts index f2db2a571..cb7ae28fc 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -11,6 +11,7 @@ const RENDERER_FILE = const BUILD_KEYS = ['electron', 'renderer'] as const; const ELECTRON_KEYS = [ 'databaseWorker', + 'deferredEvents', 'launcher', 'main', 'playlistRefreshWorker', @@ -27,6 +28,7 @@ const RENDERER_KEYS = [ ] as const; const ELECTRON_PATHS = { databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js', + deferredEvents: 'dist/apps/electron-backend/deferred-events.js', launcher: 'dist/apps/electron-backend/main.js', main: 'dist/apps/electron-backend/main.app.js', playlistRefreshWorker: @@ -46,6 +48,10 @@ export function parseXtreamBenchmarkBuildIdentity( electronInput['databaseWorker'], ELECTRON_PATHS.databaseWorker ), + deferredEvents: pair( + electronInput['deferredEvents'], + ELECTRON_PATHS.deferredEvents + ), launcher: pair(electronInput['launcher'], ELECTRON_PATHS.launcher), main: pair(electronInput['main'], ELECTRON_PATHS.main), playlistRefreshWorker: pair( diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts index 1e3ca2cdc..7f1afba19 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -46,6 +46,11 @@ describe('Xtream benchmark build identity', () => { path: 'dist/apps/electron-backend/main.js', sha256: sha256('launcher'), }); + assert.deepEqual(identity.electron.deferredEvents.javascript, { + bytes: 8, + path: 'dist/apps/electron-backend/deferred-events.js', + sha256: sha256('deferred'), + }); assert.deepEqual(identity.electron.preload.sourceMap, { bytes: 11, path: 'dist/apps/electron-backend/main.preload.js.map', @@ -224,6 +229,8 @@ async function buildFixture(): Promise { mkdir(join(renderer, 'assets'), { recursive: true }), ]); await Promise.all([ + writeFile(join(backend, 'deferred-events.js'), 'deferred'), + writeFile(join(backend, 'deferred-events.js.map'), 'deferred-map'), writeFile(join(backend, 'main.js'), 'launcher'), writeFile(join(backend, 'main.js.map'), 'launcher-map'), writeFile(join(backend, 'main.app.js'), 'main'), diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts index fac4cabe3..7e9fe1747 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -18,6 +18,7 @@ export interface XtreamBuildPairIdentity { export interface XtreamBenchmarkBuildIdentity { readonly electron: { readonly databaseWorker: XtreamBuildPairIdentity; + readonly deferredEvents: XtreamBuildPairIdentity; readonly launcher: XtreamBuildPairIdentity; readonly main: XtreamBuildPairIdentity; readonly playlistRefreshWorker: XtreamBuildPairIdentity; @@ -36,6 +37,9 @@ const BACKEND_ROOT = 'dist/apps/electron-backend'; const RENDERER_ROOT = 'dist/apps/web'; const ELECTRON_PATHS = { databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`, + // main.app.js loads this chunk once the window starts loading; most IPC + // handlers and the database wiring live there. + deferredEvents: `${BACKEND_ROOT}/deferred-events.js`, // main.js only enables the compile cache and requires main.app.js, but // it decides startup behavior, so both belong to the identity. launcher: `${BACKEND_ROOT}/main.js`, @@ -51,6 +55,7 @@ export async function captureXtreamBuildIdentity( if (!isAbsolute(workspaceRoot)) invalid(); const [ databaseWorker, + deferredEvents, launcher, main, playlistRefreshWorker, @@ -58,6 +63,7 @@ export async function captureXtreamBuildIdentity( renderer, ] = await Promise.all([ readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker), + readPair(workspaceRoot, ELECTRON_PATHS.deferredEvents), readPair(workspaceRoot, ELECTRON_PATHS.launcher), readPair(workspaceRoot, ELECTRON_PATHS.main), readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker), @@ -67,6 +73,7 @@ export async function captureXtreamBuildIdentity( return Object.freeze({ electron: Object.freeze({ databaseWorker, + deferredEvents, launcher, main, playlistRefreshWorker, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index c3fcb9046..13189747d 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -59,6 +59,7 @@ } ], "tsConfig": "apps/electron-backend/tsconfig.app.json", + "webpackConfig": "apps/electron-backend/webpack.config.cjs", "assets": [ "apps/electron-backend/src/assets", { @@ -157,6 +158,7 @@ } ], "tsConfig": "apps/electron-backend/tsconfig.app.json", + "webpackConfig": "apps/electron-backend/webpack.config.cjs", "assets": [ "apps/electron-backend/src/assets", { @@ -213,7 +215,7 @@ { "from": "electron-backend", "to": "electron-backend", - "filter": ["main.app.js"] + "filter": ["main.app.js", "deferred-events.js"] } ], "sourcePath": "dist/apps", @@ -234,7 +236,7 @@ { "from": "electron-backend", "to": "electron-backend", - "filter": ["main.app.js"] + "filter": ["main.app.js", "deferred-events.js"] } ], "sourcePath": "dist/apps", diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts new file mode 100644 index 000000000..c1f54988e --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.spec.ts @@ -0,0 +1,145 @@ +import { EventEmitter } from 'node:events'; + +import { createDeferredBootstrap } from './deferred-bootstrap'; + +interface FakeModule { + readonly name: string; +} + +const fakeModule: FakeModule = { name: 'deferred' }; + +/** Mirrors webpack's node chunk loading: a synchronous require behind a resolved promise. */ +const loadResolved = () => Promise.resolve(fakeModule); + +function macrotask(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('deferred main-process bootstrap', () => { + it('registers handlers before the next macrotask once the window starts loading', async () => { + const order: string[] = []; + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: (module) => { + order.push(`run:${module.name}`); + return 'registered'; + }, + }); + bootstrap.armOn(webContents); + + // An IPC message that the renderer sends right after it starts + // loading arrives as a macrotask; it must queue behind registration. + setImmediate(() => order.push('renderer-ipc')); + webContents.emit('did-start-loading'); + await macrotask(); + + expect(order).toEqual(['run:deferred', 'renderer-ipc']); + expect(bootstrap.module).toBe(fakeModule); + }); + + it('runs the deferred work exactly once across both triggers', async () => { + const run = jest.fn(() => 'once'); + const load = jest.fn(loadResolved); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ load, run }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + webContents.emit('did-start-loading'); + const explicit = bootstrap.trigger(); + const outcome = await explicit; + + expect(load).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ module: fakeModule, result: 'once' }); + await expect(bootstrap.trigger()).resolves.toBe(outcome); + }); + + it('falls back to the explicit trigger when no window is available', async () => { + const sources: string[] = []; + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => undefined, + onTrigger: (source) => sources.push(source), + }); + + bootstrap.armOn(null); + bootstrap.armOn(undefined); + await bootstrap.trigger(); + + expect(sources).toEqual(['explicit']); + }); + + it('reports the trigger source and the duration', async () => { + const onTrigger = jest.fn(); + const onDone = jest.fn(); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => undefined, + onTrigger, + onDone, + }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + await bootstrap.trigger(); + + expect(onTrigger).toHaveBeenCalledTimes(1); + expect(onTrigger).toHaveBeenCalledWith('did-start-loading'); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onDone.mock.calls[0][0]).toBeGreaterThanOrEqual(0); + }); + + it('surfaces a failed load to every awaiting caller without running handlers', async () => { + const run = jest.fn(); + const onError = jest.fn(); + const bootstrap = createDeferredBootstrap({ + load: () => Promise.reject(new Error('chunk missing')), + run, + onError, + }); + + const first = bootstrap.trigger(); + const second = bootstrap.trigger(); + + await expect(first).rejects.toThrow('chunk missing'); + await expect(second).rejects.toThrow('chunk missing'); + expect(run).not.toHaveBeenCalled(); + expect(bootstrap.module).toBeNull(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('reports a failure fired by the window event instead of leaving it unhandled', async () => { + const unhandled = jest.fn(); + process.on('unhandledRejection', unhandled); + try { + const onError = jest.fn(); + const webContents = new EventEmitter(); + const bootstrap = createDeferredBootstrap({ + load: loadResolved, + run: () => { + throw new Error('handler registration failed'); + }, + onError, + }); + bootstrap.armOn(webContents); + + webContents.emit('did-start-loading'); + await macrotask(); + await macrotask(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(unhandled).not.toHaveBeenCalled(); + // A later awaiting caller still sees the failure. + await expect(bootstrap.trigger()).rejects.toThrow( + 'handler registration failed' + ); + expect(onError).toHaveBeenCalledTimes(1); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/apps/electron-backend/src/app/startup/deferred-bootstrap.ts b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts new file mode 100644 index 000000000..a3394f951 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-bootstrap.ts @@ -0,0 +1,88 @@ +/** + * Runs a deferred piece of main-process startup exactly once, triggered by + * the main window's `did-start-loading` event or, as a fallback, explicitly. + * + * Ordering guarantee relied on by main.ts: `load()` is a webpack dynamic + * import of a sibling chunk, which on the Electron main target is a + * synchronous `require` wrapped in an already-resolved promise, and `run()` + * registers IPC handlers synchronously. Both therefore finish within the + * microtask checkpoint of the task that fired the trigger. A renderer IPC + * message is delivered as a separate macrotask, so no `invoke` can arrive + * between the renderer starting to load and the handlers existing. + */ +export interface DeferredBootstrapOptions { + readonly load: () => Promise; + readonly run: (module: TModule) => TResult; + readonly onTrigger?: (source: DeferredBootstrapTrigger) => void; + readonly onDone?: (durationMs: number) => void; + /** + * Called once when the load or the registration fails. The event + * listener has no caller to report to, so without this a failure would + * only surface as an unhandled rejection; the promise returned by + * `trigger()` still rejects for callers that await it. + */ + readonly onError?: (error: unknown) => void; +} + +export type DeferredBootstrapTrigger = 'did-start-loading' | 'explicit'; + +export interface DeferredBootstrapOutcome { + readonly module: TModule; + readonly result: TResult; +} + +export interface DeferredBootstrapWebContents { + once(event: 'did-start-loading', listener: () => void): unknown; +} + +export interface DeferredBootstrap { + /** The loaded module, or null until the trigger has fired. */ + readonly module: TModule | null; + /** Arms the `did-start-loading` trigger; a missing webContents is a no-op. */ + armOn(webContents: DeferredBootstrapWebContents | null | undefined): void; + /** Starts load + run if not started yet; always returns the same promise. */ + trigger( + source?: DeferredBootstrapTrigger + ): Promise>; +} + +export function createDeferredBootstrap( + options: DeferredBootstrapOptions +): DeferredBootstrap { + let started: Promise> | null = + null; + let loadedModule: TModule | null = null; + + const trigger = ( + source: DeferredBootstrapTrigger = 'explicit' + ): Promise> => { + if (started) { + return started; + } + + options.onTrigger?.(source); + const startedAt = performance.now(); + started = options.load().then((module) => { + loadedModule = module; + const result = options.run(module); + options.onDone?.(performance.now() - startedAt); + return { module, result }; + }); + started.catch((error: unknown) => options.onError?.(error)); + return started; + }; + + return { + get module() { + return loadedModule; + }, + armOn(webContents) { + webContents?.once('did-start-loading', () => { + // Rejections are reported through onError and re-surface to + // whoever awaits trigger(); nothing to handle here. + trigger('did-start-loading').catch(() => undefined); + }); + }, + trigger, + }; +} diff --git a/apps/electron-backend/src/app/startup/deferred-events.ts b/apps/electron-backend/src/app/startup/deferred-events.ts new file mode 100644 index 000000000..38a708900 --- /dev/null +++ b/apps/electron-backend/src/app/startup/deferred-events.ts @@ -0,0 +1,178 @@ +/** + * Main-process work that only has to exist once the renderer has started + * loading: portal, EPG, download, player, probe, remote-control and update + * IPC, the database, and the recovery passes that follow the first load. + * + * main.ts loads this module through a dynamic import inside the main + * window's `did-start-loading` listener (see deferred-bootstrap.ts), so the + * heavy dependencies it pulls in (axios, drizzle-orm, better-sqlite3, + * electron-updater, fix-path) are evaluated while the renderer parses and + * runs its own bundle instead of before the window can load at all. + * + * Keep `bootstrapDeferredEvents()` synchronous: the guarantee that no + * renderer `invoke` finds a missing handler depends on it. + */ +import { app } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import { registerM3uSourceProbe } from '../events/m3u-source-probe'; +import { registerSourceProbeCancellation } from '../events/source-probe-control'; +import App from '../app'; +import { initDatabase } from '../database/connection'; +import DatabaseEvents from '../events/database.events'; +import { + resetStaleDownloads, + setMainWindow as setDownloadsMainWindow, +} from '../events/database/downloads.events'; +import { setRecordingsMainWindow } from '../events/database/recording-broadcast'; +import { reconcileStaleRecordings } from '../events/database/recording-recovery'; +import ElectronEvents from '../events/electron.events'; +import EmbeddedMpvEvents, { + shutdownEmbeddedMpv, +} from '../events/embedded-mpv.events'; +import EpgEvents from '../events/epg.events'; +import AppUpdateEvents from '../events/app-update.events'; +import { shutdownMpvSession } from '../events/mpv-session.service'; +import PlayerEvents from '../events/player.events'; +import { shutdownVlcSession } from '../events/vlc-session.service'; +import PlaylistEvents from '../events/playlist.events'; +import RemoteControlEvents from '../events/remote-control.events'; +import SettingsEvents from '../events/settings.events'; +import SharedEvents from '../events/shared.events'; +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 { AppUpdateService } from '../services/app-update.service'; +import { + onAppUpdateChannelChange, + readStoredAppUpdateChannel, +} from '../services/app-update-channel'; +import { databaseWorkerClient } from '../services/database-worker-client'; +import type { bootstrapWindowCloseGuard } from '../services/window-close-guard.service'; + +export interface DeferredEventsContext { + readonly appVersion: string; + readonly windowCloseGuard: ReturnType; +} + +export interface DeferredEventsHandles { + readonly appUpdateService: AppUpdateService; +} + +export function bootstrapDeferredEvents( + context: DeferredEventsContext +): DeferredEventsHandles { + const { windowCloseGuard } = context; + const appUpdateService = new AppUpdateService({ + app, + appVersion: context.appVersion, + channel: readStoredAppUpdateChannel(), + getMainWindow: () => App.mainWindow, + updater: () => autoUpdater, + // quitAndInstall() closes the windows before 'before-quit' fires + // (macOS), so without this an armed close guard would intercept + // the install's window close and strand the update. + prepareQuit: () => windowCloseGuard.allowNextClose(), + cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(), + }); + AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); + onAppUpdateChannelChange((channel) => appUpdateService.setChannel(channel)); + + ElectronEvents.bootstrapElectronEvents(); + EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); + PlaylistEvents.bootstrapPlaylistEvents(); + SharedEvents.bootstrapSharedEvents(); + PlayerEvents.bootstrapPlayerEvents(); + SettingsEvents.bootstrapSettingsEvents(); + StalkerEvents.bootstrapStalkerEvents(); + XtreamEvents.bootstrapXtreamEvents(); + registerStreamProbeHandlers(); + registerM3uSourceProbe(); + registerSourceProbeCancellation(); + registerConnectivityGuardHandlers(); + DatabaseEvents.bootstrapDatabaseEvents(); + EpgEvents.bootstrapEpgEvents(); + RemoteControlEvents.bootstrapRemoteControlEvents(); + + // Keep the downloads broadcaster bound to the live window. macOS can + // rebuild the window while the process runs, and a stale reference + // silently swallows every DOWNLOADS_UPDATE_EVENT. + App.onMainWindowCreated(setDownloadsMainWindow); + App.onMainWindowCreated(setRecordingsMainWindow); + + return { appUpdateService }; +} + +/** + * Database initialization and recovery, after the first renderer load is + * underway so Linux Electron E2E can observe a BrowserWindow even when + * SQLite startup or download recovery is slow. IPC handlers call + * getDatabase() lazily and share the same initialization promise. + */ +export async function finishStartupAfterFirstLoad(): Promise { + await initDatabase(); + + if (isStartupTraceEnabled()) { + trace('startup', 'init-database:done'); + } + + await resetStaleDownloads(); + + if (isStartupTraceEnabled()) { + trace('startup', 'reset-stale-downloads:done'); + } + + await reconcileStaleRecordings(); + + if (isStartupTraceEnabled()) { + trace('startup', 'reconcile-stale-recordings:done'); + } +} + +let fixPathScheduled = false; + +/** + * Update process.env.PATH from the user's interactive login shell so that + * spawned external players (MPV/VLC) can be resolved by binary name. + * + * Runs after window creation + IPC handler registration so the 50-300 ms + * shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent: + * subsequent calls are no-ops. fix-path itself is imported here, on demand, + * so its module evaluation stays off the launch path as well. + */ +export function scheduleDeferredFixPath(): void { + if (fixPathScheduled || process.platform === 'win32') { + return; + } + + fixPathScheduled = true; + setImmediate(() => { + import('fix-path') + .then(({ default: fixPath }) => { + fixPath(); + if (isStartupTraceEnabled()) { + trace('startup', 'fix-path:done'); + } + }) + .catch((error) => { + console.warn('fix-path failed:', error); + }); + }); +} + +/** Tears down sessions and the DB worker; safe when nothing was started. */ +export function shutdownDeferredServices(): void { + shutdownEmbeddedMpv(); + shutdownMpvSession(); + shutdownVlcSession(); + void databaseWorkerClient.shutdown(); +} + +/** The module shape main.ts receives from its dynamic import. */ +export type DeferredEventsModule = { + readonly bootstrapDeferredEvents: typeof bootstrapDeferredEvents; + readonly finishStartupAfterFirstLoad: typeof finishStartupAfterFirstLoad; + readonly scheduleDeferredFixPath: typeof scheduleDeferredFixPath; + readonly shutdownDeferredServices: typeof shutdownDeferredServices; +}; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 1bc492d8f..0961a0f9b 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -1,51 +1,24 @@ -import { registerM3uSourceProbe } from './app/events/m3u-source-probe'; -import { registerSourceProbeCancellation } from './app/events/source-probe-control'; // Select persistence before eager imports (notably electron-conf) cache userData. import './app/services/electron-profile-bootstrap'; import { app, BrowserWindow } from 'electron'; -import { autoUpdater } from 'electron-updater'; -import fixPath from 'fix-path'; import App from './app/app'; -import { initDatabase } from './app/database/connection'; -import DatabaseEvents from './app/events/database.events'; -import { - resetStaleDownloads, - setMainWindow as setDownloadsMainWindow, -} from './app/events/database/downloads.events'; -import { setRecordingsMainWindow } from './app/events/database/recording-broadcast'; -import { reconcileStaleRecordings } from './app/events/database/recording-recovery'; -import ElectronEvents from './app/events/electron.events'; -import EmbeddedMpvEvents, { - shutdownEmbeddedMpv, -} from './app/events/embedded-mpv.events'; -import EpgEvents from './app/events/epg.events'; -import AppUpdateEvents from './app/events/app-update.events'; -import { shutdownMpvSession } from './app/events/mpv-session.service'; -import PlayerEvents from './app/events/player.events'; -import { shutdownVlcSession } from './app/events/vlc-session.service'; -import PlaylistEvents from './app/events/playlist.events'; import PlaylistOpenEvents from './app/events/playlist-open.events'; -import RemoteControlEvents from './app/events/remote-control.events'; -import SettingsEvents from './app/events/settings.events'; -import SharedEvents from './app/events/shared.events'; import SquirrelEvents from './app/events/squirrel.events'; -import StalkerEvents from './app/events/stalker.events'; import { isStartupTraceEnabled, trace } from './app/services/debug-trace'; import { readCompileCacheOutcome } from './app/services/compile-cache'; import { applyElectronNetworkDefaults } from './app/util/network-defaults'; import { registerStaticHeaderShims } from './app/services/request-header-overrides.service'; -import { AppUpdateService } from './app/services/app-update.service'; -import { - onAppUpdateChannelChange, - readStoredAppUpdateChannel, -} from './app/services/app-update-channel'; -import { databaseWorkerClient } from './app/services/database-worker-client'; import WindowEvents from './app/events/window.events'; import { bootstrapWindowCloseGuard } from './app/services/window-close-guard.service'; -import { registerStreamProbeHandlers } from './app/events/stream-probe'; -import { registerConnectivityGuardHandlers } from './app/events/connectivity-guard.events'; -import XtreamEvents from './app/events/xtream.events'; import { environment } from './environments/environment'; +import { + createDeferredBootstrap, + type DeferredBootstrap, +} from './app/startup/deferred-bootstrap'; +import type { + DeferredEventsHandles, + DeferredEventsModule, +} from './app/startup/deferred-events'; import { isFrameCopyRuntimeUsable, shouldPromotePersistedFrameCopyOptIn, @@ -104,33 +77,11 @@ if ( process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; } -let fixPathScheduled = false; - -/** - * Update process.env.PATH from the user's interactive login shell so that - * spawned external players (MPV/VLC) can be resolved by binary name. - * - * Runs after window creation + IPC handler registration so the 50-300 ms - * shell-spawn cost (bash/zsh -ilc env) doesn't block startup. Idempotent: - * subsequent calls are no-ops. - */ -function scheduleDeferredFixPath(): void { - if (fixPathScheduled || process.platform === 'win32') { - return; - } - - fixPathScheduled = true; - setImmediate(() => { - try { - fixPath(); - if (isStartupTraceEnabled()) { - trace('startup', 'fix-path:done'); - } - } catch (error) { - console.warn('fix-path failed:', error); - } - }); -} +/** Set once bootstrapAppEvents() arms the deferred group; read at quit. */ +let deferredEvents: DeferredBootstrap< + DeferredEventsModule, + DeferredEventsHandles +> | null = null; export default class Main { static initialize() { @@ -147,6 +98,13 @@ export default class Main { App.main(app, BrowserWindow); } + /** + * Everything the renderer may call before its first paint registers + * here, synchronously, before the window loads. The rest lives in + * app/startup/deferred-events.ts and is loaded inside the window's + * `did-start-loading` listener (see deferred-bootstrap.ts for why that + * still guarantees the handlers exist before any renderer invoke). + */ static async bootstrapAppEvents() { if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:start'); @@ -155,75 +113,62 @@ export default class Main { const windowCloseGuard = bootstrapWindowCloseGuard((listener) => App.onMainWindowCreated(listener) ); - const appUpdateService = new AppUpdateService({ - app, - appVersion: environment.version, - channel: readStoredAppUpdateChannel(), - getMainWindow: () => App.mainWindow, - updater: () => autoUpdater, - // quitAndInstall() closes the windows before 'before-quit' fires - // (macOS), so without this an armed close guard would intercept - // the install's window close and strand the update. - prepareQuit: () => windowCloseGuard.allowNextClose(), - cancelPreparedQuit: () => windowCloseGuard.revokeAllowedClose(), - }); - AppUpdateEvents.bootstrapAppUpdateEvents(appUpdateService); - onAppUpdateChannelChange((channel) => - appUpdateService.setChannel(channel) - ); - registerStaticHeaderShims(); - ElectronEvents.bootstrapElectronEvents(); WindowEvents.bootstrapWindowEvents(); - EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents(); - PlaylistEvents.bootstrapPlaylistEvents(); PlaylistOpenEvents.bootstrapPlaylistOpenEvents(); - SharedEvents.bootstrapSharedEvents(); - PlayerEvents.bootstrapPlayerEvents(); - SettingsEvents.bootstrapSettingsEvents(); - StalkerEvents.bootstrapStalkerEvents(); - XtreamEvents.bootstrapXtreamEvents(); - registerStreamProbeHandlers(); - registerM3uSourceProbe(); - registerSourceProbeCancellation(); - registerConnectivityGuardHandlers(); - DatabaseEvents.bootstrapDatabaseEvents(); - EpgEvents.bootstrapEpgEvents(); - RemoteControlEvents.bootstrapRemoteControlEvents(); - // Keep the downloads broadcaster bound to the live window. macOS can - // rebuild the window while the process runs, and a stale reference - // silently swallows every DOWNLOADS_UPDATE_EVENT. - App.onMainWindowCreated(setDownloadsMainWindow); - App.onMainWindowCreated(setRecordingsMainWindow); + const deferred = createDeferredBootstrap< + DeferredEventsModule, + DeferredEventsHandles + >({ + load: () => + import( + /* webpackChunkName: "deferred-events" */ './app/startup/deferred-events.js' + ), + run: (module) => + module.bootstrapDeferredEvents({ + appVersion: environment.version, + windowCloseGuard, + }), + onTrigger: (source) => { + if (isStartupTraceEnabled()) { + trace('startup', 'deferred-events:start', { source }); + } + }, + onDone: (durationMs) => { + if (isStartupTraceEnabled()) { + trace('startup', '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. + onError: (error) => { + console.error( + 'Deferred main-process startup failed; portal, EPG, database and download handlers are unavailable:', + error + ); + if (isStartupTraceEnabled()) { + trace('startup', 'deferred-events:failed', error); + } + }, + }); + deferredEvents = deferred; + deferred.armOn(App.mainWindow?.webContents); - // Load the renderer only after IPC handlers are registered. On slower - // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC - // before the main process has installed handlers. - await App.loadMainWindow(); - void appUpdateService.checkForUpdatesOnStartup(); + // Load the renderer only after the pre-paint handlers are registered. + // The deferred group registers as soon as the navigation starts; the + // fallback below covers a load that never gets that far. Its errors + // surface through the awaited trigger(), so they are swallowed here. + const loadingMainWindow = App.loadMainWindow(); + void loadingMainWindow + .catch(() => undefined) + .then(() => deferred.trigger()) + .catch(() => undefined); + await loadingMainWindow; + const { module, result } = await deferred.trigger(); + void result.appUpdateService.checkForUpdatesOnStartup(); - // Initialize the database after the first renderer load is underway so - // Linux Electron E2E can observe a BrowserWindow even when SQLite - // startup or download recovery is slow. IPC handlers call getDatabase() - // lazily and share the same initialization promise. - await initDatabase(); - - if (isStartupTraceEnabled()) { - trace('startup', 'init-database:done'); - } - - await resetStaleDownloads(); - - if (isStartupTraceEnabled()) { - trace('startup', 'reset-stale-downloads:done'); - } - - await reconcileStaleRecordings(); - - if (isStartupTraceEnabled()) { - trace('startup', 'reconcile-stale-recordings:done'); - } + await module.finishStartupAfterFirstLoad(); if (isStartupTraceEnabled()) { trace('startup', 'bootstrap-events:done'); @@ -236,7 +181,7 @@ export default class Main { // takes to complete; the spawn would still find MPV/VLC at any of // the well-known paths checked by getDefault*Path before falling // back to bare-name PATH lookup. - scheduleDeferredFixPath(); + module.scheduleDeferredFixPath(); } } @@ -307,9 +252,7 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // playback and database work destroyed. 'will-quit' only fires once // every window close was allowed through. app.on('will-quit', () => { - shutdownEmbeddedMpv(); - shutdownMpvSession(); - shutdownVlcSession(); - void databaseWorkerClient.shutdown(); + // Nothing to tear down when the deferred group never loaded. + deferredEvents?.module?.shutdownDeferredServices(); }); }); diff --git a/apps/electron-backend/webpack.config.cjs b/apps/electron-backend/webpack.config.cjs new file mode 100644 index 000000000..0923ee17a --- /dev/null +++ b/apps/electron-backend/webpack.config.cjs @@ -0,0 +1,21 @@ +/** + * nx-electron build hook (project.json `webpackConfig`). + * + * The backend compiles with TypeScript's NodeNext resolution, which spells a + * relative dynamic import with a `.js` extension (main.ts loads + * `./app/startup/deferred-events.js`). webpack must map that back onto the + * `.ts` source, which is what `resolve.extensionAlias` does. + */ +module.exports = (config) => { + // Async chunks keep their webpackChunkName instead of a numeric id, so + // packaging and the layout check can list them by name. + config.output = { ...config.output, chunkFilename: '[name].js' }; + config.resolve = { + ...config.resolve, + extensionAlias: { + ...config.resolve?.extensionAlias, + '.js': ['.ts', '.js'], + }, + }; + return config; +}; diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md index f198ab427..0734f9e15 100644 --- a/docs/development/electron-debugging.md +++ b/docs/development/electron-debugging.md @@ -97,14 +97,24 @@ classifying a zero rendered-frame signal as an infrastructure flake. The process entry is `apps/electron-backend/src/main.entry.ts` (built to `dist/apps/electron-backend/main.js`): it enables the V8 compile cache under `userData/v8-compile-cache` and then requires the application bundle, -`main.app.js`, built from `apps/electron-backend/src/main.ts`, which bootstraps -the database, registers events and creates the main window. The cache is +`main.app.js`, built from `apps/electron-backend/src/main.ts`. Before the window +loads, `main.ts` registers only what the renderer can call before its first paint +(window state, the close guard, playlist-open requests, request-header shims); +everything else, including the database, portal, EPG, download, player, +remote-control and update IPC, lives in +`apps/electron-backend/src/app/startup/deferred-events.ts`, built as the +`deferred-events.js` chunk and loaded inside the window's `did-start-loading` +listener. That import and its registrations finish within the same task, so no +renderer `invoke` can find a missing handler (`app/startup/deferred-bootstrap.ts` +holds the scheduler and its test); the startup trace reports it as +`deferred-events:start` and `deferred-events:done`. The cache is disposable; `IPTVNATOR_DISABLE_COMPILE_CACHE=1` turns it off and `IPTVNATOR_COMPILE_CACHE_DIR` relocates it (E2E runs keep it inside `IPTVNATOR_E2E_DATA_DIR`). nx-electron packages the backend through an -allowlist, so `apps/electron-backend/project.json` lists `main.app.js` under the -`files` option of the `package` and `make` targets, and `verify:package-layout` -fails when either entry file is missing from `app.asar`. The preload is +allowlist, so `apps/electron-backend/project.json` lists `main.app.js` and +`deferred-events.js` under the `files` option of the `package` and `make` +targets, and `verify:package-layout` fails when any of the entry files is missing +from `app.asar`. The preload is `apps/electron-backend/src/app/api/main.preload.ts`, with handlers under `apps/electron-backend/src/app/events/`. The window follows the saved startup mode (normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 54bd1ecb9..9fb7fa6ae 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -474,13 +474,15 @@ function verifyPackagedPackageMetadata(resourceDir, errors) { /** * The main-process entry is split: `main.js` enables the V8 compile cache and - * requires `main.app.js`, the application bundle. nx-electron packages the + * requires `main.app.js`, the application bundle, which loads the + * `deferred-events.js` chunk once the window starts loading. nx-electron packages the * backend through an allowlist, so a missing bundle only surfaces as an * uncaught "Cannot find module" at launch; fail the layout check instead. */ const REQUIRED_MAIN_PROCESS_ENTRIES = [ '/electron-backend/main.js', '/electron-backend/main.app.js', + '/electron-backend/deferred-events.js', '/electron-backend/main.preload.js', ]; From e619a2be860dc3068ce135e4d8dec227e6f7cbcf Mon Sep 17 00:00:00 2001 From: Kate Markson Date: Sat, 26 Sep 2026 12:59:09 -0700 Subject: [PATCH 03/20] fix(xtream): scroll to the selected category (#1663) --- .changes/xtream-category-selection-scroll.md | 6 + .../src/category-management.e2e.ts | 128 ++++++++++++++++++ apps/xtream-mock-server/src/app/data-store.ts | 9 ++ apps/xtream-mock-server/src/app/scenarios.ts | 14 ++ docs/architecture/category-management.md | 5 + ...rkspace-context-category-view.component.ts | 13 +- .../workspace-context-panel.component.spec.ts | 57 +++++++- 7 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 .changes/xtream-category-selection-scroll.md diff --git a/.changes/xtream-category-selection-scroll.md b/.changes/xtream-category-selection-scroll.md new file mode 100644 index 000000000..19c2bed83 --- /dev/null +++ b/.changes/xtream-category-selection-scroll.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Selecting an Xtream category no longer scrolls the category panel to an unrelated category, including when categories are hidden or sorted alphabetically. diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 181bc1079..af7ae8137 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -1,4 +1,6 @@ import { Locator, Page } from '@playwright/test'; +import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces'; +import { ok as assert } from 'node:assert'; import { addXtreamPortal, closeElectronApp, @@ -14,8 +16,96 @@ import { waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { applyTheme } from './theme-contrast'; test.describe('Electron Xtream Category Management', () => { + test('keeps the selected category in view with 800 categories, 600 hidden and A-Z sorting', async ({ + dataDir, + request, + }) => { + test.slow(); + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + try { + await addXtreamPortal(app.mainWindow, { + username: 'category-scroll', + password: 'category-scroll', + }); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await waitForXtreamWorkspaceReady(app.mainWindow); + const panel = app.mainWindow.locator('app-workspace-context-panel'); + const rows = panel.locator('.category-item'); + await expect(rows).toHaveCount(800); + const dialog = await openManageCategoriesDialog(app.mainWindow); + await dialog + .getByRole('button', { name: 'Deselect All', exact: true }) + .click(); + await dialog.locator('input[type="search"]').fill('Visible'); + await dialog + .getByRole('button', { name: 'Select Filtered', exact: true }) + .click(); + await expect(dialog.locator('.selection-info')).toHaveText( + 'Total selected: 200 / 800' + ); + await dialog + .getByRole('button', { name: 'Save', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await expect(rows).toHaveCount(200); + await panel + .getByRole('button', { name: 'Sort categories', exact: true }) + .click(); + await app.mainWindow + .getByRole('menuitem', { name: 'Name A-Z' }) + .click(); + + const categories = await app.mainWindow.evaluate(async () => { + const playlistId = location.pathname.match( + /\/workspace\/xtreams\/([^/]+)/ + )?.[1]; + if (!playlistId) + throw new Error('Xtream playlist route is missing'); + const api = ( + window as unknown as { electron: ElectronBridgeApi } + ).electron; + return api.dbGetCategories(playlistId, 'live'); + }); + categories.sort((left, right) => + left.name.localeCompare(right.name) + ); + await expect(rows.locator('.nav-item-label')).toHaveText( + categories.map((category) => category.name) + ); + + // Exercise collisions above and below the clicked row. Reading the + // imported IDs avoids relying on SQLite allocation/import order. + for (const [theme, direction] of [ + ['dark', -1], + ['light', 1], + ] as const) { + await applyTheme(app.mainWindow, theme); + const category = categories.find((candidate, index) => { + const wrongIndex = categories.findIndex( + (other) => other.xtream_id === candidate.id + ); + return ( + wrongIndex >= 0 && (wrongIndex - index) * direction > 15 + ); + }); + assert( + category, + `Missing fixture collision in direction ${direction}` + ); + const row = rows.filter({ hasText: category.name }); + await row.click(); + await expect(row).toHaveAttribute('aria-current', 'true'); + await expectCategoryCentered(row); + } + } finally { + await closeElectronApp(app); + } + }); + for (const section of ['Live TV', 'Movies', 'Series']) { test(`bulk edits only filtered ${section} categories and saves or discards the draft`, async ({ dataDir, @@ -407,6 +497,44 @@ async function openManageCategoriesDialog(page: Page) { return dialog; } +/** Wait for the real smooth scroll to finish with the selected row centered. */ +async function expectCategoryCentered(row: Locator): Promise { + let previousTop = -1; + let stableSamples = 0; + await expect + .poll( + async () => { + const position = await row.evaluate((element) => { + const container = element.closest( + 'app-workspace-context-category-view' + ) as HTMLElement; + const bounds = container.getBoundingClientRect(); + const rowBounds = element.getBoundingClientRect(); + const target = + container.scrollTop + + rowBounds.top - + bounds.top - + container.clientHeight / 2 + + rowBounds.height / 2; + const clamped = Math.min( + container.scrollHeight - container.clientHeight, + Math.max(0, target) + ); + return { + top: container.scrollTop, + centered: Math.abs(container.scrollTop - clamped) < 2, + }; + }); + stableSamples = + position.top === previousTop ? stableSamples + 1 : 0; + previousTop = position.top; + return position.centered && stableSamples >= 3; + }, + { intervals: [100] } + ) + .toBe(true); +} + async function refreshFromWorkspaceHeader(page: Page): Promise { await page .getByRole('button', { name: 'Refresh playlist', exact: true }) diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index 82b908554..37877570c 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -84,6 +84,15 @@ function generatePortalData(username: string, password: string): PortalData { }; } let liveCategories = generateCategories('live', categoryCount.live); + if (scenario.categoryFixture === 'scroll') { + liveCategories = liveCategories.map((category, index) => ({ + ...category, + // Coprime step permutes names independently of provider/SQLite IDs. + category_name: `${index % 4 === 0 ? 'Visible' : 'Hidden'} ${String( + (index * 137) % categoryCount.live + ).padStart(3, '0')}`, + })); + } const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); const epgListingsByStreamId = new Map(); diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 4470cda72..1ab1949a0 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -13,6 +13,8 @@ export interface ScenarioConfig { expiryDate: string; /** Optional deterministic EPG fixture profile for scenario-specific tests. */ epgFixture?: 'timezone-focus'; + /** Large, deliberately reordered categories for sidebar scroll coverage. */ + categoryFixture?: 'scroll'; /** * Optional `server_info` clock override. `timezone` is reported * verbatim (real panels sometimes send spellings such as `UTC+3` that @@ -54,6 +56,18 @@ export interface ScenarioConfig { * Unknown credential pairs use a hash of "username:password" as seed. */ export const SCENARIOS: Record = { + 'category-scroll:category-scroll': { + name: 'category-scroll', + description: '800 live categories, including 200 marked Visible', + seed: 800, + categoryCount: { live: 800, vod: 0, series: 0 }, + itemsPerCategory: 1, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + categoryFixture: 'scroll', + }, 'live-fallback:live-fallback': { name: 'live-format-fallback', description: 'Local HLS failures and playable TS', diff --git a/docs/architecture/category-management.md b/docs/architecture/category-management.md index 8a863b44d..d22e0d4a7 100644 --- a/docs/architecture/category-management.md +++ b/docs/architecture/category-management.md @@ -87,6 +87,11 @@ ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0 - **No content deletion**: Hiding a category only affects sidebar visibility; the category and its content remain in the database - **Display order**: The sidebar defaults to server order. Users can switch the category panel to `A-Z` or `Z-A` from the sort menu next to category search. +- **Selection scrolling**: The panel centers the rendered selected row after + selection changes. Electron selects by local SQLite category ID; the row's + `data-category-id` can contain its provider ID. Those IDs are not + interchangeable when locating the scroll target, including after filtering + hidden categories or sorting. - **All-hidden recovery**: Once the selected Xtream type is loaded, the manage categories button remains available even if every visible category has been hidden. The sidebar category list is filtered, but the dialog reads all diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts index 72e1b2ccd..bd7b117cd 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.ts @@ -61,7 +61,7 @@ export class WorkspaceContextCategoryViewComponent { } } - private readonly hostEl = inject(ElementRef); + private readonly hostEl = inject>(ElementRef); readonly categoryClicked = output(); @@ -74,12 +74,11 @@ export class WorkspaceContextCategoryViewComponent { queueMicrotask(() => { const container = this.hostEl.nativeElement; - const candidates = Array.from( - container.querySelectorAll('[data-category-id]') - ) as HTMLElement[]; - const selected = candidates.find( - (el) => - el.dataset['categoryId'] === String(selectedCategory) + // Follow the rendered selection: Electron selects by SQLite + // ID, while data-category-id can contain a provider ID that + // coincides with a different row's SQLite ID. + const selected = container.querySelector( + '.category-item[aria-current="true"]' ); if (!selected) { return; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index 936fb565c..4f7741dfe 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -62,7 +62,9 @@ function getCategoryLabels( describe('WorkspaceContextPanelComponent', () => { let fixture: ComponentFixture; - const xtreamCategories = signal([ + const xtreamCategories = signal< + Array<{ id: number; name: string; xtream_id?: number }> + >([ { id: 1, name: 'News' }, { id: 2, name: 'Sports' }, ]); @@ -323,6 +325,59 @@ describe('WorkspaceContextPanelComponent', () => { ); }); + it.each([ + { id: 7, name: 'Alpha', top: 520 }, + { id: 8, name: 'Zulu', top: 2420 }, + ])( + 'scrolls to the selected local category $name when provider IDs collide after sorting', + async ({ id, name, top }) => { + fixture.componentRef.setInput('section', 'live'); + xtreamSelectedTypeContentState.set('ready'); + // SQLite IDs drive selection; provider IDs can belong to another row. + xtreamCategories.set([ + { id: 8, xtream_id: 7, name: 'Zulu' }, + { id: 50, xtream_id: 500, name: 'Middle' }, + { id: 7, xtream_id: 8, name: 'Alpha' }, + ]); + fixture.componentInstance.setCategorySortMode('name-asc'); + fixture.detectChanges(); + + const container = fixture.nativeElement.querySelector( + 'app-workspace-context-category-view' + ) as HTMLElement; + Object.defineProperties(container, { + clientHeight: { value: 400 }, + scrollHeight: { value: 3000 }, + }); + container.scrollTop = 500; + container.getBoundingClientRect = () => + new DOMRect(0, 100, 200, 400); + container.scrollTo = jest.fn(); + const rows = Array.from( + container.querySelectorAll('.category-item') + ); + rows.forEach((row, index) => { + row.getBoundingClientRect = () => + new DOMRect(0, [300, 800, 2200][index], 200, 40); + }); + const selected = rows.find((row) => + row.textContent?.includes(name) + ); + if (!selected) throw new Error(`Missing category ${name}`); + selected.click(); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(id); + xtreamSelectedCategoryId.set(id); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(selected.getAttribute('aria-current')).toBe('true'); + expect(container.scrollTo).toHaveBeenCalledWith({ + behavior: 'smooth', + top, + }); + } + ); + it('uses translated category sort labels and distinct mode icons', () => { fixture.componentRef.setInput('section', 'vod'); xtreamSelectedTypeContentState.set('ready'); From 8ebb7e3424a5f1afa584dac92cdfee03b3c5e999 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:05:26 +0200 Subject: [PATCH 04/20] perf(ci): run Tier A coverage concurrently with isolatedModules ts-jest (#1701) Tier A coverage runs projects a few at a time (largest first, bounded Jest workers, buffered output, fail-fast kept) and ts-jest transpiles with isolatedModules instead of type-checking per process; five type re-exports become export type, two decorated inputs use import type. Unit Tests and Typechecks job: 26 min -> 9 min (Tier A step 23 min -> 6.5 min). Co-Authored-By: Claude Fable 5.1 Co-Authored-By: Claude Opus 5.5 --- apps/electron-backend/tsconfig.spec.json | 1 + apps/web-backend/tsconfig.spec.json | 1 + apps/web/tsconfig.spec.json | 1 + docs/architecture/validation-map.md | 11 ++ libs/epg/data-access/tsconfig.spec.json | 1 + libs/m3u-state/tsconfig.spec.json | 1 + libs/playback/util/tsconfig.spec.json | 1 + .../import/feature/tsconfig.spec.json | 1 + .../playlist-item/playlist-item.component.ts | 2 +- libs/playlist/shared/ui/tsconfig.spec.json | 1 + libs/playlist/shared/util/tsconfig.spec.json | 1 + .../portal/catalog/feature/tsconfig.spec.json | 1 + .../downloads/feature/tsconfig.spec.json | 1 + .../shared/data-access/tsconfig.spec.json | 1 + libs/portal/shared/ui/tsconfig.spec.json | 1 + libs/portal/shared/util/tsconfig.spec.json | 1 + .../stalker-serial-details.interface.ts | 2 +- .../models/stalker-vod-details.interface.ts | 2 +- .../stalker/data-access/tsconfig.spec.json | 1 + .../portal/stalker/feature/tsconfig.spec.json | 1 + .../xtream-data-source.interface.ts | 2 +- .../xtream/data-access/tsconfig.spec.json | 1 + libs/portal/xtream/feature/tsconfig.spec.json | 1 + libs/services/tsconfig.spec.json | 1 + libs/shared/database/tsconfig.spec.json | 1 + libs/shared/host-health/tsconfig.spec.json | 1 + libs/shared/interfaces/tsconfig.spec.json | 1 + libs/shared/logging/tsconfig.spec.json | 1 + libs/shared/m3u-utils/tsconfig.spec.json | 1 + libs/ui/components/tsconfig.spec.json | 1 + libs/ui/pipes/tsconfig.spec.json | 1 + .../html-video-player.component.ts | 2 +- .../dashboard/data-access/tsconfig.spec.json | 1 + .../dashboard/feature/tsconfig.spec.json | 1 + .../global-favorites-list.component.ts | 2 +- .../unified-favorite-channel.interface.ts | 4 +- .../shell/feature/tsconfig.spec.json | 1 + libs/workspace/shell/util/tsconfig.spec.json | 1 + package.json | 2 +- tools/coverage/coverage-run-pool.mjs | 153 ++++++++++++++++++ tools/coverage/coverage-run-pool.test.mjs | 139 ++++++++++++++++ tools/coverage/run-tier-a-coverage.mjs | 124 +++++++++++--- tsconfig.base.json | 1 + 43 files changed, 449 insertions(+), 27 deletions(-) create mode 100644 tools/coverage/coverage-run-pool.mjs create mode 100644 tools/coverage/coverage-run-pool.test.mjs diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index ae084b520..be2310d6e 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "esModuleInterop": true, "allowJs": true, diff --git a/apps/web-backend/tsconfig.spec.json b/apps/web-backend/tsconfig.spec.json index 09849f85f..58b060efd 100644 --- a/apps/web-backend/tsconfig.spec.json +++ b/apps/web-backend/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/apps/web/tsconfig.spec.json b/apps/web/tsconfig.spec.json index fd45b3daa..35ee5ed11 100644 --- a/apps/web/tsconfig.spec.json +++ b/apps/web/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "module": "ESNext", "target": "ESNext", diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 0e0e6e767..0c38921f3 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -71,6 +71,17 @@ behavior. Tier A coverage is fail-closed. `coverage:unit:ci` relays Jest output but exits nonzero on a `Failed to collect coverage` marker, a missing or invalid project report, or a runtime-owning production TypeScript file absent from that report. +It runs projects a few at a time, largest first, with a bounded Jest worker +count per project (defaults: `min(3, cores - 1)` in flight and +`ceil(cores / concurrency)` workers each; override with `--concurrency=N`, +`--max-workers=N` or `TIER_A_CONCURRENCY` / `TIER_A_MAX_WORKERS`). Each +project's output is printed as one block when it finishes, and the run ends +with the wall-clock total and the longest projects. Spec `tsconfig`s set +`isolatedModules: true`, so ts-jest transpiles files one at a time instead of +type-checking each through a language service; spec type errors therefore do +not fail Jest (the web configs already ran with `diagnostics: false`), while +`isolatedModules`-incompatible syntax such as a type re-export without +`export type` still fails at load time. `coverage:merge` requires every configured Tier A report before replacing the merged output. Strict health validation also requires the merged Istanbul map itself to contain usable instrumentation for every runtime-owning Tier A file, diff --git a/libs/epg/data-access/tsconfig.spec.json b/libs/epg/data-access/tsconfig.spec.json index 7b3e56bde..7f31bb031 100644 --- a/libs/epg/data-access/tsconfig.spec.json +++ b/libs/epg/data-access/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/m3u-state/tsconfig.spec.json b/libs/m3u-state/tsconfig.spec.json index 19497995e..3e3a2a849 100644 --- a/libs/m3u-state/tsconfig.spec.json +++ b/libs/m3u-state/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "module": "preserve", "target": "es2016", diff --git a/libs/playback/util/tsconfig.spec.json b/libs/playback/util/tsconfig.spec.json index 01174f48f..f4ba36395 100644 --- a/libs/playback/util/tsconfig.spec.json +++ b/libs/playback/util/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "ESNext", "target": "ESNext", diff --git a/libs/playlist/import/feature/tsconfig.spec.json b/libs/playlist/import/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/playlist/import/feature/tsconfig.spec.json +++ b/libs/playlist/import/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts index 1ab9a3f84..dc0c817b0 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-item/playlist-item.component.ts @@ -28,7 +28,7 @@ import { PortalStatusService, RuntimeCapabilitiesService, } from '@iptvnator/services'; -import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import type { PlaylistMeta } from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-playlist-item', diff --git a/libs/playlist/shared/ui/tsconfig.spec.json b/libs/playlist/shared/ui/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/playlist/shared/ui/tsconfig.spec.json +++ b/libs/playlist/shared/ui/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/playlist/shared/util/tsconfig.spec.json b/libs/playlist/shared/util/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/playlist/shared/util/tsconfig.spec.json +++ b/libs/playlist/shared/util/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/catalog/feature/tsconfig.spec.json b/libs/portal/catalog/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/catalog/feature/tsconfig.spec.json +++ b/libs/portal/catalog/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/downloads/feature/tsconfig.spec.json b/libs/portal/downloads/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/downloads/feature/tsconfig.spec.json +++ b/libs/portal/downloads/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/shared/data-access/tsconfig.spec.json b/libs/portal/shared/data-access/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/shared/data-access/tsconfig.spec.json +++ b/libs/portal/shared/data-access/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/shared/ui/tsconfig.spec.json b/libs/portal/shared/ui/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/shared/ui/tsconfig.spec.json +++ b/libs/portal/shared/ui/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/shared/util/tsconfig.spec.json b/libs/portal/shared/util/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/shared/util/tsconfig.spec.json +++ b/libs/portal/shared/util/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-serial-details.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-serial-details.interface.ts index 648cb1cb0..83815ffac 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-serial-details.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-serial-details.interface.ts @@ -2,4 +2,4 @@ * @deprecated Import from '@iptvnator/shared/interfaces' instead. * This re-export exists for backward compatibility. */ -export { StalkerSerialDetails } from '@iptvnator/shared/interfaces'; +export type { StalkerSerialDetails } from '@iptvnator/shared/interfaces'; diff --git a/libs/portal/stalker/data-access/src/lib/models/stalker-vod-details.interface.ts b/libs/portal/stalker/data-access/src/lib/models/stalker-vod-details.interface.ts index 48839c6c8..349a0a1cd 100644 --- a/libs/portal/stalker/data-access/src/lib/models/stalker-vod-details.interface.ts +++ b/libs/portal/stalker/data-access/src/lib/models/stalker-vod-details.interface.ts @@ -2,7 +2,7 @@ * @deprecated Import from '@iptvnator/shared/interfaces' instead. * This re-export exists for backward compatibility. */ -export { +export type { StalkerVodDetails, StalkerVodInfo, } from '@iptvnator/shared/interfaces'; diff --git a/libs/portal/stalker/data-access/tsconfig.spec.json b/libs/portal/stalker/data-access/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/stalker/data-access/tsconfig.spec.json +++ b/libs/portal/stalker/data-access/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/stalker/feature/tsconfig.spec.json b/libs/portal/stalker/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/stalker/feature/tsconfig.spec.json +++ b/libs/portal/stalker/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 383e108a3..e23731b59 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -16,7 +16,7 @@ import { } from '../services/xtream-api.service'; // Re-export for backward compatibility -export { PlaybackPositionData }; +export type { PlaybackPositionData }; /** * Playlist representation in the data source diff --git a/libs/portal/xtream/data-access/tsconfig.spec.json b/libs/portal/xtream/data-access/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/xtream/data-access/tsconfig.spec.json +++ b/libs/portal/xtream/data-access/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/portal/xtream/feature/tsconfig.spec.json b/libs/portal/xtream/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/portal/xtream/feature/tsconfig.spec.json +++ b/libs/portal/xtream/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/services/tsconfig.spec.json b/libs/services/tsconfig.spec.json index 19497995e..3e3a2a849 100644 --- a/libs/services/tsconfig.spec.json +++ b/libs/services/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../dist/out-tsc", "module": "preserve", "target": "es2016", diff --git a/libs/shared/database/tsconfig.spec.json b/libs/shared/database/tsconfig.spec.json index 90592e418..fd2a917d0 100644 --- a/libs/shared/database/tsconfig.spec.json +++ b/libs/shared/database/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "esModuleInterop": true, "module": "commonjs", diff --git a/libs/shared/host-health/tsconfig.spec.json b/libs/shared/host-health/tsconfig.spec.json index 4b0383fc4..d3d081d10 100644 --- a/libs/shared/host-health/tsconfig.spec.json +++ b/libs/shared/host-health/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/shared/interfaces/tsconfig.spec.json b/libs/shared/interfaces/tsconfig.spec.json index 4b0383fc4..d3d081d10 100644 --- a/libs/shared/interfaces/tsconfig.spec.json +++ b/libs/shared/interfaces/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/shared/logging/tsconfig.spec.json b/libs/shared/logging/tsconfig.spec.json index 4b0383fc4..d3d081d10 100644 --- a/libs/shared/logging/tsconfig.spec.json +++ b/libs/shared/logging/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/shared/m3u-utils/tsconfig.spec.json b/libs/shared/m3u-utils/tsconfig.spec.json index 4b0383fc4..d3d081d10 100644 --- a/libs/shared/m3u-utils/tsconfig.spec.json +++ b/libs/shared/m3u-utils/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/ui/components/tsconfig.spec.json b/libs/ui/components/tsconfig.spec.json index 7b3e56bde..7f31bb031 100644 --- a/libs/ui/components/tsconfig.spec.json +++ b/libs/ui/components/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/ui/pipes/tsconfig.spec.json b/libs/ui/pipes/tsconfig.spec.json index 7b3e56bde..7f31bb031 100644 --- a/libs/ui/pipes/tsconfig.spec.json +++ b/libs/ui/pipes/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts index b73989560..9cbb37f06 100644 --- a/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts +++ b/libs/ui/playback/src/lib/html-video-player/html-video-player.component.ts @@ -17,7 +17,7 @@ import { } from '@angular/core'; import Hls, { type ErrorData, type ManifestParsedData } from 'hls.js'; import mpegts from 'mpegts.js'; -import { Channel, createDevLogger } from '@iptvnator/shared/interfaces'; +import { type Channel, createDevLogger } from '@iptvnator/shared/interfaces'; import { releaseVideoPictureInPicture } from '../player-controls/web-video-picture-in-picture-lifecycle'; import { InlinePlaybackPlayer, diff --git a/libs/workspace/dashboard/data-access/tsconfig.spec.json b/libs/workspace/dashboard/data-access/tsconfig.spec.json index e7d59a0a6..4bee0750e 100644 --- a/libs/workspace/dashboard/data-access/tsconfig.spec.json +++ b/libs/workspace/dashboard/data-access/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/workspace/dashboard/feature/tsconfig.spec.json b/libs/workspace/dashboard/feature/tsconfig.spec.json index e7d59a0a6..4bee0750e 100644 --- a/libs/workspace/dashboard/feature/tsconfig.spec.json +++ b/libs/workspace/dashboard/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "moduleResolution": "node10", diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts index cadecccc7..246af0ece 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites-list.component.ts @@ -1,4 +1,4 @@ export { - EnrichedUnifiedFavorite, + type EnrichedUnifiedFavorite, GlobalFavoritesListComponent, } from '@iptvnator/portal/shared/ui'; diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts b/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts index e642808ce..7b09dd6c5 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/unified-favorite-channel.interface.ts @@ -1,5 +1,5 @@ export { buildFavoriteUid, - FavoriteSourceType, - UnifiedFavoriteChannel, + type FavoriteSourceType, + type UnifiedFavoriteChannel, } from '@iptvnator/portal/shared/util'; diff --git a/libs/workspace/shell/feature/tsconfig.spec.json b/libs/workspace/shell/feature/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/workspace/shell/feature/tsconfig.spec.json +++ b/libs/workspace/shell/feature/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/libs/workspace/shell/util/tsconfig.spec.json b/libs/workspace/shell/util/tsconfig.spec.json index c0bb248be..6aa6499b9 100644 --- a/libs/workspace/shell/util/tsconfig.spec.json +++ b/libs/workspace/shell/util/tsconfig.spec.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { + "emitDecoratorMetadata": false, "outDir": "../../../../dist/out-tsc", "module": "commonjs", "target": "es2016", diff --git a/package.json b/package.json index 9254f378f..c4071a31a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "styles:inputs:test": "node --test tools/nx/check-stylesheet-inputs.test.mjs", "styles:inputs:check": "node tools/nx/check-stylesheet-inputs.mjs", "styles:inputs:validate": "pnpm run styles:inputs:test && pnpm run styles:inputs:check", - "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs", + "coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs", "coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs", "coverage:merge": "node tools/coverage/merge-coverage.mjs", "coverage:health": "node tools/coverage/coverage-health.mjs", diff --git a/tools/coverage/coverage-run-pool.mjs b/tools/coverage/coverage-run-pool.mjs new file mode 100644 index 000000000..d6e56b804 --- /dev/null +++ b/tools/coverage/coverage-run-pool.mjs @@ -0,0 +1,153 @@ +/** + * Scheduling helpers for tools/coverage/run-tier-a-coverage.mjs. + * + * Tier A used to run its ~33 projects one after another, each as its own + * `pnpm nx run :test` process: 23 minutes in CI, most of it Jest and + * Nx start-up, ts-jest cache warm-up and idle workers on small projects. The + * runner now keeps a few projects in flight at once and gives each Jest a + * bounded worker count, so the runner's total CPU budget stays close to the + * machine's core count instead of multiplying with it. + */ +import { readdirSync } from 'node:fs'; +import path from 'node:path'; + +const SPEC_FILE = /\.(spec|test)\.ts$/; + +/** Counts spec files under a directory; used to start the big projects first. */ +export function countSpecFiles(directory) { + let count = 0; + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch { + return 0; + } + for (const entry of entries) { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + count += countSpecFiles(fullPath); + } else if (entry.isFile() && SPEC_FILE.test(entry.name)) { + count += 1; + } + } + return count; +} + +/** + * Longest-first ordering: with a bounded pool, a big project started last + * would run alone at the end while the other slots sit idle. Ties keep the + * policy order so the output stays stable. + */ +export function orderLongestFirst(projects, weightOf) { + return projects + .map((project, index) => ({ project, index, weight: weightOf(project) })) + .sort((a, b) => b.weight - a.weight || a.index - b.index) + .map((entry) => entry.project); +} + +/** + * How many projects to keep in flight. Defaults to one less than the core + * count, capped at three: beyond that the per-process start-up cost is paid + * anyway and the Jest workers of the concurrent runs starve each other. + */ +export function resolveConcurrency({ requested, cpuCount }) { + if (Number.isInteger(requested) && requested > 0) return requested; + return Math.max(1, Math.min(3, cpuCount - 1)); +} + +/** + * Jest workers per project, so that concurrency × workers stays near the core + * count. Small projects never use them all, which is what leaves room for the + * other slots. + */ +export function resolveWorkersPerProject({ requested, concurrency, cpuCount }) { + if (Number.isInteger(requested) && requested > 0) return requested; + return Math.max(1, Math.ceil(cpuCount / concurrency)); +} + +/** + * Runs `tasks` (functions returning a promise of `{ status }`) with at most + * `concurrency` in flight. Fail-fast: after the first non-zero status no new + * task starts, but the ones already running are awaited so their output and + * coverage files are complete. Resolves with every started task's result in + * start order plus the names that were never started. + */ +export async function runWithConcurrency(tasks, { concurrency, onSettled }) { + const results = []; + const skipped = []; + let nextIndex = 0; + let failed = false; + + async function worker() { + while (nextIndex < tasks.length) { + const index = nextIndex++; + const task = tasks[index]; + if (failed) { + skipped.push(task.name); + continue; + } + const startedAt = Date.now(); + let result; + try { + result = await task.run(); + } catch (error) { + result = { status: 1, error }; + } + const settled = { + name: task.name, + status: result.status, + error: result.error, + durationMs: Date.now() - startedAt, + }; + results[index] = settled; + if (settled.status !== 0) failed = true; + onSettled?.(settled); + } + } + + const workers = []; + for (let slot = 0; slot < Math.max(1, concurrency); slot += 1) { + workers.push(worker()); + } + await Promise.all(workers); + + return { + failed, + results: results.filter(Boolean), + skipped, + }; +} + +export function formatDuration(ms) { + const seconds = Math.round(ms / 1000); + const minutes = Math.floor(seconds / 60); + return minutes > 0 ? `${minutes}m ${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`; +} + +/** + * Whole positive integers only: `3oops` and `2.5` are rejected rather than + * truncated, so a typo cannot silently apply a different resource budget. + */ +function parsePositiveInteger(raw, what) { + if (!/^\d+$/.test(raw.trim()) || Number.parseInt(raw, 10) < 1) { + throw new Error( + `${what} expects a positive integer, received "${raw}".` + ); + } + return Number.parseInt(raw, 10); +} + +/** Reads a positive integer from the environment; unset or empty means absent. */ +export function integerEnv(env, name) { + const raw = env[name]; + if (raw === undefined || raw.trim() === '') return undefined; + return parsePositiveInteger(raw, name); +} + +/** Parses `--flag=value` style integers; returns undefined when absent. */ +export function integerFlag(argv, name) { + const prefix = `--${name}=`; + const raw = argv.find((argument) => argument.startsWith(prefix)); + if (raw === undefined) return undefined; + return parsePositiveInteger(raw.slice(prefix.length), prefix); +} diff --git a/tools/coverage/coverage-run-pool.test.mjs b/tools/coverage/coverage-run-pool.test.mjs new file mode 100644 index 000000000..608c0d273 --- /dev/null +++ b/tools/coverage/coverage-run-pool.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; + +import { + countSpecFiles, + formatDuration, + integerEnv, + integerFlag, + orderLongestFirst, + resolveConcurrency, + resolveWorkersPerProject, + runWithConcurrency, +} from './coverage-run-pool.mjs'; + +let workDir; +before(async () => { + workDir = await mkdtemp(path.join(os.tmpdir(), 'coverage-run-pool-')); +}); +after(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('counts spec and test files recursively and ignores sources', async () => { + const root = path.join(workDir, 'lib'); + await mkdir(path.join(root, 'nested', 'deeper'), { recursive: true }); + await writeFile(path.join(root, 'a.spec.ts'), ''); + await writeFile(path.join(root, 'a.ts'), ''); + await writeFile(path.join(root, 'nested', 'b.test.ts'), ''); + await writeFile(path.join(root, 'nested', 'deeper', 'c.spec.ts'), ''); + await writeFile(path.join(root, 'nested', 'deeper', 'c.spec.ts.snap'), ''); + assert.equal(countSpecFiles(root), 3); + assert.equal(countSpecFiles(path.join(workDir, 'missing')), 0); +}); + +test('orders longest first and keeps policy order for ties', () => { + const projects = [ + { name: 'small' }, + { name: 'big' }, + { name: 'medium' }, + { name: 'also-small' }, + ]; + const weights = { small: 2, big: 50, medium: 10, 'also-small': 2 }; + assert.deepEqual( + orderLongestFirst(projects, (project) => weights[project.name]).map((p) => p.name), + ['big', 'medium', 'small', 'also-small'] + ); +}); + +test('derives concurrency and workers from the core count unless overridden', () => { + assert.equal(resolveConcurrency({ requested: undefined, cpuCount: 4 }), 3); + assert.equal(resolveConcurrency({ requested: undefined, cpuCount: 2 }), 1); + assert.equal(resolveConcurrency({ requested: undefined, cpuCount: 16 }), 3); + assert.equal(resolveConcurrency({ requested: 5, cpuCount: 2 }), 5); + assert.equal(resolveWorkersPerProject({ requested: undefined, concurrency: 3, cpuCount: 4 }), 2); + assert.equal(resolveWorkersPerProject({ requested: undefined, concurrency: 1, cpuCount: 4 }), 4); + assert.equal(resolveWorkersPerProject({ requested: 1, concurrency: 3, cpuCount: 16 }), 1); +}); + +function task(name, { delay = 0, status = 0, log }) { + return { + name, + run: () => + new Promise((resolve) => { + log.push(`start ${name}`); + setTimeout(() => { + log.push(`end ${name}`); + resolve({ status }); + }, delay); + }), + }; +} + +test('keeps at most `concurrency` tasks in flight and reports results in start order', async () => { + const log = []; + const tasks = [ + task('a', { delay: 30, log }), + task('b', { delay: 10, log }), + task('c', { delay: 10, log }), + task('d', { delay: 5, log }), + ]; + const settled = []; + const outcome = await runWithConcurrency(tasks, { + concurrency: 2, + onSettled: (result) => settled.push(result.name), + }); + assert.equal(outcome.failed, false); + assert.deepEqual(outcome.skipped, []); + assert.deepEqual(outcome.results.map((r) => r.name), ['a', 'b', 'c', 'd']); + // b finishes before a, so the third task starts before a ends. + assert.ok(log.indexOf('start c') < log.indexOf('end a')); + assert.ok(log.indexOf('start c') > log.indexOf('end b')); + assert.ok(outcome.results.every((r) => r.status === 0 && r.durationMs >= 0)); + assert.equal(settled.length, 4); +}); + +test('fails fast: a failure stops new tasks but lets running ones finish', async () => { + const log = []; + const tasks = [ + task('a', { delay: 40, log }), + task('b', { delay: 5, status: 1, log }), + task('c', { delay: 5, log }), + task('d', { delay: 5, log }), + ]; + const outcome = await runWithConcurrency(tasks, { concurrency: 2 }); + assert.equal(outcome.failed, true); + assert.deepEqual(outcome.skipped, ['c', 'd']); + assert.deepEqual(outcome.results.map((r) => [r.name, r.status]), [['a', 0], ['b', 1]]); + assert.ok(log.includes('end a'), 'the running task was awaited'); +}); + +test('a task that throws counts as a failure with the error attached', async () => { + const outcome = await runWithConcurrency( + [{ name: 'boom', run: () => Promise.reject(new Error('spawn failed')) }], + { concurrency: 1 } + ); + assert.equal(outcome.failed, true); + assert.match(outcome.results[0].error.message, /spawn failed/); +}); + +test('formats durations and parses integer flags', () => { + assert.equal(formatDuration(4200), '4s'); + assert.equal(formatDuration(125000), '2m 05s'); + assert.equal(integerFlag(['--concurrency=3'], 'concurrency'), 3); + assert.equal(integerFlag(['--projects=a'], 'concurrency'), undefined); + assert.throws(() => integerFlag(['--concurrency=0'], 'concurrency'), /positive integer/); + assert.throws(() => integerFlag(['--max-workers=two'], 'max-workers'), /positive integer/); + assert.equal(integerEnv({}, 'TIER_A_CONCURRENCY'), undefined); + assert.equal(integerEnv({ TIER_A_CONCURRENCY: '' }, 'TIER_A_CONCURRENCY'), undefined); + assert.equal(integerEnv({ TIER_A_CONCURRENCY: '2' }, 'TIER_A_CONCURRENCY'), 2); + assert.throws(() => integerEnv({ TIER_A_CONCURRENCY: 'x' }, 'TIER_A_CONCURRENCY'), /positive integer/); // Prefixes and fractions are rejected, not truncated. + assert.throws(() => integerFlag(['--concurrency=3oops'], 'concurrency'), /positive integer/); + assert.throws(() => integerFlag(['--concurrency=2.5'], 'concurrency'), /positive integer/); + assert.throws(() => integerEnv({ TIER_A_MAX_WORKERS: '2.5' }, 'TIER_A_MAX_WORKERS'), /positive integer/); + assert.throws(() => integerEnv({ TIER_A_MAX_WORKERS: '-1' }, 'TIER_A_MAX_WORKERS'), /positive integer/); + assert.equal(integerEnv({ TIER_A_MAX_WORKERS: ' 4 ' }, 'TIER_A_MAX_WORKERS'), 4); +}); diff --git a/tools/coverage/run-tier-a-coverage.mjs b/tools/coverage/run-tier-a-coverage.mjs index c9dda4974..716a4c57b 100644 --- a/tools/coverage/run-tier-a-coverage.mjs +++ b/tools/coverage/run-tier-a-coverage.mjs @@ -2,6 +2,7 @@ import { spawn } from 'node:child_process'; import { existsSync, readFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; @@ -9,6 +10,16 @@ import { createCoverageOutputScanner, validateProjectCoverage, } from './coverage-integrity.mjs'; +import { + countSpecFiles, + formatDuration, + integerEnv, + integerFlag, + orderLongestFirst, + resolveConcurrency, + resolveWorkersPerProject, + runWithConcurrency, +} from './coverage-run-pool.mjs'; const workspaceRoot = process.cwd(); const policyPath = path.join(workspaceRoot, 'tools/coverage/coverage-policy.json'); @@ -29,6 +40,24 @@ const tierAProjects = policy.unitCoverage.tierA.filter( (project) => requestedProjects.size === 0 || requestedProjects.has(project.name) ); +// Projects run a few at a time (see coverage-run-pool.mjs). Override with +// --concurrency=N / --max-workers=N or TIER_A_CONCURRENCY / TIER_A_MAX_WORKERS +// when a machine has more or less room than the defaults assume. +const cpuCount = os.availableParallelism?.() ?? os.cpus().length; +const concurrency = resolveConcurrency({ + requested: + integerFlag(process.argv.slice(2), 'concurrency') ?? + integerEnv(process.env, 'TIER_A_CONCURRENCY'), + cpuCount, +}); +const workersPerProject = resolveWorkersPerProject({ + requested: + integerFlag(process.argv.slice(2), 'max-workers') ?? + integerEnv(process.env, 'TIER_A_MAX_WORKERS'), + concurrency, + cpuCount, +}); + if (tierAProjects.length === 0) { console.error('No Tier A coverage projects matched the requested filters.'); process.exit(1); @@ -103,6 +132,7 @@ function buildNxArgs(project) { '--configuration=ci', '--codeCoverage', `--coverageDirectory=${coverageDirFor(project, 'workspace')}`, + `--maxWorkers=${workersPerProject}`, '--output-style=static', ]; } @@ -116,6 +146,7 @@ function buildNxArgs(project) { `${project.name}:test`, '--output-style=static', '--', + `--maxWorkers=${workersPerProject}`, ...collectCoverageArgs(project, jestRootMode), ]; } @@ -125,7 +156,12 @@ function buildNxArgs(project) { ); } -function spawnCoverage(args, scanner) { +/** + * Output is buffered per project and written in one piece when the project + * finishes: with several Jest processes in flight, interleaved lines would be + * unreadable and the coverage-failure scanner would see other projects' text. + */ +function spawnCoverage(args, scanner, output) { return new Promise((resolve, reject) => { const child = spawn('pnpm', args, { cwd: workspaceRoot, @@ -134,16 +170,16 @@ function spawnCoverage(args, scanner) { CI: process.env.CI ?? 'true', NX_TASKS_RUNNER_DYNAMIC_OUTPUT: 'false', }, - stdio: ['inherit', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe'], }); child.stdout.on('data', (chunk) => { scanner.push(chunk); - process.stdout.write(chunk); + output.push(chunk); }); child.stderr.on('data', (chunk) => { scanner.push(chunk); - process.stderr.write(chunk); + output.push(chunk); }); child.once('error', reject); child.once('close', (code, signal) => { @@ -152,35 +188,48 @@ function spawnCoverage(args, scanner) { }); } -async function collectProjectCoverage(project) { +async function collectProjectCoverage(project, specCount) { const args = buildNxArgs(project); - console.log(`\n==> Collecting coverage for ${project.name}`); - console.log(`pnpm ${args.join(' ')}`); + const output = []; + // The start line goes out immediately so a stalled project is visible in + // the log before the job times out; its full output follows on completion. + console.log( + `==> Started ${project.name} (${specCount} spec files): pnpm ${args.join(' ')}` + ); + const lines = [`\n==> Coverage for ${project.name}`, `pnpm ${args.join(' ')}`]; const scanner = createCoverageOutputScanner(); - const result = await spawnCoverage(args, scanner); + const result = await spawnCoverage(args, scanner, output); let failed = result.code !== 0 || result.signal !== null; + const flush = () => { + process.stdout.write(`${lines.join('\n')}\n`); + for (const chunk of output) process.stdout.write(chunk); + const last = output.at(-1); + if (last && !last.toString().endsWith('\n')) process.stdout.write('\n'); + }; + if (scanner.collectionFailed) { - console.error( - `Coverage collection failed while testing ${project.name}.` - ); - failed = true; + flush(); + console.error(`Coverage collection failed while testing ${project.name}.`); + return { status: 1 }; } if (failed) { - return result.code && result.code !== 0 ? result.code : 1; + flush(); + return { status: result.code && result.code !== 0 ? result.code : 1 }; } const validation = validateProjectCoverage({ project, workspaceRoot, }); + flush(); for (const error of validation.errors) { console.error(`Error: ${error}`); } - return validation.errors.length === 0 ? 0 : 1; + return { status: validation.errors.length === 0 ? 0 : 1 }; } for (const project of tierAProjects) { @@ -198,9 +247,48 @@ if (requestedProjects.size === 0) { } } -for (const project of tierAProjects) { - const status = await collectProjectCoverage(project); - if (status !== 0) { - process.exit(status); +const specCounts = new Map( + tierAProjects.map((project) => [ + project.name, + countSpecFiles(path.join(workspaceRoot, project.sourceRoot)), + ]) +); +const ordered = orderLongestFirst(tierAProjects, (project) => + specCounts.get(project.name) +); +console.log( + `Tier A coverage: ${ordered.length} projects, ${concurrency} in flight, ${workersPerProject} Jest workers each (${cpuCount} cores).` +); +const startedAt = Date.now(); +const outcome = await runWithConcurrency( + ordered.map((project) => ({ + name: project.name, + run: () => + collectProjectCoverage(project, specCounts.get(project.name)), + })), + { + concurrency, + onSettled: (settled) => { + console.log( + `<== ${settled.name} ${settled.status === 0 ? 'ok' : `failed (${settled.status})`} in ${formatDuration(settled.durationMs)}` + ); + if (settled.error) console.error(settled.error); + }, } +); + +const longest = [...outcome.results].sort((a, b) => b.durationMs - a.durationMs); +console.log(`\nTier A coverage finished in ${formatDuration(Date.now() - startedAt)} wall-clock; longest projects:`); +for (const entry of longest.slice(0, 8)) { + console.log(` ${formatDuration(entry.durationMs).padStart(7)} ${entry.name}`); +} +if (outcome.skipped.length > 0) { + console.error(`Not started after the first failure: ${outcome.skipped.join(', ')}`); +} +if (outcome.failed) { + const first = outcome.results.find((entry) => entry.status !== 0); + // Set the exit code instead of calling process.exit(): the failing + // project's buffered output may still be queued on a stdout pipe, and an + // immediate exit would truncate exactly the log that explains the failure. + process.exitCode = first?.status && first.status !== 0 ? first.status : 1; } diff --git a/tsconfig.base.json b/tsconfig.base.json index e3c20cee1..35697e966 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,6 +8,7 @@ "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, + "isolatedModules": true, "importHelpers": true, "target": "es2015", "module": "esnext", From b87291e38856df142bdcfbbf7bdcdbb0ae7cfb92 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:32:37 +0200 Subject: [PATCH 05/20] ci(e2e): run the macOS Electron suite as two shards (#1707) macOS runners are the scarcest on this account, so macOS now runs the Electron E2E suite as two Playwright shards instead of three; Ubuntu and Windows keep three. macOS shards get a 40-minute timeout. The summary job needed no change because it reads each shard's total from its report. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/e2e-tests.yaml | 19 ++++++++++++++----- docs/architecture/validation-map.md | 5 +++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index dd4031464..24d672d5e 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -42,17 +42,26 @@ jobs: # The sequential Electron suite is split into Playwright shards (one # runner each, split by spec file). Measured on 2026-09-26, a third of # the suite is 6-12 minutes of test time on top of 3-7 minutes of - # setup, so 30 minutes leaves room for retries on every OS. - timeout-minutes: 30 + # setup. macOS uses two shards because its runners are the scarcest: + # three macOS shards per run queued for 45-57 minutes on master. Half + # of the suite is about 15 minutes of test time on macOS, so it gets + # a longer timeout to leave room for retries. + timeout-minutes: ${{ matrix.os == 'macos-latest' && 40 || 30 }} env: IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '1' NX_SKIP_NX_CACHE: true strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - shard: [1, 2, 3] - shard-total: [3] + include: + - { os: ubuntu-latest, shard: 1, shard-total: 3 } + - { os: ubuntu-latest, shard: 2, shard-total: 3 } + - { os: ubuntu-latest, shard: 3, shard-total: 3 } + - { os: macos-latest, shard: 1, shard-total: 2 } + - { os: macos-latest, shard: 2, shard-total: 2 } + - { os: windows-latest, shard: 1, shard-total: 3 } + - { os: windows-latest, shard: 2, shard-total: 3 } + - { os: windows-latest, shard: 3, shard-total: 3 } steps: - uses: actions/checkout@v7 diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 0c38921f3..92e500a9c 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -146,8 +146,9 @@ After an E2E run, generate the semantic summary with: pnpm run coverage:e2e:summary ``` -CI runs the Electron suite as three Playwright shards per OS -(`--shard=/3`, split by spec file because the suite is sequential). Each +CI runs the Electron suite as Playwright shards (`--shard=/`, split +by spec file because the suite is sequential): three shards on Ubuntu and +Windows, two on macOS, whose runners are the scarcest and queued longest. Each shard uploads `playwright-report-electron--`; the follow-up `Electron E2E summary` job downloads the shards of each OS into their own directory and runs the summary per OS with `--input=` and From ea51cae3dbeab7328985c19b103b7aaeea06e717 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 27 Sep 2026 07:42:53 +0200 Subject: [PATCH 06/20] feat(settings): search settings from the header and the command palette (#1714) * feat(settings): search settings from the header and the command palette The header search on the Settings page was shown but disabled. It now searches a shared index of all 56 settings rows by translated title, description and English synonyms, replaces the section page with ranked results, and opens a result by scrolling to, focusing and briefly highlighting its row. Enter opens the best match, and the section navigation shows per-section match counts. The command palette gains a "Settings" group that lists the best six matches for a non-empty query, so any setting is one Ctrl/Cmd+K away. Rows hidden by the current form state fall back to the control that reveals them; rows the runtime cannot render are never returned. The index ships through a new @iptvnator/workspace/shell/util/settings-search sub-entrypoint so it stays out of the eager bundle, and a registry spec keeps it in step with the section templates. Co-Authored-By: Claude Opus 5.5 * fix(settings): let search reveals win over pending input and gate embedded MPV rows - A reveal (result click, command palette, Enter) now cancels a search keystroke still waiting for its debounce, so its q navigation can no longer supersede the reveal and leave the results open. - Embedded MPV extra options and auto-reconnect require a lazily probed embedded MPV capability; frame copy also needs frameCopyAvailable, so search never offers a row the settings page cannot render. - Keyboard users keep a focus-visible ring on the revealed row after the highlight fades. Co-Authored-By: Claude Opus 5.5 * fix(workspace): wait for palette probes without Promise.allSettled The web tsconfig lib predates Promise.allSettled; use Promise.all over rejection-safe probes instead. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .changes/settings-search.md | 10 + README.md | 7 +- apps/web-e2e/src/settings.e2e.ts | 78 +++++ .../settings-about-section.component.html | 22 +- .../settings-backup-section.component.html | 7 +- .../settings-dashboard-section.component.html | 64 ++-- .../settings-epg-section.component.html | 17 +- .../settings-general-section.component.html | 28 +- apps/web/src/app/settings/settings-options.ts | 75 +---- .../settings-playback-section.component.html | 46 ++- ...ings-remote-control-section.component.html | 12 +- .../settings-reset-section.component.html | 10 +- .../settings/settings-search-registry.spec.ts | 129 ++++++++ .../settings-search-results.component.html | 62 ++++ .../settings-search-results.component.scss | 121 +++++++ .../settings-search-results.component.ts | 30 ++ .../app/settings/settings-search.facade.ts | 178 +++++++++++ .../settings-tmdb-section.component.html | 11 +- .../src/app/settings/settings.component.html | 238 +++++++------- .../src/app/settings/settings.component.scss | 58 ++++ .../settings.component.search.spec.ts | 181 +++++++++++ .../src/app/settings/settings.component.ts | 16 + .../test-stubs/settings-test-harness.stub.ts | 20 +- apps/web/src/assets/i18n/ar.json | 9 +- apps/web/src/assets/i18n/ary.json | 9 +- apps/web/src/assets/i18n/by.json | 9 +- apps/web/src/assets/i18n/de.json | 9 +- apps/web/src/assets/i18n/el.json | 9 +- apps/web/src/assets/i18n/en.json | 9 +- apps/web/src/assets/i18n/es.json | 9 +- apps/web/src/assets/i18n/fr.json | 9 +- apps/web/src/assets/i18n/hu.json | 9 +- apps/web/src/assets/i18n/it.json | 9 +- apps/web/src/assets/i18n/ja.json | 9 +- apps/web/src/assets/i18n/ko.json | 9 +- apps/web/src/assets/i18n/nl.json | 9 +- apps/web/src/assets/i18n/pl.json | 9 +- apps/web/src/assets/i18n/pt.json | 9 +- apps/web/src/assets/i18n/ru.json | 9 +- apps/web/src/assets/i18n/tr.json | 9 +- apps/web/src/assets/i18n/zh.json | 9 +- apps/web/src/assets/i18n/zhtw.json | 9 +- docs/architecture/nx-workspace-boundaries.md | 3 + docs/architecture/workspace-shell.md | 54 +++- .../src/lib/workspace-view-command.types.ts | 7 +- ...orkspace-command-palette.component.spec.ts | 146 +++++++++ .../workspace-command-palette.component.ts | 42 ++- ...pace-settings-context-panel.component.scss | 5 + ...e-settings-context-panel.component.spec.ts | 75 +++++ ...kspace-settings-context-panel.component.ts | 19 +- .../workspace-shell-header.component.html | 4 +- .../workspace-shell-header.component.spec.ts | 15 + .../workspace-settings-commands.spec.ts | 96 ++++++ .../helpers/workspace-settings-commands.ts | 58 ++++ .../workspace-shell-command-builders.ts | 2 + .../helpers/workspace-shell-constants.ts | 2 + ...workspace-shell-command-palette.service.ts | 39 ++- ...orkspace-shell-search-sync.service.spec.ts | 16 + .../workspace-shell-search-sync.service.ts | 8 + .../workspace-shell-search.service.ts | 30 +- .../workspace-shell-search.settings.spec.ts | 133 ++++++++ .../services/workspace-shell.facade.spec.ts | 2 +- .../workspace-shell.component.spec.ts | 19 ++ .../workspace-shell.component.ts | 6 +- .../workspace-shell-route.utils.spec.ts | 3 + .../navigation/workspace-shell-route.utils.ts | 1 + .../util/src/lib/settings-context.service.ts | 13 + .../util/src/lib/settings-search/index.ts | 5 + .../settings-search-entries-playback.ts | 178 +++++++++++ .../settings-search-entries.ts | 259 +++++++++++++++ .../settings-search-rank.spec.ts | 86 +++++ .../settings-search/settings-search-rank.ts | 92 ++++++ .../settings-search-sections.ts | 47 +++ .../settings-search.service.spec.ts | 294 ++++++++++++++++++ .../settings-search.service.ts | 193 ++++++++++++ .../settings-search/settings-search.types.ts | 74 +++++ tsconfig.base.json | 3 + 77 files changed, 3304 insertions(+), 316 deletions(-) create mode 100644 .changes/settings-search.md create mode 100644 apps/web/src/app/settings/settings-search-registry.spec.ts create mode 100644 apps/web/src/app/settings/settings-search-results.component.html create mode 100644 apps/web/src/app/settings/settings-search-results.component.scss create mode 100644 apps/web/src/app/settings/settings-search-results.component.ts create mode 100644 apps/web/src/app/settings/settings-search.facade.ts create mode 100644 apps/web/src/app/settings/settings.component.search.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-settings-commands.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-settings-commands.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.settings.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/index.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search-entries-playback.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search-entries.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search-rank.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search-rank.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search-sections.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search.service.spec.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search.service.ts create mode 100644 libs/workspace/shell/util/src/lib/settings-search/settings-search.types.ts diff --git a/.changes/settings-search.md b/.changes/settings-search.md new file mode 100644 index 000000000..137d7eff1 --- /dev/null +++ b/.changes/settings-search.md @@ -0,0 +1,10 @@ +--- +type: feature +area: settings +highlight: Search your settings +--- + +Settings are now searchable: type in the search box on the Settings page to +find any option by name, description or a common word like "dark" or "mpv", +then jump straight to it. The command palette (Ctrl/Cmd+K) finds settings +too, from anywhere in the app. diff --git a/README.md b/README.md index fbec01873..25bcbb9d8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ The application is a cross-platform, open-source project built with Electron and - Per-playlist and global favorites, aggregated across all playlists ⭐ - Recently viewed / watch history -- Command palette (`Ctrl/Cmd+K`) +- Command palette (`Ctrl/Cmd+K`) that also finds and opens individual settings +- Settings search from the header search box on the Settings page **Platform** @@ -76,9 +77,9 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list. | Area | Shortcut | Action | | ----------------- | --------------------------- | ---------------------------------------------------------- | | Global | `Ctrl/Cmd+K` | Open command palette | -| Global | `Ctrl/Cmd+F` | Open global search in the desktop app | +| Global | `Ctrl/Cmd+F` | Open global search (desktop); on Settings, search settings | | Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app | -| Global | `Enter` in workspace search | Submit the current search | +| Global | `Enter` in workspace search | Submit the search; on Settings, open the best match | | Global | `F11` | Toggle app window fullscreen in the desktop app | | Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | | Navigation | `0-9` | Select an M3U channel by number | diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index 1ed31f3f8..c4b131b6b 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -254,6 +254,84 @@ test.describe('Settings', () => { ); }); + test('@settings @search @web Search settings from the header and open a result', async ({ + page, + }) => { + await openSettings(page); + // The fresh browser context has no playlists; settings search must + // still be offered. + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + await expect(search).toBeEnabled(); + + // "subtitles" is a keyword of the "Show captions" row. + await search.fill('subtitles'); + await expect(page).toHaveURL(/\/workspace\/settings\/general\?q=subtitles$/); + await expect( + page.locator('[data-test-id="settings-search-results"]') + ).toBeVisible(); + await expect(page.locator('app-settings-general-section')).toHaveCount(0); + await expect( + page.locator('[data-test-id="settings-section-matches-general"]') + ).toHaveText('1'); + + await page + .locator('[data-test-id="settings-search-result-show-captions"]') + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/general$/); + await expect(search).toHaveValue(''); + const row = page.locator('[data-setting-id="show-captions"]'); + await expect(row).toBeFocused(); + await expect(row).toHaveClass(/setting-item--revealed/); + await expect(row).not.toHaveClass(/setting-item--revealed/, { + timeout: 5000, + }); + }); + + test('@settings @search @web Enter opens the best settings match', async ({ + page, + }) => { + await openSettings(page); + const search = page.locator( + 'app-workspace-shell-header input[type="search"]' + ); + + await search.fill('stream format'); + await search.press('Enter'); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="stream-format"]') + ).toBeFocused(); + }); + + test('@settings @search @web Command palette opens a setting from anywhere', async ({ + page, + }) => { + // The shell registers Ctrl/Cmd+K once its lazy chunk has rendered. + await expect( + page.locator('a[href$="/workspace/settings"]') + ).toBeVisible(); + await expect(page).not.toHaveURL(/\/workspace\/settings/); + await page.keyboard.press('Control+k'); + + const palette = page.locator('.workspace-command-palette-overlay'); + await expect(palette).toBeVisible(); + await palette.locator('input[type="search"]').fill('ambient'); + await expect(palette).toContainText('Settings'); + await palette + .locator('.palette-command', { hasText: /ambient/i }) + .first() + .click(); + + await expect(page).toHaveURL(/\/workspace\/settings\/playback$/); + await expect( + page.locator('[data-setting-id="player-ambient-mode"]') + ).toBeFocused(); + }); + test.afterEach(async ({ page }, testInfo) => { await page.screenshot({ path: join( diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index 7542ce093..0d6f87c9f 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -9,7 +9,7 @@ -
+

{{ 'SETTINGS.VERSION' | translate }}

{{ 'SETTINGS.VERSION_DESCRIPTION' | translate }}

@@ -31,7 +31,10 @@
@if (isDesktop() && appUpdateStatus(); as updateStatus) { -
+

{{ 'SETTINGS.APP_UPDATE_TITLE' | translate }}

{{ 'SETTINGS.APP_UPDATE_DESCRIPTION' | translate }}

@@ -263,7 +266,10 @@
} -
+

{{ 'SETTINGS.TMDB_CREDITS' | translate }}

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

@@ -281,7 +287,10 @@
-
+

{{ 'SETTINGS.COMMUNITY' | translate }}

{{ 'SETTINGS.COMMUNITY_DESCRIPTION' | translate }}

@@ -364,7 +373,10 @@
-
+
diff --git a/apps/web/src/app/settings/settings-backup-section.component.html b/apps/web/src/app/settings/settings-backup-section.component.html index bae147754..4c9a3ee07 100644 --- a/apps/web/src/app/settings/settings-backup-section.component.html +++ b/apps/web/src/app/settings/settings-backup-section.component.html @@ -1,7 +1,4 @@ -
+
backup @@ -12,7 +9,7 @@
-
+

{{ 'SETTINGS.IMPORT_EXPORT_DATA' | translate }}

diff --git a/apps/web/src/app/settings/settings-dashboard-section.component.html b/apps/web/src/app/settings/settings-dashboard-section.component.html index 5c5657c4f..cd5ee2e0e 100644 --- a/apps/web/src/app/settings/settings-dashboard-section.component.html +++ b/apps/web/src/app/settings/settings-dashboard-section.component.html @@ -1,8 +1,4 @@ -

+
dashboard @@ -13,7 +9,7 @@
-
+

{{ 'SETTINGS.SHOW_DASHBOARD' | translate }}

@@ -29,7 +25,7 @@

-
+

{{ 'SETTINGS.DASHBOARD_HERO' | translate }}

@@ -44,7 +40,10 @@

-
+

{{ 'SETTINGS.DASHBOARD_RAIL_CONTINUE' | translate }}

@@ -62,11 +61,14 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_LIVE_FAVORITES_DESCRIPTION' @@ -82,11 +84,9 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate - }}

+

{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE' | translate }}

{{ 'SETTINGS.DASHBOARD_RAIL_RECENT_LIVE_DESCRIPTION' @@ -102,11 +102,11 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_FAVORITE_VOD_DESCRIPTION' @@ -122,7 +122,7 @@

-
+

{{ 'SETTINGS.DASHBOARD_RAIL_SOURCES' | translate }}

@@ -140,11 +140,11 @@

-
+
-

{{ - 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate - }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_XTREAM_ADDED_DESCRIPTION' @@ -160,9 +160,14 @@

-
+
-

{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }}

+

+ {{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING' | translate }} +

{{ 'SETTINGS.DASHBOARD_RAIL_TMDB_TRENDING_DESCRIPTION' @@ -178,7 +183,10 @@

-
+

{{ diff --git a/apps/web/src/app/settings/settings-epg-section.component.html b/apps/web/src/app/settings/settings-epg-section.component.html index c8a96c1c1..b82aa1643 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.html +++ b/apps/web/src/app/settings/settings-epg-section.component.html @@ -9,7 +9,10 @@

-
+

{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}

{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}

@@ -43,7 +46,10 @@
-
+

{{ 'SETTINGS.EPG_OFFSET_TITLE' | translate }}

{{ 'SETTINGS.EPG_OFFSET_HINT' | translate }}

@@ -69,7 +75,10 @@
-
+

{{ 'SETTINGS.EPG_SOURCE_LIST_TITLE' | translate }}

{{ 'SETTINGS.EPG_SOURCE_FORMATS_HINT' | translate }}

@@ -178,7 +187,7 @@
-
+

{{ 'SETTINGS.PREFER_UPLOADED_EPG_TITLE' | translate }}

{{ 'SETTINGS.PREFER_UPLOADED_EPG_HINT' | translate }}

diff --git a/apps/web/src/app/settings/settings-general-section.component.html b/apps/web/src/app/settings/settings-general-section.component.html index 452ed72a9..012a8f449 100644 --- a/apps/web/src/app/settings/settings-general-section.component.html +++ b/apps/web/src/app/settings/settings-general-section.component.html @@ -9,7 +9,7 @@
-
+

{{ 'SETTINGS.LANGUAGE' | translate }}

{{ 'SETTINGS.LANGUAGE_DESCRIPTION' | translate }}

@@ -40,7 +40,10 @@
-
+

{{ 'SETTINGS.THEME' | translate }}

{{ 'SETTINGS.THEME_DESCRIPTION' | translate }}

@@ -74,7 +77,10 @@
-
+

{{ 'SETTINGS.COVER_SIZE' | translate }}

{{ 'SETTINGS.COVER_SIZE_DESCRIPTION' | translate }}

@@ -108,7 +114,7 @@
-
+

{{ 'SETTINGS.COVER_TITLES' | translate }} @@ -127,7 +133,7 @@

-
+

{{ 'SETTINGS.STARTUP_BEHAVIOR' | translate }}

@@ -160,7 +166,11 @@

@if (supportsStartupWindowMode()) { -
+

{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}

@@ -195,7 +205,7 @@

} -
+

{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}

@@ -207,7 +217,7 @@

-
+

{{ 'SETTINGS.STRIP_COUNTRY_PREFIX' | translate }}

@@ -233,7 +243,7 @@

{{ 'SETTINGS.PORTAL_CONNECTIONS' | translate }}

-
+

{{ 'SETTINGS.PORTAL_CONNECTIVITY_GUARD' | translate }} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index d17660ec8..3b03e57aa 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -5,6 +5,10 @@ import { Theme, VideoPlayer, } from '@iptvnator/shared/interfaces'; +import { + meetsSettingsRequirements, + SETTINGS_SECTION_DEFINITIONS, +} from '@iptvnator/workspace/shell/util/settings-search'; import { CoverSizeOption, EpgViewModeOption, @@ -164,65 +168,14 @@ export function buildSettingsSectionNavItems({ supportsEpg, supportsRemoteControl, }: SettingsSectionVisibility): SettingsSection[] { - return [ - { - id: 'general', - label: 'SETTINGS.NAV_GENERAL', - icon: 'tune', - visible: true, - }, - { - id: 'playback', - label: 'SETTINGS.NAV_PLAYBACK', - icon: 'play_circle', - visible: true, - }, - { - id: 'epg', - label: 'SETTINGS.NAV_EPG', - icon: 'calendar_month', - visible: supportsEpg, - }, - { - id: 'dashboard', - label: 'SETTINGS.NAV_DASHBOARD', - icon: 'dashboard', - visible: true, - }, - { - // Must match the section's HTML id (`remote-control`) so the - // settings-section-scroll directive can resolve the anchor. - // Was previously '@iptvnator/ui/remote-control' (the NX lib - // name), which meant clicking the nav item silently no-op'd - // because document.getElementById of that string returned null. - id: 'remote-control', - label: 'SETTINGS.NAV_REMOTE', - icon: 'smartphone', - visible: supportsRemoteControl, - }, - { - id: 'tmdb', - label: 'SETTINGS.NAV_TMDB', - icon: 'movie', - visible: true, - }, - { - id: 'backup', - label: 'SETTINGS.NAV_BACKUP', - icon: 'backup', - visible: true, - }, - { - id: 'reset', - label: 'SETTINGS.NAV_RESET', - icon: 'delete_sweep', - visible: true, - }, - { - id: 'about', - label: 'SETTINGS.NAV_ABOUT', - icon: 'info', - visible: true, - }, - ]; + const capabilities = { + epg: supportsEpg, + 'remote-control': supportsRemoteControl, + }; + return SETTINGS_SECTION_DEFINITIONS.map((section) => ({ + id: section.id, + label: section.navLabelKey, + icon: section.icon, + visible: meetsSettingsRequirements(section.requires, capabilities), + })); } diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 121bcafb2..7c93d5840 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -9,7 +9,7 @@

-
+

{{ 'SETTINGS.VIDEO_PLAYER_LABEL' | translate }}

{{ 'SETTINGS.VIDEO_PLAYER_DESCRIPTION' | translate }}

@@ -35,7 +35,7 @@
-
+

{{ 'SETTINGS.STREAM_FORMAT' | translate }}

{{ 'SETTINGS.STREAM_FORMAT_DESCRIPTION' | translate }}

@@ -67,6 +67,7 @@ @if (isWebPlayerSelected()) {
@@ -91,7 +92,11 @@
-
+

{{ 'SETTINGS.PLAYER_AMBIENT_MODE' | translate }} @@ -109,7 +114,11 @@

-
+

{{ 'SETTINGS.PLAYER_UP_NEXT_RAIL' | translate }} @@ -131,6 +140,7 @@ @if (supportsFullscreenChannelPanel()) {
@@ -159,7 +169,11 @@ @if (supportsVodMultiSource() && isWebPlayerSelected()) { -
+

{{ 'SETTINGS.VOD_AUTO_FAILOVER' | translate }} @@ -181,6 +195,7 @@ @if (supportsManagedExternalPlayers() && isExternalPlayerSelected()) {
@@ -206,7 +221,7 @@ } @if (isDesktop()) { -
+

{{ 'SETTINGS.SHOW_EXTERNAL_PLAYBACK_BAR' | translate }}

@@ -226,6 +241,7 @@ @if (frameCopyAvailable() || form().value.embeddedMpvFrameCopy) {

@@ -262,6 +278,7 @@ @if (form().value.player === 'embedded-mpv') {
@@ -332,6 +349,7 @@
@@ -354,7 +372,11 @@
} -
+

{{ 'SETTINGS.RECORDING_FOLDER_LABEL' | translate }}

@@ -391,7 +413,7 @@ @if ( supportsExternalPlayerPathSettings() && form().value.player === 'mpv' ) { -

+

{{ 'SETTINGS.MPV_PLAYER_PATH_LABEL' | translate }}

@@ -418,6 +440,7 @@

@@ -449,7 +472,7 @@
-
+

{{ 'SETTINGS.MPV_REUSE_INSTANCE_LABEL' | translate }}

@@ -465,7 +488,7 @@ @if ( supportsExternalPlayerPathSettings() && form().value.player === 'vlc' ) { -

+

{{ 'SETTINGS.VLC_PLAYER_PATH_LABEL' | translate }}

@@ -489,6 +512,7 @@

@@ -520,7 +544,7 @@
-
+

{{ 'SETTINGS.VLC_REUSE_INSTANCE_LABEL' | translate }}

diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.html b/apps/web/src/app/settings/settings-remote-control-section.component.html index 510c81ea4..bdead5a0d 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.html +++ b/apps/web/src/app/settings/settings-remote-control-section.component.html @@ -1,8 +1,4 @@ -

+
settings_remote @@ -13,7 +9,7 @@
-
+

{{ 'SETTINGS.REMOTE_CONTROL' | translate }}

{{ 'SETTINGS.REMOTE_CONTROL_DESCRIPTION' | translate }}

@@ -27,7 +23,7 @@
@if (form().value.remoteControl === true) { -
+

{{ 'SETTINGS.REMOTE_CONTROL_PORT' | translate }}

@@ -76,7 +72,7 @@

@if (localIpAddresses().length > 0) { -
+

{{ 'SETTINGS.REMOTE_CONTROL_URL' | translate }}

diff --git a/apps/web/src/app/settings/settings-reset-section.component.html b/apps/web/src/app/settings/settings-reset-section.component.html index 18b4f441e..cb15ca67b 100644 --- a/apps/web/src/app/settings/settings-reset-section.component.html +++ b/apps/web/src/app/settings/settings-reset-section.component.html @@ -1,7 +1,4 @@ -

+
delete_sweep @@ -12,7 +9,10 @@
-
+

{{ 'SETTINGS.REMOVE_ALL' | translate }}

diff --git a/apps/web/src/app/settings/settings-search-registry.spec.ts b/apps/web/src/app/settings/settings-search-registry.spec.ts new file mode 100644 index 000000000..f6afbf8fa --- /dev/null +++ b/apps/web/src/app/settings/settings-search-registry.spec.ts @@ -0,0 +1,129 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { SETTINGS_SEARCH_ENTRIES } from '@iptvnator/workspace/shell/util/settings-search'; + +/** + * The settings search index is hand-maintained next to templates whose + * labels are hard-coded translate keys. This spec keeps the two in step: a + * new settings row without an index entry (or a renamed key) fails here + * instead of silently becoming unsearchable. + */ +interface TemplateRow { + file: string; + id: string | null; + labelKey: string | null; + descriptionKey: string | null; +} + +// The web jest config runs as ESM, so there is no `__dirname` here. +const SETTINGS_DIR = resolve(process.cwd(), 'apps/web/src/app/settings'); +const EN = JSON.parse( + readFileSync(join(SETTINGS_DIR, '../../assets/i18n/en.json'), 'utf8') +); + +function readTemplateRows(): TemplateRow[] { + const rows: TemplateRow[] = []; + const files = readdirSync(SETTINGS_DIR).filter((file) => + /^settings-.+-section\.component\.html$/.test(file) + ); + + for (const file of files) { + const html = readFileSync(join(SETTINGS_DIR, file), 'utf8'); + // Row containers only: `setting-item` itself, not `__meta` etc. + const starts = [ + ...html.matchAll(/]*class="setting-item(?:\s[^"]*)?"/g), + ]; + starts.forEach((match, index) => { + const end = starts[index + 1]?.index ?? html.length; + const chunk = html.slice(match.index, end); + const tag = chunk.slice(0, chunk.indexOf('>') + 1); + const meta = chunk.split('setting-item__control')[0]; + rows.push({ + file, + id: tag.match(/data-setting-id="([^"]+)"/)?.[1] ?? null, + labelKey: + chunk.match( + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + )?.[1] ?? null, + descriptionKey: + meta.match( + /]*>\s*\{\{\s*'(SETTINGS\.[A-Z0-9_]+)'/ + )?.[1] ?? null, + }); + }); + } + return rows; +} + +function translationExists(key: string): boolean { + return ( + typeof key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' + ? (node as Record)[part] + : undefined, + EN + ) === 'string' + ); +} + +describe('settings search registry', () => { + const rows = readTemplateRows(); + const titledRows = rows.filter((row) => row.labelKey !== null); + const byId = new Map(SETTINGS_SEARCH_ENTRIES.map((e) => [e.id, e])); + + it('finds the settings rows in the section templates', () => { + expect(titledRows.length).toBeGreaterThan(40); + }); + + it('anchors every titled settings row with a registered id', () => { + const missing = titledRows + .filter((row) => !row.id || !byId.has(row.id)) + .map((row) => `${row.file}: ${row.labelKey}`); + + expect(missing).toEqual([]); + }); + + it('registers every anchored row exactly once', () => { + const templateIds = rows.flatMap((row) => (row.id ? [row.id] : [])); + + expect(new Set(templateIds).size).toBe(templateIds.length); + expect(templateIds.slice().sort()).toEqual( + SETTINGS_SEARCH_ENTRIES.map((entry) => entry.id).sort() + ); + }); + + it('indexes the same title and description the row renders', () => { + const mismatches = titledRows.flatMap((row) => { + const entry = row.id ? byId.get(row.id) : undefined; + if (!entry) { + return []; + } + const problems: string[] = []; + if (entry.labelKey !== row.labelKey) { + problems.push(`${row.id}: label ${row.labelKey}`); + } + if (entry.descriptionKey !== (row.descriptionKey ?? undefined)) { + problems.push(`${row.id}: description ${row.descriptionKey}`); + } + if (!row.file.includes(`settings-${entry.section}-section`)) { + problems.push(`${row.id}: rendered in ${row.file}`); + } + return problems; + }); + + expect(mismatches).toEqual([]); + }); + + it('uses translation keys that exist in English', () => { + const missing = SETTINGS_SEARCH_ENTRIES.flatMap((entry) => + [entry.labelKey, entry.descriptionKey].filter( + (key): key is string => !!key && !translationExists(key) + ) + ); + + expect(missing).toEqual([]); + }); +}); diff --git a/apps/web/src/app/settings/settings-search-results.component.html b/apps/web/src/app/settings/settings-search-results.component.html new file mode 100644 index 000000000..d1290af20 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.html @@ -0,0 +1,62 @@ +
+
+
+

+ {{ 'SETTINGS.SEARCH_RESULTS_TITLE' | translate }} +

+

+ {{ + 'SETTINGS.SEARCH_RESULTS_COUNT' + | translate: { count: results().length, query: query() } + }} +

+
+
+ + @if (results().length > 0) { +
    + @for (result of results(); track result.entry.id) { +
  • + +
  • + } +
+ } @else { +
+ search_off +

{{ 'SETTINGS.SEARCH_EMPTY_HINT' | translate }}

+
+ } +
diff --git a/apps/web/src/app/settings/settings-search-results.component.scss b/apps/web/src/app/settings/settings-search-results.component.scss new file mode 100644 index 000000000..7e5f8ebd7 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.scss @@ -0,0 +1,121 @@ +:host { + display: block; +} + +.settings-search-results__list { + list-style: none; + margin: 0; + padding: 0; +} + +.settings-search-result { + display: flex; + align-items: center; + gap: 14px; + width: 100%; + min-height: 56px; + padding: 12px 10px; + border: 0; + border-top: 1px solid var(--settings-item-border); + border-radius: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + + &:hover { + background: var(--app-hover-overlay); + } + + &:focus-visible { + outline: 2px solid var(--app-selection-color); + outline-offset: -2px; + border-radius: 8px; + } +} + +li:first-child .settings-search-result { + border-top: 0; +} + +.settings-search-result__icon, +.settings-search-result__chevron { + flex-shrink: 0; + color: var(--settings-muted); +} + +.settings-search-result__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.settings-search-result__label { + font-size: 0.95rem; + font-weight: 650; + letter-spacing: -0.01em; + color: var(--app-heading-color); +} + +.settings-search-result__description { + overflow: hidden; + color: var(--settings-muted); + font-size: 0.84rem; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-search-result__section { + flex-shrink: 0; + padding: 2px 10px; + border: 1px solid var(--settings-item-border); + border-radius: 999px; + color: var(--settings-muted); + font-size: 0.78rem; + white-space: nowrap; +} + +.settings-search-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 40px 16px; + color: var(--settings-muted); + text-align: center; + + mat-icon { + width: 32px; + height: 32px; + font-size: 32px; + } + + p { + margin: 0; + max-width: 420px; + line-height: 1.45; + } +} + +@media (max-width: 640px) { + .settings-search-result { + flex-wrap: wrap; + padding: 12px 14px; + } + + .settings-search-result__description { + white-space: normal; + } + + .settings-search-result__section { + margin-left: 38px; + } + + .settings-search-result__chevron { + display: none; + } +} diff --git a/apps/web/src/app/settings/settings-search-results.component.ts b/apps/web/src/app/settings/settings-search-results.component.ts new file mode 100644 index 000000000..3bb002d23 --- /dev/null +++ b/apps/web/src/app/settings/settings-search-results.component.ts @@ -0,0 +1,30 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, +} from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { + SettingsSearchEntry, + SettingsSearchResult, +} from '@iptvnator/workspace/shell/util/settings-search'; + +/** + * Ranked settings matches for the header search term, shown in place of the + * section page while a term is active. Best match first — the same row the + * Enter key in the header search opens. + */ +@Component({ + selector: 'app-settings-search-results', + imports: [MatIconModule, TranslatePipe], + templateUrl: './settings-search-results.component.html', + styleUrl: './settings-search-results.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SettingsSearchResultsComponent { + readonly query = input.required(); + readonly results = input.required(); + readonly selected = output(); +} diff --git a/apps/web/src/app/settings/settings-search.facade.ts b/apps/web/src/app/settings/settings-search.facade.ts new file mode 100644 index 000000000..5d8c2ce55 --- /dev/null +++ b/apps/web/src/app/settings/settings-search.facade.ts @@ -0,0 +1,178 @@ +import { + afterNextRender, + computed, + DestroyRef, + effect, + ElementRef, + inject, + Injectable, + Injector, + Signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { + SettingsRevealRequest, + SettingsSearchEntry, + SettingsSearchService, +} from '@iptvnator/workspace/shell/util/settings-search'; +import { map, startWith } from 'rxjs'; + +/** How long a revealed row keeps its highlight. */ +export const SETTINGS_REVEAL_HIGHLIGHT_MS = 2400; +export const SETTINGS_REVEALED_CLASS = 'setting-item--revealed'; + +export interface SettingsRevealBinding { + /** Section page currently rendered. */ + readonly activeSection: Signal; + /** True once the form is hydrated, so form-dependent rows exist. */ + readonly ready: Signal; +} + +/** + * Settings page side of settings search. The header search writes the term + * to the `q` query param (shell-owned); this facade turns it into ranked + * results, publishes per-section match counts to the settings navigation, + * and scrolls to and highlights a row that search or the command palette + * asked `SettingsSearchService` to reveal. + */ +@Injectable() +export class SettingsSearchFacade { + private readonly route = inject(ActivatedRoute); + private readonly settingsSearch = inject(SettingsSearchService); + private readonly settingsCtx = inject(SettingsContextService); + private readonly translate = inject(TranslateService); + private readonly host = inject(ElementRef); + private readonly injector = inject(Injector); + private highlightTimeoutId: ReturnType | null = null; + + private readonly languageTick = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } + ); + + readonly query = toSignal( + this.route.queryParamMap.pipe( + map((params) => (params.get('q') ?? '').trim()) + ), + { initialValue: '' } + ); + readonly isSearching = computed(() => this.query().length > 0); + readonly results = computed(() => { + this.languageTick(); + return this.settingsSearch.search(this.query()); + }); + + constructor() { + void this.settingsSearch.ensureEmbeddedMpvSupportLoaded(); + + effect(() => { + if (!this.isSearching()) { + this.settingsCtx.setMatchCounts(null); + return; + } + + const counts: Record = {}; + for (const { entry } of this.results()) { + counts[entry.section] = (counts[entry.section] ?? 0) + 1; + } + this.settingsCtx.setMatchCounts(counts); + }); + + inject(DestroyRef).onDestroy(() => { + this.settingsCtx.setMatchCounts(null); + this.clearHighlightTimer(); + }); + } + + select(entry: SettingsSearchEntry): void { + this.settingsSearch.reveal(entry); + } + + /** Starts handling reveal requests once the page can render them. */ + bindReveal({ activeSection, ready }: SettingsRevealBinding): void { + effect( + () => { + const request = this.settingsSearch.pendingReveal(); + if ( + !request || + !ready() || + this.isSearching() || + activeSection() !== request.section + ) { + return; + } + + afterNextRender(() => this.revealRow(request), { + injector: this.injector, + }); + }, + { injector: this.injector } + ); + } + + private revealRow(request: SettingsRevealRequest): void { + // A request superseded before this render is handled by its successor. + if (this.settingsSearch.pendingReveal()?.nonce !== request.nonce) { + return; + } + this.settingsSearch.completeReveal(request); + + const row = + this.findRow(request.id) ?? + (request.fallbackId ? this.findRow(request.fallbackId) : null); + if (!row) { + return; + } + + this.clearHighlight(); + row.scrollIntoView({ + block: 'center', + behavior: prefersReducedMotion() ? 'auto' : 'smooth', + }); + // Moving focus to the row lets keyboard and screen-reader users + // continue from the setting they searched for; Tab reaches its + // control next. + if (!row.hasAttribute('tabindex')) { + row.setAttribute('tabindex', '-1'); + } + row.focus({ preventScroll: true }); + row.classList.add(SETTINGS_REVEALED_CLASS); + this.highlightTimeoutId = setTimeout( + () => this.clearHighlight(), + SETTINGS_REVEAL_HIGHLIGHT_MS + ); + } + + /** `id` comes from the static registry (`[a-z0-9-]`), never from input. */ + private findRow(id: string): HTMLElement | null { + return (this.host.nativeElement as HTMLElement).querySelector( + `[data-setting-id="${id}"]` + ); + } + + private clearHighlight(): void { + this.clearHighlightTimer(); + (this.host.nativeElement as HTMLElement) + .querySelectorAll(`.${SETTINGS_REVEALED_CLASS}`) + .forEach((element) => + element.classList.remove(SETTINGS_REVEALED_CLASS) + ); + } + + private clearHighlightTimer(): void { + if (this.highlightTimeoutId !== null) { + clearTimeout(this.highlightTimeoutId); + this.highlightTimeoutId = null; + } + } +} + +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index c273acb47..fbb63aca5 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -10,7 +10,7 @@
-
+

{{ 'SETTINGS.TMDB_ENABLE' | translate }}

{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}

@@ -24,7 +24,7 @@
@if (form().value.tmdb?.enabled === true) { -
+

{{ 'SETTINGS.TMDB_API_KEY' | translate }}

{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}

@@ -96,7 +96,10 @@ @if (form().value.tmdb?.enabled === true) { @if (m3uVodDetailsControl; as m3uVodDetails) { -
+

{{ 'SETTINGS.TMDB_M3U_VOD_DETAILS' | translate }} @@ -118,7 +121,7 @@ } } -
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index c3ee2e281..11649ffa6 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -16,113 +16,137 @@ - @switch (activeSection()) { - @case ('playback') { - - } - @case ('epg') { - - } - @case ('dashboard') { - - } - @case ('remote-control') { - - } - @case ('tmdb') { - - } - @case ('backup') { - - } - @case ('reset') { - - } - @case ('about') { - - } - @default { - + + @if (search.isSearching()) { + + } @else { + @switch (activeSection()) { + @case ('playback') { + + } + @case ('epg') { + + } + @case ('dashboard') { + + } + @case ('remote-control') { + + } + @case ('tmdb') { + + } + @case ('backup') { + + } + @case ('reset') { + + } + @case ('about') { + + } + @default { + + } } }

diff --git a/apps/web/src/app/settings/settings.component.scss b/apps/web/src/app/settings/settings.component.scss index 7e6190bbb..e02e8e917 100644 --- a/apps/web/src/app/settings/settings.component.scss +++ b/apps/web/src/app/settings/settings.component.scss @@ -114,6 +114,64 @@ margin-bottom: 0; } +// A row opened from settings search or the command palette. It receives +// programmatic focus (tabindex -1). Keyboard users keep a focus ring after +// the temporary highlight fades; pointer users rely on the highlight alone, +// as with any mouse-driven focus. The wash fills the row's own padding box +// and two offset shadows widen it past the flush-left row edges without +// shifting layout or painting over the separators above and below. +.setting-item[data-setting-id] { + scroll-margin: 96px 0 calc(96px + var(--settings-action-bar-space)); + + &:focus { + outline: none; + } + + &:focus-visible { + outline: 2px solid + color-mix(in srgb, var(--app-selection-color) 60%, transparent); + outline-offset: 6px; + border-radius: 4px; + } +} + +.setting-item--revealed { + --settings-reveal-wash: color-mix( + in srgb, + var(--app-selection-color) 12%, + transparent + ); + + background: var(--settings-reveal-wash); + box-shadow: + -12px 0 0 var(--settings-reveal-wash), + 12px 0 0 var(--settings-reveal-wash); + animation: settings-reveal-fade 2.4s ease-out forwards; +} + +@keyframes settings-reveal-fade { + 0%, + 60% { + background: var(--settings-reveal-wash); + box-shadow: + -12px 0 0 var(--settings-reveal-wash), + 12px 0 0 var(--settings-reveal-wash); + } + + 100% { + background: transparent; + box-shadow: + -12px 0 0 transparent, + 12px 0 0 transparent; + } +} + +@media (prefers-reduced-motion: reduce) { + .setting-item--revealed { + animation: none; + } +} + .setting-item__meta { h4 { margin: 0; diff --git a/apps/web/src/app/settings/settings.component.search.spec.ts b/apps/web/src/app/settings/settings.component.search.spec.ts new file mode 100644 index 000000000..197ffd3e4 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.search.spec.ts @@ -0,0 +1,181 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { + SETTINGS_SEARCH_ENTRIES, + SettingsSearchEntry, + SettingsSearchService, +} from '@iptvnator/workspace/shell/util/settings-search'; +import { SettingsComponent } from './settings.component'; +import { SETTINGS_REVEALED_CLASS } from './settings-search.facade'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + MockRouter, + setSettingsSearchQuery, + setSettingsSection, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +function entry(id: string): SettingsSearchEntry { + const found = SETTINGS_SEARCH_ENTRIES.find((item) => item.id === id); + if (!found) { + throw new Error(`unknown settings entry ${id}`); + } + return found; +} + +/** + * Settings search on the page: the shell writes the header term to `q`, + * the page swaps the section for ranked results, and a chosen result (or a + * command palette pick) scrolls to and highlights the row. The labels are + * translation keys here (no translations are loaded), which the index + * searches like any other text. + */ +describe('SettingsComponent search', () => { + let fixture: ComponentFixture; + let component: SettingsComponent; + let settingsSearch: SettingsSearchService; + let settingsCtx: SettingsContextService; + let scrollIntoView: jest.Mock; + const originalElectron = window.electron; + const originalScrollIntoView = Element.prototype.scrollIntoView; + + const element = (): HTMLElement => fixture.nativeElement as HTMLElement; + const query = (selector: string) => element().querySelector(selector); + + beforeEach(waitForAsync(() => { + configureSettingsComponentTestBed(createEpgBridgeStub()); + })); + + beforeEach(async () => { + window.electron = createElectronStub(); + scrollIntoView = jest.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + + fixture = TestBed.createComponent(SettingsComponent); + component = fixture.componentInstance; + settingsSearch = TestBed.inject(SettingsSearchService); + settingsCtx = TestBed.inject(SettingsContextService); + stubSettingsSideEffects(component); + const loadSettings = jest.spyOn(component.form, 'loadSettings'); + fixture.detectChanges(); + await loadSettings.mock.results[0].value; + await fixture.whenStable(); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.useRealTimers(); + window.electron = originalElectron; + Element.prototype.scrollIntoView = originalScrollIntoView; + }); + + it('replaces the section page with ranked results while searching', () => { + expect(query('app-settings-general-section')).not.toBeNull(); + + setSettingsSearchQuery('theme'); + fixture.detectChanges(); + + expect(query('app-settings-general-section')).toBeNull(); + expect( + query('[data-test-id="settings-search-result-theme"]') + ).not.toBeNull(); + expect( + query('[data-test-id="settings-search-summary"]')?.getAttribute( + 'aria-live' + ) + ).toBe('polite'); + + setSettingsSearchQuery(''); + fixture.detectChanges(); + + expect(query('[data-test-id="settings-search-results"]')).toBeNull(); + expect(query('app-settings-general-section')).not.toBeNull(); + }); + + it('shows an empty state when nothing matches', () => { + setSettingsSearchQuery('zzzz-no-such-setting'); + fixture.detectChanges(); + + expect(query('[data-test-id="settings-search-empty"]')).not.toBeNull(); + expect(query('.settings-search-result')).toBeNull(); + }); + + it('publishes match counts per section for the navigation', () => { + setSettingsSearchQuery('dark'); + fixture.detectChanges(); + + expect(settingsCtx.matchCounts()).toEqual( + expect.objectContaining({ general: 1 }) + ); + + setSettingsSearchQuery(''); + fixture.detectChanges(); + expect(settingsCtx.matchCounts()).toBeNull(); + }); + + it('reveals the chosen result through the settings search service', () => { + const reveal = jest.spyOn(settingsSearch, 'reveal'); + setSettingsSearchQuery('theme'); + fixture.detectChanges(); + + ( + query( + '[data-test-id="settings-search-result-theme"]' + ) as HTMLButtonElement + ).click(); + + expect(reveal).toHaveBeenCalledWith(entry('theme')); + expect( + (TestBed.inject(Router) as unknown as MockRouter).navigate + ).toHaveBeenCalledWith(['/workspace/settings', 'general']); + }); + + it('scrolls to, focuses and briefly highlights a revealed row', () => { + jest.useFakeTimers(); + + settingsSearch.reveal(entry('show-captions')); + fixture.detectChanges(); + + const row = query('[data-setting-id="show-captions"]') as HTMLElement; + expect(scrollIntoView).toHaveBeenCalled(); + expect(scrollIntoView.mock.contexts[0]).toBe(row); + expect(row.classList).toContain(SETTINGS_REVEALED_CLASS); + expect(document.activeElement).toBe(row); + expect(settingsSearch.pendingReveal()).toBeNull(); + + jest.runOnlyPendingTimers(); + expect(row.classList).not.toContain(SETTINGS_REVEALED_CLASS); + }); + + it('waits for the section page before revealing a row on it', () => { + settingsSearch.reveal(entry('epg-offset')); + fixture.detectChanges(); + + expect(scrollIntoView).not.toHaveBeenCalled(); + expect(settingsSearch.pendingReveal()?.id).toBe('epg-offset'); + + setSettingsSection('epg'); + fixture.detectChanges(); + + expect(query('[data-setting-id="epg-offset"]')?.classList).toContain( + SETTINGS_REVEALED_CLASS + ); + }); + + it('falls back to the controlling row when the searched row is hidden', () => { + // The API key row only renders while TMDB is enabled. + setSettingsSection('tmdb'); + fixture.detectChanges(); + expect(query('[data-setting-id="tmdb-api-key"]')).toBeNull(); + + settingsSearch.reveal(entry('tmdb-api-key')); + fixture.detectChanges(); + + expect(query('[data-setting-id="tmdb-enable"]')?.classList).toContain( + SETTINGS_REVEALED_CLASS + ); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index a48c8381d..e66083e33 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -6,6 +6,7 @@ import { inject, OnDestroy, OnInit, + signal, ViewEncapsulation, ChangeDetectionStrategy, } from '@angular/core'; @@ -49,6 +50,8 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co import { SettingsRemoteControlFacade } from './settings-remote-control.facade'; import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component'; import { SettingsResetSectionComponent } from './settings-reset-section.component'; +import { SettingsSearchFacade } from './settings-search.facade'; +import { SettingsSearchResultsComponent } from './settings-search-results.component'; import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component'; import { SettingsUnsavedChangesChoice, @@ -93,6 +96,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsPlaybackSectionComponent, SettingsRemoteControlSectionComponent, SettingsResetSectionComponent, + SettingsSearchResultsComponent, SettingsTmdbSectionComponent, ], // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. @@ -105,6 +109,7 @@ export const SETTINGS_DEFAULT_SECTION = 'general'; SettingsFormFacade, SettingsPlaylistResetFacade, SettingsRemoteControlFacade, + SettingsSearchFacade, SettingsSnackbarService, SettingsUnloadGuardService, ], @@ -119,6 +124,7 @@ export class SettingsComponent readonly form = inject(SettingsFormFacade); readonly playlistReset = inject(SettingsPlaylistResetFacade); readonly remoteControl = inject(SettingsRemoteControlFacade); + readonly search = inject(SettingsSearchFacade); private readonly settingsCtx = inject(SettingsContextService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -155,6 +161,9 @@ export class SettingsComponent /** Settings form object */ readonly settingsForm = this.form.form; + /** Set once the form is hydrated, so form-dependent rows can render. */ + private readonly formReady = signal(false); + /** Player options */ readonly players = computed(() => buildSettingsPlayerOptions({ @@ -219,10 +228,16 @@ export class SettingsComponent // on purpose — this is navigation, not an animated transition. effect(() => { this.activeSection(); + this.search.isSearching(); this.hostElement.nativeElement .closest('main.workspace-content') ?.scrollTo({ top: 0 }); }); + + this.search.bindReveal({ + activeSection: this.activeSection, + ready: this.formReady.asReadonly(), + }); } /** @@ -241,6 +256,7 @@ export class SettingsComponent await this.form.loadSettings(); this.form.hydrateFromStore(); this.form.bindDashboardControlsEnabledState(); + this.formReady.set(true); void this.embeddedMpv.load(); this.appUpdate.checkAppVersion(); this.appUpdate.init(); diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 4a2b516ef..7c7925187 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -146,19 +146,28 @@ export class MockRouter { } /** - * Stands in for the `:section` route param the settings page renders from. - * Specs switch section pages with `setSettingsSection` below. + * Stands in for the `:section` route param the settings page renders from + * and the shell-owned `q` search param. Specs switch section pages with + * `setSettingsSection` and search with `setSettingsSearchQuery` below. */ export class MockActivatedRoute { private readonly params = new BehaviorSubject( convertToParamMap({ section: 'general' }) ); + private readonly queryParams = new BehaviorSubject( + convertToParamMap({}) + ); readonly paramMap = this.params.asObservable(); + readonly queryParamMap = this.queryParams.asObservable(); setSection(section: string): void { this.params.next(convertToParamMap({ section })); } + + setQuery(query: string): void { + this.queryParams.next(convertToParamMap(query ? { q: query } : {})); + } } /** Routes the rendered settings page to the given section page. */ @@ -168,6 +177,13 @@ export function setSettingsSection(section: string): void { ).setSection(section); } +/** Sets the header search term (`q`) the settings page searches for. */ +export function setSettingsSearchQuery(query: string): void { + ( + TestBed.inject(ActivatedRoute) as unknown as MockActivatedRoute + ).setQuery(query); +} + export class MockSettingsStore { private _settings = signal(DEFAULT_SETTINGS); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 00e92dc71..cf337f290 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "بعد تكرار فشل الاتصال، تُوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تعطيل هذا الخيار إذا كانت البوابة تصبح غير متاحة بشكل متقطع. يُطبّق بعد الحفظ دون إعادة التشغيل.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "سيُنفّذ كل طلب بشكل مستقل. قد يستغرق ظهور الخطأ وقتًا أطول عند عدم توفر الخادم.", "TITLE": "الإعدادات", + "SEARCH_RESULTS_TITLE": "نتائج البحث", + "SEARCH_RESULTS_COUNT": "الإعدادات المطابقة لـ «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "لا توجد إعدادات مطابقة. جرّب كلمة أخرى، مثل المشغل أو السمة أو EPG.", "GENERAL": "عام", "GENERAL_SUBTITLE": "تغيير تكوين التطبيق", "PLAYBACK": "التشغيل", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "إجراءات السياق", "SEARCH_LABEL": "بحث", "SEARCH_SOURCES_PLACEHOLDER": "البحث في المصادر (جميع قوائم التشغيل)...", + "SEARCH_SETTINGS_PLACEHOLDER": "البحث في الإعدادات...", "SEARCH_GLOBAL_PLACEHOLDER": "البحث في كل قوائم التشغيل...", "SEARCH_PLAYLIST_PLACEHOLDER": "البحث في قائمة التشغيل هذه...", "SEARCH_SECTION_PLACEHOLDER": "البحث في هذا القسم...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "لوحة الأوامر", "SUBTITLE": "إجراءات عامة وسياقية", - "SEARCH_PLACEHOLDER": "اكتب لتصفية الأوامر", + "SEARCH_PLACEHOLDER": "البحث في الأوامر والإعدادات", "SEARCH_LABEL": "تصفية الأوامر", "EMPTY": "لم يتم العثور على أوامر", "GROUP_GLOBAL": "الإجراءات العامة", "GROUP_PLAYLIST": "قائمة التشغيل هذه", "GROUP_VIEW": "العرض الحالي", "GROUP_SECTION": "هذا القسم", - "GROUP_RECENT": "المستخدمة مؤخرًا" + "GROUP_RECENT": "المستخدمة مؤخرًا", + "GROUP_SETTINGS": "الإعدادات" }, "SHORTCUTS": { "TITLE": "اختصارات لوحة المفاتيح", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1b4be9931..f35b57c18 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "من بعد ما يفشل الاتصال عدة مرات، كيوقف طلبات Xtream وStalker الجديدة لمدة 30 ثانية. جرّب تطفي هاد الخيار إلا كانت البوابة كتقطع مرة مرة. كيتطبق من بعد الحفظ بلا إعادة التشغيل.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "كل طلب غادي يتنفذ بوحدو. إلا كان السيرفر ما متاحش، رسالة الخطأ تقدر تتأخر.", "TITLE": "الإعدادات", + "SEARCH_RESULTS_TITLE": "نتائج البحث", + "SEARCH_RESULTS_COUNT": "الإعدادات اللي كتطابق «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "ما كاين حتى إعداد مطابق. جرب كلمة أخرى، بحال المشغل ولا المظهر ولا EPG.", "GENERAL": "عام", "GENERAL_SUBTITLE": "بدل تكوين التطبيق", "PLAYBACK": "التشغيل", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "إجراءات القسم", "SEARCH_LABEL": "بحث", "SEARCH_SOURCES_PLACEHOLDER": "قلب على مصادر (جميع قوائم التشغيل)...", + "SEARCH_SETTINGS_PLACEHOLDER": "قلب فالإعدادات...", "SEARCH_GLOBAL_PLACEHOLDER": "قلب فكل لوائح التشغيل...", "SEARCH_PLAYLIST_PLACEHOLDER": "قلب في هاد قائمة التشغيل...", "SEARCH_SECTION_PLACEHOLDER": "قلب فهاد القسم...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "لوحة الأوامر", "SUBTITLE": "إجراءات عامة وسياقية", - "SEARCH_PLACEHOLDER": "كتب باش تفلتر الأوامر", + "SEARCH_PLACEHOLDER": "قلب على الأوامر والإعدادات", "SEARCH_LABEL": "فلتر الأوامر", "EMPTY": "ما تلقاتش حتى أمر", "GROUP_GLOBAL": "إجراءات عامة", "GROUP_PLAYLIST": "هاد قائمة التشغيل", "GROUP_VIEW": "العرض الحالي", "GROUP_SECTION": "هاد القسم", - "GROUP_RECENT": "مستعملة مؤخراً" + "GROUP_RECENT": "مستعملة مؤخراً", + "GROUP_SETTINGS": "الإعدادات" }, "SHORTCUTS": { "TITLE": "اختصارات الكلافيي", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 36d299200..a45938c0c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Пасля паўторных памылак падключэння прыпыняць новыя запыты Xtream і Stalker на 30 секунд. Паспрабуйце выключыць, калі партал перыядычна становіцца недаступным. Ужываецца пасля захавання без перазапуску.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Кожны запыт будзе выконвацца незалежна. Калі сервер недаступны, чаканне памылкі можа павялічыцца.", "TITLE": "Налады", + "SEARCH_RESULTS_TITLE": "Вынікі пошуку", + "SEARCH_RESULTS_COUNT": "Налады па запыце «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "Нічога не знойдзена. Паспрабуйце іншае слова, напрыклад «прайгравальнік», «тэма» ці «EPG».", "GENERAL": "Агульныя", "GENERAL_SUBTITLE": "Змяніце канфігурацыю праграмы", "PLAYBACK": "Прайграванне", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Кантэкстныя дзеянні", "SEARCH_LABEL": "Пошук", "SEARCH_SOURCES_PLACEHOLDER": "Пошук крыніц (усе плэйлісты)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Шукаць у наладах...", "SEARCH_GLOBAL_PLACEHOLDER": "Шукаць ва ўсіх плэйлістах...", "SEARCH_PLAYLIST_PLACEHOLDER": "Пошук у гэтым плэйлісце...", "SEARCH_SECTION_PLACEHOLDER": "Шукаць у гэтым раздзеле...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Палітра каманд", "SUBTITLE": "Глабальныя і кантэкстныя дзеянні", - "SEARCH_PLACEHOLDER": "Увядзіце для фільтрацыі каманд", + "SEARCH_PLACEHOLDER": "Шукаць каманды і налады", "SEARCH_LABEL": "Фільтр каманд", "EMPTY": "Каманд не знойдзена", "GROUP_GLOBAL": "Глабальныя дзеянні", "GROUP_PLAYLIST": "Гэты плэйліст", "GROUP_VIEW": "Бягучы выгляд", "GROUP_SECTION": "Гэты раздзел", - "GROUP_RECENT": "Нядаўна выкарыстаныя" + "GROUP_RECENT": "Нядаўна выкарыстаныя", + "GROUP_SETTINGS": "Налады" }, "SHORTCUTS": { "TITLE": "Спалучэнні клавіш", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index f60cf8531..42106ef24 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Nach wiederholten Verbindungsfehlern neue Xtream- und Stalker-Anfragen 30 Sekunden pausieren. Bei zeitweise nicht erreichbaren Portalen versuchsweise deaktivieren. Gilt nach dem Speichern ohne Neustart.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Jede Anfrage wird unabhängig ausgeführt. Bei nicht erreichbaren Servern kann die Fehlermeldung länger dauern.", "TITLE": "Einstellungen", + "SEARCH_RESULTS_TITLE": "Suchergebnisse", + "SEARCH_RESULTS_COUNT": "Einstellungen zu „{{query}}“: {{count}}", + "SEARCH_EMPTY_HINT": "Keine passenden Einstellungen. Ein anderes Wort versuchen, etwa Player, Design oder EPG.", "GENERAL": "Allgemein", "GENERAL_SUBTITLE": "Allgemeine App-Einstellungen ändern", "PLAYBACK": "Wiedergabe", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Abschnittsaktionen", "SEARCH_LABEL": "Suche", "SEARCH_SOURCES_PLACEHOLDER": "Quellen durchsuchen (alle Playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Einstellungen durchsuchen...", "SEARCH_GLOBAL_PLACEHOLDER": "Alle Wiedergabelisten durchsuchen...", "SEARCH_PLAYLIST_PLACEHOLDER": "In dieser Playlist suchen...", "SEARCH_SECTION_PLACEHOLDER": "In diesem Abschnitt suchen...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Befehlspalette", "SUBTITLE": "Globale und kontextbezogene Aktionen", - "SEARCH_PLACEHOLDER": "Zum Filtern der Befehle tippen", + "SEARCH_PLACEHOLDER": "Befehle und Einstellungen durchsuchen", "SEARCH_LABEL": "Befehle filtern", "EMPTY": "Keine Befehle gefunden", "GROUP_GLOBAL": "Globale Aktionen", "GROUP_PLAYLIST": "Diese Playlist", "GROUP_VIEW": "Aktuelle Ansicht", "GROUP_SECTION": "Dieser Abschnitt", - "GROUP_RECENT": "Kürzlich verwendet" + "GROUP_RECENT": "Kürzlich verwendet", + "GROUP_SETTINGS": "Einstellungen" }, "SHORTCUTS": { "TITLE": "Tastenkürzel", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dbfb4cc58..184b7aef7 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Μετά από επανειλημμένα σφάλματα σύνδεσης, παύση νέων αιτημάτων Xtream και Stalker για 30 δευτερόλεπτα. Δοκιμάστε να το απενεργοποιήσετε αν μια πύλη είναι κατά διαστήματα μη διαθέσιμη. Ισχύει μετά την αποθήκευση, χωρίς επανεκκίνηση.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Κάθε αίτημα εκτελείται ανεξάρτητα. Οι μη διαθέσιμοι διακομιστές μπορεί να αργούν περισσότερο να αναφέρουν σφάλμα.", "TITLE": "Ρυθμίσεις", + "SEARCH_RESULTS_TITLE": "Αποτελέσματα αναζήτησης", + "SEARCH_RESULTS_COUNT": "Ρυθμίσεις για «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "Δεν βρέθηκαν ρυθμίσεις. Δοκιμάστε άλλη λέξη, π.χ. αναπαραγωγή, θέμα ή EPG.", "GENERAL": "Γενικά", "GENERAL_SUBTITLE": "Αλλαγή της διαμόρφωσης της εφαρμογής", "PLAYBACK": "Αναπαραγωγή", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Ενέργειες ενότητας", "SEARCH_LABEL": "Αναζήτηση", "SEARCH_SOURCES_PLACEHOLDER": "Αναζήτηση πηγών (όλες οι λίστες αναπαραγωγής)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Αναζήτηση ρυθμίσεων...", "SEARCH_GLOBAL_PLACEHOLDER": "Αναζήτηση σε όλες τις λίστες...", "SEARCH_PLAYLIST_PLACEHOLDER": "Αναζήτηση σε αυτή τη λίστα αναπαραγωγής...", "SEARCH_SECTION_PLACEHOLDER": "Αναζήτηση σε αυτή την ενότητα...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Παλέτα εντολών", "SUBTITLE": "Καθολικές και συμφραζόμενες ενέργειες", - "SEARCH_PLACEHOLDER": "Πληκτρολογήστε για φιλτράρισμα εντολών", + "SEARCH_PLACEHOLDER": "Αναζήτηση εντολών και ρυθμίσεων", "SEARCH_LABEL": "Φιλτράρισμα εντολών", "EMPTY": "Δεν βρέθηκαν εντολές", "GROUP_GLOBAL": "Καθολικές ενέργειες", "GROUP_PLAYLIST": "Αυτή η λίστα αναπαραγωγής", "GROUP_VIEW": "Τρέχουσα προβολή", "GROUP_SECTION": "Αυτή η ενότητα", - "GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες" + "GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες", + "GROUP_SETTINGS": "Ρυθμίσεις" }, "SHORTCUTS": { "TITLE": "Συντομεύσεις πληκτρολογίου", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6c99bdb4c..f4b30f4c9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "After repeated connection failures, pause new Xtream and Stalker requests for 30 seconds. Try disabling this if a portal becomes unavailable intermittently. Applies after saving, without restarting.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Each request will run independently. Unavailable servers may take longer to report an error.", "TITLE": "Settings", + "SEARCH_RESULTS_TITLE": "Search results", + "SEARCH_RESULTS_COUNT": "Settings matching “{{query}}”: {{count}}", + "SEARCH_EMPTY_HINT": "No settings match. Try another word, such as player, theme or EPG.", "GENERAL": "General", "GENERAL_SUBTITLE": "Change the configuration of the application", "PLAYBACK": "Playback", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Section actions", "SEARCH_LABEL": "Search", "SEARCH_SOURCES_PLACEHOLDER": "Search sources (all playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Search settings...", "SEARCH_GLOBAL_PLACEHOLDER": "Search all playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Search in this playlist...", "SEARCH_SECTION_PLACEHOLDER": "Search in this section...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Command palette", "SUBTITLE": "Global and contextual actions", - "SEARCH_PLACEHOLDER": "Type to filter commands", + "SEARCH_PLACEHOLDER": "Search commands and settings", "SEARCH_LABEL": "Filter commands", "EMPTY": "No commands found", "GROUP_GLOBAL": "Global actions", "GROUP_PLAYLIST": "This playlist", "GROUP_VIEW": "Current view", "GROUP_SECTION": "This section", - "GROUP_RECENT": "Recently used" + "GROUP_RECENT": "Recently used", + "GROUP_SETTINGS": "Settings" }, "SHORTCUTS": { "TITLE": "Keyboard shortcuts", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index c0a51add3..d86706364 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tras varios fallos de conexión, pausar las nuevas solicitudes de Xtream y Stalker durante 30 segundos. Prueba a desactivarlo si un portal deja de estar disponible de forma intermitente. Se aplica al guardar, sin reiniciar.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada solicitud se ejecutará de forma independiente. Los servidores no disponibles pueden tardar más en mostrar un error.", "TITLE": "Ajustes", + "SEARCH_RESULTS_TITLE": "Resultados de búsqueda", + "SEARCH_RESULTS_COUNT": "Ajustes que coinciden con «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "Ningún ajuste coincide. Prueba con otra palabra, como reproductor, tema o EPG.", "GENERAL": "General", "GENERAL_SUBTITLE": "Cambia la configuracion general de la aplicacion", "PLAYBACK": "Reproduccion", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Acciones de la sección", "SEARCH_LABEL": "Buscar", "SEARCH_SOURCES_PLACEHOLDER": "Buscar fuentes (todas las listas)…", + "SEARCH_SETTINGS_PLACEHOLDER": "Buscar en ajustes…", "SEARCH_GLOBAL_PLACEHOLDER": "Buscar en todas las listas...", "SEARCH_PLAYLIST_PLACEHOLDER": "Buscar en esta lista de reproducción…", "SEARCH_SECTION_PLACEHOLDER": "Buscar en esta sección...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta de comandos", "SUBTITLE": "Acciones globales y contextuales", - "SEARCH_PLACEHOLDER": "Escribe para filtrar comandos", + "SEARCH_PLACEHOLDER": "Buscar comandos y ajustes", "SEARCH_LABEL": "Filtrar comandos", "EMPTY": "No se encontraron comandos", "GROUP_GLOBAL": "Acciones globales", "GROUP_PLAYLIST": "Esta lista de reproducción", "GROUP_VIEW": "Vista actual", "GROUP_SECTION": "Esta sección", - "GROUP_RECENT": "Usados recientemente" + "GROUP_RECENT": "Usados recientemente", + "GROUP_SETTINGS": "Ajustes" }, "SHORTCUTS": { "TITLE": "Atajos de teclado", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 4b014fc6e..24ad67d2b 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Après plusieurs échecs de connexion, suspendre les nouvelles requêtes Xtream et Stalker pendant 30 secondes. Essayez de désactiver cette option si un portail devient indisponible par intermittence. Prend effet après enregistrement, sans redémarrage.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Chaque requête sera exécutée indépendamment. Les serveurs indisponibles peuvent mettre plus de temps à signaler une erreur.", "TITLE": "Paramètres", + "SEARCH_RESULTS_TITLE": "Résultats de la recherche", + "SEARCH_RESULTS_COUNT": "Paramètres correspondant à « {{query}} » : {{count}}", + "SEARCH_EMPTY_HINT": "Aucun paramètre ne correspond. Essayez un autre mot, par exemple lecteur, thème ou EPG.", "GENERAL": "Général", "GENERAL_SUBTITLE": "Modifier la configuration de l'application", "PLAYBACK": "Lecture", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Actions de la section", "SEARCH_LABEL": "Rechercher", "SEARCH_SOURCES_PLACEHOLDER": "Rechercher des sources (toutes les listes)…", + "SEARCH_SETTINGS_PLACEHOLDER": "Rechercher dans les paramètres…", "SEARCH_GLOBAL_PLACEHOLDER": "Rechercher dans toutes les playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Rechercher dans cette liste de lecture…", "SEARCH_SECTION_PLACEHOLDER": "Rechercher dans cette section...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Palette de commandes", "SUBTITLE": "Actions globales et contextuelles", - "SEARCH_PLACEHOLDER": "Saisir pour filtrer les commandes", + "SEARCH_PLACEHOLDER": "Rechercher des commandes et des paramètres", "SEARCH_LABEL": "Filtrer les commandes", "EMPTY": "Aucune commande trouvée", "GROUP_GLOBAL": "Actions globales", "GROUP_PLAYLIST": "Cette liste de lecture", "GROUP_VIEW": "Vue actuelle", "GROUP_SECTION": "Cette section", - "GROUP_RECENT": "Récemment utilisées" + "GROUP_RECENT": "Récemment utilisées", + "GROUP_SETTINGS": "Paramètres" }, "SHORTCUTS": { "TITLE": "Raccourcis clavier", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 8da46cb54..f9e5d4afa 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Ismételt kapcsolódási hibák után az új Xtream- és Stalker-kérések szüneteltetése 30 másodpercre. Ha egy portál időnként elérhetetlenné válik, próbálja kikapcsolni. Mentés után, újraindítás nélkül érvényesül.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Minden kérés önállóan fut. Elérhetetlen szervereknél a hiba megjelenése tovább tarthat.", "TITLE": "Beállítások", + "SEARCH_RESULTS_TITLE": "Keresési találatok", + "SEARCH_RESULTS_COUNT": "„{{query}}” kifejezésre illeszkedő beállítások: {{count}}", + "SEARCH_EMPTY_HINT": "Nincs illeszkedő beállítás. Próbáljon másik szót, például lejátszó, téma vagy EPG.", "GENERAL": "Általános", "GENERAL_SUBTITLE": "Az alkalmazás beállításainak módosítása", "PLAYBACK": "Lejátszás", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Szakaszműveletek", "SEARCH_LABEL": "Keresés", "SEARCH_SOURCES_PLACEHOLDER": "Keresés a források között (összes lejátszási lista)…", + "SEARCH_SETTINGS_PLACEHOLDER": "Keresés a beállításokban…", "SEARCH_GLOBAL_PLACEHOLDER": "Keresés az összes lejátszási listában…", "SEARCH_PLAYLIST_PLACEHOLDER": "Keresés ebben a lejátszási listában…", "SEARCH_SECTION_PLACEHOLDER": "Keresés ebben a szakaszban…", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Parancspaletta", "SUBTITLE": "Globális és környezetfüggő műveletek", - "SEARCH_PLACEHOLDER": "A parancsok szűréséhez kezdjen gépelni", + "SEARCH_PLACEHOLDER": "Parancsok és beállítások keresése", "SEARCH_LABEL": "Parancsok szűrése", "EMPTY": "Nincs találat a parancsok között", "GROUP_GLOBAL": "Globális műveletek", "GROUP_PLAYLIST": "Ez a lejátszási lista", "GROUP_VIEW": "Aktuális nézet", "GROUP_SECTION": "Aktuális szakasz", - "GROUP_RECENT": "Legutóbb használt" + "GROUP_RECENT": "Legutóbb használt", + "GROUP_SETTINGS": "Beállítások" }, "SHORTCUTS": { "TITLE": "Billentyűparancsok", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 9bc6b53d7..3ee4f713d 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Dopo ripetuti errori di connessione, sospendi le nuove richieste Xtream e Stalker per 30 secondi. Prova a disattivare se un portale diventa intermittentemente non disponibile. Si applica al salvataggio, senza riavvio.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Ogni richiesta verrà eseguita indipendentemente. I server non disponibili potrebbero impiegare più tempo a segnalare un errore.", "TITLE": "Impostazioni IPTVnator", + "SEARCH_RESULTS_TITLE": "Risultati della ricerca", + "SEARCH_RESULTS_COUNT": "Impostazioni corrispondenti a “{{query}}”: {{count}}", + "SEARCH_EMPTY_HINT": "Nessuna impostazione corrisponde. Prova un'altra parola, ad esempio riproduttore, tema o EPG.", "GENERAL": "Generale", "GENERAL_SUBTITLE": "Modifica le impostazioni generali dell'applicazione", "PLAYBACK": "Riproduzione", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Azioni sezione", "SEARCH_LABEL": "Cerca", "SEARCH_SOURCES_PLACEHOLDER": "Cerca sorgenti (tutte le playlist)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Cerca nelle impostazioni...", "SEARCH_GLOBAL_PLACEHOLDER": "Cerca in tutte le playlist...", "SEARCH_PLAYLIST_PLACEHOLDER": "Cerca in questa playlist...", "SEARCH_SECTION_PLACEHOLDER": "Cerca in questa sezione...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Tavolozza comandi", "SUBTITLE": "Azioni globali e contestuali", - "SEARCH_PLACEHOLDER": "Digita per filtrare i comandi", + "SEARCH_PLACEHOLDER": "Cerca comandi e impostazioni", "SEARCH_LABEL": "Filtra comandi", "EMPTY": "Nessun comando trovato", "GROUP_GLOBAL": "Azioni globali", "GROUP_PLAYLIST": "Questa playlist", "GROUP_VIEW": "Vista corrente", "GROUP_SECTION": "Questa sezione", - "GROUP_RECENT": "Usati di recente" + "GROUP_RECENT": "Usati di recente", + "GROUP_SETTINGS": "Impostazioni" }, "SHORTCUTS": { "TITLE": "Scorciatoie da tastiera", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 971dd433f..d012458ef 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "接続エラーが繰り返された場合、新しい Xtream と Stalker のリクエストを30秒間停止します。ポータルが断続的に利用できなくなる場合は、無効にしてみてください。保存後、再起動せずに適用されます。", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "各リクエストは個別に実行されます。サーバーが利用できない場合、エラーの表示に時間がかかることがあります。", "TITLE": "設定", + "SEARCH_RESULTS_TITLE": "検索結果", + "SEARCH_RESULTS_COUNT": "「{{query}}」に一致する設定: {{count}}", + "SEARCH_EMPTY_HINT": "一致する設定がありません。「プレーヤー」「テーマ」「EPG」など別の言葉を試してください。", "GENERAL": "一般", "GENERAL_SUBTITLE": "アプリケーションの設定を変更", "PLAYBACK": "再生", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "コンテキスト操作", "SEARCH_LABEL": "検索", "SEARCH_SOURCES_PLACEHOLDER": "ソースを検索(すべてのプレイリスト)...", + "SEARCH_SETTINGS_PLACEHOLDER": "設定を検索...", "SEARCH_GLOBAL_PLACEHOLDER": "すべてのプレイリストを検索...", "SEARCH_PLAYLIST_PLACEHOLDER": "このプレイリスト内を検索...", "SEARCH_SECTION_PLACEHOLDER": "このセクション内を検索...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "コマンドパレット", "SUBTITLE": "グローバルおよびコンテキストアクション", - "SEARCH_PLACEHOLDER": "コマンドを絞り込むには入力", + "SEARCH_PLACEHOLDER": "コマンドと設定を検索", "SEARCH_LABEL": "コマンドを絞り込む", "EMPTY": "コマンドが見つかりません", "GROUP_GLOBAL": "グローバルアクション", "GROUP_PLAYLIST": "このプレイリスト", "GROUP_VIEW": "現在のビュー", "GROUP_SECTION": "現在のセクション", - "GROUP_RECENT": "最近使用" + "GROUP_RECENT": "最近使用", + "GROUP_SETTINGS": "設定" }, "SHORTCUTS": { "TITLE": "キーボードショートカット", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d98dc32c7..7f2753c00 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "연결 오류가 반복되면 새 Xtream 및 Stalker 요청을 30초 동안 중지합니다. 포털을 간헐적으로 사용할 수 없다면 이 옵션을 꺼 보세요. 저장 후 재시작 없이 적용됩니다.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "각 요청이 독립적으로 실행됩니다. 서버를 사용할 수 없으면 오류가 표시되기까지 더 오래 걸릴 수 있습니다.", "TITLE": "설정", + "SEARCH_RESULTS_TITLE": "검색 결과", + "SEARCH_RESULTS_COUNT": "“{{query}}”와 일치하는 설정: {{count}}", + "SEARCH_EMPTY_HINT": "일치하는 설정이 없습니다. 플레이어, 테마, EPG 같은 다른 단어로 검색해 보세요.", "GENERAL": "일반", "GENERAL_SUBTITLE": "애플리케이션 구성을 변경합니다", "PLAYBACK": "재생", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "컨텍스트 작업", "SEARCH_LABEL": "검색", "SEARCH_SOURCES_PLACEHOLDER": "소스 검색 (모든 재생목록)...", + "SEARCH_SETTINGS_PLACEHOLDER": "설정 검색...", "SEARCH_GLOBAL_PLACEHOLDER": "모든 플레이리스트 검색...", "SEARCH_PLAYLIST_PLACEHOLDER": "이 재생목록에서 검색...", "SEARCH_SECTION_PLACEHOLDER": "이 섹션에서 검색...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "명령어 팔레트", "SUBTITLE": "전역 및 상황별 작업", - "SEARCH_PLACEHOLDER": "명령어를 필터링하려면 입력하세요", + "SEARCH_PLACEHOLDER": "명령어와 설정 검색", "SEARCH_LABEL": "명령어 필터", "EMPTY": "명령어를 찾을 수 없습니다", "GROUP_GLOBAL": "전역 작업", "GROUP_PLAYLIST": "이 재생목록", "GROUP_VIEW": "현재 화면", "GROUP_SECTION": "현재 섹션", - "GROUP_RECENT": "최근 사용" + "GROUP_RECENT": "최근 사용", + "GROUP_SETTINGS": "설정" }, "SHORTCUTS": { "TITLE": "키보드 단축키", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index cb2737e25..9b17ba524 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Pauzeer nieuwe Xtream- en Stalker-verzoeken 30 seconden na herhaalde verbindingsfouten. Probeer dit uit te schakelen als een portal af en toe onbereikbaar is. Geldt na opslaan, zonder herstart.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Elk verzoek wordt onafhankelijk uitgevoerd. Bij onbereikbare servers kan een foutmelding langer duren.", "TITLE": "Instellingen", + "SEARCH_RESULTS_TITLE": "Zoekresultaten", + "SEARCH_RESULTS_COUNT": "Instellingen voor “{{query}}”: {{count}}", + "SEARCH_EMPTY_HINT": "Geen instellingen gevonden. Probeer een ander woord, zoals speler, thema of EPG.", "GENERAL": "Algemeen", "GENERAL_SUBTITLE": "Wijzig de configuratie van de applicatie", "PLAYBACK": "Afspelen", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Sectieacties", "SEARCH_LABEL": "Zoeken", "SEARCH_SOURCES_PLACEHOLDER": "Zoek bronnen (alle afspeellijsten)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Zoek in instellingen...", "SEARCH_GLOBAL_PLACEHOLDER": "Alle afspeellijsten doorzoeken...", "SEARCH_PLAYLIST_PLACEHOLDER": "Zoek in deze afspeellijst...", "SEARCH_SECTION_PLACEHOLDER": "Zoeken in deze sectie...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Opdrachtenpalet", "SUBTITLE": "Algemene en contextuele acties", - "SEARCH_PLACEHOLDER": "Typ om opdrachten te filteren", + "SEARCH_PLACEHOLDER": "Zoek opdrachten en instellingen", "SEARCH_LABEL": "Filter opdrachten", "EMPTY": "Geen opdrachten gevonden", "GROUP_GLOBAL": "Algemene acties", "GROUP_PLAYLIST": "Deze afspeellijst", "GROUP_VIEW": "Huidige weergave", "GROUP_SECTION": "Deze sectie", - "GROUP_RECENT": "Recent gebruikt" + "GROUP_RECENT": "Recent gebruikt", + "GROUP_SETTINGS": "Instellingen" }, "SHORTCUTS": { "TITLE": "Sneltoetsen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 3456e2102..2f99542fb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Po powtarzających się błędach połączenia wstrzymuj nowe żądania Xtream i Stalker na 30 sekund. Spróbuj wyłączyć, jeśli portal bywa niedostępny. Zmiana działa po zapisaniu, bez ponownego uruchomienia.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Każde żądanie będzie wykonywane niezależnie. Niedostępne serwery mogą zgłaszać błąd z większym opóźnieniem.", "TITLE": "Ustawienia", + "SEARCH_RESULTS_TITLE": "Wyniki wyszukiwania", + "SEARCH_RESULTS_COUNT": "Ustawienia pasujące do „{{query}}”: {{count}}", + "SEARCH_EMPTY_HINT": "Brak pasujących ustawień. Spróbuj innego słowa, np. odtwarzacz, motyw lub EPG.", "GENERAL": "Ogólne", "GENERAL_SUBTITLE": "Zmień konfigurację aplikacji", "PLAYBACK": "Odtwarzanie", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Akcje sekcji", "SEARCH_LABEL": "Szukaj", "SEARCH_SOURCES_PLACEHOLDER": "Szukaj źródeł (wszystkie listy odtwarzania)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Szukaj w ustawieniach...", "SEARCH_GLOBAL_PLACEHOLDER": "Przeszukaj wszystkie listy odtwarzania...", "SEARCH_PLAYLIST_PLACEHOLDER": "Szukaj na tej liście odtwarzania...", "SEARCH_SECTION_PLACEHOLDER": "Szukaj w tej sekcji...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta poleceń", "SUBTITLE": "Akcje globalne i kontekstowe", - "SEARCH_PLACEHOLDER": "Wpisz, aby filtrować polecenia", + "SEARCH_PLACEHOLDER": "Szukaj poleceń i ustawień", "SEARCH_LABEL": "Filtruj polecenia", "EMPTY": "Nie znaleziono poleceń", "GROUP_GLOBAL": "Akcje globalne", "GROUP_PLAYLIST": "Ta lista odtwarzania", "GROUP_VIEW": "Bieżący widok", "GROUP_SECTION": "Ta sekcja", - "GROUP_RECENT": "Ostatnio używane" + "GROUP_RECENT": "Ostatnio używane", + "GROUP_SETTINGS": "Ustawienia" }, "SHORTCUTS": { "TITLE": "Skróty klawiaturowe", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index e48fde94a..e48d01536 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Após falhas repetidas de ligação, pausar novos pedidos Xtream e Stalker durante 30 segundos. Experimente desativar se um portal ficar indisponível de forma intermitente. Aplica-se ao guardar, sem reiniciar.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Cada pedido será executado de forma independente. Os servidores indisponíveis podem demorar mais a indicar um erro.", "TITLE": "Configurações", + "SEARCH_RESULTS_TITLE": "Resultados da pesquisa", + "SEARCH_RESULTS_COUNT": "Configurações correspondentes a “{{query}}”: {{count}}", + "SEARCH_EMPTY_HINT": "Nenhuma configuração corresponde. Tente outra palavra, como reprodutor, tema ou EPG.", "GENERAL": "Geral", "GENERAL_SUBTITLE": "Altere a configuração do aplicativo", "PLAYBACK": "Reprodução", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Ações de contexto", "SEARCH_LABEL": "Pesquisar", "SEARCH_SOURCES_PLACEHOLDER": "Pesquisar fontes (todas as playlists)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Pesquisar nas configurações...", "SEARCH_GLOBAL_PLACEHOLDER": "Pesquisar todas as playlists...", "SEARCH_PLAYLIST_PLACEHOLDER": "Pesquisar nesta playlist...", "SEARCH_SECTION_PLACEHOLDER": "Pesquisar nesta seção...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Paleta de comandos", "SUBTITLE": "Ações globais e contextuais", - "SEARCH_PLACEHOLDER": "Digite para filtrar comandos", + "SEARCH_PLACEHOLDER": "Pesquisar comandos e configurações", "SEARCH_LABEL": "Filtrar comandos", "EMPTY": "Nenhum comando encontrado", "GROUP_GLOBAL": "Ações globais", "GROUP_PLAYLIST": "Esta playlist", "GROUP_VIEW": "Visualização atual", "GROUP_SECTION": "Esta seção", - "GROUP_RECENT": "Usados recentemente" + "GROUP_RECENT": "Usados recentemente", + "GROUP_SETTINGS": "Configurações" }, "SHORTCUTS": { "TITLE": "Atalhos de teclado", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index ea575277c..b9b28c997 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "После повторных ошибок подключения приостанавливать новые запросы Xtream и Stalker на 30 секунд. Попробуйте отключить, если портал периодически становится недоступен. Применяется после сохранения без перезапуска.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Каждый запрос будет выполняться независимо. Если сервер недоступен, ожидание ошибки может увеличиться.", "TITLE": "Настройки", + "SEARCH_RESULTS_TITLE": "Результаты поиска", + "SEARCH_RESULTS_COUNT": "Настройки по запросу «{{query}}»: {{count}}", + "SEARCH_EMPTY_HINT": "Ничего не найдено. Попробуйте другое слово, например «плеер», «тема» или «EPG».", "GENERAL": "Общие", "GENERAL_SUBTITLE": "Изменение основных параметров приложения", "PLAYBACK": "Воспроизведение", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Действия раздела", "SEARCH_LABEL": "Поиск", "SEARCH_SOURCES_PLACEHOLDER": "Искать источники (все плейлисты)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Искать в настройках...", "SEARCH_GLOBAL_PLACEHOLDER": "Искать во всех плейлистах...", "SEARCH_PLAYLIST_PLACEHOLDER": "Искать в этом плейлисте...", "SEARCH_SECTION_PLACEHOLDER": "Искать в этом разделе...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Палитра команд", "SUBTITLE": "Глобальные и контекстные действия", - "SEARCH_PLACEHOLDER": "Введите текст для фильтрации команд", + "SEARCH_PLACEHOLDER": "Искать команды и настройки", "SEARCH_LABEL": "Фильтровать команды", "EMPTY": "Команды не найдены", "GROUP_GLOBAL": "Глобальные действия", "GROUP_PLAYLIST": "Этот плейлист", "GROUP_VIEW": "Текущее представление", "GROUP_SECTION": "Этот раздел", - "GROUP_RECENT": "Недавно использованные" + "GROUP_RECENT": "Недавно использованные", + "GROUP_SETTINGS": "Настройки" }, "SHORTCUTS": { "TITLE": "Сочетания клавиш", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6bbcf920d..b5aa4393a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "Tekrarlanan bağlantı hatalarından sonra yeni Xtream ve Stalker isteklerini 30 saniye duraklatır. Portal zaman zaman kullanılamıyorsa kapatmayı deneyin. Kaydettikten sonra yeniden başlatmadan uygulanır.", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "Her istek bağımsız yürütülür. Kullanılamayan sunucularda hata bildirimi daha uzun sürebilir.", "TITLE": "Ayarlar", + "SEARCH_RESULTS_TITLE": "Arama sonuçları", + "SEARCH_RESULTS_COUNT": "“{{query}}” ile eşleşen ayarlar: {{count}}", + "SEARCH_EMPTY_HINT": "Eşleşen ayar yok. Oynatıcı, tema veya EPG gibi başka bir kelime deneyin.", "GENERAL": "Genel", "GENERAL_SUBTITLE": "Uygulamanın yapılandırmasını değiştirin", "PLAYBACK": "Oynatma", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "Bölüm eylemleri", "SEARCH_LABEL": "Ara", "SEARCH_SOURCES_PLACEHOLDER": "Kaynaklarda ara (tüm oynatma listeleri)...", + "SEARCH_SETTINGS_PLACEHOLDER": "Ayarlarda ara...", "SEARCH_GLOBAL_PLACEHOLDER": "Tüm oynatma listelerinde ara...", "SEARCH_PLAYLIST_PLACEHOLDER": "Bu oynatma listesinde ara...", "SEARCH_SECTION_PLACEHOLDER": "Bu bölümde ara...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "Komut paleti", "SUBTITLE": "Genel ve bağlamsal işlemler", - "SEARCH_PLACEHOLDER": "Komutları filtrelemek için yazın", + "SEARCH_PLACEHOLDER": "Komutlarda ve ayarlarda ara", "SEARCH_LABEL": "Komutları filtrele", "EMPTY": "Komut bulunamadı", "GROUP_GLOBAL": "Genel işlemler", "GROUP_PLAYLIST": "Bu oynatma listesi", "GROUP_VIEW": "Geçerli görünüm", "GROUP_SECTION": "Bu bölüm", - "GROUP_RECENT": "Son kullanılanlar" + "GROUP_RECENT": "Son kullanılanlar", + "GROUP_SETTINGS": "Ayarlar" }, "SHORTCUTS": { "TITLE": "Klavye kısayolları", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index e289f60c5..368a45c8a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "连接连续失败后,暂停新的 Xtream 和 Stalker 请求 30 秒。如果门户间歇性不可用,请尝试关闭此选项。保存后生效,无需重启。", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "每个请求将独立执行。服务器不可用时,可能需要更长时间才能显示错误。", "TITLE": "设置", + "SEARCH_RESULTS_TITLE": "搜索结果", + "SEARCH_RESULTS_COUNT": "与“{{query}}”匹配的设置:{{count}}", + "SEARCH_EMPTY_HINT": "没有匹配的设置。试试其他词,例如“播放器”“主题”或“EPG”。", "GENERAL": "通用", "GENERAL_SUBTITLE": "更改应用程序的配置", "PLAYBACK": "播放", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "上下文操作", "SEARCH_LABEL": "搜索", "SEARCH_SOURCES_PLACEHOLDER": "搜索源(所有播放列表)…", + "SEARCH_SETTINGS_PLACEHOLDER": "搜索设置…", "SEARCH_GLOBAL_PLACEHOLDER": "搜索所有播放列表…", "SEARCH_PLAYLIST_PLACEHOLDER": "在此播放列表中搜索…", "SEARCH_SECTION_PLACEHOLDER": "在此区域中搜索…", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "命令面板", "SUBTITLE": "全局和上下文操作", - "SEARCH_PLACEHOLDER": "输入以筛选命令", + "SEARCH_PLACEHOLDER": "搜索命令和设置", "SEARCH_LABEL": "筛选命令", "EMPTY": "未找到命令", "GROUP_GLOBAL": "全局操作", "GROUP_PLAYLIST": "本播放列表", "GROUP_VIEW": "当前视图", "GROUP_SECTION": "当前区域", - "GROUP_RECENT": "最近使用" + "GROUP_RECENT": "最近使用", + "GROUP_SETTINGS": "设置" }, "SHORTCUTS": { "TITLE": "键盘快捷键", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index ed991534f..b701b6160 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -264,6 +264,9 @@ "PORTAL_CONNECTIVITY_GUARD_DESCRIPTION": "連線連續失敗後,暫停新的 Xtream 和 Stalker 請求 30 秒。如果入口網站間歇性無法使用,請嘗試關閉此選項。儲存後生效,無需重新啟動。", "PORTAL_CONNECTIVITY_GUARD_DISABLED": "每個請求將獨立執行。伺服器無法使用時,可能需要更長時間才能顯示錯誤。", "TITLE": "設定", + "SEARCH_RESULTS_TITLE": "搜尋結果", + "SEARCH_RESULTS_COUNT": "符合「{{query}}」的設定:{{count}}", + "SEARCH_EMPTY_HINT": "沒有符合的設定。試試其他詞,例如「播放器」「主題」或「EPG」。", "GENERAL": "一般", "GENERAL_SUBTITLE": "更改應用程式的配置", "PLAYBACK": "播放", @@ -1569,6 +1572,7 @@ "RAIL_CONTEXT_ACTIONS": "區段操作", "SEARCH_LABEL": "搜尋", "SEARCH_SOURCES_PLACEHOLDER": "搜尋來源(所有播放清單)...", + "SEARCH_SETTINGS_PLACEHOLDER": "搜尋設定...", "SEARCH_GLOBAL_PLACEHOLDER": "搜尋所有播放清單…", "SEARCH_PLAYLIST_PLACEHOLDER": "在此播放清單中搜尋...", "SEARCH_SECTION_PLACEHOLDER": "在此區段中搜尋...", @@ -1722,14 +1726,15 @@ "COMMAND_PALETTE": { "TITLE": "命令選擇區", "SUBTITLE": "全域與情境化操作", - "SEARCH_PLACEHOLDER": "輸入以篩選命令", + "SEARCH_PLACEHOLDER": "搜尋命令和設定", "SEARCH_LABEL": "篩選命令", "EMPTY": "找不到命令", "GROUP_GLOBAL": "全域操作", "GROUP_PLAYLIST": "此播放清單", "GROUP_VIEW": "目前畫面", "GROUP_SECTION": "此區段", - "GROUP_RECENT": "最近使用" + "GROUP_RECENT": "最近使用", + "GROUP_SETTINGS": "設定" }, "SHORTCUTS": { "TITLE": "鍵盤快捷鍵", diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 8d238199b..9ea588c11 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -232,6 +232,9 @@ sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and `@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the preload bundle must not carry, so the preload only type-imports the barrel and value-imports those two dependency-free modules directly. +`@iptvnator/workspace/shell/util/settings-search` exists for the opposite +reason: the main `workspace-shell-util` barrel is imported eagerly, and the +settings search index must stay in the lazy settings and shell chunks. For a buildable library that has a local `package.json`, its `name` must match the scoped alias. Nx uses that package name when rewriting buildable dependency diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index bb7384886..e013ea857 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -169,7 +169,8 @@ restarting playback; remote commands retain captured playback order. See the Search is shell-owned and route-aware: -1. Disabled on settings routes. +1. On settings routes, searches the settings themselves (see + [Settings search](#settings-search)). 2. Enabled on sources routes. 3. Enabled for `/workspace/search`, which is the Electron-only routed global-search view. `Ctrl/Cmd+F` in Electron opens this route and @@ -214,8 +215,10 @@ Rail navigation is also shell-owned: Command palette behavior is shell-owned but view-extensible: -1. The shell resolves commands into three groups in fixed order: current view, - this playlist, then global. +1. The shell resolves commands into groups in fixed order: current view, + this playlist, global, then settings. The settings group appears only for a + non-empty query and holds at most six settings matches (see + [Settings search](#settings-search)). 2. Shell-owned commands are derived from route context and current playlist state; empty groups are omitted instead of rendering disabled placeholders. 3. Workspace features contribute current-view commands through @@ -245,6 +248,51 @@ Command palette behavior is shell-owned but view-extensible: is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted. +### Settings search + +Settings rows are searchable from the header search on `/workspace/settings` +and from the command palette. Both use the same index and ranking. + +1. The index is `SETTINGS_SEARCH_ENTRIES` in + `libs/workspace/shell/util/src/lib/settings-search/`, published through the + `@iptvnator/workspace/shell/util/settings-search` sub-entrypoint. Eager + code imports the main shell util barrel, so the index stays out of it and + ships only in lazy chunks (the initial-bytes ratchet enforces this). +2. Each entry names its section, title and description translation keys, + untranslated synonyms (`keywords`), runtime `requires`, and an optional + `fallbackId`. Section definitions (`SETTINGS_SECTION_DEFINITIONS`) are the + single source for the settings navigation too. +3. Every titled `.setting-item` in the section templates carries + `data-setting-id`. `settings-search-registry.spec.ts` fails when a row, id, + title key or description key drifts from the index, so a new settings row + must be added to the index in the same change. +4. `SettingsSearchService.search()` matches the translated title and + description of the current language plus the keywords; every query token + must match (AND), and a label prefix outranks a word start, which outranks + an inner match. Rows whose `requires` the runtime lacks are never returned. + Embedded MPV rows depend on a lazy support probe + (`ensureEmbeddedMpvSupportLoaded()`), run when the settings page or the + command palette opens, never from shell bootstrap; frame copy also needs + `frameCopyAvailable`, matching the settings page gate. +5. Settings routes use `local-filter` search mode, so the term lives in `q`. + While `q` is set, the settings page shows ranked results in place of the + section page and the settings context panel shows per-section match + counts, muting sections without matches. The search box is shown on + settings even when no playlist exists. +6. Choosing a result, pressing `Enter` in the header search (best match), or + picking a settings command in the palette calls `reveal()`: it navigates + to the section page without `q` (which clears the box) and the page + scrolls to, focuses, and briefly highlights the row once the form is + hydrated. A row hidden by the current form state falls back to its + `fallbackId`, the control that makes it appear. A reveal must win over the + typed term: `WorkspaceShellSearchSyncService` drops a keystroke still + waiting for its debounce through `onReveal()`, and Enter does not apply + the term first, because either `q` sync navigation would supersede the + reveal navigation. Keyboard users keep a `:focus-visible` ring on the row + after the highlight fades. +7. `Ctrl/Cmd+F` on settings focuses the header search instead of opening + global search. + Keyboard shortcut help is shell-owned: 1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`. diff --git a/libs/portal/shared/util/src/lib/workspace-view-command.types.ts b/libs/portal/shared/util/src/lib/workspace-view-command.types.ts index 5cd305609..905a971f3 100644 --- a/libs/portal/shared/util/src/lib/workspace-view-command.types.ts +++ b/libs/portal/shared/util/src/lib/workspace-view-command.types.ts @@ -1,4 +1,9 @@ -export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global'; +/** + * `settings` holds one command per searchable settings row. The palette + * lists them only for a non-empty query, ranked and capped, so they never + * flood the default command list. + */ +export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global' | 'settings'; export interface WorkspaceCommandActionContext { query: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts index 65b776133..de5b4af46 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.spec.ts @@ -381,3 +381,149 @@ describe('WorkspaceCommandPaletteComponent - recent section', () => { ).toBe(false); }); }); + +describe('WorkspaceCommandPaletteComponent - settings group', () => { + function settingsCommand( + id: string, + label: string + ): WorkspaceResolvedCommandItem { + return { + id: `settings:${id}`, + label, + description: 'Playback', + group: 'settings', + icon: 'play_circle', + keywords: [], + priority: 100, + visible: true, + enabled: true, + run: () => undefined, + }; + } + + const commands: WorkspaceResolvedCommandItem[] = [ + { + id: 'open-settings', + label: 'Open settings', + description: '', + group: 'global', + icon: 'settings', + keywords: ['settings'], + priority: 50, + visible: true, + enabled: true, + run: () => undefined, + }, + settingsCommand('video-player', 'Video player'), + settingsCommand('theme', 'Theme'), + ]; + + function setup(options: { + query: string; + recentIds?: readonly string[]; + searchSettings?: jest.Mock; + }) { + const searchSettings = + options.searchSettings ?? + jest.fn((query: string) => + query.includes('player') ? ['settings:video-player'] : [] + ); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [WorkspaceCommandPaletteComponent], + providers: [ + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { + provide: MAT_DIALOG_DATA, + useValue: { + query: options.query, + commands, + recentIds: options.recentIds ?? [], + searchSettings, + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }); + + const fixture = TestBed.createComponent( + WorkspaceCommandPaletteComponent + ); + fixture.detectChanges(); + return { + component: fixture.componentInstance, + fixture, + searchSettings, + }; + } + + it('keeps settings out of the list while the query is empty', () => { + const { component, searchSettings } = setup({ query: '' }); + + expect(component.flatCommands().map((command) => command.id)).toEqual([ + 'open-settings', + ]); + expect(searchSettings).not.toHaveBeenCalled(); + }); + + it('lists settings matches after the command groups, in search order', () => { + const searchSettings = jest.fn(() => [ + 'settings:theme', + 'settings:video-player', + ]); + const { component, fixture } = setup({ + query: 'settings', + searchSettings, + }); + + const groups = component.commandGroups(); + expect(groups.map((group) => group.group)).toEqual([ + 'global', + 'settings', + ]); + expect(groups[1].items.map((item) => item.id)).toEqual([ + 'settings:theme', + 'settings:video-player', + ]); + expect(searchSettings).toHaveBeenLastCalledWith('settings'); + expect(fixture.nativeElement.textContent).toContain( + 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS' + ); + }); + + it('ranks settings only through the search callback, never by substring', () => { + const { component } = setup({ query: 'theme' }); + + // "Theme" contains the query, but the search callback decides. + expect(component.flatCommands()).toEqual([]); + + component.query.set('player'); + expect(component.flatCommands().map((command) => command.id)).toEqual([ + 'settings:video-player', + ]); + }); + + it('shows a recently opened setting in the recent section', () => { + const { component } = setup({ + query: '', + recentIds: ['settings:theme'], + }); + + const [recent] = component.commandGroups(); + expect(recent.group).toBe('recent'); + expect(recent.items.map((item) => item.id)).toEqual(['settings:theme']); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts index 2d35a1e8b..b0d8f3889 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.ts @@ -23,10 +23,15 @@ import { } from '@iptvnator/portal/shared/util'; import { foldSearchText } from '@iptvnator/shared/interfaces'; -interface WorkspaceCommandPaletteData { +export interface WorkspaceCommandPaletteData { commands: WorkspaceResolvedCommandItem[]; query?: string; recentIds?: readonly string[]; + /** + * Ranked ids of the `settings` group commands matching a query. Settings + * commands are listed only through this, never for an empty query. + */ + searchSettings?: (query: string) => readonly string[]; } type PaletteSectionGroup = WorkspaceCommandGroup | 'recent'; @@ -65,7 +70,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { readonly filteredCommands = computed(() => { const term = foldSearchText(this.query().trim()); - const commands = this.visibleCommands(); + const commands = this.visibleCommands().filter( + (command) => command.group !== 'settings' + ); if (!term) { return commands; @@ -107,6 +114,28 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { } ); + readonly settingsSection = computed( + () => { + const query = this.query().trim(); + const searchSettings = this.data?.searchSettings; + if (!query || !searchSettings) { + return null; + } + + const byId = new Map( + this.visibleCommands().map((command) => [command.id, command]) + ); + const items = searchSettings(query) + .map((id) => byId.get(id)) + .filter( + (command): command is WorkspaceResolvedCommandItem => + command?.group === 'settings' + ); + + return items.length === 0 ? null : { group: 'settings', items }; + } + ); + readonly commandGroups = computed(() => { const commands = this.filteredCommands(); const recent = this.recentSection(); @@ -144,6 +173,12 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { ); sections.push(...groups); + + const settings = this.settingsSection(); + if (settings) { + sections.push(settings); + } + return sections; }); @@ -259,6 +294,9 @@ export class WorkspaceCommandPaletteComponent implements AfterViewInit { if (group === 'playlist') { return 'WORKSPACE.COMMAND_PALETTE.GROUP_PLAYLIST'; } + if (group === 'settings') { + return 'WORKSPACE.COMMAND_PALETTE.GROUP_SETTINGS'; + } return 'WORKSPACE.COMMAND_PALETTE.GROUP_GLOBAL'; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss index c439cb7e2..faf6063f2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.scss @@ -45,6 +45,11 @@ border-top: 1px solid var(--app-separator); } +// Sections without settings-search matches stay reachable but recede. +.settings-section-item.has-no-matches:not(.active) { + opacity: 0.55; +} + .settings-section-item:hover:not(.active):not(.selected), .settings-back-button:hover:not(.active):not(.selected) { background: var(--app-selection-surface); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts new file mode 100644 index 000000000..ec6ee1e5d --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.spec.ts @@ -0,0 +1,75 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SettingsContextService } from '@iptvnator/workspace/shell/util'; +import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component'; + +describe('WorkspaceSettingsContextPanelComponent', () => { + function setup() { + TestBed.configureTestingModule({ + imports: [WorkspaceSettingsContextPanelComponent], + providers: [ + provideRouter([]), + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + ], + }); + const ctx = TestBed.inject(SettingsContextService); + ctx.setSections([ + { id: 'general', label: 'SETTINGS.NAV_GENERAL', icon: 'tune' }, + { + id: 'playback', + label: 'SETTINGS.NAV_PLAYBACK', + icon: 'play_circle', + }, + ]); + const fixture = TestBed.createComponent( + WorkspaceSettingsContextPanelComponent + ); + fixture.detectChanges(); + const link = (id: string) => + (fixture.nativeElement as HTMLElement).querySelector( + `[data-test-id="settings-section-${id}"]` + ) as HTMLElement; + return { ctx, fixture, link }; + } + + it('shows no counts while settings search is idle', () => { + const { link } = setup(); + + expect(link('general').querySelector('.nav-item-meta')).toBeNull(); + expect(link('general').classList).not.toContain('has-no-matches'); + }); + + it('shows per-section match counts and mutes sections without matches', () => { + const { ctx, fixture, link } = setup(); + + ctx.setMatchCounts({ general: 2 }); + fixture.detectChanges(); + + expect( + link('general').querySelector('.nav-item-meta')?.textContent + ).toBe('2'); + expect( + link('playback').querySelector('.nav-item-meta')?.textContent + ).toBe('0'); + expect(link('general').classList).not.toContain('has-no-matches'); + expect(link('playback').classList).toContain('has-no-matches'); + + ctx.reset(); + fixture.detectChanges(); + expect(ctx.matchCounts()).toBeNull(); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index 2c736855f..92448563f 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -28,10 +28,27 @@ import { [routerLink]="['/workspace/settings', section.id]" [replaceUrl]="true" [attr.data-test-id]="'settings-section-' + section.id" + [class.has-no-matches]=" + ctx.matchCounts() !== null && + !ctx.matchCounts()?.[section.id] + " (click)="onSectionClicked()" > {{ section.icon }} - {{ section.label | translate }} + {{ + section.label | translate + }} + + @if (ctx.matchCounts(); as counts) { + {{ counts[section.id] ?? 0 }} + } }
diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index cd7d38991..7fd3d62ad 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -42,7 +42,9 @@ /> } - @if (!hasNoPlaylists()) { + + @if (!hasNoPlaylists() || isSettingsRoute()) {