From 54265755eeb4abf5eebf7899f526420d9aee51a0 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 12 Jul 2026 13:24:58 +0200 Subject: [PATCH 001/106] fix(favorites): persist custom drag-and-drop order for Xtream favorites (#1143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(favorites): persist custom drag-and-drop order for Xtream favorites Prepared-statement writes dispatched via drizzle's `.execute()` on the better-sqlite3 driver return a promise and defer the write to a microtask. Inside a synchronous `db.transaction(() => ...)` callback (which cannot await), the transaction commits before that promise settles, so the write is a silent no-op — no error, no rows changed. This bit `reorderGlobalFavorites`: the custom favorites order never persisted for the per-playlist ("This playlist") Xtream scope, which relies solely on the `favorites.position` column. The global ("All playlists") scope masked the bug because it also persists an order to the `appState` `global-favorites-channel-order-v1` key and re-applies it on read. `removeRecentItemsBatch` had the same latent bug — batch "clear recent items" silently did nothing. Switch both writers to synchronous `.run()`. Add regression coverage that asserts `.run()` (not `.execute()`) is used and would fail on the old behavior, and document the gotcha in the DB worker architecture doc. Verified over CDP against a live Electron instance: reorder writes positions 0..N, and the order survives navigation and a full reload. Fixes #1137 Co-Authored-By: Claude Opus 4.8 * fix(favorites): scope reorder position writes by playlist The global favorites reorder wrote the new position filtering only by content_id, so two Xtream playlists holding a favorite with the same content_id would clobber each other's persisted order (greptile P1). Thread playlist_id through the whole reorder path — the renderer builder (UnifiedCollectionItem already carries playlistId), the IPC contract (ElectronBridgeFavoriteReorderUpdate + inline payload types), the worker op — and scope the prepared UPDATE by (contentId, playlistId), matching the favorites composite unique index. Tests: favorites.operations.spec asserts the playlistId placeholder and per-row playlistId payload; preload contract fixture updated. Co-Authored-By: Claude Opus 4.8 * fix(favorites): include playlist_id in workspace global favorites reorder payload The workspace global-favorites reorder path still sent updates with only content_id and position. Since the backend UPDATE is now scoped by (contentId, playlistId), that payload binds an undefined playlist id and matches no rows — the DB write silently no-ops (flagged by Greptile P1). Also scope the prepared-statement example in the sqlite-db-worker gotcha doc by (contentId, playlistId) so it no longer documents the cross-playlist rewrite this PR fixes (flagged by Codex P3). Regression spec asserts the reorder payload carries playlist_id per item (fails on the old payload shape) and that the appState uid order is still persisted for non-Xtream items. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Opus 4.8 --- .../src/app/api/main.preload.spec-data.ts | 4 +- .../src/app/api/main.preload.ts | 2 +- .../operations/favorites.operations.spec.ts | 77 +++++++++++- .../operations/favorites.operations.ts | 23 +++- .../operations/operations.test-helpers.ts | 25 +++- .../recently-viewed.operations.spec.ts | 13 +- .../operations/recently-viewed.operations.ts | 7 +- .../app/events/database/favorites.events.ts | 2 +- .../src/app/workers/database.worker.ts | 2 +- docs/architecture/sqlite-db-worker.md | 44 +++++++ .../unified-favorites-data.service.ts | 7 +- .../src/lib/electron-api.interface.ts | 2 + .../global-favorites.service.spec.ts | 116 ++++++++++++++++++ .../global-favorites.service.ts | 3 + 14 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 229524cf8..3b8b5cfab 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -34,7 +34,9 @@ const streams = [{ stream_id: 42, name: 'Channel' }]; const favorites = [{ contentId: 1, playlistId }]; const recentlyViewed = [{ contentId: 2, playlistId }]; const categoryIds = [10, 11]; -const reorderUpdates = [{ content_id: 12, position: 1 }]; +const reorderUpdates = [ + { content_id: 12, playlist_id: 'playlist-1', position: 1 }, +]; const recentItemsBatch = [{ contentId: 13, playlistId }]; const playbackData = { contentXtreamId: 42, diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 743220410..0110962a5 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -710,7 +710,7 @@ const electronApi: ElectronBridgeApi = { dbGetAllGlobalFavorites: () => ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'), dbReorderGlobalFavorites: ( - updates: { content_id: number; position: number }[] + updates: { content_id: number; playlist_id: string; position: number }[] ) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates), // Recently viewed (playlist-specific) dbGetRecentItems: (playlistId: string) => diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts index 6db591360..90b65e4de 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -4,6 +4,10 @@ const eqMock = jest.fn((left: unknown, right: unknown) => ({ right, })); const whereMock = jest.fn(); +const placeholderMock = jest.fn((name: string) => ({ + kind: 'placeholder', + name, +})); jest.mock('drizzle-orm', () => ({ and: jest.fn((...conditions: unknown[]) => ({ kind: 'and', conditions })), @@ -11,11 +15,22 @@ jest.mock('drizzle-orm', () => ({ desc: jest.fn((value: unknown) => ({ kind: 'desc', value })), eq: (left: unknown, right: unknown) => eqMock(left, right), inArray: jest.fn(), - sql: jest.fn(), + sql: Object.assign( + jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + kind: 'sql', + strings: Array.from(strings ?? []), + values, + })), + { placeholder: (name: string) => placeholderMock(name) } + ), })); import type { AppDatabase } from '../database.types'; -import { getGlobalFavorites } from './favorites.operations'; +import { createDbMock } from './operations.test-helpers'; +import { + getGlobalFavorites, + reorderGlobalFavorites, +} from './favorites.operations'; function createGlobalFavoritesDbMock(rows: unknown[]) { const query = { @@ -48,6 +63,7 @@ describe('favorites.operations', () => { beforeEach(() => { eqMock.mockClear(); whereMock.mockClear(); + placeholderMock.mockClear(); }); it('filters live global favorites after scanning the small favorites set', async () => { @@ -76,4 +92,61 @@ describe('favorites.operations', () => { }), ]); }); + + describe('reorderGlobalFavorites', () => { + it('short-circuits without touching the db when there are no updates', async () => { + const { db, update, transaction } = createDbMock(); + + await expect(reorderGlobalFavorites(db, [])).resolves.toEqual({ + success: true, + }); + + expect(update).not.toHaveBeenCalled(); + expect(transaction).not.toHaveBeenCalled(); + }); + + it('runs (not executes) the prepared position update per favorite inside a transaction', async () => { + const { db, updateRun, updateExecute, updatePrepare, transaction } = + createDbMock(); + + await expect( + reorderGlobalFavorites(db, [ + { content_id: 30, playlist_id: 'p1', position: 0 }, + { content_id: 10, playlist_id: 'p1', position: 1 }, + { content_id: 20, playlist_id: 'p2', position: 2 }, + ]) + ).resolves.toEqual({ success: true }); + + expect(updatePrepare).toHaveBeenCalledTimes(1); + expect(placeholderMock).toHaveBeenCalledWith('position'); + expect(placeholderMock).toHaveBeenCalledWith('contentId'); + // Regression: favorites are playlist-scoped, so the UPDATE must + // filter by playlistId too — otherwise a same-contentId favorite + // in another playlist gets its position silently rewritten. + expect(placeholderMock).toHaveBeenCalledWith('playlistId'); + expect(transaction).toHaveBeenCalledTimes(1); + + // Regression (issue #1137): the prepared UPDATE must be dispatched + // with synchronous `.run()`. On the better-sqlite3 driver + // `.execute()` defers the write to a promise that never settles + // inside the synchronous transaction callback, so favorites + // positions silently never persist and the custom order is lost. + expect(updateExecute).not.toHaveBeenCalled(); + expect(updateRun).toHaveBeenNthCalledWith(1, { + position: 0, + contentId: 30, + playlistId: 'p1', + }); + expect(updateRun).toHaveBeenNthCalledWith(2, { + position: 1, + contentId: 10, + playlistId: 'p1', + }); + expect(updateRun).toHaveBeenNthCalledWith(3, { + position: 2, + contentId: 20, + playlistId: 'p2', + }); + }); + }); }); diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index c0b402a63..8bb66e98d 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -140,7 +140,7 @@ function selectGlobalFavoriteRows( export async function reorderGlobalFavorites( db: AppDatabase, - updates: { content_id: number; position: number }[], + updates: { content_id: number; playlist_id: string; position: number }[], control?: OperationControl ): Promise<{ success: boolean }> { if (!Array.isArray(updates) || updates.length === 0) { @@ -152,20 +152,35 @@ export async function reorderGlobalFavorites( // Drizzle's .set() doesn't accept a bare Placeholder — wrap it in an // sql template so the value resolves to SQL at compile time. + // Scope by (contentId, playlistId): the favorites table is + // playlist-scoped, so filtering by contentId alone would also rewrite + // the position of a same-contentId favorite in another playlist. const updateFavoritePosition = db .update(schema.favorites) .set({ position: sql`${sql.placeholder('position')}` }) - .where(eq(schema.favorites.contentId, sql.placeholder('contentId'))) + .where( + and( + eq(schema.favorites.contentId, sql.placeholder('contentId')), + eq(schema.favorites.playlistId, sql.placeholder('playlistId')) + ) + ) .prepare(); for (const chunk of chunkValues(updates, DEFAULT_BATCH_SIZE)) { await checkpointOperation(control); await db.transaction(() => { - for (const { content_id, position } of chunk) { - updateFavoritePosition.execute({ + for (const { content_id, playlist_id, position } of chunk) { + // Must be .run() (synchronous), NOT .execute(): on the + // better-sqlite3 driver .execute() defers the write to a + // resolved promise, which never settles inside this + // synchronous transaction callback — the UPDATE would be a + // silent no-op and the custom favorites order would never + // persist (issue #1137). + updateFavoritePosition.run({ position, contentId: content_id, + playlistId: playlist_id, }); } }); diff --git a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts index cf6b3c49f..6a9c6904f 100644 --- a/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts +++ b/apps/electron-backend/src/app/database/operations/operations.test-helpers.ts @@ -112,14 +112,31 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { const insertValues = jest.fn().mockResolvedValue(undefined); const insert = jest.fn().mockReturnValue({ values: insertValues }); - const updateWhere = jest.fn().mockResolvedValue(undefined); + // Prepared-statement writes MUST use `.run()` (synchronous) rather than + // `.execute()` inside a synchronous `db.transaction()` callback — the + // better-sqlite3 driver's `.execute()` defers to a promise that never + // settles before COMMIT, silently dropping the write (issue #1137). The + // mock exposes both so specs can assert the correct method is used. + const updateRun = jest.fn(); + const updateExecute = jest.fn().mockResolvedValue(undefined); + const updatePrepare = jest + .fn() + .mockReturnValue({ run: updateRun, execute: updateExecute }); + const updateWhere = jest.fn().mockReturnValue({ + prepare: updatePrepare, + then: ( + resolve: (value: unknown) => void, + reject: (reason: unknown) => void + ) => Promise.resolve(undefined).then(resolve, reject), + }); const updateSet = jest.fn().mockReturnValue({ where: updateWhere }); const update = jest.fn().mockReturnValue({ set: updateSet }); + const deleteRun = jest.fn(); const deleteExecute = jest.fn().mockResolvedValue(undefined); const deletePrepare = jest .fn() - .mockReturnValue({ execute: deleteExecute }); + .mockReturnValue({ execute: deleteExecute, run: deleteRun }); const deleteResult = { prepare: deletePrepare, then: ( @@ -152,6 +169,7 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { deleteExecute, deleteFn, deletePrepare, + deleteRun, deleteWhere, insert, insertValues, @@ -159,6 +177,9 @@ export function createDbMock(selectResultsByCall: unknown[][] = []) { select, transaction, update, + updatePrepare, + updateRun, + updateExecute, updateSet, updateWhere, }; diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts index a1a151a8c..4dce217f1 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -200,8 +200,8 @@ describe('recently-viewed.operations', () => { expect(transaction).not.toHaveBeenCalled(); }); - it('executes one prepared placeholder delete per item inside a transaction', async () => { - const { db, deleteExecute, deletePrepare, transaction } = + it('runs one prepared placeholder delete per item inside a transaction', async () => { + const { db, deleteRun, deleteExecute, deletePrepare, transaction } = createDbMock(); await expect( @@ -215,11 +215,16 @@ describe('recently-viewed.operations', () => { expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('contentId'); expect(mockDrizzle.sql.placeholder).toHaveBeenCalledWith('playlistId'); expect(transaction).toHaveBeenCalledTimes(1); - expect(deleteExecute).toHaveBeenNthCalledWith(1, { + // Regression (issue #1137): the prepared delete must be dispatched + // with synchronous `.run()`. `.execute()` defers to a promise that + // never settles inside the synchronous transaction callback, so the + // batch delete would silently no-op. + expect(deleteExecute).not.toHaveBeenCalled(); + expect(deleteRun).toHaveBeenNthCalledWith(1, { contentId: 1, playlistId: 'playlist-1', }); - expect(deleteExecute).toHaveBeenNthCalledWith(2, { + expect(deleteRun).toHaveBeenNthCalledWith(2, { contentId: 2, playlistId: 'playlist-2', }); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index d1df9adff..fee6df185 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -177,7 +177,12 @@ export async function removeRecentItemsBatch( await db.transaction(() => { for (const { contentId, playlistId } of items) { - stmt.execute({ contentId, playlistId }); + // .run() (synchronous), NOT .execute(): the better-sqlite3 + // driver's .execute() defers the write to a resolved promise + // that never settles inside this synchronous transaction + // callback, so the DELETE would silently do nothing. See the + // matching note in favorites.operations.ts (issue #1137). + stmt.run({ contentId, playlistId }); } }); diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts index a0c77ae28..2daef247b 100644 --- a/apps/electron-backend/src/app/events/database/favorites.events.ts +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -39,7 +39,7 @@ handleWorkerRequest('DB_GET_ALL_GLOBAL_FAVORITES', () => ({})); handleWorkerRequest( 'DB_REORDER_GLOBAL_FAVORITES', - (updates: { content_id: number; position: number }[]) => ({ + (updates: { content_id: number; playlist_id: string; position: number }[]) => ({ updates, }) ); diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 868a856d5..3cfd67144 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -770,7 +770,7 @@ async function executeRequest(message: DbWorkerRequestMessage) { case 'DB_REORDER_GLOBAL_FAVORITES': { const payload = message.payload as { - updates: { content_id: number; position: number }[]; + updates: { content_id: number; playlist_id: string; position: number }[]; }; return reorderGlobalFavorites(db, payload.updates); } diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 09693bccd..4aaa17c61 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -496,6 +496,50 @@ A running Electron app keeps using the worker bundle it already loaded at startup. This is a common reason a worker fix appears "not working" in manual verification even when the source patch is correct. +## Gotchas + +### Prepared-statement writes inside a transaction must use `.run()`, not `.execute()` + +Drizzle's `PreparedQuery.execute()` on the `better-sqlite3` driver returns a +**promise** and defers the actual SQL to a microtask. Our bulk writers run their +statements inside a **synchronous** `db.transaction(() => { ... })` callback, +which cannot `await`. If the statement is dispatched with `.execute()`, the +transaction commits before the deferred promise settles, so the write is a +**silent no-op** — no error, no rows changed. + +Always call the synchronous `.run(placeholderValues)` on prepared statements +executed inside a synchronous transaction callback: + +```ts +// favorites is playlist-scoped: filter by (contentId, playlistId), otherwise +// a same-contentId favorite in another playlist gets rewritten too. +const stmt = db.update(schema.favorites) + .set({ position: sql`${sql.placeholder('position')}` }) + .where( + and( + eq(schema.favorites.contentId, sql.placeholder('contentId')), + eq(schema.favorites.playlistId, sql.placeholder('playlistId')) + ) + ) + .prepare(); + +db.transaction(() => { + for (const { content_id, playlist_id, position } of chunk) { + // NOT .execute() + stmt.run({ position, contentId: content_id, playlistId: playlist_id }); + } +}); +``` + +This bit `reorderGlobalFavorites` and `removeRecentItemsBatch` (issue #1137): +custom favorites drag-and-drop order silently never persisted for the +per-playlist ("this playlist") view. Global ("all playlists") favorites masked +it because that path also persists an order to the `appState` +`global-favorites-channel-order-v1` key and re-applies it on read, independent +of the DB `position` column. The mocked operations specs did not catch it — +a jest mock records an `.execute()` call the same as a `.run()` call, so the +regression tests explicitly assert `.run()` is used and `.execute()` is not. + ## Testing ### Unit coverage added diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 096fdc6a8..4dbf97134 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -807,12 +807,17 @@ export class UnifiedFavoritesDataService { } private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) { - const updates: { content_id: number; position: number }[] = []; + const updates: { + content_id: number; + playlist_id: string; + position: number; + }[] = []; for (const item of items) { if (item.sourceType === 'xtream' && item.contentId != null) { updates.push({ content_id: item.contentId, + playlist_id: item.playlistId, position: updates.length, }); } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 73d222fcb..3ad7c5b5e 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -414,6 +414,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr export interface ElectronBridgeFavoriteReorderUpdate { content_id: number; + /** Favorites are playlist-scoped — scope the position write per playlist */ + playlist_id: string; position: number; } diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts new file mode 100644 index 000000000..34ae1087e --- /dev/null +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { UnifiedFavoriteChannel } from '@iptvnator/portal/shared/util'; +import { GlobalFavoritesService } from './global-favorites.service'; + +describe('GlobalFavoritesService', () => { + let service: GlobalFavoritesService; + let electronApi: { + dbReorderGlobalFavorites: jest.Mock; + dbSetAppState: jest.Mock; + }; + + const makeChannel = ( + overrides: Partial & + Pick + ): UnifiedFavoriteChannel => ({ + name: 'Channel', + logo: null, + playlistName: 'Playlist', + addedAt: new Date(0).toISOString(), + position: 0, + ...overrides, + }); + + beforeEach(() => { + electronApi = { + dbReorderGlobalFavorites: jest + .fn() + .mockResolvedValue({ success: true }), + dbSetAppState: jest.fn().mockResolvedValue({ success: true }), + }; + Object.defineProperty(window, 'electron', { + value: electronApi as unknown as Window['electron'], + configurable: true, + }); + + TestBed.configureTestingModule({ + providers: [ + GlobalFavoritesService, + { provide: Store, useValue: { select: jest.fn() } }, + { provide: DatabaseService, useValue: {} }, + { provide: PlaylistsService, useValue: {} }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, + ], + }); + service = TestBed.inject(GlobalFavoritesService); + }); + + afterEach(() => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + }); + + describe('reorder', () => { + it('sends playlist-scoped position updates for Xtream favorites', async () => { + // The backend UPDATE filters by (contentId, playlistId); a payload + // without playlist_id silently matches no rows (PR #1143 review). + const channels: UnifiedFavoriteChannel[] = [ + makeChannel({ + uid: 'xtream::playlist-b::20', + sourceType: 'xtream', + playlistId: 'playlist-b', + contentId: 202, + }), + makeChannel({ + uid: 'm3u::playlist-m::url', + sourceType: 'm3u', + playlistId: 'playlist-m', + }), + makeChannel({ + uid: 'xtream::playlist-a::10', + sourceType: 'xtream', + playlistId: 'playlist-a', + contentId: 101, + }), + ]; + + await service.reorder(channels); + + expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([ + { content_id: 202, playlist_id: 'playlist-b', position: 0 }, + { content_id: 101, playlist_id: 'playlist-a', position: 1 }, + ]); + }); + + it('persists the full uid order and skips the DB write without Xtream items', async () => { + const channels: UnifiedFavoriteChannel[] = [ + makeChannel({ + uid: 'm3u::playlist-m::url', + sourceType: 'm3u', + playlistId: 'playlist-m', + }), + makeChannel({ + uid: 'stalker::playlist-s::5', + sourceType: 'stalker', + playlistId: 'playlist-s', + }), + ]; + + await service.reorder(channels); + + expect(electronApi.dbReorderGlobalFavorites).not.toHaveBeenCalled(); + expect(electronApi.dbSetAppState).toHaveBeenCalledWith( + 'global-favorites-channel-order-v1', + JSON.stringify(['m3u::playlist-m::url', 'stalker::playlist-s::5']) + ); + }); + }); +}); diff --git a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts index bfea67259..83506948e 100644 --- a/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts +++ b/libs/workspace/shell/feature/src/lib/global-favorites/global-favorites.service.ts @@ -141,6 +141,9 @@ export class GlobalFavoritesService { ) .map((ch, index) => ({ content_id: ch.contentId, + // The backend UPDATE is scoped by (contentId, playlistId) — + // without the playlist id the write matches no rows. + playlist_id: ch.playlistId, position: index, })); From a51c537bb2f75924cf2c5494f32b168b8aaeb50a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:24:49 +0200 Subject: [PATCH 002/106] fix(theme): make scrollbars follow the app theme instead of the OS color scheme (#1179) On Windows with a light OS theme, scrollbars rendered light even when the app was switched to dark. Two combined causes: - The page never declared `color-scheme`, so Chromium colored native scrollbars from the OS preference. Declare `color-scheme: light` on html and flip it to `dark` via `html:has(> body.dark-theme)` plus the `.dark-theme` block itself. - Scrollbar styling referenced `--mat-sys-*` tokens, which are never emitted by the current Material theme setup (mat.define-theme + all-component-themes does not produce system tokens). Those `scrollbar-color` declarations computed to `auto`, falling back to the native (light) scrollbar. Switch scrollbar styling to the `--app-muted-color` design token (defined for both themes), replace hardcoded white `rgba(255,255,255,.08)` thumbs, and add an explicit `scrollbar-color` where only `scrollbar-width: thin` was set. Verified live in Electron via CDP in both themes: scrollbar-color resolves and scrollbars render dark in dark theme regardless of the OS setting. Co-authored-by: Claude Fable 5 --- apps/web/src/styles.scss | 23 +++++++++++++++++-- .../category-content-view.component.scss | 3 ++- .../live-stream-layout.component.scss | 3 ++- .../epg-progress-panel.component.scss | 2 ++ .../embedded-mpv-player.component.scss | 2 ++ libs/ui/styles/_detail-view.scss | 6 +++++ .../workspace-command-palette.component.scss | 3 ++- .../workspace-context-panel.component.scss | 3 ++- 8 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 60e038639..4e9fc76c3 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -108,6 +108,20 @@ body.frameless-platform #epg-navigation { } // ─── Base ───────────────────────────────────────────────────────────────────── +// Native UI (scrollbars, form controls, popups) must follow the app theme, +// not the OS preference. Chromium colors unstyled/native scrollbars from the +// element's color-scheme, so declare it explicitly for both themes. +html { + color-scheme: light; +} + +// The root scroller (html) sits outside body.dark-theme, so flip it via +// :has(); the color-scheme declaration in .dark-theme below keeps the body +// subtree covered in engines without :has() support. +html:has(> body.dark-theme) { + color-scheme: dark; +} + body, html { user-select: none; @@ -155,6 +169,7 @@ textarea, // ─── Dark Theme ─────────────────────────────────────────────────────────────── .dark-theme { background: var(--mat-sys-surface) !important; + color-scheme: dark; // Refined scrollbar for dark mode ::-webkit-scrollbar { @@ -167,11 +182,15 @@ textarea, } ::-webkit-scrollbar-thumb { - background-color: var(--mat-sys-outline-variant); + background-color: color-mix( + in srgb, + var(--app-muted-color) 70%, + transparent + ); border-radius: 999px; &:hover { - background-color: var(--mat-sys-outline); + background-color: var(--app-muted-color); } } } diff --git a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss index 36340c715..a55858215 100644 --- a/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss +++ b/libs/portal/catalog/feature/src/lib/category-content-view/category-content-view.component.scss @@ -245,7 +245,8 @@ app-grid-list { padding: 16px 20px 20px; display: block; scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.08) transparent; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } @media (max-width: 768px) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss index ee772f77f..6bbc80b9f 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.scss @@ -77,7 +77,8 @@ app-grid-list.live-all-items-grid { overflow-y: auto; padding: 16px 20px 20px; scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.08) transparent; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } .loading-indicator { diff --git a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss index f3d46d3c2..61914a45c 100644 --- a/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss +++ b/libs/ui/epg/src/lib/epg-progress-panel/epg-progress-panel.component.scss @@ -120,6 +120,8 @@ overflow-y: auto; padding: 4px 0; scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } .import-item { diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss index 94e4fca2d..2206462b6 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-player.component.scss @@ -454,6 +454,8 @@ max-height: 240px; overflow-y: auto; scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } .embedded-mpv-player__audio-track { diff --git a/libs/ui/styles/_detail-view.scss b/libs/ui/styles/_detail-view.scss index 49f8c0f50..e07d954cc 100644 --- a/libs/ui/styles/_detail-view.scss +++ b/libs/ui/styles/_detail-view.scss @@ -223,6 +223,12 @@ overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; + scrollbar-color: color-mix( + in srgb, + var(--app-muted-color) 55%, + transparent + ) + transparent; } &__similar-card { diff --git a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss index d1ac130d1..1fe3199f0 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-command-palette/workspace-command-palette.component.scss @@ -89,7 +89,8 @@ overflow-x: hidden; padding: 6px; scrollbar-width: thin; - scrollbar-color: var(--mat-sys-outline-variant) transparent; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } // ─── Group ─────────────────────────────────────────────────────────────────── diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss index e2e03a3f5..084577aca 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.scss @@ -177,7 +177,8 @@ app-workspace-context-category-view { overflow-x: hidden; overscroll-behavior: contain; scrollbar-width: thin; - scrollbar-color: var(--mat-sys-outline-variant) transparent; + scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent) + transparent; } .context-skeleton { From 5dbf20593511ccb909454fbb018ebd33bbd73239 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:50:35 +0200 Subject: [PATCH 003/106] fix(lint): quote eslint globs in web-backend and database lint targets (#1177) The unquoted globs in these two nx:run-commands lint targets are expanded by the POSIX shell on Linux CI, which matches only a shallow subset of files, while on Windows the literal pattern reaches ESLint and the full tree is linted. Quoting the glob (as tools/packaging already does) makes both platforms lint the same complete file set. Full-tree lint of both projects passes on Windows, so the widened Linux coverage introduces no new errors. Co-authored-by: Claude Fable 5 --- apps/web-backend/project.json | 2 +- libs/shared/database/project.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web-backend/project.json b/apps/web-backend/project.json index a29059af2..075b3c30f 100644 --- a/apps/web-backend/project.json +++ b/apps/web-backend/project.json @@ -37,7 +37,7 @@ } }, "lint": { - "command": "eslint apps/web-backend/**/*.ts" + "command": "eslint \"apps/web-backend/**/*.ts\"" }, "test": { "executor": "@nx/jest:jest", diff --git a/libs/shared/database/project.json b/libs/shared/database/project.json index 4898aea9e..5c1c44099 100644 --- a/libs/shared/database/project.json +++ b/libs/shared/database/project.json @@ -25,7 +25,7 @@ } }, "lint": { - "command": "eslint libs/shared/database/**/*.ts" + "command": "eslint \"libs/shared/database/**/*.ts\"" } } } From b6e1c1db168d78abd6bd67b3ed390f4ea7aeddd8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:36:08 +0200 Subject: [PATCH 004/106] fix(e2e): stop saveSettings hanging on a spurious form-submit navigation wait (#1180) The shared saveSettings helper clicks the settings save control, which is a native ` + + @if (capabilities().seriesNavigation) { + + + + } + + @if (capabilities().seek) { + + + + } + + +
+ @if (capabilities().seek) { + + } +
+ {{ formatTime(timelineValue()) }} + @if (state().canSeek) { + {{ formatTime(timelineDuration()) }} + } @else if (state().isLive) { + + + LIVE + + } @else { + --:-- + } +
+ @if (recordingStatusText(); as recordingStatus) { +
+ {{ + isRecording() ? 'fiber_manual_record' : 'check_circle' + }} + {{ recordingStatus }} +
+ } +
+ +
+ @if (capabilities().volume) { +
+ + @if (menus.volumeOpen()) { + + } +
+ } + + @if (hasAudioTracks()) { +
+ + @if (menus.audioOpen()) { + + } +
+ } + + @if (hasSubtitleTracks()) { +
+ + @if (menus.subtitleOpen()) { + + } +
+ } + + @if (capabilities().playbackSpeed) { +
+ + @if (menus.speedOpen()) { + + } +
+ } + + @if (capabilities().aspectRatio) { +
+ + @if (menus.aspectOpen()) { + + } +
+ } + + @if (canRecord()) { + + } + + @if (capabilities().fullscreen) { + + } +
+ +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts new file mode 100644 index 000000000..3bc3f388f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.interactions.spec.ts @@ -0,0 +1,370 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent interactions', () => { + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + const queryAll = (selector: string) => + Array.from( + fixture.nativeElement.querySelectorAll(selector) + ) as HTMLElement[]; + + beforeEach(async () => { + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + describe('timeline', () => { + it('ignores seekBy while the stream cannot seek', () => { + setState({ canSeek: false }); + fixture.detectChanges(); + + component.seekBy(10); + expect(fake.commands.seekBy).not.toHaveBeenCalled(); + expect(component.feedback.current()).toBeNull(); + }); + + it('flashes seek feedback with a signed label', () => { + jest.useFakeTimers(); + setCapabilities({ seek: true }); + setState({ canSeek: true, durationSeconds: 600 }); + fixture.detectChanges(); + + component.seekBy(-10); + fixture.detectChanges(); + + expect(fake.commands.seekBy).toHaveBeenCalledWith(-10); + const feedback = query('.player-controls__feedback'); + expect(feedback?.textContent).toContain('replay_10'); + expect(feedback?.textContent).toContain('-10s'); + + jest.advanceTimersByTime(800); + fixture.detectChanges(); + expect(query('.player-controls__feedback')).toBeNull(); + jest.useRealTimers(); + }); + }); + + describe('volume', () => { + beforeEach(() => { + setCapabilities({ volume: true }); + fixture.detectChanges(); + }); + + it('opens the popover on hover and applies slider input', () => { + component.onVolumeHoverEnter(); + fixture.detectChanges(); + + const slider = query( + '.player-controls__slider--volume' + ) as HTMLInputElement; + expect(slider).not.toBeNull(); + + slider.value = '0.3'; + slider.dispatchEvent(new Event('input')); + expect(fake.commands.setVolume).toHaveBeenCalledWith(0.3); + expect(component.displayVolume()).toBe(0.3); + }); + + it('closes the popover shortly after hover leave', () => { + jest.useFakeTimers(); + component.onVolumeHoverEnter(); + fixture.detectChanges(); + expect(component.menus.volumeOpen()).toBe(true); + + component.onVolumeHoverLeave(); + jest.advanceTimersByTime(300); + fixture.detectChanges(); + expect(component.menus.volumeOpen()).toBe(false); + jest.useRealTimers(); + }); + + it('adjusts volume via wheel and flashes feedback', () => { + component.onVolumeWheel( + new WheelEvent('wheel', { deltaY: 100, cancelable: true }) + ); + expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95); + expect(component.feedback.current()?.label).toBe('95%'); + + component.onVolumeWheel(new WheelEvent('wheel', { deltaY: -100 })); + expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1); + }); + + it('mutes on click and restores the previous volume on unmute', () => { + const muteButton = query('[aria-label="EMBEDDED_MPV.PLAYER.MUTE"]'); + muteButton?.click(); + fixture.detectChanges(); + + expect(fake.commands.setVolume).toHaveBeenCalledWith(0); + expect(component.displayVolume()).toBe(0); + expect(component.volumeIcon()).toBe('volume_off'); + + query('[aria-label="EMBEDDED_MPV.PLAYER.UNMUTE"]')?.click(); + expect(fake.commands.setVolume).toHaveBeenLastCalledWith(1); + expect(component.displayVolume()).toBe(1); + }); + + it('reconciles the optimistic volume with controller state', () => { + setState({ volume: 0.25 }); + fixture.detectChanges(); + expect(component.displayVolume()).toBe(0.25); + expect(component.volumePercent()).toBe(25); + }); + + it('keeps optimistic volume across capability and visibility changes', () => { + component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 })); + expect(component.displayVolume()).toBe(0.95); + + setState({ positionSeconds: 15, volume: 1 }); + fixture.detectChanges(); + expect(component.displayVolume()).toBe(0.95); + + setCapabilities({ volume: true, playbackSpeed: true }); + fixture.detectChanges(); + expect(component.displayVolume()).toBe(0.95); + + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + expect(component.displayVolume()).toBe(0.95); + }); + + it('reapplies persisted volume when the capability returns', () => { + component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 })); + fake.commands.setVolume.mockClear(); + + setCapabilities({ volume: false }); + setState({ volume: 1 }); + fixture.detectChanges(); + setCapabilities({ volume: true }); + fixture.detectChanges(); + + expect(fake.commands.setVolume).toHaveBeenCalledTimes(1); + expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95); + expect(component.displayVolume()).toBe(0.95); + }); + + it('reconciles volume when the controller changes at the same value', () => { + component.onVolumeWheel(new WheelEvent('wheel', { deltaY: 100 })); + expect(component.displayVolume()).toBe(0.95); + localStorage.removeItem('volume'); + + const replacement = createFakeController(); + replacement.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + volume: true, + }); + fixture.componentRef.setInput('controller', replacement.controller); + fixture.detectChanges(); + + expect(component.displayVolume()).toBe(1); + }); + }); + + describe('menu selections', () => { + it('applies a speed preset and closes the menu', () => { + setCapabilities({ playbackSpeed: true }); + fixture.detectChanges(); + + query('[aria-label="EMBEDDED_MPV.PLAYER.PLAYBACK_SPEED"]')?.click(); + fixture.detectChanges(); + expect(component.menus.speedOpen()).toBe(true); + + const preset = queryAll('.player-controls__track').find((item) => + item.textContent?.includes('1.5×') + ); + preset?.click(); + fixture.detectChanges(); + + expect(fake.commands.setPlaybackSpeed).toHaveBeenCalledWith(1.5); + expect(component.menus.speedOpen()).toBe(false); + }); + + it('applies an aspect preset and closes the menu', () => { + setCapabilities({ aspectRatio: true }); + fixture.detectChanges(); + + query('[aria-label="EMBEDDED_MPV.PLAYER.ASPECT_RATIO"]')?.click(); + fixture.detectChanges(); + + const preset = queryAll('.player-controls__track').find((item) => + item.textContent?.includes('16:9') + ); + preset?.click(); + fixture.detectChanges(); + + expect(fake.commands.setAspectRatio).toHaveBeenCalledWith('16:9'); + expect(component.menus.aspectOpen()).toBe(false); + }); + + it('disables subtitles via the Off entry', () => { + setCapabilities({ subtitles: true }); + setState({ + subtitleTracks: [{ id: 1, label: 'English', selected: true }], + subtitlesEnabled: true, + }); + fixture.detectChanges(); + + query('[aria-label="EMBEDDED_MPV.PLAYER.SUBTITLES"]')?.click(); + fixture.detectChanges(); + + queryAll('.player-controls__track')[0]?.click(); + expect(fake.commands.setSubtitleTrack).toHaveBeenCalledWith(-1); + expect(component.menus.subtitleOpen()).toBe(false); + }); + + it('opening one menu closes the others', () => { + setCapabilities({ playbackSpeed: true, aspectRatio: true }); + fixture.detectChanges(); + + component.toggleMenu('speed'); + component.toggleMenu('aspect'); + expect(component.menus.speedOpen()).toBe(false); + expect(component.menus.aspectOpen()).toBe(true); + expect(component.anyMenuOpen()).toBe(true); + }); + }); + + describe('recording', () => { + it('does nothing when recording is not available', () => { + component.toggleRecording(); + expect(fake.commands.toggleRecording).not.toHaveBeenCalled(); + }); + + it('toggles recording when capable and live', () => { + setCapabilities({ recording: true }); + setState({ isLive: true }); + fixture.detectChanges(); + + query('.player-controls__record-button')?.click(); + expect(fake.commands.toggleRecording).toHaveBeenCalledTimes(1); + }); + + it('flashes feedback on recording start and stop transitions', () => { + const translate = TestBed.inject(TranslateService); + translate.setTranslation('de', { + EMBEDDED_MPV: { + PLAYER: { + RECORDING: 'Aufnahme', + RECORDING_SAVED: 'Aufnahme gespeichert', + }, + }, + }); + translate.use('de'); + + setState({ + isLive: true, + recording: { active: true, elapsedSeconds: 0, message: null }, + }); + fixture.detectChanges(); + expect(component.feedback.current()?.label).toBe('Aufnahme'); + + setState({ isLive: true }); + fixture.detectChanges(); + expect(component.feedback.current()?.label).toBe( + 'Aufnahme gespeichert' + ); + }); + }); + + describe('reveal/hide interplay', () => { + beforeEach(() => { + jest.useFakeTimers(); + setState({ status: 'playing' }); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('keeps the controls visible while a menu is open, hides after close', () => { + setCapabilities({ playbackSpeed: true }); + fixture.detectChanges(); + component.toggleMenu('speed'); + fixture.detectChanges(); + + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(true); + + component.menuSelection.speed(1.25); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(false); + }); + + it('stays visible while a status message is showing', () => { + setState({ status: 'playing', statusMessage: 'Buffering…' }); + fixture.detectChanges(); + + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(true); + }); + + it('re-reveals hidden controls on reveal()', () => { + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(false); + + component.reveal(); + fixture.detectChanges(); + expect(component.controlsAreVisible()).toBe(true); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss new file mode 100644 index 000000000..c3de35ba9 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -0,0 +1,469 @@ +:host { + --pc-accent: #ffffff; + --pc-track: rgba(255, 255, 255, 0.3); + --pc-thumb-ring: rgba(255, 255, 255, 0.95); + --pc-panel-bg: rgba(18, 18, 20, 0.92); + --pc-panel-border: rgba(255, 255, 255, 0.16); + --pc-text: #ffffff; + --pc-text-dim: rgba(255, 255, 255, 0.82); + + position: absolute; + inset: 0; + z-index: 2; + display: block; + // The control layer is transparent to the mouse so the video underneath + // stays interactive; only the bar/popovers opt back in to pointer events. + pointer-events: none; + color: var(--pc-text); + // Become a query container so popovers can size against the actual player + // box (the host fills the player via inset:0) instead of fixed pixels or + // the viewport — they grow to fit when there is room and shrink to scroll + // when the player is small, never spilling past its edges. + container-type: size; + container-name: player-controls; +} + +:host(.player-controls-host--cursor-hidden) { + cursor: none; +} + +.player-controls__feedback { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: inline-flex; + align-items: center; + gap: 10px; + padding: 10px 18px; + color: #ffffff; + background: rgba(0, 0, 0, 0.65); + border-radius: 999px; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + pointer-events: none; + z-index: 3; + animation: player-controls-feedback 700ms ease-out forwards; +} + +.player-controls__feedback mat-icon { + font-size: 22px; + width: 22px; + height: 22px; + color: #ffffff; +} + +@keyframes player-controls-feedback { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + } + 18% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 70% { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1); + } +} + +.player-controls__bar { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 16px; + align-items: center; + align-content: end; + min-height: 64px; + // Generous top padding lets the scrim rise above the controls and fade out. + padding: 48px 16px 12px; + opacity: 0; + pointer-events: none; + transform: translateY(8px); + // True-overlay scrim: opaque-ish black at the bottom fading to fully + // transparent at the top, so controls read clearly over full-bleed video. + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.78) 0%, + rgba(0, 0, 0, 0.45) 45%, + transparent 100% + ); + transition: + opacity 180ms ease-out, + transform 200ms ease-out; +} + +.player-controls__bar--visible { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.player-controls__transport { + display: flex; + align-items: center; + gap: 8px; +} + +.player-controls__timeline { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.player-controls__time { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + color: var(--pc-text-dim); + font-size: 0.78rem; + font-variant-numeric: tabular-nums; +} + +.player-controls__recording-status { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + color: rgba(255, 255, 255, 0.78); + font-size: 0.72rem; + font-variant-numeric: tabular-nums; +} + +.player-controls__recording-status mat-icon { + flex: 0 0 auto; + width: 16px; + height: 16px; + font-size: 16px; + color: #ffffff; +} + +.player-controls__recording-status span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__recording-status--active { + color: var(--mat-sys-error, #ff5252); + font-weight: 700; +} + +.player-controls__recording-status--active mat-icon { + color: var(--mat-sys-error, #ff5252); +} + +.player-controls__live-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 4px; + color: #ffffff; + background: var(--mat-sys-error, #e53935); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; +} + +.player-controls__live-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + animation: player-controls-live-pulse 1.6s ease-in-out infinite; +} + +@keyframes player-controls-live-pulse { + 50% { + opacity: 0.35; + } +} + +.player-controls__actions { + display: flex; + align-items: center; + gap: 8px; +} + +.player-controls__popover-anchor { + position: relative; + display: flex; +} + +.player-controls__record-button--active { + color: var(--mat-sys-error, #ff5252) !important; +} + +// Scope to :host — the controls render directly under the component host +// (`.player-controls-host`); there is no `.player-controls` wrapper element, so +// selectors prefixed with `.player-controls ` never matched (which is why the +// icons were only white on :hover, the one rule scoped to `.player-controls__bar`). +:host :is(button[mat-icon-button]) { + // Material drives the icon colour through MDC tokens, not plain `color`, + // so force both — otherwise the icons render in the dim theme colour. + color: #ffffff; + --mdc-icon-button-icon-color: #ffffff; + --mat-icon-button-icon-color: #ffffff; +} + +:host :is(button[mat-icon-button]) mat-icon { + color: #ffffff; +} + +:host :is(button[mat-icon-button][disabled]) { + // Keep disabled affordances visibly muted. + --mdc-icon-button-icon-color: rgba(255, 255, 255, 0.38); + color: rgba(255, 255, 255, 0.38); +} + +:host :is(button[mat-icon-button][disabled]) mat-icon { + color: rgba(255, 255, 255, 0.38); +} + +:host .player-controls__bar :is(button[mat-icon-button]:hover) { + color: #ffffff; + background: rgba(255, 255, 255, 0.16); +} + +.player-controls__slider { + --slider-progress: 0%; + width: 100%; + height: 18px; + appearance: none; + -webkit-appearance: none; + background: transparent; + cursor: pointer; +} + +.player-controls__slider:focus-visible { + outline: 2px solid var(--pc-accent); + outline-offset: 4px; + border-radius: 4px; +} + +.player-controls__slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: linear-gradient( + to right, + var(--pc-accent) var(--slider-progress), + var(--pc-track) var(--slider-progress) + ); +} + +.player-controls__slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + margin-top: -5px; + border: 2px solid var(--pc-thumb-ring); + border-radius: 50%; + background: var(--pc-accent); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + transition: transform 120ms ease-out; +} + +.player-controls__slider:hover::-webkit-slider-thumb, +.player-controls__slider:focus-visible::-webkit-slider-thumb { + transform: scale(1.18); +} + +.player-controls__slider:disabled::-webkit-slider-runnable-track { + background: var(--pc-track); +} + +.player-controls__slider:disabled::-webkit-slider-thumb { + background: rgba(255, 255, 255, 0.45); + border-color: rgba(255, 255, 255, 0.6); + box-shadow: none; +} + +.player-controls__slider::-moz-range-track { + height: 4px; + border-radius: 999px; + background: var(--pc-track); +} + +.player-controls__slider::-moz-range-progress { + height: 4px; + border-radius: 999px; + background: var(--pc-accent); +} + +.player-controls__slider::-moz-range-thumb { + width: 14px; + height: 14px; + border: 2px solid var(--pc-thumb-ring); + border-radius: 50%; + background: var(--pc-accent); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.55); + transition: transform 120ms ease-out; +} + +.player-controls__slider:hover::-moz-range-thumb, +.player-controls__slider:focus-visible::-moz-range-thumb { + transform: scale(1.18); +} + +.player-controls__slider:disabled::-moz-range-thumb { + background: rgba(255, 255, 255, 0.45); + border-color: rgba(255, 255, 255, 0.6); + box-shadow: none; +} + +.player-controls__panel { + min-width: 0; + color: var(--pc-text); + background: var(--pc-panel-bg); + border: 1px solid var(--pc-panel-border); + border-radius: 14px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(18px); +} + +.player-controls__volume-popover { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + display: grid; + grid-template-columns: 1fr; + gap: 4px; + // Comfortable fixed width for the slider; only clamp on players too narrow + // to fit it so the popover never spills past the player's edges. + width: 168px; + max-width: calc(100cqw - 24px); + padding: 12px 14px 10px; + z-index: 3; +} + +.player-controls__volume-popover::before { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -8px; + height: 8px; +} + +.player-controls__volume-popover .player-controls__slider--volume { + width: 100%; +} + +.player-controls__volume-value { + color: var(--pc-text-dim); + font-size: 0.74rem; + font-variant-numeric: tabular-nums; + text-align: center; + white-space: nowrap; +} + +.player-controls__track-popover { + position: absolute; + bottom: calc(100% + 6px); + right: 0; + display: flex; + flex-direction: column; + min-width: 220px; + // Cap to the player box (minus a small gutter) so the panel never spills + // past the player's edges on small windows; falls back to the viewport + // where container units are unavailable. + max-width: min(420px, calc(100cqw - 24px), 80vw); + // Height grows to fit the list; only when it would exceed the room above + // the controls bar does the inner list start scrolling. 96px ≈ the controls + // bar height plus the 6px gap and a little breathing room. + max-height: min(calc(100cqh - 96px), 70vh); + padding: 10px 12px; + z-index: 3; +} + +.player-controls__menu-title { + flex: 0 0 auto; + padding: 0 2px 6px; + color: rgba(255, 255, 255, 0.7); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + +.player-controls__track-list { + display: flex; + flex-direction: column; + gap: 4px; + // No fixed cap: the panel's max-height bounds the popover, and the title + // above stays pinned while this list takes the remaining space and scrolls + // only when the content genuinely exceeds it. + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; +} + +.player-controls__track { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + color: var(--pc-text); + background: transparent; + border: 0; + border-radius: 10px; + cursor: pointer; + font: inherit; + text-align: left; +} + +.player-controls__track span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.player-controls__track:hover, +.player-controls__track:focus-visible, +.player-controls__track--selected { + background: rgba(255, 255, 255, 0.16); +} + +.player-controls__track mat-icon { + color: #ffffff; + font-size: 18px; + width: 18px; + height: 18px; +} + +@media (max-width: 720px) { + // Keep the single-row layout when space is tight: the transport and action + // buttons hold their natural width while the middle timeline column + // (the seek slider) absorbs the shrink. Collapsing to a single column here + // is what used to push the transport buttons into a row above the slider. + .player-controls__bar { + gap: 8px; + } + + .player-controls__actions { + min-width: 0; + } + + .player-controls__volume-popover, + .player-controls__track-popover { + right: 0; + left: auto; + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts new file mode 100644 index 000000000..f6c707629 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.spec.ts @@ -0,0 +1,376 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { DEFAULT_PLAYER_CAPABILITIES } from './player-controls-defaults'; +import { createEmptyControlsState } from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent', () => { + let fixture: ComponentFixture; + let fake: ReturnType; + + const setCapabilities = ( + overrides: Partial + ) => { + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + ...overrides, + }); + }; + + const setState = (overrides: Partial) => { + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + }; + + const query = (selector: string) => + fixture.nativeElement.querySelector(selector) as HTMLElement | null; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + // Load the real English values so aria-labels resolve to the same text + // the capability/command specs assert against. + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + PLAY: 'Play', + PAUSE: 'Pause', + PREVIOUS_EPISODE: 'Previous episode', + NEXT_EPISODE: 'Next episode', + BACK_10_SECONDS: 'Back 10 seconds', + FORWARD_10_SECONDS: 'Forward 10 seconds', + LIVE_STREAM: 'Live stream', + PLAYBACK_POSITION: 'Playback position', + MUTE: 'Mute', + UNMUTE: 'Unmute', + VOLUME: 'Volume', + VOLUME_LABEL: 'Volume {{percent}}%', + MUTED: 'Muted', + AUDIO_TRACKS: 'Audio tracks', + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + PLAYBACK_SPEED: 'Playback speed', + SPEED_TOOLTIP: 'Speed: {{speed}}', + ASPECT_RATIO: 'Aspect ratio', + ASPECT_DEFAULT: 'Default', + START_RECORDING: 'Start recording', + STOP_RECORDING: 'Stop recording', + ENTER_FULLSCREEN: 'Enter fullscreen', + EXIT_FULLSCREEN: 'Exit fullscreen', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + describe('capability gating', () => { + it('hides every optional control when no capability is enabled', () => { + expect(query('[aria-label="Back 10 seconds"]')).toBeNull(); + expect(query('[aria-label="Audio tracks"]')).toBeNull(); + expect(query('[aria-label="Subtitles"]')).toBeNull(); + expect(query('[aria-label="Playback speed"]')).toBeNull(); + expect(query('[aria-label="Aspect ratio"]')).toBeNull(); + expect(query('.player-controls__record-button')).toBeNull(); + expect(query('[aria-label="Enter fullscreen"]')).toBeNull(); + expect(query('[aria-label="Mute"]')).toBeNull(); + expect( + query('[data-test-id="player-controls-previous-episode"]') + ).toBeNull(); + }); + + it('shows seek controls only when the seek capability is on', () => { + setCapabilities({ seek: true }); + fixture.detectChanges(); + expect(query('[aria-label="Back 10 seconds"]')).not.toBeNull(); + expect(query('[aria-label="Forward 10 seconds"]')).not.toBeNull(); + }); + + it('shows the recording button only when recording is enabled and live', () => { + setCapabilities({ recording: true }); + setState({ isLive: true }); + fixture.detectChanges(); + expect(query('.player-controls__record-button')).not.toBeNull(); + + setState({ isLive: false }); + fixture.detectChanges(); + expect(query('.player-controls__record-button')).toBeNull(); + }); + + it('shows the audio menu only with the capability and more than one track', () => { + setCapabilities({ audioTracks: true }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + }); + fixture.detectChanges(); + expect(query('[aria-label="Audio tracks"]')).not.toBeNull(); + }); + + it('shows speed and aspect controls per their capabilities', () => { + setCapabilities({ playbackSpeed: true, aspectRatio: true }); + fixture.detectChanges(); + expect(query('[aria-label="Playback speed"]')).not.toBeNull(); + expect(query('[aria-label="Aspect ratio"]')).not.toBeNull(); + }); + + it('shows the fullscreen button when supported', () => { + setCapabilities({ fullscreen: true }); + fixture.detectChanges(); + expect(query('[aria-label="Enter fullscreen"]')).not.toBeNull(); + }); + }); + + describe('command wiring', () => { + it('toggles play/pause', () => { + query('[aria-label="Play"]')?.click(); + expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1); + }); + + it('presents errors as non-playing and disables playback toggles', () => { + setState({ status: 'error' }); + fixture.detectChanges(); + + const play = query('[aria-label="Play"]') as HTMLButtonElement; + expect(play.disabled).toBe(true); + + fixture.componentInstance.togglePlay(); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('seeks via the ±10s buttons when seeking is possible', () => { + setCapabilities({ seek: true }); + setState({ canSeek: true, durationSeconds: 600 }); + fixture.detectChanges(); + + query('[aria-label="Forward 10 seconds"]')?.click(); + query('[aria-label="Back 10 seconds"]')?.click(); + expect(fake.commands.seekBy).toHaveBeenCalledWith(10); + expect(fake.commands.seekBy).toHaveBeenCalledWith(-10); + }); + + it('selects an audio track', () => { + setCapabilities({ audioTracks: true }); + setState({ + audioTracks: [ + { id: 1, label: 'English', selected: true }, + { id: 2, label: 'German', selected: false }, + ], + }); + fixture.detectChanges(); + + query('[aria-label="Audio tracks"]')?.click(); + fixture.detectChanges(); + const items = fixture.nativeElement.querySelectorAll( + '.player-controls__track' + ) as NodeListOf; + items[1].click(); + expect(fake.commands.setAudioTrack).toHaveBeenCalledWith(2); + }); + + it('disables the timeline when seeking is not possible', () => { + setCapabilities({ seek: true }); + fixture.detectChanges(); + const slider = query( + '.player-controls__slider' + ) as HTMLInputElement | null; + expect(slider?.disabled).toBe(true); + + setState({ canSeek: true, durationSeconds: 600 }); + fixture.detectChanges(); + const seekable = query( + '.player-controls__slider' + ) as HTMLInputElement | null; + expect(seekable?.disabled).toBe(false); + }); + + it('gives the timeline slider an accessible name', () => { + setCapabilities({ seek: true }); + fixture.detectChanges(); + expect(query('[aria-label="Playback position"]')).not.toBeNull(); + }); + + it('localizes mute feedback through the active locale', () => { + const translate = TestBed.inject(TranslateService); + translate.setTranslation('de', { + EMBEDDED_MPV: { PLAYER: { MUTED: 'Stumm' } }, + }); + translate.use('de'); + setCapabilities({ volume: true }); + fixture.detectChanges(); + + fixture.componentInstance.toggleMute(); + + expect(fixture.componentInstance.feedback.current()?.label).toBe( + 'Stumm' + ); + }); + }); + + describe('auto-hide', () => { + const bar = () => query('.player-controls__bar') as HTMLElement | null; + + beforeEach(() => { + jest.useFakeTimers(); + // Auto-hide only runs while playing. + setState({ status: 'playing' }); + fixture.detectChanges(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('does NOT hide while the bar is hovered, then hides after leave', () => { + expect(fixture.componentInstance.controlsAreVisible()).toBe(true); + + bar()?.dispatchEvent(new MouseEvent('pointerenter')); + fixture.detectChanges(); + + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + // Still visible: the pointer rests over the bar. + expect(fixture.componentInstance.controlsAreVisible()).toBe(true); + + bar()?.dispatchEvent(new MouseEvent('pointerleave')); + fixture.detectChanges(); + + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + // Now it auto-hides. + expect(fixture.componentInstance.controlsAreVisible()).toBe(false); + }); + + it('reveals on focus, stays visible within the bar, and hides after focus leaves', () => { + setCapabilities({ seek: true }); + setState({ + status: 'playing', + canSeek: true, + durationSeconds: 600, + }); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(fixture.componentInstance.controlsAreVisible()).toBe(false); + + const buttons = Array.from( + bar()?.querySelectorAll('button') ?? [] + ) as HTMLButtonElement[]; + buttons[0].focus(); + fixture.detectChanges(); + expect(fixture.componentInstance.controlsAreVisible()).toBe(true); + + jest.advanceTimersByTime(10000); + buttons[1].focus(); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + expect(fixture.componentInstance.controlsAreVisible()).toBe(true); + + const outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(fixture.componentInstance.controlsAreVisible()).toBe(false); + outside.remove(); + }); + }); + + describe('bar-hover state', () => { + const bar = () => query('.player-controls__bar') as HTMLElement | null; + + it('tracks barHovered on bar pointerenter / pointerleave', () => { + expect(fixture.componentInstance.barHovered()).toBe(false); + + bar()?.dispatchEvent(new MouseEvent('pointerenter')); + expect(fixture.componentInstance.barHovered()).toBe(true); + + bar()?.dispatchEvent(new MouseEvent('pointerleave')); + expect(fixture.componentInstance.barHovered()).toBe(false); + }); + }); + + describe('keyboard shortcuts', () => { + const dispatchSpace = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: ' ', + bubbles: true, + cancelable: true, + }) + ); + + it('toggles play on Space when controls are visible', () => { + dispatchSpace(); + expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1); + }); + + it('does not toggle play on Space when controls are hidden', () => { + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + + dispatchSpace(); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + }); + + describe('episode navigation outputs', () => { + it('emits previous/next episode requests when navigable', () => { + const previous = jest.fn(); + const next = jest.fn(); + fixture.componentInstance.previousEpisodeRequested.subscribe( + previous + ); + fixture.componentInstance.nextEpisodeRequested.subscribe(next); + + setCapabilities({ seriesNavigation: true }); + setState({ canPreviousEpisode: true, canNextEpisode: true }); + fixture.detectChanges(); + + query('[data-test-id="player-controls-previous-episode"]')?.click(); + query('[data-test-id="player-controls-next-episode"]')?.click(); + + expect(previous).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts new file mode 100644 index 000000000..ed4bb8d1f --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.surface.spec.ts @@ -0,0 +1,400 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCapabilities, + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities: WritableSignal = signal({ + ...DEFAULT_PLAYER_CAPABILITIES, + }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent surface, fullscreen and shortcuts', () => { + let fixture: ComponentFixture; + let component: PlayerControlsComponent; + let fake: ReturnType; + let surface: HTMLElement; + let fullscreenElement: Element | null; + let requestFullscreen: jest.Mock; + let exitFullscreen: jest.Mock; + + const setCapabilities = (overrides: Partial) => + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES, ...overrides }); + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const pressKey = (key: string) => { + const event = new KeyboardEvent('keydown', { + key, + cancelable: true, + }); + document.dispatchEvent(event); + return event.defaultPrevented; + }; + + beforeEach(async () => { + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + surface = document.createElement('div'); + document.body.appendChild(surface); + + fullscreenElement = null; + requestFullscreen = jest.fn(async () => { + fullscreenElement = surface; + document.dispatchEvent(new Event('fullscreenchange')); + }); + exitFullscreen = jest.fn(async () => { + fullscreenElement = null; + document.dispatchEvent(new Event('fullscreenchange')); + }); + Object.defineProperty(document, 'fullscreenElement', { + configurable: true, + get: () => fullscreenElement, + }); + (surface as HTMLElement & { requestFullscreen: jest.Mock })[ + 'requestFullscreen' + ] = requestFullscreen; + (document as Document & { exitFullscreen: jest.Mock })[ + 'exitFullscreen' + ] = exitFullscreen; + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput('controller', fake.controller); + fixture.componentRef.setInput('playerSurface', surface); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + surface.remove(); + jest.useRealTimers(); + }); + + describe('surface interactions', () => { + it('reveals hidden controls on pointer movement over the surface', () => { + jest.useFakeTimers(); + setState({ status: 'playing' }); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + expect(component.controlsAreVisible()).toBe(false); + + surface.dispatchEvent(new MouseEvent('pointermove')); + expect(component.controlsAreVisible()).toBe(true); + }); + + it('toggles play after a short delay on a plain viewport click', () => { + jest.useFakeTimers(); + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(250); + expect(fake.commands.togglePlay).toHaveBeenCalledTimes(1); + }); + + it('does not toggle playback from the surface while loading', () => { + jest.useFakeTimers(); + setState({ status: 'loading' }); + fixture.detectChanges(); + + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + setState({ status: 'playing' }); + fixture.detectChanges(); + jest.advanceTimersByTime(250); + + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('does not toggle playback if loading starts during the click delay', () => { + jest.useFakeTimers(); + setState({ status: 'playing' }); + fixture.detectChanges(); + + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + setState({ status: 'loading' }); + fixture.detectChanges(); + jest.advanceTimersByTime(250); + + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('detaches surface interactions while controls are disabled', () => { + jest.useFakeTimers(); + setCapabilities({ fullscreen: true }); + const reveal = jest.spyOn(component, 'reveal'); + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + + surface.dispatchEvent(new MouseEvent('pointermove')); + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + surface.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true }) + ); + jest.advanceTimersByTime(1000); + + expect(reveal).not.toHaveBeenCalled(); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + expect(requestFullscreen).not.toHaveBeenCalled(); + }); + + it('a double-click cancels the pending pause and toggles fullscreen', () => { + jest.useFakeTimers(); + setCapabilities({ fullscreen: true }); + fixture.detectChanges(); + + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + surface.dispatchEvent( + new MouseEvent('dblclick', { bubbles: true }) + ); + jest.advanceTimersByTime(1000); + + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + }); + + it('ignores clicks and double-clicks on interactive elements', () => { + jest.useFakeTimers(); + setCapabilities({ fullscreen: true }); + fixture.detectChanges(); + const button = document.createElement('button'); + surface.appendChild(button); + + button.dispatchEvent(new MouseEvent('click', { bubbles: true })); + button.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + jest.advanceTimersByTime(1000); + + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + expect(requestFullscreen).not.toHaveBeenCalled(); + }); + + it('a click while a menu is open dismisses it instead of pausing', () => { + jest.useFakeTimers(); + component.toggleMenu('speed'); + expect(component.anyMenuOpen()).toBe(true); + + surface.dispatchEvent(new MouseEvent('click', { bubbles: true })); + jest.advanceTimersByTime(1000); + + expect(component.anyMenuOpen()).toBe(false); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('an outside pointerdown closes open menus', () => { + component.toggleMenu('audio'); + expect(component.anyMenuOpen()).toBe(true); + + document.body.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true }) + ); + expect(component.anyMenuOpen()).toBe(false); + }); + + it('keeps menus open for pointerdown inside the sibling controls root', () => { + const controlsRoot = fixture.nativeElement as HTMLElement; + const popoverChild = document.createElement('button'); + controlsRoot.appendChild(popoverChild); + document.body.appendChild(controlsRoot); + component.toggleMenu('audio'); + + popoverChild.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true }) + ); + expect(component.anyMenuOpen()).toBe(true); + + document.body.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true }) + ); + expect(component.anyMenuOpen()).toBe(false); + }); + + it('an outside pointerdown with no open menus is a no-op', () => { + expect(component.anyMenuOpen()).toBe(false); + document.body.dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true }) + ); + expect(component.anyMenuOpen()).toBe(false); + }); + }); + + describe('fullscreen', () => { + beforeEach(() => { + setCapabilities({ fullscreen: true }); + fixture.detectChanges(); + }); + + it('enters fullscreen on the surface and tracks the change event', async () => { + await component.toggleFullscreen(); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + expect(component.isFullscreen()).toBe(true); + }); + + it('exits fullscreen when the surface is already fullscreen', async () => { + fullscreenElement = surface; + await component.toggleFullscreen(); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + expect(requestFullscreen).not.toHaveBeenCalled(); + expect(component.isFullscreen()).toBe(false); + }); + + it('does nothing when the engine does not support fullscreen', async () => { + setCapabilities({ fullscreen: false }); + fixture.detectChanges(); + + await component.toggleFullscreen(); + expect(requestFullscreen).not.toHaveBeenCalled(); + }); + + it('restores the fullscreen cursor when controls are disabled', () => { + jest.useFakeTimers(); + surface.style.cursor = 'crosshair'; + fullscreenElement = surface; + document.dispatchEvent(new Event('fullscreenchange')); + setState({ status: 'playing' }); + fixture.detectChanges(); + jest.advanceTimersByTime(10000); + fixture.detectChanges(); + expect(component.hideCursor()).toBe(true); + const host = fixture.nativeElement as HTMLElement; + expect( + host.classList.contains('player-controls-host--cursor-hidden') + ).toBe(true); + expect(surface.style.cursor).toBe('none'); + fixture.componentRef.setInput('showControls', false); + fixture.detectChanges(); + expect(component.hideCursor()).toBe(false); + expect(surface.style.cursor).toBe('crosshair'); + }); + }); + + describe('keyboard shortcuts', () => { + it('does not consume or toggle playback while loading', () => { + setState({ status: 'loading' }); + fixture.detectChanges(); + + expect(pressKey(' ')).toBe(false); + expect(pressKey('k')).toBe(false); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('does not consume or toggle playback after an error', () => { + setState({ status: 'error' }); + fixture.detectChanges(); + + expect(pressKey(' ')).toBe(false); + expect(pressKey('k')).toBe(false); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + }); + + it('Escape closes an open menu', () => { + component.toggleMenu('subtitle'); + expect(component.anyMenuOpen()).toBe(true); + + pressKey('Escape'); + expect(component.anyMenuOpen()).toBe(false); + }); + + it('f toggles fullscreen', () => { + expect(pressKey('f')).toBe(false); + expect(requestFullscreen).not.toHaveBeenCalled(); + + setCapabilities({ fullscreen: true }); + fixture.detectChanges(); + + expect(pressKey('f')).toBe(true); + expect(requestFullscreen).toHaveBeenCalledTimes(1); + }); + + it('arrow left/right seek by ±5 seconds', () => { + setCapabilities({ seek: true }); + setState({ canSeek: true, durationSeconds: 600 }); + fixture.detectChanges(); + + expect(pressKey('ArrowRight')).toBe(true); + expect(fake.commands.seekBy).toHaveBeenCalledWith(5); + + expect(pressKey('ArrowLeft')).toBe(true); + expect(fake.commands.seekBy).toHaveBeenCalledWith(-5); + }); + + it('arrow down lowers the volume and flashes feedback', () => { + setCapabilities({ volume: true }); + fixture.detectChanges(); + expect(pressKey('ArrowDown')).toBe(true); + + expect(fake.commands.setVolume).toHaveBeenCalledWith(0.95); + expect(component.feedback.current()?.label).toBe('95%'); + }); + + it('m mutes the player', () => { + setCapabilities({ volume: true }); + fixture.detectChanges(); + expect(pressKey('m')).toBe(true); + expect(fake.commands.setVolume).toHaveBeenCalledWith(0); + expect(component.displayVolume()).toBe(0); + }); + + it('ignores playback shortcuts while shortcuts are disabled', () => { + setCapabilities({ volume: true }); + fixture.componentRef.setInput('shortcutsEnabled', false); + fixture.detectChanges(); + + pressKey(' '); + pressKey('m'); + expect(fake.commands.togglePlay).not.toHaveBeenCalled(); + expect(fake.commands.setVolume).not.toHaveBeenCalled(); + }); + }); + + describe('episode navigation', () => { + it('emits only when a previous/next episode exists', () => { + const previous = jest.fn(); + const next = jest.fn(); + component.previousEpisodeRequested.subscribe(previous); + component.nextEpisodeRequested.subscribe(next); + + component.requestPreviousEpisode(); + component.requestNextEpisode(); + expect(previous).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + + setState({ canPreviousEpisode: true, canNextEpisode: true }); + fixture.detectChanges(); + + component.requestPreviousEpisode(); + component.requestNextEpisode(); + expect(previous).toHaveBeenCalledTimes(1); + expect(next).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts new file mode 100644 index 000000000..fccff8128 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.timeline.spec.ts @@ -0,0 +1,208 @@ +import { WritableSignal, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { + DEFAULT_PLAYER_CAPABILITIES, + createEmptyControlsState, +} from './player-controls-defaults'; +import { PlayerControlsComponent } from './player-controls.component'; +import type { + PlayerControlsCommands, + PlayerControlsState, + PlayerController, +} from './player-controls.model'; + +function createFakeController() { + const capabilities = signal({ ...DEFAULT_PLAYER_CAPABILITIES }); + const state: WritableSignal = signal( + createEmptyControlsState() + ); + const commands: jest.Mocked = { + togglePlay: jest.fn(), + seekTo: jest.fn(), + seekBy: jest.fn(), + setVolume: jest.fn(), + setAudioTrack: jest.fn(), + setSubtitleTrack: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + }; + const controller: PlayerController = { + capabilities, + state, + commands, + }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent timeline scrubbing', () => { + let fixture: ComponentFixture; + let fake: ReturnType; + + const setState = (overrides: Partial) => + fake.state.set({ ...createEmptyControlsState(), ...overrides }); + + const slider = () => + fixture.nativeElement.querySelector( + '.player-controls__slider' + ) as HTMLInputElement; + + const currentTimeText = () => + ( + fixture.nativeElement.querySelector( + '.player-controls__time > span:first-child' + ) as HTMLElement + ).textContent; + + const dispatch = ( + type: 'input' | 'change', + value: string, + rawValue = false + ) => { + const element = slider(); + if (rawValue) { + Object.defineProperty(element, 'value', { + configurable: true, + value, + writable: true, + }); + } else { + element.value = value; + } + element.dispatchEvent(new Event(type, { bubbles: true })); + fixture.detectChanges(); + return element; + }; + + beforeEach(async () => { + localStorage.removeItem('volume'); + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + fixture.componentRef.setInput('controller', fake.controller); + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + seek: true, + }); + setState({ + canSeek: true, + durationSeconds: 600, + positionSeconds: 30, + }); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + }); + + it('previews timeline input locally without seeking', () => { + const element = dispatch('input', '120'); + + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + expect(element.value).toBe('120'); + expect(element.style.getPropertyValue('--slider-progress')).toBe('20%'); + expect(element.getAttribute('aria-valuetext')).toBe('2:00'); + expect(currentTimeText()).toBe('2:00'); + }); + + it('keeps the local preview while controller position updates', () => { + dispatch('input', '60'); + + setState({ + canSeek: true, + durationSeconds: 600, + positionSeconds: 45, + }); + fixture.detectChanges(); + + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + expect(slider().value).toBe('60'); + expect(slider().getAttribute('aria-valuetext')).toBe('1:00'); + expect(currentTimeText()).toBe('1:00'); + }); + + it('clears the local preview when controller state becomes non-seekable', () => { + dispatch('input', '120'); + + setState({ + canSeek: false, + isLive: true, + positionSeconds: 5, + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.scrubPosition()).toBeNull(); + expect(currentTimeText()).toBe('0:05'); + }); + + it('does not resurrect a preview after seek capability is restored', () => { + dispatch('input', '120'); + + fake.capabilities.set({ ...DEFAULT_PLAYER_CAPABILITIES }); + fixture.detectChanges(); + expect(fixture.componentInstance.scrubPosition()).toBeNull(); + expect(slider()).toBeNull(); + + fake.capabilities.set({ + ...DEFAULT_PLAYER_CAPABILITIES, + seek: true, + }); + setState({ + canSeek: true, + durationSeconds: 300, + positionSeconds: 15, + }); + fixture.detectChanges(); + + expect(slider().value).toBe('15'); + expect(currentTimeText()).toBe('0:15'); + }); + + it('commits exactly one seek on change and clears the preview', () => { + dispatch('input', '55'); + dispatch('input', '60'); + + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + expect(currentTimeText()).toBe('1:00'); + + const element = dispatch('change', '60'); + + expect(fake.commands.seekTo).toHaveBeenCalledTimes(1); + expect(fake.commands.seekTo).toHaveBeenCalledWith(60); + expect(element.value).toBe('30'); + expect(element.style.getPropertyValue('--slider-progress')).toBe('5%'); + expect(element.getAttribute('aria-valuetext')).toBe('0:30'); + expect(currentTimeText()).toBe('0:30'); + }); + + it.each([ + ['below', '-10', 0, '0:00'], + ['above', '999', 600, '10:00'], + ])( + 'clamps %s-range scrub values before commit', + (_boundary, raw, expected, expectedTime) => { + const element = dispatch('input', raw, true); + + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + expect(element.getAttribute('aria-valuetext')).toBe(expectedTime); + expect(currentTimeText()).toBe(expectedTime); + + dispatch('change', raw, true); + expect(fake.commands.seekTo).toHaveBeenCalledTimes(1); + expect(fake.commands.seekTo).toHaveBeenCalledWith(expected); + } + ); + + it('ignores invalid scrub values without seeking', () => { + dispatch('input', 'not-a-number', true); + dispatch('change', 'not-a-number', true); + + expect(fake.commands.seekTo).not.toHaveBeenCalled(); + expect(currentTimeText()).toBe('0:30'); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts new file mode 100644 index 000000000..2b350312c --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -0,0 +1,399 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { ControlsFeedback } from './controls-feedback'; +import { ControlsFullscreen } from './controls-fullscreen'; +import { ControlsMenuSelection } from './controls-menu-selection'; +import { ControlsMenuState } from './controls-menu-state'; +import { ControlsShortcuts } from './controls-shortcuts'; +import { ControlsSurface } from './controls-surface'; +import { ControlsVisibility } from './controls-visibility'; +import { createControlsViewModel } from './controls-view-model'; +import { ControlsVolume } from './controls-volume'; +import { formatTime, speedLabel } from './controls-format.utils'; +import type { PlayerController } from './player-controls.model'; + +@Component({ + selector: 'app-player-controls', + templateUrl: './player-controls.component.html', + styleUrl: './player-controls.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'player-controls-host', + '[class.player-controls-host--cursor-hidden]': 'hideCursor()', + }, +}) +export class PlayerControlsComponent implements OnDestroy { + private readonly host = inject(ElementRef).nativeElement; + private readonly translate = inject(TranslateService); + readonly controller = input.required(); + readonly playerSurface = input(null); + readonly showControls = input(true); + readonly shortcutsEnabled = input(true); + readonly previousEpisodeRequested = output(); + readonly nextEpisodeRequested = output(); + readonly barHovered = signal(false); + private readonly barFocused = signal(false); + readonly menus = new ControlsMenuState(); + readonly feedback = new ControlsFeedback(); + readonly anyMenuOpen = this.menus.anyOpen; + private readonly shortcuts = new ControlsShortcuts(); + private readonly visibility = new ControlsVisibility(() => this.canHide()); + private readonly fullscreen = new ControlsFullscreen( + () => this.playerSurface(), + () => this.reveal() + ); + private readonly volume = new ControlsVolume({ + apply: (value) => this.controller().commands.setVolume(value), + flash: (icon, label) => this.feedback.flash(icon, label), + mutedLabel: () => this.translate.instant('EMBEDDED_MPV.PLAYER.MUTED'), + openPopover: () => this.menus.open('volume'), + closePopover: () => this.menus.close('volume'), + }); + private readonly surface = new ControlsSurface( + { + reveal: () => this.reveal(), + toggleFullscreen: () => void this.toggleFullscreen(), + closePopovers: () => this.closePopovers(), + togglePlay: () => this.togglePlay(), + canTogglePlay: () => this.canTogglePlay(), + isMenuOpen: () => this.menus.anyOpen(), + }, + this.host + ); + readonly menuSelection = new ControlsMenuSelection({ + commands: () => this.controller().commands, + menus: this.menus, + visibility: this.visibility, + revealSticky: () => this.reveal({ scheduleHide: false }), + }); + + readonly state = computed(() => this.controller().state()); + readonly capabilities = computed(() => this.controller().capabilities()); + private readonly controllerVolume = computed(() => this.state().volume); + readonly scrubPosition = signal(null); + readonly timelineDuration = computed(() => { + const duration = this.state().durationSeconds; + return typeof duration === 'number' && Number.isFinite(duration) + ? Math.max(0, duration) + : 0; + }); + readonly timelineValue = computed( + () => + this.normalizeTimelineValue( + this.scrubPosition() ?? this.state().positionSeconds + ) ?? 0 + ); + readonly timelineProgress = computed(() => { + const duration = this.timelineDuration(); + return this.state().canSeek && duration > 0 + ? (this.timelineValue() / duration) * 100 + : 0; + }); + + readonly displayVolume = this.volume.value; + readonly isFullscreen = this.fullscreen.isFullscreen; + + private readonly vm = createControlsViewModel({ + state: this.state, + capabilities: this.capabilities, + volume: this.volume.value, + isFullscreen: this.isFullscreen, + canFullscreenNative: () => this.fullscreen.canFullscreen(), + showControls: this.showControls, + autoHideVisible: this.visibility.visible, + anyMenuOpen: this.menus.anyOpen, + }); + + readonly isLoading = this.vm.isLoading; + readonly isPaused = this.vm.isPaused; + readonly isPlaying = this.vm.isPlaying; + readonly canTogglePlay = this.vm.canTogglePlay; + readonly hasAudioTracks = this.vm.hasAudioTracks; + readonly hasSubtitleTracks = this.vm.hasSubtitleTracks; + readonly canRecord = this.vm.canRecord; + readonly isRecording = this.vm.isRecording; + readonly recordingStatusText = this.vm.recordingStatusText; + readonly volumeIcon = this.vm.volumeIcon; + readonly canFullscreen = this.vm.canFullscreen; + readonly volumePercent = computed(() => + Math.round(this.displayVolume() * 100) + ); + readonly controlsAreVisible = this.vm.controlsAreVisible; + readonly hideCursor = this.vm.hideCursor; + constructor() { + this.shortcuts.attach({ + isAvailable: () => this.shortcutsEnabled() && this.showControls(), + canTogglePaused: () => this.canTogglePlay(), + canSeek: () => this.capabilities().seek && this.state().canSeek, + canAdjustVolume: () => this.capabilities().volume, + canToggleFullscreen: () => this.canFullscreen(), + onEscape: () => this.closePopovers(), + togglePaused: () => this.togglePlay(), + toggleFullscreen: () => void this.toggleFullscreen(), + seekBy: (delta) => this.seekBy(delta), + adjustVolume: (delta) => this.adjustVolume(delta), + toggleMute: () => this.toggleMute(), + }); + effect((onCleanup) => { + const surface = this.showControls() ? this.playerSurface() : null; + onCleanup(this.surface.attachSurface(surface)); + }); + effect(() => { + const controller = this.controller(); + if ( + !this.volume.beginCapabilityEpoch( + controller, + this.capabilities().volume + ) + ) { + return; + } + const volume = this.controllerVolume(); + untracked(() => + this.volume.initializeController(controller, volume) + ); + }); + effect(() => { + const controller = this.controller(); + const volume = this.controllerVolume(); + untracked(() => + this.volume.reconcileController(controller, volume) + ); + }); + effect((onCleanup) => { + const surface = this.playerSurface(); + if (!surface || !this.hideCursor()) { + return; + } + const previousCursor = surface.style.cursor; + surface.style.cursor = 'none'; + onCleanup(() => { + if (surface.style.cursor === 'none') { + surface.style.cursor = previousCursor; + } + }); + }); + effect(() => { + const state = this.state(); + const showControls = this.showControls(); + const capabilities = this.capabilities(); + untracked(() => { + if (!capabilities.seek || !state.canSeek) { + this.scrubPosition.set(null); + } + this.menus.reconcileControllerAvailability( + showControls, + capabilities, + state + ); + this.visibility.scheduleHide(); + this.feedback.flashRecordingTransition(state.recording.active, { + active: this.translate.instant( + 'EMBEDDED_MPV.PLAYER.RECORDING' + ), + inactive: + state.recording.message || + this.translate.instant( + 'EMBEDDED_MPV.PLAYER.RECORDING_SAVED' + ), + }); + }); + }); + } + ngOnDestroy(): void { + this.shortcuts.detach(); + this.feedback.dispose(); + this.visibility.dispose(); + this.fullscreen.dispose(); + this.volume.dispose(); + this.surface.dispose(); + } + formatTime = formatTime; + speedLabel = speedLabel; + togglePlay(): void { + this.reveal(); + if (!this.canTogglePlay()) { + return; + } + this.controller().commands.togglePlay(); + } + seekBy(deltaSeconds: number): void { + this.reveal(); + if (!this.capabilities().seek || !this.state().canSeek) { + return; + } + this.controller().commands.seekBy(deltaSeconds); + this.feedback.flash( + deltaSeconds >= 0 ? 'forward_10' : 'replay_10', + `${deltaSeconds >= 0 ? '+' : ''}${Math.round(deltaSeconds)}s` + ); + } + onTimelineInput(event: Event): void { + this.reveal(); + this.scrubPosition.set(this.readTimelineValue(event)); + } + onTimelineCommit(event: Event): void { + this.reveal(); + const target = this.readTimelineValue(event); + this.scrubPosition.set(null); + if ( + target === null || + !this.capabilities().seek || + !this.state().canSeek + ) { + return; + } + this.controller().commands.seekTo(target); + } + requestPreviousEpisode(): void { + this.reveal(); + if (!this.state().canPreviousEpisode) { + return; + } + this.previousEpisodeRequested.emit(); + } + requestNextEpisode(): void { + this.reveal(); + if (!this.state().canNextEpisode) { + return; + } + this.nextEpisodeRequested.emit(); + } + onVolumeInput(event: Event): void { + this.volume.set(Number((event.target as HTMLInputElement).value)); + this.reveal({ scheduleHide: false }); + } + + onVolumeWheel(event: WheelEvent): void { + event.preventDefault(); + this.adjustVolume(event.deltaY > 0 ? -0.05 : 0.05); + } + + onVolumeHoverEnter(): void { + this.volume.hoverEnter(); + } + + onVolumeHoverLeave(): void { + this.volume.hoverLeave(); + } + + toggleMute(): void { + if (!this.capabilities().volume) { + return; + } + this.volume.toggleMute(); + this.reveal(); + } + + toggleMenu(menu: 'audio' | 'subtitle' | 'speed' | 'aspect'): void { + this.menus.toggle(menu); + this.reveal(); + } + + toggleRecording(): void { + if (!this.canRecord()) { + return; + } + this.reveal({ scheduleHide: false }); + this.controller().commands.toggleRecording(); + } + + async toggleFullscreen(): Promise { + this.reveal(); + if (!this.canFullscreen()) { + return; + } + await this.fullscreen.toggle(); + } + + private adjustVolume(delta: number): void { + if (!this.capabilities().volume) { + return; + } + this.volume.adjust(delta); + this.reveal(); + } + + private readTimelineValue(event: Event): number | null { + return this.normalizeTimelineValue( + Number((event.target as HTMLInputElement).value) + ); + } + + private normalizeTimelineValue(value: number): number | null { + if (!Number.isFinite(value)) { + return null; + } + + const duration = this.state().durationSeconds; + const upperBound = + typeof duration === 'number' && Number.isFinite(duration) + ? Math.max(0, duration) + : Number.POSITIVE_INFINITY; + return Math.min(Math.max(0, value), upperBound); + } + + private closePopovers(): void { + if (!this.menus.anyOpen()) { + return; + } + this.menus.closeAll(); + this.visibility.scheduleHide(); + } + + reveal(options: { scheduleHide?: boolean } = {}): void { + this.shortcuts.activate(); + this.visibility.reveal(options); + } + + onBarPointerEnter(): void { + this.barHovered.set(true); + this.reveal({ scheduleHide: false }); + } + + onBarPointerLeave(): void { + this.barHovered.set(false); + this.visibility.scheduleHide(); + } + + onBarFocusIn(): void { + this.barFocused.set(true); + this.reveal({ scheduleHide: false }); + } + + onBarFocusOut(event: FocusEvent): void { + const bar = event.currentTarget as HTMLElement | null; + const next = event.relatedTarget; + if (bar && next instanceof Node && bar.contains(next)) { + return; + } + this.barFocused.set(false); + this.visibility.scheduleHide(); + } + + private canHide(): boolean { + return ( + this.isPlaying() && + !this.barHovered() && + !this.barFocused() && + !this.menus.anyOpen() && + !this.state().statusMessage + ); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts new file mode 100644 index 000000000..913ea4902 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -0,0 +1,89 @@ +import type { Signal } from '@angular/core'; + +export type PlayerStatus = + | 'idle' + | 'loading' + | 'playing' + | 'paused' + | 'ended' + | 'error'; + +/** Which controls an engine supports. A control is only rendered when its flag is true. */ +export interface PlayerControlsCapabilities { + seek: boolean; // scrub bar / ±10s (VOD) + volume: boolean; + audioTracks: boolean; + subtitles: boolean; + playbackSpeed: boolean; + aspectRatio: boolean; + recording: boolean; + fullscreen: boolean; + seriesNavigation: boolean; +} + +export interface PlayerTrack { + id: number; + label: string; // adapter pre-computes the display label + selected: boolean; +} + +export interface PlayerPreset { + value: T; + label: string; +} + +export interface PlayerRecordingState { + active: boolean; + /** Seconds since recording started; adapter computes via a 1s tick. */ + elapsedSeconds: number; + /** Persistent status text (e.g. "Saved to …" / error). null when none. */ + message: string | null; +} + +export interface PlayerControlsState { + status: PlayerStatus; + /** Loading/error text for the player surface; '' when nothing to show. */ + statusMessage: string; + stalled: boolean; + positionSeconds: number; + durationSeconds: number | null; + isLive: boolean; + canSeek: boolean; + volume: number; // 0..1 + audioTracks: PlayerTrack[]; + subtitleTracks: PlayerTrack[]; + /** True when a subtitle track is active (selected id !== null/off). */ + subtitlesEnabled: boolean; + playbackSpeed: number; + speedPresets: ReadonlyArray>; + aspectRatio: string; + aspectPresets: ReadonlyArray>; + recording: PlayerRecordingState; + canPreviousEpisode: boolean; + canNextEpisode: boolean; +} + +/** Imperative command surface. All fire-and-forget (void). */ +export interface PlayerControlsCommands { + togglePlay(): void; + seekTo(seconds: number): void; + seekBy(deltaSeconds: number): void; + setVolume(value: number): void; // 0..1 + setAudioTrack(id: number): void; + setSubtitleTrack(id: number): void; // -1 = off + setPlaybackSpeed(speed: number): void; + setAspectRatio(value: string): void; + toggleRecording(): void; +} + +/** + * What any engine implements to drive the shared controls. Intentionally has NO + * component-lifecycle assumptions — a controller may live above the router + * (background-playback readiness, subissue 04). Episode navigation and + * fullscreen are NOT here: they are controls-component outputs / DOM affordances. + */ +export interface PlayerController { + readonly capabilities: Signal; + readonly state: Signal; + readonly commands: PlayerControlsCommands; +} diff --git a/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts new file mode 100644 index 000000000..1b695b71b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.spec.ts @@ -0,0 +1,29 @@ +import { TestBed } from '@angular/core/testing'; +import { + WEB_PLAYER_SHARED_CONTROLS, + WEB_PLAYER_SHARED_CONTROLS_ENABLED, +} from './web-player-controls.flag'; + +describe('WEB_PLAYER_SHARED_CONTROLS flag', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('is rolled out OFF by default', () => { + expect(WEB_PLAYER_SHARED_CONTROLS_ENABLED).toBe(false); + }); + + it('resolves the constant through the root injection token', () => { + TestBed.configureTestingModule({}); + expect(TestBed.inject(WEB_PLAYER_SHARED_CONTROLS)).toBe( + WEB_PLAYER_SHARED_CONTROLS_ENABLED + ); + }); + + it('can be overridden via a TestBed provider', () => { + TestBed.configureTestingModule({ + providers: [ + { provide: WEB_PLAYER_SHARED_CONTROLS, useValue: true }, + ], + }); + expect(TestBed.inject(WEB_PLAYER_SHARED_CONTROLS)).toBe(true); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts new file mode 100644 index 000000000..5808a22ee --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-player-controls.flag.ts @@ -0,0 +1,24 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Reserved rollout switch for shared `app-player-controls` chrome on the web + * video engines (Video.js, html5+hls.js, ArtPlayer). + * + * DEFAULT OFF. #1148 adds no runtime consumer, so changing this constant alone + * has no effect until a follow-up engine host reads the injectable + * {@link WEB_PLAYER_SHARED_CONTROLS} token and performs the actual UI switch. + */ +export const WEB_PLAYER_SHARED_CONTROLS_ENABLED = false; + +/** + * Injectable view of {@link WEB_PLAYER_SHARED_CONTROLS_ENABLED}. Components + * inject this token; specs override it via TestBed providers without mocking a + * module-level constant. + */ +export const WEB_PLAYER_SHARED_CONTROLS = new InjectionToken( + 'WEB_PLAYER_SHARED_CONTROLS', + { + providedIn: 'root', + factory: () => WEB_PLAYER_SHARED_CONTROLS_ENABLED, + } +); diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts new file mode 100644 index 000000000..17e9bd2f5 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.commands.spec.ts @@ -0,0 +1,319 @@ +import { signal } from '@angular/core'; +import { WebVideoControlsAdapter } from './web-video-controls.adapter'; +import { + createEmptyControlsState, + DEFAULT_PLAYER_CAPABILITIES, +} from './player-controls-defaults'; +import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; + +/** jsdom `