From 5b091809efcad0132b87d4066f17705892a460b5 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 19 May 2026 14:39:38 +0200 Subject: [PATCH 01/21] fix(xtream): ignore future recently added dates ## Summary - Normalize Xtream recently-added timestamps across UI, import, and dashboard query paths. - Filter future/invalid provider dates before ranking rails and migrate legacy millisecond cache rows. - Add regression coverage for future timestamps, series date priority, and DB migration behavior. ## Validation - GitHub checks passed, including Unit Tests and Typechecks, Web E2E, Electron E2E on macOS/Ubuntu/Windows, CodeQL, builds, and Greptile Review. --- .../operations/content.operations.spec.ts | 91 +++++++++- .../database/operations/content.operations.ts | 70 ++++---- docs/architecture/date-handling.md | 22 ++- .../recently-added.component.html | 2 +- .../recently-added.component.spec.ts | 164 ++++++++++++++++++ .../recently-added.component.ts | 80 +++++++-- .../database/src/lib/connection.spec.ts | 53 ++++++ libs/shared/database/src/lib/connection.ts | 62 ++++++- libs/shared/interfaces/src/index.ts | 1 + .../lib/xtream-recently-added.utils.spec.ts | 28 +++ .../src/lib/xtream-recently-added.utils.ts | 60 +++++++ 11 files changed, 579 insertions(+), 54 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts diff --git a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts index 7563642df..1688b1fb5 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.spec.ts @@ -93,7 +93,9 @@ describe('content.operations', () => { }); it('uses a synchronous better-sqlite transaction callback when saving content', async () => { - const existingContentWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const existingContentWhere = jest + .fn() + .mockResolvedValue([{ count: 0 }]); const existingContentInnerJoin = jest .fn() .mockReturnValue({ where: existingContentWhere }); @@ -166,4 +168,91 @@ describe('content.operations', () => { ]); expect(run).toHaveBeenCalled(); }); + + it('does not persist far-future provider timestamps as valid recently-added dates', async () => { + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-05-19T00:00:00.000Z')); + const existingContentWhere = jest + .fn() + .mockResolvedValue([{ count: 0 }]); + const existingContentInnerJoin = jest + .fn() + .mockReturnValue({ where: existingContentWhere }); + const existingContentFrom = jest + .fn() + .mockReturnValue({ innerJoin: existingContentInnerJoin }); + + const categoriesWhere = jest.fn().mockResolvedValue([ + { + id: 42, + xtreamId: 201, + }, + ]); + const categoriesFrom = jest + .fn() + .mockReturnValue({ where: categoriesWhere }); + + const select = jest + .fn() + .mockReturnValueOnce({ from: existingContentFrom }) + .mockReturnValueOnce({ from: categoriesFrom }); + + const run = jest.fn(); + const onConflictDoNothing = jest.fn().mockReturnValue({ run }); + const values = jest.fn().mockReturnValue({ onConflictDoNothing }); + const insert = jest.fn().mockReturnValue({ values }); + const transaction = jest.fn((callback: (tx: unknown) => unknown) => + callback({ insert }) + ); + const db = { + select, + transaction, + } as unknown as AppDatabase; + + try { + await saveContent( + db, + 'playlist-1', + [ + { + added: '1893456000', + category_id: '201', + name: 'Future Movie', + stream_id: '100', + }, + { + added: '1779062400', + category_id: '201', + name: 'Fresh Movie', + stream_id: '101', + }, + { + category_id: '201', + last_modified: '1778976000', + name: 'Modified Movie', + stream_id: '102', + }, + ], + 'movie' + ); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ + title: 'Future Movie', + added: '', + }), + expect.objectContaining({ + title: 'Fresh Movie', + added: '1779062400', + }), + expect.objectContaining({ + title: 'Modified Movie', + added: '1778976000', + }), + ]); + } finally { + dateNowSpy.mockRestore(); + } + }); }); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 738b63e3d..200bb7cf3 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -1,5 +1,9 @@ import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; +import { + getXtreamRecentlyAddedMaxEpochSeconds, + toXtreamRecentlyAddedEpochSeconds, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, @@ -129,18 +133,19 @@ export async function getGlobalRecentlyAdded( inArray(schema.content.type, contentTypes), eq(schema.categories.hidden, false), sql`${schema.content.added} <> ''`, + sql`${schema.content.added} <= ${getXtreamRecentlyAddedMaxEpochSeconds()}`, ]; if (playlistType) { whereConditions.push(eq(schema.playlists.type, playlistType)); } - // Sort by `added` directly. Xtream stores Unix-epoch timestamps as - // 10-digit numeric strings (since 2001-09-09), so lexicographic sort - // is equivalent to numeric sort. Wrapping the column in CAST(... AS - // INTEGER) — as we used to — blocks SQLite from using - // idx_content_type_added and forces a full table scan + sort on the - // entire content table (often 100k+ rows) on every dashboard load. + // Sort by `added` directly. Xtream import and DB startup migrations + // normalize recently-added epochs to 10-digit seconds strings, so + // lexicographic sort is equivalent to numeric sort. Wrapping the column in + // CAST(... AS INTEGER) blocks SQLite from using idx_content_type_added and + // forces a full table scan + sort on the entire content table (often 100k+ + // rows) on every dashboard load. return db .select({ ...selectContentFields(), @@ -183,16 +188,16 @@ type XtreamContentSource = Record & { last_modified?: string; added?: string; stream_icon?: string; - poster?: string; - cover?: string; - name?: string; - title?: string; - epg_channel_id?: string; - tv_archive?: string | number; - tv_archive_duration?: string | number; - direct_source?: string; - series_id?: string | number; - stream_id?: string | number; + poster?: string; + cover?: string; + name?: string; + title?: string; + epg_channel_id?: string; + tv_archive?: string | number; + tv_archive_duration?: string | number; + direct_source?: string; + series_id?: string | number; + stream_id?: string | number; }; function toXtreamContentValue( @@ -224,10 +229,11 @@ function toXtreamContentValue( categoryId, title, rating: String(source.rating || source.rating_imdb || ''), - added: + added: toXtreamRecentlyAddedEpochSeconds( type === 'series' - ? String(source.last_modified || '') - : String(source.added || ''), + ? source.last_modified || source.added + : source.added || source.last_modified + ), posterUrl: String( source.stream_icon || source.poster || source.cover || '' ), @@ -300,10 +306,9 @@ export async function saveContent( ) ); - const categoryMap = new Map(categories.map((category) => [ - category.xtreamId, - category.id, - ])); + const categoryMap = new Map( + categories.map((category) => [category.xtreamId, category.id]) + ); const values = streams .map((stream) => toXtreamContentValue(stream, type, categoryMap)) @@ -317,8 +322,7 @@ export async function saveContent( await checkpointOperation(control); const chunk = values.slice(index, index + chunkSize); await db.transaction((tx) => { - tx - .insert(schema.content) + tx.insert(schema.content) .values(chunk) .onConflictDoNothing({ target: [ @@ -374,8 +378,7 @@ export async function clearXtreamImportCache( 100 )) { await db.transaction((tx) => { - tx - .delete(schema.content) + tx.delete(schema.content) .where(inArray(schema.content.id, chunk)) .run(); }); @@ -383,8 +386,7 @@ export async function clearXtreamImportCache( for (const chunk of chunkValues(categoryIds, 100)) { await db.transaction((tx) => { - tx - .delete(schema.categories) + tx.delete(schema.categories) .where(inArray(schema.categories.id, chunk)) .run(); }); @@ -440,7 +442,10 @@ export async function searchContent( const conditions = [ eq(schema.categories.playlistId, playlistId), - inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), or(...likeConditions), ]; @@ -482,7 +487,10 @@ export async function globalSearch( ); const conditions = [ - inArray(schema.content.type, types as Array<'live' | 'movie' | 'series'>), + inArray( + schema.content.type, + types as Array<'live' | 'movie' | 'series'> + ), or(...likeConditions), ]; diff --git a/docs/architecture/date-handling.md b/docs/architecture/date-handling.md index 467bf4fec..363cccffa 100644 --- a/docs/architecture/date-handling.md +++ b/docs/architecture/date-handling.md @@ -12,14 +12,26 @@ - Date display should follow the user-selected app language from `TranslateService`. - When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`. -- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/Users/4gray/Code/iptvnator/apps/web/src/app/app-date-locales.ts). -- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/Users/4gray/Code/iptvnator/libs/ui/pipes/src/lib/date-format.util.ts): - - `ary` -> `ar-MA` - - `by` -> `be` - - `zhtw` -> `zh-Hant` +- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts). +- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/libs/ui/pipes/src/lib/date-format.util.ts): + - `ary` -> `ar-MA` + - `by` -> `be` + - `zhtw` -> `zh-Hant` ## Parsing Boundaries - Normalize provider-specific or legacy date strings to ISO as early as possible. - Keep optional epoch fields such as `startTimestamp` and `stopTimestamp` when the provider already supplies them. - Avoid new non-standard `Date.parse(...)` usage for provider formats; prefer explicit `date-fns` parsing when the input is not ISO. +- Xtream `added` / `last_modified` values are provider-supplied epoch fields. + Normalize them through `toXtreamRecentlyAddedTimestamp()` / + `toXtreamRecentlyAddedEpochSeconds()` from `@iptvnator/shared/interfaces` + before ranking or storing recently-added content. Values more than 24 hours + in the future are treated as invalid so provider placeholders such as + `2030-01-01` cannot permanently pin the top of recently-added rails. + Recently-added ranking uses `added` before `last_modified` for live/VOD + content and `last_modified` before `added` for series. The VOD/live fallback + is intentional for providers that omit `added` but still expose a valid + `last_modified` epoch. Legacy cached millisecond epochs in `content.added` + are migrated to epoch seconds during database startup so indexed dashboard + queries can continue to compare and sort text timestamps directly. diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html index 5acf14702..de551d98f 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.html @@ -55,7 +55,7 @@ [title]="item.title || item.name" [posterUrl]="item.poster_url || item.cover" [type]="'series'" - [date]="getDate(item)" + [date]="getDate(item, true)" (cardClick)="openItem(item, 'series')" /> } diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts new file mode 100644 index 000000000..c7caac068 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.spec.ts @@ -0,0 +1,164 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { RecentlyAddedComponent } from './recently-added.component'; + +jest.mock('@iptvnator/portal/shared/ui', () => ({ + ContentCardComponent: class {}, + ContentRailShellComponent: class {}, +})); + +interface TestRecentlyAddedItem { + readonly added?: string; + readonly category_id: number; + readonly last_modified?: string; + readonly poster_url: string; + readonly title: string; + readonly xtream_id: number; +} + +function toEpochSeconds(iso: string): string { + return String(Math.floor(Date.parse(iso) / 1000)); +} + +function createItem( + index: number, + overrides: Partial = {} +): TestRecentlyAddedItem { + return { + added: toEpochSeconds('2026-05-18T00:00:00.000Z'), + category_id: 10, + poster_url: '', + title: `Item ${index}`, + xtream_id: index, + ...overrides, + }; +} + +class MockXtreamStore { + readonly liveStreams = signal([]); + readonly vodStreams = signal([]); + readonly serialStreams = signal([]); + readonly currentPlaylist = signal({ id: 'playlist-1' }); + readonly isLoadingContent = signal(false); + readonly isLoadingCategories = signal(false); + readonly isImporting = signal(false); + readonly setSelectedContentType = jest.fn(); +} + +describe('RecentlyAddedComponent', () => { + let store: MockXtreamStore; + let dateNowSpy: jest.SpyInstance; + + beforeEach(() => { + dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(Date.parse('2026-05-19T00:00:00.000Z')); + + TestBed.configureTestingModule({ + providers: [ + { + provide: XtreamStore, + useClass: MockXtreamStore, + }, + { + provide: Router, + useValue: { + navigate: jest.fn(), + }, + }, + { + provide: ActivatedRoute, + useValue: {}, + }, + { + provide: TranslateService, + useValue: { + currentLang: 'en', + defaultLang: 'en', + instant: jest.fn((key: string) => key), + onLangChange: of(null), + }, + }, + ], + }); + + store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore; + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it('does not let far-future provider dates pin the first VOD rail slots', () => { + const freshItems = Array.from({ length: 21 }, (_, index) => + createItem(index + 1, { + added: String( + Math.floor( + (Date.parse('2026-05-18T00:00:00.000Z') - + index * 60_000) / + 1000 + ) + ), + title: `Fresh ${index + 1}`, + xtream_id: index + 1, + }) + ); + const futureItem = createItem(999, { + added: toEpochSeconds('2030-01-01T00:00:00.000Z'), + title: 'Future Provider Item', + xtream_id: 999, + }); + + store.vodStreams.set([futureItem, ...freshItems]); + + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + const titles = component.recentlyAddedVod().map((item) => item.title); + + expect(titles).toHaveLength(20); + expect(titles[0]).toBe('Fresh 1'); + expect(titles).not.toContain('Future Provider Item'); + }); + + it('filters invalid provider dates before slicing small VOD rails', () => { + const futureItem = createItem(999, { + added: toEpochSeconds('2030-01-01T00:00:00.000Z'), + title: 'Future Provider Item', + xtream_id: 999, + }); + + store.vodStreams.set([ + futureItem, + createItem(1, { title: 'Fresh 1', xtream_id: 1 }), + createItem(2, { title: 'Fresh 2', xtream_id: 2 }), + ]); + + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + const titles = component.recentlyAddedVod().map((item) => item.title); + + expect(titles).toEqual(['Fresh 1', 'Fresh 2']); + }); + + it('uses the series sort timestamp priority for displayed dates', () => { + const component = TestBed.runInInjectionContext( + () => new RecentlyAddedComponent() + ); + + expect( + component.getDate( + createItem(1, { + added: toEpochSeconds('2026-05-01T00:00:00.000Z'), + last_modified: toEpochSeconds('2026-05-15T00:00:00.000Z'), + }), + true + ) + ).toBe(Date.parse('2026-05-15T00:00:00.000Z')); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts index 9e9be6356..a37a4714b 100644 --- a/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts +++ b/libs/portal/xtream/feature/src/lib/recently-added/recently-added.component.ts @@ -1,4 +1,9 @@ -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -7,6 +12,7 @@ import { ContentCardComponent, ContentRailShellComponent, } from '@iptvnator/portal/shared/ui'; +import { toXtreamRecentlyAddedTimestamp } from '@iptvnator/shared/interfaces'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { ContentType } from '@iptvnator/portal/xtream/data-access'; @@ -97,9 +103,7 @@ export class RecentlyAddedComponent { ); readonly vodSeeAllLink = computed(() => this.buildSectionLink('vod')); - readonly seriesSeeAllLink = computed(() => - this.buildSectionLink('series') - ); + readonly seriesSeeAllLink = computed(() => this.buildSectionLink('series')); readonly liveSeeAllLink = computed(() => this.buildSectionLink('live')); readonly vodSeeAllLabel = computed(() => @@ -112,7 +116,9 @@ export class RecentlyAddedComponent { this.translateWithTick('PORTALS.BROWSE_ALL_LIVE') ); - private buildSectionLink(section: 'vod' | 'series' | 'live'): string[] | null { + private buildSectionLink( + section: 'vod' | 'series' | 'live' + ): string[] | null { const id = this.playlistId(); if (!id) return null; return ['/workspace', 'xtreams', id, section]; @@ -127,20 +133,64 @@ export class RecentlyAddedComponent { items: T[], isSeries = false ): T[] { - return [...items] + const nowMs = Date.now(); + + return items + .map((item) => ({ + item, + sortTimestamp: this.getSortTimestamp(item, isSeries, nowMs), + })) + .filter(({ sortTimestamp }) => sortTimestamp > 0) .sort((a, b) => { - const dateA = - parseInt(isSeries ? a.last_modified : a.added) || 0; - const dateB = - parseInt(isSeries ? b.last_modified : b.added) || 0; - return dateB - dateA; + return b.sortTimestamp - a.sortTimestamp; }) - .slice(0, 20); + .slice(0, 20) + .map(({ item }) => item); } - getDate(item: RecentlyAddedItem): number { - const timestamp = item.added || item.last_modified; - return parseInt(timestamp) * 1000; + getDate(item: RecentlyAddedItem, isSeries = false): number { + const nowMs = Date.now(); + return ( + toXtreamRecentlyAddedTimestamp( + this.getPrimaryTimestamp(item, isSeries), + nowMs + ) || + toXtreamRecentlyAddedTimestamp( + this.getFallbackTimestamp(item, isSeries), + nowMs + ) + ); + } + + private getSortTimestamp( + item: RecentlyAddedItem, + isSeries: boolean, + nowMs: number + ): number { + return ( + toXtreamRecentlyAddedTimestamp( + this.getPrimaryTimestamp(item, isSeries), + nowMs + ) || + toXtreamRecentlyAddedTimestamp( + this.getFallbackTimestamp(item, isSeries), + nowMs + ) + ); + } + + private getPrimaryTimestamp( + item: RecentlyAddedItem, + isSeries: boolean + ): string | undefined { + return isSeries ? item.last_modified : item.added; + } + + private getFallbackTimestamp( + item: RecentlyAddedItem, + isSeries: boolean + ): string | undefined { + return isSeries ? item.added : item.last_modified; } openItem(item: RecentlyAddedItem, type: ContentType) { diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 5f60cd3fc..8dcf6dfca 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -22,6 +22,7 @@ describe('database schema statements', () => { createTableStatements, columnMigrationStatements, indexMigrationStatements, + normalizeXtreamContentAddedEpochs, } = __databaseConnectionTestHooks; it('defines the core fresh-install tables, indexes, and FTS triggers', () => { @@ -117,4 +118,56 @@ describe('database schema statements', () => { expect(objectNames).toHaveLength(new Set(objectNames).size); }); + + it('runs the legacy millisecond Xtream timestamp normalization once', () => { + const selectGet = jest.fn().mockReturnValue(undefined); + const updateRun = jest.fn(); + const stateRun = jest.fn(); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + if (statement.includes('UPDATE content')) { + return { run: updateRun }; + } + if (statement.includes('INSERT INTO app_state')) { + return { run: stateRun }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + normalizeXtreamContentAddedEpochs(sqlite); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(updateRun).toHaveBeenCalledWith(10_000_000_000, 10_000_000_000); + expect(stateRun).toHaveBeenCalledWith( + 'migration:xtream-content-added-epoch-seconds:v1' + ); + }); + + it('skips legacy Xtream timestamp normalization after it has run', () => { + const selectGet = jest.fn().mockReturnValue({ value: 'done' }); + const prepare = jest.fn((statement: string) => { + if (statement.includes('SELECT value FROM app_state')) { + return { get: selectGet }; + } + + throw new Error(`Unexpected statement: ${compactSql(statement)}`); + }); + const transaction = jest.fn((callback: () => void) => callback); + const sqlite = { + prepare, + transaction, + } as unknown as Parameters[0]; + + normalizeXtreamContentAddedEpochs(sqlite); + + expect(transaction).not.toHaveBeenCalled(); + }); }); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 6f1e08763..ae2649101 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -23,6 +23,10 @@ let db: DatabaseInstance | null = null; let sqlite: Database.Database | null = null; let initPromise: Promise | null = null; +const XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000; +const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY = + 'migration:xtream-content-added-epoch-seconds:v1'; + function readTraceFlag(name: string): boolean { const value = process.env[name]?.trim().toLowerCase(); return value ? TRACE_ENV_TRUE_VALUES.has(value) : false; @@ -298,6 +302,7 @@ export const __databaseConnectionTestHooks = { createTableStatements: CREATE_TABLE_STATEMENTS, columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS, indexMigrationStatements: INDEX_MIGRATION_STATEMENTS, + normalizeXtreamContentAddedEpochs, } as const; /** @@ -437,7 +442,9 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void { const deleteRecentlyViewed = sqliteDb.prepare( `DELETE FROM recently_viewed WHERE content_id = ?` ); - const deleteContent = sqliteDb.prepare(`DELETE FROM content WHERE id = ?`); + const deleteContent = sqliteDb.prepare( + `DELETE FROM content WHERE id = ?` + ); for (const group of duplicateContentGroups) { const candidates = selectContentCandidates.all( @@ -464,6 +471,58 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void { executeCleanup(); } +function normalizeXtreamContentAddedEpochs(sqliteDb: Database.Database): void { + try { + const migrationState = sqliteDb + .prepare(`SELECT value FROM app_state WHERE key = ?`) + .get(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY) as + | { value?: unknown } + | undefined; + + if (migrationState?.value === 'done') { + return; + } + + const executeMigration = sqliteDb.transaction(() => { + sqliteDb + .prepare( + `UPDATE content + SET added = CAST(CAST(added AS INTEGER) / 1000 AS TEXT) + WHERE added IS NOT NULL + AND added <> '' + AND added NOT GLOB '*[^0-9]*' + AND CAST(added AS INTEGER) >= ? + AND CAST(added AS INTEGER) / 1000 < ?` + ) + .run( + XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD, + XTREAM_ADDED_EPOCH_MILLISECONDS_THRESHOLD + ); + + sqliteDb + .prepare( + `INSERT INTO app_state (key, value, updated_at) + VALUES (?, 'done', datetime('now')) + ON CONFLICT(key) DO UPDATE SET + value = excluded.value, + updated_at = excluded.updated_at` + ) + .run(XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY); + }); + + executeMigration(); + } catch (error) { + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String((error as { message?: unknown }).message ?? error) + : String(error); + + console.warn( + `Xtream added timestamp normalization failed (continuing): ${message}` + ); + } +} + function runMigrationStatements( sqliteDb: Database.Database, statements: string[] @@ -498,6 +557,7 @@ function runMigrationStatements( function runMigrations(sqliteDb: Database.Database): void { runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS); deduplicateXtreamCache(sqliteDb); + normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 93670032f..747be3c26 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -36,6 +36,7 @@ export * from './lib/xtream-item.interface'; export * from './lib/xtream-live-stream.interface'; export * from './lib/xtream-response.interface'; export * from './lib/xtream-restore-state.util'; +export * from './lib/xtream-recently-added.utils'; export * from './lib/xtream-serie-details.interface'; export * from './lib/xtream-serie-item.interface'; export * from './lib/xtream-vod-details.interface'; diff --git a/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts new file mode 100644 index 000000000..2ca8e62d2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.spec.ts @@ -0,0 +1,28 @@ +import { + getXtreamRecentlyAddedMaxEpochSeconds, + toXtreamRecentlyAddedTimestamp, +} from './xtream-recently-added.utils'; + +describe('Xtream recently-added timestamp helpers', () => { + const now = Date.parse('2026-05-19T00:00:00.000Z'); + + it('treats far-future provider timestamps as invalid for recent ranking', () => { + expect(toXtreamRecentlyAddedTimestamp('1893456000', now)).toBe(0); + }); + + it('normalizes valid epoch seconds to milliseconds', () => { + expect(toXtreamRecentlyAddedTimestamp('1779062400', now)).toBe( + Date.parse('2026-05-18T00:00:00.000Z') + ); + }); + + it('treats the epoch unit threshold itself as milliseconds', () => { + expect(toXtreamRecentlyAddedTimestamp('10000000000', now)).toBe( + 10_000_000_000 + ); + }); + + it('builds the query upper-bound with the same future grace window', () => { + expect(getXtreamRecentlyAddedMaxEpochSeconds(now)).toBe('1779235200'); + }); +}); diff --git a/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts new file mode 100644 index 000000000..2e001f989 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-recently-added.utils.ts @@ -0,0 +1,60 @@ +const EPOCH_MILLISECONDS_THRESHOLD = 10_000_000_000; + +export const XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS = 24 * 60 * 60 * 1000; + +function normalizeEpochNumber(value: number): number { + return value >= EPOCH_MILLISECONDS_THRESHOLD ? value : value * 1000; +} + +function parseTimestampMs(value: unknown): number { + if (typeof value === 'number') { + return normalizeEpochNumber(value); + } + + if (typeof value !== 'string') { + return 0; + } + + const trimmed = value.trim(); + if (!trimmed) { + return 0; + } + + if (/^\d+$/.test(trimmed)) { + return normalizeEpochNumber(Number(trimmed)); + } + + return 0; +} + +export function toXtreamRecentlyAddedTimestamp( + value: unknown, + nowMs = Date.now() +): number { + const timestampMs = parseTimestampMs(value); + if (!Number.isFinite(timestampMs) || timestampMs <= 0) { + return 0; + } + + if (timestampMs > nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) { + return 0; + } + + return timestampMs; +} + +export function toXtreamRecentlyAddedEpochSeconds( + value: unknown, + nowMs = Date.now() +): string { + const timestampMs = toXtreamRecentlyAddedTimestamp(value, nowMs); + return timestampMs > 0 ? String(Math.floor(timestampMs / 1000)) : ''; +} + +export function getXtreamRecentlyAddedMaxEpochSeconds( + nowMs = Date.now() +): string { + return String( + Math.floor((nowMs + XTREAM_RECENTLY_ADDED_FUTURE_GRACE_MS) / 1000) + ); +} From 3096115e6496c3dc92b5441fb3d28f3aa7250a9f Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 20 May 2026 15:35:38 +0200 Subject: [PATCH 02/21] docs: add external players playback article --- .../diagnostics-anatomy-v0-22.jpg | Bin 0 -> 54669 bytes .../external-player-handoff.jpg | Bin 0 -> 69961 bytes .../playback-diagnostics-v0-22.jpg | Bin 0 -> 53543 bytes .../blog/why-external-players-help.mdx | 176 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg create mode 100644 apps/website/public/blog/external-players/external-player-handoff.jpg create mode 100644 apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg create mode 100644 apps/website/src/content/blog/why-external-players-help.mdx diff --git a/apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg b/apps/website/public/blog/external-players/diagnostics-anatomy-v0-22.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ca777771d4ab1063b9517635f64cb63f84309602 GIT binary patch literal 54669 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

5{ijqxiax0RM= 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 literal 0 HcmV?d00001 diff --git a/apps/website/public/blog/external-players/external-player-handoff.jpg b/apps/website/public/blog/external-players/external-player-handoff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..79ef6025902c6fdae66aa4e7c3a3d9cb6bf06289 GIT binary patch literal 69961 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

b>IpfjEZ5|Fx<-$n^!qM{_?eLg>LxpW_ zeSCQpQwbraBPN+rmr^%UHwLkzQ3tASvK#UNHq(CqtebFu=9}Q<#V*Q|7t603-&hHA zIVQa9l-9wGB7*)T`Gj`YG-`NA81t7m=E(Mt`2&2kf`U)*ZADp^PkVj95o;sfbgu9Y zLNJ>JqVVmLB-CQEe@}?OAiyKMgnI#t2t_XRC&n0XnB*XI3b3duJeILD z8QX`1JZwsK5ivC*m%45hlPL}-*Pz7jKg9D9)B}U-FE~x(KmF^-2%<3b$jBYW$aEw{tC8q+`cK6HITr<4%3A~H=R68x4qhR}v)KVCEc<8|>YhB` zLdHD<$`NGQU!c&kfB0Vi`^Wa3##*LBxW4ahiym^2SEft*d4oqp+pJvkHamd>mQH|d z^hbfSW^y6}H+&wYjZClFjMp@$-0G{Si^f9xXDwND85gR@qu2WH5z-#f;YNh$nN zbC6mOF>A5d)39|nBHP(&aWOFR5#2Exf9Qyag;}a5G?O z)HAIb!%1J}Ub3JXIHxgv;MT{EbaF^a`OMD4z)K6}1Xsqa*hh%0d?B(MChF*J4yHp< zSaE0-(RP5P(OCAc?lI%&RoE%rcV8~BwNIj@pZvzUHCz%4Z{**RG~_^R`9ea!&z z48Cudv7UHWM<*+lHGh#U=C|*QSu|kk2MW-NQ|M zZ(MUQj9~GV3$+O4Z|wO*@CQU^`uy)Xw&Bc51&N7D0vO$~T?Vzr1uUMDYlMQ^1FdcR zJKOE$d`Q1&)k?V`{%b1Pa)qMcZ5My(vH0*n8?UwEhFIo|Olf-k+2# z^G_ddW6Uz-e*b;k_&(fUQOqV)*GKusN$C=;k%NCE{9t4|tx790!Q8a>d&#dX8M$_O z?Ns0)y-{N&TiG1-1x$Y+gNH@Z zw`RWub3m3m;M>CK=_~IC;d)odKrIR3x@_Z0-!=^Umy!)lA35+eWA$+JsEQ0>cfzm{=00jNsW^T zzo+p2TK*utsje8$${lw_p~cNN>0#NT$&x5LBIImlNgIDJ;%l5qV|wvwYxVZd!b*^R z?%b=D#b3N}XsNBegw&HUuM@*`jWS^rPS?`?W`p)F$u;h%u@vAkUZ$UPX3cOr%FMm| zY28%i$Wopn)Hed}=+J|0Al+FuftkJRi)H5;@a>75XEx^$GRV31>KpM?R3!p;(~Uk` zU&yVdsY7ejJr}%9F!2pXv)9W>2$R;BLZ& zH0E0s_Pub9K?Q6>Q`13_c7EJ#tRqGa+t);N&~tf4Pnw9=@a1nZZwzv;SVjb2oMm2C zPg=Kf!>Uv%Hxq@ur4>pFhKPLAfSLL1cvRYwQlF_BO1$>+32I+X9;yQ^QlS+KPH1JE9nzYlw;p1K=8C3Sehd3)HEbYi$- z@Vo8_$6-vqF${J=B0G&oo|t?x>Tu!gh+lueFY^AaaKmBn1H&I6o%}A%@`|CMZMmGU z#?GnK;4{k~!0bZ4>6_=eODVkuQ{hY+y!P+OTOQjN_mr!c*5dZLoXf%!_jUz$+NFPh z@Qpc@OW{EJt&fu&bKpGe6|=LiicI#!3N`4l2cGH5tb-DsvDk2QdgY04gEGnQvyL*c z_p63C7yX7a{~yZEI;OIi>({uuySux)ySqCa+?_!NmtpY1-45>V?(T4~!F^zGn2+~O z?w8!ZZf>%hE!o*=+V0hM^;y5(3aS67$3lZxHR5N@;uGqLY2H!d`_3<$94E28f}QQT zg5&;2gNtrdeYV{=I1O!0dV%s$nYobl7xQV5c&XM1@BOKIur)K2w-c>YfUZM;xZRjo zuIAK{rA5teCf0PV{uwN@M@=q&FsM6jxCdDV+roiHWe(r z(Kqu(>uqpyV!-{eRH;QcpkDEbc3GxVaGbjyrkSg~x8l1`j9Gru^`P=zsQU+@(l3(# z8X(M(@NEWwI)RZ75+X>9flRJ^NxW$MZimjcbgO+P8dLrK*0}gxqDwWq@44LDKax;W z{ycHw%8RqiQ>N5^!Su2jaMZ9Ipv;pY81_4$%4k+bcj7_|1t?vpYb%7crq#Om428Y% zKACYVWS}bL@Mvg6noO-#wZ(|w%8swyB{VdVAJ%&A9dxErsa{l-OaU$X&g0kZO@g4#Ftf?$=}H;^|1Y98_qk>QL$;W=R=Yh{KX=ARlhmB!aD z@>^U${;2*cd)H?vg`?Io&UhJ{o8%;~ zma}ow3$*%>2J%^9UA$=Q0^NO_fO4vU!m~cQSOLHvRv$|U^2yrG7d%r15nu=2WE0eSxISD~BsvRR$Q+b3Ss zrphC=M1#avx*}Yuf}3@L0FQF(ua6cNFrL_Vd|JbD{gh>^5iTq7t3A~Y_IYw1hNpt7BN6NCG~75n0tXkXp0U0Q;5eGI-C~nkA@D+I^wq@rc}9KaLfp^<1u5@ zKXqG(5~+zEt?fE$w9qkUWa%|`k-5T^RDb>DMZmVKiLxH3CG&{zpj&o5C(uHNwcZ&q z^k4Tj(!1~<>}s!aF+`ctTjP6x&{{5eL_KzNDwqDq>!%C=_Kq2@Vow*U&z$3F5hvbD zlq+Z4yO&=n`^bDnU&ppPj28`6Ap``fiGHC=ALZt@+CCuo>T@mBmT*y;ewb2K(G?fX z(BrOShgtrspJSvJVdQ2Q1>9pB^?q1$)c?R*^M7{@4DT+P-xGohbkT{X^mYf7;mXu! zJvBl$zy7UJZ&L1BM|73b~oJA;!at65;L#gQ48MG#qxV5jdTDiyrUSG(RZGH-JH^TXayK6h6V_~L~#B1ZLj zUh`soolj?&3Wzm|;*)G$LffO=SP*Pm!*txb)ZF3h%H;0}kIv2=?=Eov4R0t&7Opzg z-|;*_$gp#+-4PKaJq9ib#UOT_uvIJ)O$T6$+K1M8_r^>GPuIbgEk9EH1MAkzJxoXl zmZ&3YjQc#*`D0I-Vu{V()YaupsT?u8Z{JY;rCehBpe@1Ns>-M0qe=I_g_$e$hQnuE zBx25;tKdL5T@|I++hY{9Gy6UYTUa~p#=ghVRda$^?4#{q@^Qk)b-;$r zpbw%@9`H%yZFnR;gr6qrEvK|nh=H833*FeMSM2C2Vw8p&hlRlAsq`2{(QI-%LI^0* z<~Y}LIS;ue^9ZNV&oehvso901)lOm@OH#ezUPQyWqx<2SsI8U^m+;G=)n zi!sB_#khE8w`?-Q&fr-opzpI>DS?v(AV1rUG&2E+Xfk~)Mo#l=imssJeG%<-UH$WK)G(3X$v~wW(-AwO9u8;5I zWqzz5&HuotFAPo`S&;A-u5zECMA^al%AXDK@}J62N^@ zdR8P&BvMkEC|5desMve>%LdZBYNxDx`n>&|)@);;arS(R?fcYy#aLcNMxA%@mumlr+2$$9UFF@#KOmCqQOYYjD1|QI3WwyJ08rT0pdE;icTh&T=UkA8YS}+qyl7oiK{`4i zeskPjLz_oCOG}v>d6>Z7I}vDo1^6R`(pktEq3@JtEM#!UnGT_2)J|8~T&Zt4F#JUZ zR!q0K>c|1|dD3ppowOzaP^Yfiygp$x#IVVwe{{nRD^T+5BGM-`9n^io+-?Vt*zyh? zE!f%&X!xt*Y~)iX=HdAC%<`#xJcuz5e7a#xwO+Ns8+On_k({$B1_m5wy^kaWiS=*5 zi!-zXcEG!lC94a6`dQ9%WWlXT+hqzsy9n#}(3r9+5?xEVx6a-%QeaP!>4^K`RHqNA_6bQD*>|= z+Y`bP$i8H|RB3W59NMz?0kM!-d>iuRNe_UR{1F0XlyS2(GR;6YB)`_Cj|Ytw-?sl{`N`?h<7@4GyVZ_!d684C zosTE6edpDMb1OeBE+2284Q31_O52mN!;tZPYrcEQx%)|{D}>b$${p1l)grNn5u5tU zXh!6&OIU~`n1i1xwPQ^0NK_1`Gm0kiUIu&%e8v31)^~MZUpA^}Q6N=M-Su8a!-Qsl zaR!4;adtO6lK7fcTu4M#Lhn|!$}aTfR8|$lac8k%xg=fZSzELOO6LX}n_4o(qWl?L z=hYqya~Kd@jeicTx4ajw(T?Dr!FjP&Dg2i_lZ*Z;Tmd|82;mkA1Vxt9pOUs+_Wyt zk}Qhr7g@oU4P^M7wsKN`8zC##kaO37fSy@oya1Q>g>&36%&awcX>EEm+OyKI!Td5y zDJya_zI4w<@|17GwRS?E9HlWPzO&)1CSv8(Iy1~~0?87d1=Ns|#h3B8QsKcIE#D{s z&GB+<(#j6vH3+ZWu?`qMz7Q*eQ`J%;8EY$n(;q?sDOJB5yvWjagQ{x9mWqk3rk`nI zJ@-SB#RbX`Jok9Kyc^qz3=9D%frXY#;6kykuQ@WBKq>h&5Q4haPvQ?%HW-R`Qc35+f*(hUJ_82{Hi-~S*sTdkn_b_W;^cK)%|FM z^kEy#DRtVa$I~2Mp3`N@^jy!pDNwG7yU+Z^hTt85j}%~zJLC|6#g6MGg|N$*oG;2O zxbH!YRAkt2X|0*lu&w99=Vw@GNFYMxgEJ&DsgGsgV*oZgeokRauSrCADx)nr!nT=x zoF7_En2>(oSyb2hK|{%(zIoNKw$2q!_}Q zv#6GKHB?6s>4;=DG@vP?$K5D{y22o$LUzKNTIlWHy=ES;X}`GtL3@Cgn|oA4ZQAfgKZTYUL9o%ARH z?mKP6olI;nOkn!{bbu_@+e*30%lUNz^|Zs0cYqWeUq03&Oy|DBTPfdGcR!M8nhAjz zl4FS10?fqjxBX9JH6{uhGLa*d+}e(Z>rPF?@W=%hKDfr5oz5#iY`1-nc*O$rx2FJdLA1vZ-$r0VNOMEKya;1+LjuJjR7$NjVH39nzg@VZj3-0%>|c(< zI}HM(3#%L3odurNt#(mq?F5JKzou%_)aUSa@I8^y@nco2Ju{suYs=l0!_By5iJ+QX5RLho)=gD$Vg-dy-mLtlki(KQ$!-X&MR{bgeyjB@T zZW6dwcR`5{hTVh_ZsPaH7dS5!NF0{^(wW<6Q<0Nw#(BdYKC<3Efz0A1Ryv!p2CYl_ zn(Y*<81iNJRCVXRWa|6y_lMsL1)9)Bq1nDjFu_7g<|Z<0uCsK!K15irM=$7mQ*iw* z9ZRL4g#y1eG71@21s$%4`}_!1-srrsN9m+2khm}VB{f^qwjQTgFGXZukSj$*<3c=3 z3f&zvh>15X$;@YDRI`*EB#U96zKLDKx2B8+byl{eSnoCX?THkF*`wa;D}3CO7nRGt z&lG&dfH;9m$2dBHpJwM#3TkhgZH!8J^O>Q8rc{L`cB%orXfETH$08-?F~h}nJ=!kx zJF!UT95<=&B?%!?akx;A`k!^R2gBb%q<_9qg}Ss9RMNA*dNf)DK`Vc1`3u!+6^7(XLANp2$CGl-`@Sr}7zPIAVQRL@) z`2y>wG7etQRLEN&T0de)b@aNrn^-|DBqqX^(nADJ+EoNjz6Hio+R&uBqj-!)kfz`S zOYo1F;_SeKe`LgeElFzY5?K)7z5eDCFl|#UALJrXJCf!0) zL==-r&~J4@z@#_vB7Qv_ERkC|+Ib1)%pjEuZ>ADs!l>aiiyxhG&RMZiUu~6_7x5X8 zDt(&Dlqy?ffl!{9Nzd{2e4{#qhi_vZFJGkXOXGV799UGH&_R)TGb0+ zAyEO!)49|>HAM&GnOe|jC!M8?bCH^LWzuD+VYYrCp27+}1E@TFy8LHM3o@?36#~{&BU#KrwUsE8&=gckQ8{0GM< zOBg}{r9!`8U9@egVABXC76x%rh-E005u7_CIR5VpC)p{-$?hNw&g0&F^ z0g1;5JUk*_6OF0UzCYodf6?G+*o1ia3x~NkL%jZ$)>J$;pM$U6V)1)C(K+wv0MROt zx_%|am$?W$?GUr&ZFsh1*mGprLTA_uR8?k3k92`ZwV(mv#N@DsjTy(%J_&xI70R?K zrfL;J!UTDWL(A7;T6{87^gJh0Mf3?A@@x>-mosfj(LWliE6C2fPPaQZ?!;i?o4Th$ zc2wMu$vb$uu?9y~eJ>0TzUM)gXDfw&UNu=di^v~mTd}tZPC(!xweCTNk0m?oht@8h zhmalDi?dPp8~^U=A$}i4qpq0^5yH@(vkNgB3Qgp;W@m|6#S9qj_YTL0=6IAoIDRgA zViP-vf^^Ax5$+RQ^nQA7yb$g(RPI(`!z4P5nhrp6EHfs&YWH&r8f0SIQ}O2G;WaP{ zjJlKaPz)R^!>51^F*R6da!@-FZ;CgRF%4!>Ec|n{q|ax4HO}e zv_)R=1`6lM1NHU4-*EG!Bpg+zNL&t|y_KamE~vyg+kTpc5g(yzp3K5iUOtms6NX;m zmDAQnYgTT2s!U#8kFS~d-9`LilR1LbaQ?P|FOm7vyMsK$sx73EKja%Lwv)|RzbsJ6?a+HxrxtID0J}t zYEQL~=eS2RK0{RkthfKHFHXx=i_(KO{pde1?>mwa#m2QAk1C&t^2>UpqF3#s2jDhh zwo{Gh5<-tba{@yCOT71y(SwTZ#8zO}35%$gvV!s6nI{F|hLaB^2ovF0>b(Fd=8d7x zduBJ)z+jXYa@3Cj>#QNP?n>2shrqa~K#-f~Dd?hW3G(L4QG7Ge+VY)9*SB0>>c`6V z^;3dl&IPf#xATMKTaxQz+ExWx|0s89SAqCo#oM=-%Z61hRNJtt7L$HOdrjt~Nrhrr z&)A5SNLcnx^ej~;)s&Uw+j-S`*3{OmH#r9jt{SwLSah{WQo-*ZM zD`uC!eV-`~sEluQT4{k)Qkg5}6VeXAk_6dQ5 zVwvhLUn^Q#8@F&~Xf}}1=Qo7Mydq55Ws=~G1z_`E!IFq>`3IwZAJdV;)9o?IBHCr{ zE4Jg)@^eSD)s`@V)w6edbC5&CP+eHdT)hl%LE~t+Jp<#wNHNBZg?hMN7oK0nR=dPf zB`=e*h!(iu;9+iDWm+`KKcF18?P=ibbsiD}$Av3)-gL2hbh;)eB-##3VXIO_Zbgns zj2@L_Paag7rLLDMmi_?@ zXx)BCMMCvwrm1ww=!XdE4!eSBQApQpWQI_l>;kM**lXJQrANmHYh5oG&mp1I!yrvo zysp^ewCop+UQlQoZa;C?HT#&Ur0^>{+1GWfB#@htJp4INWO2w@+G%zCQLMe`xl^Q) zvY_k0pv4W~5*kOJdM(T9rfudf0TW<=>oi+Fe1~WW20CwVnyNsM3hA8Q!pRWwcEh93yrt;Hh(tn^9L0=uO;$8nf~FSwIq<*Cm)DlO|*A{eB*< zR^;S$`O`<|FN10nF30{5L&|=Wa>Vz;56w2b$98dtuqqEcL-oSehXl+EoO;lf(IT6K z?={_%3mAc0JVQv!_wH6FQZH>!9(&E}xpdJYFOE^IvO}IeU?qqnSK$Yc*ddYvTjg)+ zc~s|x5f?+=1+0%|HyVG{8&GH`Wj zJ$c>py@mSr)ps;luHU-Gr|BAVAAN#;yGUH#k+SnVPqGN4&_17Tsx>7wXKESkx%)JY zf9WR25{75s@DN(UF1Jc@4c^*Quf#XbaHB%oiQrFkqr6oO2@(XK)eu3sKyEugmq*Km0VH zbo)s8`l&J?E-|r|)!XS!Sm&braVrTNCJC(^|1?s)r=;M5Mkc0w1cFOtqr%{F_$>o6 z1KfxddrpI2>z*J8JT)33sKHz%R=DI=EO2nCff%^Bv6+L!xVXhdOU^)R8{M!`iTOg8 zgYFNL*VLh0w@Hs|Dd&B|KW>MHpw+NMli5)r(M&H zHO%;T34VXd@`utli-H;)2)-4&W*L|&>3r%h?4k_KbwR){?YJkYxgf5I+T24u|Ct_SL>F6ZUv;YgfhnxypP5>IFkS|vr+!RD=6AR*J+Tmc zI~q_9A{LE(W9&8KG5Brm(dF$Q*w&p47BXzu*GjMF zL zK}kQ+ti9V$Pj!bTM-y1;eh{xVNkJxaR+5!MrgL1oiIr1hqW=1H)(+ zT0VgF$cjY{TEOM!%T*9j97Ugd5>A^T{UCL&ASgn860%YE%YV}QBtc)Wywh}ctSd%- z54DuiMf)TZ#of31YBi{Yo(9ZchJ8c;wdw>>daxxT9~d#?VG6K6s(fOhyfz9(9lM`f zxHTc&CW>AIB9D5Ccmlt^?&Yp8aNK<{Agkn9qzF2+rj(LeXD>Z^SXQY1F0}~nEGsVNtyD664KhR*;Y?_ z$Wr{WBwCX6m|<^4bW5}-Mw2XgDZ_)1B`4J&38(+vu6j5nGE<+$4-TjK8|f97SS#gL zU+75bH$96R<(SR4a-ql5My#>VMlJ*F|G-iY+=SrlEM0pI7;sAyR?g8oZYv^yXbf;j z&F8~TkLVp*geU+CuND=0<8i!SUDJ(06R7c#X|?=D{|ak0Koj?|tnP~OY`Zt@BDQF; z=#{q?d$3&>>_6Pw_xb;jS2a4lF?Md>C%}y42%tIBFoKDE3JcdM5Xv`da2iII8X=<} zYbkHOdhAb+zAt){8SRVK{8IRWg#2QT|GS+GI2bxPySO@LvOWB=?)o7;}$vkDWW{)#^T%M~ejvT~2Y9a!-4#+s)0vm;4iDlm>BMMG*6$=fX|~JpNw~ zhCH;NKK@2$NIjATW^Y?OclcYiO~$S@H*Jbiah3p#G7FR1BX62K(_A~j)~Rh*MH@R# zGfnb-YD#PA6!-$H;q{>_x2FY5A>OJ!SHuKdaMlqxC}nZ`N;;1&i#oz4^a(j~BWPhh zj+wIF9e!mtSRO{L;$_8{UrpEa=?O1pb=otDo2jT)0~PxSP0Tbj!3S!al2{VB#};vL z4J#AlQn{<{A|=V~Ic}hoW$kj`QVh`1DqYij=MeOSSUn(j9t`OR!bcEv|Lj|NxhKoKK>*^h7kUh1gp-+PGzI_w%Xtxq3 zLQ^%y^_Ic(T$QROR3kLbCQsg_LmQiPmy^YnCv$5-R%|&<6Ajj2Q^}%3)AC5aLvnV< zEUUZK!gmI3znn4`v5p3=$utuSKDEK%dNr2p;WCbpaDEzwTGG+d5HUytK<;63vrgyn z@oS5sFbdghv=}h>*P=br*8G8yb4?bJqCMB9D@0pNbV}O9G<Nrbcv&t@{+a+CvOn7tE`0hdojNl?Wl_72CRjNR% zl!2?vGOKfBn-u=(;yWc3Fe^k5(g;Lm4O zOT)f6nZ-Mdc*(#!L(Q>S#^Tjc+hJn(56m2+K8JI!0Hcjh*7O#@pE=${k>`5*+lH=~492fvxXz4Kt-+B$0NO_95liN6DQN@5)aTbdt( znW~uGxeu2r%Xa}&?g4Y5XF|dCR!in->$%L)a4|)ar;q|E&ZW#35K!RCE=-O7n?;Yp z4(?^bD=th*HPu^8?K?7qcZ|@~$fpWSCh6^H=oKE|6a-YpMoc|uG`x|2(>Q7>U8VZg zptP~D>h$Vq#f-_s$d*x>@Q;tmqcM58U6{ri6C%z;d1Jg;8BPxFcw2qaS)ky!iT;5B zwaR1GZT+qUCpI&lryig3zFmJGpdZwIQ22~7$_Jb*`1amU+=$?^;J9XNaE@&iy=dK@ z)|%=YeKsBVt5Wz8BLvH3-$eAitgy%L_q_b_G5K zKH9=7VN~bVKU02R0x1XuRrS(<9~gbgG9Euay5emqxz1PkH{8%mN;dw1tqOit!JmDH zVgy6el>q2#++9EwRLW+=Y+M~?MU185tlq->30smcVhHvSWDc7)Y^DS_dXD9tw9KN| zLaz!R`_cQ_2g+HnF)g+IehLF?Jw6qkQ#jG*(fN2&d0g7K4ZW84Q5u2Qmd=W%Ff{bO<#d*EPj{G5Jb9pn#!{WIy zq*a*?&E@BLahoIRPH^z@%^2xa|3aTTU$E&hMpK48Ydp35(QZw3<#f@|I6u`BOYI5k5sag_t%B+?gOd*ASOy^IT3KuYb%RZz z71glqd-=_%*F#~E3juoUTU9eKRN+So3QbrXBp8PuglVJM1Kv_>U$~!LkK4LvxHMp8 zZwppop@;5{!}Qze)yFaN@@ao-g+t7Pi z9ql-S?X(B7oy1Nm=9Yn*QTHQd2m$ytk9ecHGoC)WJ{Y79S32d4E5D_*GguanV244Z zw%|-+aY)+W8|(LYD(!t^CefIXd7hBt=BF%T=7=spcvi{!9>JJ+(XD?xZb;#s`{R}= z=FyAnYK7R+H@Rtq3SZs>Qy6V777`a9X}>)pWp!q=)RtQxi=PB#_AmOs77Frm z$FRqwSdNM}0h1`-dv!KS3ThMpW6>dS6*z0@I^@&P!W%Z-5avB?i%3s%@v}`ZdISb$_{cPUfya1r+qXim7&~Ff zZ45pU-Cmxx76*_U5by7`3!kT zroqi~szqAWT%r3B8*3mn)YYRrflJevDwyn|f)W6A)}o0sHi))q+N)5Jk?mE}ThPcE zZVRnWr@;o8CR$rmGPqhMPxLeFy#y2be9DQ_e}iE77#^ z9H6$W*skt#c8D-OdOi)Qi(>HWOgl;>HY8GvTiZW|*5@+igikf`0dimV5RC|vH-t%yDSFxg*3Sj;+G{)gxX;M5eT6Vl)oM*X_{}#@h zqG#elsh!y?oLp<30@zeTb<7@kNFCo{~AHJ|P$G-~rZky&)WUp=0RhN7qQkKAPFSh|~;Poe5 zrDoJRhZ*q5*4_&5N;I7bHgxdEgi%fH0k!|Yc*DnNCeE+85`+s4HtvbW*1mmFulO6U za5X#2ZtKxmzYRCwz{+&G#=780P`?FV)>*vLOkgPxv~#tSW?8VOkFBcdK#k3K!SA?#M>AO4q(r~WEi)dA zv};uyxS0#1Wlu0yz`N7zGF2R|RD7%0y5wZ zYNk`7AhEWOJ74eu=x){ajfxMa3g>VKpTro(*w8Jv_Ghn<;8*f5C7ks ze_(VE_PWH5ECFw39R)_U%0a4ko&Ni^%X?YI#g+;}vPB6uY=Q@Nc*8uHzQ;D-bm6Oidr5Qq!(CAK=)pM1Haifm zD0fZ!%~p$9r{B@h;9m|=G#BI0BoMC#kFTU{lAxZwlF(4fb)_wx78t#DVnZ`k%?5j& z@>}M_6&T9Y8zOcJzXsQ{kYch@~Iqmy*pN9*!LOa7t2!6-p23L zDAnpfO891RM-@`c3(+}og~b?$j6xrEEESCv`U0d|{tGQx3hQVVj38pfSR8_|VQCEw zClSt=zhAcL`CYa2;~07%@77-R=Sf*u`tEQI691pzEC7-=Z@)}h-tz1}u;Z-$LMH$> z15?)Mf#z_laij)8*G6`pa%<{^EiMin>jK`y8(V`k$VAqv}>tAzMRvI#*y$|`l|RWV*8nMXO6V2 zDjE>NL+1$37B2aGGMD*<;cC?(Be{P8?)rQ7MF-<^i&pI z=?*UYI9J=XQIW&0k3@GcM%@&Goqgc;Ta@el^uASun)y8#VRGjI7=6q2dXL#t(~ra8 zN)CJ$i^VD*98&a8*CKkxf59*CWe>ZQB%p$3Z2~d0K5-A`$Z(tg-Lg&mdu@ZFdz;mT6|o zfR6gj(0vZbXq^|cX15%6`bu-lv(#7ppQjHmA|6LC56`thBKeQ$GIcA> zMP>iMHt5Pqh4YUCO_Ic!d3tD{E`g|U;^J(XG~li9yGMiV3na$D`SXey)CYDeq|zVC zwurVX!Fk~s-aX?v9BT?}**32nUw$$-N(t;K@jT= zmO#D8&wWyWh-9X_YS?}u8W5^O;|Rb1@bYW0`8qC8!ChozvdkTUHo+OGt!3L*t%s9P z^cTo}##RHQbt@@zZbsFM!E!QwwFf_@NJ8hndXtWkOb$O;aK^6^9PJk5fBBIg4f9J; z;Vve+k-=sozDS^!KnzHp{$np%wP12CQ(?P~6CC2fGDZC{jN?`j-+TEY*Rm?&47RgO zkjTgY`JfKICcbUl-e|}$rHPT5z+RXbRf|5X3kMhxT_1YvfJ3*wp2@=Uu4n)rx2Z>b zkEml7tK0R+{p=_mv--wyg<}5~NIX-Z$T5Mv^%@u(;gra2_CWP3yKIW`PHMB&*s5)G5WglPNe@)^I->g{!*TAzB z8mTNmoX_rHXRGk%L5$KXo@wkr^MI_H;*|?)z7$QUQ#qhmlqp9QT94)8!HC*8ts3dD zMaPIgbYlISjsxLZcJqRteKFJ@Dp6t~OIrKb1*284)*u$YP~Msq>P`mU(toapr+g^-GpRlO_0jme9DdRWjW`@IJ5^rWg#V8u_3xNb=N^yjH5xd z!L6;{;Bi_JVYT-8wwO5ythK!ueir@|s|0b|T;^VLfY}WT;yM9-Cwe)1)E%RD z*tLFbG^a*6n2}?8kF7(1W7v04iq)!ay!~NWps&Nq{VwIa+&1+YAffU`=U%s|^5#+9 zn3=Z~Stm`JlTRWLlV!B0)c-2=TT)Kri3Q$uWrXJjBE|5dbe>R;FC9;q%w%$3W0Ja% zmD_*%d{e1f!Z0Rw!|Q-Iw4o!${y0YZZZ8s*&KUtsZ4_0304e}R%7&_X(h*Y&$~!tT z_D6iM%i$LUl$}v94w(7}*3hRi(i(L;hA@vREuO&wAjcRn+eyYoff}2!7`We-L!Pmd zY=2aF+b#Af>Il-mOZN>rddL5>efd!~KXQUg@OjY9V7y|;U>IBjbmp@7jn>g%QRdlR z`qR2O?YLFJ$dW%(UE5r%kpc7_=S86my`jj+PHb^+IRb)7G;z=*l8cnRVM?<7YZb)D zDKp$|-X$&*oQI}KkIUamX=bifFTrm>k?74H%HDvz;|j49>H54rglRKyJiio2#GKGSlWDvXG*s;W^z~KjdR)Ybd z#}F6Qq((s+4vL_nTG0Wg(-8DeFv++fPT0=QSE+?nWaf9Vz5e?uZHz>@H#GTq@W7b} z2@oY_ym@GE>^a@QSJ))?2jN4TPxB{`qLM+dRWJ(VKo1NN*4qxLGQ1-MKbLtAj`@q0 zadcqpBOh&P!W*`qRq1WeWh5dwfw9pke)f!>j?|<$O4EwmB+iBAm3^(Tatys8uZg7d z%Vq;_Ai}E@wa84N6S*KhN)e?`ALi#r*frbIt8Kt#$ ziuhU0+ufNWdEYZ)B4ddR_NXp78m z`=qZ^ju#hq!)EK|q2dl9+~O3H#bmii7uySxc{$O*_XJk}mGP%4u_HM(<b&vC%}H~?bnDy=EJS9-(AooV}>UL77~A84?~%t^>ZPg;DSpJr}vMP-Gn z)y*H5XSkX&VknR25rLZ0dI13y@YuIlLk%7h7fHEl*rM`-F2jGz&9a}#-1h{qgP)DC z7UNcu=}7)iu3H&>F2s=j6QUhFKW zzVq)s-L=xTZp$c3$1tyZq2NO6&&frnYMG%$6uU7BA?czRT$4G@-SBaNdSY>Zlg?RRSGj(36iYw_p#SWE* zms6eb;UU%V11(by#$uh$KR8*y3)V`Vz&(@{L{xsi>7MB%AK~&ZyDBQ_GW*I*o&GkQ z188t#+Tgz`?-sre$eat>9BLy-%8<^?Ds&;1`pY~Q(cAebdMn$Cdz*3-1L>GA0H48k z%KprmQruw&iQ!z4O_yVwQW)6wAbY|Uz3iZ)V>;}tZP8zb+YvczofkB3(xNfJZT@|` z;zh2eRcD-PMdciqz(+i&7O|xaNlTcQnso^gQwt)Uf~K@7yWhV!>r{3D*7sQxzv{FX6@PR%%2I7~pR6 zpR@YJtG!N7Q(MQ1knIqw7ZK7|U07%KshDFxZp3@o zCg#sLa^|dt6 zfSK*Icn246fLdA^-bGtXDxTdTqMT6f^5DdeT1gR8vF9*QT}L(aWJp>|l+x(;!<8z2 zJ+j(eN$m>)Ii2bnmLX;93E}~3{vGU%!MW7}lGLN zc*uH^Iv$M8x;Ei}GdQ8r8p#oYDPL}$3YTQ#EQ0CVE5mxg1q*LW{s2xWz2&(UL_46u z9K<$hL%6mlE)OFNdGr?}WsSv5T1@)}hH=#-kjHUOI~~4S@KE+SvxsU)qj9dYbhtnO%Et3 zd1S;7oA-Ky1^h}6NA4T>r%%ci0|kyyM#gt}HTv0m108Bldou;Rdo*@M;>0V$XInk< zDknU>=TwNnCl`7%Pnd2gUCA4f#h+}a(}{;@A4wN2hyytn>h>tfn?APoX=2O(=jsXI zLh(ex5htQX3QLy_ts(*#UpF;kk|BPvv^* zFj?gEg=*_WYzQ?+zDJ749jx;91#6wJO$gwTn@2I_RD2HlAq&M)L+R?(OI%@2_61@^ zJ>-&VG7KndBEyWAJ@!awzZtpF5g;mgs}7v z3|3bm#%t)CpxXt8G!8$csh-29B_duyul+-Y#H37fJ7LoJg93(G05v8wPvj0Ws&fZhl0!bcC#>#KxI*g>7Fr_>!P>pOqM*>`l@nitT?`dkf$=nj{T$#LUcKi)q9(vRJm5 znI(%YW@cuKnVA_ZMqA9xvScwc`HlbG|L$JI?cTnKH`US8GgI|dR#s+qPeEqWCG;!q zPLK=Wmi;O!?3-*cDs`{U%lyziW)usM10{8V%yR9->=FnlXEjlwS-h)jU5q&BkfkRj zo%)U>t%9ROXRbRxJ-a{*RCVZ;YqCv?KE^`OEe^O1p2>=d8G0p@$u*)6#k`5xtb4gR zc|0(u7IP(fcahxN!F8CmjZ2MHIE&d#uiv)xE@YYEdwU({FFT|=*;m% zX=&f)`#;@8Q4qhOivxQRt|;$RsQsH8b&i1D8TR$chY#8o3Cd$X(jY2^v!|L(n-6ox<$mfV;&P*X zu|+aY`j$m$1%(Y|mZQ;|%gpb%gXmvWA~&6Is>HuVLO8zst1-7aA$$Le%{~qi4DsR4 z&KJBSEnR){18lwu*VYs4u}ttt&+2Cpv33UPk~AwG^v!S){$|3SS@$H3ZyCI)Na{L* z_f7q47I(1{mgX-gAGxgoxtddI|PfO=Y>b3gs2F{E|!}jjalkVpO7m> zZ+Es17_UFi`1?h2htx+~7Y&&oZZLnK+D?HbdW;O(5b$s^gWKaHXUDBF-*KJ9XX6UJ86D_$^il_D0V*AYIyIb6(rY2sSx;eE-q+vGj`H1oXy-( z<*^;C@|i-b&S7{8&DBV1a}sKVmxw;fa*#Lp31Uv-3Rx|Y@kDQH8mW35gp$?+uq!=B z7gA1(@HP+_lS?23%}JaGKSc&x;euiSj1*aK@fR)%Q?{F_)?=Lpil}eK$9UP(F=E|R zm726A{%MFqlt0l6Lbe~!QLFA{;$+$tG)x#`Oe2IAlM2A@nLDcuP|7IJ&bX61^O81Z zHds`B1jiO}tl5fZWiE-VGfh?d6ht;Q&pwWpBKwEueO>F*rDzd}UB%PONVw({FUf5R zNoBm02e0+-8+}~GnA!Y{4R%EY7z_z+cRAE_YejUNJUh0*LwolMr-P^&jp_P9&tPTj zsKJGPYv0S#%kmv9G+$9YvRjAPd^Z9f z^yOABPgJZ2{I20Q)Dz=OG5Y6s3=>uXdgT#Do2@cJjOJZ zW@JV28neEtEEN4W4F*OnRx8sFGXYcuI=*b{rUC1N83TiJ{L3tHlFlNE9o3!8SFN^C zt{k)CDaDMroIl1YMCNkK9ol7?^=t@fiHwkMY`hc)s;vl+Q5+^L0zuFN2KB&&n6GzY zkhn8}VeCAKmP?&p&XZHNW=O}9H`O!CUxDWLWO}IvBsP13F@B{48s2I`fXrpDj9$vy4V3nJn4z;iCwNKGaRaR{O#6 z2)gAm+SG9FmMU5%k;=?EH7aRO+F}!; znvFsp`&K0c_7YAYn^QPh5k9fTc%sQMLOV!c6&RYxwHycQKPu5ljR{%hOc+fU^-d>^PPJMV2ON%&p}Dm0wjvBh zN#sSw(i%Q*7@*uErP##Ic*5cWG%u56PE4Ec9JxnrdzJu%`sOY^TR3xSG#+bQ{4ys@ z?t!`M$UFZHD3Z*@tzM3?46bnEH0mE~%V)_)5&6V6ANx!q*2!@!0_+7SX>02*D7v7! z)cpn+QQ?ah*sv>l<*j-y5?a9cH6MGwrQ2c|Y8cPng_-!3@aEXrT!<^7-v)5D<)sn6 zfB!T?U@iCL)O@8=ESaTL zwr;y4bjt}hYgChv$~UiE=>j%qOvf^+SXN0Mn7@^mU-h_ut~^PwLNpvB6vBc0beACZ>rikh43+Y zViJr=k2^R)e8ETHbosN#=M!E-~}f)15MX2Jgv@p3P;NX_P0y- zy-4ET!pPTtv?3X@kCQn|v?@Doow4Xa8CT`!j>FQD93&B2{Vd<%I#KX;>1=1IBR-oc zo9~#9emQj>2ll%9+vgGL{2V!!^4wl&OqsblO+34ol1?pMRcQu|wvxajT_R&>NZdF& zkq6SkDBZ8*vobMYuN{U-pqe4!+7+e&rzPf7(nOK{mem_QrRUGEz9pw0v)vF+Pe-kN z_SmO>v{D8x#c5B^SwxM(vgutjSYH;Jcq!n(a(z0d6V~vj8J%+cD8X)TFI=?YJ<#|0 zJ@nRf*cDX_6O!C>L4_$!vDW^P%Pfjla*KAqat~ymKmh+%bxW}i*yPk&JfToWv|X`6 zf~WQQsVFKf$*1eQQ$PMh**7T9rY0-xNnah~6#6F8XudYmZp7Q@LHpx~3ZX0PmtI5? zgW#0)OgpUM2mIG!*-UBFU`r|*W?RiNoEpY?JwapXGXkeGy~qr@q6oDgKrXwHCg<$c z9>w<&>)M(#kFBbnJr4YxvpnOFWMAS}6U?WU#&`6UkN1S$;K?VH%Z#fwD=BX zw36`v_~$|NJfCwpq&}In9V_anV`J%jE-$TAvJN|pDv|s=Xdj-4onoZ^k*|FXM>Q)T zMxY4UGQQ`_h<*AlOR0ag|Rc!K|6pS+&`Mb4$>9xGD=?=C~_|fo)Gy2ep1wCkQCyz zE;L7UbUY@%5-IH+fPv6qKG@qTBnU%i5i2%>ub@gJ_7oY>u(h#02Y#iWe@nNixO3fG z)sJWeNaIP$LmY~3eu9HfANmZXtYL@i>ccOF02iJEIgqkM-kGbc@L9*ROkr-mkCmwm zwtJi(9`qK~?|&|}cp~~%M|MbAvXOq?hL?XJk}w%SBNx!F^1Jz%2F?* zvDSvHUB-dTFQjhO^{&!1&9|OVJgCj#>~$|-f8{WhtfBy zIIDEKlEXAMxLmfx2mo1J5mrT$Oe6(jn_ajk?!{4y>4o#?r13!LfHd`-NlBAzB#)uK zi)F1(kK{J8hpo)*a?lZw*_m4TBHxMS5jf^GiGc()K;R^QCo1X8AEi3)`-8p#cF|3l zS!R^oT_t~mGFCdvNGRGaYL+gmvkovv&rm^X*o40lYGLs7HBD>?btYrh`Y}C_$C7E8 zBQeFL&;RpDs>rCF1~CnLnh2}sVARMoIZvH?*fE}w(YHtk-m)3^03E{pheRu}lbn-~ zocyW+Rvc*0EUOn8&49(?ch;88x+aC(%i?{BOtA7HH2y}3Eb{JtCjl)2#^OX;HJtW0 z2kUy+DW&Net+GL~vc-O!R!A-TOoN##YjbeD&f2rrbcb~n>2D?!-f8w%`Q}u=cE>oI zpjiPJE(2X!e#c`Q_OP$|VN~y#s-e}*CnqQmh||HgLZm_6b))#JM1$s|f?A5|0>=W9 zh8A-Jf5@6C9qgAXEH_Ku8F)6n_go)4MPS-_!QCr@F6~zqtr|I4UZfgG)%9^S?K6Uy zBh=Bo7z_E<;Z;6t zH1=yQ`2B%RJ&}TIo53+7OzHK zlC*mG>2Yk`4KlO5*7}$%1{czX=hNev-@sn}(0{CGhS8Bfweo{oCd!Z=jlM%p*dKuC zYERD+eI!&1O7`0b@+OfcwXF5Xhu{OKNa+Uqnljvy(_vwh?!sS_S zoNS-U+`*AVM?Bb?!j>1R);oE;I`Gmj$xZo_lKX_#`{e7VV5N!xN{+^+X7Z!_V7i~97Z-uV~Y?rLK4oQsTzV6@x zPxGy`27?C^EPOHj&O$7KFqmnyL(sOwkIDqmE3YOnb@KZ#bUa4=?XtfdnQE53Qx;=Fw>~n_(nkUsiUS$oP#&IY@^> zsF9aHcctgy7dr5Mf8AoIU$b}fcK!{pEP#`F{V0NY4vaL;wC%9!YP$-g$&yrFd4AE7kZJPrb1R5f+$-$p9UKq%X%s?3y z)%@ZHeoP{=?NCSm?7BgYB9|#cVo<6KYa4Fo0JthMgydu%wg``|QPIY8`yo=Trn6>_ zuXUJ@t$X5wAin9$XrZ+>(m67qQDoMAX7d|hx_R>|R_j;H*ktY@U6s)!k#dwSf^SGe zlAu%B(9Un>LR$Hu;oB98k%DTVh2G+pN4r*u{26%BvpnXSv^`yNa(1}xuf(fFN7fC+ z4TF8-5aaL|nxL+Mt^rR3n~j;xpceK{{%~8yT11BICf6TtG&kJ?r_WeTXEb9uC+T*( zDv*_M8Zm3)_~7k^7=s%UpYP304R#f@9ydVzcmrL^)-~pY5EYH zn?M(@NEY9>@qD)FHY>JL{41s3Fq70z7baPiC;>38ppY|atke;SC72L}gPYQu>d1Ky zI9Yh##`GIzo##spib|XY&;FlGMX^QM!g|G<+bEwr6B92YgQd8f~O)W20KZhN`3tP zP&_9n4Q@`m00WqC6%V+N#iTZ&&*1q3JZ465v~|2S7Dmqs#4*%JcxTkSr#~zM(|P-k z6P)0SBN<<&%`U7 zo<{CJXf8e1S!1536$*HCG88a?89LK0pd-7;-f&K1br?D(e~fUsnPzXB@fu_GwA2Z% zw0j23_l4A`w*>tuo)QpsQc?kF()7_yb*^ zo!n?lOZw}svoXA-1`>CG*%b;8SOQo?OMPk&E3f66zne3A&-qO5Q$WozbXvO6;pz%g!iBB zJ9j6>#Gq`(DhLxT*@fD)TK5OO*cP+o-=LOAA4ngNu;j7i>y(8GJV}285v+eSyf8_@ zejUEiSAC@b(&7$gNXa_Y!QFQ_Y2 z`lu5%W&Tf*KlN)LXth3l-FI4CbR$|)%e2jkOGmS=T`=$7eoS`pmn;LD-!C_OI$+)w z^%vbxjaNO+D;A4S6Ivc?)?`F9>2_V3_{;D)A&i6}s z%Ccza*-TdkpgE&W#hPditPki<)&5nnUcPzdD2mb1wlz<5EAtm5^)V!*(f6etcv`+q zU;2w~yZmRiXPz*ha4dSeE4|8sI%M=a_$pPJn~cd5znGgcVvsqewl%D{>fCg-OdZ-CbV8L) zdtmPM=ilmuf~~K}b+^%P+xFz{PjfAdfy=?N?DdSWW~_8lw<(GJlLUrI?nJ!e|oaK{=;+E5%T z5tRJMcq=+$99rtz+LT^Y1d^6^xlh^!|NcE&tRPL!=WFIGC2@Vs1<9|kZbCeX_N=|E zy=c%k0!4k=_$@^@d@QO5PTC`5%Z(N7 z@kg-HXe&BoVu3H)1GRgMgiyVG-5ZroVM+!Mew_UVfVUVTgCj^l0-(U5KhUqg1CY-9 zp~<`}1z!u%K0J_PU-v-igLD1=mihpY+GoAcWWG^?uepC}ApTEkc%B4ZgAeek-G80n zDPjeh0qJJXD8Al)@?8hypliQ>O*?288QA~#vEjPuy10J-8{qZ4_@{#B^E3Qda7-AT7{TFeQpo{(`DPqMwZ1s+7aCn(N`6qdVq0wdUANqYa z4Y1C;;Nd_Q0I)Bf|C!#vgiP{-8#nPcJ@Q~UR1mOVUhwaH(CEV0o8BkD&HE+5^?5!( z9+V6CfAL}r*e?KvK>kMvn}436kU`g@Xvjb?F`M2To8FMy$ivBvhB01*ks=V2AOP#% z@V@~s{m8Bz*HPet$iTnp0Dl1Iy6OYK3p~Hn4^DzE2Z&|8u6GfwK=popj+5l@+Zk2u1WS%yDRfZ@^nQ5s z7Rq?fF7aUwxbbec`8jWi4DV272Twl$#w4f*0`+;Tv^fyHFV@gC-aA-Nu~(EB5W@qI z0U99?gT=1jx_R^@4hUUQ%;%;6=R10SjNHx|4FFdPz&P)E-suoa_YPhWzrnM>TN1bX zgd70#emj8YDhT*|-o_+o2&>bdVt-WZrRm$rbKi5WgztgD(y;|W`h1p;U|9;MARz=p zP^d5L5`bVQ0_`u4tN;P%6^4cMxQxnpLa{=dFN8|zM zk=1akpV=z#Ax5C502uXh#v~9*9QRf^M+8mSCEgo009rT_IzJe?&L6)w0v!N=gs&Urn3hz}bRHye zUtE(Udl|w<1_A!4WYBdO$_*l9kwRV5&fK&p1T4B3`gb7v0J5koNO1rf8MxVf4IYUG z3+&7Dxi|Q&`=Q@s1fVcUj&v_r6;q{it%JEG!VL2i7eWWf%^H5n>2<;=Uh@ z|J~I8{i=5Ox(DDm0`NQwK$s-Q+w?@c^k9_)$NPmZSrUduQ|S-jU?0Lah6Omt!v8@y z)}2yJA#!ir5Kj5?@eR0fN!K zG#!m*s{V? zfgm=mkbV2n6y97fIA&q77qh(Y*KG{?k%c6I7vjVe@Z^8W|Eu);4 zKa9f5tqTw{n!Fcmb)W%Iav&&wo_-iwMdPg)i?XmE_$gH zUW!1KCPkd4H2 zZ!{?p8EXWeuDNHuJM+L>L;iL8Uy%+zn`*dg^UY9D2F<=<1S4OM|IsmPRQ!L9^H=K_ z|7u=Y!u1~3_ z{yQY_P7fehNkJDuU?q*a8t}fU?T_~4S0(SmP8%1s#ax^lhg&#pY2xyOcrAMWZ-ol1 z_O*9ce%Mz%*kF^P+=p>L(@&Q34u;0l4C~gel894j(XZ`!sE*lfV)i5G|Dfuvb{1^C zX8QoE#{o$4@EP?nT){(Dyw4o`^mQb+xc7VizJb31SN*Fa0MSW! z(er9}CJ-x$_bx<50z~DJ zA%@d|KiE0UB=vp++-f;imdb_ITrZWL5+jlg|8KP407j0NS^&pYANuNf1pIrB>+fCk zhd`2W%JJ>bI9cL*tFrTb69nWS`vk}T1nmWQch&QUb5JZ@t+@fBNR1El#F^jG3m6M< zQk;L`O2&hq9H7Y&H2)JY@Ga${fAkOUz|G})K8}DVk8wM!<;s2koof6J-`YvV5RUj7 z!FCYWXYxP8d;W`i0FLW^fT(TP!%p&3QBYp(TVTalp^$FTcL{9{=|@|JT*aMgQs~G5{J(E<6M>5FQOa9*Q*z zY#9GO1m1Sh-iH4XrV$G8m(vuO|Blm$PQhwG4pa~lHdM5;_w$e6zNq`lValH{jp)xG zYZspo(;vT0F-uRe7*4-+e^=b!N|v01mv2J8qmvG%lcK3)ZphVjBR)tC+Qtg4Au>q! zbec&lqLWg(z$;B0pov(A4O>c+cA~dihuz6{R<<4#h?{rfuH-^d6c?9sr&=-As;fI8 zv_?T;*S5takAE*vM|UEnY#Ah}Baqt6np;M7pxCI}w&kiw$t6RjU{Qoi!l$7BZs+~i zFsDOI_;uO1bkQ$_`HJ$w&lvcNuw@g2JJSC3;LYp#%KSw7!rZrTSGNomM1vr zcj}PFgjApXNog$X2+;3vziAWt$a}o+PHZ{_v3wf;!tVJnEN1_JPDV&f{RMZk_nD;ILn`3^5q!+Wm>7eBvIq;SWTyS^2&|C||6d(Cg<$H@9_ zoCuFaf@Rx_hA;wm}smz3JM59bWGF zV-&{EoQm~*JXFz*r=oo2P;l_(*MNH(EBzYJ)f-=UIW)NM!qA2fKBa$L;Lu`#|7}7* z!a{;WPX4nyAuw3cDae7~keiAI_J$XB;P8c)e|6@cc$4UF?=&f4`9DgHyfm@#sOG<- zEE=}fz&v4ft*}h{MBeaxPlzu5b^0`L_GG(HNM^X9sW;i0vV707k#VPA-x-|_4uugu z6U`JBvbiO-|0v+!pJ=Hat|j-=xZD3-MpX8%(?F*^;*y8LlAJk;_ivCH8Vo-)wHW9$ zrIt0Nl|~MfyR)VUCf{f#A`m*jL5akt{UVpzykt<-krHSh zW_}|V_`E}98q)a6E5C!bg{D5DJ6!r0Thy+U(%*?b{Zq=KCFh3MWteJr3r&pcYo)MO zwefHdDbiWG_)o6Uh?=QYmz+08QofHeJktJdEP-y6X!><@h{3F;5k?}>7*3JO%~9jqJk)R7$fc! z^*)6cYz0vuOd0Pm;c~65tgyxXp8pY8hkDG z_TG!*k9A2A|8trwp-+yyBpt=rmuWTDhm{}n<^om|hCiO7{)$cu z^G`MTr>7`Gbg-u=a$sKFg;4MG_N9WsfBA_*f1Dh63ElS64}Uy=n}5iOdtr&Z6chmO zI1yxetoh;N`5Vyaqy1#|Rwb3haN|s`QuAmFtqS=ghdsG0h&Zb zNsH_wA~w~qFscwv(p*dcqM$l3xG`p4D!O$J{zNBU9PNOvPMLv4Y7)X7wnLGj`neK+ zj{gm#tH+wij%k|N4>m5vu$juG{C$Nu3gVBoht28k>m&6SOg)$RN9UZBC;8D=n!UG# zEATAcqi^9$_l%GJp&-l6|My>LZek$%bC(_kxn2ZL?pyPNM;PX(qK zGTD>~`@Uqdchdzm4^_HreQ-YndT|m};F%ID^o|1k@DXQ%q#diIbVi}cN6x;tS6@|q zS?J+cqG58h!aiM*Q#$Jl)-*&!c!v14Aomh{0|D~aA6JaCTZ|oHV&VfvH z6>`Ba<+qW+KRuxKvfr)}-cO4lT2~Ae?KUKR-93aKEIcyf8yuDlx>NYDH@+QGk8d4F z{iu$ubW+EHks1oCYN0N&h%s_ag*(qt@0 zE~`~g05-j}{R>zp1C3x4vhho%%(o>`z z7C({e8tbAj9J`lZ#W{#KL#}m(e}+czYc5(p$E*;6(qJ)`FIk~7tt@j_!*lkUm)eFi z&{h|3vkD*#Oa-8V0-*u#Ax6CCX=*l@c=5&Y18)=C?`>?;X^j7w2BK6n=mh_pq zGNnA1hVO~PWjC@k8X1*aj2WU6#f40>R<^0vxNYrGNQO=&>k-^y%)bHkhlivvLVdS$ zz?)Q;$hmCurGgnchd3#&tzNGk#Qui$Z@Uyj<@8vT$vqu&OH3)6Mx6$0Iz1wJLqwI1 zikB|hO3~?GRJ$cjO@(0lWs2H@R2s8Ft`<@R4CtYGQdrU!;M^~(v8kyvH`BWIm2TB& z@={^K6PEA`SX{nWCqve1b&LRE;w*a9;FwKHV)I!Fh)Pi>_l)9k)x7_vELmoN}9`iP;>bqaP@rB=3;<=9rvdt-|^1?~(@ zYmhQoDoe!NL%XyTYKUrf%Yh}Ct>hm1P#S(RxrvH6AL^V)tD~N|HNI6(d1?e;?^Mpl z*eg4`+U6b2NjAm!n=h@5=`k#5u@9%@S4A1SYTHEUOZ8EpLk85;!swgtE}-Xm(W>fm zT{=Qi6-oh z2(I`Do(huP<-|UNZ9~c4+bq~NSUmbRzI3nn=${I*+~uVFhrb9m+UcP{27Z5R`3>NF zHSsD>dZJIFS1D5}lKOLXRq?iZwK=o+qb5VC$S1o9EFr%Ely8ntc(*uLR(p#tTHq`{ z7Z1+gteyxTajq(EaQ<2N&s(>!QkSd`co;n<7)H8h)ee{IC(8&tgu0N{*cP{@%u_;G zQBfs-rre7m+*F1FfD?2XzRa}OZP}(I>KD6Uy3=Bk{dF$JU%#*pW{N3=#m=*?#$3R{ zBX_fLfj69-v53$sO`QUX>54ZwQ@E*ykG!&O$;CrO3|cl^29OUq$K0_40UHc_6dR>- znw78o1k|9NJw_CD%6hh;6#611xh{aK`gp_#$x+QRm97Y(a$SJcLJ0rryQMy?Pu*n# zW`K4Iv;Oxshc||l>x%tw(W`N0q@#nj*hDTU_zn50RqM%#+hFwS+~mdeLk!x8!J-xA z;1#SvY^Qq?uID~8$;L%pqd-+2bj|8DcUeXC+_p-Gq>hP$@V#4@k{R7|T$$4S>y!?T zJj^K#^1u+Q5R=T-_aoyGDy-dZpaiF1`Y`Ky2W~#)-Hw(?M_HgDI1$zBwPBoI@(Np zoM2EQv~sQT7>)-o9Ub3~W>!IL4W#AkVE-0?rKwN}TrqwKQPG8%wB^pxfH6V|*LiF2 z;sbZ;@lTyLhrQ7hW}WUCm6{}d5&?o0?-K7*ro}qJ(X6$Xa6$5%ouanDYqp%@7q4iI zT|j0Bzp6d3DL?)Q7>OAGahzIC#CzhTC#i-^@89kbAGD8P{Y#Mzri?yGeI|e00|lxw4cf6a#`;rx;cvNp|Fqz!iNx7R=RxFz51e2v>QX9$_VkxrOrrAZj$q zPjC@U*9s}QscnC927*?#`?3fqHT4BLv-Ga8r!H9>=Y>3xTED1X!LgipnlE>91*fBuj|BSnZ!`~MFXgV;*>LJNX#470GJ?@B z(l4L&B{Nlinb^Jj#h+t#Ey$4*FBrc30xB^gId6w}U0!*vCELo`l@hFCeMRum8)){e z15fSQvK*c7p;;*edj%NXtT&zHz|ce3S}f~*ch~T8y>L~pCGE%f6qfpN5D;%(-U(mz zy^xOd*CE4BO-z@#*K7qDiWU|vjI+@M#Z>X98X<11ca#chMM?UVJq@3oWNlHYU=pg- zVb(!vHrTg>272PEiH^pVBYWNY2GSG6Zr!9o1f3dNrM2n`dA3O95L(F++UlQX6C52u zh>Bdg&f3G5=pPj3Xqo2YAC>d*LV6km?5yjXC8XWsO_*CdT-BwP@mgsiif1zc_T3OQ z`2ccM+0L(dIL>wOHZjU!u=IK=omQh^rBGfmQhW>e8%AuY@UyPj&M$MMWjskHOp6)6 zCf^t8?6AGFwej%vkfBCKabKjiaI-{<+g6by&G~g${g<)?FR%sgO%FDb+9Fr|M6TNK zKk<|oo`xb7R?FTS?XypJhf}2E7o>do&_MX}N3sb_IrBlg&B{9O@hR z8i8L`Q<0|c-rYOpL$KewJocU-yf-UJQ zl1HovhLbhE@~iqtu!!@j+)|yr*Xx_)a>RuYqQ&XmwTW#6-;Z1|bB5F%*HYhthHp!r zlwTzu$4&&&9wl6-*Y4!b_kQtz=zq-lDu0nPaMn$#lym-5gKk+i=^jb%;3?#`bfres zIEw_7n^Q4H?kX&&-5Gv}6=otHq4jf|gx%jS0ki~@d&Fs@tXOH~*6ohbbrg2mD@12C z4fkQ&kUdZ)uuV*9xTdniYHMnVpYlU3I%I_qkwI3K3Zgy=?1ix*q$8C)fkp&DbtsA) z{rr4D3}OG87X&I_Fop^55yppJN(L)jrN`9ut#`6)YLOG9TIWi)rb%T+*J|&kPlkn* zljs4~8lcbJNkA4B=2!}CrCiBUg4OxGPD_$}_|jaa+i)_KrvZ^WRIlw`1};3Pk^X1H zG{_$eYuDz@kp>5fZeI2&iGpFTVlv`YoMQHBTmF(2H)=ONA~ z5NwFfS~BFoK)<(0PyQ94JM{@OQvt`=9v6}PxK!a+YM>5aLlKJDjKM`zX8dag3!avo z6=w@{?GrOkRGJggpbU-OSRXg~S(_j5(0W#dR~j{=S2K-nN?{3RXVDujJxIIG4#<|* zzc)sWBdtJ%WW}-4&?i!7-K$}r+gi>XhtdQg^ z>2t*tmUf^<0mOkax`H7fsjEwi%~$)>4TD(hq z6@sszL!g6L4xhQ>{={oOP<<=wfmQCO6g3}U7fvnVZBF1eX`w5wUXs)XlW8h=$uM#cc*RDHP zu>@Hpv{GwV6(s^Y^c8WFcc(^`(TEmzd5#B3$7F?T$}G%AVx3fV661mKNSZJEx>fEH z1_wEJVPwa(GyXRK;$@=kX2AKJY~ak7R54ioUJ!HCx8+)}-^^^8Wg3Eze&dHt*-Lj} zHB2t%QLZ4oTd~FIFDW+2ni?o)I#F!uC5BOf7XtYhiq`n4`qeVc^JI zHu$bCy{(~^Fr#c{D1@YubjV|oBzD77bxf9p)!9L?P>Dzr3|vX4dk~Gyw9|_RkE26G zM6Ab6ekig3Mw(yeXUtm zofD)&&%A5tTuhQq7D$Sxb8hnRirkWpUWPijzkpbjUdCH8m>xsz`S7q}UBL+*!&&HW z?B@}Di%@Co>6~9*r2iR$9R4f3zwX8z=2=Q-SiudvIz(V$pr(_eB};91dJ2?f6_4wf zW}ec3ngXgmzE=7QHZQcmuz!_oJ$WO>t~cNA)W zd4hcwx1Y2}2Jgx~TDXqFQbr}=IYTegGKbw6S$5DVSX0GMslJBchN;d#O+i2D`(s!g zPg~+>UH>BG{pDTAFZUt2jwql!;+s)`dqW!xR0Ao#L@9k8xW8sUL&dV*eRdsXcjUf?C5bIdJN2N6( zN#!P+WRV8!-Hou;KkC>6(8rm^+f5lc76Yi6k=EYA;)fF6hkKjde=zNG_F{ZONNk_>WHza3HG#<)MXt~L%DLiq*BQ;9GB_b-_<~T4&R8j9HOe0d|!jO?ql6;6$KToszmxoDULp4@gC>=q!dt8lrOugJ& z{)nu;Mae}%DN3){(Non>XLJf~z+OW94KSmGQOM{M;7z5mw=$0TGz=gx9+GwkZ)%K! zWHFRY3b{vNg<>Rz&7mduMiI4y!-fhCq9?aAqtQv(U^AbMYh_RKp@fU~A0_8IviO0; zZyIt)x(pGHK4z;M=G%&MF}cmBv6Wi>0qXXP~DZ{G+@8C zCvL0WsyvnW+i!>v{wOIkt8^PoDsYrt@u`6yHc2(g#EE6E{`!y*Q^VIUn`~IYjbVj< z0dY(5D^#SCB?J=wcxH-=GBC=^Z`GR$l*B-+t&m3&F z*DbEj70#3pd%t4hS;7q&9!8$`ajZpP*=)y5l5}Z$eh*(8C#M~}No#51%#`glvilQ- zBYg&}UF^x)ct9;CL!4@}NR-X?K!hg`(Lusx78y(^9p(JjEI;j@`oeIl*~A$R{*|$O z1Vq!Gs-@#VwFi7e!ia@xyNGAjMlpJnCHJ)#d3~6C-&lpxlsJYqot`3%f~u8!EsXD? zn%RB_XtL@cS~w*6JVcHXE*7vD~74o33x1skbB5vFu1UJw)LY;Lqx0>WGfI z?k=*Tykf&k)D<_Q(T+T4p!YE)Y{%WCKywl#C0?N2wmv104`~7=yXIkCW+%>M&SRAx z$muWM&U+oEtlQ*py3*>NJcLreqV2t;R7wt&D*)R1Wq>%ZXqi^+b78m+U{Yv4eV2|u z3wN+n!?v~?hg-QTQ`iZD)?L;>+96DjWZFJ(@rU=UD`u~#_#DmqEJafdm&--smO;-i zYBXV|K%*=8-ZYl?gH-F8#-1;Leb@oA&*^&F2AZ=Qi{KVJ^mgV+?&9`^pnpX z3#nAA!m`U@=Qh%oZ>9H4`Z*MKRjiYA`kWwNTXkf(mcF2fi|`9W50tkKR9+HE{)k#- z(^LzbagQQ~*cUnS=?EBw>xSFZDasr7%FSPyopSuLoQ8b#J(Yb>oCGmPY_m8?BhARGf2;up(9xe(B%h@xv!Z$ZtJF z(Znr63A+e~0=%>1ui(5sz7dqYAaob925KmeD_A4h!ka)*5Ne9T-f=)TjvSyjhN+1L zPf=6$k=JG*A&UB$EQ9=z6sS?mHxQ-s)J#u9CFlWz)vFEhEIU4Ud)`RruxJWNt^kSg z`;=~}`9fO(V*|=i%pnUU1!ubgR*R}?3S}W89+;iUtCZjkk=j10 zd(L4NQ55%Al0~0GCv8-}?Bz`@r%YBlm(O=<*JQRRDg3}SSp{N9LykJ48XXGV{tAZ>)M;2^oSVYjeR;>TZfm#bWP` z-O|k+m5niYtRgT@JS@Od4C&MP3oF?B_jbU#G$xr=3c9}%l(w-N8p{ zd||BVixbkDimG`P1`FvT)rF%DxuydgZ-huBmx|LX(H4i4I#M{|z}2;^dc7v+_VVa= zA&Q45a=U3PX8jHMX6*7la&Dp%<%;d(G^!0yNU9|&!psktR6?)~N-fVYz+rA2 zft{*+L%h8W_yKIs!x8>E{vik%@=IrLZGUn|`Sn5k0wg>QItCXuvnWwaFaZE%!?52x zFwWUw--}F+d+D4=h&bc+6!U?)e$Ysl(XK5yQj_JB%~)ai5U-I-h3*(21XItE$}k>r%`0dcxuen!( zuXYqQyHlj#Q@bV!#*ZxuK3)6vP57{Ur7wvQPfRO?%IEN{i7aw0Bw76k1OlnZ_^DF_ z9$M-V4_r)cfV!-%DN~g%4#&Z^9r$4yTP{+pwe=7f2Ql(!BTjOW@;3rG-66P@+0(Zz z{m+y6)m7@~^jG3O1IQN!u=niUg*_g$CvpPm9#T`q?v**dg_X&BziP?^iY6-HT0fk( zgkr?{BIj7h((j%K%9m@Ha8@g#QG;f=(2FfXCzO`G zcUJU?Up_Guc^!;?xSRcQUz)3Ki8YcVTN4nbL+WY!? zoh^Bzc9OgK_17XPGmJ=|eJIpQ=PzvoP{~@!=KkW3GJPZtDK?Rk)D>aQyFv3>ltU1T zmwev~OW%tIT;WnZ?t!U7B$T9r8x>+BZRuRV8Y6V`bZ#iGA%ID^5^0QpS95SF`GNTn zaD7agvp2o^EM6l-&Ht?jNeTs`+-q#~aX$B(NT zT(!8GF=Fr+Lc0|~H>j`#{B!&!9NGPjT*+IMIER@TI*3&|#zF(dfsQtzFpRfe1!yrB zQa%Ype8{pKJ-x3zOT58*ogR)?=$0WpwZv1QQbQV7Sdd9=*zE`EnE)P;wyHp*;^9=E zRzO`K4Nr#@Z5RjzJ9kke=0b6sbO_I-rD3XHS@QkD$1fB!?G4XyT~Gb#)ocCy=07PW zj!0WpI7GvEf^DH}9yT##W+YpYXf33N8#~P)@~CFZH5ZBqx4ve^74aa^_bEb~nYImx zb+@opu&M*duT2C%7W3lG^n*OsC_A)??>c#ULlZ_|1VqZ$^|m4pve3X%C(SO{F{ApV zd5VTT;M64e4d07)Kf`f<00aR<>Tli0gCm*&l8o?%EQqSlpi!4+YU?d!QMG_vzC{ zO`X38=J=wroj>5HtJgrPt7Ye?FR`Dp2EgauzI7Nq^AMu-s(Q}Hl@8+0F_FwKU5f-g1_yMv z0&(6u1bz6PK8@z-Oc$A?Wi}rQsne_Swvj-?gr{VXxTHjFZw&k~I0K#k4^wZ!6$cY6 zYz~7nxH|-bGlOdg3+~HmB{q}sjKcLU)?vk$V zs($LyTZ6nIPcY=10N4sVTMRcRw}ywKeeZ>81f(OXTW0?ypC9yex3;^dYL+#ibEmyI zk5C2J{6KAf#(7ieuCJ3T`Xi*2(BTFu#RYm#YRBaVu((vYmvmQtN@lZJ2;A6AM}^(a z+V$UtUZ9S^QH@{Tu5_fNTZj6Lp}IDhz~{_Ml6al#v)#hzTs=lX&OAD2_I7e$oc`J# zHRGz&*NuxM3=MPxhLd)^AY|Rtf&}m!AW7bBSN`plw{^L-yG7@p0#2o{-llE;tqp#_ zycWoK>|HY^XxHB|QFcgs>ts&4d$Zrpa;IdF57j*wmcFp@R*OWAYxpir{r6`_I@{fX z9sPwpg%KT_Tgmy6c^AEQIF}i?sp>do$Ajg<4JB85*CY%0Ab+J%8keoj1Y|4u{uqMA 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 I-uVAb0C5F+Pyhe` literal 0 HcmV?d00001 diff --git a/apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg b/apps/website/public/blog/external-players/playback-diagnostics-v0-22.jpg new file mode 100644 index 0000000000000000000000000000000000000000..40ed1b46d34ac031b866c6eb0d65b71c1da908f3 GIT binary patch literal 53543 zcmeFZ1zc6zwm7^Iq(MTE?vUl_ zx%d0M``)|X_y5l|_ug~O7-P&aN6c7j-%j5y0Z^pGB*g#_5C8xK_yXL{0fYgt(9kf@ z_hDgRVBp|j;SnDqAtE3kVxyryc!-ZfNPv%nhet$4Pf0{VONxj0n1hOzk%^U+m5`E) zkCT~~o`se94hRGs92_D7A{G)77Bev(G4ucHaQhB`3JWQD9{>qK0=S0?0f`E6+X27@ z6L}Bvj@;h^1k^o9==(6>LTrd{;rAkNr|v;Q-M^g$AVPxMp+KU5a}HVo02K0jPyeT) zAefwcC^BLc);_p6OS9wbY@wk*pxX50e^LVUbasNG2OOs&qgTkh04Oh+K$NjV8*r05 zYswQ`3g9?iuRC5JG8j1!7CE@bHzg1_!u4UueOb@-;DP!uSH&oA3-1=*O;xt7aL&)s z=Fpzr*sk=yJAxqdZQ2X|N&%OE|*=*AAQ}Y4O06FI_>65-%@>HkTA!P@bxnJNLPUb9oABZ+?8L z^H8-Y@lLS7fm=AqF8Rnu1}BhZYT`l^EaKk-0!-1eJI}fwz=79M&9SjiEzU#Zg!vu? z>{M!%tR@CBOYGKVFo#8Ml8XnBt0EI8)fbkf#a_Ek6|{|QK2kpWPVxoI{Bx5~R}Cbh zltlf8R^8_gkz1FqcmV*$WU*oOVx(Eo^v6A_GJpWTMF zX-vJ@?nHU7o`?+A&P;xp+!|@fx{Ho!&wQSFpWJ{cniptX-8lY3F>Mh7^}F8k%0DHl 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 XJ;YZQ-oGkw-(UOJRDoXodE!3+esRG- literal 0 HcmV?d00001 diff --git a/apps/website/src/content/blog/why-external-players-help.mdx b/apps/website/src/content/blog/why-external-players-help.mdx new file mode 100644 index 000000000..ca30134ec --- /dev/null +++ b/apps/website/src/content/blog/why-external-players-help.mdx @@ -0,0 +1,176 @@ +--- +title: Why some streams need an external player +description: "Why some IPTV streams do not play in IPTVnator's built-in browser players, and when MPV, VLC, or IINA is the right workaround." +featured: false +pubDate: 2026-05-14 +author: 4gray +heroImage: /iptvnator/screenshots/external-player-support-mpv.webp +tags: + - playback + - external-players + - diagnostics + - mpv + - vlc + - codecs +draft: false +--- + +import Alert from '../../components/blog/Alert.astro'; +import BlogImageSlider from '../../components/blog/BlogImageSlider.astro'; +import LinkCards from '../../components/blog/LinkCards.astro'; + +Some IPTV streams work perfectly in one player and fail in another. That can be confusing, especially when the same playlist opens in VLC, MPV, Kodi, or a TV app but does not start inside IPTVnator's built-in players. + +The short version: IPTVnator's internal players run inside Electron/Chromium, so they inherit browser media support and browser security rules. That makes them convenient and integrated, but it also means they cannot play every codec, container, protocol, DRM setup, or stream variant that a native media player can handle. + + + + + IPTVnator does not provide, sell, host, or redistribute any playlists, + channels, subscriptions, portals, EPG data, or streams. It is only a media + player for sources provided by the user. Please use it only with content you + are authorized to access, and do not share private playlist URLs or + credentials in public bug reports. + + +## Why the built-in players may fail + +The built-in Video.js, ArtPlayer, and HTML5-based players are browser-based. They are great for many HLS and web-friendly streams, but they are still limited by what Chromium can decode and what the browser playback stack allows. + +Common cases where this matters: + +- unsupported audio codecs such as AC3 or MPEG audio in some streams; +- video formats such as MPEG-TS, HEVC/H.265, high-bitrate 4K, or unusual HLS variants; +- DASH/MPD, DRM-protected, or provider-specific streams; +- streams that require behavior normally handled by a full native media player; +- platform-specific Chromium/Electron differences between Windows, Linux, macOS, and the web app. + +When this happens, the playlist itself may be valid and the stream may still work in VLC or MPV. That does not always mean IPTVnator can make the internal browser player decode it. + +## New in version 0.22: playback diagnostics + +Starting with IPTVnator 0.22, the app shows a clearer diagnostic screen when a stream cannot be played by the selected built-in browser player. + +Instead of only showing a generic media error, IPTVnator can explain the likely reason, for example that the stream codec is probably unsupported by the Chromium player. The diagnostics view can also show technical details such as the selected player, detected container, MIME type, native error code, and native browser error message. + +When the problem looks like a browser-player limitation, IPTVnator offers quick actions to open the same stream in external players such as MPV or VLC, plus an option to copy the stream URL. This makes it easier to understand whether the issue is a playlist/source problem, an IPTVnator bug, or simply a codec/container that needs a native player. + + + +This diagnostic flow was introduced in [PR #939](https://github.com/4gray/iptvnator/pull/939). + +## The recommended workaround: use an external player + +This is exactly why IPTVnator supports external players. + +If a stream does not work in the built-in player, try opening it with: + +- **MPV** — lightweight, powerful, and very codec-friendly; +- **VLC** — widely installed and handles many IPTV edge cases; +- **IINA** — a macOS-friendly player based on MPV. + +External players use their own native playback engines instead of Chromium's media stack. For many streams, that is the simplest and most reliable solution. + + + +## Another benefit: multiple streams at once + +External players are also useful when you want more control over your desktop. + +Because MPV, VLC, or IINA open their own windows, you can start multiple streams in parallel and arrange them however you like: side by side, on another monitor, in a corner of the screen, or across a multi-display setup. + +That workflow is especially useful for monitoring several live channels at the same time. + +## When should I report an issue? + +Please still report playback problems when they look like IPTVnator bugs, but include enough detail to separate app issues from stream compatibility limits: + +- IPTVnator version; +- operating system; +- selected player: Video.js, ArtPlayer, HTML5, MPV, VLC, IINA, or embedded MPV; +- stream type if known: HLS, MPEG-TS, DASH/MPD, live TV, VOD, series; +- whether the same stream works in VLC, MPV, IINA, or another player; +- any visible error message. + + + If your playlist URL contains credentials, tokens, a private portal address, + or subscription details, do not paste it into a public GitHub issue. Share a + public sample stream when possible, or describe the format and error + instead. + + +## Useful links + + + +## Summary + +IPTVnator will keep improving the built-in playback experience, but no Electron/Chromium-based player can guarantee support for every IPTV stream in the wild. + +If a stream fails internally but works in VLC, MPV, or IINA, using an external player is not a hack — it is one of the intended playback paths in IPTVnator. From 738397c9ed07c2feeac84e253c3291e35c215705 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 20 May 2026 18:02:19 +0200 Subject: [PATCH 03/21] feat(website): add giscus blog comments --- apps/website/README.md | 28 +++++++++++++++ apps/website/project.json | 7 ++++ .../src/components/GiscusComments.astro | 27 ++++++++++++++ apps/website/src/layouts/BlogPost.astro | 3 ++ .../testing/website-giscus-comments.test.mjs | 36 +++++++++++++++++++ 5 files changed, 101 insertions(+) create mode 100644 apps/website/README.md create mode 100644 apps/website/src/components/GiscusComments.astro create mode 100644 tools/testing/website-giscus-comments.test.mjs diff --git a/apps/website/README.md b/apps/website/README.md new file mode 100644 index 000000000..031816021 --- /dev/null +++ b/apps/website/README.md @@ -0,0 +1,28 @@ +# IPTVnator Website + +The website is an Astro static site deployed to GitHub Pages at `https://4gray.github.io/iptvnator/`. + +## Blog Comments + +Blog posts render Giscus comments from `apps/website/src/components/GiscusComments.astro`. +Giscus stores comments in GitHub Discussions for `4gray/iptvnator` and maps each page to a discussion by `pathname`, including the GitHub Pages base path such as `/iptvnator/blog/why-external-players-help/`. + +The embed is wired to the dedicated `Blog comments` discussion category: + +- Repository id: `MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=` +- Category id: `DIC_kwDODLUX8s4C9eBJ` +- Mapping: `pathname` +- Theme: `transparent_dark` + +If the category is recreated, query the new category id: + +```bash +gh api graphql \ + -f owner=4gray \ + -f name=iptvnator \ + -f query='query($owner:String!, $name:String!) { repository(owner:$owner, name:$name) { discussionCategories(first:25) { nodes { id name slug isAnswerable } } } }' +``` + +Then update `data-category-id` in `GiscusComments.astro`. + +Moderation happens in GitHub Discussions. Maintainers can hide, delete, lock, or move discussions and comments from the repository Discussions UI. diff --git a/apps/website/project.json b/apps/website/project.json index d4ebcea22..5b07c6306 100644 --- a/apps/website/project.json +++ b/apps/website/project.json @@ -14,6 +14,13 @@ "cwd": "apps/website" } }, + "test": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "node --test tools/testing/website-giscus-comments.test.mjs" + } + }, "serve": { "executor": "nx:run-commands", "options": { diff --git a/apps/website/src/components/GiscusComments.astro b/apps/website/src/components/GiscusComments.astro new file mode 100644 index 000000000..614601812 --- /dev/null +++ b/apps/website/src/components/GiscusComments.astro @@ -0,0 +1,27 @@ +

+
+

+ Comments +

+
+ +
+ +
diff --git a/apps/website/src/layouts/BlogPost.astro b/apps/website/src/layouts/BlogPost.astro index 27475b8dc..1a0dcbb45 100644 --- a/apps/website/src/layouts/BlogPost.astro +++ b/apps/website/src/layouts/BlogPost.astro @@ -1,5 +1,6 @@ --- import BaseLayout from './BaseLayout.astro'; +import GiscusComments from '../components/GiscusComments.astro'; interface Props { title: string; @@ -150,6 +151,8 @@ const blogPostSchema = { > + + diff --git a/tools/testing/website-giscus-comments.test.mjs b/tools/testing/website-giscus-comments.test.mjs new file mode 100644 index 000000000..585507acc --- /dev/null +++ b/tools/testing/website-giscus-comments.test.mjs @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +const postHtmlPath = new URL('../../dist/apps/website/blog/why-external-players-help/index.html', import.meta.url); + +const expectedGiscusAttributes = { + src: 'https://giscus.app/client.js', + 'data-repo': '4gray/iptvnator', + 'data-repo-id': 'MDEwOlJlcG9zaXRvcnkyMTMxOTQ3Mzg=', + 'data-category': 'Blog comments', + 'data-category-id': 'DIC_kwDODLUX8s4C9eBJ', + 'data-mapping': 'pathname', + 'data-strict': '1', + 'data-reactions-enabled': '1', + 'data-emit-metadata': '0', + 'data-input-position': 'bottom', + 'data-theme': 'transparent_dark', + 'data-lang': 'en', +}; + +test('published blog posts include the Giscus comments embed configuration', async () => { + const html = await readFile(postHtmlPath, 'utf8'); + const scriptMatch = html.match(/]*giscus\.app\/client\.js[^>]*>/); + + assert.ok(scriptMatch, 'Expected the blog post HTML to include the Giscus client script.'); + + const scriptTag = scriptMatch[0]; + for (const [name, value] of Object.entries(expectedGiscusAttributes)) { + assert.match(scriptTag, new RegExp(`${name}="${value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`)); + } + + assert.match(scriptTag, /\bloading="lazy"/); + assert.match(scriptTag, /\bcrossorigin="anonymous"/); + assert.match(scriptTag, /\basync/); +}); From fd594470644f3b57fdaba9e5ebd9bad94e47d36d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 21 May 2026 09:30:57 +0200 Subject: [PATCH 04/21] docs(docker): restore self-hosted PWA compose flow * docs(docker): restore self-hosted PWA compose flow * fix(docker): supervise self-hosted runtime processes * fix(docker): exit cleanly on container stop --- README.md | 18 +++- docker/Dockerfile | 42 +++++--- docker/README.md | 92 ++++++++++++++-- docker/docker-compose.yml | 27 ++--- docker/docker-entrypoint.sh | 109 +++++++++++++++++++ docker/nginx.conf | 35 +++++-- docs/architecture/pwa-self-hosted.md | 150 +++++++++++++++++++++++++++ 7 files changed, 422 insertions(+), 51 deletions(-) create mode 100644 docker/docker-entrypoint.sh create mode 100644 docs/architecture/pwa-self-hosted.md diff --git a/README.md b/README.md index a7d1c594b..85497b279 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ The application is a cross-platform, open-source project built with Electron and - Polish - Custom "User Agent" header configuration for playlists - Light and Dark themes -- Docker version available for self-hosting +- Docker image available for self-hosting the PWA and web backend together ## Screenshots: @@ -79,6 +79,22 @@ The application is a cross-platform, open-source project built with Electron and _Note: First version of the application which was developed as a PWA is available in an extra git branch._ +## Self-hosted PWA + +The Docker setup builds the Angular PWA and the monorepo web backend into one +image. The backend handles remote M3U parsing plus Xtream and Stalker proxy +requests under `/api`, so a separate `4gray/iptvnator-backend` container is not +required for the default self-hosted flow. + +```bash +docker compose -f docker/docker-compose.yml up --build -d +``` + +The application is available at . See +[`docker/docker-compose.yml`](./docker/docker-compose.yml) for the ready-to-run +compose file and [`docker/README.md`](./docker/README.md) for environment +variables, reverse proxy notes, and build details. + ## Download Download the latest version of the application for macOS, Windows, and Linux from the [release page](https://github.com/4gray/iptvnator/releases). diff --git a/docker/Dockerfile b/docker/Dockerfile index 04fe1d685..240597b51 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,32 +1,40 @@ -# Stage 1 - build environment FROM node:22-alpine AS build RUN apk add --no-cache python3 make g++ git -# Create app directory WORKDIR /usr/src/app -# Swtich to node user -#RUN chown node:node ./ -#USER node - COPY .npmrc ./ COPY package.json pnpm-lock.yaml ./ +COPY patches ./patches -# Install app dependencies -RUN corepack enable && pnpm install --frozen-lockfile +RUN corepack enable && pnpm install --frozen-lockfile --ignore-scripts -# Copy all required files COPY . . -# Build the application -RUN pnpm run build:web +RUN pnpm nx build web --configuration=pwa +RUN pnpm nx build web-backend -# Stage 2 - the production environment -FROM nginx:stable-alpine +FROM node:22-alpine -# Copy artifacts and nignx.conf -COPY --from=build /usr/src/app/dist/browser /usr/share/nginx/html -COPY --from=build /usr/src/app/docker/nginx.conf /etc/nginx/conf.d/default.conf +RUN apk add --no-cache gettext nginx -CMD sed -i "s#http://localhost:3333#$BACKEND_URL#g" /usr/share/nginx/html/main.js && nginx -g 'daemon off;' +WORKDIR /opt/iptvnator + +ENV PORT=3000 +ENV BACKEND_URL=/api +ENV CLIENT_URL=http://localhost:4333 + +COPY --from=build /usr/src/app/dist/apps/web /usr/share/nginx/html +COPY --from=build /usr/src/app/dist/apps/web-backend ./web-backend +COPY docker/nginx.conf /etc/nginx/http.d/default.conf.template +COPY docker/docker-entrypoint.sh /usr/local/bin/iptvnator-entrypoint + +RUN chmod +x /usr/local/bin/iptvnator-entrypoint + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1 + +CMD ["iptvnator-entrypoint"] diff --git a/docker/README.md b/docker/README.md index 120944a0f..bc585c047 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,20 +1,90 @@ -# Self-hosted version of IPTVnator +# Self-hosted IPTVnator -You can deploy and run the PWA version of IPTVnator on your own machine with `docker-compose` using the following command: +The self-hosted image contains both pieces required for the browser PWA: - $ cd docker - $ docker-compose up -d +- Angular PWA static files served by nginx +- The monorepo `web-backend` Express app proxied under `/api` -This command will launch the frontend and backend applications. By default, the application will be available at: http://localhost:4333/. The ports can be configured in the `docker-compose.yml` file. +The historical standalone `4gray/iptvnator-backend` image is no longer needed +for the default Docker deployment. -The web backend proxy accepts only `http` and `https` provider URLs. The PWA first registers provider URLs through `/provider-targets`, then uses the returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend blocks loopback, private, link-local, and reserved network targets by default to avoid exposing the self-hosted server as a generic internal-network fetcher. If you intentionally need to test against local mock servers or LAN-only IPTV sources, set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` on the backend container and avoid exposing that instance to untrusted users. +## Run With Docker Compose -For providers that use private certificate authorities, keep TLS validation enabled and pass the CA bundle to Node with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`. +From the repository root: -## Build frontend +```bash +docker compose -f docker/docker-compose.yml up --build -d +``` - $ docker build -t 4gray/iptvnator -f docker/Dockerfile . +The ready-to-run compose file is [`docker-compose.yml`](./docker-compose.yml). +By default the app is available at . No additional +environment variables, backend repository checkout, or separate backend +container are required for the default local deployment. -## Build backend +## Build The Image -You can find the backend app with all instructions in a separate GitHub repository - https://github.com/4gray/iptvnator-backend +```bash +docker build -t 4gray/iptvnator -f docker/Dockerfile . +``` + +The image build runs: + +```bash +pnpm nx build web --configuration=pwa +pnpm nx build web-backend +``` + +## Runtime Configuration + +The container writes `/usr/share/nginx/html/assets/app-config.js` on startup. +That file sets `window.__IPTVNATOR_CONFIG__.BACKEND_URL`, which the PWA reads +before it creates `PwaService`. + +These variables are supported by the Docker image. The compose file sets the +safe local defaults shown below. + +| Variable | Default | Purpose | +| ---------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `BACKEND_URL` | `/api` | Browser-facing backend URL used by the PWA. Keep `/api` for the bundled nginx proxy. | +| `CLIENT_URL` | `http://localhost:4333` | Allowed browser origin for backend CORS. Use the public URL when hosting behind a reverse proxy. Multiple origins can be comma-separated. | +| `PORT` | `3000` | Internal Express backend port. nginx proxy config is rendered from the template to match it at startup. | +| `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` | `0` | Set to `1` or `true` only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints. | +| `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. | + +The web backend proxy accepts only `http` and `https` provider URLs. The PWA +first registers provider URLs through `/provider-targets`, then uses the +returned `targetId` for playlist, Xtream, and Stalker proxy calls. The backend +blocks loopback, private, link-local, and reserved network targets by default so +a publicly exposed instance cannot be used as a generic internal-network +fetcher. If you enable `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1`, keep the +instance restricted to trusted users. + +For providers that use private certificate authorities, keep TLS validation +enabled and pass the CA bundle to Node: + +```yaml +services: + iptvnator: + volumes: + - ./ca.pem:/etc/ssl/private/provider-ca.pem:ro + environment: + NODE_EXTRA_CA_CERTS: /etc/ssl/private/provider-ca.pem +``` + +The entrypoint renders the nginx config from `docker/nginx.conf`, starts the +backend, waits for `/health`, and only then starts nginx. The nginx config +serves the PWA with SPA fallback, avoids caching `assets/app-config.js`, and +proxies `/api/*` to the internal backend. The Dockerfile and compose file both +define a health check against `/api/health`. If nginx or the backend exits +after startup, the entrypoint exits the container so Docker Compose can apply +the `restart: unless-stopped` policy. + +## Local Validation + +```bash +pnpm nx test web-backend +pnpm nx build web --configuration=pwa --skip-nx-cache +pnpm nx build web-backend +pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted +docker compose -f docker/docker-compose.yml config +``` diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d36ab1c08..4e03f53bc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -1,18 +1,21 @@ --- services: - backend: - image: 4gray/iptvnator-backend:latest - ports: - - "7333:3000" - environment: - - CLIENT_URL=http://localhost:4333 - # this one should match with the address and port in frontend CLIENT_URL env - - frontend: + iptvnator: image: 4gray/iptvnator:latest + build: + context: .. + dockerfile: docker/Dockerfile + restart: unless-stopped ports: - "4333:80" environment: - - BACKEND_URL=http://localhost:7333 - # this one should match with the address of the backend service - + BACKEND_URL: /api + CLIENT_URL: http://localhost:4333 + PORT: "3000" + IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: "0" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 000000000..2ca44aeda --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,109 @@ +#!/bin/sh +set -eu + +export PORT="${PORT:-3000}" +export BACKEND_URL="${BACKEND_URL:-/api}" + +envsubst '${PORT}' < /etc/nginx/http.d/default.conf.template > /etc/nginx/http.d/default.conf + +node <<'NODE' +const fs = require('node:fs'); + +fs.writeFileSync( + '/usr/share/nginx/html/assets/app-config.js', + `window.__IPTVNATOR_CONFIG__ = ${JSON.stringify( + { BACKEND_URL: process.env.BACKEND_URL || '/api' }, + null, + 2 + )};\n` +); +NODE + +node /opt/iptvnator/web-backend/main.cjs & +BACKEND_PID=$! + +cleanup_done=0 +cleanup() { + if [ "$cleanup_done" -eq 1 ]; then + return + fi + + cleanup_done=1 + trap - INT TERM EXIT + + if [ -n "${NGINX_PID:-}" ]; then + kill "$NGINX_PID" 2>/dev/null || true + wait "$NGINX_PID" 2>/dev/null || true + fi + + if [ -n "${BACKEND_PID:-}" ]; then + kill "$BACKEND_PID" 2>/dev/null || true + wait "$BACKEND_PID" 2>/dev/null || true + fi +} + +handle_signal() { + cleanup + exit 0 +} + +process_is_running() { + if [ ! -r "/proc/$1/stat" ]; then + return 1 + fi + + state="$(awk '{ print $3 }' "/proc/$1/stat" 2>/dev/null || true)" + [ -n "$state" ] && [ "$state" != "Z" ] +} + +trap handle_signal INT TERM +trap cleanup EXIT + +backend_ready=0 + +for _ in $(seq 1 30); do + if wget -qO- "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then + backend_ready=1 + break + fi + + if ! process_is_running "$BACKEND_PID"; then + set +e + wait "$BACKEND_PID" + BACKEND_STATUS=$? + set -e + exit "$BACKEND_STATUS" + fi + + sleep 1 +done + +if [ "$backend_ready" -ne 1 ]; then + echo "IPTVnator web backend did not become healthy on port ${PORT}." + exit 1 +fi + +nginx -g 'daemon off;' & +NGINX_PID=$! + +while :; do + if ! process_is_running "$BACKEND_PID"; then + set +e + wait "$BACKEND_PID" + EXIT_STATUS=$? + set -e + echo "IPTVnator web backend exited with status ${EXIT_STATUS}." + exit "$EXIT_STATUS" + fi + + if ! process_is_running "$NGINX_PID"; then + set +e + wait "$NGINX_PID" + EXIT_STATUS=$? + set -e + echo "nginx exited with status ${EXIT_STATUS}." + exit "$EXIT_STATUS" + fi + + sleep 1 +done diff --git a/docker/nginx.conf b/docker/nginx.conf index 3eacd9674..1abb8aea8 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,11 +1,26 @@ server { - listen 80; - - location / { - root /usr/share/nginx/html; - index index.html index.htm; - try_files $uri $uri/ /index.html =404; - } - - include /etc/nginx/extra-conf.d/*.conf; -} \ No newline at end of file + listen 80; + listen [::]:80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:${PORT}/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /assets/app-config.js { + add_header Cache-Control "no-store"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md new file mode 100644 index 000000000..6920af21b --- /dev/null +++ b/docs/architecture/pwa-self-hosted.md @@ -0,0 +1,150 @@ +# PWA Self-hosted Architecture + +This document describes the browser PWA and self-hosted Docker path. + +## Ownership + +- `apps/web` owns the Angular browser UI and PWA service worker configuration. +- `apps/web-backend` owns the browser-only backend proxy for remote playlist, + Xtream, and Stalker requests. +- `docker/` owns the production self-hosted image that bundles the PWA and + `web-backend` into one container. + +The old external `4gray/iptvnator-backend` repository is not required for the +default self-hosted deployment. Sync behavior from that repository only when a +change intentionally restores or imports missing backend capabilities. + +## Runtime Backend Configuration + +The PWA reads `window.__IPTVNATOR_CONFIG__.BACKEND_URL` through +`apps/web/src/app/services/runtime-config.ts`. The static placeholder lives at +`apps/web/src/assets/app-config.js` and keeps hosted builds working without +Docker-specific values. + +The Docker entrypoint rewrites `assets/app-config.js` at container startup. +`ngsw-config.json` explicitly excludes this file from Angular service worker +asset hashing so a runtime rewrite does not break cache validation. + +## Service Worker Build + +Use the PWA build configuration for browser deployments: + +```bash +pnpm nx build web --configuration=pwa +``` + +The build must emit these files in `dist/apps/web`: + +- `ngsw-worker.js` +- `ngsw.json` +- `safety-worker.js` +- `worker-basic.min.js` + +`web:serve-static` serves `dist/apps/web` and builds with `web:build:pwa`, so it +exercises the same output layout as Docker. If Nx daemon state returns stale +service worker outputs while changing build options, run: + +```bash +pnpm nx reset +pnpm nx build web --configuration=pwa --skip-nx-cache +``` + +## Web Backend + +The current self-hosted PWA uses these `apps/web-backend` routes: + +- `GET /health` +- `GET /config.js` +- `POST /provider-targets` with `{ "url": "" }` +- `GET /parse?targetId=` +- `GET /xtream?targetId=&username=&password=

&action=` +- `GET /stalker?targetId=&macAddress=&action=` + +The PWA continues to use `PwaService`; only the backend base URL is resolved at +runtime. Electron routes remain owned by the Electron backend and preload +bridge. + +EPG/XMLTV is not a supported self-hosted PWA capability yet. Do not document it +as part of the Docker user flow or use it as the readiness signal for this path. + +Provider URLs are registered before proxy calls so the proxy endpoints do not +accept raw target URLs in query strings. Registration validates the target URL +before any outbound request: + +- only `http:` and `https:` provider URLs are accepted +- URL credentials are rejected +- loopback, private, link-local, and reserved network targets are blocked by + default +- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` explicitly enables trusted + local/LAN targets for development, mock servers, or private deployments + +Do not disable TLS certificate validation in the backend proxy. For private +certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`. + +## PWA Portal User Data + +Xtream favorites and recently viewed items use the browser-side +`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA +stores this user activity in localStorage: + +- `xtream-favorites` +- `xtream-recent-items` + +Entries should include a content snapshot when the item is added. Global +collection routes and the dashboard can then restore titles, posters, content +type, and category IDs after navigation or a page reload without relying on the +Electron SQLite content table. + +Shared collection services must not import `@iptvnator/portal/xtream/data-access` +directly. Use `XTREAM_COLLECTION_DATA_SOURCE` from +`@iptvnator/portal/shared/util` and bind it to `XTREAM_DATA_SOURCE` at the app +provider boundary (`apps/web/src/app/app.config.ts`). This keeps +`portal-shared-util` provider-neutral and avoids adding new Nx boundary cycles. + +## Docker Runtime + +The Docker image has two stages: + +1. Build stage installs dependencies and runs `web:pwa` plus `web-backend`. +2. Runtime stage uses `node:22-alpine` with nginx installed. nginx serves + `dist/apps/web` and proxies `/api/*` to the local Express backend. + The entrypoint renders the nginx config from a `${PORT}` template, starts the + backend, waits for `/health`, and then starts nginx. If either process exits + after startup, the entrypoint exits the container so the compose restart + policy can recover the service. + +Default runtime values: + +- `BACKEND_URL=/api` +- `CLIENT_URL=http://localhost:4333` +- `PORT=3000` +- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0` +- `NODE_EXTRA_CA_CERTS` unset + +When hosting behind another domain, set `CLIENT_URL` to the browser origin and +keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere. +Only set `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=1` when the self-hosted +instance is restricted to trusted users and intentionally needs private network +IPTV targets. For providers using private certificate authorities, mount the CA +bundle into the container and set `NODE_EXTRA_CA_CERTS` to that mounted path. + +## Validation + +Use the narrow validation ladder for self-hosted changes: + +```bash +pnpm nx test web-backend +pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts +pnpm nx build web --configuration=pwa --skip-nx-cache +pnpm nx build web-backend +pnpm nx run web-e2e:e2e -- --project=chromium --grep @self-hosted +docker compose -f docker/docker-compose.yml config +``` + +Run `docker build -t iptvnator:self-hosted-test -f docker/Dockerfile .` when a +Docker daemon is available. + +For manual Docker smoke testing, run the Xtream and Stalker mock servers plus a +small M3U fixture, then verify in the browser that M3U, Xtream, and Stalker can +add sources, play an item, toggle favorites, populate global favorites, +populate recently viewed, and appear on the dashboard rails. From 5d31c1d49af763acb2c40dad33714371481be6fe Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 21 May 2026 09:37:43 +0200 Subject: [PATCH 05/21] ci(docker): publish images after trusted events --- .github/workflows/docker.yml | 144 ++++++++++++++++++++++++++--------- docker/README.md | 23 ++++++ 2 files changed, 132 insertions(+), 35 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 74ba2dc4b..ed059fefc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,39 +1,113 @@ name: docker-build -on: [workflow_dispatch] +on: + pull_request: + paths: + - '.github/workflows/docker.yml' + - 'apps/web/**' + - 'apps/web-backend/**' + - 'docker/**' + - 'libs/**' + - 'package.json' + - 'pnpm-lock.yaml' + push: + branches: + - master + tags: + - 'v*' + workflow_dispatch: + inputs: + push: + description: 'Push the image to Docker Hub' + required: true + type: boolean + default: false + +permissions: + contents: read jobs: - build: - runs-on: ubuntu-latest - steps: - - - name: Checkout - uses: actions/checkout@v3 - - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Get version from package.json - id: package-version - run: echo "VERSION=$(cat package.json | jq -r .version)" >> $GITHUB_OUTPUT - - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build and push - uses: docker/build-push-action@v4 - with: - context: . - file: ./docker/Dockerfile - push: true - tags: | - 4gray/iptvnator:latest - 4gray/iptvnator:${{ github.sha }} - 4gray/iptvnator:${{ steps.package-version.outputs.VERSION }} - platforms: linux/amd64,linux/arm64 + build: + name: Build Docker image + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Prepare Docker metadata + id: docker-meta + shell: bash + run: | + set -euo pipefail + + version="$(node -p "require('./package.json').version")" + short_sha="${GITHUB_SHA::12}" + publish="false" + platforms="linux/amd64" + tags="4gray/iptvnator:pr-${{ github.event.pull_request.number || 'manual' }}" + + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == "refs/heads/master" ]]; then + publish="true" + platforms="linux/amd64,linux/arm64" + tags=$(cat <> "${GITHUB_OUTPUT}" + + - name: Login to Docker Hub + if: steps.docker-meta.outputs.publish == 'true' + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build Docker image + uses: docker/build-push-action@v7 + with: + context: . + file: ./docker/Dockerfile + push: ${{ steps.docker-meta.outputs.publish == 'true' }} + tags: ${{ steps.docker-meta.outputs.tags }} + platforms: ${{ steps.docker-meta.outputs.platforms }} + cache-from: type=gha + cache-to: type=gha,mode=max,ignore-error=true diff --git a/docker/README.md b/docker/README.md index bc585c047..7906c6fa8 100644 --- a/docker/README.md +++ b/docker/README.md @@ -34,6 +34,29 @@ pnpm nx build web --configuration=pwa pnpm nx build web-backend ``` +## Published Docker Tags + +Pull request builds validate the Dockerfile without pushing an image. Docker +Hub publishing happens only from trusted repository events. +Publishing requires the repository secrets `DOCKERHUB_USERNAME` and +`DOCKERHUB_TOKEN`; pull request builds and default manual runs do not use those +secrets. + +| Tag pattern | Published from | Use case | +| -------------------------- | ------------------------ | ------------------------------------------------------------------------- | +| `latest` | `master` pushes | Default self-hosted image for users who want the newest merged PWA build. | +| `-pwa` | `master` pushes | Latest PWA image for the current `package.json` version. | +| `-pwa-` | `master` pushes | Immutable PWA image for a specific merged commit within a version. | +| `sha-` | `master` pushes | Commit-addressable image, useful for rollback and support diagnostics. | +| `` / `v` | `v*` release tags | Release image aligned with a repository release tag. | +| `stable` | Stable `v*` release tags | Most recent non-prerelease tagged release image. | +| `manual-` | Manual runs with `push` | Explicit maintainer-triggered rebuilds outside normal publish events. | + +Use `latest` for the simplest self-hosted setup. Pin `sha-` or +`-pwa-` when you need reproducible deployments. Use release tags +when you want the Docker image to track a tagged IPTVnator release rather than +every merge to `master`. + ## Runtime Configuration The container writes `/usr/share/nginx/html/assets/app-config.js` on startup. From 401d434bce038649d7e4db2bbff5224d5c873db6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 11:19:04 +0300 Subject: [PATCH 06/21] test(pwa): cover self-hosted provider target flow --- apps/web-e2e/src/self-hosted.e2e.ts | 61 +++++++++++++++++++++++++ apps/xtream-mock-server/src/main.ts | 41 +++++++++++++---- docs/architecture/xtream-mock-server.md | 10 ++++ 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 0529f4baa..8a5abc5c4 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -48,6 +48,27 @@ async function addXtreamPortal(page: Page): Promise { await page.waitForURL(/xtreams.*vod/); } +async function addM3uPlaylist(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + + await setInputValue( + dialog.getByRole('textbox', { name: /Playlist URL/ }), + `${XTREAM_MOCK_SERVER}/playlist.m3u` + ); + await setInputValue( + dialog.getByRole('textbox', { name: 'Playlist title' }), + 'Self-hosted M3U' + ); + + await dialog + .getByRole('button', { name: 'Add playlist', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/playlists.*all/); +} + async function addStalkerPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -65,6 +86,27 @@ async function addStalkerPortal(page: Page): Promise { await page.waitForURL(/stalker.*vod/); } +function collectBackendRequests(page: Page, path: string): string[] { + const requests: string[] = []; + page.on('request', (request) => { + const requestUrl = request.url(); + if (requestUrl.startsWith(`${WEB_BACKEND_URL}${path}`)) { + requests.push(requestUrl); + } + }); + return requests; +} + +function expectRequestsUseTargetId(requests: string[], path: string): void { + expect(requests.length).toBeGreaterThan(0); + for (const requestUrl of requests) { + const url = new URL(requestUrl); + expect(url.pathname).toBe(path); + expect(url.searchParams.get('targetId')).toBeTruthy(); + expect(url.searchParams.has('url')).toBeFalsy(); + } +} + test.beforeEach(async ({ page, request }) => { await request.post(`${XTREAM_MOCK_SERVER}/reset`); await request.post(`${STALKER_MOCK_SERVER}/reset`); @@ -90,17 +132,36 @@ test('@self-hosted runtime config points PWA calls at the monorepo backend', asy test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { + const xtreamRequests = collectBackendRequests(page, '/xtream'); + await addXtreamPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(xtreamRequests, '/xtream'); +}); + +test('@self-hosted M3U URL loads through web-backend proxy', async ({ + page, +}) => { + const parseRequests = collectBackendRequests(page, '/parse'); + + await addM3uPlaylist(page); + + await expect(page.getByText('4 channels')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('1. Channel 1')).toBeVisible(); + await expect(page.getByText('4. HappyKids TV')).toBeVisible(); + expectRequestsUseTargetId(parseRequests, '/parse'); }); test('@self-hosted Stalker portal loads through web-backend proxy', async ({ page, }) => { + const stalkerRequests = collectBackendRequests(page, '/stalker'); + await addStalkerPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(stalkerRequests, '/stalker'); }); diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 34d8b8e18..648a15c34 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -9,6 +9,16 @@ import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js const app = express(); const PORT = parseInt(process.env['PORT'] ?? '3211', 10); +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; const marketingRasterAssetRoot = join( process.cwd(), 'apps/xtream-mock-server/public/marketing' @@ -28,6 +38,13 @@ app.post('/reset', (_req, res) => { res.json({ status: 'reset' }); }); +// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── +app.get('/playlist.m3u', (_req, res) => { + res.type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); +}); + // ─── Local fictional artwork for release screenshots ────────────────────────── app.get('/assets/marketing/:kind/:slug', (req, res) => { const kind = req.params['kind'] as @@ -46,18 +63,20 @@ app.get('/assets/marketing/:kind/:slug', (req, res) => { } const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); if (existsSync(rasterPath)) { - res - .type('image/png') + res.type('image/png') .set('Cache-Control', 'public, max-age=3600') .send(readFileSync(rasterPath)); return; } - res - .type('image/svg+xml') + res.type('image/svg+xml') .set('Cache-Control', 'public, max-age=3600') .send(renderMarketingAssetSvg(kind, slug, size)); }); @@ -149,8 +168,12 @@ server.on('error', (err: NodeJS.ErrnoException) => { server.listen(PORT, () => { console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`); - console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`); + console.log( + `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` + ); + console.log( + `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` + ); console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); }); @@ -173,4 +196,6 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 7e52683b2..a41f89880 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -95,6 +95,16 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### M3U fixture endpoint + +``` +GET /playlist.m3u +``` + +Returns a small deterministic four-channel playlist. The self-hosted PWA E2E +suite uses this endpoint to verify M3U URL imports through `apps/web-backend` +and the provider target registry. + ### Stream stub endpoints ``` From 89df1ada0a15128280e3412071d4d14cb6d93664 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 13:36:32 +0300 Subject: [PATCH 07/21] fix(docker): exclude agent worktrees from build context --- .dockerignore | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.dockerignore b/.dockerignore index b1254b53c..103a72d8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,13 +7,53 @@ .angular .electron .husky +.nx +.agent +.agents +.aider* +.amp +.augment +.claude +.codeium +.codex +.continue +.crush +.cursor +.entire +.gemini +.goose +.idea +.kiro +.opencode +.plans +.qodo +.roo +.serena +.supermaven +.tabnine +.windsurf +.zed +.worktrees +worktrees Dockerfile README.md dist e2e node_modules +**/node_modules screenshots test-results coverage build release +tmp +out-tsc +playwright-report +.astro +.cache +.next +.parcel-cache +.svelte-kit +.turbo +.vercel +apps/xtream-mock-server/public/marketing From 6829c667bd2e32721874db1f93989f32a3c4ebea Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 14:18:11 +0300 Subject: [PATCH 08/21] fix(pwa): restore xtream rail and vod navigation --- apps/web-e2e/src/self-hosted.e2e.ts | 26 ++++ .../lib/navigation/portal-rail-links.spec.ts | 29 ++-- .../src/lib/navigation/portal-rail-links.ts | 11 -- .../pwa-xtream-data-source.spec.ts | 141 ++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 99 ++++++++++-- .../src/lib/database-electron.service.spec.ts | 47 ++++++ .../src/lib/database-electron.service.ts | 20 ++- 7 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts create mode 100644 libs/services/src/lib/database-electron.service.spec.ts diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 8a5abc5c4..216840ee4 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -133,11 +133,37 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { const xtreamRequests = collectBackendRequests(page, '/xtream'); + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); await addXtreamPortal(page); + const rail = page.locator('app-workspace-shell-rail'); + await expect(rail.locator('a[aria-label="Movies"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Live TV"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Series"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Recently added"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Advanced search"]')).toBeVisible(); + const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + const vodItem = page.locator('app-grid-list mat-card').first(); + await expect(vodItem).toBeVisible({ timeout: 30_000 }); + await vodItem.click(); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/); + await expect( + page.getByRole('button', { name: 'Play', exact: true }) + ).toBeVisible({ timeout: 15_000 }); + + expect( + consoleErrors.filter((message) => + /db(SetAppState|GetContentByXtreamId)/.test(message) + ) + ).toEqual([]); expectRequestsUseTargetId(xtreamRequests, '/xtream'); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index b5bb6f5a1..9bdd78e5c 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -24,7 +24,7 @@ describe('buildPortalRailLinks', () => { expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)'); }); - it('builds workspace Xtream links without downloads on web', () => { + it('builds workspace Xtream content links on web without downloads', () => { const links = buildPortalRailLinks({ provider: 'xtreams', playlistId: 'xtream-web', @@ -32,24 +32,26 @@ describe('buildPortalRailLinks', () => { workspace: true, }); - expect(links.primary).toEqual([ - { - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: ['/workspace', 'xtreams', 'xtream-web'], - exact: true, - section: 'library', - }, + expect(links.primary.map((link) => link.section)).toEqual([ + 'vod', + 'live', + 'series', ]); - expect(links.secondary).toEqual([]); + expect(links.secondary.map((link) => link.section)).toEqual([ + 'recently-added', + 'search', + ]); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); - it('builds Stalker links with scoped tooltip labels', () => { + it('builds workspace Stalker links with scoped tooltip labels on web', () => { const links = buildPortalRailLinks({ provider: 'stalker', playlistId: 'portal-1', isElectron: false, - workspace: false, + workspace: true, }); expect(links.primary.map((link) => link.section)).toEqual([ @@ -63,6 +65,9 @@ describe('buildPortalRailLinks', () => { expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)'); expect(links.primary[2]?.tooltip).toBe('Radio (this playlist)'); expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)'); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); it('builds M3U playlist links with scoped tooltip labels', () => { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index cb335cefb..895476704 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -46,17 +46,6 @@ export function buildPortalRailLinks( const primary: PortalRailLink[] = []; const secondary: PortalRailLink[] = []; - if (workspace && !isElectron) { - primary.push({ - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: root, - exact: true, - section: 'library', - }); - return { primary, secondary }; - } - primary.push( { icon: 'movie', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts new file mode 100644 index 000000000..32ad75e03 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -0,0 +1,141 @@ +import { TestBed } from '@angular/core/testing'; +import { PwaXtreamDataSource } from './pwa-xtream-data-source'; +import { + XtreamApiService, + XtreamCredentials, +} from '../services/xtream-api.service'; + +describe('PwaXtreamDataSource', () => { + let dataSource: PwaXtreamDataSource; + let apiService: { + getStreams: jest.Mock; + }; + + const credentials: XtreamCredentials = { + serverUrl: 'http://localhost:3211', + username: 'demo', + password: 'secret', + }; + + beforeEach(() => { + localStorage.clear(); + + apiService = { + getStreams: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + + dataSource = TestBed.inject(PwaXtreamDataSource); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => { + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => { + switch (type) { + case 'live': + return Promise.resolve([ + { + stream_id: 101, + name: 'News Live', + stream_icon: 'live.png', + category_id: '10', + added: '1', + }, + ]); + case 'movie': + return Promise.resolve([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + case 'series': + return Promise.resolve([ + { + series_id: 303, + name: 'Series One', + cover: 'series.png', + category_id: 30, + last_modified: '3', + }, + ]); + default: + return Promise.resolve([]); + } + } + ); + + const live = (await dataSource.getContent( + 'playlist-1', + credentials, + 'live' + )) as Array>; + const vod = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + const series = (await dataSource.getContent( + 'playlist-1', + credentials, + 'series' + )) as Array>; + + expect(live[0]).toEqual( + expect.objectContaining({ + id: 101, + stream_id: 101, + title: 'News Live', + poster_url: 'live.png', + type: 'live', + xtream_id: 101, + }) + ); + expect(vod[0]).toEqual( + expect.objectContaining({ + id: 202, + stream_id: 202, + title: 'Movie One', + poster_url: 'movie.png', + type: 'movie', + xtream_id: 202, + }) + ); + expect(series[0]).toEqual( + expect.objectContaining({ + id: 303, + series_id: 303, + title: 'Series One', + poster_url: 'series.png', + type: 'series', + xtream_id: 303, + }) + ); + + await expect( + dataSource.getContentByXtreamId(202, 'playlist-1', 'movie') + ).resolves.toEqual( + expect.objectContaining({ + title: 'Movie One', + type: 'movie', + xtream_id: 202, + }) + ); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 26d3b3e70..476ad009b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -37,9 +37,15 @@ const STORAGE_KEYS = { interface XtreamCachedContentItem { readonly added?: string; readonly category_id?: string | number; - readonly id?: number; + readonly cover?: string; + readonly cover_big?: string; + readonly id?: number | string; + readonly last_modified?: string; + readonly movie_image?: string; readonly name?: string; + readonly poster?: string; readonly poster_url?: string; + readonly rating?: string | number; readonly series_id?: number; readonly stream_display_name?: string; readonly stream_id?: number; @@ -47,6 +53,7 @@ interface XtreamCachedContentItem { readonly title?: string; readonly type?: string; readonly viewed_at?: string; + readonly xtream_id?: number | string; } interface StoredRecentItem { @@ -219,7 +226,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { onProgress?: (count: number) => void, onTotal?: (total: number) => void, options?: XtreamOperationOptions - ): Promise { + ): Promise< + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[] + > { void options; const cacheKey = `${playlistId}-${type}-content`; @@ -233,7 +245,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } // Fetch from API - const content = await this.apiService.getStreams(credentials, type); + const content = this.normalizeContentItems( + await this.apiService.getStreams(credentials, type), + type + ); // Report total and progress (PWA doesn't have incremental save, so report all at once) if (onTotal) { @@ -270,15 +285,73 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; - // In PWA mode, we just cache in memory + // In PWA mode, we just cache normalized API items in memory. const cacheKey = `${playlistId}-${type}-content`; - this.contentCache.set(cacheKey, streams); + const normalizedStreams = this.normalizeContentItems(streams, type); + this.contentCache.set(cacheKey, normalizedStreams); if (onProgress) { - onProgress(streams.length); + onProgress(normalizedStreams.length); } - return streams.length; + return normalizedStreams.length; + } + + private normalizeContentItems( + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], + type: 'live' | 'movie' | 'series' + ): XtreamContentItem[] { + return streams.map((item) => + this.normalizeContentItem(item as XtreamCachedContentItem, type) + ); + } + + private normalizeContentItem( + item: XtreamCachedContentItem, + type: 'live' | 'movie' | 'series' + ): XtreamContentItem { + const xtreamId = this.getItemIdentity(item, type); + const id = Number(item.id); + const title = item.title ?? item.name ?? item.stream_display_name ?? ''; + const posterUrl = + item.poster_url ?? + item.stream_icon ?? + item.cover ?? + item.cover_big ?? + item.movie_image ?? + item.poster ?? + ''; + + return { + ...item, + added: item.added ?? item.last_modified ?? '', + category_id: item.category_id ?? '', + id: Number.isFinite(id) ? id : xtreamId, + name: item.name ?? title, + poster_url: posterUrl, + rating: String(item.rating ?? ''), + title, + type, + xtream_id: xtreamId, + } as XtreamContentItem; + } + + private getItemIdentity( + item: XtreamCachedContentItem, + type?: 'live' | 'movie' | 'series' + ): number { + const preferredId = + item.xtream_id ?? + (type === 'series' ? item.series_id : item.stream_id) ?? + item.stream_id ?? + item.series_id ?? + item.id; + const numericId = Number(preferredId); + return Number.isFinite(numericId) ? numericId : 0; } // ========================================================================= @@ -326,7 +399,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; + const itemId = this.getItemIdentity(item); if (playlistFavorites.includes(itemId)) { results.push(item); } @@ -343,6 +416,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // is electron-only. Accept the param for interface parity. _backdropUrl?: string ): Promise { + void _backdropUrl; const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; @@ -533,7 +607,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; + const itemId = this.getItemIdentity(item); const recentEntry = playlistRecent.find((r) => r.id === itemId); if (recentEntry) { results.push({ @@ -559,6 +633,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, _backdropUrl?: string ): Promise { + void _backdropUrl; const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; @@ -640,8 +715,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); @@ -667,8 +741,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts new file mode 100644 index 000000000..06effcad6 --- /dev/null +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -0,0 +1,47 @@ +import { DatabaseService } from './database-electron.service'; + +describe('DatabaseService browser guards', () => { + const originalElectron = window.electron; + let service: DatabaseService; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + service = new DatabaseService(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + + it('treats app state persistence as unavailable without the Electron bridge', async () => { + await expect(service.getAppState('xtream-key')).resolves.toBeNull(); + await expect( + service.setAppState('xtream-key', 'completed') + ).resolves.toBe(false); + await expect( + service.getXtreamImportStatus('playlist-1', 'movie') + ).resolves.toBe('idle'); + await expect( + service.setXtreamImportStatus('playlist-1', 'movie', 'completed') + ).resolves.toBe(false); + await expect( + service.getContentByXtreamId(20229, 'playlist-1', 'movie') + ).resolves.toBeNull(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 49d3cf002..8b0da609d 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -157,11 +157,11 @@ export class DatabaseService { } supportsDbOperationEvents(): boolean { - return typeof window.electron.onDbOperationEvent === 'function'; + return typeof window.electron?.onDbOperationEvent === 'function'; } supportsDbOperationCancellation(): boolean { - return typeof window.electron.dbCancelOperation === 'function'; + return typeof window.electron?.dbCancelOperation === 'function'; } async cancelOperation(operationId: string): Promise { @@ -496,6 +496,10 @@ export class DatabaseService { playlistId: string, type: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbClearXtreamImportCache !== 'function') { + return false; + } + try { await window.electron.dbClearXtreamImportCache(playlistId, type); return true; @@ -506,6 +510,10 @@ export class DatabaseService { } async getAppState(key: string): Promise { + if (typeof window.electron?.dbGetAppState !== 'function') { + return null; + } + try { return await window.electron.dbGetAppState(key); } catch (error) { @@ -515,6 +523,10 @@ export class DatabaseService { } async setAppState(key: string, value: string): Promise { + if (typeof window.electron?.dbSetAppState !== 'function') { + return false; + } + try { await window.electron.dbSetAppState(key, value); return true; @@ -861,6 +873,10 @@ export class DatabaseService { playlistId: string, contentType?: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbGetContentByXtreamId !== 'function') { + return null; + } + try { return await window.electron.dbGetContentByXtreamId( xtreamId, From 362595525d96de5af7df368f42bb9ec7cf7857b4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 18:44:42 +0300 Subject: [PATCH 09/21] test(pwa): tighten self-hosted proxy assertions --- apps/web-e2e/src/self-hosted.e2e.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 216840ee4..daaf7eb73 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -90,7 +90,8 @@ function collectBackendRequests(page: Page, path: string): string[] { const requests: string[] = []; page.on('request', (request) => { const requestUrl = request.url(); - if (requestUrl.startsWith(`${WEB_BACKEND_URL}${path}`)) { + const url = new URL(requestUrl); + if (url.origin === WEB_BACKEND_URL && url.pathname === path) { requests.push(requestUrl); } }); @@ -102,8 +103,9 @@ function expectRequestsUseTargetId(requests: string[], path: string): void { for (const requestUrl of requests) { const url = new URL(requestUrl); expect(url.pathname).toBe(path); - expect(url.searchParams.get('targetId')).toBeTruthy(); - expect(url.searchParams.has('url')).toBeFalsy(); + expect(url.searchParams.get('targetId')).not.toBeNull(); + expect(url.searchParams.get('targetId')).not.toBe(''); + expect(url.searchParams.has('url')).toBe(false); } } From b2909a11863cc46de250d13a0c5180dc49b997c6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Thu, 21 May 2026 19:15:46 +0300 Subject: [PATCH 10/21] feat(workspace): add keyboard shortcuts help (#957) * feat(workspace): add keyboard shortcuts help * style(workspace): polish keyboard shortcuts dialog * fix(workspace): address shortcuts review feedback * docs(workspace): clarify desktop shortcut availability * refactor(workspace): split keyboard shortcut definitions --- README.md | 21 ++ apps/web-e2e/src/basic.e2e.ts | 18 + apps/web/src/assets/i18n/ar.json | 35 ++ apps/web/src/assets/i18n/ary.json | 35 ++ apps/web/src/assets/i18n/by.json | 35 ++ apps/web/src/assets/i18n/de.json | 35 ++ apps/web/src/assets/i18n/el.json | 35 ++ apps/web/src/assets/i18n/en.json | 35 ++ apps/web/src/assets/i18n/es.json | 35 ++ apps/web/src/assets/i18n/fr.json | 35 ++ apps/web/src/assets/i18n/it.json | 35 ++ apps/web/src/assets/i18n/ja.json | 35 ++ apps/web/src/assets/i18n/ko.json | 35 ++ apps/web/src/assets/i18n/nl.json | 35 ++ apps/web/src/assets/i18n/pl.json | 35 ++ apps/web/src/assets/i18n/pt.json | 35 ++ apps/web/src/assets/i18n/ru.json | 35 ++ apps/web/src/assets/i18n/tr.json | 35 ++ apps/web/src/assets/i18n/zh.json | 35 ++ apps/web/src/assets/i18n/zhtw.json | 35 ++ apps/web/src/styles.scss | 14 + docs/architecture/workspace-shell.md | 22 +- libs/portal/shared/util/src/index.ts | 1 + .../src/lib/keyboard-shortcut-definitions.ts | 202 +++++++++++ .../util/src/lib/keyboard-shortcuts.spec.ts | 95 +++++ .../shared/util/src/lib/keyboard-shortcuts.ts | 126 +++++++ ...e-keyboard-shortcuts-dialog.component.html | 105 ++++++ ...e-keyboard-shortcuts-dialog.component.scss | 342 ++++++++++++++++++ ...eyboard-shortcuts-dialog.component.spec.ts | 138 +++++++ ...ace-keyboard-shortcuts-dialog.component.ts | 36 ++ ...rkspace-keyboard-shortcuts.service.spec.ts | 96 +++++ .../workspace-keyboard-shortcuts.service.ts | 99 +++++ .../workspace-shell-header.component.html | 9 + .../workspace-shell-header.component.spec.ts | 12 + .../workspace-shell-header.component.ts | 5 + .../workspace-shell.component.html | 1 + .../workspace-shell.component.spec.ts | 62 ++++ .../workspace-shell.component.ts | 3 + 38 files changed, 2036 insertions(+), 1 deletion(-) create mode 100644 libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts create mode 100644 libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts create mode 100644 libs/portal/shared/util/src/lib/keyboard-shortcuts.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts create mode 100644 libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts diff --git a/README.md b/README.md index 85497b279..d56cbbc45 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,27 @@ The application is a cross-platform, open-source project built with Electron and - Light and Dark themes - Docker image available for self-hosting the PWA and web backend together +## Keyboard shortcuts + +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+R` | Open recently viewed in the desktop app | +| Global | `Enter` in workspace search | Submit the current search | +| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar | +| Navigation | `0-9` | Select an M3U channel by number | +| Playback | `Space` / `K` | Play or pause embedded MPV playback in the desktop app | +| Playback | `F` | Toggle embedded MPV fullscreen in the desktop app | +| Playback | `ArrowLeft` / `ArrowRight` | Seek embedded MPV playback by 5 seconds in the desktop app | +| Playback | `ArrowUp` / `ArrowDown` | Adjust volume by 5% | +| Playback | `M` | Mute audio | +| Dialogs and lists | `ArrowUp` / `ArrowDown` | Move command palette selection | +| Dialogs and lists | `Enter` | Run the selected command or open a focused item | +| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays | + ## Screenshots: | Dashboard with recently watched content | Live channels with inline player and EPG | diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index 6a6262ac8..3f76af819 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -29,3 +29,21 @@ test('basic test', async ({ page }) => { await expect(page.getByText('1. Channel 1')).toBeVisible(); await expect(page.getByText('4. HappyKids TV')).toBeVisible(); }); + +test('keyboard shortcuts help opens from question mark', async ({ page }) => { + await page.goto('/'); + + await page.getByRole('button', { name: 'Open keyboard shortcuts' }).focus(); + await page.keyboard.press('?'); + + const dialog = page.getByRole('dialog'); + await expect( + dialog.getByRole('heading', { name: 'Keyboard shortcuts' }) + ).toBeVisible(); + await expect(dialog.getByText('Open command palette')).toBeVisible(); + await expect(dialog.getByText('Toggle sidebar')).toBeVisible(); + await expect(dialog.getByText('Mute audio')).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +}); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a658b05c..593509aa2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "هذا القسم", "GROUP_RECENT": "المستخدمة مؤخرًا" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3faa6a9ca..3b4ec08d3 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "هاد القسم", "GROUP_RECENT": "مستعملة مؤخراً" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index a5d4b725e..1ecdeeb78 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Гэты раздзел", "GROUP_RECENT": "Нядаўна выкарыстаныя" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Усе фаварыты", "SUBTITLE": "Прамы эфір з усіх плэйлістоў", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index d6f72b073..8388a0860 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Dieser Abschnitt", "GROUP_RECENT": "Kürzlich verwendet" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Globale Favoriten", "SUBTITLE": "Live-TV aus allen Playlists", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 4e0769554..1caaf6877 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Αυτή η ενότητα", "GROUP_RECENT": "Πρόσφατα χρησιμοποιημένες" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Βασικά Αγαπημένα", "SUBTITLE": "Ζωντανή TV από όλες τις λίστες αναπαραγωγής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ee8a4a470..c53c8fd73 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "This section", "GROUP_RECENT": "Recently used" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Global Favorites", "SUBTITLE": "Live TV from all playlists", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 29b095996..7cf593332 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Esta sección", "GROUP_RECENT": "Usados recientemente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoritos globales", "SUBTITLE": "TV en vivo de todas las listas", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 42d3ad995..25b16a654 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Cette section", "GROUP_RECENT": "Récemment utilisées" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoris globaux", "SUBTITLE": "TV en direct de toutes les listes de lecture", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 3d9b4fc9d..14c46c88b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Questa sezione", "GROUP_RECENT": "Usati di recente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Preferiti globali", "SUBTITLE": "TV in diretta da tutte le playlist", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index d8c77a915..d35388e32 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "このセクション", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "グローバルお気に入り", "SUBTITLE": "すべてのプレイリストのライブTV", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 635855e55..f3d3fa86a 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "이 섹션", "GROUP_RECENT": "최근 사용" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "전체 즐겨찾기", "SUBTITLE": "모든 재생목록의 라이브 TV", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 40c46f5d4..8fbce2a4b 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Deze sectie", "GROUP_RECENT": "Recent gebruikt" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Algemene favorieten", "SUBTITLE": "Live tv uit alle afspeellijsten", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 1726294b4..bb45aa790 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Ta sekcja", "GROUP_RECENT": "Ostatnio używane" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Globalne ulubione", "SUBTITLE": "Telewizja na żywo ze wszystkich list odtwarzania", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 79d31dbc2..a5961a753 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Esta seção", "GROUP_RECENT": "Usados recentemente" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Favoritos globais", "SUBTITLE": "TV ao vivo de todas as playlists", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 80dcdaf6c..5009b61cf 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Этот раздел", "GROUP_RECENT": "Недавно использованные" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Глобальные избранные", "SUBTITLE": "Live TV из всех плейлистов", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6911a1c88..d86ed8793 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "Bu bölüm", "GROUP_RECENT": "Son kullanılanlar" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "Tüm Favoriler", "SUBTITLE": "Tüm oynatma listelerinden canlı TV", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 7016ca2eb..33b6b5abe 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "本章节", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "全局收藏", "SUBTITLE": "来自所有播放列表的直播电视", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 4f362b502..8da2bf49d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1019,6 +1019,41 @@ "GROUP_SECTION": "此區段", "GROUP_RECENT": "最近使用" }, + "SHORTCUTS": { + "TITLE": "Keyboard shortcuts", + "SUBTITLE": "Workspace, navigation, playback, and dialog shortcuts available in IPTVnator.", + "OPEN_TOOLTIP": "Keyboard shortcuts (?)", + "OPEN_ARIA": "Open keyboard shortcuts", + "PLATFORM": { + "MAC": "macOS layout", + "OTHER": "Windows/Linux layout" + }, + "GROUPS": { + "GLOBAL": "Global", + "NAVIGATION": "Navigation", + "PLAYBACK": "Playback", + "DIALOGS": "Dialogs and lists" + }, + "ITEMS": { + "OPEN_KEYBOARD_SHORTCUTS": "Open this shortcuts list", + "OPEN_COMMAND_PALETTE": "Open command palette", + "OPEN_GLOBAL_SEARCH": "Open global search", + "OPEN_RECENTLY_VIEWED": "Open recently viewed", + "SUBMIT_SHELL_SEARCH": "Submit workspace search", + "TOGGLE_SIDEBAR": "Toggle sidebar", + "M3U_CHANNEL_NUMBER": "Select M3U channel by number", + "PLAY_PAUSE": "Play or pause embedded MPV playback", + "TOGGLE_FULLSCREEN": "Toggle embedded MPV fullscreen", + "SEEK": "Seek embedded MPV playback by 5 seconds", + "ADJUST_VOLUME": "Adjust volume by 5%", + "MUTE_AUDIO": "Mute audio", + "CLOSE_PLAYER_POPOVERS": "Close embedded MPV popovers", + "COMMAND_PALETTE_NAVIGATION": "Move command palette selection", + "COMMAND_PALETTE_RUN": "Run selected command palette item", + "CLOSE_DIALOGS": "Close dialog or dismiss overlay", + "DOWNLOADS_OPEN_ITEM": "Open focused download item in the library" + } + }, "GLOBAL_FAVORITES": { "TITLE": "全域我的最愛", "SUBTITLE": "來自所有播放清單的直播電視", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index f1ec3dcfb..b8d185552 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -200,6 +200,20 @@ textarea, overflow: visible; } +.workspace-shortcuts-overlay, +.workspace-shortcuts-overlay .mat-mdc-dialog-surface, +.workspace-shortcuts-overlay .mat-mdc-dialog-container { + app-region: no-drag; +} + +.workspace-shortcuts-overlay .mat-mdc-dialog-surface { + background: transparent; + box-shadow: none; + border-radius: 14px; + padding: 0; + overflow: visible; +} + .mat-mdc-snack-bar-container.settings-snackbar { margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index b3e37bd75..67d9fe3a8 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -150,6 +150,24 @@ Command palette behavior is shell-owned but view-extensible: value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted. +Keyboard shortcut help is shell-owned: + +1. `WorkspaceKeyboardShortcutsService` is provided by `WorkspaceShellComponent`. + It owns the workspace-scoped `document:keydown` listener for `?` / + `Shift+/`. +2. The listener ignores events from inputs, textareas, selects, and + content-editable elements via `isTypingInInput(...)`. +3. `libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts` is the + metadata registry for shortcuts shown in the help dialog and documented in + README. `keyboard-shortcuts.ts` owns the display transformation and help + trigger detection. + Shortcuts that only work through the Electron bridge, such as embedded MPV + controls, must set `electronOnly: true` so the PWA dialog does not advertise + unavailable commands. +4. New custom shortcuts should be added to that registry when the handler is + added. Do not include native browser/editor behavior such as `Tab` or + platform text editing shortcuts. + ## Maintenance Guidance Use this document as the source of truth when changing workspace shell behavior. @@ -160,5 +178,7 @@ Use this document as the source of truth when changing workspace shell behavior. not duplicated inside the shell. 3. If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together. -4. Historical migration notes, cleanup lists, and one-off refactor steps should +4. When adding a non-native keyboard shortcut, update the shared shortcuts + registry, the help dialog tests, README, and the closest behavior test. +5. Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead. diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 6ce6a72c9..425e617b2 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -17,6 +17,7 @@ export * from './lib/live-epg-panel-state'; export * from './lib/live-sidebar-state'; export * from './lib/live-layout-sidebar-state.service'; export * from './lib/keyboard'; +export * from './lib/keyboard-shortcuts'; export * from './lib/remote-channel-navigation'; export * from './lib/workspace-header-context.service'; export * from './lib/workspace-view-command.types'; diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts new file mode 100644 index 000000000..306e95abe --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts @@ -0,0 +1,202 @@ +export type KeyboardShortcutGroupId = + | 'global' + | 'navigation' + | 'playback' + | 'dialogs'; + +export interface PlatformShortcutChord { + mac: readonly string[]; + other: readonly string[]; +} + +export type KeyboardShortcutChord = + | string + | readonly string[] + | PlatformShortcutChord; + +export interface KeyboardShortcutDefinition { + id: string; + group: KeyboardShortcutGroupId; + labelKey: string; + icon: string; + keys: readonly KeyboardShortcutChord[]; + order: number; + electronOnly?: boolean; +} + +export const KEYBOARD_SHORTCUT_GROUPS: readonly { + id: KeyboardShortcutGroupId; + labelKey: string; + icon: string; +}[] = [ + { + id: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.GLOBAL', + icon: 'keyboard', + }, + { + id: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.NAVIGATION', + icon: 'explore', + }, + { + id: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.PLAYBACK', + icon: 'play_circle', + }, + { + id: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.DIALOGS', + icon: 'web_asset', + }, +]; + +const commandChord = (key: string): PlatformShortcutChord => ({ + mac: ['Cmd', key], + other: ['Ctrl', key], +}); + +export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [ + { + id: 'open-keyboard-shortcuts', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_KEYBOARD_SHORTCUTS', + icon: 'help_outline', + keys: ['?'], + order: 0, + }, + { + id: 'open-command-palette', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE', + icon: 'terminal', + keys: [commandChord('K')], + order: 10, + }, + { + id: 'open-global-search', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_GLOBAL_SEARCH', + icon: 'search', + keys: [commandChord('F')], + order: 20, + electronOnly: true, + }, + { + id: 'open-recently-viewed', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_RECENTLY_VIEWED', + icon: 'history', + keys: [commandChord('R')], + order: 30, + electronOnly: true, + }, + { + id: 'submit-shell-search', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.SUBMIT_SHELL_SEARCH', + icon: 'keyboard_return', + keys: ['Enter'], + order: 40, + }, + { + id: 'toggle-sidebar', + group: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_SIDEBAR', + icon: 'view_sidebar', + keys: [commandChord('B')], + order: 10, + }, + { + id: 'm3u-channel-number', + group: 'navigation', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.M3U_CHANNEL_NUMBER', + icon: 'dialpad', + keys: ['0-9'], + order: 20, + }, + { + id: 'embedded-mpv-play-pause', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE', + icon: 'play_arrow', + keys: ['Space', 'K'], + order: 10, + electronOnly: true, + }, + { + id: 'embedded-mpv-fullscreen', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_FULLSCREEN', + icon: 'fullscreen', + keys: ['F'], + order: 20, + electronOnly: true, + }, + { + id: 'embedded-mpv-seek', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.SEEK', + icon: 'swap_horiz', + keys: ['ArrowLeft', 'ArrowRight'], + order: 30, + electronOnly: true, + }, + { + id: 'adjust-volume', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.ADJUST_VOLUME', + icon: 'volume_up', + keys: ['ArrowUp', 'ArrowDown'], + order: 40, + }, + { + id: 'mute-audio', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.MUTE_AUDIO', + icon: 'volume_off', + keys: ['M'], + order: 50, + }, + { + id: 'close-player-popovers', + group: 'playback', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.CLOSE_PLAYER_POPOVERS', + icon: 'close', + keys: ['Escape'], + order: 60, + electronOnly: true, + }, + { + id: 'command-palette-navigation', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.COMMAND_PALETTE_NAVIGATION', + icon: 'unfold_more', + keys: ['ArrowUp', 'ArrowDown'], + order: 10, + }, + { + id: 'command-palette-run', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.COMMAND_PALETTE_RUN', + icon: 'subdirectory_arrow_right', + keys: ['Enter'], + order: 20, + }, + { + id: 'close-dialogs', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.CLOSE_DIALOGS', + icon: 'close', + keys: ['Escape'], + order: 30, + }, + { + id: 'downloads-open-item', + group: 'dialogs', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.DOWNLOADS_OPEN_ITEM', + icon: 'download', + keys: ['Enter', 'Space'], + order: 40, + }, +]; diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..4852806bd --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts @@ -0,0 +1,95 @@ +import { + APP_KEYBOARD_SHORTCUTS, + getKeyboardShortcutGroups, + isKeyboardShortcutHelpTrigger, +} from './keyboard-shortcuts'; + +describe('keyboard shortcuts registry', () => { + it('keeps shortcut ids unique', () => { + const ids = APP_KEYBOARD_SHORTCUTS.map((shortcut) => shortcut.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('omits Electron-only shortcuts outside Electron', () => { + const groups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: false, + }); + const ids = groups.flatMap((group) => + group.items.map((item) => item.id) + ); + + expect(ids).not.toContain('open-global-search'); + expect(ids).not.toContain('open-recently-viewed'); + expect(ids).not.toContain('embedded-mpv-play-pause'); + expect(ids).not.toContain('embedded-mpv-fullscreen'); + expect(ids).not.toContain('embedded-mpv-seek'); + expect(ids).not.toContain('close-player-popovers'); + expect(ids).toContain('open-command-palette'); + expect(ids).toContain('adjust-volume'); + expect(ids).toContain('mute-audio'); + }); + + it('uses platform-specific modifier labels', () => { + const macGroups = getKeyboardShortcutGroups({ + isMac: true, + isElectron: true, + }); + const linuxGroups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + + expect(findChordLabels(macGroups, 'open-command-palette')).toEqual([ + ['Cmd', 'K'], + ]); + expect(findChordLabels(linuxGroups, 'open-command-palette')).toEqual([ + ['Ctrl', 'K'], + ]); + }); + + it('normalizes display labels for compact keycaps', () => { + const groups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + + expect(findChordLabels(groups, 'embedded-mpv-seek')).toEqual([ + ['←'], + ['→'], + ]); + expect(findChordLabels(groups, 'close-dialogs')).toEqual([['Esc']]); + }); + + it('detects the shortcuts help trigger', () => { + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '?' }) + ) + ).toBe(true); + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '/', shiftKey: true }) + ) + ).toBe(true); + expect( + isKeyboardShortcutHelpTrigger( + new KeyboardEvent('keydown', { key: '/', ctrlKey: true }) + ) + ).toBe(false); + }); +}); + +function findChordLabels( + groups: ReturnType, + id: string +): readonly (readonly string[])[] { + const item = groups + .flatMap((group) => group.items) + .find((shortcut) => shortcut.id === id); + + return ( + item?.chords.map((chord) => chord.keys.map((key) => key.label)) ?? [] + ); +} diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts new file mode 100644 index 000000000..7a2ebbd8f --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts @@ -0,0 +1,126 @@ +import { + APP_KEYBOARD_SHORTCUTS, + KEYBOARD_SHORTCUT_GROUPS, +} from './keyboard-shortcut-definitions'; +import type { + KeyboardShortcutChord, + KeyboardShortcutGroupId, +} from './keyboard-shortcut-definitions'; + +export { APP_KEYBOARD_SHORTCUTS }; +export type { KeyboardShortcutGroupId }; + +export interface KeyboardShortcutDisplayItem { + id: string; + labelKey: string; + icon: string; + chords: readonly KeyboardShortcutDisplayChord[]; +} + +export interface KeyboardShortcutDisplayChord { + id: string; + ariaLabel: string; + keys: readonly KeyboardShortcutDisplayKey[]; +} + +export interface KeyboardShortcutDisplayKey { + id: string; + label: string; + ariaLabel: string; + isModifier: boolean; +} + +export interface KeyboardShortcutDisplayGroup { + id: KeyboardShortcutGroupId; + labelKey: string; + icon: string; + items: readonly KeyboardShortcutDisplayItem[]; +} + +const KEY_LABELS = new Map([ + ['ArrowLeft', '←'], + ['ArrowRight', '→'], + ['ArrowUp', '↑'], + ['ArrowDown', '↓'], + ['Escape', 'Esc'], +]); + +const KEY_ARIA_LABELS = new Map([ + ['Cmd', 'Command'], + ['Ctrl', 'Control'], + ['ArrowLeft', 'Left arrow'], + ['ArrowRight', 'Right arrow'], + ['ArrowUp', 'Up arrow'], + ['ArrowDown', 'Down arrow'], + ['Escape', 'Escape'], +]); + +const MODIFIER_KEYS = new Set(['Cmd', 'Ctrl', 'Alt', 'Shift']); + +export function getKeyboardShortcutGroups(options: { + isMac: boolean; + isElectron: boolean; +}): readonly KeyboardShortcutDisplayGroup[] { + return KEYBOARD_SHORTCUT_GROUPS.map((group) => ({ + ...group, + items: APP_KEYBOARD_SHORTCUTS.filter( + (shortcut) => + shortcut.group === group.id && + (!shortcut.electronOnly || options.isElectron) + ) + .sort((first, second) => first.order - second.order) + .map((shortcut) => ({ + id: shortcut.id, + labelKey: shortcut.labelKey, + icon: shortcut.icon, + chords: shortcut.keys.map((key) => + resolveShortcutChord(key, options.isMac) + ), + })), + })).filter((group) => group.items.length > 0); +} + +export function isKeyboardShortcutHelpTrigger(event: KeyboardEvent): boolean { + if (event.ctrlKey || event.metaKey || event.altKey) { + return false; + } + + return event.key === '?' || (event.key === '/' && event.shiftKey); +} + +function resolveShortcutChord( + key: KeyboardShortcutChord, + isMac: boolean +): KeyboardShortcutDisplayChord { + const keys = resolveShortcutKeys(key, isMac); + + return { + id: keys.join('+'), + ariaLabel: keys + .map( + (shortcutKey) => KEY_ARIA_LABELS.get(shortcutKey) ?? shortcutKey + ) + .join(' + '), + keys: keys.map((shortcutKey) => ({ + id: shortcutKey.toLowerCase().replace(/[^a-z0-9]+/g, '-'), + label: KEY_LABELS.get(shortcutKey) ?? shortcutKey, + ariaLabel: KEY_ARIA_LABELS.get(shortcutKey) ?? shortcutKey, + isModifier: MODIFIER_KEYS.has(shortcutKey), + })), + }; +} + +function resolveShortcutKeys( + key: KeyboardShortcutChord, + isMac: boolean +): readonly string[] { + if (typeof key === 'string') { + return [key]; + } + + if ('mac' in key) { + return isMac ? key.mac : key.other; + } + + return key; +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html new file mode 100644 index 000000000..bb6925e26 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.html @@ -0,0 +1,105 @@ +

+
+
+ +
+

+ {{ 'WORKSPACE.SHORTCUTS.TITLE' | translate }} +

+

{{ 'WORKSPACE.SHORTCUTS.SUBTITLE' | translate }}

+
+
+
+ + + {{ data.platformLabelKey | translate }} + + +
+
+ + +
+ @for (group of groups; track group.id) { +
+
+ +

{{ group.labelKey | translate }}

+
+ +
+ @for (item of group.items; track item.id) { +
+ + + {{ item.labelKey | translate }} + + + @for ( + chord of item.chords; + track chord.id + ) { + + @for ( + key of chord.keys; + track key.id + ) { + + {{ key.label }} + + @if (!$last) { + + } + } + + } + +
+ } +
+
+ } +
+
+ + + + +
diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss new file mode 100644 index 000000000..84943135c --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.scss @@ -0,0 +1,342 @@ +:host { + display: block; + app-region: no-drag; +} + +.shortcuts-dialog { + --shortcut-surface: var(--app-widget-bg, #1e2330); + --shortcut-surface-raised: var(--app-widget-header-bg, #232840); + --shortcut-text: var(--app-heading-color, #d8dce8); + --shortcut-muted: var(--app-body-color, #8b93a8); + --shortcut-border: var(--app-separator, rgba(255, 255, 255, 0.14)); + --shortcut-accent: var(--app-selection-color, #78adff); + --shortcut-panel-bg: color-mix( + in srgb, + var(--shortcut-surface) 86%, + var(--shortcut-surface-raised) 14% + ); + --shortcut-panel-hover: color-mix( + in srgb, + var(--shortcut-accent) 10%, + var(--shortcut-surface-raised) 90% + ); + --shortcut-key-bg: color-mix( + in srgb, + var(--shortcut-surface-raised) 88%, + white 12% + ); + --shortcut-key-shadow: color-mix(in srgb, black 24%, transparent); + + color: var(--shortcut-text); + background: + linear-gradient( + 145deg, + color-mix( + in srgb, + var(--shortcut-accent) 8%, + var(--shortcut-surface) 92% + ), + var(--shortcut-surface) 42% + ), + var(--shortcut-surface); + border: 1px solid var(--shortcut-border); + border-radius: 16px; + box-shadow: 0 22px 70px color-mix(in srgb, black 34%, transparent); + overflow: hidden; +} + +.shortcuts-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 20px 22px 16px; + border-bottom: 1px solid var(--shortcut-border); +} + +.shortcuts-dialog__title-block { + display: flex; + align-items: center; + min-width: 0; + gap: 14px; +} + +.shortcuts-dialog__hero-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 46px; + height: 46px; + color: var(--shortcut-accent); + background: color-mix(in srgb, var(--shortcut-accent) 14%, transparent); + border: 1px solid + color-mix(in srgb, var(--shortcut-accent) 28%, transparent); + border-radius: 8px; + + mat-icon { + width: 24px; + height: 24px; + font-size: 24px; + } +} + +.shortcuts-dialog__header h2 { + margin: 0; + padding: 0; + font-size: 1.08rem; + font-weight: 700; + line-height: 1.25; +} + +.shortcuts-dialog__header p { + margin: 5px 0 0; + color: var(--shortcut-muted); + font-size: 0.86rem; + line-height: 1.4; +} + +.shortcuts-dialog__header-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.shortcuts-dialog__platform { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 32px; + padding: 0 10px; + color: var(--shortcut-muted); + background: color-mix( + in srgb, + var(--shortcut-surface-raised) 82%, + var(--shortcut-surface) 18% + ); + border: 1px solid var(--shortcut-border); + border-radius: 8px; + font-size: 0.76rem; + font-weight: 650; + line-height: 1; + white-space: nowrap; + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + } +} + +.shortcuts-dialog__close { + flex: 0 0 auto; +} + +.shortcuts-dialog__content { + max-height: min(68vh, 670px); + padding: 12px; +} + +.shortcuts-dialog__groups { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.shortcuts-dialog__group { + min-width: 0; + background: var(--shortcut-panel-bg); + border: 1px solid var(--shortcut-border); + border-radius: 8px; + overflow: hidden; +} + +.shortcuts-dialog__group-header { + display: flex; + align-items: center; + gap: 9px; + min-height: 38px; + padding: 8px 12px; + border-bottom: 1px solid + color-mix(in srgb, var(--shortcut-border) 72%, transparent); +} + +.shortcuts-dialog__group-icon, +.shortcuts-dialog__action-icon { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + border-radius: 7px; +} + +.shortcuts-dialog__group-icon { + width: 28px; + height: 28px; + color: var(--shortcut-accent); + background: color-mix(in srgb, var(--shortcut-accent) 12%, transparent); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +.shortcuts-dialog__group h3 { + margin: 0; + color: var(--shortcut-text); + font-size: 0.74rem; + font-weight: 750; + line-height: 1.2; + text-transform: uppercase; +} + +.shortcuts-dialog__rows { + display: grid; + gap: 1px; +} + +.shortcuts-dialog__row { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) max-content; + gap: 10px; + align-items: center; + min-height: 42px; + padding: 7px 10px; + font-size: 0.88rem; + line-height: 1.35; + transition: + background-color 140ms ease, + transform 140ms ease; + + &:hover { + background: var(--shortcut-panel-hover); + } +} + +.shortcuts-dialog__action-icon { + width: 28px; + height: 28px; + color: var(--shortcut-muted); + background: color-mix( + in srgb, + var(--shortcut-surface-raised) 78%, + var(--shortcut-surface) 22% + ); + + mat-icon { + width: 17px; + height: 17px; + font-size: 17px; + } +} + +.shortcuts-dialog__label { + min-width: 0; + color: var(--shortcut-text); + font-size: 0.85rem; +} + +.shortcuts-dialog__chords { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px 8px; + min-width: 0; +} + +.shortcuts-dialog__chord { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.shortcuts-dialog__joiner { + color: var(--shortcut-muted); + font-size: 0.68rem; + font-weight: 700; +} + +.shortcuts-dialog__chord kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 27px; + padding: 2px 8px 3px; + color: var(--shortcut-text); + background: var(--shortcut-key-bg); + border: 1px solid var(--shortcut-border); + border-radius: 6px; + box-shadow: + inset 0 -2px 0 var(--shortcut-key-shadow), + 0 1px 0 color-mix(in srgb, white 28%, transparent); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + font-weight: 750; + line-height: 1; + white-space: nowrap; +} + +.shortcuts-dialog__chord kbd.shortcuts-dialog__keycap--modifier { + min-width: 38px; + color: var(--shortcut-accent); + border-color: color-mix( + in srgb, + var(--shortcut-accent) 34%, + var(--shortcut-border) + ); +} + +.shortcuts-dialog__actions { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 9px 14px 14px; + border-top: 1px solid var(--shortcut-border); +} + +@media (prefers-reduced-motion: no-preference) { + .shortcuts-dialog { + animation: shortcuts-dialog-enter 160ms ease-out; + } +} + +@media (max-width: 640px) { + .shortcuts-dialog__header { + align-items: flex-start; + flex-direction: column; + padding: 16px 16px 12px; + } + + .shortcuts-dialog__header-actions { + justify-content: space-between; + width: 100%; + } + + .shortcuts-dialog__groups { + grid-template-columns: 1fr; + } + + .shortcuts-dialog__row { + grid-template-columns: 28px minmax(0, 1fr); + gap: 8px 10px; + } + + .shortcuts-dialog__chords { + grid-column: 2; + justify-content: flex-start; + } +} + +@keyframes shortcuts-dialog-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.985); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts new file mode 100644 index 000000000..da2a93f18 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.spec.ts @@ -0,0 +1,138 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { WorkspaceKeyboardShortcutsDialogComponent } from './workspace-keyboard-shortcuts-dialog.component'; + +describe('WorkspaceKeyboardShortcutsDialogComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WorkspaceKeyboardShortcutsDialogComponent], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: { + groups: [ + { + id: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.GROUPS.GLOBAL', + icon: 'keyboard', + items: [ + { + id: 'open-command-palette', + labelKey: + 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE', + icon: 'terminal', + chords: [ + { + id: 'Cmd+K', + ariaLabel: 'Command + K', + keys: [ + { + id: 'cmd', + label: 'Cmd', + ariaLabel: 'Command', + isModifier: true, + }, + { + id: 'k', + label: 'K', + ariaLabel: 'K', + isModifier: false, + }, + ], + }, + ], + }, + { + id: 'play-pause', + labelKey: + 'WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE', + icon: 'play_arrow', + chords: [ + { + id: 'Space', + ariaLabel: 'Space', + keys: [ + { + id: 'space', + label: 'Space', + ariaLabel: 'Space', + isModifier: false, + }, + ], + }, + { + id: 'K', + ariaLabel: 'K', + keys: [ + { + id: 'k', + label: 'K', + ariaLabel: 'K', + isModifier: false, + }, + ], + }, + ], + }, + ], + }, + ], + platformIcon: 'laptop_mac', + platformLabelKey: 'WORKSPACE.SHORTCUTS.PLATFORM.MAC', + }, + }, + { + provide: MatDialogRef, + useValue: { close: jest.fn() }, + }, + { + 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', + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent( + WorkspaceKeyboardShortcutsDialogComponent + ); + fixture.detectChanges(); + }); + + it('renders the dialog title and groups', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain('WORKSPACE.SHORTCUTS.TITLE'); + expect(text).toContain('WORKSPACE.SHORTCUTS.GROUPS.GLOBAL'); + }); + + it('renders shortcut labels and multiple keys', () => { + const text = fixture.nativeElement.textContent; + + expect(text).toContain( + 'WORKSPACE.SHORTCUTS.ITEMS.OPEN_COMMAND_PALETTE' + ); + expect(text).toContain('WORKSPACE.SHORTCUTS.ITEMS.PLAY_PAUSE'); + expect(text).toContain('Cmd'); + expect(text).toContain('Space'); + expect(text).toContain('K'); + }); + + it('renders the detected platform label', () => { + expect(fixture.nativeElement.textContent).toContain( + 'WORKSPACE.SHORTCUTS.PLATFORM.MAC' + ); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts new file mode 100644 index 000000000..1496f4c33 --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts-dialog.component.ts @@ -0,0 +1,36 @@ +import { Component, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslatePipe } from '@ngx-translate/core'; +import { KeyboardShortcutDisplayGroup } from '@iptvnator/portal/shared/util'; + +export interface WorkspaceKeyboardShortcutsDialogData { + groups: readonly KeyboardShortcutDisplayGroup[]; + platformIcon: string; + platformLabelKey: string; +} + +@Component({ + selector: 'app-workspace-keyboard-shortcuts-dialog', + imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe], + templateUrl: './workspace-keyboard-shortcuts-dialog.component.html', + styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss', +}) +export class WorkspaceKeyboardShortcutsDialogComponent { + private readonly dialogRef = inject( + MatDialogRef + ); + readonly data = + inject(MAT_DIALOG_DATA); + + readonly groups = this.data.groups; + + close(): void { + this.dialogRef.close(); + } +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts new file mode 100644 index 000000000..5b1fdf24f --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { Subject } from 'rxjs'; +import { WorkspaceKeyboardShortcutsService } from './workspace-keyboard-shortcuts.service'; + +describe('WorkspaceKeyboardShortcutsService', () => { + let afterClosed$: Subject; + let dialog: { open: jest.Mock }; + let service: WorkspaceKeyboardShortcutsService; + + beforeEach(() => { + afterClosed$ = new Subject(); + dialog = { + open: jest.fn().mockReturnValue({ + afterClosed: () => afterClosed$.asObservable(), + }), + }; + + TestBed.configureTestingModule({ + providers: [ + WorkspaceKeyboardShortcutsService, + { provide: MatDialog, useValue: dialog }, + ], + }); + + service = TestBed.inject(WorkspaceKeyboardShortcutsService); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'userAgentData', { + configurable: true, + value: undefined, + }); + TestBed.resetTestingModule(); + }); + + it('opens the shortcuts dialog when question mark is pressed', () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('opens the shortcuts dialog for Shift+/', () => { + document.dispatchEvent( + new KeyboardEvent('keydown', { key: '/', shiftKey: true }) + ); + + expect(dialog.open).toHaveBeenCalledTimes(1); + }); + + it('does not open while typing in an input', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: '?', + bubbles: true, + }) + ); + + expect(dialog.open).not.toHaveBeenCalled(); + input.remove(); + }); + + it('does not open duplicate dialogs while one is active', () => { + service.openShortcutsDialog(); + service.openShortcutsDialog(); + + expect(dialog.open).toHaveBeenCalledTimes(1); + + afterClosed$.next(); + service.openShortcutsDialog(); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('uses userAgentData platform when resolving shortcut modifier labels', () => { + Object.defineProperty(navigator, 'userAgentData', { + configurable: true, + value: { platform: 'Windows' }, + }); + + service.openShortcutsDialog(); + + const dialogData = dialog.open.mock.calls[0][1].data; + const commandPaletteShortcut = dialogData.groups + .flatMap((group) => group.items) + .find((item) => item.id === 'open-command-palette'); + + expect(dialogData.platformLabelKey).toBe( + 'WORKSPACE.SHORTCUTS.PLATFORM.OTHER' + ); + expect(commandPaletteShortcut?.chords[0].keys[0].label).toBe('Ctrl'); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts new file mode 100644 index 000000000..2b6f38daa --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -0,0 +1,99 @@ +import { DestroyRef, Injectable, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + getKeyboardShortcutGroups, + isKeyboardShortcutHelpTrigger, + isTypingInInput, +} from '@iptvnator/portal/shared/util'; +import { + WorkspaceKeyboardShortcutsDialogComponent, + WorkspaceKeyboardShortcutsDialogData, +} from './workspace-keyboard-shortcuts-dialog.component'; + +@Injectable() +export class WorkspaceKeyboardShortcutsService { + private readonly dialog = inject(MatDialog); + private readonly destroyRef = inject(DestroyRef); + private readonly onDocumentKeydown = (event: KeyboardEvent): void => + this.handleKeydown(event); + + private dialogRef: MatDialogRef< + WorkspaceKeyboardShortcutsDialogComponent, + unknown + > | null = null; + + constructor() { + if (typeof document !== 'undefined') { + document.addEventListener('keydown', this.onDocumentKeydown); + this.destroyRef.onDestroy(() => { + document.removeEventListener('keydown', this.onDocumentKeydown); + }); + } + } + + openShortcutsDialog(): void { + if (this.dialogRef) { + return; + } + + const platform = this.getShortcutPlatform(); + const dialogRef = this.dialog.open< + WorkspaceKeyboardShortcutsDialogComponent, + WorkspaceKeyboardShortcutsDialogData + >(WorkspaceKeyboardShortcutsDialogComponent, { + width: 'min(960px, 94vw)', + maxWidth: '94vw', + panelClass: 'workspace-shortcuts-overlay', + autoFocus: false, + data: { + groups: getKeyboardShortcutGroups({ + isMac: platform === 'mac', + isElectron: this.isElectron(), + }), + platformIcon: + platform === 'mac' ? 'laptop_mac' : 'desktop_windows', + platformLabelKey: + platform === 'mac' + ? 'WORKSPACE.SHORTCUTS.PLATFORM.MAC' + : 'WORKSPACE.SHORTCUTS.PLATFORM.OTHER', + }, + }); + + this.dialogRef = dialogRef; + dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dialogRef = null; + }); + } + + private handleKeydown(event: KeyboardEvent): void { + if (isTypingInInput(event) || !isKeyboardShortcutHelpTrigger(event)) { + return; + } + + event.preventDefault(); + this.openShortcutsDialog(); + } + + private getShortcutPlatform(): 'mac' | 'other' { + if (typeof navigator === 'undefined') { + return 'other'; + } + + const navigatorWithUserAgentData = navigator as Navigator & { + userAgentData?: { platform?: string }; + }; + const platform = + navigatorWithUserAgentData.userAgentData?.platform || + navigator.userAgent; + + return /Mac|iPhone|iPad|iPod/i.test(platform) ? 'mac' : 'other'; + } + + private isElectron(): boolean { + return typeof window !== 'undefined' && !!window.electron; + } +} 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 01afaf1cd..ff09cfe6e 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 @@ -54,6 +54,15 @@ }
+