mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(playback): advanced subtitle support in shared player controls (#1471)
This commit is contained in:
1 parent
d6a9c23148
commit
069b8b3cc9
75 files changed
+3337
-49
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 coLine truncated
|
||||
- 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 desLine truncated
|
||||
- Shared web picture-in-picture stays inside that default-off rollout.
|
||||
`PlayerController` exposes capability `pictureInPicture`, state
|
||||
`pictureInPictureActive`/`canPictureInPicture`, and command
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_ADD_SUBTITLE', sessionId, filePath),
|
||||
setEmbeddedMpvSubtitleDelay: (
|
||||
sessionId: string,
|
||||
seconds: number
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke(
|
||||
'EMBEDDED_MPV_SET_SUBTITLE_DELAY',
|
||||
sessionId,
|
||||
seconds
|
||||
),
|
||||
setEmbeddedMpvSubtitleStyle: (
|
||||
sessionId: string,
|
||||
style: EmbeddedMpvSubtitleStyle
|
||||
): Promise<EmbeddedMpvSession | null> =>
|
||||
ipcRenderer.invoke(
|
||||
'EMBEDDED_MPV_SET_SUBTITLE_STYLE',
|
||||
sessionId,
|
||||
style
|
||||
),
|
||||
selectEmbeddedMpvSubtitleFile: (): Promise<string | null> =>
|
||||
ipcRenderer.invoke('EMBEDDED_MPV_SELECT_SUBTITLE_FILE'),
|
||||
setEmbeddedMpvSpeed: (
|
||||
sessionId: string,
|
||||
speed: number
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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<string | null> {
|
||||
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();
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<EmbeddedMpvSession | null>;
|
||||
/** Loads an external subtitle file (absolute path) via mpv `sub-add`. */
|
||||
addEmbeddedMpvSubtitle?: (
|
||||
sessionId: string,
|
||||
filePath: string
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvSubtitleDelay?: (
|
||||
sessionId: string,
|
||||
seconds: number
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
setEmbeddedMpvSubtitleStyle?: (
|
||||
sessionId: string,
|
||||
style: EmbeddedMpvSubtitleStyle
|
||||
) => Promise<EmbeddedMpvSession | null>;
|
||||
/** Opens the main-process subtitle file dialog; null when cancelled. */
|
||||
selectEmbeddedMpvSubtitleFile?: () => Promise<string | null>;
|
||||
setEmbeddedMpvSpeed?: (
|
||||
sessionId: string,
|
||||
speed: number
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PlayerSubtitleStyle>;
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -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<string>((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();
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const id = this.ctx.sessionId();
|
||||
const electron = this.bridge();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ResolvedPortalPlayback>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> =>
|
||||
this.commands.setSubtitleTrack(trackId);
|
||||
readonly addExternalSubtitle = (): Promise<boolean> =>
|
||||
this.commands.addExternalSubtitle();
|
||||
readonly setSubtitleDelay = (seconds: number): Promise<void> =>
|
||||
this.commands.setSubtitleDelay(seconds);
|
||||
readonly setSubtitleStyle = (
|
||||
style: EmbeddedMpvSubtitleStyle
|
||||
): Promise<void> => this.commands.setSubtitleStyle(style);
|
||||
readonly setSpeed = (speed: number): Promise<void> =>
|
||||
this.commands.setSpeed(speed);
|
||||
readonly setAspect = (aspect: string): Promise<void> =>
|
||||
|
||||
@@ -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<typeof createControllerMock>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerSubtitleStyle>(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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -359,27 +359,39 @@
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES' | translate
|
||||
}}
|
||||
</div>
|
||||
<!-- Single scroll owner for the track list AND the
|
||||
delay/style sections: on short players the
|
||||
popover height cap must scroll everything, not
|
||||
clip the sections outside the list. -->
|
||||
<div class="player-controls__subtitle-body">
|
||||
<div class="player-controls__track-list">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
(click)="menuSelection.subtitleTrack(-1)"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (!state().subtitlesEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
@if (
|
||||
capabilities().subtitles &&
|
||||
state().subtitleTracks.length > 0
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
[attr.aria-checked]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
class="player-controls__track"
|
||||
[class.player-controls__track--selected]="
|
||||
!state().subtitlesEnabled
|
||||
"
|
||||
(click)="
|
||||
menuSelection.subtitleTrack(-1)
|
||||
"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLES_OFF'
|
||||
| translate
|
||||
}}</span>
|
||||
@if (!state().subtitlesEnabled) {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@for (
|
||||
track of state().subtitleTracks;
|
||||
track track.id
|
||||
@@ -404,6 +416,214 @@
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@if (capabilities().externalSubtitles) {
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="player-controls__track player-controls__load-subtitle"
|
||||
data-test-id="player-controls-load-subtitle"
|
||||
(click)="loadExternalSubtitle()"
|
||||
>
|
||||
<span>{{
|
||||
'EMBEDDED_MPV.PLAYER.LOAD_SUBTITLE_FILE'
|
||||
| translate
|
||||
}}</span>
|
||||
<mat-icon>upload_file</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (capabilities().subtitleDelay) {
|
||||
<div
|
||||
class="player-controls__subtitle-section"
|
||||
data-test-id="player-controls-subtitle-delay"
|
||||
>
|
||||
<div
|
||||
class="player-controls__subtitle-section-title"
|
||||
>
|
||||
{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="player-controls__subtitle-delay-row"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
(click)="
|
||||
subtitleSettings.adjustDelay(
|
||||
-subtitleDelayStep
|
||||
)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
|
||||
| translate
|
||||
"
|
||||
[matTooltip]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_DECREASE'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>remove</mat-icon>
|
||||
</button>
|
||||
<span
|
||||
class="player-controls__subtitle-delay-value"
|
||||
aria-live="polite"
|
||||
>{{
|
||||
subtitleDelayLabel(
|
||||
state()
|
||||
.subtitleDelaySeconds
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
(click)="
|
||||
subtitleSettings.adjustDelay(
|
||||
subtitleDelayStep
|
||||
)
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
|
||||
| translate
|
||||
"
|
||||
[matTooltip]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_INCREASE'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
class="player-controls__button"
|
||||
[disabled]="
|
||||
state().subtitleDelaySeconds ===
|
||||
0
|
||||
"
|
||||
(click)="subtitleSettings.resetDelay()"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
|
||||
| translate
|
||||
"
|
||||
[matTooltip]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_DELAY_RESET'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<mat-icon>restart_alt</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (capabilities().subtitleStyle) {
|
||||
<div
|
||||
class="player-controls__subtitle-section"
|
||||
data-test-id="player-controls-subtitle-style"
|
||||
>
|
||||
<div
|
||||
class="player-controls__subtitle-section-title"
|
||||
>
|
||||
{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="player-controls__subtitle-preset-row"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_SIZE'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
@for (
|
||||
preset of subtitleSizePresets;
|
||||
track preset.value
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-controls__subtitle-chip"
|
||||
[attr.aria-checked]="
|
||||
preset.value ===
|
||||
state().subtitleStyle
|
||||
.sizePercent
|
||||
"
|
||||
[class.player-controls__subtitle-chip--selected]="
|
||||
preset.value ===
|
||||
state().subtitleStyle
|
||||
.sizePercent
|
||||
"
|
||||
(click)="
|
||||
subtitleSettings.setSize(
|
||||
preset.value
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
class="player-controls__subtitle-section-title"
|
||||
>
|
||||
{{
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
|
||||
| translate
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="player-controls__subtitle-preset-row"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="
|
||||
'EMBEDDED_MPV.PLAYER.SUBTITLE_COLOR'
|
||||
| translate
|
||||
"
|
||||
>
|
||||
@for (
|
||||
preset of subtitleColorPresets;
|
||||
track preset.label
|
||||
) {
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
class="player-controls__subtitle-swatch"
|
||||
[class.player-controls__subtitle-swatch--default]="
|
||||
preset.value === null
|
||||
"
|
||||
[style.--swatch-color]="
|
||||
preset.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
preset.value ===
|
||||
state().subtitleStyle.color
|
||||
"
|
||||
[class.player-controls__subtitle-swatch--selected]="
|
||||
preset.value ===
|
||||
state().subtitleStyle.color
|
||||
"
|
||||
[attr.aria-label]="
|
||||
preset.label | translate
|
||||
"
|
||||
[matTooltip]="
|
||||
preset.label | translate
|
||||
"
|
||||
(click)="
|
||||
subtitleSettings.setColor(
|
||||
preset.value
|
||||
)
|
||||
"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+3
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+248
@@ -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<PlayerControlsCapabilities> = signal({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
});
|
||||
const state: WritableSignal<PlayerControlsState> = signal(
|
||||
createEmptyControlsState()
|
||||
);
|
||||
const commands: jest.Mocked<PlayerControlsCommands> = {
|
||||
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<PlayerControlsComponent>;
|
||||
let fake: ReturnType<typeof createFakeController>;
|
||||
|
||||
const setCapabilities = (
|
||||
overrides: Partial<PlayerControlsCapabilities>
|
||||
) => {
|
||||
fake.capabilities.set({
|
||||
...DEFAULT_PLAYER_CAPABILITIES,
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
const setState = (overrides: Partial<PlayerControlsState>) => {
|
||||
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<HTMLButtonElement>(
|
||||
'.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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PlayerPreset<number>> = [
|
||||
{ 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<PlayerPreset<string | null>> =
|
||||
[
|
||||
{ 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`;
|
||||
}
|
||||
+107
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<void>;
|
||||
getSubtitleTracks?: () => PlayerTrack[];
|
||||
setSubtitleTrack?: (id: number) => void | Promise<void>;
|
||||
/** Opens the engine's subtitle file picker; loads + selects the pick. */
|
||||
addExternalSubtitleFile?: () => void;
|
||||
getSubtitleDelay?: () => number;
|
||||
setSubtitleDelay?: (seconds: number) => void | Promise<void>;
|
||||
/** Runtime gate for the delay UI (e.g. only once a file is loaded). */
|
||||
canAdjustSubtitleDelay?: () => boolean;
|
||||
getSubtitleStyle?: () => PlayerSubtitleStyle;
|
||||
setSubtitleStyle?: (style: PlayerSubtitleStyle) => void | Promise<void>;
|
||||
getQualityLevels?: () => PlayerTrack[];
|
||||
setQualityLevel?: (id: number) => void | Promise<void>;
|
||||
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()
|
||||
|
||||
@@ -96,9 +96,9 @@ export function applyVideoSpeed(
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTrackSelection(
|
||||
setter: ((id: number) => void | Promise<void>) | undefined,
|
||||
id: number,
|
||||
export function applyTrackSelection<T = number>(
|
||||
setter: ((value: T) => void | Promise<void>) | undefined,
|
||||
id: T,
|
||||
refresh: () => void
|
||||
): void {
|
||||
if (!setter) {
|
||||
|
||||
@@ -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',
|
||||
'<i>Styled</i> text survives',
|
||||
].join('\n');
|
||||
|
||||
expect(
|
||||
parseExternalSubtitleCues({ format: 'vtt', content })
|
||||
).toEqual([
|
||||
{
|
||||
startSeconds: 5,
|
||||
endSeconds: 7,
|
||||
text: 'Short-form timestamps work',
|
||||
},
|
||||
{
|
||||
startSeconds: 3600,
|
||||
endSeconds: 3604,
|
||||
text: '<i>Styled</i> 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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <i>/<b>-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<ExternalSubtitleFile, 'format' | 'content'>
|
||||
): 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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<TextTrack>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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') {
|
||||
|
||||
@@ -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('; ')}; }`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user