From 10bd5acd6f04f7d80fb4aeb52cedae45285fa85f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 23 Aug 2026 00:01:14 +0200 Subject: [PATCH] fix(playback): harden external-subtitle handling from PR #1471 review Addresses the confirmed code-review findings: - Deselect the hls.js engine track BEFORE activating an external subtitle track: hls.js reacts to subtitleTrack=-1 by disabling every subtitle-kind TextTrack, which undid the just-selected external track. - Track ownership of every TextTrack the session ever created (addTextTrack tracks cannot leave the element), so stale or attach-failed tracks stay excluded from the native enumeration instead of reappearing as ghost engine tracks after source changes; failed attaches are silenced and their partial cues removed. - Guard the file pick with a source generation so a pick that outlives a stream change (Up Next, zapping, failover) is discarded instead of attaching the previous stream's subtitles to the next one. - Decode picked files encoding-aware (UTF-16 BOMs, strict UTF-8, then a Windows-1251/1252 heuristic) instead of Blob.text()'s silent UTF-8 substitution that rendered legacy-encoded SRT files as mojibake. - Gate the delay row on an external track being SELECTED, not merely loaded, so it can no longer sit enabled while visually inert. - Keep real (possibly negative) cue times under a negative delay instead of clamping pre-roll cues into a simultaneous stack at t=0. - Fix subtitleDelayLabel returning a signed negative zero for sub-tenth values. - Hoist the canonical PlayerSubtitleStyle shape plus clamp/normalize rules into @iptvnator/shared/interfaces (subtitle-style.util.ts); the renderer and the Electron main process now share one implementation, removing the triplicated literals and the toLowerCase divergence. Adds regression coverage for each fix; updates the player-controls contract doc and CLAUDE.md accordingly. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- .../services/embedded-mpv-native.service.ts | 22 ++--- docs/architecture/player-controls-contract.md | 45 ++++++--- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/embedded-mpv-session.interface.ts | 14 +-- .../interfaces/src/lib/subtitle-style.util.ts | 71 ++++++++++++++ .../player-controls/player-controls.model.ts | 13 +-- .../player-controls/subtitle-style.spec.ts | 5 + .../src/lib/player-controls/subtitle-style.ts | 91 +++++++---------- .../external-subtitle-cues.util.spec.ts | 49 ++++++++++ .../external-subtitle-cues.util.ts | 43 ++++++++ .../web-video-external-subtitles.spec.ts | 60 +++++++++++- .../web-video-external-subtitles.ts | 57 ++++++++--- .../web-video-source-controls.bridge.ts | 9 +- .../web-video-source-tracks.subtitles.spec.ts | 98 +++++++++++++++++++ .../web-video-source-tracks.ts | 20 +++- 16 files changed, 481 insertions(+), 119 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/subtitle-style.util.ts create mode 100644 libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.subtitles.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 312a173c1..122d7e19e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1101,7 +1101,7 @@ engine` (restart required) or helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime contracts: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. -- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker, native `TextTrack` rendering, `::cue` styling, delay only for the loaded file's owned cues); Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. +- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state `pictureInPictureActive`/`canPictureInPicture`, and command diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 1c56e2eb7..0296d8e9c 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -27,6 +27,8 @@ import { EMBEDDED_MPV_FRAME_SOURCE_CHANGED, EMBEDDED_MPV_SESSION_UPDATE, ResolvedPortalPlayback, + clampSubtitleDelay, + normalizeSubtitleStyle, } from '@iptvnator/shared/interfaces'; import { toNativeViewBounds } from './embedded-mpv-bounds.util'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; @@ -598,10 +600,9 @@ export class EmbeddedMpvNativeService { 'Embedded MPV addon does not support subtitle delay. Rebuild the native addon to enable this feature.' ); } - const clamped = Number.isFinite(seconds) - ? Math.max(-60, Math.min(60, seconds)) - : 0; - addon.setSubtitleDelay(sessionId, clamped); + // Same rules as the renderer, same implementation: the shared helper + // is the one place the limits are defined. + addon.setSubtitleDelay(sessionId, clampSubtitleDelay(seconds)); return this.refreshSession(sessionId); } @@ -616,17 +617,8 @@ export class EmbeddedMpvNativeService { 'Embedded MPV addon does not support subtitle styling. Rebuild the native addon to enable this feature.' ); } - const sizePercent = - typeof style?.sizePercent === 'number' && - Number.isFinite(style.sizePercent) - ? Math.max(25, Math.min(400, Math.round(style.sizePercent))) - : 100; - const color = - typeof style?.color === 'string' && - /^#[0-9a-f]{6}$/i.test(style.color) - ? style.color - : null; - addon.setSubtitleStyle(sessionId, { sizePercent, color }); + // Re-validate untrusted IPC input with the exact renderer rules. + addon.setSubtitleStyle(sessionId, normalizeSubtitleStyle(style)); return this.refreshSession(sessionId); } diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 28d8ea377..eec230da1 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -618,23 +618,44 @@ as the shared `volume` key, and is normalized/clamped on every read and write. The delay and any loaded file are deliberately per-session/per-source — they correct one specific stream. +The canonical `PlayerSubtitleStyle` shape and the clamp/normalize rules +(delay limit, size bounds, color validation) live in +`@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). The renderer +applies them to user input and the Electron main process re-applies the exact +same implementation to untrusted IPC payloads — deliberate defense-in-depth +with a single source of truth, so widening a limit on one side cannot +silently re-clamp on the other. + Per-engine implementations: - **HTML5 + ArtPlayer (shared-controls mode, neutral source bridge).** The picker is a renderer-side DOM file input (`.srt`/`.vtt` only; works in the - PWA and Electron alike, and no filesystem path ever enters the app). - `WebVideoExternalSubtitles` parses the file - (`external-subtitle-cues.util.ts`) and renders it through a native + PWA and Electron alike, and no filesystem path ever enters the app). File + bytes are decoded encoding-aware (`decodeExternalSubtitleBytes`: UTF-16 + BOMs, strict UTF-8, then a Windows-1251/1252 heuristic keyed on high-byte + density), because `Blob.text()`'s silent UTF-8 substitution turns common + legacy-encoded SRT files into mojibake. `WebVideoExternalSubtitles` parses + the file (`external-subtitle-cues.util.ts`) and renders it through a native `TextTrack` on the video element, so it works under every source kind. The - native track enumeration excludes externally owned tracks, and - `WebVideoSourceTracks` merges them into the subtitle listing with IDs from - 100000 up, routing selection so exactly one owner (engine or external) is - active. The delay capability is runtime-gated on a loaded file: only owned - cues can be re-timed exactly, while engine/stream cues arrive incrementally - and are left untouched. Style applies through a scoped `::cue` rule - (`WebVideoSubtitleStyle`), which covers embedded, hls.js-managed, and - external native cues. ASS rendering would need libass and is out of scope - for the web engines. + native track enumeration excludes externally owned tracks — ownership is + tracked for every track the session EVER created, because `addTextTrack` + tracks cannot leave the element and per-source ownership would let stale or + attach-failed tracks reappear as ghost engine tracks. `WebVideoSourceTracks` + merges external tracks into the subtitle listing with IDs from 100000 up, + routing selection so exactly one owner (engine or external) is active; + external selection deselects the engine BEFORE setting track modes, since + hls.js reacts to `subtitleTrack = -1` by disabling every subtitle-kind + `TextTrack` on the element. A pick captures the source generation and is + discarded if the stream changed while the dialog was open (mirroring the + Embedded MPV runner's session recheck). The delay capability is + runtime-gated on an external track being the SELECTED one — only owned cues + can be re-timed exactly, and with an engine track active the row would be + enabled yet visually inert. Negatively shifted cues keep their real + (possibly negative) times, which are valid and simply never active; + clamping them to t≈0 would stack every pre-roll cue at playback start. + Style applies through a scoped `::cue` rule (`WebVideoSubtitleStyle`), + which covers embedded, hls.js-managed, and external native cues. ASS + rendering would need libass and is out of scope for the web engines. - **Embedded MPV frame-copy.** The helper protocol gained `sub-add`, `sub-delay`, `sub-scale`, and `sub-color` commands. The picker is a main-process open dialog (`.srt/.ass/.ssa/.vtt/.sub` — mpv renders ASS diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 2a076185c..7aeec07b2 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/content-metadata.interface'; export * from './lib/dev-logger.util'; export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; +export * from './lib/subtitle-style.util'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 99e4eb4a9..edc885df5 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -1,3 +1,5 @@ +import type { PlayerSubtitleStyle } from './subtitle-style.util'; + export type EmbeddedMpvSessionStatus = | 'idle' | 'loading' @@ -29,14 +31,12 @@ export interface EmbeddedMpvCapabilities { } /** - * Engine-neutral subtitle presentation preferences forwarded to mpv. - * `sizePercent` maps to `sub-scale` (100 = 1.0); `color` maps to `sub-color`, - * null restores mpv's default. + * Subtitle presentation preferences forwarded to mpv: `sizePercent` maps to + * `sub-scale` (100 = 1.0); `color` maps to `sub-color`, null restores mpv's + * default. Alias of the canonical shared shape so the renderer controls and + * the IPC contract cannot drift structurally. */ -export interface EmbeddedMpvSubtitleStyle { - sizePercent: number; - color: string | null; -} +export type EmbeddedMpvSubtitleStyle = PlayerSubtitleStyle; export type EmbeddedMpvEngine = 'native' | 'frame-copy'; diff --git a/libs/shared/interfaces/src/lib/subtitle-style.util.ts b/libs/shared/interfaces/src/lib/subtitle-style.util.ts new file mode 100644 index 000000000..60c661c12 --- /dev/null +++ b/libs/shared/interfaces/src/lib/subtitle-style.util.ts @@ -0,0 +1,71 @@ +/** + * Canonical subtitle presentation contract shared by the renderer controls + * (`libs/ui/playback`) and the Electron main process. The clamp/normalize + * rules live HERE so the two sides cannot drift: the renderer applies them to + * user input, and the main process re-applies the exact same rules to + * untrusted IPC payloads (deliberate defense-in-depth, same implementation). + */ + +/** + * Engine-neutral subtitle presentation preferences. `sizePercent` is relative + * to the engine's default rendering size (100 = default); `color` is a + * lowercase CSS hex color, or null for the engine default. + */ +export interface PlayerSubtitleStyle { + sizePercent: number; + color: string | null; +} + +export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = { + sizePercent: 100, + color: null, +}; + +export const SUBTITLE_DELAY_LIMIT_SECONDS = 60; +export const SUBTITLE_SIZE_MIN_PERCENT = 25; +export const SUBTITLE_SIZE_MAX_PERCENT = 400; + +const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; + +export function clampSubtitleDelay(seconds: number): number { + if (!Number.isFinite(seconds)) { + return 0; + } + const clamped = Math.max( + -SUBTITLE_DELAY_LIMIT_SECONDS, + Math.min(SUBTITLE_DELAY_LIMIT_SECONDS, seconds) + ); + // Avoid float drift from repeated ±0.5 steps ("0.30000000000000004"). + return Math.round(clamped * 1000) / 1000; +} + +export function normalizeSubtitleStyle(value: unknown): PlayerSubtitleStyle { + if (typeof value !== 'object' || value === null) { + return { ...DEFAULT_SUBTITLE_STYLE }; + } + const candidate = value as Partial; + const sizePercent = + typeof candidate.sizePercent === 'number' && + Number.isFinite(candidate.sizePercent) + ? Math.max( + SUBTITLE_SIZE_MIN_PERCENT, + Math.min( + SUBTITLE_SIZE_MAX_PERCENT, + Math.round(candidate.sizePercent) + ) + ) + : DEFAULT_SUBTITLE_STYLE.sizePercent; + const color = + typeof candidate.color === 'string' && + HEX_COLOR_PATTERN.test(candidate.color) + ? candidate.color.toLowerCase() + : null; + return { sizePercent, color }; +} + +export function isDefaultSubtitleStyle(style: PlayerSubtitleStyle): boolean { + return ( + style.sizePercent === DEFAULT_SUBTITLE_STYLE.sizePercent && + style.color === DEFAULT_SUBTITLE_STYLE.color + ); +} 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 index 3601fee02..cc335554d 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.model.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.model.ts @@ -1,4 +1,5 @@ import type { Signal } from '@angular/core'; +import type { PlayerSubtitleStyle } from '@iptvnator/shared/interfaces'; export type PlayerStatus = | 'idle' @@ -28,15 +29,9 @@ export interface PlayerControlsCapabilities { seriesNavigation: boolean; } -/** - * Engine-neutral subtitle presentation preferences. `sizePercent` is relative - * to the engine's default rendering size (100 = default); `color` is a CSS hex - * color or null for the engine default. - */ -export interface PlayerSubtitleStyle { - sizePercent: number; - color: string | null; -} +// Canonical shape lives in @iptvnator/shared/interfaces so the Electron main +// process validates IPC payloads against the identical contract. +export type { PlayerSubtitleStyle }; export interface PlayerTrack { id: number; diff --git a/libs/ui/playback/src/lib/player-controls/subtitle-style.spec.ts b/libs/ui/playback/src/lib/player-controls/subtitle-style.spec.ts index f0329a658..6374bff92 100644 --- a/libs/ui/playback/src/lib/player-controls/subtitle-style.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/subtitle-style.spec.ts @@ -95,5 +95,10 @@ describe('subtitle-style', () => { expect(subtitleDelayLabel(0.5)).toBe('+0.5 s'); expect(subtitleDelayLabel(-1.5)).toBe('−1.5 s'); }); + + it('never shows a signed negative zero for sub-tenth values', () => { + expect(subtitleDelayLabel(0.02)).toBe('0 s'); + expect(subtitleDelayLabel(-0.04)).toBe('0 s'); + }); }); }); diff --git a/libs/ui/playback/src/lib/player-controls/subtitle-style.ts b/libs/ui/playback/src/lib/player-controls/subtitle-style.ts index 6aa332b5c..4501c3918 100644 --- a/libs/ui/playback/src/lib/player-controls/subtitle-style.ts +++ b/libs/ui/playback/src/lib/player-controls/subtitle-style.ts @@ -1,19 +1,36 @@ -import type { PlayerPreset, PlayerSubtitleStyle } from './player-controls.model'; +import { + DEFAULT_SUBTITLE_STYLE, + type PlayerSubtitleStyle, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, +} from '@iptvnator/shared/interfaces'; +import type { PlayerPreset } from './player-controls.model'; /** - * Shared subtitle presentation preferences. The style (size/color) persists - * across sessions through the same localStorage mechanism the players already - * use for the shared 'volume' key, so every engine adapter reads one source of - * truth. The delay and any loaded external subtitle file are deliberately - * per-session: they correct one specific stream, not a user preference. + * UI-side subtitle presentation preferences. The canonical shape and the + * clamp/normalize rules live in `@iptvnator/shared/interfaces` + * (`subtitle-style.util.ts`) so the Electron main process re-validates IPC + * input with the exact same implementation; this file adds only what the + * controls UI needs — presets, the delay step, persistence, and labels. + * + * The style (size/color) persists across sessions through the same + * localStorage mechanism the players already use for the shared 'volume' key, + * so every engine adapter reads one source of truth. The delay and any loaded + * external subtitle file are deliberately per-session: they correct one + * specific stream, not a user preference. */ -export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle'; +export { + DEFAULT_SUBTITLE_STYLE, + SUBTITLE_DELAY_LIMIT_SECONDS, + SUBTITLE_SIZE_MAX_PERCENT, + SUBTITLE_SIZE_MIN_PERCENT, + clampSubtitleDelay, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, +} from '@iptvnator/shared/interfaces'; -export const DEFAULT_SUBTITLE_STYLE: PlayerSubtitleStyle = { - sizePercent: 100, - color: null, -}; +export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle'; export const SUBTITLE_SIZE_PRESETS: ReadonlyArray> = [ { value: 75, label: '75%' }, @@ -36,51 +53,6 @@ export const SUBTITLE_COLOR_PRESETS: ReadonlyArray> ]; export const SUBTITLE_DELAY_STEP_SECONDS = 0.5; -export const SUBTITLE_DELAY_LIMIT_SECONDS = 60; - -const MIN_SIZE_PERCENT = 25; -const MAX_SIZE_PERCENT = 400; -const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i; - -export function clampSubtitleDelay(seconds: number): number { - if (!Number.isFinite(seconds)) { - return 0; - } - const clamped = Math.max( - -SUBTITLE_DELAY_LIMIT_SECONDS, - Math.min(SUBTITLE_DELAY_LIMIT_SECONDS, seconds) - ); - // Avoid float drift from repeated ±0.5 steps ("0.30000000000000004"). - return Math.round(clamped * 1000) / 1000; -} - -export function normalizeSubtitleStyle(value: unknown): PlayerSubtitleStyle { - if (typeof value !== 'object' || value === null) { - return { ...DEFAULT_SUBTITLE_STYLE }; - } - const candidate = value as Partial; - const sizePercent = - typeof candidate.sizePercent === 'number' && - Number.isFinite(candidate.sizePercent) - ? Math.max( - MIN_SIZE_PERCENT, - Math.min(MAX_SIZE_PERCENT, Math.round(candidate.sizePercent)) - ) - : DEFAULT_SUBTITLE_STYLE.sizePercent; - const color = - typeof candidate.color === 'string' && - HEX_COLOR_PATTERN.test(candidate.color) - ? candidate.color.toLowerCase() - : null; - return { sizePercent, color }; -} - -export function isDefaultSubtitleStyle(style: PlayerSubtitleStyle): boolean { - return ( - style.sizePercent === DEFAULT_SUBTITLE_STYLE.sizePercent && - style.color === DEFAULT_SUBTITLE_STYLE.color - ); -} export function readStoredSubtitleStyle(): PlayerSubtitleStyle { try { @@ -112,10 +84,15 @@ export function persistSubtitleStyle(style: PlayerSubtitleStyle): void { /** "+0.5 s" / "−1.5 s" / "0 s" display label for the delay row. */ export function subtitleDelayLabel(seconds: number): string { - if (!Number.isFinite(seconds) || seconds === 0) { + if (!Number.isFinite(seconds)) { return '0 s'; } const rounded = Math.round(seconds * 10) / 10; + // Derive the sign AFTER rounding: 0.02 rounds to 0 and must render as + // "0 s", not "−0.0 s". + if (rounded === 0) { + return '0 s'; + } const magnitude = Math.abs(rounded).toFixed(1); return `${rounded > 0 ? '+' : '−'}${magnitude} s`; } diff --git a/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.spec.ts b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.spec.ts index 85e143b08..abc45ce55 100644 --- a/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.spec.ts @@ -1,4 +1,5 @@ import { + decodeExternalSubtitleBytes, detectExternalSubtitleFormat, parseExternalSubtitleCues, } from './external-subtitle-cues.util'; @@ -18,6 +19,54 @@ describe('external-subtitle-cues.util', () => { }); }); + describe('decodeExternalSubtitleBytes', () => { + const toBuffer = (bytes: number[]): ArrayBuffer => + Uint8Array.from(bytes).buffer; + + it('decodes valid UTF-8 as-is', () => { + const utf8 = new TextEncoder().encode('Привет\nmonde'); + expect(decodeExternalSubtitleBytes(utf8.buffer)).toBe( + 'Привет\nmonde' + ); + }); + + it('decodes Cyrillic Windows-1251 bytes (high-byte-heavy text)', () => { + // "Привет мир" in CP1251: every letter is a high byte. + const cp1251 = [ + 0xcf, 0xf0, 0xe8, 0xe2, 0xe5, 0xf2, 0x20, 0xec, 0xe8, 0xf0, + ]; + expect(decodeExternalSubtitleBytes(toBuffer(cp1251))).toBe( + 'Привет мир' + ); + }); + + it('decodes mostly-ASCII Windows-1252 bytes (sparse accents)', () => { + // "resume: cafe" with two accented letters among ASCII. + const cp1252 = [ + ...Array.from('r').map((c) => c.charCodeAt(0)), + 0xe9, // é + ...Array.from('sum').map((c) => c.charCodeAt(0)), + 0xe9, // é + ...Array.from(': cafe and plain ascii words').map((c) => + c.charCodeAt(0) + ), + ]; + expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toBe( + 'résumé: cafe and plain ascii words' + ); + }); + + it('honors a UTF-16LE byte-order mark', () => { + const text = '1\n00:00:01,000'; + const bytes: number[] = [0xff, 0xfe]; + for (const char of text) { + const code = char.charCodeAt(0); + bytes.push(code & 0xff, code >> 8); + } + expect(decodeExternalSubtitleBytes(toBuffer(bytes))).toBe(text); + }); + }); + describe('parseExternalSubtitleCues', () => { it('parses a standard SRT file', () => { const content = [ diff --git a/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.ts b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.ts index 729c27387..3837b2019 100644 --- a/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.ts +++ b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.ts @@ -39,6 +39,49 @@ export function detectExternalSubtitleFormat( return null; } +/** + * Decodes a picked subtitle file's bytes. `Blob.text()` is strictly UTF-8 with + * silent U+FFFD substitution, which turns the still-common legacy-encoded SRT + * files (Windows-1251 Cyrillic, Windows-1252 Western European) and Windows' + * UTF-16 saves into mojibake that parses "successfully". Best-effort order: + * UTF-16 BOMs, then strict UTF-8, then a single-byte fallback chosen by how + * much of the text is non-ASCII — a non-Latin script (Cyrillic) makes high + * bytes dominate, while Latin text with accents stays mostly ASCII. + */ +export function decodeExternalSubtitleBytes(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + if (bytes.length >= 2) { + if (bytes[0] === 0xff && bytes[1] === 0xfe) { + return new TextDecoder('utf-16le').decode(buffer); + } + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + return new TextDecoder('utf-16be').decode(buffer); + } + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer); + } catch { + // Not valid UTF-8: a legacy single-byte encoding. + } + + let highBytes = 0; + for (const byte of bytes) { + if (byte >= 0x80) { + highBytes += 1; + } + } + const encoding = + highBytes / Math.max(1, bytes.length) > 0.15 + ? 'windows-1251' + : 'windows-1252'; + try { + return new TextDecoder(encoding).decode(buffer); + } catch { + // Runtime without legacy decoders: non-fatal UTF-8 is the last resort. + return new TextDecoder().decode(buffer); + } +} + // SRT uses "00:00:01,500"; VTT uses "00:00:01.500" and allows a missing hour // part ("01:23.456"). One pattern covers both. const TIMESTAMP_PATTERN = diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.spec.ts index 62610450f..0de79076c 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.spec.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.spec.ts @@ -121,9 +121,11 @@ describe('WebVideoExternalSubtitles', () => { [12.5, 14.5], ]); - // Negative delay clamps a cue start at zero without inverting it. + // Negative delay keeps real cue times: negative values are legal and + // simply never active. Clamping to [0, ~0] would stack every pre-roll + // cue simultaneously at t=0. session.setDelay(-2); - expect(track.added[0].startTime).toBe(0); + expect(track.added[0].startTime).toBe(-1); expect(track.added[0].endTime).toBe(1); session.setDelay(0); @@ -162,6 +164,60 @@ describe('WebVideoExternalSubtitles', () => { expect(track.added).toHaveLength(0); expect(session.hasTracks()).toBe(false); expect(session.getDelay()).toBe(0); + // addTextTrack tracks cannot leave the element: ownership must + // survive clear() so the native enumeration keeps excluding them + // instead of listing ghost tracks on the next source. + expect(session.ownsTrack(track as unknown as TextTrack)).toBe(true); + }); + + it('keeps the external track showing when engine deselect disables all tracks (hls.js)', () => { + // hls.js reacts to `subtitleTrack = -1` by disabling every + // subtitle-kind TextTrack on the element. select() must deselect the + // engine BEFORE setting its own modes so its writes win. + deselectEngineSubtitles.mockImplementation(() => { + for (const result of video.addTextTrack.mock.results) { + (result.value as FakeTextTrack).mode = 'disabled'; + } + }); + + expect(session.addFromFile(SRT_FILE)).toBe(true); + expect(lastTrack().mode).toBe('showing'); + }); + + it('reports selection separately from loaded files', () => { + session.addFromFile(SRT_FILE); + expect(session.hasTracks()).toBe(true); + expect(session.hasSelectedTrack()).toBe(true); + + session.deselectAll(); + expect(session.hasTracks()).toBe(true); + expect(session.hasSelectedTrack()).toBe(false); + }); + + it('silences and retains ownership of a track whose attach fails mid-file', () => { + const failingTrack = new FakeTextTrack('broken.srt'); + let added = 0; + failingTrack.addCue = (cue: FakeVTTCue) => { + added += 1; + if (added > 1) { + throw new Error('addCue rejected'); + } + failingTrack.added.push(cue); + }; + video.addTextTrack.mockReturnValueOnce(failingTrack); + + expect( + session.addFromFile({ ...SRT_FILE, name: 'broken.srt' }) + ).toBe(false); + + expect(session.hasTracks()).toBe(false); + // The half-populated track is silenced, emptied, and stays owned so + // the native enumeration cannot surface it as a phantom engine track. + expect(failingTrack.mode).toBe('disabled'); + expect(failingTrack.added).toHaveLength(0); + expect( + session.ownsTrack(failingTrack as unknown as TextTrack) + ).toBe(true); }); it('fails closed when the runtime lacks addTextTrack or VTTCue', () => { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.ts b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.ts index be886957f..d5d6af446 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.ts @@ -4,6 +4,7 @@ import { type ExternalSubtitleFile, type ParsedSubtitleCue, WEB_SUBTITLE_FILE_EXTENSIONS, + decodeExternalSubtitleBytes, detectExternalSubtitleFormat, parseExternalSubtitleCues, } from './external-subtitle-cues.util'; @@ -43,6 +44,13 @@ export class WebVideoExternalSubtitles { private entries: ExternalSubtitleEntry[] = []; private delaySeconds = 0; private nextId = EXTERNAL_SUBTITLE_TRACK_ID_BASE; + /** + * Every TextTrack this session ever created. `addTextTrack` tracks cannot + * be removed from the element, so ownership must outlive `clear()` — a + * dropped-per-source set would let stale (or attach-failed) tracks + * reappear in the native enumeration as ghost engine tracks. + */ + private readonly createdTracks = new Set(); constructor(private readonly config: WebVideoExternalSubtitlesConfig) {} @@ -50,8 +58,13 @@ export class WebVideoExternalSubtitles { return this.entries.length > 0; } + /** True while an external track is the one actually rendering. */ + hasSelectedTrack(): boolean { + return this.entries.some((entry) => entry.track?.mode === 'showing'); + } + ownsTrack(track: TextTrack): boolean { - return this.entries.some((entry) => entry.track === track); + return this.createdTracks.has(track); } ownsTrackId(id: number): boolean { @@ -94,12 +107,15 @@ export class WebVideoExternalSubtitles { if (!this.ownsTrackId(id)) { return; } + // Deselect the engine FIRST: hls.js reacts to `subtitleTrack = -1` by + // disabling every subtitle-kind TextTrack on the element, which would + // immediately undo a mode we had already set. + this.config.deselectEngineSubtitles(); for (const entry of this.entries) { if (entry.track) { entry.track.mode = entry.id === id ? 'showing' : 'hidden'; } } - this.config.deselectEngineSubtitles(); this.config.refresh(); } @@ -151,8 +167,11 @@ export class WebVideoExternalSubtitles { try { const track = video.addTextTrack('subtitles', entry.label); + this.createdTracks.add(track); entry.track = track; - entry.trackCues = entry.cues.map((cue) => { + // Push incrementally so the catch below can remove exactly the + // cues that made it onto the track before a mid-loop failure. + for (const cue of entry.cues) { const shifted = this.shiftCueTimes(cue); const vttCue = new CueCtor( shifted.startSeconds, @@ -160,10 +179,14 @@ export class WebVideoExternalSubtitles { cue.text ); track.addCue(vttCue); - return vttCue; - }); + entry.trackCues.push(vttCue); + } return true; } catch { + // A mid-loop failure leaves an unremovable track on the element: + // silence it so the half-populated cue set can never render. It + // stays in `createdTracks`, so the enumeration keeps excluding it. + this.detachEntry(entry); return false; } } @@ -197,12 +220,13 @@ export class WebVideoExternalSubtitles { startSeconds: number; endSeconds: number; } { - const startSeconds = Math.max(0, cue.startSeconds + this.delaySeconds); - const endSeconds = Math.max( - startSeconds + 0.001, - cue.endSeconds + this.delaySeconds - ); - return { startSeconds, endSeconds }; + // No clamping: negative cue times are valid VTTCue values that are + // simply never active. Clamping early cues to [0, ~0] would stack + // every pre-roll cue simultaneously at t=0 under a negative delay. + return { + startSeconds: cue.startSeconds + this.delaySeconds, + endSeconds: cue.endSeconds + this.delaySeconds, + }; } } @@ -227,8 +251,15 @@ export function pickExternalSubtitleFile( if (!format) { return; } - void file.text().then( - (content) => onPicked({ name: file.name, format, content }), + // Raw bytes, not file.text(): legacy encodings (CP1251/1252, UTF-16) + // are still common for downloaded subtitles and need detection. + void file.arrayBuffer().then( + (buffer) => + onPicked({ + name: file.name, + format, + content: decodeExternalSubtitleBytes(buffer), + }), () => undefined ); }); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts index d876cf9d9..a5dca452f 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-controls.bridge.ts @@ -99,8 +99,15 @@ export class WebVideoSourceControlsBridge { } private pickExternalSubtitle(): void { + // The picker is modal-slow: the stream can change (Up Next, zapping, + // failover) before the user confirms. A pick made for one source must + // never attach to its successor. + const generation = this.tracks.getSourceGeneration(); pickExternalSubtitleFile(this.config.video.ownerDocument, (file) => { - if (this.destroyed) { + if ( + this.destroyed || + this.tracks.getSourceGeneration() !== generation + ) { return; } this.tracks.addExternalSubtitleFile(file); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.subtitles.spec.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.subtitles.spec.ts new file mode 100644 index 000000000..33cb13a67 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.subtitles.spec.ts @@ -0,0 +1,98 @@ +import { EXTERNAL_SUBTITLE_TRACK_ID_BASE } from './web-video-external-subtitles'; +import { WebVideoSourceTracks } from './web-video-source-tracks'; + +class FakeVTTCue { + constructor( + public startTime: number, + public endTime: number, + public text: string + ) {} +} + +class FakeTextTrack { + kind = 'subtitles'; + mode: TextTrackMode = 'hidden'; + readonly added: FakeVTTCue[] = []; + + constructor(public label: string) {} + + addCue(cue: FakeVTTCue): void { + this.added.push(cue); + } + + removeCue(cue: FakeVTTCue): void { + const index = this.added.indexOf(cue); + if (index >= 0) { + this.added.splice(index, 1); + } + } +} + +const SRT_FILE = { + name: 'movie.srt', + format: 'srt' as const, + content: ['1', '00:00:01,000 --> 00:00:03,000', 'Hello', ''].join('\n'), +}; + +function createFakeVideo() { + return { + textTracks: { + length: 0, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + addTextTrack: jest.fn( + (_kind: string, label: string) => new FakeTextTrack(label) + ), + } as unknown as HTMLVideoElement; +} + +describe('WebVideoSourceTracks external subtitle integration', () => { + let tracks: WebVideoSourceTracks; + + beforeEach(() => { + (globalThis as { VTTCue?: unknown }).VTTCue = FakeVTTCue; + tracks = new WebVideoSourceTracks({ + video: createFakeVideo(), + showCaptions: () => false, + }); + tracks.setSource({ kind: 'native' }); + }); + + afterEach(() => { + tracks.destroy(); + delete (globalThis as { VTTCue?: unknown }).VTTCue; + }); + + it('advances the source generation on every source change', () => { + const initial = tracks.getSourceGeneration(); + tracks.setSource({ kind: 'mpegts' }); + expect(tracks.getSourceGeneration()).toBe(initial + 1); + tracks.clearSource(); + expect(tracks.getSourceGeneration()).toBe(initial + 2); + }); + + it('offers delay adjustment only while an external track is selected', () => { + expect(tracks.canAdjustSubtitleDelay()).toBe(false); + + expect(tracks.addExternalSubtitleFile(SRT_FILE)).toBe(true); + expect(tracks.canAdjustSubtitleDelay()).toBe(true); + + // Turning subtitles off deselects the external track; the delay UI + // must retire with it — otherwise it is enabled yet visually inert. + tracks.setSubtitleTrack(-1); + expect(tracks.canAdjustSubtitleDelay()).toBe(false); + + tracks.setSubtitleTrack(EXTERNAL_SUBTITLE_TRACK_ID_BASE); + expect(tracks.canAdjustSubtitleDelay()).toBe(true); + }); + + it('drops loaded external files with the source they corrected', () => { + tracks.addExternalSubtitleFile(SRT_FILE); + expect(tracks.getSubtitleTracks()).toHaveLength(1); + + tracks.setSource({ kind: 'native' }); + expect(tracks.getSubtitleTracks()).toHaveLength(0); + expect(tracks.canAdjustSubtitleDelay()).toBe(false); + }); +}); diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts index 99b0a56e5..adcecd8ff 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-source-tracks.ts @@ -45,6 +45,7 @@ export class WebVideoSourceTracks { private readonly nativeTextTracks: WebVideoNativeTextTracks; private readonly externalSubtitles: WebVideoExternalSubtitles; private source: WebVideoControlsSource | null = null; + private sourceGeneration = 0; private playbackStarted = false; private playingListener: (() => void) | null = null; private destroyed = false; @@ -90,11 +91,21 @@ export class WebVideoSourceTracks { return this.source?.kind ?? null; } + /** + * Changes whenever the bound source changes. Asynchronous work started + * against one source (the external subtitle file picker) captures this and + * bails when it no longer matches, so a pick cannot land on a later stream. + */ + getSourceGeneration(): number { + return this.sourceGeneration; + } + setSource(source: WebVideoControlsSource): void { if (this.destroyed) { return; } + this.sourceGeneration += 1; this.clearActiveSource(); this.source = source; // A new source starts unsettled so its own defaults are seeded again. @@ -127,6 +138,7 @@ export class WebVideoSourceTracks { return; } + this.sourceGeneration += 1; this.clearActiveSource(); this.source = null; } @@ -197,9 +209,13 @@ export class WebVideoSourceTracks { this.externalSubtitles.setDelay(seconds); } - /** Delay applies to owned external cues only, so it needs a loaded file. */ + /** + * Delay re-times owned external cues only, so the UI is offered exactly + * while an external track is the one rendering — with an engine track + * selected the row would be enabled yet visually inert. + */ canAdjustSubtitleDelay(): boolean { - return this.externalSubtitles.hasTracks(); + return this.externalSubtitles.hasSelectedTrack(); } private getEngineSubtitleTracks(): PlayerTrack[] {