diff --git a/.changes/playback-advanced-subtitles.md b/.changes/playback-advanced-subtitles.md new file mode 100644 index 000000000..e4f29890a --- /dev/null +++ b/.changes/playback-advanced-subtitles.md @@ -0,0 +1,10 @@ +--- +type: feature +area: playback +issues: [1408] +--- + +The shared player controls' subtitle menu now loads external subtitle files +(.srt/.vtt in the built-in web players, plus .ass in Embedded MPV), adjusts the +subtitle timing offset in ±0.5 s steps, and sets the subtitle text size and +color — size/color persist across sessions and are shared between engines. diff --git a/AGENTS.md b/AGENTS.md index 93a371648..964f5274f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,22 @@ Key files: engine-neutral `PlayerController` contract, standalone `app-player-controls`, generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. +- The 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 + 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 diff --git a/CLAUDE.md b/CLAUDE.md index 777597fc9..ab4c7e62d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1103,7 +1103,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. 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. Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. 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"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. 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-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts index b9a43f860..0a900e5ca 100644 --- a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -5,6 +5,7 @@ import { test, type LaunchedElectronApp, } from './electron-test-fixtures'; +import { writeFileSync } from 'fs'; import { join } from 'path'; import { assertNativeFallbackPrerequisites, @@ -197,6 +198,58 @@ test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { ) .toBe('playing'); + // Advanced subtitle support (#1408): exercise the real preload + // IPC, main-process validation, and the helper's `sub-add` + // protocol command against the packaged runtime. The native file + // dialog cannot be automated, so the path-based IPC is driven + // directly with a fixture file; mpv parses and auto-selects it, + // and the helper's track-list observation must report it back. + const subtitlePath = join(dataDir, 'packaged-smoke.srt'); + writeFileSync( + subtitlePath, + '1\n00:00:00,000 --> 00:00:10,000\nPackaged subtitle smoke\n' + ); + const withSubtitle = await launchedFrameCopyApp.mainWindow.evaluate( + async ({ sessionId, path }) => + window.electron.addEmbeddedMpvSubtitle?.(sessionId, path), + { sessionId: created.id, path: subtitlePath } + ); + expect(withSubtitle?.id).toBe(created.id); + await expect + .poll( + async () => { + const session = await getLatestSession( + launchedFrameCopyApp, + created.id + ); + return session?.subtitleTracks?.length ?? 0; + }, + { timeout: 15000 } + ) + .toBeGreaterThan(0); + + // Delay and style ride the same session-guarded IPC; mpv does not + // echo the values in the snapshot, so a session reply for the live + // session (instead of a rejection) is the observable contract. + const delayReply = await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvSubtitleDelay?.( + sessionId, + 1.5 + ), + created.id + ); + expect(delayReply?.id).toBe(created.id); + const styleReply = await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvSubtitleStyle?.(sessionId, { + sizePercent: 150, + color: '#ffe94f', + }), + created.id + ); + expect(styleReply?.id).toBe(created.id); + await launchedFrameCopyApp.mainWindow.evaluate( (sessionId) => window.electron.setEmbeddedMpvPaused(sessionId, true), diff --git a/apps/electron-backend/native/helper/mpv_frame_helper.cpp b/apps/electron-backend/native/helper/mpv_frame_helper.cpp index 99a9f1626..1d39792d3 100644 --- a/apps/electron-backend/native/helper/mpv_frame_helper.cpp +++ b/apps/electron-backend/native/helper/mpv_frame_helper.cpp @@ -569,6 +569,25 @@ void handleCommand(const Command& command) { const std::string value = command.get("value"); setPropertyString(command.name.c_str(), value == "-1" ? "no" : value); + } else if (command.name == "sub-add") { + /* External subtitle file (.srt/.ass/.vtt/…): mpv parses and renders + * it natively; "select" makes it the active subtitle track. */ + const std::string path = command.get("path"); + if (!path.empty()) { + const char* args[] = {"sub-add", path.c_str(), "select", nullptr}; + mpv_command(g_state.mpv, args); + } + } else if (command.name == "sub-delay") { + double delay = + std::clamp(command.getDouble("value", 0), -60.0, 60.0); + mpv_set_property(g_state.mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delay); + } else if (command.name == "sub-scale") { + double scale = std::clamp(command.getDouble("value", 1), 0.25, 4.0); + mpv_set_property(g_state.mpv, "sub-scale", MPV_FORMAT_DOUBLE, &scale); + } else if (command.name == "sub-color") { + /* Affects text subtitles rendered by mpv's OSD path; ASS files keep + * their own embedded styling. */ + setPropertyString("sub-color", command.get("value", "#FFFFFF")); } else if (command.name == "speed") { double speed = std::clamp(command.getDouble("value", 1), 0.25, 4.0); mpv_set_property(g_state.mpv, "speed", MPV_FORMAT_DOUBLE, &speed); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a5a24af21..af983ede4 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -28,6 +28,7 @@ import type { DownloadMetadataSnapshot, EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, + EmbeddedMpvSubtitleStyle, EmbeddedMpvSession, EmbeddedMpvSupport, ElectronBridgeApi, @@ -587,6 +588,31 @@ const electronApi: ElectronBridgeApi = { sessionId, trackId ), + addEmbeddedMpvSubtitle: ( + sessionId: string, + filePath: string + ): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_ADD_SUBTITLE', sessionId, filePath), + setEmbeddedMpvSubtitleDelay: ( + sessionId: string, + seconds: number + ): Promise => + ipcRenderer.invoke( + 'EMBEDDED_MPV_SET_SUBTITLE_DELAY', + sessionId, + seconds + ), + setEmbeddedMpvSubtitleStyle: ( + sessionId: string, + style: EmbeddedMpvSubtitleStyle + ): Promise => + ipcRenderer.invoke( + 'EMBEDDED_MPV_SET_SUBTITLE_STYLE', + sessionId, + style + ), + selectEmbeddedMpvSubtitleFile: (): Promise => + ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'), setEmbeddedMpvSpeed: ( sessionId: string, speed: number diff --git a/apps/electron-backend/src/app/events/embedded-mpv.events.ts b/apps/electron-backend/src/app/events/embedded-mpv.events.ts index 38a76bf2f..5a37687e4 100644 --- a/apps/electron-backend/src/app/events/embedded-mpv.events.ts +++ b/apps/electron-backend/src/app/events/embedded-mpv.events.ts @@ -1,18 +1,23 @@ import { ipcMain } from 'electron'; import { + EMBEDDED_MPV_ADD_SUBTITLE, EMBEDDED_MPV_CREATE_SESSION, EMBEDDED_MPV_DISPOSE_SESSION, EMBEDDED_MPV_GET_FRAME_SOURCE, EMBEDDED_MPV_LOAD_PLAYBACK, EMBEDDED_MPV_PREPARE, EMBEDDED_MPV_SEEK, + EMBEDDED_MPV_SELECT_SUBTITLE_FILE, EMBEDDED_MPV_SET_ASPECT, EMBEDDED_MPV_SET_AUDIO_TRACK, EMBEDDED_MPV_SET_BOUNDS, EMBEDDED_MPV_SET_PAUSED, EMBEDDED_MPV_SET_SPEED, + EMBEDDED_MPV_SET_SUBTITLE_DELAY, + EMBEDDED_MPV_SET_SUBTITLE_STYLE, EMBEDDED_MPV_SET_SUBTITLE_TRACK, EMBEDDED_MPV_SET_VOLUME, + EmbeddedMpvSubtitleStyle, EMBEDDED_MPV_GET_DEFAULT_RECORDING_FOLDER, EMBEDDED_MPV_SELECT_RECORDING_FOLDER, EMBEDDED_MPV_START_RECORDING, @@ -110,6 +115,28 @@ handleEmbeddedMpv( getService().setSubtitleTrack(sessionId, trackId) ); +handleEmbeddedMpv( + EMBEDDED_MPV_ADD_SUBTITLE, + (sessionId: string, filePath: string) => + getService().addSubtitle(sessionId, filePath) +); + +handleEmbeddedMpv( + EMBEDDED_MPV_SET_SUBTITLE_DELAY, + (sessionId: string, seconds: number) => + getService().setSubtitleDelay(sessionId, seconds) +); + +handleEmbeddedMpv( + EMBEDDED_MPV_SET_SUBTITLE_STYLE, + (sessionId: string, style: EmbeddedMpvSubtitleStyle) => + getService().setSubtitleStyle(sessionId, style) +); + +handleEmbeddedMpv(EMBEDDED_MPV_SELECT_SUBTITLE_FILE, () => + getService().selectSubtitleFile() +); + handleEmbeddedMpv( EMBEDDED_MPV_SET_SPEED, (sessionId: string, speed: number) => getService().setSpeed(sessionId, speed) diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 0947fe4bb..c998fd83f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -110,6 +110,42 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { expect(line).toContain('opt.http-header-fields=X-Token: abc'); }); + it('sends the subtitle protocol commands over stdin', () => { + const sessionId = createSession(); + + adapter.addSubtitle(sessionId, '/subs/movie subs.srt'); + expect(child.stdin.written.at(-1)).toBe( + 'sub-add\tpath=/subs/movie subs.srt\n' + ); + + adapter.setSubtitleDelay(sessionId, 1.5); + expect(child.stdin.written.at(-1)).toBe('sub-delay\tvalue=1.5\n'); + + adapter.setSubtitleStyle(sessionId, { + sizePercent: 150, + color: '#ffe94f', + }); + expect(child.stdin.written.slice(-2)).toEqual([ + 'sub-scale\tvalue=1.5\n', + 'sub-color\tvalue=#ffe94f\n', + ]); + + // A null color resets mpv's default so a previous pick cannot linger. + adapter.setSubtitleStyle(sessionId, { sizePercent: 100, color: null }); + expect(child.stdin.written.slice(-2)).toEqual([ + 'sub-scale\tvalue=1\n', + 'sub-color\tvalue=#FFFFFF\n', + ]); + }); + + it('percent-escapes protocol-reserved characters in subtitle paths', () => { + const sessionId = createSession(); + adapter.addSubtitle(sessionId, '/subs/tab\tname.srt'); + expect(child.stdin.written.at(-1)).toBe( + 'sub-add\tpath=/subs/tab%09name.srt\n' + ); + }); + it('scales bounds and ignores hidden/degenerate bounds', () => { const sessionId = createSession(); adapter.setBounds(sessionId, { x: 0, y: 0, width: 800, height: 450 }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index b16331b8d..037f32ddf 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -192,6 +192,30 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { this.send(sessionId, `sid\tvalue=${trackId}`); } + addSubtitle(sessionId: string, filePath: string): void { + this.send( + sessionId, + `sub-add\tpath=${encodeProtocolValue(filePath)}` + ); + } + + setSubtitleDelay(sessionId: string, seconds: number): void { + this.send(sessionId, `sub-delay\tvalue=${seconds}`); + } + + setSubtitleStyle( + sessionId: string, + style: { sizePercent: number; color: string | null } + ): void { + this.send(sessionId, `sub-scale\tvalue=${style.sizePercent / 100}`); + // mpv's default sub-color; an explicit reset keeps a previous pick + // from lingering after the user returns to "default". + this.send( + sessionId, + `sub-color\tvalue=${encodeProtocolValue(style.color ?? '#FFFFFF')}` + ); + } + setSpeed(sessionId: string, speed: number): void { this.send(sessionId, `speed\tvalue=${speed}`); } 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 65a5e6141..ff50e9329 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 @@ -19,6 +19,7 @@ import { EmbeddedMpvRecordingState, EmbeddedMpvSession, EmbeddedMpvSessionStatus, + EmbeddedMpvSubtitleStyle, EmbeddedMpvSubtitleTrack, EmbeddedMpvEngine, EmbeddedMpvFrameSource, @@ -26,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 { embeddedMpvRecordingTracker } from './embedded-mpv-recording-tracker'; @@ -75,6 +78,9 @@ export interface NativeEmbeddedMpvAddon { setVolume(sessionId: string, volume: number): void; setAudioTrack(sessionId: string, trackId: number): void; setSubtitleTrack?(sessionId: string, trackId: number): void; + addSubtitle?(sessionId: string, filePath: string): void; + setSubtitleDelay?(sessionId: string, seconds: number): void; + setSubtitleStyle?(sessionId: string, style: EmbeddedMpvSubtitleStyle): void; setSpeed?(sessionId: string, speed: number): void; setAspect?(sessionId: string, aspect: string): void; startRecording?(sessionId: string, targetPath: string): void; @@ -241,6 +247,9 @@ export class EmbeddedMpvNativeService { aspectOverride: true, screenshot: false, recording: true, + externalSubtitles: true, + subtitleDelay: true, + subtitleStyle: true, }; } const addon = this.addon; @@ -252,6 +261,9 @@ export class EmbeddedMpvNativeService { recording: typeof addon?.startRecording === 'function' && typeof addon?.stopRecording === 'function', + externalSubtitles: typeof addon?.addSubtitle === 'function', + subtitleDelay: typeof addon?.setSubtitleDelay === 'function', + subtitleStyle: typeof addon?.setSubtitleStyle === 'function', }; } @@ -562,6 +574,74 @@ export class EmbeddedMpvNativeService { return this.refreshSession(sessionId); } + addSubtitle(sessionId: string, filePath: string): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.addSubtitle !== 'function') { + throw new Error( + 'Embedded MPV addon does not support external subtitles. Rebuild the native addon to enable this feature.' + ); + } + const normalized = typeof filePath === 'string' ? filePath.trim() : ''; + if (!normalized || !existsSync(normalized)) { + throw new Error('The selected subtitle file was not found.'); + } + addon.addSubtitle(sessionId, normalized); + return this.refreshSession(sessionId); + } + + setSubtitleDelay( + sessionId: string, + seconds: number + ): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.setSubtitleDelay !== 'function') { + throw new Error( + 'Embedded MPV addon does not support subtitle delay. Rebuild the native addon to enable this feature.' + ); + } + // 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); + } + + setSubtitleStyle( + sessionId: string, + style: EmbeddedMpvSubtitleStyle + ): EmbeddedMpvSession | null { + this.assertEmbeddedMpvEnabled(); + const addon = this.getAddon(); + if (typeof addon.setSubtitleStyle !== 'function') { + throw new Error( + 'Embedded MPV addon does not support subtitle styling. Rebuild the native addon to enable this feature.' + ); + } + // Re-validate untrusted IPC input with the exact renderer rules. + addon.setSubtitleStyle(sessionId, normalizeSubtitleStyle(style)); + return this.refreshSession(sessionId); + } + + async selectSubtitleFile(): Promise { + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + title: 'Select Subtitle File', + filters: [ + { + name: 'Subtitle files', + extensions: ['srt', 'ass', 'ssa', 'vtt', 'sub'], + }, + ], + }); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; + } + setSpeed(sessionId: string, speed: number): EmbeddedMpvSession | null { this.assertEmbeddedMpvEnabled(); const addon = this.getAddon(); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 02e1a6c58..f30bd6c8b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "تشغيل صورة داخل صورة", "EXIT_PICTURE_IN_PICTURE": "إنهاء صورة داخل صورة", "ENTER_FULLSCREEN": "ملء الشاشة", - "EXIT_FULLSCREEN": "الخروج من ملء الشاشة" + "EXIT_FULLSCREEN": "الخروج من ملء الشاشة", + "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SUBTITLE_DELAY": "تأخير الترجمة", + "SUBTITLE_DELAY_DECREASE": "عرض الترجمة مبكرًا", + "SUBTITLE_DELAY_INCREASE": "عرض الترجمة لاحقًا", + "SUBTITLE_DELAY_RESET": "إعادة ضبط تأخير الترجمة", + "SUBTITLE_SIZE": "حجم الترجمة", + "SUBTITLE_COLOR": "لون الترجمة", + "SUBTITLE_COLOR_DEFAULT": "افتراضي", + "SUBTITLE_COLOR_WHITE": "أبيض", + "SUBTITLE_COLOR_YELLOW": "أصفر", + "SUBTITLE_COLOR_CYAN": "سماوي" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index bbe6bf7b1..981436a62 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "شغّل صورة داخل صورة", "EXIT_PICTURE_IN_PICTURE": "خرج من صورة داخل صورة", "ENTER_FULLSCREEN": "دخل لملء الشاشة", - "EXIT_FULLSCREEN": "خرج من ملء الشاشة" + "EXIT_FULLSCREEN": "خرج من ملء الشاشة", + "LOAD_SUBTITLE_FILE": "تحميل ملف الترجمة…", + "SUBTITLE_DELAY": "تأخير الترجمة", + "SUBTITLE_DELAY_DECREASE": "ورّي الترجمة قبل", + "SUBTITLE_DELAY_INCREASE": "ورّي الترجمة من بعد", + "SUBTITLE_DELAY_RESET": "رجّع تأخير الترجمة", + "SUBTITLE_SIZE": "حجم الترجمة", + "SUBTITLE_COLOR": "لون الترجمة", + "SUBTITLE_COLOR_DEFAULT": "افتراضي", + "SUBTITLE_COLOR_WHITE": "أبيض", + "SUBTITLE_COLOR_YELLOW": "أصفر", + "SUBTITLE_COLOR_CYAN": "سماوي" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index c1740f917..c2ce7e0a4 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Уключыць рэжым «выява ў выяве»", "EXIT_PICTURE_IN_PICTURE": "Выйсці з рэжыму «выява ў выяве»", "ENTER_FULLSCREEN": "Увайсці ў поўнаэкранны рэжым", - "EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму" + "EXIT_FULLSCREEN": "Выйсці з поўнаэкраннага рэжыму", + "LOAD_SUBTITLE_FILE": "Загрузіць файл субтытраў…", + "SUBTITLE_DELAY": "Затрымка субтытраў", + "SUBTITLE_DELAY_DECREASE": "Паказваць субтытры раней", + "SUBTITLE_DELAY_INCREASE": "Паказваць субтытры пазней", + "SUBTITLE_DELAY_RESET": "Скінуць затрымку субтытраў", + "SUBTITLE_SIZE": "Памер субтытраў", + "SUBTITLE_COLOR": "Колер субтытраў", + "SUBTITLE_COLOR_DEFAULT": "Па змаўчанні", + "SUBTITLE_COLOR_WHITE": "Белы", + "SUBTITLE_COLOR_YELLOW": "Жоўты", + "SUBTITLE_COLOR_CYAN": "Блакітны" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index e75bb72fb..de5e1abf7 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Bild-in-Bild starten", "EXIT_PICTURE_IN_PICTURE": "Bild-in-Bild beenden", "ENTER_FULLSCREEN": "Vollbild aktivieren", - "EXIT_FULLSCREEN": "Vollbild beenden" + "EXIT_FULLSCREEN": "Vollbild beenden", + "LOAD_SUBTITLE_FILE": "Untertiteldatei laden…", + "SUBTITLE_DELAY": "Untertitelverzögerung", + "SUBTITLE_DELAY_DECREASE": "Untertitel früher anzeigen", + "SUBTITLE_DELAY_INCREASE": "Untertitel später anzeigen", + "SUBTITLE_DELAY_RESET": "Untertitelverzögerung zurücksetzen", + "SUBTITLE_SIZE": "Untertitelgröße", + "SUBTITLE_COLOR": "Untertitelfarbe", + "SUBTITLE_COLOR_DEFAULT": "Standard", + "SUBTITLE_COLOR_WHITE": "Weiß", + "SUBTITLE_COLOR_YELLOW": "Gelb", + "SUBTITLE_COLOR_CYAN": "Cyan" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dfe0bcf6e..b790c280e 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Έναρξη εικόνας εντός εικόνας", "EXIT_PICTURE_IN_PICTURE": "Έξοδος από εικόνα εντός εικόνας", "ENTER_FULLSCREEN": "Μετάβαση σε πλήρη οθόνη", - "EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη" + "EXIT_FULLSCREEN": "Έξοδος από πλήρη οθόνη", + "LOAD_SUBTITLE_FILE": "Φόρτωση αρχείου υποτίτλων…", + "SUBTITLE_DELAY": "Καθυστέρηση υποτίτλων", + "SUBTITLE_DELAY_DECREASE": "Εμφάνιση υποτίτλων νωρίτερα", + "SUBTITLE_DELAY_INCREASE": "Εμφάνιση υποτίτλων αργότερα", + "SUBTITLE_DELAY_RESET": "Επαναφορά καθυστέρησης υποτίτλων", + "SUBTITLE_SIZE": "Μέγεθος υποτίτλων", + "SUBTITLE_COLOR": "Χρώμα υποτίτλων", + "SUBTITLE_COLOR_DEFAULT": "Προεπιλογή", + "SUBTITLE_COLOR_WHITE": "Λευκό", + "SUBTITLE_COLOR_YELLOW": "Κίτρινο", + "SUBTITLE_COLOR_CYAN": "Κυανό" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 28b8a5727..012fd4951 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Enter picture-in-picture", "EXIT_PICTURE_IN_PICTURE": "Exit picture-in-picture", "ENTER_FULLSCREEN": "Enter fullscreen", - "EXIT_FULLSCREEN": "Exit fullscreen" + "EXIT_FULLSCREEN": "Exit fullscreen", + "LOAD_SUBTITLE_FILE": "Load subtitle file…", + "SUBTITLE_DELAY": "Subtitle delay", + "SUBTITLE_DELAY_DECREASE": "Show subtitles earlier", + "SUBTITLE_DELAY_INCREASE": "Show subtitles later", + "SUBTITLE_DELAY_RESET": "Reset subtitle delay", + "SUBTITLE_SIZE": "Subtitle size", + "SUBTITLE_COLOR": "Subtitle color", + "SUBTITLE_COLOR_DEFAULT": "Default", + "SUBTITLE_COLOR_WHITE": "White", + "SUBTITLE_COLOR_YELLOW": "Yellow", + "SUBTITLE_COLOR_CYAN": "Cyan" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0b4dd72ad..e59f319d4 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Activar imagen en imagen", "EXIT_PICTURE_IN_PICTURE": "Salir de imagen en imagen", "ENTER_FULLSCREEN": "Entrar en pantalla completa", - "EXIT_FULLSCREEN": "Salir de pantalla completa" + "EXIT_FULLSCREEN": "Salir de pantalla completa", + "LOAD_SUBTITLE_FILE": "Cargar archivo de subtítulos…", + "SUBTITLE_DELAY": "Retardo de subtítulos", + "SUBTITLE_DELAY_DECREASE": "Mostrar subtítulos antes", + "SUBTITLE_DELAY_INCREASE": "Mostrar subtítulos después", + "SUBTITLE_DELAY_RESET": "Restablecer retardo de subtítulos", + "SUBTITLE_SIZE": "Tamaño de subtítulos", + "SUBTITLE_COLOR": "Color de subtítulos", + "SUBTITLE_COLOR_DEFAULT": "Predeterminado", + "SUBTITLE_COLOR_WHITE": "Blanco", + "SUBTITLE_COLOR_YELLOW": "Amarillo", + "SUBTITLE_COLOR_CYAN": "Cian" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 062879718..6d424e355 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Activer le mode image dans l’image", "EXIT_PICTURE_IN_PICTURE": "Quitter le mode image dans l’image", "ENTER_FULLSCREEN": "Passer en plein écran", - "EXIT_FULLSCREEN": "Quitter le plein écran" + "EXIT_FULLSCREEN": "Quitter le plein écran", + "LOAD_SUBTITLE_FILE": "Charger un fichier de sous-titres…", + "SUBTITLE_DELAY": "Décalage des sous-titres", + "SUBTITLE_DELAY_DECREASE": "Afficher les sous-titres plus tôt", + "SUBTITLE_DELAY_INCREASE": "Afficher les sous-titres plus tard", + "SUBTITLE_DELAY_RESET": "Réinitialiser le décalage des sous-titres", + "SUBTITLE_SIZE": "Taille des sous-titres", + "SUBTITLE_COLOR": "Couleur des sous-titres", + "SUBTITLE_COLOR_DEFAULT": "Par défaut", + "SUBTITLE_COLOR_WHITE": "Blanc", + "SUBTITLE_COLOR_YELLOW": "Jaune", + "SUBTITLE_COLOR_CYAN": "Cyan" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 047f2bcf8..8aa42beac 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Kép a képben mód bekapcsolása", "EXIT_PICTURE_IN_PICTURE": "Kilépés a kép a képben módból", "ENTER_FULLSCREEN": "Váltás teljes képernyőre", - "EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból" + "EXIT_FULLSCREEN": "Kilépés a teljes képernyős módból", + "LOAD_SUBTITLE_FILE": "Feliratfájl betöltése…", + "SUBTITLE_DELAY": "Felirat késleltetése", + "SUBTITLE_DELAY_DECREASE": "Feliratok megjelenítése korábban", + "SUBTITLE_DELAY_INCREASE": "Feliratok megjelenítése később", + "SUBTITLE_DELAY_RESET": "Feliratkésleltetés visszaállítása", + "SUBTITLE_SIZE": "Felirat mérete", + "SUBTITLE_COLOR": "Felirat színe", + "SUBTITLE_COLOR_DEFAULT": "Alapértelmezett", + "SUBTITLE_COLOR_WHITE": "Fehér", + "SUBTITLE_COLOR_YELLOW": "Sárga", + "SUBTITLE_COLOR_CYAN": "Cián" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 4d97e5374..4b47398bb 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Attiva Picture-in-Picture", "EXIT_PICTURE_IN_PICTURE": "Esci da Picture-in-Picture", "ENTER_FULLSCREEN": "Attiva schermo intero", - "EXIT_FULLSCREEN": "Esci dallo schermo intero" + "EXIT_FULLSCREEN": "Esci dallo schermo intero", + "LOAD_SUBTITLE_FILE": "Carica file sottotitoli…", + "SUBTITLE_DELAY": "Ritardo sottotitoli", + "SUBTITLE_DELAY_DECREASE": "Mostra i sottotitoli prima", + "SUBTITLE_DELAY_INCREASE": "Mostra i sottotitoli dopo", + "SUBTITLE_DELAY_RESET": "Reimposta il ritardo dei sottotitoli", + "SUBTITLE_SIZE": "Dimensione sottotitoli", + "SUBTITLE_COLOR": "Colore sottotitoli", + "SUBTITLE_COLOR_DEFAULT": "Predefinito", + "SUBTITLE_COLOR_WHITE": "Bianco", + "SUBTITLE_COLOR_YELLOW": "Giallo", + "SUBTITLE_COLOR_CYAN": "Ciano" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 6cac2e8c8..576e6e6c6 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを開始", "EXIT_PICTURE_IN_PICTURE": "ピクチャーインピクチャーを終了", "ENTER_FULLSCREEN": "全画面表示", - "EXIT_FULLSCREEN": "全画面表示を終了" + "EXIT_FULLSCREEN": "全画面表示を終了", + "LOAD_SUBTITLE_FILE": "字幕ファイルを読み込む…", + "SUBTITLE_DELAY": "字幕の遅延", + "SUBTITLE_DELAY_DECREASE": "字幕を早く表示", + "SUBTITLE_DELAY_INCREASE": "字幕を遅く表示", + "SUBTITLE_DELAY_RESET": "字幕の遅延をリセット", + "SUBTITLE_SIZE": "字幕サイズ", + "SUBTITLE_COLOR": "字幕の色", + "SUBTITLE_COLOR_DEFAULT": "デフォルト", + "SUBTITLE_COLOR_WHITE": "白", + "SUBTITLE_COLOR_YELLOW": "黄色", + "SUBTITLE_COLOR_CYAN": "シアン" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index be0dbbf34..b95f02819 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "PIP 모드 시작", "EXIT_PICTURE_IN_PICTURE": "PIP 모드 종료", "ENTER_FULLSCREEN": "전체 화면", - "EXIT_FULLSCREEN": "전체 화면 종료" + "EXIT_FULLSCREEN": "전체 화면 종료", + "LOAD_SUBTITLE_FILE": "자막 파일 불러오기…", + "SUBTITLE_DELAY": "자막 지연", + "SUBTITLE_DELAY_DECREASE": "자막을 더 빨리 표시", + "SUBTITLE_DELAY_INCREASE": "자막을 더 늦게 표시", + "SUBTITLE_DELAY_RESET": "자막 지연 초기화", + "SUBTITLE_SIZE": "자막 크기", + "SUBTITLE_COLOR": "자막 색상", + "SUBTITLE_COLOR_DEFAULT": "기본값", + "SUBTITLE_COLOR_WHITE": "흰색", + "SUBTITLE_COLOR_YELLOW": "노란색", + "SUBTITLE_COLOR_CYAN": "청록색" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c92d257cb..4e4f96d2d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Beeld-in-beeld starten", "EXIT_PICTURE_IN_PICTURE": "Beeld-in-beeld sluiten", "ENTER_FULLSCREEN": "Volledig scherm openen", - "EXIT_FULLSCREEN": "Volledig scherm sluiten" + "EXIT_FULLSCREEN": "Volledig scherm sluiten", + "LOAD_SUBTITLE_FILE": "Ondertitelbestand laden…", + "SUBTITLE_DELAY": "Ondertitelvertraging", + "SUBTITLE_DELAY_DECREASE": "Ondertitels eerder tonen", + "SUBTITLE_DELAY_INCREASE": "Ondertitels later tonen", + "SUBTITLE_DELAY_RESET": "Ondertitelvertraging resetten", + "SUBTITLE_SIZE": "Ondertitelgrootte", + "SUBTITLE_COLOR": "Ondertitelkleur", + "SUBTITLE_COLOR_DEFAULT": "Standaard", + "SUBTITLE_COLOR_WHITE": "Wit", + "SUBTITLE_COLOR_YELLOW": "Geel", + "SUBTITLE_COLOR_CYAN": "Cyaan" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 01282f6a9..73de881cb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Włącz obraz w obrazie", "EXIT_PICTURE_IN_PICTURE": "Wyłącz obraz w obrazie", "ENTER_FULLSCREEN": "Włącz pełny ekran", - "EXIT_FULLSCREEN": "Wyłącz pełny ekran" + "EXIT_FULLSCREEN": "Wyłącz pełny ekran", + "LOAD_SUBTITLE_FILE": "Wczytaj plik napisów…", + "SUBTITLE_DELAY": "Opóźnienie napisów", + "SUBTITLE_DELAY_DECREASE": "Pokaż napisy wcześniej", + "SUBTITLE_DELAY_INCREASE": "Pokaż napisy później", + "SUBTITLE_DELAY_RESET": "Zresetuj opóźnienie napisów", + "SUBTITLE_SIZE": "Rozmiar napisów", + "SUBTITLE_COLOR": "Kolor napisów", + "SUBTITLE_COLOR_DEFAULT": "Domyślny", + "SUBTITLE_COLOR_WHITE": "Biały", + "SUBTITLE_COLOR_YELLOW": "Żółty", + "SUBTITLE_COLOR_CYAN": "Cyjan" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 54b113a83..12f3f73b0 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Ativar imagem em imagem", "EXIT_PICTURE_IN_PICTURE": "Sair de imagem em imagem", "ENTER_FULLSCREEN": "Entrar em tela cheia", - "EXIT_FULLSCREEN": "Sair da tela cheia" + "EXIT_FULLSCREEN": "Sair da tela cheia", + "LOAD_SUBTITLE_FILE": "Carregar ficheiro de legendas…", + "SUBTITLE_DELAY": "Atraso das legendas", + "SUBTITLE_DELAY_DECREASE": "Mostrar legendas mais cedo", + "SUBTITLE_DELAY_INCREASE": "Mostrar legendas mais tarde", + "SUBTITLE_DELAY_RESET": "Repor atraso das legendas", + "SUBTITLE_SIZE": "Tamanho das legendas", + "SUBTITLE_COLOR": "Cor das legendas", + "SUBTITLE_COLOR_DEFAULT": "Predefinição", + "SUBTITLE_COLOR_WHITE": "Branco", + "SUBTITLE_COLOR_YELLOW": "Amarelo", + "SUBTITLE_COLOR_CYAN": "Ciano" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index d32072550..5e1291085 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Включить режим «картинка в картинке»", "EXIT_PICTURE_IN_PICTURE": "Выйти из режима «картинка в картинке»", "ENTER_FULLSCREEN": "Во весь экран", - "EXIT_FULLSCREEN": "Выйти из полноэкранного режима" + "EXIT_FULLSCREEN": "Выйти из полноэкранного режима", + "LOAD_SUBTITLE_FILE": "Загрузить файл субтитров…", + "SUBTITLE_DELAY": "Задержка субтитров", + "SUBTITLE_DELAY_DECREASE": "Показывать субтитры раньше", + "SUBTITLE_DELAY_INCREASE": "Показывать субтитры позже", + "SUBTITLE_DELAY_RESET": "Сбросить задержку субтитров", + "SUBTITLE_SIZE": "Размер субтитров", + "SUBTITLE_COLOR": "Цвет субтитров", + "SUBTITLE_COLOR_DEFAULT": "По умолчанию", + "SUBTITLE_COLOR_WHITE": "Белый", + "SUBTITLE_COLOR_YELLOW": "Жёлтый", + "SUBTITLE_COLOR_CYAN": "Голубой" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 6e2edfeab..c2595c060 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "Resim içinde resim modunu aç", "EXIT_PICTURE_IN_PICTURE": "Resim içinde resim modundan çık", "ENTER_FULLSCREEN": "Tam ekrana geç", - "EXIT_FULLSCREEN": "Tam ekrandan çık" + "EXIT_FULLSCREEN": "Tam ekrandan çık", + "LOAD_SUBTITLE_FILE": "Altyazı dosyası yükle…", + "SUBTITLE_DELAY": "Altyazı gecikmesi", + "SUBTITLE_DELAY_DECREASE": "Altyazıları daha erken göster", + "SUBTITLE_DELAY_INCREASE": "Altyazıları daha geç göster", + "SUBTITLE_DELAY_RESET": "Altyazı gecikmesini sıfırla", + "SUBTITLE_SIZE": "Altyazı boyutu", + "SUBTITLE_COLOR": "Altyazı rengi", + "SUBTITLE_COLOR_DEFAULT": "Varsayılan", + "SUBTITLE_COLOR_WHITE": "Beyaz", + "SUBTITLE_COLOR_YELLOW": "Sarı", + "SUBTITLE_COLOR_CYAN": "Camgöbeği" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 3f3a1ed27..b2992df9a 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "进入画中画", "EXIT_PICTURE_IN_PICTURE": "退出画中画", "ENTER_FULLSCREEN": "进入全屏", - "EXIT_FULLSCREEN": "退出全屏" + "EXIT_FULLSCREEN": "退出全屏", + "LOAD_SUBTITLE_FILE": "加载字幕文件…", + "SUBTITLE_DELAY": "字幕延迟", + "SUBTITLE_DELAY_DECREASE": "提前显示字幕", + "SUBTITLE_DELAY_INCREASE": "延后显示字幕", + "SUBTITLE_DELAY_RESET": "重置字幕延迟", + "SUBTITLE_SIZE": "字幕大小", + "SUBTITLE_COLOR": "字幕颜色", + "SUBTITLE_COLOR_DEFAULT": "默认", + "SUBTITLE_COLOR_WHITE": "白色", + "SUBTITLE_COLOR_YELLOW": "黄色", + "SUBTITLE_COLOR_CYAN": "青色" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 1d1123e3b..d0376ef16 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -555,7 +555,18 @@ "ENTER_PICTURE_IN_PICTURE": "進入子母畫面", "EXIT_PICTURE_IN_PICTURE": "退出子母畫面", "ENTER_FULLSCREEN": "進入全螢幕", - "EXIT_FULLSCREEN": "離開全螢幕" + "EXIT_FULLSCREEN": "離開全螢幕", + "LOAD_SUBTITLE_FILE": "載入字幕檔案…", + "SUBTITLE_DELAY": "字幕延遲", + "SUBTITLE_DELAY_DECREASE": "提前顯示字幕", + "SUBTITLE_DELAY_INCREASE": "延後顯示字幕", + "SUBTITLE_DELAY_RESET": "重設字幕延遲", + "SUBTITLE_SIZE": "字幕大小", + "SUBTITLE_COLOR": "字幕顏色", + "SUBTITLE_COLOR_DEFAULT": "預設", + "SUBTITLE_COLOR_WHITE": "白色", + "SUBTITLE_COLOR_YELLOW": "黃色", + "SUBTITLE_COLOR_CYAN": "青色" } }, "PLAYBACK_DIAGNOSTICS": { diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md index 034f17a6a..4200e4e6b 100644 --- a/docs/architecture/player-controls-contract.md +++ b/docs/architecture/player-controls-contract.md @@ -172,7 +172,8 @@ interface PlayerController { ### Capabilities `PlayerControlsCapabilities` contains booleans for `seek`, `volume`, -`audioTracks`, `subtitles`, `qualityLevels`, `playbackSpeed`, `aspectRatio`, +`audioTracks`, `subtitles`, `externalSubtitles`, `subtitleDelay`, +`subtitleStyle`, `qualityLevels`, `playbackSpeed`, `aspectRatio`, `recording`, `pictureInPicture`, `fullscreen`, and `seriesNavigation`. The default is all-false. An adapter enables only features that its engine and @@ -209,6 +210,7 @@ owner. - `seekTo` / `seekBy` - `setVolume` - `setAudioTrack` / `setSubtitleTrack` +- `addExternalSubtitleFile` / `setSubtitleDelay` / `setSubtitleStyle` - `setQualityLevel` (`AUTO_QUALITY_LEVEL_ID` = `-1` re-enables auto) - `setPlaybackSpeed` - `setAspectRatio` @@ -647,6 +649,103 @@ so vendor storage cannot override the app-wide preference. With the token disabled, the existing ArtPlayer options, HLS audio settings, skin, source semantics, stored volume behavior, and series navigation remain unchanged. +## Advanced subtitle support + +The subtitle popover carries three capability-gated extensions beyond track +selection (#1408): loading an external subtitle file, adjusting the subtitle +timing offset, and styling subtitle text (size + color). Each is honest per +engine — an engine that cannot support a control simply never advertises the +capability, and the UI is not rendered. + +Contract surface: + +- capabilities `externalSubtitles`, `subtitleDelay`, `subtitleStyle`; +- state `subtitleDelaySeconds` (positive = subtitles appear later) and + `subtitleStyle` (`PlayerSubtitleStyle { sizePercent, color }`); and +- commands `addExternalSubtitleFile()` (fire-and-forget; the adapter owns its + environment's picker), `setSubtitleDelay(seconds)`, and + `setSubtitleStyle(style)`. + +The subtitle menu stays reachable with an empty track list whenever +`externalSubtitles` is set — loading a file is what creates the first track. +Delay and style rows keep the popover open, because these settings are tuned +iteratively against the running video (`ControlsSubtitleSettings` owns those +interactions); the load action closes it because a file dialog opens on top. + +Persistence: the style (size/color) is a cross-engine preference stored under +the `subtitleStyle` localStorage key (`subtitle-style.ts`), the same mechanism +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). File + bytes are decoded encoding-aware (`decodeExternalSubtitleBytes`: UTF-16 + BOMs, strict UTF-8, then `chooseLegacySingleByteDecode`, which picks + between Windows-1251 and Windows-1252 by the plausibility of the 1251 + candidate's decoded words — pure-Cyrillic words vote for 1251, words + mixing Cyrillic with ASCII letters vote against (misread Latin text like + "était" decodes to the mixed-script "йtait" that real subtitles never + contain), and Cyrillic must also carry a meaningful share of all letters + so an isolated accented CP1252 word ("À table" → "А table") cannot flip + the file), 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 — 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 + natively), and the renderer only ever forwards the returned path over the + dedicated IPC (`EMBEDDED_MPV_ADD_SUBTITLE` etc.); delay applies to every + subtitle track. mpv does not report these values back through the session + snapshot, so `EmbeddedMpvSubtitleSettings` keeps the authoritative + renderer-side values: the delay resets per session, and a non-default + persisted style is re-applied to each new session. `sub-color` affects + mpv's text-subtitle rendering; ASS files keep their embedded styling. + Runtime coverage: the packaged Linux frame-copy smoke + (`apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts`) + drives `addEmbeddedMpvSubtitle` with a fixture file against the real + packaged helper and asserts the track appears in the session snapshot, + plus round-trips the delay/style IPC. The native file dialog itself + (`selectEmbeddedMpvSubtitleFile`) cannot be automated and is verified + manually. +- **Not wired (capabilities stay false):** Video.js shared mode (its emulated + text-track display needs a separate remote-track + CSS integration — a + follow-up), the vendor-chrome (preference-off) web players by design, the + Embedded MPV native-view legacy dock, the Linux out-of-process native path + (which exports no subtitle commands), and external MPV/VLC, which own their + own UI. + ## Embedded MPV rendering constraints The shared contract does not replace either Embedded MPV renderer. The host diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 8c0c81074..331ebb2e3 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/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index be6e34dee..6923735d7 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -2,6 +2,7 @@ import { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, EmbeddedMpvSession, + EmbeddedMpvSubtitleStyle, EmbeddedMpvSupport, } from './embedded-mpv-session.interface'; import { ContentMetadataPatch } from './content-metadata.interface'; @@ -1164,6 +1165,21 @@ export interface ElectronBridgeApi { sessionId: string, trackId: number ) => Promise; + /** Loads an external subtitle file (absolute path) via mpv `sub-add`. */ + addEmbeddedMpvSubtitle?: ( + sessionId: string, + filePath: string + ) => Promise; + setEmbeddedMpvSubtitleDelay?: ( + sessionId: string, + seconds: number + ) => Promise; + setEmbeddedMpvSubtitleStyle?: ( + sessionId: string, + style: EmbeddedMpvSubtitleStyle + ) => Promise; + /** Opens the main-process subtitle file dialog; null when cancelled. */ + selectEmbeddedMpvSubtitleFile?: () => Promise; setEmbeddedMpvSpeed?: ( sessionId: string, speed: number 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 0c2b4c2fe..3ae0926ac 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,4 @@ +import type { PlayerSubtitleStyle } from './subtitle-style.util'; import type { RecordingStartMetadata } from './recording-metadata.interface'; export type EmbeddedMpvSessionStatus = @@ -22,8 +23,22 @@ export interface EmbeddedMpvCapabilities { aspectOverride: boolean; screenshot: boolean; recording: boolean; + /** Loading an external subtitle file via `sub-add` (frame-copy engine). */ + externalSubtitles?: boolean; + /** Adjusting `sub-delay` at runtime (frame-copy engine). */ + subtitleDelay?: boolean; + /** Adjusting `sub-scale`/`sub-color` at runtime (frame-copy engine). */ + subtitleStyle?: boolean; } +/** + * 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 type EmbeddedMpvSubtitleStyle = PlayerSubtitleStyle; + export type EmbeddedMpvEngine = 'native' | 'frame-copy'; export interface EmbeddedMpvSupport { diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts index 36aebbcfd..b16be976a 100644 --- a/libs/shared/interfaces/src/lib/ipc-commands.ts +++ b/libs/shared/interfaces/src/lib/ipc-commands.ts @@ -75,6 +75,13 @@ export const EMBEDDED_MPV_SET_VOLUME = 'EMBEDDED_MPV_SET_VOLUME'; export const EMBEDDED_MPV_SET_AUDIO_TRACK = 'EMBEDDED_MPV_SET_AUDIO_TRACK'; export const EMBEDDED_MPV_SET_SUBTITLE_TRACK = 'EMBEDDED_MPV_SET_SUBTITLE_TRACK'; +export const EMBEDDED_MPV_ADD_SUBTITLE = 'EMBEDDED_MPV_ADD_SUBTITLE'; +export const EMBEDDED_MPV_SET_SUBTITLE_DELAY = + 'EMBEDDED_MPV_SET_SUBTITLE_DELAY'; +export const EMBEDDED_MPV_SET_SUBTITLE_STYLE = + 'EMBEDDED_MPV_SET_SUBTITLE_STYLE'; +export const EMBEDDED_MPV_SELECT_SUBTITLE_FILE = + 'EMBEDDED_MPV_SELECT_SUBTITLE_FILE'; export const EMBEDDED_MPV_SET_SPEED = 'EMBEDDED_MPV_SET_SPEED'; export const EMBEDDED_MPV_SET_ASPECT = 'EMBEDDED_MPV_SET_ASPECT'; export const EMBEDDED_MPV_START_RECORDING = 'EMBEDDED_MPV_START_RECORDING'; 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/embedded-mpv-player/embedded-mpv-command-runner.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts index 84861131e..1892ce929 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.spec.ts @@ -339,4 +339,75 @@ describe('EmbeddedMpvCommandRunner', () => { expect(recording).toEqual({ active: false, targetPath: '/tmp/rec.ts' }); expect(session()?.recording).toEqual(recording); }); + + it('addExternalSubtitle loads the picked file into the session', async () => { + electron.selectEmbeddedMpvSubtitleFile = jest + .fn() + .mockResolvedValue('/subs/movie.srt'); + electron.addEmbeddedMpvSubtitle = jest + .fn() + .mockResolvedValue(createSession()); + + await expect(runner.addExternalSubtitle()).resolves.toBe(true); + expect(electron.addEmbeddedMpvSubtitle).toHaveBeenCalledWith( + 'mpv-1', + '/subs/movie.srt' + ); + }); + + it('addExternalSubtitle is a no-op on dialog cancel', async () => { + electron.selectEmbeddedMpvSubtitleFile = jest + .fn() + .mockResolvedValue(null); + electron.addEmbeddedMpvSubtitle = jest.fn(); + + await expect(runner.addExternalSubtitle()).resolves.toBe(false); + expect(electron.addEmbeddedMpvSubtitle).not.toHaveBeenCalled(); + }); + + it('addExternalSubtitle drops a pick that outlived its session', async () => { + let resolveDialog!: (value: string) => void; + electron.selectEmbeddedMpvSubtitleFile = jest.fn().mockReturnValue( + new Promise((resolve) => { + resolveDialog = resolve; + }) + ); + electron.addEmbeddedMpvSubtitle = jest.fn(); + + const pending = runner.addExternalSubtitle(); + sessionId.set('mpv-2'); + resolveDialog('/subs/movie.srt'); + + await expect(pending).resolves.toBe(false); + expect(electron.addEmbeddedMpvSubtitle).not.toHaveBeenCalled(); + }); + + it('forwards subtitle delay and style to the bridge', async () => { + electron.setEmbeddedMpvSubtitleDelay = jest + .fn() + .mockResolvedValue(createSession()); + electron.setEmbeddedMpvSubtitleStyle = jest + .fn() + .mockResolvedValue(createSession()); + + await runner.setSubtitleDelay(1.5); + expect(electron.setEmbeddedMpvSubtitleDelay).toHaveBeenCalledWith( + 'mpv-1', + 1.5 + ); + + await runner.setSubtitleStyle({ sizePercent: 150, color: '#ffffff' }); + expect(electron.setEmbeddedMpvSubtitleStyle).toHaveBeenCalledWith( + 'mpv-1', + { sizePercent: 150, color: '#ffffff' } + ); + }); + + it('subtitle commands no-op on bridges without the new methods', async () => { + await expect(runner.addExternalSubtitle()).resolves.toBe(false); + await expect(runner.setSubtitleDelay(1)).resolves.toBeUndefined(); + await expect( + runner.setSubtitleStyle({ sizePercent: 100, color: null }) + ).resolves.toBeUndefined(); + }); }); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts index 2239ebc0f..304479f7d 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-command-runner.ts @@ -1,6 +1,7 @@ import { Signal, WritableSignal } from '@angular/core'; import { EmbeddedMpvSession, + EmbeddedMpvSubtitleStyle, RecordingStartMetadata, } from '@iptvnator/shared/interfaces'; @@ -88,6 +89,48 @@ export class EmbeddedMpvCommandRunner { await this.run(id, () => setSubtitleTrack(id, trackId)); } + /** + * Opens the main-process subtitle file dialog and, when the user picks a + * file, hands its path to mpv. Returns true when a file was loaded. + */ + async addExternalSubtitle(): Promise { + const id = this.ctx.sessionId(); + const electron = this.bridge(); + const selectSubtitleFile = electron?.selectEmbeddedMpvSubtitleFile; + const addSubtitle = electron?.addEmbeddedMpvSubtitle; + if (!id || !selectSubtitleFile || !addSubtitle) { + return false; + } + const filePath = await this.guardIpc(selectSubtitleFile); + // The dialog is modal-slow; the session may be gone by the time the + // user picked a file. + if (!filePath || this.ctx.sessionId() !== id) { + return false; + } + const updated = await this.run(id, () => addSubtitle(id, filePath)); + return updated !== null; + } + + async setSubtitleDelay(seconds: number): Promise { + const id = this.ctx.sessionId(); + const electron = this.bridge(); + if (!id || !electron?.setEmbeddedMpvSubtitleDelay) { + return; + } + const setSubtitleDelay = electron.setEmbeddedMpvSubtitleDelay; + await this.run(id, () => setSubtitleDelay(id, seconds)); + } + + async setSubtitleStyle(style: EmbeddedMpvSubtitleStyle): Promise { + const id = this.ctx.sessionId(); + const electron = this.bridge(); + if (!id || !electron?.setEmbeddedMpvSubtitleStyle) { + return; + } + const setSubtitleStyle = electron.setEmbeddedMpvSubtitleStyle; + await this.run(id, () => setSubtitleStyle(id, style)); + } + async setSpeed(speed: number): Promise { const id = this.ctx.sessionId(); const electron = this.bridge(); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts index 4461baafa..cf8df65bf 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.spec.ts @@ -44,6 +44,9 @@ function supported( aspectOverride: true, screenshot: false, recording: true, + externalSubtitles: true, + subtitleDelay: true, + subtitleStyle: true, }, ...overrides, }; @@ -195,6 +198,9 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: true, + externalSubtitles: true, + subtitleDelay: true, + subtitleStyle: true, qualityLevels: false, playbackSpeed: true, aspectRatio: true, @@ -221,6 +227,9 @@ describe('EmbeddedMpvControlsAdapter', () => { volume: true, audioTracks: true, subtitles: false, + externalSubtitles: false, + subtitleDelay: false, + subtitleStyle: false, qualityLevels: false, playbackSpeed: false, aspectRatio: false, diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts index 7dd4ad750..1a13f4c83 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts @@ -40,6 +40,7 @@ import { import { resolveRecordingFeedback } from './embedded-mpv-controls-recording-feedback'; import { EmbeddedMpvControlsRecording } from './embedded-mpv-controls-recording'; import { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; +import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings'; export interface EmbeddedMpvControlsContext { readonly playback: Signal; @@ -91,6 +92,9 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { private readonly activeSessionId = computed( () => this.controller.session()?.id ?? null ); + private readonly subtitleSettings = new EmbeddedMpvSubtitleSettings( + this.controller + ); private readonly recordingTransitionKey = computed(() => { const playbackIdentity = this.recordingPlaybackIdentity(); const sessionId = this.activeSessionId(); @@ -115,6 +119,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { volume: true, audioTracks: true, subtitles: optionalCapabilities?.subtitles ?? false, + externalSubtitles: + optionalCapabilities?.externalSubtitles ?? false, + subtitleDelay: optionalCapabilities?.subtitleDelay ?? false, + subtitleStyle: optionalCapabilities?.subtitleStyle ?? false, playbackSpeed: optionalCapabilities?.playbackSpeed ?? false, aspectRatio: optionalCapabilities?.aspectOverride ?? false, recording: optionalCapabilities?.recording ?? false, @@ -180,6 +188,8 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { }) ), subtitlesEnabled: (session?.selectedSubtitleTrackId ?? -1) >= 0, + subtitleDelaySeconds: this.subtitleSettings.delaySeconds(), + subtitleStyle: this.subtitleSettings.style(), // MPV demuxes one program; no HLS/DASH rendition list is surfaced. qualityLevels: [], qualityAutoEnabled: true, @@ -214,6 +224,10 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { setVolume: (value) => void this.controller.applyVolume(value), setAudioTrack: (id) => void this.controller.setAudioTrack(id), setSubtitleTrack: (id) => void this.controller.setSubtitleTrack(id), + addExternalSubtitleFile: () => + this.subtitleSettings.addExternalSubtitle(), + setSubtitleDelay: (seconds) => this.subtitleSettings.setDelay(seconds), + setSubtitleStyle: (style) => this.subtitleSettings.setStyle(style), setQualityLevel: () => undefined, setPlaybackSpeed: (speed) => void this.controller.setSpeed(speed), setAspectRatio: (value) => void this.controller.setAspect(value), @@ -243,6 +257,12 @@ export class EmbeddedMpvControlsAdapter implements PlayerController { ); }); + // Delay is per-session; the persisted style is re-applied per session. + effect(() => { + const sessionId = this.activeSessionId(); + untracked(() => this.subtitleSettings.syncSession(sessionId)); + }); + effect(() => { const session = this.controller.session(); const playbackIdentity = this.recordingPlaybackIdentity(); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts index ecb54e379..d78b910e8 100644 --- a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts @@ -10,6 +10,7 @@ import { import { EmbeddedMpvBounds, EmbeddedMpvSession, + EmbeddedMpvSubtitleStyle, EmbeddedMpvSupport, RecordingStartMetadata, ResolvedPortalPlayback, @@ -301,6 +302,13 @@ export class EmbeddedMpvSessionController { this.commands.setAudioTrack(trackId); readonly setSubtitleTrack = (trackId: number): Promise => this.commands.setSubtitleTrack(trackId); + readonly addExternalSubtitle = (): Promise => + this.commands.addExternalSubtitle(); + readonly setSubtitleDelay = (seconds: number): Promise => + this.commands.setSubtitleDelay(seconds); + readonly setSubtitleStyle = ( + style: EmbeddedMpvSubtitleStyle + ): Promise => this.commands.setSubtitleStyle(style); readonly setSpeed = (speed: number): Promise => this.commands.setSpeed(speed); readonly setAspect = (aspect: string): Promise => diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.spec.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.spec.ts new file mode 100644 index 000000000..543a90b85 --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.spec.ts @@ -0,0 +1,104 @@ +import { + DEFAULT_SUBTITLE_STYLE, + SUBTITLE_STYLE_STORAGE_KEY, +} from '../player-controls/subtitle-style'; +import { EmbeddedMpvSubtitleSettings } from './embedded-mpv-subtitle-settings'; +import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +function createControllerMock() { + return { + addExternalSubtitle: jest.fn().mockResolvedValue(true), + setSubtitleDelay: jest.fn().mockResolvedValue(undefined), + setSubtitleStyle: jest.fn().mockResolvedValue(undefined), + }; +} + +describe('EmbeddedMpvSubtitleSettings', () => { + let controller: ReturnType; + + function createSettings(): EmbeddedMpvSubtitleSettings { + return new EmbeddedMpvSubtitleSettings( + controller as unknown as EmbeddedMpvSessionController + ); + } + + beforeEach(() => { + controller = createControllerMock(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('starts with the stored style and a zero delay', () => { + localStorage.setItem( + SUBTITLE_STYLE_STORAGE_KEY, + JSON.stringify({ sizePercent: 150, color: '#ffffff' }) + ); + const settings = createSettings(); + expect(settings.style()).toEqual({ + sizePercent: 150, + color: '#ffffff', + }); + expect(settings.delaySeconds()).toBe(0); + }); + + it('clamps and forwards the delay to the session controller', () => { + const settings = createSettings(); + settings.setDelay(90); + expect(settings.delaySeconds()).toBe(60); + expect(controller.setSubtitleDelay).toHaveBeenCalledWith(60); + }); + + it('normalizes, persists, and forwards style changes', () => { + const settings = createSettings(); + settings.setStyle({ sizePercent: 125, color: '#FFE94F' }); + + expect(settings.style()).toEqual({ + sizePercent: 125, + color: '#ffe94f', + }); + expect(controller.setSubtitleStyle).toHaveBeenCalledWith({ + sizePercent: 125, + color: '#ffe94f', + }); + expect( + JSON.parse(localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY) ?? '') + ).toEqual({ sizePercent: 125, color: '#ffe94f' }); + }); + + it('resets the delay per session and re-applies a non-default style', () => { + const settings = createSettings(); + settings.syncSession('session-1'); + // Default style: nothing pushed to a fresh session. + expect(controller.setSubtitleStyle).not.toHaveBeenCalled(); + + settings.setStyle({ sizePercent: 200, color: null }); + settings.setDelay(2); + controller.setSubtitleStyle.mockClear(); + + settings.syncSession('session-2'); + expect(settings.delaySeconds()).toBe(0); + expect(controller.setSubtitleStyle).toHaveBeenCalledWith({ + sizePercent: 200, + color: null, + }); + + // Re-observing the same session must not re-apply or reset anything. + settings.setDelay(1.5); + controller.setSubtitleStyle.mockClear(); + settings.syncSession('session-2'); + expect(settings.delaySeconds()).toBe(1.5); + expect(controller.setSubtitleStyle).not.toHaveBeenCalled(); + }); + + it('does not push the style while no session is active', () => { + const settings = createSettings(); + settings.setStyle({ sizePercent: 150, color: null }); + controller.setSubtitleStyle.mockClear(); + settings.syncSession(null); + expect(controller.setSubtitleStyle).not.toHaveBeenCalled(); + expect(settings.style()).not.toEqual(DEFAULT_SUBTITLE_STYLE); + }); +}); diff --git a/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.ts b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.ts new file mode 100644 index 000000000..b3604b88d --- /dev/null +++ b/libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-subtitle-settings.ts @@ -0,0 +1,56 @@ +import { signal } from '@angular/core'; +import type { PlayerSubtitleStyle } from '../player-controls/player-controls.model'; +import { + clampSubtitleDelay, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, + persistSubtitleStyle, + readStoredSubtitleStyle, +} from '../player-controls/subtitle-style'; +import type { EmbeddedMpvSessionController } from './embedded-mpv-session-controller'; + +/** + * Renderer-side owner of the Embedded MPV subtitle delay and style. mpv does + * not report `sub-delay`/`sub-scale`/`sub-color` back through the session + * snapshot, so this class keeps the authoritative optimistic values: delay is + * per-session (it corrects one stream) and resets when the session changes, + * while the style is the shared persisted preference and is re-applied to + * every new session. + */ +export class EmbeddedMpvSubtitleSettings { + readonly delaySeconds = signal(0); + readonly style = signal(readStoredSubtitleStyle()); + + private appliedSessionId: string | null = null; + + constructor(private readonly controller: EmbeddedMpvSessionController) {} + + /** Called from an effect whenever the active session id changes. */ + syncSession(sessionId: string | null): void { + if (sessionId === this.appliedSessionId) { + return; + } + this.appliedSessionId = sessionId; + this.delaySeconds.set(0); + if (sessionId && !isDefaultSubtitleStyle(this.style())) { + void this.controller.setSubtitleStyle(this.style()); + } + } + + setDelay(seconds: number): void { + const clamped = clampSubtitleDelay(seconds); + this.delaySeconds.set(clamped); + void this.controller.setSubtitleDelay(clamped); + } + + setStyle(style: PlayerSubtitleStyle): void { + const normalized = normalizeSubtitleStyle(style); + this.style.set(normalized); + persistSubtitleStyle(normalized); + void this.controller.setSubtitleStyle(normalized); + } + + addExternalSubtitle(): void { + void this.controller.addExternalSubtitle(); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts index fa90cb99e..c692ec3e4 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.spec.ts @@ -18,6 +18,9 @@ describe('ControlsMenuSelection', () => { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts index 7e2938d3c..3a57ad9cf 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-selection.ts @@ -32,6 +32,11 @@ export class ControlsMenuSelection { this.apply('subtitle', (c) => c.setSubtitleTrack(trackId)); } + /** Opens the engine's subtitle file picker and closes the popover. */ + externalSubtitle(): void { + this.apply('subtitle', (c) => c.addExternalSubtitleFile()); + } + qualityLevel(levelId: number): void { this.apply('quality', (c) => c.setQualityLevel(levelId)); } diff --git a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts index 98a0fd765..f78bc6766 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-menu-state.ts @@ -27,10 +27,13 @@ function getControlsMenuAvailability( showControls && capabilities.audioTracks && state.audioTracks.length > 1, + // External subtitle loading keeps the menu reachable with an empty + // track list — the "Load subtitle file…" action is how the first + // track appears. subtitle: showControls && - capabilities.subtitles && - state.subtitleTracks.length > 0, + ((capabilities.subtitles && state.subtitleTracks.length > 0) || + capabilities.externalSubtitles), quality: showControls && capabilities.qualityLevels && diff --git a/libs/ui/playback/src/lib/player-controls/controls-subtitle-settings.ts b/libs/ui/playback/src/lib/player-controls/controls-subtitle-settings.ts new file mode 100644 index 000000000..09960b06d --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/controls-subtitle-settings.ts @@ -0,0 +1,60 @@ +import type { + PlayerController, + PlayerSubtitleStyle, +} from './player-controls.model'; +import { clampSubtitleDelay } from './subtitle-style'; + +export interface ControlsSubtitleSettingsDeps { + controller: () => PlayerController; + /** Reveal without rescheduling auto-hide (menu stays open while tuning). */ + revealSticky: () => void; +} + +/** + * Owns the delay/size/color interactions of the subtitle popover: guard on the + * capability, keep the controls revealed, clamp, and forward the command. The + * popover intentionally stays open — these settings are tuned iteratively + * against the running video. + */ +export class ControlsSubtitleSettings { + constructor(private readonly deps: ControlsSubtitleSettingsDeps) {} + + adjustDelay(deltaSeconds: number): void { + const controller = this.deps.controller(); + this.applyDelay( + controller.state().subtitleDelaySeconds + deltaSeconds + ); + } + + resetDelay(): void { + this.applyDelay(0); + } + + setSize(sizePercent: number): void { + const controller = this.deps.controller(); + this.applyStyle({ ...controller.state().subtitleStyle, sizePercent }); + } + + setColor(color: string | null): void { + const controller = this.deps.controller(); + this.applyStyle({ ...controller.state().subtitleStyle, color }); + } + + private applyDelay(seconds: number): void { + const controller = this.deps.controller(); + if (!controller.capabilities().subtitleDelay) { + return; + } + this.deps.revealSticky(); + controller.commands.setSubtitleDelay(clampSubtitleDelay(seconds)); + } + + private applyStyle(style: PlayerSubtitleStyle): void { + const controller = this.deps.controller(); + if (!controller.capabilities().subtitleStyle) { + return; + } + this.deps.revealSticky(); + controller.commands.setSubtitleStyle(style); + } +} diff --git a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts index 8ff1525c6..c3b62471d 100644 --- a/libs/ui/playback/src/lib/player-controls/controls-view-model.ts +++ b/libs/ui/playback/src/lib/player-controls/controls-view-model.ts @@ -50,8 +50,12 @@ export function createControlsViewModel(deps: ControlsViewModelDeps) { const hasAudioTracks = computed( () => capabilities().audioTracks && state().audioTracks.length > 1 ); + // External subtitle loading renders the button even with no embedded + // tracks yet — loading a file is what creates the first one. const hasSubtitleTracks = computed( - () => capabilities().subtitles && state().subtitleTracks.length > 0 + () => + (capabilities().subtitles && state().subtitleTracks.length > 0) || + capabilities().externalSubtitles ); const hasQualityLevels = computed( () => capabilities().qualityLevels && state().qualityLevels.length > 1 diff --git a/libs/ui/playback/src/lib/player-controls/index.ts b/libs/ui/playback/src/lib/player-controls/index.ts index 1b5d68896..f900833e9 100644 --- a/libs/ui/playback/src/lib/player-controls/index.ts +++ b/libs/ui/playback/src/lib/player-controls/index.ts @@ -1,7 +1,9 @@ export * from './player-controls.model'; export * from './player-controls-defaults'; +export * from './subtitle-style'; export * from './player-controls.component'; export * from './controls-menu-state'; +export * from './controls-subtitle-settings'; export * from './controls-feedback'; export * from './controls-fullscreen'; export * from './controls-shortcuts'; diff --git a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts index d32c7871a..12c5d3b33 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls-defaults.ts @@ -3,12 +3,16 @@ import type { PlayerControlsState, PlayerPreset, } from './player-controls.model'; +import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style'; export const DEFAULT_PLAYER_CAPABILITIES: PlayerControlsCapabilities = { seek: false, volume: false, audioTracks: false, subtitles: false, + externalSubtitles: false, + subtitleDelay: false, + subtitleStyle: false, qualityLevels: false, playbackSpeed: false, aspectRatio: false, @@ -48,6 +52,8 @@ export function createEmptyControlsState(): PlayerControlsState { audioTracks: [], subtitleTracks: [], subtitlesEnabled: false, + subtitleDelaySeconds: 0, + subtitleStyle: { ...DEFAULT_SUBTITLE_STYLE }, qualityLevels: [], qualityAutoEnabled: true, playbackSpeed: 1, diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts index 587908a71..51aa4523d 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.contract.spec.ts @@ -30,6 +30,9 @@ function createFakeController(): FakeController { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.html b/libs/ui/playback/src/lib/player-controls/player-controls.component.html index 3d356f1ad..a85df057c 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.html +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.html @@ -359,27 +359,39 @@ 'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate }} + +
- + @if ( + capabilities().subtitles && + state().subtitleTracks.length > 0 + ) { + + } @for ( track of state().subtitleTracks; track track.id @@ -404,6 +416,214 @@ } } + @if (capabilities().externalSubtitles) { + + } +
+ @if (capabilities().subtitleDelay) { +
+
+ {{ + 'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY' + | translate + }} +
+
+ + {{ + subtitleDelayLabel( + state() + .subtitleDelaySeconds + ) + }} + + +
+
+ } + @if (capabilities().subtitleStyle) { +
+
+ {{ + 'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE' + | translate + }} +
+
+ @for ( + preset of subtitleSizePresets; + track preset.value + ) { + + } +
+
+ {{ + 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR' + | translate + }} +
+
+ @for ( + preset of subtitleColorPresets; + track preset.label + ) { + + } +
+
+ }
} 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 index b5f106141..22a042edd 100644 --- 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 @@ -27,6 +27,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.media-title.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.media-title.spec.ts index 8873be322..c23dfdf40 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.media-title.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.media-title.spec.ts @@ -27,6 +27,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.picture-in-picture.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.picture-in-picture.spec.ts index da4412b31..2cd93c2aa 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.picture-in-picture.spec.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.picture-in-picture.spec.ts @@ -27,6 +27,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), 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 index bd4de88fa..79059f289 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.scss +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.scss @@ -501,6 +501,114 @@ height: 18px; } +.player-controls__load-subtitle mat-icon { + color: rgba(255, 255, 255, 0.85); +} + +// Single scroll owner for the subtitle popover's list + delay/style sections: +// on short players (e.g. the 220px portal player) the panel's max-height cap +// must scroll ALL content — sections outside the list's own scroll area would +// otherwise clip or overflow into the controls bar. +.player-controls__subtitle-body { + display: flex; + flex-direction: column; + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; +} + +// Inside the shared scroll owner the list must not nest a second scroll area. +.player-controls__subtitle-body .player-controls__track-list { + flex: none; + overflow-y: visible; +} + +.player-controls__subtitle-section { + flex: 0 0 auto; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.player-controls__subtitle-section-title { + 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__subtitle-delay-row { + display: flex; + align-items: center; + gap: 4px; +} + +.player-controls__subtitle-delay-value { + min-width: 58px; + text-align: center; + font-variant-numeric: tabular-nums; + color: var(--pc-text); +} + +.player-controls__subtitle-preset-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding-bottom: 4px; +} + +.player-controls__subtitle-chip { + padding: 4px 10px; + color: var(--pc-text); + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 999px; + cursor: pointer; + font: inherit; + font-size: 0.8rem; +} + +.player-controls__subtitle-chip:hover, +.player-controls__subtitle-chip:focus-visible, +.player-controls__subtitle-chip--selected { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.6); +} + +.player-controls__subtitle-swatch { + width: 26px; + height: 26px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-radius: 50%; + cursor: pointer; + background: var(--swatch-color, transparent); +} + +// The "engine default" swatch has no color of its own: render a diagonal +// slash so it reads as "no override". +.player-controls__subtitle-swatch--default { + background: + linear-gradient( + 135deg, + transparent 44%, + rgba(255, 255, 255, 0.7) 46%, + rgba(255, 255, 255, 0.7) 54%, + transparent 56% + ), + rgba(255, 255, 255, 0.08); +} + +.player-controls__subtitle-swatch:hover, +.player-controls__subtitle-swatch:focus-visible, +.player-controls__subtitle-swatch--selected { + border-color: #ffffff; +} + @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 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 index 9762252c5..c7cc161d3 100644 --- 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 @@ -25,6 +25,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), diff --git a/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts new file mode 100644 index 000000000..cccbd8b1b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.subtitle-settings.spec.ts @@ -0,0 +1,248 @@ +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(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), + setPlaybackSpeed: jest.fn(), + setAspectRatio: jest.fn(), + toggleRecording: jest.fn(), + togglePictureInPicture: jest.fn(), + }; + const controller: PlayerController = { capabilities, state, commands }; + return { controller, capabilities, state, commands }; +} + +describe('PlayerControlsComponent subtitle settings', () => { + 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; + + const openSubtitleMenu = () => { + fixture.componentInstance.toggleMenu('subtitle'); + fixture.detectChanges(); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [PlayerControlsComponent, TranslateModule.forRoot()], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + EMBEDDED_MPV: { + PLAYER: { + SUBTITLES: 'Subtitles', + SUBTITLES_OFF: 'Off', + LOAD_SUBTITLE_FILE: 'Load subtitle file…', + SUBTITLE_DELAY: 'Subtitle delay', + SUBTITLE_DELAY_DECREASE: 'Show subtitles earlier', + SUBTITLE_DELAY_INCREASE: 'Show subtitles later', + SUBTITLE_DELAY_RESET: 'Reset subtitle delay', + SUBTITLE_SIZE: 'Subtitle size', + SUBTITLE_COLOR: 'Subtitle color', + SUBTITLE_COLOR_DEFAULT: 'Default', + SUBTITLE_COLOR_WHITE: 'White', + SUBTITLE_COLOR_YELLOW: 'Yellow', + SUBTITLE_COLOR_CYAN: 'Cyan', + }, + }, + }); + translate.use('en'); + + fake = createFakeController(); + fixture = TestBed.createComponent(PlayerControlsComponent); + fixture.componentRef.setInput('controller', fake.controller); + fixture.detectChanges(); + }); + + it('renders the subtitle button with zero tracks when external loading exists', () => { + expect(query('[aria-label="Subtitles"]')).toBeNull(); + + setCapabilities({ externalSubtitles: true }); + fixture.detectChanges(); + + expect(query('[aria-label="Subtitles"]')).not.toBeNull(); + openSubtitleMenu(); + // No track list entries: no Off row without a selectable track… + expect(query('.player-controls__track--selected')).toBeNull(); + // …but the load action is present. + const load = query('[data-test-id="player-controls-load-subtitle"]'); + expect(load).not.toBeNull(); + load?.click(); + expect(fake.commands.addExternalSubtitleFile).toHaveBeenCalledTimes(1); + fixture.detectChanges(); + // The pick closes the popover (a file dialog opens on top). + expect( + query('[data-test-id="player-controls-load-subtitle"]') + ).toBeNull(); + }); + + it('hides the load action, delay, and style sections without the capabilities', () => { + setCapabilities({ subtitles: true }); + setState({ + subtitleTracks: [{ id: 0, label: 'English', selected: false }], + }); + fixture.detectChanges(); + openSubtitleMenu(); + + expect( + query('[data-test-id="player-controls-load-subtitle"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-delay"]') + ).toBeNull(); + expect( + query('[data-test-id="player-controls-subtitle-style"]') + ).toBeNull(); + }); + + it('steps, displays, and resets the subtitle delay', () => { + setCapabilities({ subtitles: true, subtitleDelay: true }); + setState({ + subtitleTracks: [{ id: 0, label: 'External', selected: true }], + subtitlesEnabled: true, + subtitleDelaySeconds: 0.5, + }); + fixture.detectChanges(); + openSubtitleMenu(); + + const section = query( + '[data-test-id="player-controls-subtitle-delay"]' + ); + expect(section).not.toBeNull(); + expect( + section?.querySelector('.player-controls__subtitle-delay-value') + ?.textContent + ).toContain('+0.5 s'); + + ( + section?.querySelector( + '[aria-label="Show subtitles later"]' + ) as HTMLButtonElement + ).click(); + expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(1); + + ( + section?.querySelector( + '[aria-label="Show subtitles earlier"]' + ) as HTMLButtonElement + ).click(); + expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(0); + + ( + section?.querySelector( + '[aria-label="Reset subtitle delay"]' + ) as HTMLButtonElement + ).click(); + expect(fake.commands.setSubtitleDelay).toHaveBeenLastCalledWith(0); + // The menu stays open for repeated adjustment. + fixture.detectChanges(); + expect( + query('[data-test-id="player-controls-subtitle-delay"]') + ).not.toBeNull(); + }); + + it('applies size and color presets through setSubtitleStyle', () => { + setCapabilities({ subtitles: true, subtitleStyle: true }); + setState({ + subtitleTracks: [{ id: 0, label: 'English', selected: true }], + subtitlesEnabled: true, + }); + fixture.detectChanges(); + openSubtitleMenu(); + + const style = query( + '[data-test-id="player-controls-subtitle-style"]' + ) as HTMLElement; + const chips = Array.from( + style.querySelectorAll( + '.player-controls__subtitle-chip' + ) + ); + const largeChip = chips.find((chip) => + chip.textContent?.includes('150%') + ); + largeChip?.click(); + expect(fake.commands.setSubtitleStyle).toHaveBeenCalledWith({ + sizePercent: 150, + color: null, + }); + + ( + style.querySelector( + '[aria-label="Yellow"]' + ) as HTMLButtonElement + ).click(); + expect(fake.commands.setSubtitleStyle).toHaveBeenLastCalledWith({ + sizePercent: 100, + color: '#ffe94f', + }); + }); + + it('guards the new commands behind their capabilities', () => { + const component = fixture.componentInstance; + component.loadExternalSubtitle(); + component.subtitleSettings.adjustDelay(0.5); + component.subtitleSettings.resetDelay(); + component.subtitleSettings.setSize(150); + component.subtitleSettings.setColor('#ffffff'); + + expect(fake.commands.addExternalSubtitleFile).not.toHaveBeenCalled(); + expect(fake.commands.setSubtitleDelay).not.toHaveBeenCalled(); + expect(fake.commands.setSubtitleStyle).not.toHaveBeenCalled(); + }); + + it('clamps stepped delays to the supported window', () => { + setCapabilities({ subtitles: true, subtitleDelay: true }); + setState({ + subtitleTracks: [{ id: 0, label: 'External', selected: true }], + subtitleDelaySeconds: 60, + }); + fixture.detectChanges(); + + fixture.componentInstance.subtitleSettings.adjustDelay(0.5); + expect(fake.commands.setSubtitleDelay).toHaveBeenCalledWith(60); + }); +}); 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 index ba8df2bf1..807e2071c 100644 --- 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 @@ -27,6 +27,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), 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 index d4b36318f..f40a5a654 100644 --- 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 @@ -24,6 +24,9 @@ function createFakeController() { setVolume: jest.fn(), setAudioTrack: jest.fn(), setSubtitleTrack: jest.fn(), + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + setSubtitleStyle: jest.fn(), setQualityLevel: jest.fn(), setPlaybackSpeed: jest.fn(), setAspectRatio: jest.fn(), 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 index ec0446860..14bc07322 100644 --- a/libs/ui/playback/src/lib/player-controls/player-controls.component.ts +++ b/libs/ui/playback/src/lib/player-controls/player-controls.component.ts @@ -25,11 +25,18 @@ import { ControlsTimeline } from './controls-timeline'; import { ControlsVisibility } from './controls-visibility'; import { createControlsViewModel } from './controls-view-model'; import { ControlsVolume } from './controls-volume'; +import { ControlsSubtitleSettings } from './controls-subtitle-settings'; import { formatTime, speedLabel } from './controls-format.utils'; import type { PlayerController, PlayerMediaTitle, } from './player-controls.model'; +import { + SUBTITLE_COLOR_PRESETS, + SUBTITLE_DELAY_STEP_SECONDS, + SUBTITLE_SIZE_PRESETS, + subtitleDelayLabel, +} from './subtitle-style'; @Component({ selector: 'app-player-controls', @@ -81,6 +88,10 @@ export class PlayerControlsComponent implements OnDestroy { }, this.host ); + readonly subtitleSettings = new ControlsSubtitleSettings({ + controller: () => this.controller(), + revealSticky: () => this.reveal({ scheduleHide: false }), + }); readonly menuSelection = new ControlsMenuSelection({ commands: () => this.controller().commands, menus: this.menus, @@ -229,6 +240,10 @@ export class PlayerControlsComponent implements OnDestroy { } formatTime = formatTime; speedLabel = speedLabel; + subtitleDelayLabel = subtitleDelayLabel; + readonly subtitleSizePresets = SUBTITLE_SIZE_PRESETS; + readonly subtitleColorPresets = SUBTITLE_COLOR_PRESETS; + readonly subtitleDelayStep = SUBTITLE_DELAY_STEP_SECONDS; togglePlay(): void { this.reveal(); if (!this.canTogglePlay()) { @@ -307,6 +322,13 @@ export class PlayerControlsComponent implements OnDestroy { this.menus.toggle(menu); this.reveal(); } + + loadExternalSubtitle(): void { + if (!this.capabilities().externalSubtitles) { + return; + } + this.menuSelection.externalSubtitle(); + } toggleRecording(): void { if (!this.canRecord()) { return; 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 6241ef20c..7690db949 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' @@ -14,6 +15,12 @@ export interface PlayerControlsCapabilities { volume: boolean; audioTracks: boolean; subtitles: boolean; + /** Loading an external subtitle file (.srt/.vtt, mpv also .ass). */ + externalSubtitles: boolean; + /** Adjusting the subtitle timing offset for the current playback. */ + subtitleDelay: boolean; + /** Adjusting subtitle text size and color. */ + subtitleStyle: boolean; /** Manifest-driven: true only when the source exposes >1 video rendition. */ qualityLevels: boolean; playbackSpeed: boolean; @@ -24,6 +31,10 @@ export interface PlayerControlsCapabilities { seriesNavigation: boolean; } +// 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; label: string; // adapter pre-computes the display label @@ -77,6 +88,10 @@ export interface PlayerControlsState { subtitleTracks: PlayerTrack[]; /** True when a subtitle track is active (selected id !== null/off). */ subtitlesEnabled: boolean; + /** Current subtitle timing offset; positive shows subtitles later. */ + subtitleDelaySeconds: number; + /** Current subtitle presentation preferences. */ + subtitleStyle: PlayerSubtitleStyle; /** * Per-level quality options ("1080p", …). A level reports `selected` only * while a manual selection is active; with ABR on, none is selected. @@ -103,6 +118,10 @@ export interface PlayerControlsCommands { setVolume(value: number): void; // 0..1 setAudioTrack(id: number): void; setSubtitleTrack(id: number): void; // -1 = off + /** Opens the engine's subtitle file picker; loads + selects the file. */ + addExternalSubtitleFile(): void; + setSubtitleDelay(seconds: number): void; + setSubtitleStyle(style: PlayerSubtitleStyle): void; setQualityLevel(id: number): void; // AUTO_QUALITY_LEVEL_ID (-1) = auto setPlaybackSpeed(speed: number): void; setAspectRatio(value: string): void; 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 new file mode 100644 index 000000000..6374bff92 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/subtitle-style.spec.ts @@ -0,0 +1,104 @@ +import { + DEFAULT_SUBTITLE_STYLE, + SUBTITLE_STYLE_STORAGE_KEY, + clampSubtitleDelay, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, + persistSubtitleStyle, + readStoredSubtitleStyle, + subtitleDelayLabel, +} from './subtitle-style'; + +describe('subtitle-style', () => { + afterEach(() => { + localStorage.clear(); + }); + + describe('clampSubtitleDelay', () => { + it('clamps to the ±60 s window and rejects non-finite values', () => { + expect(clampSubtitleDelay(1.5)).toBe(1.5); + expect(clampSubtitleDelay(500)).toBe(60); + expect(clampSubtitleDelay(-500)).toBe(-60); + expect(clampSubtitleDelay(Number.NaN)).toBe(0); + expect(clampSubtitleDelay(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it('rounds away float drift from repeated 0.5 steps', () => { + expect(clampSubtitleDelay(0.1 + 0.2)).toBe(0.3); + }); + }); + + describe('normalizeSubtitleStyle', () => { + it('returns the default for junk input', () => { + expect(normalizeSubtitleStyle(null)).toEqual( + DEFAULT_SUBTITLE_STYLE + ); + expect(normalizeSubtitleStyle('big')).toEqual( + DEFAULT_SUBTITLE_STYLE + ); + expect( + normalizeSubtitleStyle({ sizePercent: 'x', color: 42 }) + ).toEqual(DEFAULT_SUBTITLE_STYLE); + }); + + it('clamps the size and validates the color format', () => { + expect( + normalizeSubtitleStyle({ sizePercent: 1000, color: '#FFE94F' }) + ).toEqual({ sizePercent: 400, color: '#ffe94f' }); + expect( + normalizeSubtitleStyle({ sizePercent: 5, color: 'red' }) + ).toEqual({ sizePercent: 25, color: null }); + }); + }); + + describe('persistence', () => { + it('round-trips a non-default style through localStorage', () => { + persistSubtitleStyle({ sizePercent: 150, color: '#ffffff' }); + expect(readStoredSubtitleStyle()).toEqual({ + sizePercent: 150, + color: '#ffffff', + }); + }); + + it('removes the stored value when the style returns to default', () => { + persistSubtitleStyle({ sizePercent: 150, color: null }); + persistSubtitleStyle({ ...DEFAULT_SUBTITLE_STYLE }); + expect( + localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY) + ).toBeNull(); + expect(readStoredSubtitleStyle()).toEqual(DEFAULT_SUBTITLE_STYLE); + }); + + it('falls back to the default for corrupted stored values', () => { + localStorage.setItem(SUBTITLE_STYLE_STORAGE_KEY, '{not json'); + expect(readStoredSubtitleStyle()).toEqual(DEFAULT_SUBTITLE_STYLE); + }); + }); + + describe('isDefaultSubtitleStyle', () => { + it('detects the default and non-default styles', () => { + expect(isDefaultSubtitleStyle({ ...DEFAULT_SUBTITLE_STYLE })).toBe( + true + ); + expect( + isDefaultSubtitleStyle({ sizePercent: 100, color: '#ffffff' }) + ).toBe(false); + expect( + isDefaultSubtitleStyle({ sizePercent: 125, color: null }) + ).toBe(false); + }); + }); + + describe('subtitleDelayLabel', () => { + it('formats zero, positive, and negative delays', () => { + expect(subtitleDelayLabel(0)).toBe('0 s'); + 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 new file mode 100644 index 000000000..4501c3918 --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/subtitle-style.ts @@ -0,0 +1,98 @@ +import { + DEFAULT_SUBTITLE_STYLE, + type PlayerSubtitleStyle, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, +} from '@iptvnator/shared/interfaces'; +import type { PlayerPreset } from './player-controls.model'; + +/** + * 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 { + DEFAULT_SUBTITLE_STYLE, + SUBTITLE_DELAY_LIMIT_SECONDS, + SUBTITLE_SIZE_MAX_PERCENT, + SUBTITLE_SIZE_MIN_PERCENT, + clampSubtitleDelay, + isDefaultSubtitleStyle, + normalizeSubtitleStyle, +} from '@iptvnator/shared/interfaces'; + +export const SUBTITLE_STYLE_STORAGE_KEY = 'subtitleStyle'; + +export const SUBTITLE_SIZE_PRESETS: ReadonlyArray> = [ + { value: 75, label: '75%' }, + { value: 100, label: '100%' }, + { value: 125, label: '125%' }, + { value: 150, label: '150%' }, + { value: 200, label: '200%' }, +]; + +/** Swatch values; null = engine default. Labels are translation keys. */ +export const SUBTITLE_COLOR_PRESETS: ReadonlyArray> = + [ + { value: null, label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_DEFAULT' }, + { value: '#ffffff', label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_WHITE' }, + { + value: '#ffe94f', + label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_YELLOW', + }, + { value: '#7fdbff', label: 'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR_CYAN' }, + ]; + +export const SUBTITLE_DELAY_STEP_SECONDS = 0.5; + +export function readStoredSubtitleStyle(): PlayerSubtitleStyle { + try { + const raw = localStorage.getItem(SUBTITLE_STYLE_STORAGE_KEY); + if (!raw) { + return { ...DEFAULT_SUBTITLE_STYLE }; + } + return normalizeSubtitleStyle(JSON.parse(raw)); + } catch { + return { ...DEFAULT_SUBTITLE_STYLE }; + } +} + +export function persistSubtitleStyle(style: PlayerSubtitleStyle): void { + try { + const normalized = normalizeSubtitleStyle(style); + if (isDefaultSubtitleStyle(normalized)) { + localStorage.removeItem(SUBTITLE_STYLE_STORAGE_KEY); + return; + } + localStorage.setItem( + SUBTITLE_STYLE_STORAGE_KEY, + JSON.stringify(normalized) + ); + } catch { + // Storage may be unavailable (private mode); the style stays session-local. + } +} + +/** "+0.5 s" / "−1.5 s" / "0 s" display label for the delay row. */ +export function subtitleDelayLabel(seconds: number): string { + 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/player-controls/web-video-controls.adapter.subtitle-settings.spec.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.subtitle-settings.spec.ts new file mode 100644 index 000000000..a983ea25b --- /dev/null +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.subtitle-settings.spec.ts @@ -0,0 +1,107 @@ +import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style'; +import { WebVideoControlsAdapter } from './web-video-controls.adapter'; + +function createVideo(): HTMLVideoElement { + const video = document.createElement('video'); + Object.defineProperties(video, { + duration: { configurable: true, value: 120 }, + readyState: { configurable: true, value: 4 }, + networkState: { configurable: true, value: 1 }, + paused: { configurable: true, value: true }, + seekable: { configurable: true, value: { length: 1 } }, + }); + return video; +} + +describe('WebVideoControlsAdapter subtitle settings', () => { + let adapter: WebVideoControlsAdapter; + let video: HTMLVideoElement; + + beforeEach(() => { + adapter = new WebVideoControlsAdapter(); + video = createVideo(); + }); + + afterEach(() => { + adapter.detach(); + }); + + it('advertises no subtitle-settings capability without the options', () => { + adapter.attach(video, {}); + const capabilities = adapter.capabilities(); + expect(capabilities.externalSubtitles).toBe(false); + expect(capabilities.subtitleDelay).toBe(false); + expect(capabilities.subtitleStyle).toBe(false); + expect(adapter.state().subtitleDelaySeconds).toBe(0); + expect(adapter.state().subtitleStyle).toEqual(DEFAULT_SUBTITLE_STYLE); + }); + + it('derives the capabilities from the injected options', () => { + const canAdjustSubtitleDelay = jest.fn().mockReturnValue(false); + adapter.attach(video, { + addExternalSubtitleFile: jest.fn(), + setSubtitleDelay: jest.fn(), + canAdjustSubtitleDelay, + setSubtitleStyle: jest.fn(), + }); + + expect(adapter.capabilities().externalSubtitles).toBe(true); + expect(adapter.capabilities().subtitleStyle).toBe(true); + // The runtime gate keeps delay off until a file is loaded. + expect(adapter.capabilities().subtitleDelay).toBe(false); + + canAdjustSubtitleDelay.mockReturnValue(true); + adapter.refresh(); + expect(adapter.capabilities().subtitleDelay).toBe(true); + }); + + it('projects delay and style state from the engine getters', () => { + adapter.attach(video, { + getSubtitleDelay: () => 1.5, + getSubtitleStyle: () => ({ sizePercent: 150, color: '#ffffff' }), + }); + + expect(adapter.state().subtitleDelaySeconds).toBe(1.5); + expect(adapter.state().subtitleStyle).toEqual({ + sizePercent: 150, + color: '#ffffff', + }); + }); + + it('delegates the commands and refreshes after synchronous setters', () => { + const addExternalSubtitleFile = jest.fn(); + const setSubtitleDelay = jest.fn(); + const setSubtitleStyle = jest.fn(); + const getSubtitleDelay = jest.fn().mockReturnValue(0); + adapter.attach(video, { + addExternalSubtitleFile, + getSubtitleDelay, + setSubtitleDelay, + setSubtitleStyle, + }); + + adapter.commands.addExternalSubtitleFile(); + expect(addExternalSubtitleFile).toHaveBeenCalledTimes(1); + + getSubtitleDelay.mockReturnValue(2); + adapter.commands.setSubtitleDelay(2); + expect(setSubtitleDelay).toHaveBeenCalledWith(2); + expect(adapter.state().subtitleDelaySeconds).toBe(2); + + adapter.commands.setSubtitleStyle({ sizePercent: 125, color: null }); + expect(setSubtitleStyle).toHaveBeenCalledWith({ + sizePercent: 125, + color: null, + }); + }); + + it('contains a throwing picker without breaking the command surface', () => { + adapter.attach(video, { + addExternalSubtitleFile: () => { + throw new Error('no dialog'); + }, + }); + + expect(() => adapter.commands.addExternalSubtitleFile()).not.toThrow(); + }); +}); diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts index b93c73918..14bd3a92e 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.adapter.ts @@ -10,8 +10,10 @@ import type { PlayerControlsCapabilities, PlayerControlsCommands, PlayerControlsState, + PlayerSubtitleStyle, PlayerTrack, } from './player-controls.model'; +import { DEFAULT_SUBTITLE_STYLE } from './subtitle-style'; import type { SeriesPlaybackNavigation } from '../portal-inline-player/series-playback-navigation'; import { applyTrackSelection, @@ -40,6 +42,14 @@ export interface WebVideoControlsOptions extends WebVideoMetadataOptions { setAudioTrack?: (id: number) => void | Promise; getSubtitleTracks?: () => PlayerTrack[]; setSubtitleTrack?: (id: number) => void | Promise; + /** Opens the engine's subtitle file picker; loads + selects the pick. */ + addExternalSubtitleFile?: () => void; + getSubtitleDelay?: () => number; + setSubtitleDelay?: (seconds: number) => void | Promise; + /** Runtime gate for the delay UI (e.g. only once a file is loaded). */ + canAdjustSubtitleDelay?: () => boolean; + getSubtitleStyle?: () => PlayerSubtitleStyle; + setSubtitleStyle?: (style: PlayerSubtitleStyle) => void | Promise; getQualityLevels?: () => PlayerTrack[]; setQualityLevel?: (id: number) => void | Promise; isAutoQualityEnabled?: () => boolean; @@ -107,6 +117,12 @@ export class WebVideoControlsAdapter implements PlayerController { fullscreen: true, audioTracks: hasAudioTracks, subtitles: hasSubtitles, + externalSubtitles: + typeof this.opts.addExternalSubtitleFile === 'function', + subtitleDelay: + typeof this.opts.setSubtitleDelay === 'function' && + (this.opts.canAdjustSubtitleDelay?.() ?? true), + subtitleStyle: typeof this.opts.setSubtitleStyle === 'function', qualityLevels: hasQualityLevels, aspectRatio: false, recording: false, @@ -147,6 +163,9 @@ export class WebVideoControlsAdapter implements PlayerController { audioTracks, subtitleTracks, subtitlesEnabled: subtitleTracks.some((track) => track.selected), + subtitleDelaySeconds: this.opts.getSubtitleDelay?.() ?? 0, + subtitleStyle: + this.opts.getSubtitleStyle?.() ?? DEFAULT_SUBTITLE_STYLE, qualityLevels, qualityAutoEnabled: this.opts.isAutoQualityEnabled?.() ?? true, playbackSpeed: video?.playbackRate ?? 1, @@ -183,6 +202,23 @@ export class WebVideoControlsAdapter implements PlayerController { applyTrackSelection(this.opts.setSubtitleTrack, id, () => this.refresh() ), + addExternalSubtitleFile: () => { + try { + // The picker refreshes through its own callback once the + // asynchronous file read lands. + this.opts.addExternalSubtitleFile?.(); + } catch { + // A failed picker leaves the current tracks untouched. + } + }, + setSubtitleDelay: (seconds) => + applyTrackSelection(this.opts.setSubtitleDelay, seconds, () => + this.refresh() + ), + setSubtitleStyle: (style) => + applyTrackSelection(this.opts.setSubtitleStyle, style, () => + this.refresh() + ), setQualityLevel: (id) => applyTrackSelection(this.opts.setQualityLevel, id, () => this.refresh() diff --git a/libs/ui/playback/src/lib/player-controls/web-video-controls.media-helpers.ts b/libs/ui/playback/src/lib/player-controls/web-video-controls.media-helpers.ts index 313578cdf..3a09dc5f2 100644 --- a/libs/ui/playback/src/lib/player-controls/web-video-controls.media-helpers.ts +++ b/libs/ui/playback/src/lib/player-controls/web-video-controls.media-helpers.ts @@ -96,9 +96,9 @@ export function applyVideoSpeed( } } -export function applyTrackSelection( - setter: ((id: number) => void | Promise) | undefined, - id: number, +export function applyTrackSelection( + setter: ((value: T) => void | Promise) | undefined, + id: T, refresh: () => void ): void { if (!setter) { 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 new file mode 100644 index 000000000..7f2505a39 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.spec.ts @@ -0,0 +1,234 @@ +import { + decodeExternalSubtitleBytes, + detectExternalSubtitleFormat, + parseExternalSubtitleCues, +} from './external-subtitle-cues.util'; + +describe('external-subtitle-cues.util', () => { + describe('detectExternalSubtitleFormat', () => { + it('detects srt and vtt by extension, case-insensitively', () => { + expect(detectExternalSubtitleFormat('movie.srt')).toBe('srt'); + expect(detectExternalSubtitleFormat('Movie.SRT')).toBe('srt'); + expect(detectExternalSubtitleFormat('movie.en.vtt')).toBe('vtt'); + }); + + it('rejects unsupported extensions', () => { + expect(detectExternalSubtitleFormat('movie.ass')).toBeNull(); + expect(detectExternalSubtitleFormat('movie.sub')).toBeNull(); + expect(detectExternalSubtitleFormat('movie')).toBeNull(); + }); + }); + + 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('detects CP1251 in a short-dialogue SRT where ASCII timing bytes dominate', () => { + // "Да" / "Нет" in CP1251 among full SRT timing scaffolding: only + // 5 of ~70 bytes are high, but ALL letter bytes are — the + // discriminator must ignore the timing lines. + const ascii = (text: string) => + Array.from(text).map((c) => c.charCodeAt(0)); + const srt = [ + ...ascii('1\n00:00:01,000 --> 00:00:02,000\n'), + 0xc4, 0xe0, // Да + ...ascii('\n\n2\n00:00:03,000 --> 00:00:04,000\n'), + 0xcd, 0xe5, 0xf2, // Нет + ...ascii('\n'), + ]; + expect(decodeExternalSubtitleBytes(toBuffer(srt))).toContain('Да'); + expect(decodeExternalSubtitleBytes(toBuffer(srt))).toContain( + 'Нет' + ); + }); + + it('keeps accent-dense CP1252 words Latin ("Été" must not become Cyrillic)', () => { + // É=0xC9, t, é=0xE9 — more accented than ASCII letters, so a + // byte-ratio heuristic flips to CP1251 and renders "Йtй". The + // mixed-script plausibility check must keep this Windows-1252. + const cp1252 = [ + 0xc9, 0x74, 0xe9, // Été + ...Array.from(' 1\n00:00:01,000 --> 00:00:02,000\n').map((c) => + c.charCodeAt(0) + ), + 0xc0, // À + ...Array.from(' table !').map((c) => c.charCodeAt(0)), + ]; + const decoded = decodeExternalSubtitleBytes(toBuffer(cp1252)); + expect(decoded).toContain('Été'); + expect(decoded).toContain('À table'); + }); + + it('keeps a minimal isolated-accent CP1252 caption Latin ("À la")', () => { + // One lone Cyrillic-looking letter and two ASCII letters passed + // the earlier share guard; single-letter words must carry no + // script evidence at all. + const cp1252 = [ + 0xc0, // À + ...Array.from( + ' la\n1\n00:00:01,000 --> 00:00:02,000\n' + ).map((c) => c.charCodeAt(0)), + ]; + expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toContain( + 'À la' + ); + }); + + it('keeps an isolated accented CP1252 word Latin ("À table")', () => { + // À=0xC0 decodes under CP1251 to the pure-Cyrillic one-letter + // word "А"; without the letter-share guard that single vote + // flips the whole file to Cyrillic. + const cp1252 = [ + 0xc0, // À + ...Array.from( + ' table !\n1\n00:00:01,000 --> 00:00:02,000\n' + ).map((c) => c.charCodeAt(0)), + ]; + expect(decodeExternalSubtitleBytes(toBuffer(cp1252))).toContain( + 'À table' + ); + }); + + 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 = [ + '1', + '00:00:01,000 --> 00:00:03,500', + 'First line', + 'second row', + '', + '2', + '00:01:00,250 --> 00:01:02,000', + 'Later', + '', + ].join('\r\n'); + + expect( + parseExternalSubtitleCues({ format: 'srt', content }) + ).toEqual([ + { + startSeconds: 1, + endSeconds: 3.5, + text: 'First line\nsecond row', + }, + { startSeconds: 60.25, endSeconds: 62, text: 'Later' }, + ]); + }); + + it('parses a WebVTT file with header, notes, and settings', () => { + const content = [ + 'WEBVTT', + '', + 'NOTE this is a comment', + '', + 'intro', + '00:05.000 --> 00:07.000 align:start line:90%', + 'Short-form timestamps work', + '', + '01:00:00.000 --> 01:00:04.000', + 'Styled text survives', + ].join('\n'); + + expect( + parseExternalSubtitleCues({ format: 'vtt', content }) + ).toEqual([ + { + startSeconds: 5, + endSeconds: 7, + text: 'Short-form timestamps work', + }, + { + startSeconds: 3600, + endSeconds: 3604, + text: 'Styled text survives', + }, + ]); + }); + + it('skips malformed blocks instead of failing the file', () => { + const content = [ + '1', + '00:00:05,000 --> 00:00:01,000', + 'End before start is dropped', + '', + '2', + '00:00:10,000 --> 00:00:12,000', + 'Valid', + '', + 'not a timing line at all', + 'trailing junk', + ].join('\n'); + + expect( + parseExternalSubtitleCues({ format: 'srt', content }) + ).toEqual([ + { startSeconds: 10, endSeconds: 12, text: 'Valid' }, + ]); + }); + + it('sorts cues by start time and drops empty-text cues', () => { + const content = [ + '00:00:30.000 --> 00:00:31.000', + 'Second', + '', + '00:00:02.000 --> 00:00:03.000', + 'First', + '', + '00:00:40.000 --> 00:00:41.000', + '<00:00:40.500>', + '', + ].join('\n'); + + expect( + parseExternalSubtitleCues({ format: 'vtt', content }) + ).toEqual([ + { startSeconds: 2, endSeconds: 3, text: 'First' }, + { startSeconds: 30, endSeconds: 31, text: 'Second' }, + ]); + }); + }); +}); 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 new file mode 100644 index 000000000..137710fd3 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/external-subtitle-cues.util.ts @@ -0,0 +1,192 @@ +/** + * Minimal SRT/WebVTT cue extraction for user-supplied subtitle files. + * + * The web engines render external subtitles through native `TextTrack`s, so + * only start/end/text are needed — positioning and styling blocks are + * intentionally dropped. ASS/SSA is NOT handled here: rendering it faithfully + * needs libass, so the web engines simply do not accept those files (the + * Embedded MPV engine plays them natively instead). + */ + +export interface ParsedSubtitleCue { + startSeconds: number; + endSeconds: number; + text: string; +} + +export type ExternalSubtitleFormat = 'srt' | 'vtt'; + +export interface ExternalSubtitleFile { + /** Display name, usually the picked file's name. */ + name: string; + format: ExternalSubtitleFormat; + content: string; +} + +/** Extensions the web engines accept in their file picker. */ +export const WEB_SUBTITLE_FILE_EXTENSIONS = ['.srt', '.vtt'] as const; + +export function detectExternalSubtitleFormat( + fileName: string +): ExternalSubtitleFormat | null { + const normalized = fileName.trim().toLowerCase(); + if (normalized.endsWith('.srt')) { + return 'srt'; + } + if (normalized.endsWith('.vtt')) { + return 'vtt'; + } + 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 the + * plausibility of the decoded text (see {@link chooseLegacySingleByteDecode}). + */ +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. + } + + try { + return chooseLegacySingleByteDecode(buffer); + } catch { + // Runtime without legacy decoders: non-fatal UTF-8 is the last resort. + return new TextDecoder().decode(buffer); + } +} + +/** + * Picks between the two dominant legacy encodings by the PLAUSIBILITY of the + * Windows-1251 candidate, not a byte ratio (a ratio drowns short dialogue in + * ASCII timing bytes one way, and over-weights accent-dense Latin words like + * "Été" the other way). CP1251 maps every high letter byte into the Cyrillic + * block, so a genuinely Cyrillic file decodes into pure-Cyrillic words, while + * misread Latin text decodes into words that MIX Cyrillic and ASCII letters + * ("était" → "йtait") — a shape real subtitles never contain. + */ +function chooseLegacySingleByteDecode(buffer: ArrayBuffer): string { + const decoded1251 = new TextDecoder('windows-1251').decode(buffer); + let cyrillicLetters = 0; + let mixedLetters = 0; + let asciiLetters = 0; + for (const word of decoded1251.split(/[^\p{L}]+/u)) { + if (!word) { + continue; + } + if (!/[\u0400-\u04ff]/.test(word)) { + asciiLetters += word.length; + } else if (/[A-Za-z]/.test(word)) { + mixedLetters += word.length; + } else if (word.length >= 2) { + // A single letter carries no trustworthy script information: an + // isolated CP1252 accent ("\u00c0"/"\u00c9") decodes to a lone Cyrillic + // letter and must not count as Cyrillic evidence, while genuine + // Cyrillic dialogue always contains multi-letter words. + cyrillicLetters += word.length; + } + } + // Two guards on top of that: mixed-script words are strong evidence of + // misread Latin text ("\u00e9tait" \u2192 "\u0439tait"), and Cyrillic must carry a + // meaningful share of all letters before 1251 wins \u2014 genuinely Cyrillic + // dialogue dominates its own letter count even with embedded Latin names. + // A file whose only high bytes are one or two isolated letters is + // fundamentally ambiguous; defaulting it to 1252 is the best effort. + const plausiblyCyrillic = + cyrillicLetters > mixedLetters * 2 && + cyrillicLetters * 4 > asciiLetters; + return plausiblyCyrillic + ? decoded1251 + : new TextDecoder('windows-1252').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 = + /(?:(\d{1,3}):)?(\d{1,2}):(\d{1,2})[.,](\d{1,3})/; +const TIMING_LINE_PATTERN = new RegExp( + `^\\s*(${TIMESTAMP_PATTERN.source})\\s*-->\\s*(${TIMESTAMP_PATTERN.source})` +); + +function parseTimestamp(value: string): number | null { + const match = TIMESTAMP_PATTERN.exec(value.trim()); + if (!match || match[0] !== value.trim()) { + return null; + } + const hours = match[1] !== undefined ? Number(match[1]) : 0; + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const millis = Number(match[4].padEnd(3, '0')); + return hours * 3600 + minutes * 60 + seconds + millis / 1000; +} + +function stripVttMarkup(text: string): string { + // Remove inline cue timestamps ("karaoke" tags) but keep the text; the + // browser renders remaining /-style tags itself inside VTTCue. + return text.replace(/<\d{1,3}:\d{2}:\d{2}[.,]\d{1,3}>/g, ''); +} + +/** + * Parses SRT or VTT content into cues, skipping malformed blocks instead of + * failing the whole file. Returns cues sorted by start time. + */ +export function parseExternalSubtitleCues( + file: Pick +): ParsedSubtitleCue[] { + const lines = file.content.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/); + const cues: ParsedSubtitleCue[] = []; + + let index = 0; + while (index < lines.length) { + const line = lines[index]; + const timing = TIMING_LINE_PATTERN.exec(line); + if (!timing) { + index += 1; + continue; + } + + const startSeconds = parseTimestamp(timing[1]); + const endSeconds = parseTimestamp(timing[6]); + index += 1; + + const textLines: string[] = []; + while (index < lines.length && lines[index].trim() !== '') { + textLines.push(lines[index]); + index += 1; + } + + if ( + startSeconds === null || + endSeconds === null || + endSeconds <= startSeconds || + textLines.length === 0 + ) { + continue; + } + + cues.push({ + startSeconds, + endSeconds, + text: stripVttMarkup(textLines.join('\n')).trim(), + }); + } + + return cues + .filter((cue) => cue.text.length > 0) + .sort((a, b) => a.startSeconds - b.startSeconds); +} 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 new file mode 100644 index 000000000..0de79076c --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.spec.ts @@ -0,0 +1,228 @@ +import { + EXTERNAL_SUBTITLE_TRACK_ID_BASE, + WebVideoExternalSubtitles, +} from './web-video-external-subtitles'; +import type { ExternalSubtitleFile } from './external-subtitle-cues.util'; + +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: ExternalSubtitleFile = { + name: 'movie.srt', + format: 'srt', + content: [ + '1', + '00:00:01,000 --> 00:00:03,000', + 'Hello', + '', + '2', + '00:00:10,000 --> 00:00:12,000', + 'World', + '', + ].join('\n'), +}; + +describe('WebVideoExternalSubtitles', () => { + let video: { addTextTrack: jest.Mock }; + let deselectEngineSubtitles: jest.Mock; + let refresh: jest.Mock; + let session: WebVideoExternalSubtitles; + + beforeEach(() => { + (globalThis as { VTTCue?: unknown }).VTTCue = FakeVTTCue; + video = { + addTextTrack: jest.fn( + (_kind: string, label: string) => new FakeTextTrack(label) + ), + }; + deselectEngineSubtitles = jest.fn(); + refresh = jest.fn(); + session = new WebVideoExternalSubtitles({ + getVideo: () => video as unknown as HTMLVideoElement, + deselectEngineSubtitles, + refresh, + }); + }); + + afterEach(() => { + delete (globalThis as { VTTCue?: unknown }).VTTCue; + }); + + function lastTrack(): FakeTextTrack { + return video.addTextTrack.mock.results.at(-1)?.value as FakeTextTrack; + } + + it('creates a native track, adds cues, and selects the file', () => { + expect(session.addFromFile(SRT_FILE)).toBe(true); + + const track = lastTrack(); + expect(video.addTextTrack).toHaveBeenCalledWith( + 'subtitles', + 'movie.srt' + ); + expect(track.mode).toBe('showing'); + expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([ + [1, 3], + [10, 12], + ]); + expect(deselectEngineSubtitles).toHaveBeenCalled(); + expect(session.getTracks()).toEqual([ + { + id: EXTERNAL_SUBTITLE_TRACK_ID_BASE, + label: 'movie.srt', + selected: true, + }, + ]); + expect(session.ownsTrack(track as unknown as TextTrack)).toBe(true); + }); + + it('rejects files that yield no usable cue', () => { + expect( + session.addFromFile({ + name: 'broken.srt', + format: 'srt', + content: 'no cues here', + }) + ).toBe(false); + expect(session.hasTracks()).toBe(false); + }); + + it('re-times owned cues when the delay changes and keeps originals exact', () => { + session.addFromFile(SRT_FILE); + const track = lastTrack(); + + session.setDelay(2.5); + expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([ + [3.5, 5.5], + [12.5, 14.5], + ]); + + // 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(-1); + expect(track.added[0].endTime).toBe(1); + + session.setDelay(0); + expect(track.added.map((cue) => [cue.startTime, cue.endTime])).toEqual([ + [1, 3], + [10, 12], + ]); + }); + + it('keeps exactly one external track showing and deselects on demand', () => { + session.addFromFile(SRT_FILE); + const first = lastTrack(); + session.addFromFile({ ...SRT_FILE, name: 'other.srt' }); + const second = lastTrack(); + + expect(first.mode).toBe('hidden'); + expect(second.mode).toBe('showing'); + + session.select(EXTERNAL_SUBTITLE_TRACK_ID_BASE); + expect(first.mode).toBe('showing'); + expect(second.mode).toBe('hidden'); + + session.deselectAll(); + expect(first.mode).toBe('hidden'); + expect(second.mode).toBe('hidden'); + }); + + it('clear() disables tracks, removes cues, and resets the delay', () => { + session.addFromFile(SRT_FILE); + const track = lastTrack(); + session.setDelay(1); + + session.clear(); + + expect(track.mode).toBe('disabled'); + 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', () => { + delete (globalThis as { VTTCue?: unknown }).VTTCue; + expect(session.addFromFile(SRT_FILE)).toBe(false); + expect(session.hasTracks()).toBe(false); + }); +}); 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 new file mode 100644 index 000000000..d5d6af446 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-external-subtitles.ts @@ -0,0 +1,267 @@ +import type { PlayerTrack } from '../player-controls/player-controls.model'; +import { clampSubtitleDelay } from '../player-controls/subtitle-style'; +import { + type ExternalSubtitleFile, + type ParsedSubtitleCue, + WEB_SUBTITLE_FILE_EXTENSIONS, + decodeExternalSubtitleBytes, + detectExternalSubtitleFormat, + parseExternalSubtitleCues, +} from './external-subtitle-cues.util'; + +/** + * External track IDs live far above every engine ID space (hls.js/Shaka list + * indices, native enumeration counters), so merged listings cannot collide. + */ +export const EXTERNAL_SUBTITLE_TRACK_ID_BASE = 100000; + +interface ExternalSubtitleEntry { + id: number; + label: string; + cues: ParsedSubtitleCue[]; + track: TextTrack | null; + /** Same order as `cues`; kept so delay changes can re-time exactly. */ + trackCues: VTTCue[]; +} + +export interface WebVideoExternalSubtitlesConfig { + getVideo: () => HTMLVideoElement | null; + /** Turns the engine-owned subtitle selection off when an external track is chosen. */ + deselectEngineSubtitles: () => void; + refresh: () => void; +} + +/** + * Owns user-loaded subtitle files for a web video engine. Each file becomes a + * native `TextTrack` on the video element, so the browser renders the cues + * (and `::cue` styling applies) regardless of which source engine is active. + * + * The subtitle delay applies to these tracks only: their cues are fully owned + * here, so re-timing is exact. Engine/stream tracks arrive incrementally and + * are left untouched. + */ +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) {} + + hasTracks(): boolean { + 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.createdTracks.has(track); + } + + ownsTrackId(id: number): boolean { + return this.entries.some((entry) => entry.id === id); + } + + /** Parses and attaches the file; returns false when no cue was usable. */ + addFromFile(file: ExternalSubtitleFile): boolean { + const cues = parseExternalSubtitleCues(file); + if (cues.length === 0) { + return false; + } + + const entry: ExternalSubtitleEntry = { + id: this.nextId, + label: file.name, + cues, + track: null, + trackCues: [], + }; + if (!this.attachEntry(entry)) { + return false; + } + + this.nextId += 1; + this.entries.push(entry); + this.select(entry.id); + return true; + } + + getTracks(): PlayerTrack[] { + return this.entries.map((entry) => ({ + id: entry.id, + label: entry.label, + selected: entry.track?.mode === 'showing', + })); + } + + select(id: number): void { + 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.refresh(); + } + + deselectAll(): void { + for (const entry of this.entries) { + if (entry.track) { + entry.track.mode = 'hidden'; + } + } + } + + getDelay(): number { + return this.delaySeconds; + } + + setDelay(seconds: number): void { + this.delaySeconds = clampSubtitleDelay(seconds); + for (const entry of this.entries) { + this.applyDelayToEntry(entry); + } + this.config.refresh(); + } + + /** Per-source teardown: external files correct one specific stream. */ + clear(): void { + for (const entry of this.entries) { + this.detachEntry(entry); + } + this.entries = []; + this.delaySeconds = 0; + } + + private attachEntry(entry: ExternalSubtitleEntry): boolean { + const video = this.config.getVideo(); + const CueCtor = ( + globalThis as { VTTCue?: new ( + start: number, + end: number, + text: string + ) => VTTCue } + ).VTTCue; + if ( + !video || + typeof video.addTextTrack !== 'function' || + typeof CueCtor !== 'function' + ) { + return false; + } + + try { + const track = video.addTextTrack('subtitles', entry.label); + this.createdTracks.add(track); + entry.track = track; + // 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, + shifted.endSeconds, + cue.text + ); + track.addCue(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; + } + } + + private detachEntry(entry: ExternalSubtitleEntry): void { + const track = entry.track; + entry.track = null; + if (!track) { + return; + } + try { + for (const cue of entry.trackCues) { + track.removeCue(cue); + } + } catch { + // Removing cues is best-effort; disabling the track hides them. + } + entry.trackCues = []; + track.mode = 'disabled'; + } + + private applyDelayToEntry(entry: ExternalSubtitleEntry): void { + for (let index = 0; index < entry.trackCues.length; index += 1) { + const shifted = this.shiftCueTimes(entry.cues[index]); + entry.trackCues[index].startTime = shifted.startSeconds; + entry.trackCues[index].endTime = shifted.endSeconds; + } + } + + private shiftCueTimes(cue: ParsedSubtitleCue): { + startSeconds: number; + endSeconds: number; + } { + // 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, + }; + } +} + +/** + * Opens a one-shot subtitle file picker. Runs entirely in the renderer (works + * in the PWA and Electron alike) and hands back the file's name and content — + * no filesystem path ever enters the app, so nothing sensitive can be logged. + */ +export function pickExternalSubtitleFile( + doc: Document, + onPicked: (file: ExternalSubtitleFile) => void +): void { + const input = doc.createElement('input'); + input.type = 'file'; + input.accept = WEB_SUBTITLE_FILE_EXTENSIONS.join(','); + input.addEventListener('change', () => { + const file = input.files?.[0]; + if (!file) { + return; + } + const format = detectExternalSubtitleFormat(file.name); + if (!format) { + return; + } + // 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 + ); + }); + input.click(); +} diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts index 2da4d985c..3225f819d 100644 --- a/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts +++ b/libs/ui/playback/src/lib/web-video-support/web-video-native-text-tracks.ts @@ -19,6 +19,12 @@ export interface WebVideoNativeTextTracksConfig { * menu keeps working. Shared controls omit it. */ playbackStarted?: () => boolean; + /** + * Tracks another owner manages (user-loaded external subtitle files) are + * excluded from this enumeration so they are neither double-listed nor + * touched by the caption-preference suppression. + */ + excludeTrack?: (track: TextTrack) => boolean; } export class WebVideoNativeTextTracks { @@ -115,7 +121,8 @@ export class WebVideoNativeTextTracks { const track = tracks[index]; if ( !track || - (track.kind !== 'captions' && track.kind !== 'subtitles') + (track.kind !== 'captions' && track.kind !== 'subtitles') || + this.config.excludeTrack?.(track) === true ) { continue; } 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 14b4ad7b9..7e35c888b 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 @@ -1,5 +1,7 @@ import type { PlayerTrack } from '../player-controls/player-controls.model'; import type { WebVideoControlsAdapter } from '../player-controls/web-video-controls.adapter'; +import { pickExternalSubtitleFile } from './web-video-external-subtitles'; +import { WebVideoSubtitleStyle } from './web-video-subtitle-style'; import { type WebVideoControlsSource, WebVideoSourceTracks, @@ -17,6 +19,7 @@ export interface WebVideoSourceControlsBridgeConfig { export class WebVideoSourceControlsBridge { private readonly config: WebVideoSourceControlsBridgeConfig; private readonly tracks: WebVideoSourceTracks; + private readonly subtitleStyle = new WebVideoSubtitleStyle(); private attached = false; private destroyed = false; @@ -34,6 +37,7 @@ export class WebVideoSourceControlsBridge { return; } + this.subtitleStyle.attach(this.config.video); this.config.adapter.attach(this.config.video, { isLive: this.config.isLive, getDuration: () => this.readDuration(), @@ -41,6 +45,13 @@ export class WebVideoSourceControlsBridge { setAudioTrack: (id) => this.setAudioTrack(id), getSubtitleTracks: () => this.getSubtitleTracks(), setSubtitleTrack: (id) => this.setSubtitleTrack(id), + addExternalSubtitleFile: () => this.pickExternalSubtitle(), + getSubtitleDelay: () => this.tracks.getExternalSubtitleDelay(), + setSubtitleDelay: (seconds) => + this.tracks.setExternalSubtitleDelay(seconds), + canAdjustSubtitleDelay: () => this.tracks.canAdjustSubtitleDelay(), + getSubtitleStyle: () => this.subtitleStyle.current(), + setSubtitleStyle: (style) => this.subtitleStyle.set(style), getQualityLevels: () => this.tracks.getQualityLevels(), setQualityLevel: (id) => this.tracks.setQualityLevel(id), isAutoQualityEnabled: () => this.tracks.isAutoQualityEnabled(), @@ -82,6 +93,7 @@ export class WebVideoSourceControlsBridge { this.clearSource(); this.tracks.destroy(); + this.subtitleStyle.destroy(); if (this.attached) { this.config.adapter.detach(); this.attached = false; @@ -89,6 +101,23 @@ export class WebVideoSourceControlsBridge { this.destroyed = true; } + 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 || + this.tracks.getSourceGeneration() !== generation + ) { + return; + } + this.tracks.addExternalSubtitleFile(file); + this.config.adapter.refresh(); + }); + } + readDuration(): number { if (this.tracks.sourceKind !== 'mpegts' || this.config.isLive()) { return NaN; 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 cc0c13258..36d2f8c9f 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 @@ -1,6 +1,8 @@ import type Hls from 'hls.js'; import type { PlayerTrack } from '../player-controls/player-controls.model'; import type { ShakaVideoSession } from '../shaka-engine/shaka-video-session'; +import type { ExternalSubtitleFile } from './external-subtitle-cues.util'; +import { WebVideoExternalSubtitles } from './web-video-external-subtitles'; import { WebVideoHlsControls } from './web-video-hls-controls'; import { WebVideoNativeTextTracks } from './web-video-native-text-tracks'; import { WebVideoShakaControls } from './web-video-shaka-controls'; @@ -41,7 +43,9 @@ export class WebVideoSourceTracks { private readonly hlsControls: WebVideoHlsControls; private readonly shakaControls: WebVideoShakaControls; 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; @@ -62,11 +66,17 @@ export class WebVideoSourceTracks { refresh, playbackStarted, }); + this.externalSubtitles = new WebVideoExternalSubtitles({ + getVideo: () => this.config.video, + deselectEngineSubtitles: () => this.applyEngineSubtitleTrack(-1), + refresh, + }); this.nativeTextTracks = new WebVideoNativeTextTracks({ video: config.video, showCaptions: config.showCaptions, refresh, playbackStarted, + excludeTrack: (track) => this.externalSubtitles.ownsTrack(track), }); if (config.vendorCaptionControls) { const listener = () => { @@ -81,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. @@ -118,6 +138,7 @@ export class WebVideoSourceTracks { return; } + this.sourceGeneration += 1; this.clearActiveSource(); this.source = null; } @@ -186,6 +207,47 @@ export class WebVideoSourceTracks { } getSubtitleTracks(): PlayerTrack[] { + // The native enumeration excludes external tracks, so appending them + // here is collision-free for every source kind. + return [ + ...this.getEngineSubtitleTracks(), + ...this.externalSubtitles.getTracks(), + ]; + } + + setSubtitleTrack(id: number): void { + if (this.externalSubtitles.ownsTrackId(id)) { + // select() also turns the engine-owned selection off. + this.externalSubtitles.select(id); + return; + } + this.externalSubtitles.deselectAll(); + this.applyEngineSubtitleTrack(id); + } + + /** Loads a user-picked subtitle file and selects it. */ + addExternalSubtitleFile(file: ExternalSubtitleFile): boolean { + return this.externalSubtitles.addFromFile(file); + } + + getExternalSubtitleDelay(): number { + return this.externalSubtitles.getDelay(); + } + + setExternalSubtitleDelay(seconds: number): void { + this.externalSubtitles.setDelay(seconds); + } + + /** + * 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.hasSelectedTrack(); + } + + private getEngineSubtitleTracks(): PlayerTrack[] { if (this.source?.kind === 'hls') { return this.hlsControls.getSubtitleTracks(); } @@ -195,7 +257,7 @@ export class WebVideoSourceTracks { return this.source ? this.nativeTextTracks.getSubtitleTracks() : []; } - setSubtitleTrack(id: number): void { + private applyEngineSubtitleTrack(id: number): void { if (this.source?.kind === 'hls') { this.hlsControls.setSubtitleTrack(id); } else if (this.source?.kind === 'shaka') { @@ -206,6 +268,8 @@ export class WebVideoSourceTracks { } private clearActiveSource(): void { + // External files correct one specific stream; drop them with it. + this.externalSubtitles.clear(); if (this.source?.kind === 'hls') { this.hlsControls.clear(); } else if (this.source?.kind === 'shaka') { diff --git a/libs/ui/playback/src/lib/web-video-support/web-video-subtitle-style.ts b/libs/ui/playback/src/lib/web-video-support/web-video-subtitle-style.ts new file mode 100644 index 000000000..546cecd65 --- /dev/null +++ b/libs/ui/playback/src/lib/web-video-support/web-video-subtitle-style.ts @@ -0,0 +1,83 @@ +import type { PlayerSubtitleStyle } from '../player-controls/player-controls.model'; +import { + isDefaultSubtitleStyle, + normalizeSubtitleStyle, + persistSubtitleStyle, + readStoredSubtitleStyle, +} from '../player-controls/subtitle-style'; + +let nextStyleScopeId = 0; + +/** + * Applies the shared subtitle style (size/color) to a web engine's native cue + * rendering through a scoped `::cue` rule. Native `TextTrack` cues — embedded + * tracks, hls.js-managed tracks, and user-loaded external files — all render + * through the browser's cue display, so one rule covers every source kind. + * + * The style is read from and persisted to the shared localStorage preference, + * so it survives sessions and is shared with the Embedded MPV engine. + */ +export class WebVideoSubtitleStyle { + private style: PlayerSubtitleStyle = readStoredSubtitleStyle(); + private styleElement: HTMLStyleElement | null = null; + private video: HTMLVideoElement | null = null; + private readonly scopeClass = `iptv-subtitle-style-${nextStyleScopeId++}`; + + attach(video: HTMLVideoElement): void { + if (this.video === video) { + return; + } + this.video?.classList.remove(this.scopeClass); + this.video = video; + video.classList.add(this.scopeClass); + this.applyCss(); + } + + current(): PlayerSubtitleStyle { + return this.style; + } + + set(style: PlayerSubtitleStyle): void { + this.style = normalizeSubtitleStyle(style); + persistSubtitleStyle(this.style); + this.applyCss(); + } + + destroy(): void { + this.video?.classList.remove(this.scopeClass); + this.video = null; + this.styleElement?.remove(); + this.styleElement = null; + } + + private applyCss(): void { + const video = this.video; + if (!video) { + return; + } + + if (isDefaultSubtitleStyle(this.style)) { + this.styleElement?.remove(); + this.styleElement = null; + return; + } + + const declarations: string[] = []; + if (this.style.sizePercent !== 100) { + // Percentage resolves against the browser's video-size-derived + // default cue font size, so it scales with the player box. + declarations.push(`font-size: ${this.style.sizePercent}%`); + } + if (this.style.color) { + declarations.push(`color: ${this.style.color}`); + } + + const doc = video.ownerDocument; + if (!this.styleElement || !this.styleElement.isConnected) { + this.styleElement?.remove(); + this.styleElement = doc.createElement('style'); + doc.head.appendChild(this.styleElement); + } + this.styleElement.textContent = `.${this.scopeClass}::cue { ${declarations.join('; ')}; }`; + } +}