fix(jellyfin): subtitle timing, resume progress, and overlay sync

- Add per-stream subtitle delay persistence and auto timeline-offset correction
- Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload
- Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports
- Keep Play vs Resume distinct to avoid early seek race on normal play
- Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress
- Deduplicate show/hide overlay commands using recorded visibility state
- Rewrite docs-site Jellyfin page around cast-to-device UX
This commit is contained in:
2026-05-24 02:49:47 -07:00
parent dc9d7b77bb
commit 127e1ea88e
42 changed files with 2113 additions and 298 deletions
@@ -25,7 +25,9 @@ test('play jellyfin item in mpv main deps builder maps callbacks', async () => {
armQuitOnDisconnect: () => calls.push('arm'),
schedule: (_callback, delayMs) => calls.push(`schedule:${delayMs}`),
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => calls.push('preload'),
preloadExternalSubtitles: () => {
calls.push('preload');
},
setActivePlayback: () => calls.push('active'),
setLastProgressAtMs: () => calls.push('progress'),
reportPlaying: () => calls.push('report'),
+352 -14
View File
@@ -77,7 +77,9 @@ test('playback handler drives mpv commands and playback state', async () => {
scheduled.push({ delay: delayMs, callback });
},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => calls.push('preload'),
preloadExternalSubtitles: () => {
calls.push('preload');
},
setActivePlayback: (state) => activeStates.push(state as Record<string, unknown>),
setLastProgressAtMs: (value) => calls.push(`progress:${value}`),
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
@@ -94,12 +96,21 @@ test('playback handler drives mpv commands and playback state', async () => {
itemId: 'item-1',
});
assert.deepEqual(commands.slice(0, 5), [
assert.deepEqual(commands.slice(0, 8), [
['set_property', 'sub-auto', 'no'],
['loadfile', 'https://stream.example/video.m3u8', 'replace'],
['set_property', 'force-media-title', 'Episode 1'],
['set_property', 'sid', 'no'],
['seek', 1.2, 'absolute+exact'],
['set_property', 'secondary-sid', 'no'],
['set_property', 'sub-visibility', 'no'],
['set_property', 'secondary-sub-visibility', 'no'],
['script-message', 'subminer-managed-subtitles-loading'],
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no,start=1.2',
],
['set_property', 'force-media-title', 'Episode 1'],
]);
assert.equal(scheduled.length, 0);
assert.equal(
@@ -108,11 +119,11 @@ test('playback handler drives mpv commands and playback state', async () => {
);
assert.ok(calls.includes('defaults'));
assert.ok(calls.includes('visible-overlay'));
assert.ok(
calls.indexOf('visible-overlay') < calls.indexOf('preload'),
'visible overlay should be shown before Jellyfin subtitles are selected',
calls.indexOf('preload') < calls.indexOf('visible-overlay'),
'visible overlay should be shown after Jellyfin subtitles are selected',
);
assert.ok(calls.includes('visible-overlay'));
assert.ok(calls.includes('arm'));
assert.ok(calls.includes('preload'));
assert.ok(calls.includes('progress:0'));
@@ -120,6 +131,7 @@ test('playback handler drives mpv commands and playback state', async () => {
assert.equal(activeStates.length, 1);
assert.equal(activeStates[0]?.playMethod, 'DirectPlay');
assert.equal(activeStates[0]?.lastKnownPositionSeconds, 1.2);
assert.equal(reportPayloads.length, 1);
assert.equal(reportPayloads[0]?.eventName, 'start');
assert.equal(reportPayloads[0]?.positionTicks, 12_000_000);
@@ -137,6 +149,249 @@ test('playback handler drives mpv commands and playback state', async () => {
]);
});
test('playback handler waits for Jellyfin subtitle preload before showing visible overlay', async () => {
const calls: string[] = [];
let resolvePreload!: () => void;
const preloadComplete = new Promise<void>((resolve) => {
resolvePreload = resolve;
});
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: 'Show Title',
seasonNumber: 1,
episodeNumber: 1,
startTimeTicks: 0,
audioStreamIndex: 1,
subtitleStreamIndex: 2,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => calls.push('visible-overlay'),
sendMpvCommand: () => {},
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: async () => {
calls.push('preload-start');
await preloadComplete;
calls.push('preload-done');
},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
const playback = handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
for (let i = 0; i < 5 && calls.length === 0; i += 1) {
await Promise.resolve();
}
assert.equal(calls.length, 1);
assert.equal(calls[0], 'preload-start');
resolvePreload();
await playback;
assert.deepEqual(calls, ['preload-start', 'preload-done', 'visible-overlay']);
});
test('playback handler strips Jellyfin subtitle stream from mpv load URL', async () => {
const commands: Array<Array<string | number>> = [];
const reports: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://jellyfin.local/Videos/ep-1/stream?static=true&api_key=secret-token&MediaSourceId=ms-1&AudioStreamIndex=3&SubtitleStreamIndex=4',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: 3,
subtitleStreamIndex: 4,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reports.push(payload),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'ep-1',
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const url = new URL(String(loadCommand[1]));
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
assert.equal(url.searchParams.has('SubtitleStreamIndex'), false);
assert.equal(reports[0]?.subtitleStreamIndex, 4);
});
test('playback handler starts remote Play from beginning when requested despite saved plan progress', async () => {
const commands: Array<Array<string | number>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
mode: 'transcode',
title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 35_000_000,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 0,
fallbackToPlanStartTimeOnZeroOverride: false,
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), null);
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
assert.equal(reportPayloads[0]?.positionTicks, 0);
});
test('playback handler disables mpv subtitle selection before Jellyfin media loads', async () => {
const commands: Array<Array<string | number>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8',
mode: 'direct',
title: 'Episode 1',
itemTitle: 'Episode 1',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 0,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: () => {},
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-1',
});
const loadIndex = commands.findIndex((command) => command[0] === 'loadfile');
assert.ok(loadIndex > 0);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'script-message' &&
command[1] === 'subminer-managed-subtitles-loading',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'sid' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'secondary-sid' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'sub-visibility' &&
command[2] === 'no',
) >= 0,
);
assert.ok(
commands.findIndex(
(command, index) =>
index < loadIndex &&
command[0] === 'set_property' &&
command[1] === 'secondary-sub-visibility' &&
command[2] === 'no',
) >= 0,
);
assert.equal(
commands[loadIndex]?.[4],
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
);
});
test('playback handler publishes Jellyfin title before loading tokenized stream url', async () => {
const timeline: string[] = [];
const handler = createPlayJellyfinItemInMpvHandler({
@@ -264,11 +519,67 @@ test('playback handler applies start override to stream url for remote resume',
startTimeTicksOverride: 55_000_000,
});
assert.equal(commands[1]?.[0], 'loadfile');
const loadedUrl = String(commands[1]?.[1] ?? '');
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), '55000000');
assert.deepEqual(commands[4], ['seek', 5.5, 'absolute+exact']);
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
});
test('playback handler keeps Jellyfin resume ticks when remote start override is zero', async () => {
const commands: Array<Array<string | number>> = [];
const reportPayloads: Array<Record<string, unknown>> = [];
const handler = createPlayJellyfinItemInMpvHandler({
ensureMpvConnectedForPlayback: async () => true,
getMpvClient: () => ({ connected: true, send: () => {} }),
resolvePlaybackPlan: async () => ({
url: 'https://stream.example/video.m3u8?api_key=token&StartTimeTicks=35000000',
mode: 'transcode',
title: 'Episode 2',
itemTitle: 'Episode 2',
seriesTitle: null,
seasonNumber: null,
episodeNumber: null,
startTimeTicks: 35_000_000,
audioStreamIndex: null,
subtitleStreamIndex: null,
}),
applyJellyfinMpvDefaults: () => {},
showVisibleOverlay: () => {},
sendMpvCommand: (command) => commands.push(command),
armQuitOnDisconnect: () => {},
schedule: () => {},
convertTicksToSeconds: (ticks) => ticks / 10_000_000,
preloadExternalSubtitles: () => {},
setActivePlayback: () => {},
setLastProgressAtMs: () => {},
reportPlaying: (payload) => reportPayloads.push(payload as Record<string, unknown>),
showMpvOsd: () => {},
});
await handler({
session: baseSession,
clientInfo: baseClientInfo,
jellyfinConfig: {},
itemId: 'item-2',
startTimeTicksOverride: 0,
fallbackToPlanStartTimeOnZeroOverride: true,
});
const loadCommand = commands.find((command) => command[0] === 'loadfile');
assert.ok(loadCommand);
const loadedUrl = String(loadCommand[1] ?? '');
const parsed = new URL(loadedUrl);
assert.equal(parsed.searchParams.get('StartTimeTicks'), '35000000');
assert.equal(
commands.some((command) => command[0] === 'seek'),
false,
);
assert.equal(reportPayloads[0]?.positionTicks, 35_000_000);
});
test('playback handler does not let stats metadata failures block playback startup', async () => {
@@ -311,7 +622,16 @@ test('playback handler does not let stats metadata failures block playback start
itemId: 'item-3',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
test('playback handler does not let media title failures block playback startup', async () => {
@@ -354,7 +674,16 @@ test('playback handler does not let media title failures block playback startup'
itemId: 'item-4',
});
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
test('playback handler handles rejected best-effort hook promises', async () => {
@@ -402,5 +731,14 @@ test('playback handler handles rejected best-effort hook promises', async () =>
await Promise.resolve();
await Promise.resolve();
assert.deepEqual(commands[1], ['loadfile', 'https://stream.example/video.m3u8', 'replace']);
assert.deepEqual(
commands.find((command) => command[0] === 'loadfile'),
[
'loadfile',
'https://stream.example/video.m3u8',
'replace',
-1,
'sid=no,secondary-sid=no,sub-auto=no,sub-visibility=no,secondary-sub-visibility=no',
],
);
});
+88 -17
View File
@@ -16,6 +16,7 @@ type ActivePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
lastKnownPositionSeconds?: number;
};
export type JellyfinPlaybackStatsMetadata = {
@@ -28,6 +29,14 @@ export type JellyfinPlaybackStatsMetadata = {
itemId: string;
};
const JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS = [
'sid=no',
'secondary-sid=no',
'sub-auto=no',
'sub-visibility=no',
'secondary-sub-visibility=no',
];
function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void {
try {
void Promise.resolve(callback()).catch(() => {});
@@ -36,6 +45,14 @@ function runBestEffortPlaybackHook(callback: () => void | Promise<void>): void {
}
}
async function awaitBestEffortPlaybackHook(callback: () => void | Promise<void>): Promise<void> {
try {
await Promise.resolve(callback());
} catch {
// Best-effort startup hooks must not block playback startup.
}
}
function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?: number): string {
if (typeof startTimeTicksOverride !== 'number') return url;
try {
@@ -51,6 +68,48 @@ function applyStartTimeTicksToPlaybackUrl(url: string, startTimeTicksOverride?:
}
}
function stripStartTimeTicksFromPlaybackUrl(url: string): string {
try {
const resolved = new URL(url);
resolved.searchParams.delete('StartTimeTicks');
return resolved.toString();
} catch {
return url;
}
}
function stripManagedSubtitleStreamFromPlaybackUrl(url: string): string {
try {
const resolved = new URL(url);
resolved.searchParams.delete('SubtitleStreamIndex');
return resolved.toString();
} catch {
return url;
}
}
function resolveEffectiveStartTimeTicks(
planStartTimeTicks: number,
startTimeTicksOverride?: number,
fallbackToPlanStartTimeOnZeroOverride = false,
) {
if (typeof startTimeTicksOverride === 'number' && startTimeTicksOverride > 0) {
return Math.max(0, startTimeTicksOverride);
}
if (typeof startTimeTicksOverride === 'number') {
return fallbackToPlanStartTimeOnZeroOverride ? Math.max(0, planStartTimeTicks) : 0;
}
return Math.max(0, planStartTimeTicks);
}
function buildJellyfinLoadfileOptions(plan: JellyfinPlaybackPlan, startSeconds: number): string {
const options = [...JELLYFIN_LOADFILE_SUBTITLE_SUPPRESSION_OPTIONS];
if (plan.mode === 'direct' && startSeconds > 0) {
options.push(`start=${startSeconds}`);
}
return options.join(',');
}
export function createPlayJellyfinItemInMpvHandler(deps: {
ensureMpvConnectedForPlayback: () => Promise<boolean>;
getMpvClient: () => MpvRuntimeClientLike | null;
@@ -72,7 +131,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
session: JellyfinAuthSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => void;
}) => void | Promise<void>;
setActivePlayback: (state: ActivePlaybackState) => void;
setLastProgressAtMs: (value: number) => void;
reportPlaying: (payload: {
@@ -99,6 +158,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
audioStreamIndex?: number | null;
subtitleStreamIndex?: number | null;
startTimeTicksOverride?: number;
fallbackToPlanStartTimeOnZeroOverride?: boolean;
setQuitOnDisconnectArm?: boolean;
}): Promise<void> => {
const connected = await deps.ensureMpvConnectedForPlayback();
@@ -120,7 +180,23 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
deps.applyJellyfinMpvDefaults(mpvClient);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
const playbackUrl = applyStartTimeTicksToPlaybackUrl(plan.url, params.startTimeTicksOverride);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
const startTimeTicks = resolveEffectiveStartTimeTicks(
plan.startTimeTicks,
params.startTimeTicksOverride,
params.fallbackToPlanStartTimeOnZeroOverride,
);
const startSeconds =
startTimeTicks > 0 ? Math.max(0, deps.convertTicksToSeconds(startTimeTicks)) : 0;
const playbackUrlBase =
plan.mode === 'direct'
? stripStartTimeTicksFromPlaybackUrl(plan.url)
: applyStartTimeTicksToPlaybackUrl(plan.url, startTimeTicks);
const playbackUrl = stripManagedSubtitleStreamFromPlaybackUrl(playbackUrlBase);
const loadfileOptions = buildJellyfinLoadfileOptions(plan, startSeconds);
const playMethod = plan.mode === 'direct' ? 'DirectPlay' : 'Transcode';
runBestEffortPlaybackHook(() => deps.updateCurrentMediaTitle?.(plan.title));
runBestEffortPlaybackHook(() =>
@@ -141,29 +217,24 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
subtitleStreamIndex: plan.subtitleStreamIndex,
playMethod,
loadedMediaPath: null,
lastKnownPositionSeconds: startSeconds > 0 ? startSeconds : undefined,
});
deps.setLastProgressAtMs(0);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace']);
deps.sendMpvCommand(['script-message', 'subminer-managed-subtitles-loading']);
deps.sendMpvCommand(['loadfile', playbackUrl, 'replace', -1, loadfileOptions]);
if (params.setQuitOnDisconnectArm !== false) {
deps.armQuitOnDisconnect();
}
deps.sendMpvCommand(['set_property', 'force-media-title', plan.title]);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
const startTimeTicks =
typeof params.startTimeTicksOverride === 'number'
? Math.max(0, params.startTimeTicksOverride)
: plan.startTimeTicks;
if (startTimeTicks > 0) {
deps.sendMpvCommand(['seek', deps.convertTicksToSeconds(startTimeTicks), 'absolute+exact']);
}
await awaitBestEffortPlaybackHook(() =>
deps.preloadExternalSubtitles({
session: params.session,
clientInfo: params.clientInfo,
itemId: params.itemId,
}),
);
deps.showVisibleOverlay();
deps.preloadExternalSubtitles({
session: params.session,
clientInfo: params.clientInfo,
itemId: params.itemId,
});
deps.reportPlaying({
itemId: params.itemId,
@@ -21,7 +21,13 @@ test('getConfiguredJellyfinSession returns null for incomplete config', () => {
});
test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', async () => {
const calls: Array<{ itemId: string; audio?: number; subtitle?: number; start?: number }> = [];
const calls: Array<{
itemId: string;
audio?: number;
subtitle?: number;
start?: number;
fallback?: boolean;
}> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
@@ -37,6 +43,7 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
audio: params.audioStreamIndex,
subtitle: params.subtitleStreamIndex,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
@@ -49,11 +56,13 @@ test('createHandleJellyfinRemotePlay forwards parsed payload to play runtime', a
StartPositionTicks: 1000,
});
assert.deepEqual(calls, [{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000 }]);
assert.deepEqual(calls, [
{ itemId: 'item-1', audio: 3, subtitle: 7, start: 1000, fallback: true },
]);
});
test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async () => {
const calls: Array<{ itemId: string; start?: number }> = [];
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
@@ -67,6 +76,7 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
@@ -77,7 +87,64 @@ test('createHandleJellyfinRemotePlay parses string StartPositionTicks', async ()
StartPositionTicks: '12345',
});
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345 }]);
assert.deepEqual(calls, [{ itemId: 'item-2', start: 12345, fallback: true }]);
});
test('createHandleJellyfinRemotePlay starts from beginning when StartPositionTicks is omitted', async () => {
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-3'],
});
assert.deepEqual(calls, [{ itemId: 'item-3', start: 0, fallback: false }]);
});
test('createHandleJellyfinRemotePlay lets explicit zero fall back to Jellyfin item progress', async () => {
const calls: Array<{ itemId: string; start?: number; fallback?: boolean }> = [];
const handlePlay = createHandleJellyfinRemotePlay({
getConfiguredSession: () => ({
serverUrl: 'https://jellyfin.local',
accessToken: 'token',
userId: 'user',
username: 'name',
}),
getClientInfo: () => ({ clientName: 'SubMiner', clientVersion: '1.0', deviceId: 'abc' }),
getJellyfinConfig: () => ({ enabled: true }),
playJellyfinItem: async (params) => {
calls.push({
itemId: params.itemId,
start: params.startTimeTicksOverride,
fallback: params.fallbackToPlanStartTimeOnZeroOverride,
});
},
logWarn: () => {},
});
await handlePlay({
ItemIds: ['item-4'],
StartPositionTicks: 0,
});
assert.deepEqual(calls, [{ itemId: 'item-4', start: 0, fallback: true }]);
});
test('createHandleJellyfinRemotePlay logs and skips payload without item id', async () => {
+8 -1
View File
@@ -6,6 +6,7 @@ export type ActiveJellyfinRemotePlaybackState = {
playMethod: 'DirectPlay' | 'Transcode';
loadedMediaPath?: string | null;
stopReportsAfterMs?: number;
lastKnownPositionSeconds?: number;
};
type JellyfinSession = {
@@ -62,6 +63,7 @@ export type JellyfinRemotePlayHandlerDeps = {
audioStreamIndex?: number;
subtitleStreamIndex?: number;
startTimeTicksOverride?: number;
fallbackToPlanStartTimeOnZeroOverride?: boolean;
setQuitOnDisconnectArm?: boolean;
}) => Promise<void>;
logWarn: (message: string) => void;
@@ -85,6 +87,10 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
if (deps.getActivePlayback?.()?.itemId === itemId) {
return;
}
const hasStartPositionTicks = Object.prototype.hasOwnProperty.call(data, 'StartPositionTicks');
const startTimeTicksOverride = hasStartPositionTicks
? (asInteger(data.StartPositionTicks) ?? 0)
: 0;
await deps.playJellyfinItem({
session,
clientInfo,
@@ -92,7 +98,8 @@ export function createHandleJellyfinRemotePlay(deps: JellyfinRemotePlayHandlerDe
itemId,
audioStreamIndex: asInteger(data.AudioStreamIndex),
subtitleStreamIndex: asInteger(data.SubtitleStreamIndex),
startTimeTicksOverride: asInteger(data.StartPositionTicks),
startTimeTicksOverride,
fallbackToPlanStartTimeOnZeroOverride: hasStartPositionTicks,
setQuitOnDisconnectArm: false,
});
};
@@ -5,6 +5,7 @@ import {
createReportJellyfinRemoteProgressHandler,
createReportJellyfinRemoteStoppedHandler,
secondsToJellyfinTicks,
shouldAutoLoadSecondarySubTrackForJellyfinPlayback,
} from './jellyfin-remote-playback';
test('secondsToJellyfinTicks converts seconds and clamps invalid values', () => {
@@ -13,6 +14,39 @@ test('secondsToJellyfinTicks converts seconds and clamps invalid values', () =>
assert.equal(secondsToJellyfinTicks(Number.NaN, 10_000_000), 0);
});
test('shouldAutoLoadSecondarySubTrackForJellyfinPlayback suppresses generic secondary autoload for active Jellyfin media', () => {
assert.equal(shouldAutoLoadSecondarySubTrackForJellyfinPlayback(null, '/tmp/local.mkv'), true);
assert.equal(
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
{ itemId: 'item-1', playMethod: 'DirectPlay', loadedMediaPath: null },
'http://pve-main:8096/Videos/item/stream',
),
false,
);
assert.equal(
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
{
itemId: 'item-1',
playMethod: 'DirectPlay',
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
},
'http://pve-main:8096/Videos/item/stream',
),
false,
);
assert.equal(
shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
{
itemId: 'item-1',
playMethod: 'DirectPlay',
loadedMediaPath: 'http://pve-main:8096/Videos/item/stream',
},
'/tmp/local.mkv',
),
true,
);
});
test('createReportJellyfinRemoteProgressHandler reports playback progress', async () => {
let lastProgressAtMs = 0;
const reportPayloads: Array<{ itemId: string; positionTicks: number; isPaused: boolean }> = [];
@@ -303,6 +337,48 @@ test('createReportJellyfinRemoteStoppedHandler reports stop while remote websock
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler uses cached position after mpv unload reset', async () => {
let cleared = false;
const calls: Array<{ event: string; positionTicks?: number }> = [];
const reportStopped = createReportJellyfinRemoteStoppedHandler({
getActivePlayback: () =>
({
itemId: 'item-2',
mediaSourceId: undefined,
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
loadedMediaPath: 'https://stream.example/video.m3u8',
lastKnownPositionSeconds: 72.25,
}) as never,
clearActivePlayback: () => {
cleared = true;
},
getSession: () => ({
isConnected: () => true,
reportProgress: async (payload) => {
calls.push({ event: 'progress', positionTicks: payload.positionTicks });
},
reportStopped: async (payload) => {
calls.push({ event: 'stopped', positionTicks: payload.positionTicks });
},
}),
getMpvClient: () => ({
currentTimePos: 0,
}),
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportStopped();
assert.deepEqual(calls, [
{ event: 'progress', positionTicks: 722_500_000 },
{ event: 'stopped', positionTicks: 722_500_000 },
]);
assert.equal(cleared, true);
});
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
let cleared = false;
let stopped = false;
@@ -336,6 +412,42 @@ test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback'
assert.equal(cleared, false);
});
test('createReportJellyfinRemoteProgressHandler caches last nonzero mpv position', async () => {
let position = 42;
let lastProgressAtMs = 0;
const playback = {
itemId: 'item-1',
playMethod: 'DirectPlay' as const,
};
const reportProgress = createReportJellyfinRemoteProgressHandler({
getActivePlayback: () => playback,
clearActivePlayback: () => {},
getSession: () => ({
isConnected: () => true,
reportProgress: async () => {},
reportStopped: async () => {},
}),
getMpvClient: () => ({
currentTimePos: position,
requestProperty: async (name: string) => (name === 'pause' ? false : position),
}),
getNow: () => 5000,
getLastProgressAtMs: () => lastProgressAtMs,
setLastProgressAtMs: (value) => {
lastProgressAtMs = value;
},
progressIntervalMs: 3000,
ticksPerSecond: 10_000_000,
logDebug: () => {},
});
await reportProgress(true);
position = 0;
await reportProgress(true);
assert.equal((playback as { lastKnownPositionSeconds?: number }).lastKnownPositionSeconds, 42);
});
test('markJellyfinRemotePlaybackLoaded preserves the loaded marker on unload paths', () => {
const playback = {
itemId: 'item-2',
+59 -3
View File
@@ -44,6 +44,21 @@ export function markJellyfinRemotePlaybackLoaded(
}
}
export function shouldAutoLoadSecondarySubTrackForJellyfinPlayback(
playback: ActiveJellyfinRemotePlaybackState | null,
path: string,
): boolean {
const normalizedPath = path.trim();
if (!normalizedPath || !playback) {
return true;
}
const loadedMediaPath = playback.loadedMediaPath?.trim() ?? '';
if (!loadedMediaPath) {
return false;
}
return loadedMediaPath !== normalizedPath;
}
function isMpvPauseEnabled(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
@@ -87,6 +102,29 @@ async function readMpvPositionSecondsOrFallback(
}
}
function cacheLastKnownPosition(
playback: ActiveJellyfinRemotePlaybackState,
positionSeconds: number,
): void {
if (!Number.isFinite(positionSeconds)) return;
if (positionSeconds > 0 || playback.lastKnownPositionSeconds === undefined) {
playback.lastKnownPositionSeconds = Math.max(0, positionSeconds);
}
}
function resolveReportablePositionSeconds(
playback: ActiveJellyfinRemotePlaybackState,
positionSeconds: number,
): number {
const normalizedPosition = normalizeMpvPositionSeconds(positionSeconds);
if (normalizedPosition > 0) return normalizedPosition;
const cachedPosition = playback.lastKnownPositionSeconds;
if (typeof cachedPosition === 'number' && Number.isFinite(cachedPosition) && cachedPosition > 0) {
return cachedPosition;
}
return normalizedPosition;
}
function isSeekLikePositionJump(
previousPositionSeconds: number | null,
nextPositionSeconds: number,
@@ -123,7 +161,9 @@ export function createReportJellyfinRemoteProgressHandler(
const now = deps.getNow();
try {
const mpvClient = deps.getMpvClient();
const positionSeconds = await readMpvPositionSeconds(mpvClient);
const observedPositionSeconds = await readMpvPositionSeconds(mpvClient);
cacheLastKnownPosition(playback, observedPositionSeconds);
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
const forceForSeekJump = isSeekLikePositionJump(
lastReportedPositionSeconds,
positionSeconds,
@@ -184,11 +224,27 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
return;
}
try {
const positionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
const positionTicks = secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond);
try {
await session.reportProgress({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks,
isPaused: false,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
subtitleStreamIndex: playback.subtitleStreamIndex,
eventName: 'TimeUpdate',
});
} catch (error) {
deps.logDebug('Failed to report Jellyfin remote final progress', error);
}
await session.reportStopped({
itemId: playback.itemId,
mediaSourceId: playback.mediaSourceId,
positionTicks: secondsToJellyfinTicks(positionSeconds, deps.ticksPerSecond),
positionTicks,
failed: false,
playMethod: playback.playMethod,
audioStreamIndex: playback.audioStreamIndex,
@@ -19,6 +19,19 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
getSavedSubtitleDelay: (_itemId, streamIndex) => {
calls.push(`load-delay:${streamIndex}`);
return 1.25;
},
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
loadSubtitleSourceText: async (source) => {
calls.push(`load-source:${source}`);
return 'subtitle';
},
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
return true;
},
logDebug: (message) => calls.push(`debug:${message}`),
})();
@@ -28,6 +41,21 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
deps.logDebug('oops', null);
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
assert.deepEqual(calls, [
'list',
'send',
'wait',
'cache',
'cleanup',
'load-delay:3',
'active-delay:3',
'load-source:/tmp/sub.srt',
'save-delay:3:-31.5',
'debug:oops',
]);
});
@@ -15,6 +15,19 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
wait: (ms: number) => deps.wait(ms),
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
: undefined,
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
? (key) => deps.setActiveSubtitleDelayKey!(key)
: undefined,
loadSubtitleSourceText: deps.loadSubtitleSourceText
? (source) => deps.loadSubtitleSourceText!(source)
: undefined,
saveSubtitleDelay: deps.saveSubtitleDelay
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}
@@ -32,6 +32,14 @@ function makeDeps(overrides: {
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
getSavedSubtitleDelay?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['getSavedSubtitleDelay'];
setActiveSubtitleDelayKey?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
}) {
return {
@@ -46,10 +54,38 @@ function makeDeps(overrides: {
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
})),
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
logDebug: overrides.logDebug ?? (() => {}),
};
}
function withoutTrackAutoSelectionCommands(
commands: Array<Array<string | number>>,
): Array<Array<string | number>> {
return commands.filter(
(command) =>
!(
command[0] === 'set_property' &&
(command[1] === 'track-auto-selection' ||
(command[1] === 'sid' && command[2] === 'no') ||
(command[1] === 'secondary-sid' && command[2] === 'no') ||
(command[1] === 'sub-visibility' && command[2] === 'no') ||
(command[1] === 'secondary-sub-visibility' && command[2] === 'no'))
),
);
}
function setPropertyCommandsExceptTrackAutoSelection(
commands: Array<Array<string | number>>,
): Array<Array<string | number>> {
return withoutTrackAutoSelectionCommands(commands).filter(
(command) => command[0] === 'set_property',
);
}
test('preload jellyfin subtitles caches external tracks locally and chooses japanese+english tracks', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -89,7 +125,7 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(commands, [
assert.deepEqual(withoutTrackAutoSelectionCommands(commands), [
['sub-add', '/tmp/subminer-jellyfin-subtitles/0.srt', 'auto', 'Japanese', 'jpn'],
['sub-add', '/tmp/subminer-jellyfin-subtitles/1.srt', 'auto', 'English SDH', 'eng'],
['set_property', 'sid', 5],
@@ -97,6 +133,59 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
]);
});
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(
commands.filter((command) => command[0] === 'sub-add').map((command) => command[2]),
['auto', 'auto'],
);
const firstFinalSelectionIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 5,
);
assert.ok(firstFinalSelectionIndex >= 0);
assert.equal(
commands
.slice(0, firstFinalSelectionIndex)
.some(
(command) =>
command[0] === 'sub-add' && (command[2] === 'cached' || command[2] === 'select'),
),
false,
);
});
test('preload jellyfin subtitles waits for delayed cached japanese track before selecting', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
@@ -140,13 +229,10 @@ test('preload jellyfin subtitles waits for delayed cached japanese track before
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
],
);
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 5],
['set_property', 'secondary-sid', 6],
]);
});
test('preload jellyfin subtitles waits for delayed external japanese track instead of embedded japanese', async () => {
@@ -192,13 +278,286 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 3);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 42],
['set_property', 'secondary-sid', 43],
],
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 42],
['set_property', 'secondary-sid', 43],
]);
});
test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese sources', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{
index: 0,
language: 'jpn',
title: 'External Japanese',
isExternal: true,
deliveryUrl: 'https://sub/external.srt',
},
{
index: 1,
language: 'jpn',
title: 'Embedded Japanese',
isDefault: true,
isExternal: false,
deliveryUrl: 'https://sub/embedded.srt',
},
{
index: 2,
language: 'eng',
title: 'English',
deliveryUrl: 'https://sub/english.srt',
},
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'External Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'jpn',
title: 'Embedded Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
{
type: 'sub',
id: 7,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 6],
['set_property', 'secondary-sid', 7],
]);
});
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sub-delay', 1.25],
['set_property', 'sid', 11],
]);
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
});
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: () => 1.25,
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
);
const selectedSidIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
);
assert.ok(delayIndex >= 0);
assert.ok(selectedSidIndex >= 0);
assert.ok(delayIndex < selectedSidIndex);
});
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
const commands: Array<Array<string | number>> = [];
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
const primarySrt = `1
00:00:34,935 --> 00:00:36,937
Japanese 1
2
00:00:36,937 --> 00:00:41,441
Japanese 2
3
00:00:41,441 --> 00:00:45,279
Japanese 3
4
00:00:45,279 --> 00:00:48,115
Japanese 4
5
00:00:48,115 --> 00:00:52,286
Japanese 5
6
00:00:52,286 --> 00:00:54,955
Japanese 6
7
00:00:54,955 --> 00:00:59,793
Japanese 7
8
00:00:59,793 --> 00:01:03,630
Japanese 8
9
00:01:03,630 --> 00:01:07,634
Japanese 9
10
00:01:07,634 --> 00:01:13,040
Japanese 10
11
00:01:16,643 --> 00:01:20,814
Japanese 11
12
00:01:20,814 --> 00:01:23,116
Japanese 12
13
00:01:27,988 --> 00:01:30,991
Japanese 13
14
00:01:30,991 --> 00:01:34,094
Japanese 14
15
00:01:34,094 --> 00:01:37,097
Japanese 15
16
00:01:37,097 --> 00:01:39,100
Japanese 16
`;
const referenceAss = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
`;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 10,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 12,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
getSavedSubtitleDelay: () => null,
loadSubtitleSourceText: async (source) =>
source.endsWith('.ass') ? referenceAss : primarySrt,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
savedDelays.push({ itemId, streamIndex, delaySeconds });
},
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayCommand = commands.find(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
);
assert.ok(delayCommand);
const delaySeconds = delayCommand[2];
if (typeof delaySeconds !== 'number') {
assert.fail('Expected numeric subtitle delay.');
}
assert.ok(delaySeconds > -32);
assert.ok(delaySeconds < -31);
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
});
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
@@ -243,13 +602,10 @@ test('preload jellyfin subtitles accepts numeric string mpv track ids', async ()
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[
['set_property', 'sid', 10],
['set_property', 'secondary-sid', 11],
],
);
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 10],
['set_property', 'secondary-sid', 11],
]);
});
test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => {
@@ -286,7 +642,7 @@ test('preload jellyfin subtitles retries transient mpv track-list read failures'
await preload({ session, clientInfo, itemId: 'item-1' });
assert.equal(requestCount, 2);
assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]);
assert.deepEqual(withoutTrackAutoSelectionCommands(commands).at(-1), ['set_property', 'sid', 10]);
});
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
@@ -359,13 +715,71 @@ test('preload jellyfin subtitles does not let later subtitle adds steal japanese
['sub-add', '/tmp/subminer-jellyfin-subtitles/12.srt', 'auto', 'Russian', 'rus'],
],
);
assert.deepEqual(
commands.filter((command) => command[0] === 'set_property'),
[['set_property', 'sid', 11]],
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 11],
]);
});
test('preload jellyfin subtitles suppresses subtitle selection without disabling video auto selection', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 2, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
{
type: 'sub',
id: 12,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/2.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
const firstSubAddIndex = commands.findIndex((command) => command[0] === 'sub-add');
const subtitleSuppressionIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
);
const finalPrimarySidIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'track-auto-selection',
),
false,
);
assert.ok(subtitleSuppressionIndex >= 0);
assert.ok(subtitleSuppressionIndex < firstSubAddIndex);
assert.ok(firstSubAddIndex < finalPrimarySidIndex);
assert.equal(
commands.filter(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
).length,
1,
);
});
test('preload jellyfin subtitles leaves current track alone when reported japanese track never appears', async () => {
test('preload jellyfin subtitles does not select a missing japanese track', async () => {
const commands: Array<Array<string | number>> = [];
const logs: string[] = [];
let requestCount = 0;
@@ -390,7 +804,8 @@ test('preload jellyfin subtitles leaves current track alone when reported japane
assert.equal(requestCount, 10);
assert.equal(
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 'no',
(command) =>
command[0] === 'set_property' && command[1] === 'sid' && typeof command[2] === 'number',
),
false,
);
+133 -6
View File
@@ -1,3 +1,6 @@
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
type JellyfinSession = {
serverUrl: string;
accessToken: string;
@@ -15,6 +18,11 @@ type JellyfinSubtitleTrack = {
index: number;
language?: string;
title?: string;
codec?: string;
isDefault?: boolean;
isForced?: boolean;
isExternal?: boolean;
deliveryMethod?: string;
deliveryUrl?: string | null;
};
@@ -27,6 +35,11 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type JellyfinSubtitleDelayKey = {
itemId: string;
streamIndex: number;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
@@ -130,6 +143,10 @@ function pickBestCachedTrackId(
track,
score:
(track.external ? 100 : 0) +
(cached?.source.isDefault ? 35 : 0) +
(cached?.source.isExternal === false ? 25 : 0) +
(cached?.source.isExternal === true ? -10 : 0) +
(cached?.source.isForced ? -25 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0),
};
@@ -138,6 +155,17 @@ function pickBestCachedTrackId(
return ranked[0]?.track.id ?? null;
}
function findCachedTrackForMpvTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
trackId: number | null,
): CachedExternalSubtitleTrack | null {
if (trackId === null) return null;
const mpvTrack = tracks.find((track) => track.id === trackId);
if (!mpvTrack?.externalFilename) return null;
return cachedTracks.find((track) => track.path === mpvTrack.externalFilename) ?? null;
}
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
return isJapanese(track.lang) || isJapanese(track.title);
}
@@ -229,6 +257,54 @@ async function waitForPreferredSubtitleTracks(
return subtitleTracks;
}
async function estimateSubtitleDelayFromReference(
deps: {
loadSubtitleSourceText?: (source: string) => Promise<string>;
logDebug: (message: string, error: unknown) => void;
},
primaryTrack: CachedExternalSubtitleTrack | null,
referenceTrack: CachedExternalSubtitleTrack | null,
): Promise<number | null> {
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
return null;
}
try {
const [primaryContent, referenceContent] = await Promise.all([
deps.loadSubtitleSourceText(primaryTrack.path),
deps.loadSubtitleSourceText(referenceTrack.path),
]);
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
} catch (error) {
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
return null;
}
}
function saveEstimatedSubtitleDelay(
deps: {
saveSubtitleDelay?: (
itemId: string,
streamIndex: number,
delaySeconds: number,
) => boolean | void;
logDebug: (message: string, error: unknown) => void;
},
key: JellyfinSubtitleDelayKey,
delaySeconds: number,
): void {
try {
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
if (saved === false) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
}
} catch (error) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
}
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -240,11 +316,21 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
let preloadQueue: Promise<void> = Promise.resolve();
function resetManagedSubtitleDelay(): void {
if (deps.getSavedSubtitleDelay) {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
}
}
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
if (dirs.length === 0) return;
@@ -275,6 +361,10 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
return;
}
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
await deps.wait(300);
const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = [];
@@ -310,18 +400,55 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
return;
}
const resolvedSubtitleTracks = subtitleTracks ?? [];
const japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks ?? [], isJapanese);
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ??
pickBestTrackId(resolvedSubtitleTracks, isJapanese);
const englishSecondaryId =
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId);
if (japanesePrimaryId !== null) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
const selectedCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
japanesePrimaryId,
);
if (selectedCachedTrack) {
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
deps.setActiveSubtitleDelayKey?.(delayKey);
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
} else {
const referenceCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
englishSecondaryId,
);
const estimatedDelay = await estimateSubtitleDelayFromReference(
deps,
selectedCachedTrack,
referenceCachedTrack,
);
if (estimatedDelay !== null) {
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
} else {
resetManagedSubtitleDelay();
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
}
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
}
const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks ?? [], cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks ?? [], isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
}
@@ -11,6 +11,7 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
bindEventHandlers: (client: TClient) => void;
@@ -26,6 +27,9 @@ export function createBuildMpvClientRuntimeServiceFactoryDepsHandler<
getReconnectTimer: () => deps.getReconnectTimer(),
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) =>
deps.setReconnectTimer(timer),
shouldAutoLoadSecondarySubTrack: deps.shouldAutoLoadSecondarySubTrack
? (path: string) => deps.shouldAutoLoadSecondarySubTrack?.(path) ?? true
: undefined,
shouldQuitOnMpvShutdown: () => deps.shouldQuitOnMpvShutdown?.() ?? false,
requestAppQuit: () => deps.requestAppQuit?.(),
},
@@ -7,6 +7,7 @@ export type MpvClientRuntimeServiceOptions = {
isVisibleOverlayVisible: () => boolean;
getReconnectTimer: () => ReturnType<typeof setTimeout> | null;
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
shouldQuitOnMpvShutdown?: () => boolean;
requestAppQuit?: () => void;
};
@@ -14,10 +14,11 @@ test('apply jellyfin mpv defaults sends expected property commands', () => {
applyDefaults({ connected: true, send: () => {} });
assert.deepEqual(calls, [
'set_property:sub-auto:fuzzy',
'set_property:sub-auto:no',
'set_property:aid:auto',
'set_property:sid:auto',
'set_property:secondary-sid:auto',
'set_property:sid:no',
'set_property:secondary-sid:no',
'set_property:sub-visibility:no',
'set_property:secondary-sub-visibility:no',
'set_property:alang:ja,jp',
'set_property:slang:ja,jp',
+4 -3
View File
@@ -6,10 +6,11 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
jellyfinLangPref: string;
}) {
return (client: MpvRuntimeClientLike): void => {
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'fuzzy']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-auto', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'aid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'auto']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sid', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'secondary-sub-visibility', 'no']);
deps.sendMpvCommandRuntime(client, ['set_property', 'alang', deps.jellyfinLangPref]);
deps.sendMpvCommandRuntime(client, ['set_property', 'slang', deps.jellyfinLangPref]);
@@ -10,6 +10,9 @@ export function createBuildSetVisibleOverlayVisibleMainDepsHandler(
deps: SetVisibleOverlayVisibleMainDeps,
) {
return (): SetVisibleOverlayVisibleMainDeps => ({
getVisibleOverlayVisible: deps.getVisibleOverlayVisible
? () => deps.getVisibleOverlayVisible?.() ?? false
: undefined,
setVisibleOverlayVisibleCore: (options) => deps.setVisibleOverlayVisibleCore(options),
setVisibleOverlayVisibleState: (visible: boolean) =>
deps.setVisibleOverlayVisibleState(visible),
@@ -8,9 +8,12 @@ import {
test('set visible overlay handler forwards dependencies to core', () => {
const calls: string[] = [];
let warmupStarts = 0;
let currentVisible = false;
const setVisible = createSetVisibleOverlayVisibleHandler({
getVisibleOverlayVisible: () => currentVisible,
setVisibleOverlayVisibleCore: (options) => {
calls.push(`core:${options.visible}`);
currentVisible = options.visible;
options.setVisibleOverlayVisibleState(options.visible);
options.updateVisibleOverlayVisibility();
},
@@ -25,6 +28,10 @@ test('set visible overlay handler forwards dependencies to core', () => {
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
assert.equal(warmupStarts, 1);
setVisible(true);
assert.deepEqual(calls, ['core:true', 'state:true', 'update-visible']);
assert.equal(warmupStarts, 1);
setVisible(false);
assert.equal(warmupStarts, 1);
});
@@ -1,4 +1,5 @@
export function createSetVisibleOverlayVisibleHandler(deps: {
getVisibleOverlayVisible?: () => boolean;
setVisibleOverlayVisibleCore: (options: {
visible: boolean;
setVisibleOverlayVisibleState: (visible: boolean) => void;
@@ -9,6 +10,9 @@ export function createSetVisibleOverlayVisibleHandler(deps: {
onVisibleOverlayEnabled?: () => void;
}) {
return (visible: boolean): void => {
if (deps.getVisibleOverlayVisible?.() === visible) {
return;
}
if (visible) {
deps.onVisibleOverlayEnabled?.();
}
@@ -27,6 +27,8 @@ test('overlay visibility runtime wires set/toggle handlers through composed deps
runtime.setVisibleOverlayVisible(true);
assert.equal(visible, true);
runtime.setVisibleOverlayVisible(true);
assert.equal(setVisibleCoreCalls, 1);
runtime.toggleVisibleOverlay();
assert.equal(visible, false);
@@ -22,9 +22,10 @@ export type OverlayVisibilityRuntimeDeps = {
};
export function createOverlayVisibilityRuntime(deps: OverlayVisibilityRuntimeDeps) {
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler(
deps.setVisibleOverlayVisibleDeps,
)();
const setVisibleOverlayVisibleMainDeps = createBuildSetVisibleOverlayVisibleMainDepsHandler({
...deps.setVisibleOverlayVisibleDeps,
getVisibleOverlayVisible: deps.getVisibleOverlayVisible,
})();
const setVisibleOverlayVisible = createSetVisibleOverlayVisibleHandler(
setVisibleOverlayVisibleMainDeps,
);