mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-23 17:16:19 -07:00
feat(overlay): add subtitle selection modal and Jellyfin 12 fixes
- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling - Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info - Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher - Bump electron-builder to 26.16.1 - Condense and reconcile changelog fragments; update config example and docs
This commit is contained in:
@@ -1545,3 +1545,25 @@ test('Anki metadata rejects a credential-bearing media title before metadata arr
|
||||
const result = privateApi.formatMiscInfoPattern('stream?api_key=test-secret', 426);
|
||||
assert.equal(result, '[SubMiner] Unknown media | Unknown media (00:07:06)');
|
||||
});
|
||||
|
||||
test('AnkiIntegration.formatMiscInfoPattern treats ApiKey stream paths like legacy api_key ones', () => {
|
||||
const integration = new AnkiIntegration(
|
||||
{ metadata: { pattern: '[SubMiner] %f (%t)' } } as never,
|
||||
{} as never,
|
||||
{
|
||||
currentSubText: '',
|
||||
currentVideoPath: 'stream?static=true&ApiKey=secret-token&MediaSourceId=ms-1',
|
||||
currentTimePos: 426,
|
||||
currentSubStart: 426,
|
||||
currentSubEnd: 428,
|
||||
currentMediaTitle: '[Jellyfin/direct] Bocchi the Rock! - S01E02',
|
||||
send: () => true,
|
||||
} as unknown as never,
|
||||
);
|
||||
const privateApi = integration as unknown as {
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
};
|
||||
const result = privateApi.formatMiscInfoPattern('audio_123.mp3', 426);
|
||||
assert.equal(result, '[SubMiner] [Jellyfin/direct] Bocchi the Rock! - S01E02 (00:07:06)');
|
||||
assert.equal(result.includes('ApiKey='), false);
|
||||
});
|
||||
|
||||
@@ -185,7 +185,7 @@ function extractFilenameFromMediaPath(rawPath: string): string {
|
||||
function shouldPreferMediaTitleForMiscInfo(rawPath: string, filename: string): boolean {
|
||||
const loweredPath = rawPath.toLowerCase();
|
||||
const loweredFilename = filename.toLowerCase();
|
||||
if (loweredPath.includes('api_key=')) {
|
||||
if (loweredPath.includes('api_key=') || loweredPath.includes('apikey=')) {
|
||||
return true;
|
||||
}
|
||||
if (loweredPath.startsWith('http://') || loweredPath.startsWith('https://')) {
|
||||
|
||||
@@ -58,6 +58,7 @@ const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
|
||||
const { stats } = STATS_DEFAULT_CONFIG;
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitleSelection: { enabled: false },
|
||||
subtitleGeneration: { ...DEFAULT_SUBTITLE_GENERATION_CONFIG },
|
||||
dictionaryBackend,
|
||||
hachidori,
|
||||
|
||||
@@ -103,6 +103,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||
openJimaku: 'Ctrl+Shift+J',
|
||||
openTsukihime: 'Ctrl+Shift+T',
|
||||
openSubtitleSelection: 'g-s',
|
||||
openSubtitleGeneration: 'Ctrl+Shift+G',
|
||||
openSessionHelp: 'CommandOrControl+Slash',
|
||||
openControllerSelect: 'Alt+C',
|
||||
|
||||
@@ -642,6 +642,13 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.shortcuts.openSessionHelp,
|
||||
description: 'Accelerator that opens the session help / keybinding cheatsheet.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleSelection',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.shortcuts.openSubtitleSelection,
|
||||
description:
|
||||
'Open subtitle selection when enabled. Use g-s to press g then s. Set null to unbind.',
|
||||
},
|
||||
{
|
||||
path: 'shortcuts.openSubtitleGeneration',
|
||||
kind: 'string',
|
||||
|
||||
@@ -6,6 +6,13 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultConfig: ResolvedConfig,
|
||||
): ConfigOptionRegistryEntry[] {
|
||||
return [
|
||||
{
|
||||
path: 'subtitleSelection.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleSelection.enabled,
|
||||
description:
|
||||
'Use the SubMiner modal to select primary and secondary subtitle tracks. When enabled, its shortcut overrides mpv subtitle selection.',
|
||||
},
|
||||
...(
|
||||
['whisperPath', 'modelPath', 'ffmpegPath', 'ffprobePath', 'vadModelPath', 'vadPath'] as const
|
||||
).map((key) => ({
|
||||
|
||||
@@ -17,6 +17,12 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
notes: ['Used only while Hachidori is linked to an external host.'],
|
||||
key: 'hachidori',
|
||||
},
|
||||
{
|
||||
title: 'Subtitle Selection',
|
||||
description: ['Select primary and secondary mpv subtitle tracks from the overlay.'],
|
||||
notes: ['Hot-reload: enabling or disabling updates the session shortcut immediately.'],
|
||||
key: 'subtitleSelection',
|
||||
},
|
||||
{
|
||||
title: 'Japanese Subtitle Generation',
|
||||
description: [
|
||||
|
||||
@@ -2,7 +2,13 @@ function pathStartsWith(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}.`);
|
||||
}
|
||||
|
||||
const HOT_RELOAD_ROOTS = ['subtitleStyle', 'keybindings', 'shortcuts', 'subtitleSidebar'] as const;
|
||||
const HOT_RELOAD_ROOTS = [
|
||||
'subtitleStyle',
|
||||
'keybindings',
|
||||
'shortcuts',
|
||||
'subtitleSidebar',
|
||||
'subtitleSelection',
|
||||
] as const;
|
||||
|
||||
const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
||||
'secondarySub.defaultMode',
|
||||
|
||||
@@ -33,6 +33,25 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.subtitleSelection)) {
|
||||
const enabled = asBoolean(src.subtitleSelection.enabled);
|
||||
if (enabled !== undefined) resolved.subtitleSelection.enabled = enabled;
|
||||
else if (src.subtitleSelection.enabled !== undefined)
|
||||
warn(
|
||||
'subtitleSelection.enabled',
|
||||
src.subtitleSelection.enabled,
|
||||
resolved.subtitleSelection.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
} else if (src.subtitleSelection !== undefined) {
|
||||
warn(
|
||||
'subtitleSelection',
|
||||
src.subtitleSelection,
|
||||
resolved.subtitleSelection,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
@@ -264,6 +283,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
'openRuntimeOptions',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openSessionHelp',
|
||||
'openControllerSelect',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveConfig } from '../resolve';
|
||||
import { DEFAULT_CONFIG } from '../definitions';
|
||||
import { buildConfigSettingsRegistry } from '../settings/registry';
|
||||
import { resolveConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import {
|
||||
compileSessionBindings,
|
||||
buildPluginSessionBindingsArtifact,
|
||||
} from '../../core/services/session-bindings';
|
||||
|
||||
function bindings(config: ReturnType<typeof resolveConfig>['resolved']) {
|
||||
return compileSessionBindings({
|
||||
shortcuts: resolveConfiguredShortcuts(config, DEFAULT_CONFIG),
|
||||
keybindings: config.keybindings,
|
||||
platform: 'linux',
|
||||
});
|
||||
}
|
||||
|
||||
test('subtitle selection is opt-in and enabling it compiles g-s for mpv and the overlay', () => {
|
||||
const defaults = resolveConfig({}).resolved;
|
||||
assert.equal(defaults.subtitleSelection.enabled, false);
|
||||
assert.equal(defaults.shortcuts.openSubtitleSelection, 'g-s');
|
||||
const find = (config: typeof defaults) =>
|
||||
bindings(config).bindings.find(
|
||||
(binding) =>
|
||||
binding.actionType === 'session-action' && binding.actionId === 'openSubtitleSelection',
|
||||
);
|
||||
assert.equal(find(defaults), undefined);
|
||||
const enabled = resolveConfig({ subtitleSelection: { enabled: true } }).resolved;
|
||||
const binding = find(enabled);
|
||||
assert.ok(binding);
|
||||
assert.deepEqual(binding.key, { code: 'KeyG-KeyS', modifiers: [] });
|
||||
assert.equal(bindings(enabled).warnings.length, 0);
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings: [binding],
|
||||
warnings: [],
|
||||
numericSelectionTimeoutMs: 1000,
|
||||
});
|
||||
assert.deepEqual(artifact.bindings[0], {
|
||||
...binding,
|
||||
cliArgs: ['--session-action', '{"actionId":"openSubtitleSelection"}'],
|
||||
});
|
||||
enabled.subtitleSelection.enabled = false;
|
||||
assert.equal(find(enabled), undefined);
|
||||
});
|
||||
|
||||
test('subtitle selection settings are validated, hot reloadable, and the shortcut can be cleared', () => {
|
||||
// @ts-expect-error Config files can contain invalid values at runtime.
|
||||
const { resolved, warnings } = resolveConfig({ subtitleSelection: { enabled: 'yes' } });
|
||||
assert.equal(resolved.subtitleSelection.enabled, false);
|
||||
assert.equal(warnings.length, 1);
|
||||
const field = buildConfigSettingsRegistry(resolved).find(
|
||||
(entry) => entry.configPath === 'subtitleSelection.enabled',
|
||||
);
|
||||
assert.equal(field?.category, 'behavior');
|
||||
assert.equal(field?.restartBehavior, 'hot-reload');
|
||||
const cleared = resolveConfig({
|
||||
subtitleSelection: { enabled: true },
|
||||
shortcuts: { openSubtitleSelection: null },
|
||||
}).resolved;
|
||||
assert.equal(resolveConfiguredShortcuts(cleared, DEFAULT_CONFIG).openSubtitleSelection, null);
|
||||
});
|
||||
@@ -459,6 +459,9 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('subsync.')) {
|
||||
return { category: 'integrations', section: topSection(path) };
|
||||
}
|
||||
if (path.startsWith('subtitleSelection.')) {
|
||||
return { category: 'behavior', section: 'Subtitle Selection' };
|
||||
}
|
||||
if (path.startsWith('subtitleGeneration.')) {
|
||||
return { category: 'integrations', section: 'Japanese Subtitle Generation' };
|
||||
}
|
||||
@@ -635,6 +638,7 @@ function subsectionForPath(path: string): string | undefined {
|
||||
leaf === 'openRuntimeOptions' ||
|
||||
leaf === 'openJimaku' ||
|
||||
leaf === 'openTsukihime' ||
|
||||
leaf === 'openSubtitleSelection' ||
|
||||
leaf === 'openSubtitleGeneration' ||
|
||||
leaf === 'openSessionHelp' ||
|
||||
leaf === 'openControllerSelect' ||
|
||||
|
||||
@@ -3174,6 +3174,7 @@ test('Jellyfin metadata cleanup requires both an API key and a stream marker', a
|
||||
{ filename: 'stream?api_key=secret', leaked: true },
|
||||
{ filename: '/STREAM?API_KEY=secret', leaked: true },
|
||||
{ filename: '/Videos/item?api_key=secret', leaked: true },
|
||||
{ filename: '/Videos/item?ApiKey=secret', leaked: true },
|
||||
{ filename: 'MediaSourceId=item api key secret', leaked: true },
|
||||
{ filename: 'An API Key Story', leaked: false },
|
||||
{ filename: 'api_key=ordinary-metadata', leaked: false },
|
||||
|
||||
@@ -376,6 +376,7 @@ function buildJellyfinStatsMediaPath(mediaPath: string, itemId: string): string
|
||||
|
||||
const JELLYFIN_MEDIA_ALIAS_QUERY_KEYS = [
|
||||
'api_key',
|
||||
'ApiKey',
|
||||
'StartTimeTicks',
|
||||
'AudioStreamIndex',
|
||||
'SubtitleStreamIndex',
|
||||
|
||||
@@ -82,7 +82,7 @@ function parseLegacyJellyfinStreamUrl(value: string | null): URL | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!url.searchParams.has('api_key')) {
|
||||
if (!url.searchParams.has('api_key') && !url.searchParams.has('ApiKey')) {
|
||||
return null;
|
||||
}
|
||||
return url;
|
||||
@@ -130,13 +130,13 @@ function repairLeakedJellyfinAnimeTitles(db: DatabaseSync, currentTimestamp: str
|
||||
SELECT v.canonical_title
|
||||
FROM imm_videos v
|
||||
WHERE v.anime_id = a.anime_id
|
||||
AND v.canonical_title NOT LIKE '%api_key=%'
|
||||
AND v.canonical_title NOT LIKE '%api_key=%' AND v.canonical_title NOT LIKE '%ApiKey=%'
|
||||
AND lower(v.canonical_title) NOT LIKE '%api key%'
|
||||
ORDER BY v.LAST_UPDATE_DATE DESC, v.video_id DESC
|
||||
LIMIT 1
|
||||
) AS linked_video_title
|
||||
FROM imm_anime a
|
||||
WHERE a.canonical_title LIKE '%api_key=%'
|
||||
WHERE a.canonical_title LIKE '%api_key=%' OR a.canonical_title LIKE '%ApiKey=%'
|
||||
OR lower(a.canonical_title) LIKE '%api key%'
|
||||
OR lower(a.normalized_title_key) LIKE '%api key%'
|
||||
`,
|
||||
@@ -244,11 +244,11 @@ function repairLeakedJellyfinVideoParseMetadata(
|
||||
LAST_UPDATE_DATE = ?
|
||||
WHERE source_type = 2
|
||||
AND (
|
||||
parsed_basename LIKE '%api_key=%'
|
||||
parsed_basename LIKE '%api_key=%' OR parsed_basename LIKE '%ApiKey=%'
|
||||
OR lower(parsed_basename) LIKE '%api key%'
|
||||
OR parsed_title LIKE '%api_key=%'
|
||||
OR parsed_title LIKE '%api_key=%' OR parsed_title LIKE '%ApiKey=%'
|
||||
OR lower(parsed_title) LIKE '%api key%'
|
||||
OR parse_metadata_json LIKE '%api_key=%'
|
||||
OR parse_metadata_json LIKE '%api_key=%' OR parse_metadata_json LIKE '%ApiKey=%'
|
||||
OR lower(parse_metadata_json) LIKE '%api key%'
|
||||
)
|
||||
`,
|
||||
@@ -267,7 +267,7 @@ function repairLeakedJellyfinAnimeParseMetadata(
|
||||
UPDATE imm_anime
|
||||
SET metadata_json = NULL, LAST_UPDATE_DATE = ?
|
||||
WHERE (
|
||||
metadata_json LIKE '%api_key=%'
|
||||
metadata_json LIKE '%api_key=%' OR metadata_json LIKE '%ApiKey=%'
|
||||
OR lower(metadata_json) LIKE '%api key%'
|
||||
) AND (
|
||||
lower(metadata_json) LIKE '%stream?%'
|
||||
@@ -295,11 +295,11 @@ export function repairJellyfinStreamVideoLinks(db: DatabaseSync): JellyfinLinkRe
|
||||
FROM imm_videos
|
||||
WHERE source_type = 2
|
||||
AND (
|
||||
video_key LIKE '%api_key=%'
|
||||
video_key LIKE '%api_key=%' OR video_key LIKE '%ApiKey=%'
|
||||
OR lower(video_key) LIKE '%api key%'
|
||||
OR source_url LIKE '%api_key=%'
|
||||
OR source_url LIKE '%api_key=%' OR source_url LIKE '%ApiKey=%'
|
||||
OR lower(source_url) LIKE '%api key%'
|
||||
OR canonical_title LIKE '%api_key=%'
|
||||
OR canonical_title LIKE '%api_key=%' OR canonical_title LIKE '%ApiKey=%'
|
||||
OR lower(canonical_title) LIKE '%api key%'
|
||||
)
|
||||
`,
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface IpcServiceDeps {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
@@ -378,7 +378,7 @@ export interface IpcDepsRuntimeOptions {
|
||||
handleMpvCommand: (command: Array<string | number>) => void;
|
||||
getKeybindings: () => unknown;
|
||||
getMpvInputBindings?: () => Promise<MpvInputBindingsSnapshot>;
|
||||
getSessionBindings?: () => CompiledSessionBinding[];
|
||||
getSessionBindings?: () => CompiledSessionBinding[] | Promise<CompiledSessionBinding[]>;
|
||||
getConfiguredShortcuts: () => unknown;
|
||||
dispatchSessionAction?: (request: SessionActionDispatchRequest) => void | Promise<void>;
|
||||
getStatsToggleKey: () => string;
|
||||
|
||||
@@ -4,6 +4,17 @@ import { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './je
|
||||
|
||||
class FakeWebSocket {
|
||||
private listeners: Record<string, Array<(...args: unknown[]) => void>> = {};
|
||||
sent: string[] = [];
|
||||
terminated = false;
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
this.emit('close');
|
||||
}
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void): this {
|
||||
if (!this.listeners[event]) {
|
||||
@@ -58,7 +69,7 @@ test('start posts capabilities on socket connect', async () => {
|
||||
accessToken: 'token-1',
|
||||
deviceId: 'device-1',
|
||||
webSocketFactory: (url) => {
|
||||
assert.equal(url, 'ws://jellyfin.local:8096/socket?api_key=token-1&deviceId=device-1');
|
||||
assert.equal(url, 'ws://jellyfin.local:8096/socket?ApiKey=token-1&deviceId=device-1');
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
@@ -99,7 +110,8 @@ test('socket headers include jellyfin authorization metadata', () => {
|
||||
assert.equal(seenHeaders.length, 1);
|
||||
assert.ok(seenHeaders[0]!['Authorization']!.includes('Client="SubMiner"'));
|
||||
assert.ok(seenHeaders[0]!['Authorization']!.includes('DeviceId="device-auth"'));
|
||||
assert.ok(seenHeaders[0]!['X-Emby-Authorization']);
|
||||
assert.equal('X-Emby-Authorization' in seenHeaders[0]!, false);
|
||||
assert.equal('X-Emby-Token' in seenHeaders[0]!, false);
|
||||
});
|
||||
|
||||
test('dispatches inbound Play, Playstate, and GeneralCommand messages', () => {
|
||||
@@ -355,3 +367,149 @@ test('advertiseNow validates server registration using Sessions endpoint', async
|
||||
assert.equal(ok, true);
|
||||
assert.ok(calls.some((url) => url.endsWith('/Sessions')));
|
||||
});
|
||||
|
||||
test('answers ForceKeepAlive with KeepAlive messages on the advertised cadence', () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const timers: Array<{ handler: () => void; delay: number }> = [];
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-ka',
|
||||
deviceId: 'device-ka',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
||||
setTimer: ((handler: () => void, delay?: number) => {
|
||||
timers.push({ handler, delay: Number(delay) });
|
||||
return timers.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimer: (() => undefined) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
service.start();
|
||||
sockets[0]!.emit('open');
|
||||
assert.deepEqual(sockets[0]!.sent, ['{"MessageType":"KeepAlive"}']);
|
||||
assert.equal(timers[0]!.delay, 30_000);
|
||||
|
||||
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 20 }));
|
||||
assert.equal(sockets[0]!.sent.length, 2);
|
||||
assert.equal(timers.at(-1)!.delay, 10_000);
|
||||
|
||||
timers.at(-1)!.handler();
|
||||
assert.equal(sockets[0]!.sent.length, 3);
|
||||
});
|
||||
|
||||
test('reconnects when the server stops answering keep-alives', () => {
|
||||
let now = 1_000_000;
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const timers: Array<() => void> = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-lost',
|
||||
deviceId: 'device-lost',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
||||
getNow: () => now,
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
reconnectBaseDelayMs: 100,
|
||||
setTimer: ((handler: () => void) => {
|
||||
timers.push(handler);
|
||||
return timers.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimer: (() => undefined) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
service.start();
|
||||
sockets[0]!.emit('open');
|
||||
|
||||
// Two silent ticks are still within the 90s tolerance; the third marks the socket lost.
|
||||
now += 30_000;
|
||||
timers.shift()!();
|
||||
now += 30_000;
|
||||
timers.shift()!();
|
||||
assert.equal(sockets[0]!.sent.length, 3);
|
||||
assert.equal(sockets[0]!.terminated, false);
|
||||
|
||||
now += 30_000;
|
||||
timers.shift()!();
|
||||
assert.equal(sockets[0]!.terminated, true);
|
||||
assert.equal(service.isConnected(), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
|
||||
timers.shift()!();
|
||||
assert.equal(sockets.length, 2);
|
||||
});
|
||||
|
||||
test('warns once per failing timeline endpoint until it recovers', async () => {
|
||||
const warnings: string[] = [];
|
||||
let status = 400;
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-warn',
|
||||
deviceId: 'device-warn',
|
||||
webSocketFactory: () => new FakeWebSocket() as unknown as any,
|
||||
fetchImpl: (async () => new Response(null, { status })) as typeof fetch,
|
||||
logWarn: (message) => {
|
||||
warnings.push(message);
|
||||
},
|
||||
});
|
||||
const state = { itemId: 'item-1', positionTicks: 10, playMethod: 'DirectPlay' };
|
||||
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /Sessions\/Playing\/Stopped/);
|
||||
|
||||
status = 200;
|
||||
assert.equal(await service.reportStopped(state), true);
|
||||
status = 500;
|
||||
assert.equal(await service.reportStopped(state), false);
|
||||
assert.equal(warnings.length, 2);
|
||||
});
|
||||
|
||||
test('ignores messages from a superseded socket', () => {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
const playPayloads: unknown[] = [];
|
||||
|
||||
const service = new JellyfinRemoteSessionService({
|
||||
serverUrl: 'http://jellyfin.local',
|
||||
accessToken: 'token-stale',
|
||||
deviceId: 'device-stale',
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as any;
|
||||
},
|
||||
fetchImpl: (async () => new Response(null, { status: 200 })) as typeof fetch,
|
||||
onPlay: (payload) => {
|
||||
playPayloads.push(payload);
|
||||
},
|
||||
setTimer: (() => 1 as unknown as ReturnType<typeof setTimeout>) as unknown as typeof setTimeout,
|
||||
clearTimer: (() => undefined) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
service.start();
|
||||
service.stop();
|
||||
service.start();
|
||||
sockets[1]!.emit('open');
|
||||
assert.equal(sockets.length, 2);
|
||||
|
||||
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'ForceKeepAlive', Data: 10 }));
|
||||
sockets[0]!.emit('message', JSON.stringify({ MessageType: 'Play', Data: { ItemIds: ['x'] } }));
|
||||
|
||||
assert.deepEqual(sockets[0]!.sent, []);
|
||||
assert.deepEqual(playPayloads, []);
|
||||
assert.deepEqual(sockets[1]!.sent, ['{"MessageType":"KeepAlive"}']);
|
||||
});
|
||||
|
||||
@@ -45,9 +45,22 @@ interface JellyfinRemoteSocket {
|
||||
on(event: 'close', listener: () => void): this;
|
||||
on(event: 'error', listener: (error: Error) => void): this;
|
||||
on(event: 'message', listener: (data: unknown) => void): this;
|
||||
send(data: string): void;
|
||||
terminate?(): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// Jellyfin advertises its keep-alive timeout in the ForceKeepAlive message (60s by default),
|
||||
// drops sockets that stay silent past it, and since 12.0 also detaches the session's remote
|
||||
// controller when that happens. The drop never reaches the client as a close frame, so the
|
||||
// client has to keep sending KeepAlive and treat missing replies as a dead connection.
|
||||
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 60_000;
|
||||
const KEEP_ALIVE_LOST_FACTOR = 1.5;
|
||||
|
||||
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
|
||||
(timer as unknown as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
type JellyfinRemoteSocketHeaders = Record<string, string>;
|
||||
|
||||
export interface JellyfinRemoteSessionServiceOptions {
|
||||
@@ -77,6 +90,9 @@ export interface JellyfinRemoteSessionServiceOptions {
|
||||
deviceName?: string;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
keepAliveTimeoutMs?: number;
|
||||
getNow?: () => number;
|
||||
}
|
||||
|
||||
function normalizeServerUrl(serverUrl: string): string {
|
||||
@@ -196,6 +212,12 @@ export class JellyfinRemoteSessionService {
|
||||
private readonly authHeader: string;
|
||||
private readonly onConnected?: () => void;
|
||||
private readonly onDisconnected?: () => void;
|
||||
private readonly logWarn?: (message: string, details?: unknown) => void;
|
||||
private readonly now: () => number;
|
||||
private keepAliveTimeoutMs: number;
|
||||
private keepAliveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastInboundAtMs = 0;
|
||||
private readonly failedRequestPaths = new Set<string>();
|
||||
|
||||
private readonly reconnectBaseDelayMs: number;
|
||||
private readonly reconnectMaxDelayMs: number;
|
||||
@@ -233,6 +255,12 @@ export class JellyfinRemoteSessionService {
|
||||
});
|
||||
this.onConnected = options.onConnected;
|
||||
this.onDisconnected = options.onDisconnected;
|
||||
this.logWarn = options.logWarn;
|
||||
this.now = options.getNow ?? Date.now;
|
||||
this.keepAliveTimeoutMs = Math.max(
|
||||
1000,
|
||||
options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS,
|
||||
);
|
||||
this.reconnectBaseDelayMs = Math.max(100, options.reconnectBaseDelayMs ?? 500);
|
||||
this.reconnectMaxDelayMs = Math.max(
|
||||
this.reconnectBaseDelayMs,
|
||||
@@ -250,6 +278,7 @@ export class JellyfinRemoteSessionService {
|
||||
public stop(): void {
|
||||
this.running = false;
|
||||
this.connected = false;
|
||||
this.stopKeepAlive();
|
||||
if (this.reconnectTimer) {
|
||||
this.clearTimer(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
@@ -298,12 +327,16 @@ export class JellyfinRemoteSessionService {
|
||||
if (this.socket !== socket || !this.running) return;
|
||||
this.connected = true;
|
||||
this.reconnectAttempt = 0;
|
||||
this.lastInboundAtMs = this.now();
|
||||
this.startKeepAlive(socket, this.keepAliveTimeoutMs);
|
||||
this.onConnected?.();
|
||||
void this.postCapabilities();
|
||||
});
|
||||
|
||||
socket.on('message', (rawData) => {
|
||||
this.handleInboundMessage(rawData);
|
||||
if (this.socket !== socket || !this.running) return;
|
||||
this.lastInboundAtMs = this.now();
|
||||
this.handleInboundMessage(socket, rawData);
|
||||
});
|
||||
|
||||
const handleDisconnect = () => {
|
||||
@@ -311,6 +344,7 @@ export class JellyfinRemoteSessionService {
|
||||
disconnected = true;
|
||||
if (this.socket === socket) {
|
||||
this.socket = null;
|
||||
this.stopKeepAlive();
|
||||
}
|
||||
this.connected = false;
|
||||
this.onDisconnected?.();
|
||||
@@ -323,6 +357,51 @@ export class JellyfinRemoteSessionService {
|
||||
socket.on('error', handleDisconnect);
|
||||
}
|
||||
|
||||
private startKeepAlive(socket: JellyfinRemoteSocket, timeoutMs: number): void {
|
||||
this.stopKeepAlive();
|
||||
this.keepAliveTimeoutMs = timeoutMs;
|
||||
this.sendKeepAlive(socket);
|
||||
this.scheduleKeepAliveTick(socket);
|
||||
}
|
||||
|
||||
private scheduleKeepAliveTick(socket: JellyfinRemoteSocket): void {
|
||||
const intervalMs = Math.max(1000, Math.floor(this.keepAliveTimeoutMs / 2));
|
||||
const timer = this.setTimer(() => {
|
||||
this.keepAliveTimer = null;
|
||||
if (this.socket !== socket || !this.running) return;
|
||||
const silentForMs = this.now() - this.lastInboundAtMs;
|
||||
if (silentForMs >= this.keepAliveTimeoutMs * KEEP_ALIVE_LOST_FACTOR) {
|
||||
this.logWarn?.('Jellyfin remote websocket stopped answering keep-alives; reconnecting.');
|
||||
// Dropping the socket raises 'close', which schedules the reconnect.
|
||||
if (socket.terminate) {
|
||||
socket.terminate();
|
||||
} else {
|
||||
socket.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.sendKeepAlive(socket);
|
||||
this.scheduleKeepAliveTick(socket);
|
||||
}, intervalMs);
|
||||
unrefTimer(timer);
|
||||
this.keepAliveTimer = timer;
|
||||
}
|
||||
|
||||
private stopKeepAlive(): void {
|
||||
if (this.keepAliveTimer) {
|
||||
this.clearTimer(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private sendKeepAlive(socket: JellyfinRemoteSocket): void {
|
||||
try {
|
||||
socket.send(JSON.stringify({ MessageType: 'KeepAlive' }));
|
||||
} catch (error) {
|
||||
this.logWarn?.('Failed to send Jellyfin remote keep-alive.', error);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
const delay = Math.min(
|
||||
this.reconnectMaxDelayMs,
|
||||
@@ -342,7 +421,7 @@ export class JellyfinRemoteSessionService {
|
||||
const baseUrl = new URL(`${this.serverUrl}/`);
|
||||
const socketUrl = new URL('/socket', baseUrl);
|
||||
socketUrl.protocol = baseUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socketUrl.searchParams.set('api_key', this.accessToken);
|
||||
socketUrl.searchParams.set('ApiKey', this.accessToken);
|
||||
socketUrl.searchParams.set('deviceId', this.deviceId);
|
||||
return socketUrl.toString();
|
||||
}
|
||||
@@ -350,8 +429,6 @@ export class JellyfinRemoteSessionService {
|
||||
private createSocket(url: string): JellyfinRemoteSocket {
|
||||
const headers: JellyfinRemoteSocketHeaders = {
|
||||
Authorization: this.authHeader,
|
||||
'X-Emby-Authorization': this.authHeader,
|
||||
'X-Emby-Token': this.accessToken,
|
||||
};
|
||||
if (this.socketHeadersFactory) {
|
||||
return this.socketHeadersFactory(url, headers);
|
||||
@@ -375,8 +452,6 @@ export class JellyfinRemoteSessionService {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: this.authHeader,
|
||||
'X-Emby-Authorization': this.authHeader,
|
||||
'X-Emby-Token': this.accessToken,
|
||||
},
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
@@ -398,21 +473,41 @@ export class JellyfinRemoteSessionService {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
'X-Emby-Authorization': this.authHeader,
|
||||
'X-Emby-Token': this.accessToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
this.noteRequestOutcome(path, response.ok ? null : `HTTP ${response.status}`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
this.noteRequestOutcome(path, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleInboundMessage(rawData: unknown): void {
|
||||
// Warn once per path while it keeps failing so a rejected stop report is visible in the
|
||||
// log without a warning per progress tick.
|
||||
private noteRequestOutcome(path: string, failure: unknown): void {
|
||||
if (failure === null) {
|
||||
this.failedRequestPaths.delete(path);
|
||||
return;
|
||||
}
|
||||
if (this.failedRequestPaths.has(path)) return;
|
||||
this.failedRequestPaths.add(path);
|
||||
this.logWarn?.(`Jellyfin remote request failed: POST ${path}`, failure);
|
||||
}
|
||||
|
||||
private handleInboundMessage(socket: JellyfinRemoteSocket, rawData: unknown): void {
|
||||
const message = parseInboundMessage(rawData);
|
||||
if (!message) return;
|
||||
const messageType = message.MessageType;
|
||||
if (messageType === 'ForceKeepAlive') {
|
||||
const seconds = Number(message.Data);
|
||||
const timeoutMs =
|
||||
Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : this.keepAliveTimeoutMs;
|
||||
this.startKeepAlive(socket, timeoutMs);
|
||||
return;
|
||||
}
|
||||
if (messageType === 'KeepAlive') return;
|
||||
const payload = parseMessageData(message.Data);
|
||||
if (messageType === 'Play') {
|
||||
this.onPlay?.(payload);
|
||||
|
||||
@@ -279,7 +279,7 @@ test('resolvePlaybackPlan prefers transcode when directPlayPreferred is disabled
|
||||
assert.equal(plan.mode, 'transcode');
|
||||
const url = new URL(plan.url);
|
||||
assert.match(url.pathname, /\/Videos\/movie-2\/master\.m3u8$/);
|
||||
assert.equal(url.searchParams.get('api_key'), 'token');
|
||||
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '4');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), '10000000');
|
||||
} finally {
|
||||
@@ -365,7 +365,7 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
|
||||
IsForced: true,
|
||||
IsExternal: true,
|
||||
DeliveryMethod: 'External',
|
||||
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt',
|
||||
DeliveryUrl: '/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=server-token',
|
||||
IsExternalUrl: false,
|
||||
},
|
||||
{
|
||||
@@ -402,11 +402,11 @@ test('listSubtitleTracks returns all subtitle streams with delivery urls', async
|
||||
);
|
||||
assert.equal(
|
||||
tracks[0]!.deliveryUrl,
|
||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?api_key=token',
|
||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/2/Stream.srt?ApiKey=token',
|
||||
);
|
||||
assert.equal(
|
||||
tracks[1]!.deliveryUrl,
|
||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?api_key=token',
|
||||
'http://jellyfin.local/Videos/movie-1/ms-1/Subtitles/3/Stream.srt?ApiKey=token',
|
||||
);
|
||||
assert.equal(tracks[2]!.deliveryUrl, 'https://cdn.example.com/subs.srt');
|
||||
} finally {
|
||||
@@ -505,7 +505,7 @@ test('resolvePlaybackPlan reuses server transcoding url and appends missing para
|
||||
const url = new URL(plan.url);
|
||||
assert.match(url.pathname, /\/Videos\/movie-4\/master\.m3u8$/);
|
||||
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
|
||||
assert.equal(url.searchParams.get('api_key'), 'token');
|
||||
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||
assert.equal(url.searchParams.get('AudioStreamIndex'), '3');
|
||||
assert.equal(url.searchParams.get('SubtitleStreamIndex'), '8');
|
||||
assert.equal(url.searchParams.get('StartTimeTicks'), '50000000');
|
||||
@@ -626,7 +626,7 @@ test('listSubtitleTracks falls back from PlaybackInfo to item media sources', as
|
||||
assert.equal(tracks[0]!.index, 11);
|
||||
assert.equal(
|
||||
tracks[0]!.deliveryUrl,
|
||||
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?api_key=token',
|
||||
'http://jellyfin.local/Videos/movie-fallback/ms-fallback/Subtitles/11/Stream.srt?ApiKey=token',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
@@ -789,3 +789,67 @@ test('resolvePlaybackPlan surfaces no-source and no-stream fallback errors', asy
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('API requests authenticate with the MediaBrowser header only (no legacy X-Emby-Token)', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const seenHeaders: Headers[] = [];
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
seenHeaders.push(new Headers(init?.headers));
|
||||
return new Response(JSON.stringify({ Items: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
await listLibraries(
|
||||
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
|
||||
clientInfo,
|
||||
);
|
||||
assert.equal(seenHeaders.length, 1);
|
||||
const headers = seenHeaders[0]!;
|
||||
const authorization = headers.get('authorization') ?? '';
|
||||
assert.match(authorization, /^MediaBrowser /);
|
||||
assert.match(authorization, /Token="token"/);
|
||||
assert.match(authorization, /DeviceId="subminer-test"/);
|
||||
assert.equal(headers.has('x-emby-token'), false);
|
||||
assert.equal(headers.has('x-emby-authorization'), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolvePlaybackPlan replaces a legacy api_key on the server transcoding url with ApiKey', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
Id: 'movie-legacy',
|
||||
Name: 'Movie Legacy',
|
||||
MediaSources: [
|
||||
{
|
||||
Id: 'ms-legacy',
|
||||
Container: 'mkv',
|
||||
SupportsDirectStream: false,
|
||||
SupportsTranscoding: true,
|
||||
TranscodingUrl: '/Videos/movie-legacy/master.m3u8?VideoCodec=hevc&api_key=server-token',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
)) as typeof fetch;
|
||||
|
||||
try {
|
||||
const plan = await resolvePlaybackPlan(
|
||||
{ serverUrl: 'http://jellyfin.local', accessToken: 'token', userId: 'u1', username: 'kyle' },
|
||||
clientInfo,
|
||||
{ enabled: true, directPlayPreferred: true },
|
||||
{ itemId: 'movie-legacy' },
|
||||
);
|
||||
|
||||
assert.equal(plan.mode, 'transcode');
|
||||
const url = new URL(plan.url);
|
||||
assert.equal(url.searchParams.get('ApiKey'), 'token');
|
||||
assert.equal(url.searchParams.has('api_key'), false);
|
||||
assert.equal(url.searchParams.get('VideoCodec'), 'hevc');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -136,6 +136,16 @@ function getErrorMessage(error: unknown): string {
|
||||
return String(error || 'unknown error');
|
||||
}
|
||||
|
||||
// Jellyfin reads query keys case-insensitively and older servers embed the token as
|
||||
// `api_key` in the URLs they hand back, so drop every spelling before setting the one
|
||||
// form Jellyfin 12 still accepts with legacy authorization disabled.
|
||||
function setApiKeyParam(url: URL, accessToken: string): void {
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (/^api_?key$/i.test(key)) url.searchParams.delete(key);
|
||||
}
|
||||
url.searchParams.set('ApiKey', accessToken);
|
||||
}
|
||||
|
||||
function resolveDeliveryUrl(
|
||||
session: JellyfinAuthSession,
|
||||
stream: JellyfinMediaStream,
|
||||
@@ -146,9 +156,7 @@ function resolveDeliveryUrl(
|
||||
if (deliveryUrl) {
|
||||
if (stream.IsExternalUrl === true) return deliveryUrl;
|
||||
const resolved = new URL(deliveryUrl, `${session.serverUrl}/`);
|
||||
if (!resolved.searchParams.has('api_key')) {
|
||||
resolved.searchParams.set('api_key', session.accessToken);
|
||||
}
|
||||
setApiKeyParam(resolved, session.accessToken);
|
||||
return resolved.toString();
|
||||
}
|
||||
|
||||
@@ -171,9 +179,7 @@ function resolveDeliveryUrl(
|
||||
`/Videos/${encodeURIComponent(itemId)}/${encodeURIComponent(mediaSourceId)}/Subtitles/${streamIndex}/Stream.${ext}`,
|
||||
`${session.serverUrl}/`,
|
||||
);
|
||||
if (!fallback.searchParams.has('api_key')) {
|
||||
fallback.searchParams.set('api_key', session.accessToken);
|
||||
}
|
||||
setApiKeyParam(fallback, session.accessToken);
|
||||
return fallback.toString();
|
||||
}
|
||||
|
||||
@@ -197,7 +203,6 @@ async function jellyfinRequestJson<T>(
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
headers.set('Content-Type', 'application/json');
|
||||
headers.set('Authorization', createAuthorizationHeader(client, session.accessToken));
|
||||
headers.set('X-Emby-Token', session.accessToken);
|
||||
|
||||
const response = await fetch(`${session.serverUrl}${path}`, {
|
||||
...init,
|
||||
@@ -221,7 +226,7 @@ function createDirectPlayUrl(
|
||||
): string {
|
||||
const query = new URLSearchParams({
|
||||
static: 'true',
|
||||
api_key: session.accessToken,
|
||||
ApiKey: session.accessToken,
|
||||
MediaSourceId: ensureString(mediaSource.Id),
|
||||
});
|
||||
if (mediaSource.LiveStreamId) {
|
||||
@@ -245,9 +250,7 @@ function createTranscodeUrl(
|
||||
): string {
|
||||
if (mediaSource.TranscodingUrl) {
|
||||
const url = new URL(`${session.serverUrl}${mediaSource.TranscodingUrl}`);
|
||||
if (!url.searchParams.has('api_key')) {
|
||||
url.searchParams.set('api_key', session.accessToken);
|
||||
}
|
||||
setApiKeyParam(url, session.accessToken);
|
||||
if (!url.searchParams.has('AudioStreamIndex') && plan.audioStreamIndex !== null) {
|
||||
url.searchParams.set('AudioStreamIndex', String(plan.audioStreamIndex));
|
||||
}
|
||||
@@ -261,7 +264,7 @@ function createTranscodeUrl(
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
api_key: session.accessToken,
|
||||
ApiKey: session.accessToken,
|
||||
MediaSourceId: ensureString(mediaSource.Id),
|
||||
VideoCodec: ensureString(config.transcodeVideoCodec, 'h264'),
|
||||
TranscodingContainer: 'ts',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MPV_REQUEST_ID_AID,
|
||||
MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
MPV_REQUEST_ID_OSD_DIMENSIONS,
|
||||
MPV_REQUEST_ID_OSD_HEIGHT,
|
||||
MPV_REQUEST_ID_PATH,
|
||||
@@ -85,6 +86,7 @@ const MPV_INITIAL_PROPERTY_REQUESTS: Array<MpvProtocolCommand> = [
|
||||
},
|
||||
{
|
||||
command: ['get_property', 'media-title'],
|
||||
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
},
|
||||
{
|
||||
command: ['get_property', 'pause'],
|
||||
|
||||
@@ -35,6 +35,7 @@ export const MPV_REQUEST_ID_SUB_USE_MARGINS = 122;
|
||||
export const MPV_REQUEST_ID_PAUSE = 123;
|
||||
export const MPV_REQUEST_ID_TRACK_LIST_SECONDARY = 200;
|
||||
export const MPV_REQUEST_ID_TRACK_LIST_AUDIO = 201;
|
||||
export const MPV_REQUEST_ID_MEDIA_TITLE = 202;
|
||||
|
||||
export type MpvMessageParser = (message: MpvMessage) => void;
|
||||
export type MpvParseErrorHandler = (line: string, error: unknown) => void;
|
||||
@@ -335,14 +336,18 @@ export async function dispatchMpvProtocolMessage(
|
||||
} else if (msg.name === 'fullscreen') {
|
||||
deps.emitFullscreenChange({ fullscreen: asBoolean(msg.data, false) });
|
||||
} else if (msg.name === 'media-title') {
|
||||
const title = typeof msg.data === 'string' ? sanitizeMediaTitle(msg.data) : null;
|
||||
if (typeof msg.data === 'string' && msg.data.trim() && !title) return;
|
||||
deps.emitMediaTitleChange({
|
||||
title,
|
||||
});
|
||||
applyMediaTitle(deps, msg.data);
|
||||
} else if (msg.name === 'path') {
|
||||
const path = (msg.data as string) || '';
|
||||
deps.setCurrentVideoPath(path);
|
||||
// A forced title set before loadfile arrives ahead of the path change that clears the
|
||||
// cached title and never fires again, so read it back once the new path is known.
|
||||
if (path) {
|
||||
deps.sendCommand({
|
||||
command: ['get_property', 'media-title'],
|
||||
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
});
|
||||
}
|
||||
deps.emitMediaPathChange({ path });
|
||||
deps.autoLoadSecondarySubTrack(path);
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
@@ -467,6 +472,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
deps.emitSubtitleAssChange({ text: (msg.data as string) || '' });
|
||||
} else if (msg.request_id === MPV_REQUEST_ID_PATH) {
|
||||
deps.emitMediaPathChange({ path: (msg.data as string) || '' });
|
||||
} else if (msg.request_id === MPV_REQUEST_ID_MEDIA_TITLE) {
|
||||
applyMediaTitle(deps, msg.data);
|
||||
} else if (msg.request_id === MPV_REQUEST_ID_AID) {
|
||||
deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null);
|
||||
deps.syncCurrentAudioStreamIndex();
|
||||
@@ -557,6 +564,17 @@ export function asFiniteNumber(value: unknown, fallback: number): number {
|
||||
return Number.isFinite(nextValue) ? nextValue : fallback;
|
||||
}
|
||||
|
||||
// URL-derived titles (mpv falls back to the basename of a query-bearing stream URL) must not
|
||||
// replace known metadata, so they are dropped instead of cached.
|
||||
function applyMediaTitle(
|
||||
deps: Pick<MpvProtocolHandleMessageDeps, 'emitMediaTitleChange'>,
|
||||
data: unknown,
|
||||
): void {
|
||||
const title = typeof data === 'string' ? sanitizeMediaTitle(data) : null;
|
||||
if (typeof data === 'string' && data.trim() && !title) return;
|
||||
deps.emitMediaTitleChange({ title });
|
||||
}
|
||||
|
||||
export function parseVisibilityProperty(value: unknown): boolean | null {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './mpv';
|
||||
import {
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
} from './mpv-protocol';
|
||||
|
||||
@@ -135,9 +136,15 @@ test('MpvIpcClient ignores URL-derived titles without replacing known metadata',
|
||||
assert.deepEqual(titles, ['My Anime S01E02']);
|
||||
});
|
||||
|
||||
test('MpvIpcClient clears cached media title when media path changes', async () => {
|
||||
test('MpvIpcClient clears cached media title when media path changes and reads it back', async () => {
|
||||
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
|
||||
const commands: Array<{ command?: unknown[]; request_id?: number }> = [];
|
||||
(client as any).send = (command: { command?: unknown[]; request_id?: number }) => {
|
||||
commands.push(command);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A forced title (Jellyfin sets force-media-title before loadfile) arrives before the path.
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'media-title',
|
||||
@@ -148,11 +155,33 @@ test('MpvIpcClient clears cached media title when media path changes', async ()
|
||||
await invokeHandleMessage(client, {
|
||||
event: 'property-change',
|
||||
name: 'path',
|
||||
data: '/tmp/new-episode.mkv',
|
||||
data: 'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
|
||||
});
|
||||
|
||||
assert.equal(client.currentVideoPath, '/tmp/new-episode.mkv');
|
||||
assert.equal(
|
||||
client.currentVideoPath,
|
||||
'http://pve-main:8096/Videos/item/stream?static=true&ApiKey=secret',
|
||||
);
|
||||
assert.equal(client.currentMediaTitle, null);
|
||||
const titleRequest = commands.find(
|
||||
(command) => command.command?.[0] === 'get_property' && command.command?.[1] === 'media-title',
|
||||
);
|
||||
assert.equal(titleRequest?.request_id, MPV_REQUEST_ID_MEDIA_TITLE);
|
||||
|
||||
await invokeHandleMessage(client, {
|
||||
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
error: 'success',
|
||||
data: '[Jellyfin/direct] Episode 1',
|
||||
});
|
||||
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
|
||||
|
||||
// A URL-derived read-back must not poison the cache.
|
||||
await invokeHandleMessage(client, {
|
||||
request_id: MPV_REQUEST_ID_MEDIA_TITLE,
|
||||
error: 'success',
|
||||
data: 'stream?static=true&ApiKey=secret',
|
||||
});
|
||||
assert.equal(client.currentMediaTitle, '[Jellyfin/direct] Episode 1');
|
||||
});
|
||||
|
||||
test('MpvIpcClient skips secondary subtitle autoload when media path is managed', async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ function makeShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configured
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -43,6 +43,7 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
|
||||
openControllerDebug: () => calls.push('controller-debug'),
|
||||
openJimaku: () => calls.push('jimaku'),
|
||||
openTsukihime: () => calls.push('tsukihime'),
|
||||
openSubtitleSelection: () => calls.push('subtitle-selection'),
|
||||
openSubtitleGeneration: () => calls.push('subtitle-generation'),
|
||||
openYoutubeTrackPicker: () => {
|
||||
calls.push('youtube');
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface SessionActionExecutorDeps {
|
||||
openControllerDebug: () => void;
|
||||
openJimaku: () => void;
|
||||
openTsukihime: () => void;
|
||||
openSubtitleSelection: () => void;
|
||||
openSubtitleGeneration: () => void;
|
||||
openYoutubeTrackPicker: () => void | Promise<void>;
|
||||
openPlaylistBrowser: () => boolean | void | Promise<boolean | void>;
|
||||
@@ -120,6 +121,9 @@ export async function dispatchSessionAction(
|
||||
case 'openTsukihime':
|
||||
deps.openTsukihime();
|
||||
return;
|
||||
case 'openSubtitleSelection':
|
||||
deps.openSubtitleSelection();
|
||||
return;
|
||||
case 'openSubtitleGeneration':
|
||||
deps.openSubtitleGeneration();
|
||||
return;
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): Configur
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
@@ -707,3 +708,58 @@ test('buildPluginSessionBindingsArtifact preserves plugin selector CLI for no-co
|
||||
assert.equal(byActionId.get('copySubtitleMultiple')?.cliArgs, undefined);
|
||||
assert.equal(byActionId.get('mineSentenceMultiple')?.cliArgs, undefined);
|
||||
});
|
||||
|
||||
test('single keys reserve sequence prefixes without reserving the second stroke', () => {
|
||||
for (const key of ['g', 'Ctrl+g', 's']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'g-s' }),
|
||||
keybindings: [createKeybinding(key, ['show-text', 'single'])],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === key));
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'g-s'),
|
||||
key !== 'g',
|
||||
);
|
||||
assert.equal(result.warnings.length, key === 'g' ? 1 : 0);
|
||||
if (key === 'g') assert.match(result.warnings[0]!.message, /Single-key bindings take priority/);
|
||||
}
|
||||
});
|
||||
|
||||
test('configured shortcuts and built-in overlay keys also reserve sequence prefixes', () => {
|
||||
for (const prefix of ['g', 'y', 'v']) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: `${prefix}-s`, copySubtitle: 'g' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === `${prefix}-s`),
|
||||
false,
|
||||
);
|
||||
assert.ok(result.bindings.some((binding) => binding.originalKey === 'g'));
|
||||
assert.equal(result.warnings.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('sequence reservations follow the sidebar code and literal Shift semantics', () => {
|
||||
for (const [toggleKey, disabled] of [
|
||||
['KeyG', true],
|
||||
['g', false],
|
||||
['G', true],
|
||||
] as const) {
|
||||
const result = compileSessionBindings({
|
||||
shortcuts: createShortcuts({ openSubtitleSelection: 'Shift+g-s' }),
|
||||
keybindings: [],
|
||||
platform: 'linux',
|
||||
rawConfig: {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleSidebar: { ...DEFAULT_CONFIG.subtitleSidebar, toggleKey },
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
result.bindings.some((binding) => binding.originalKey === 'Shift+g-s'),
|
||||
!disabled,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
SessionKeySpec,
|
||||
} from '../../types/session-bindings';
|
||||
import { SPECIAL_COMMANDS } from '../../config';
|
||||
import {
|
||||
resolveSessionSequenceConflicts,
|
||||
type SessionKeyReservation,
|
||||
} from '../../shared/session-key-sequences';
|
||||
|
||||
type PlatformKeyModel = 'darwin' | 'win32' | 'linux';
|
||||
|
||||
@@ -56,6 +60,7 @@ const SESSION_SHORTCUT_ACTIONS: Array<{
|
||||
{ key: 'openRuntimeOptions', actionId: 'openRuntimeOptions' },
|
||||
{ key: 'openJimaku', actionId: 'openJimaku' },
|
||||
{ key: 'openTsukihime', actionId: 'openTsukihime' },
|
||||
{ key: 'openSubtitleSelection', actionId: 'openSubtitleSelection' },
|
||||
{ key: 'openSubtitleGeneration', actionId: 'openSubtitleGeneration' },
|
||||
{ key: 'openSessionHelp', actionId: 'openSessionHelp' },
|
||||
{ key: 'openControllerSelect', actionId: 'openControllerSelect' },
|
||||
@@ -81,6 +86,13 @@ function normalizeCodeToken(
|
||||
): string | null {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) return null;
|
||||
// Two lowercase letters use mpv's sequential-key syntax, for example g-s.
|
||||
if (/^[a-z]-[a-z]$/.test(normalized)) {
|
||||
return normalized
|
||||
.split('-')
|
||||
.map((letter) => `Key${letter.toUpperCase()}`)
|
||||
.join('-');
|
||||
}
|
||||
if (options.allowMouseButtons === true) {
|
||||
const normalizedMouse = normalized.toUpperCase();
|
||||
if (MPV_MOUSE_BUTTON_CODES.has(normalizedMouse)) {
|
||||
@@ -543,7 +555,37 @@ export function compileSessionBindings(input: CompileSessionBindingsInput): {
|
||||
}
|
||||
|
||||
bindings.sort((left, right) => left.sourcePath.localeCompare(right.sourcePath));
|
||||
return { bindings, warnings };
|
||||
const reservations: SessionKeyReservation[] = [
|
||||
{ key: { code: 'KeyY', modifiers: [] }, path: 'built-in y sequences' },
|
||||
{ key: { code: 'KeyV', modifiers: [] }, path: 'primary subtitle visibility key' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['meta'] }, path: 'lookup window toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['ctrl', 'shift'] }, path: 'keyboard-driven mode toggle' },
|
||||
{ key: { code: 'KeyY', modifiers: ['shift', 'meta'] }, path: 'keyboard-driven mode toggle' },
|
||||
...[...candidates.values()].flatMap((drafts) =>
|
||||
drafts.map(({ binding }) => ({
|
||||
key: binding.key,
|
||||
path: binding.sourcePath,
|
||||
})),
|
||||
),
|
||||
];
|
||||
const sidebarKey = input.rawConfig?.subtitleSidebar?.toggleKey;
|
||||
if (sidebarKey) {
|
||||
const { key } = parseSessionBindingKey(sidebarKey, input.platform);
|
||||
if (key) {
|
||||
// The sidebar accepts DOM codes with either Shift state, or literal characters.
|
||||
if (/^[A-Z]$/.test(sidebarKey)) key.modifiers = ['shift'];
|
||||
reservations.push({ key, path: 'subtitleSidebar.toggleKey' });
|
||||
if (/^Key[A-Z]$/.test(sidebarKey)) {
|
||||
reservations.push({
|
||||
key: { ...key, modifiers: ['shift'] },
|
||||
path: 'subtitleSidebar.toggleKey',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = resolveSessionSequenceConflicts(bindings, reservations);
|
||||
return { bindings: result.bindings, warnings: [...warnings, ...result.warnings] };
|
||||
}
|
||||
|
||||
export function buildPluginSessionBindingsArtifact(input: {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ConfiguredShortcuts {
|
||||
openRuntimeOptions: string | null | undefined;
|
||||
openJimaku: string | null | undefined;
|
||||
openTsukihime: string | null | undefined;
|
||||
openSubtitleSelection: string | null | undefined;
|
||||
openSubtitleGeneration: string | null | undefined;
|
||||
openSessionHelp: string | null | undefined;
|
||||
openControllerSelect: string | null | undefined;
|
||||
@@ -68,6 +69,10 @@ export function resolveConfiguredShortcuts(
|
||||
openRuntimeOptions: normalizeShortcut(shortcutValue('openRuntimeOptions')),
|
||||
openJimaku: normalizeShortcut(shortcutValue('openJimaku')),
|
||||
openTsukihime: normalizeShortcut(shortcutValue('openTsukihime')),
|
||||
openSubtitleSelection:
|
||||
config.subtitleSelection?.enabled === true
|
||||
? normalizeShortcut(shortcutValue('openSubtitleSelection'))
|
||||
: null,
|
||||
openSubtitleGeneration: normalizeShortcut(shortcutValue('openSubtitleGeneration')),
|
||||
openSessionHelp: normalizeShortcut(shortcutValue('openSessionHelp')),
|
||||
openControllerSelect: normalizeShortcut(shortcutValue('openControllerSelect')),
|
||||
|
||||
+66
-43
@@ -84,7 +84,6 @@ protocol.registerSchemesAsPrivileged([
|
||||
]);
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { MecabTokenizer } from './mecab-tokenizer';
|
||||
@@ -131,11 +130,6 @@ import {
|
||||
import { printHelp } from './cli/help';
|
||||
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
MPV_X11_BACKEND_ARGS,
|
||||
applyX11EnvOverrides,
|
||||
shouldForceX11WaylandSession,
|
||||
} from './shared/mpv-x11-backend';
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import {
|
||||
getStartupModeFlags,
|
||||
@@ -402,6 +396,7 @@ import {
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
launchWindowsMpv,
|
||||
} from './main/runtime/windows-mpv-launch';
|
||||
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
|
||||
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
|
||||
import {
|
||||
DEFAULT_JELLYFIN_CLIENT_NAME,
|
||||
@@ -475,6 +470,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
|
||||
import { registerIpcRuntimeServices } from './main/ipc-runtime';
|
||||
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
|
||||
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
|
||||
import {
|
||||
createSubtitleSelectionRuntime,
|
||||
openSubtitleSelectionModal,
|
||||
registerSubtitleSelectionIpc,
|
||||
} from './main/runtime/subtitle-selection';
|
||||
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
|
||||
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
|
||||
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
|
||||
@@ -686,22 +686,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
|
||||
* sessions it is pinned to XWayland — Wayland-hint env stripped and an X11 GPU context
|
||||
* appended — so the XWayland overlay can stay above it, matching the `subminer` launcher.
|
||||
*/
|
||||
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
|
||||
if (!shouldForceX11WaylandSession(process.env)) {
|
||||
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
|
||||
}
|
||||
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: applyX11EnvOverrides({ ...process.env }),
|
||||
});
|
||||
}
|
||||
|
||||
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
|
||||
let jellyfinRemoteLastProgressAtMs = 0;
|
||||
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
|
||||
@@ -3231,18 +3215,20 @@ const {
|
||||
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
|
||||
},
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||
getMpvExecutablePath: () =>
|
||||
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
|
||||
getSocketPath: () => appState.mpvSocketPath,
|
||||
getLaunchMode: () => configService.getConfig().mpv.launchMode,
|
||||
platform: process.platform,
|
||||
execPath: process.execPath,
|
||||
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
|
||||
getInstalledPluginDetection: () =>
|
||||
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform: process.platform,
|
||||
homeDir: os.homedir(),
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
appDataDir: app.getPath('appData'),
|
||||
mpvExecutablePath: configService.getConfig().mpv.executablePath,
|
||||
mpvExecutablePath,
|
||||
}),
|
||||
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
|
||||
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
|
||||
@@ -3250,7 +3236,7 @@ const {
|
||||
removeSocketPath: (socketPath) => {
|
||||
fs.rmSync(socketPath, { force: true });
|
||||
},
|
||||
spawnMpv: (args) => spawnManagedMpvProcess(args),
|
||||
spawnMpv: spawnMpvProcess,
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
logInfo: (message) => logger.info(message),
|
||||
},
|
||||
@@ -4664,6 +4650,7 @@ const {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
void refreshMpvSessionBindings();
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -5384,20 +5371,32 @@ const {
|
||||
},
|
||||
});
|
||||
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
});
|
||||
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
|
||||
createSessionBindingsRuntime({
|
||||
configDir: CONFIG_DIR,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
getResolvedConfig: () => configService.getConfig(),
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
setSessionBindings: (bindings) => {
|
||||
appState.sessionBindings = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: (initialized) => {
|
||||
appState.sessionBindingsInitialized = initialized;
|
||||
},
|
||||
logWarn: (message) => logger.warn(message),
|
||||
onBindingsChanged: (bindings) =>
|
||||
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
|
||||
onWarning: (warning) => {
|
||||
if (warning.kind !== 'conflict') return;
|
||||
overlayNotificationsRuntime.showOverlayNotification({
|
||||
id: `session-binding-conflict:${warning.path}`,
|
||||
title: 'Shortcut conflict',
|
||||
body: warning.message,
|
||||
variant: 'warning',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
|
||||
appendToMpvLogMainDeps: {
|
||||
@@ -5652,6 +5651,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
|
||||
openJimaku: () => openJimakuOverlay(),
|
||||
openTsukihime: () => openTsukihimeOverlay(),
|
||||
openSessionHelp: () => openSessionHelpOverlay(),
|
||||
openSubtitleSelection: () => {
|
||||
if (!configService.getConfig().subtitleSelection.enabled) return;
|
||||
openOverlayHostedModalWithOsd(
|
||||
openSubtitleSelectionModal,
|
||||
'Subtitle selection overlay unavailable.',
|
||||
'Failed to open subtitle selection overlay.',
|
||||
);
|
||||
},
|
||||
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
|
||||
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
|
||||
openControllerSelect: () => openControllerSelectOverlay(),
|
||||
@@ -6004,8 +6011,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
|
||||
getMecabTokenizer: () => appState.mecabTokenizer,
|
||||
getKeybindings: () => appState.keybindings,
|
||||
getMpvInputBindings: () =>
|
||||
readMpvInputBindings({
|
||||
getMpvInputBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return readMpvInputBindings({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
|
||||
platform:
|
||||
@@ -6014,8 +6022,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
: process.platform === 'win32'
|
||||
? 'win32'
|
||||
: 'linux',
|
||||
}),
|
||||
getSessionBindings: () => appState.sessionBindings,
|
||||
});
|
||||
},
|
||||
getSessionBindings: async () => {
|
||||
await refreshMpvSessionBindings();
|
||||
return appState.sessionBindings;
|
||||
},
|
||||
getConfiguredShortcuts: () => getConfiguredShortcuts(),
|
||||
dispatchSessionAction: (request) => dispatchSessionAction(request),
|
||||
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
|
||||
@@ -6811,6 +6823,17 @@ function setOverlayVisible(visible: boolean): void {
|
||||
}
|
||||
|
||||
registerIpcRuntimeHandlers();
|
||||
registerSubtitleSelectionIpc({
|
||||
ipc: ipcMain,
|
||||
isAllowedSender: (sender) =>
|
||||
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
|
||||
(window) => window && !window.isDestroyed() && window.webContents === sender,
|
||||
),
|
||||
runtime: createSubtitleSelectionRuntime({
|
||||
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
}),
|
||||
});
|
||||
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
|
||||
getConfig: () => configService.getConfig().subtitleGeneration,
|
||||
getModelDirectory: () =>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createHandleJellyfinRemoteGeneralCommand,
|
||||
createHandleJellyfinRemotePlay,
|
||||
createHandleJellyfinRemotePlaystate,
|
||||
createJellyfinRemoteReportTracker,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
} from '../domains/jellyfin';
|
||||
@@ -91,13 +92,17 @@ export function composeJellyfinRemoteHandlers(
|
||||
getNow: options.getNow,
|
||||
ticksPerSecond: options.ticksPerSecond,
|
||||
logDebug: options.logDebug,
|
||||
logWarn: options.logWarn,
|
||||
});
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler(
|
||||
buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||
);
|
||||
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler(
|
||||
buildReportJellyfinRemoteStoppedMainDepsHandler(),
|
||||
);
|
||||
const reportTracker = createJellyfinRemoteReportTracker();
|
||||
const reportJellyfinRemoteProgress = createReportJellyfinRemoteProgressHandler({
|
||||
...buildReportJellyfinRemoteProgressMainDepsHandler(),
|
||||
reportTracker,
|
||||
});
|
||||
const reportJellyfinRemoteStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
...buildReportJellyfinRemoteStoppedMainDepsHandler(),
|
||||
reportTracker,
|
||||
});
|
||||
|
||||
const buildHandleJellyfinRemotePlayMainDepsHandler =
|
||||
createBuildHandleJellyfinRemotePlayMainDepsHandler({
|
||||
|
||||
@@ -54,6 +54,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
|
||||
sleep: async () => {},
|
||||
},
|
||||
launchMpvIdleForJellyfinPlaybackMainDeps: {
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/test-mpv.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
|
||||
@@ -163,7 +163,10 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
|
||||
deps.setKeybindings(payload.keybindings);
|
||||
deps.setSessionBindings(payload.sessionBindings, payload.sessionBindingWarnings);
|
||||
|
||||
if (diff.hotReloadFields.includes('shortcuts')) {
|
||||
if (
|
||||
diff.hotReloadFields.includes('shortcuts') ||
|
||||
diff.hotReloadFields.includes('subtitleSelection')
|
||||
) {
|
||||
deps.refreshGlobalAndOverlayShortcuts();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -24,6 +24,7 @@ function createShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
|
||||
@@ -165,7 +165,7 @@ export function createPlayJellyfinItemInMpvHandler(deps: {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (!connected || !mpvClient) {
|
||||
throw new Error(
|
||||
'MPV not connected and auto-launch failed. Ensure mpv is installed and available in PATH.',
|
||||
'MPV not connected and auto-launch failed. Check mpv.executablePath or ensure mpv is available in PATH.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
},
|
||||
};
|
||||
const deps = createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler({
|
||||
getMpvExecutablePath: () => '/usr/local/bin/mpv',
|
||||
getSocketPath: () => '/tmp/mpv.sock',
|
||||
getLaunchMode: () => 'fullscreen',
|
||||
platform: 'darwin',
|
||||
@@ -47,8 +48,8 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
getDefaultMpvLogPath: () => '/tmp/mpv.log',
|
||||
defaultMpvArgs: ['--no-config'],
|
||||
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
|
||||
spawnMpv: (args) => {
|
||||
calls.push(`spawn:${args.join(' ')}`);
|
||||
spawnMpv: (executablePath, args) => {
|
||||
calls.push(`spawn:${executablePath} ${args.join(' ')}`);
|
||||
return proc;
|
||||
},
|
||||
logWarn: (message) => calls.push(`warn:${message}`),
|
||||
@@ -60,14 +61,20 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
|
||||
assert.equal(deps.platform, 'darwin');
|
||||
assert.equal(deps.execPath, '/tmp/subminer');
|
||||
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
|
||||
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
|
||||
assert.equal(deps.getMpvExecutablePath(), '/usr/local/bin/mpv');
|
||||
assert.equal(deps.getInstalledPluginDetection?.('/usr/local/bin/mpv').installed, false);
|
||||
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
|
||||
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
|
||||
deps.removeSocketPath('/tmp/mpv.sock');
|
||||
deps.spawnMpv(['--idle=yes']);
|
||||
deps.spawnMpv('/usr/local/bin/mpv', ['--idle=yes']);
|
||||
deps.logInfo('launched');
|
||||
deps.logWarn('bad', null);
|
||||
assert.deepEqual(calls, ['rm:/tmp/mpv.sock', 'spawn:--idle=yes', 'info:launched', 'warn:bad']);
|
||||
assert.deepEqual(calls, [
|
||||
'rm:/tmp/mpv.sock',
|
||||
'spawn:/usr/local/bin/mpv --idle=yes',
|
||||
'info:launched',
|
||||
'warn:bad',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ensure mpv connected for jellyfin main deps builder maps callbacks', async () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
deps: LaunchMpvForJellyfinDeps,
|
||||
) {
|
||||
return (): LaunchMpvForJellyfinDeps => ({
|
||||
getMpvExecutablePath: () => deps.getMpvExecutablePath(),
|
||||
getSocketPath: () => deps.getSocketPath(),
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
@@ -26,7 +27,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
||||
spawnMpv: (args: string[]) => deps.spawnMpv(args),
|
||||
spawnMpv: (executablePath, args) => deps.spawnMpv(executablePath, args),
|
||||
logWarn: (message: string, error: unknown) => deps.logWarn(message, error),
|
||||
logInfo: (message: string) => deps.logInfo(message),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { detectInstalledMpvPlugin } from './first-run-setup-plugin';
|
||||
import { resolveWindowsMpvPath } from './mpv-process';
|
||||
import {
|
||||
createEnsureMpvConnectedForJellyfinPlaybackHandler,
|
||||
createLaunchMpvIdleForJellyfinPlaybackHandler,
|
||||
@@ -30,6 +32,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
const spawnedArgs: string[][] = [];
|
||||
const logs: string[] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'maximized',
|
||||
platform: 'darwin',
|
||||
@@ -39,7 +42,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnMpv: (_executable, args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
@@ -67,6 +70,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => 'mpv',
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
@@ -84,7 +88,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnMpv: (_executable, args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
@@ -108,41 +112,53 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
|
||||
assert.doesNotMatch(scriptOpts ?? '', /subminer-aniskip_button_key=/);
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when installed plugin exists', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getRuntimePluginEntrypoint: () => '/opt/SubMiner/plugin/subminer/main.lua',
|
||||
getInstalledPluginDetection: () => ({
|
||||
installed: true,
|
||||
path: '/home/tester/.config/mpv/scripts/subminer/main.lua',
|
||||
version: '0.1.0',
|
||||
source: 'default-config',
|
||||
message: null,
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
test('Jellyfin detects portable plugins beside the executable selected for launch', () => {
|
||||
const mpvPath = 'C:\\portable player\\mpv.exe';
|
||||
const pluginPath = 'C:\\portable player\\portable_config\\scripts\\subminer\\main.lua';
|
||||
for (const source of ['environment', 'PATH']) {
|
||||
let resolutions = 0;
|
||||
const spawned: Array<{ executable: string; args: string[] }> = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getMpvExecutablePath: () => {
|
||||
resolutions += 1;
|
||||
return resolveWindowsMpvPath({
|
||||
getEnv: () => (source === 'environment' ? mpvPath : undefined),
|
||||
runWhere: () => ({ status: 0, stdout: mpvPath }),
|
||||
fileExists: (candidate) => candidate === mpvPath,
|
||||
});
|
||||
},
|
||||
getSocketPath: () => '\\\\.\\pipe\\subminer-test',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'win32',
|
||||
execPath: 'C:\\SubMiner\\SubMiner.exe',
|
||||
getRuntimePluginEntrypoint: () => 'C:\\SubMiner\\plugin\\subminer\\main.lua',
|
||||
getInstalledPluginDetection: (mpvExecutablePath) =>
|
||||
detectInstalledMpvPlugin({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\test',
|
||||
mpvExecutablePath,
|
||||
existsSync: (candidate) => candidate === pluginPath,
|
||||
}),
|
||||
getDefaultMpvLogPath: () => '',
|
||||
defaultMpvArgs: [],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (executable, args) => {
|
||||
spawned.push({ executable, args });
|
||||
return { on: () => {}, unref: () => {} };
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
assert.equal(
|
||||
spawnedArgs[0]?.some((arg) => arg.startsWith('--script=/opt/SubMiner/plugin/subminer')),
|
||||
false,
|
||||
);
|
||||
assert.ok(spawnedArgs[0]?.some((arg) => arg.startsWith('--script-opts=')));
|
||||
launch();
|
||||
assert.equal(resolutions, 1, source);
|
||||
assert.equal(spawned.length, 1);
|
||||
assert.equal(spawned[0]!.executable, mpvPath);
|
||||
assert.equal(
|
||||
spawned[0]!.args.some((arg) => arg.startsWith('--script=')),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
|
||||
@@ -41,23 +41,25 @@ export function createWaitForMpvConnectedHandler(deps: WaitForMpvConnectedDeps)
|
||||
}
|
||||
|
||||
export type LaunchMpvForJellyfinDeps = {
|
||||
getMpvExecutablePath: () => string;
|
||||
getSocketPath: () => string;
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getRuntimePluginEntrypoint?: () => string | null | undefined;
|
||||
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
|
||||
getInstalledPluginDetection?: (mpvExecutablePath: string) => InstalledMpvPluginDetection;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
getDefaultMpvLogPath: () => string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
spawnMpv: (args: string[]) => SpawnedProcessLike;
|
||||
spawnMpv: (executablePath: string, args: string[]) => SpawnedProcessLike;
|
||||
logWarn: (message: string, error: unknown) => void;
|
||||
logInfo: (message: string) => void;
|
||||
};
|
||||
|
||||
export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvForJellyfinDeps) {
|
||||
return (): void => {
|
||||
const executablePath = deps.getMpvExecutablePath();
|
||||
const socketPath = deps.getSocketPath();
|
||||
if (deps.platform !== 'win32') {
|
||||
try {
|
||||
@@ -78,7 +80,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.();
|
||||
const installedPlugin = deps.getInstalledPluginDetection?.(executablePath);
|
||||
const runtimePluginEntrypoint = installedPlugin?.installed
|
||||
? ''
|
||||
: (deps.getRuntimePluginEntrypoint?.()?.trim() ?? '');
|
||||
@@ -95,7 +97,7 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
|
||||
`--input-ipc-server=${socketPath}`,
|
||||
];
|
||||
const proc = deps.spawnMpv(mpvArgs);
|
||||
const proc = deps.spawnMpv(executablePath, mpvArgs);
|
||||
proc.on('error', (error) => {
|
||||
deps.logWarn('Failed to launch mpv for Jellyfin remote playback', error);
|
||||
});
|
||||
|
||||
@@ -75,5 +75,6 @@ export function createBuildReportJellyfinRemoteStoppedMainDepsHandler(
|
||||
getNow: deps.getNow ? () => deps.getNow?.() ?? Date.now() : undefined,
|
||||
ticksPerSecond: deps.ticksPerSecond,
|
||||
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
|
||||
...(deps.logWarn ? { logWarn: (message: string) => deps.logWarn?.(message) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
markJellyfinRemotePlaybackLoaded,
|
||||
createJellyfinRemoteReportTracker,
|
||||
createReportJellyfinRemoteProgressHandler,
|
||||
createReportJellyfinRemoteStoppedHandler,
|
||||
secondsToJellyfinTicks,
|
||||
@@ -528,3 +529,70 @@ test('createReportJellyfinRemoteStoppedHandler ignores startup stop churn before
|
||||
assert.equal(stopped, false);
|
||||
assert.equal(cleared, false);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler clears playback before reporting and waits for in-flight progress', async () => {
|
||||
const tracker = createJellyfinRemoteReportTracker();
|
||||
let playback: { itemId: string; playMethod: 'DirectPlay'; loadedMediaPath: string } | null = {
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
loadedMediaPath: 'http://pve-main:8096/Videos/item-1/stream',
|
||||
};
|
||||
const calls: string[] = [];
|
||||
let releaseProgress: () => void = () => undefined;
|
||||
const progressGate = new Promise<void>((resolve) => {
|
||||
releaseProgress = resolve;
|
||||
});
|
||||
const session = {
|
||||
isConnected: () => true,
|
||||
reportProgress: async ({ eventName }: { eventName: string }) => {
|
||||
calls.push(`progress:${eventName}:${playback ? 'active' : 'cleared'}`);
|
||||
if (calls.length === 1) await progressGate;
|
||||
return true;
|
||||
},
|
||||
reportStopped: async () => {
|
||||
calls.push(`stopped:${playback ? 'active' : 'cleared'}`);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const shared = {
|
||||
getActivePlayback: () => playback,
|
||||
clearActivePlayback: () => {
|
||||
playback = null;
|
||||
},
|
||||
getSession: () => session,
|
||||
getMpvClient: () => ({ currentTimePos: 42 }),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => undefined,
|
||||
reportTracker: tracker,
|
||||
};
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
...shared,
|
||||
getNow: () => 10_000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => undefined,
|
||||
progressIntervalMs: 3000,
|
||||
});
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler(shared);
|
||||
|
||||
// A periodic tick is mid-request when the stop starts.
|
||||
const tick = reportProgress(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
const stop = reportStopped();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(playback, null);
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
|
||||
// A tick fired after the stop began must not report anything.
|
||||
await reportProgress(true);
|
||||
assert.deepEqual(calls, ['progress:TimeUpdate:active']);
|
||||
|
||||
releaseProgress();
|
||||
await tick;
|
||||
await stop;
|
||||
assert.deepEqual(calls, [
|
||||
'progress:TimeUpdate:active',
|
||||
'progress:TimeUpdate:cleared',
|
||||
'stopped:cleared',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -134,6 +134,29 @@ function isSeekLikePositionJump(
|
||||
return Math.abs(nextPositionSeconds - previousPositionSeconds) >= thresholdSeconds;
|
||||
}
|
||||
|
||||
// Jellyfin re-creates a session's NowPlayingItem from any progress report, so a progress
|
||||
// tick that lands after the stop report leaves the server showing playback forever. The
|
||||
// tracker lets the stop handler wait for reports that are already in flight.
|
||||
export type JellyfinRemoteReportTracker = {
|
||||
track: (report: Promise<void>) => void;
|
||||
settled: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createJellyfinRemoteReportTracker(): JellyfinRemoteReportTracker {
|
||||
const active = new Set<Promise<void>>();
|
||||
return {
|
||||
track: (report) => {
|
||||
active.add(report);
|
||||
void report.finally(() => active.delete(report));
|
||||
},
|
||||
settled: async () => {
|
||||
while (active.size > 0) {
|
||||
await Promise.allSettled([...active]);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type JellyfinRemoteProgressReporterDeps = {
|
||||
getActivePlayback: () => ActiveJellyfinRemotePlaybackState | null;
|
||||
clearActivePlayback: () => void;
|
||||
@@ -145,6 +168,7 @@ export type JellyfinRemoteProgressReporterDeps = {
|
||||
progressIntervalMs: number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
reportTracker?: JellyfinRemoteReportTracker;
|
||||
};
|
||||
|
||||
export function createReportJellyfinRemoteProgressHandler(
|
||||
@@ -152,7 +176,7 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
) {
|
||||
let lastReportedPositionSeconds: number | null = null;
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const report = async (force: boolean): Promise<void> => {
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
@@ -193,6 +217,12 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
deps.logDebug('Failed to report Jellyfin remote progress', error);
|
||||
}
|
||||
};
|
||||
|
||||
return async (force = false): Promise<void> => {
|
||||
const pending = report(force);
|
||||
deps.reportTracker?.track(pending);
|
||||
await pending;
|
||||
};
|
||||
}
|
||||
|
||||
export type JellyfinRemoteStoppedReporterDeps = {
|
||||
@@ -203,6 +233,8 @@ export type JellyfinRemoteStoppedReporterDeps = {
|
||||
getNow?: () => number;
|
||||
ticksPerSecond: number;
|
||||
logDebug: (message: string, error: unknown) => void;
|
||||
logWarn?: (message: string) => void;
|
||||
reportTracker?: JellyfinRemoteReportTracker;
|
||||
};
|
||||
|
||||
export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteStoppedReporterDeps) {
|
||||
@@ -226,6 +258,10 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
// Clear before any network call so progress ticks fired during the stop find nothing to
|
||||
// report, then let reports already in flight finish so none can arrive after the stop.
|
||||
deps.clearActivePlayback();
|
||||
await deps.reportTracker?.settled();
|
||||
try {
|
||||
const observedPositionSeconds = await readMpvPositionSecondsOrFallback(deps.getMpvClient());
|
||||
const positionSeconds = resolveReportablePositionSeconds(playback, observedPositionSeconds);
|
||||
@@ -244,7 +280,7 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote final progress', error);
|
||||
}
|
||||
await session.reportStopped({
|
||||
const reported = await session.reportStopped({
|
||||
itemId: playback.itemId,
|
||||
mediaSourceId: playback.mediaSourceId,
|
||||
positionTicks,
|
||||
@@ -254,10 +290,13 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
subtitleStreamIndex: playback.subtitleStreamIndex,
|
||||
eventName: 'stop',
|
||||
});
|
||||
if (reported === false) {
|
||||
deps.logWarn?.(
|
||||
`Jellyfin did not accept the playback stop report for item ${playback.itemId}; the server may keep showing it as playing.`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
deps.logDebug('Failed to report Jellyfin remote stop', error);
|
||||
} finally {
|
||||
deps.clearActivePlayback();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ type JellyfinRemoteServiceOptions = {
|
||||
};
|
||||
onConnected: () => void;
|
||||
onDisconnected: () => void;
|
||||
logWarn?: (message: string, details?: unknown) => void;
|
||||
onPlay: (payload: JellyfinRemoteEventPayload) => void;
|
||||
onPlaystate: (payload: JellyfinRemoteEventPayload) => void;
|
||||
onGeneralCommand: (payload: JellyfinRemoteEventPayload) => void;
|
||||
@@ -110,6 +111,7 @@ export function createStartJellyfinRemoteSessionHandler(deps: {
|
||||
onDisconnected: () => {
|
||||
deps.logWarn('Jellyfin remote websocket disconnected; retrying.');
|
||||
},
|
||||
logWarn: (message, details) => deps.logWarn(message, details),
|
||||
onPlay: (payload) => {
|
||||
void deps.handlePlay(payload).catch((error) => {
|
||||
deps.logWarn('Failed handling Jellyfin remote Play event', error);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { resolveMpvExecutablePath, spawnMpvProcess } from './mpv-process';
|
||||
|
||||
const testWindows = process.platform === 'win32' ? test : test.skip;
|
||||
|
||||
test('mpv process launcher forwards arguments and environment to the child', async () => {
|
||||
const child = spawnMpvProcess(
|
||||
process.execPath,
|
||||
['-e', 'process.exit(Number(process.env.SUBMINER_TEST_EXIT))'],
|
||||
{ ...process.env, DISPLAY: '', SUBMINER_TEST_EXIT: '17' },
|
||||
);
|
||||
try {
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 17);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
testWindows('managed Windows playback launches the configured executable', async () => {
|
||||
const executable = resolveMpvExecutablePath(` ${process.execPath} `);
|
||||
const child = spawnMpvProcess(executable, ['-e', 'process.exit(17)']);
|
||||
try {
|
||||
assert.equal(child.spawnfile, process.execPath);
|
||||
const [code] = await once(child, 'exit');
|
||||
assert.equal(code, 17);
|
||||
} finally {
|
||||
if (child.exitCode === null) child.kill();
|
||||
}
|
||||
});
|
||||
|
||||
testWindows('managed Windows playback rejects an invalid configured executable', () => {
|
||||
assert.throws(
|
||||
() => resolveMpvExecutablePath(`${process.execPath}/missing-mpv.exe`),
|
||||
/Could not find mpv.exe/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
MPV_X11_BACKEND_ARGS,
|
||||
applyX11EnvOverrides,
|
||||
shouldForceX11WaylandSession,
|
||||
} from '../../shared/mpv-x11-backend';
|
||||
|
||||
export interface WindowsMpvPathDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
}
|
||||
|
||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||
|
||||
function fileExists(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredWindowsMpvPathStatus(
|
||||
configuredMpvPath = '',
|
||||
exists: (candidate: string) => boolean = fileExists,
|
||||
): ConfiguredWindowsMpvPathStatus {
|
||||
const configPath = configuredMpvPath.trim();
|
||||
if (!configPath) {
|
||||
return 'blank';
|
||||
}
|
||||
return exists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function createWindowsMpvPathDeps(
|
||||
overrides: Partial<WindowsMpvPathDeps> = {},
|
||||
): WindowsMpvPathDeps {
|
||||
return {
|
||||
getEnv: overrides.getEnv ?? ((name) => process.env[name]),
|
||||
fileExists: overrides.fileExists ?? fileExists,
|
||||
runWhere:
|
||||
overrides.runWhere ??
|
||||
(() => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvPathDeps, configuredMpvPath = ''): string {
|
||||
const configPath = configuredMpvPath.trim();
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
return configPath;
|
||||
}
|
||||
if (configuredPathStatus === 'invalid') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const envPath = deps.getEnv('SUBMINER_MPV_PATH')?.trim();
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function spawnMpvProcess(
|
||||
executablePath: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ReturnType<typeof spawn> {
|
||||
const forceX11 = shouldForceX11WaylandSession(env);
|
||||
return spawn(executablePath, forceX11 ? [...args, ...MPV_X11_BACKEND_ARGS] : args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: forceX11 ? applyX11EnvOverrides({ ...env }) : env,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMpvExecutablePath(configuredMpvPath = ''): string {
|
||||
const executablePath =
|
||||
process.platform === 'win32'
|
||||
? resolveWindowsMpvPath(createWindowsMpvPathDeps(), configuredMpvPath)
|
||||
: 'mpv';
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
'Could not find mpv.exe. Check mpv.executablePath, SUBMINER_MPV_PATH, or PATH.',
|
||||
);
|
||||
}
|
||||
return executablePath;
|
||||
}
|
||||
@@ -70,3 +70,69 @@ test('persistSessionBindings keeps saved bindings when mpv reload notification f
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('native prefix conflicts publish the same effective bindings to the overlay and plugin and recover', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-session-conflict-'));
|
||||
const sequence: CompiledSessionBinding = {
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
};
|
||||
let nativeKeys: unknown = [];
|
||||
let failDiscovery = false;
|
||||
let published: CompiledSessionBinding[] = [];
|
||||
const events: CompiledSessionBinding[][] = [];
|
||||
const warnings: string[] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
send: () => {},
|
||||
requestProperty: async () => {
|
||||
if (failDiscovery) throw new Error('temporarily unavailable');
|
||||
return nativeKeys;
|
||||
},
|
||||
};
|
||||
const runtime = createSessionBindingsRuntime({
|
||||
configDir: root,
|
||||
getKeybindings: () => [],
|
||||
getConfiguredShortcuts: () => ({ multiCopyTimeoutMs: 1500 }) as never,
|
||||
getResolvedConfig: () => ({ stats: { toggleKey: 's', markWatchedKey: 'w' } }) as ResolvedConfig,
|
||||
getMpvClient: () => client,
|
||||
setSessionBindings: (bindings) => {
|
||||
published = bindings;
|
||||
},
|
||||
setSessionBindingsInitialized: () => {},
|
||||
logWarn: () => {},
|
||||
onBindingsChanged: (bindings) => events.push(bindings),
|
||||
onWarning: (warning) => warnings.push(warning.message),
|
||||
});
|
||||
const readArtifact = () =>
|
||||
JSON.parse(fs.readFileSync(path.join(root, 'session-bindings.json'), 'utf8'));
|
||||
try {
|
||||
runtime.persistSessionBindings([sequence]);
|
||||
nativeKeys = [{ key: 'g', cmd: 'show-text single', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, []);
|
||||
assert.deepEqual(events.at(-1), readArtifact().bindings);
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /mpv input binding "g"/);
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.equal(events.length, 2, 'unchanged discovery must not create a reload loop');
|
||||
assert.equal(warnings.length, 1);
|
||||
failDiscovery = true;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [], 'failed discovery retains the known conflict');
|
||||
failDiscovery = false;
|
||||
nativeKeys = [{ key: 'Shift+g', cmd: 'show-text shifted', priority: 1 }];
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
assert.equal(readArtifact().bindings[0].key.code, 'KeyG-KeyS');
|
||||
assert.deepEqual(readArtifact().warnings, []);
|
||||
client.connected = false;
|
||||
await runtime.refreshMpvSessionBindings();
|
||||
assert.deepEqual(published, [sequence]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,16 +6,26 @@ import {
|
||||
import type { ConfiguredShortcuts } from '../../core/utils/shortcut-config';
|
||||
import type { CompiledSessionBinding, Keybinding, ResolvedConfig } from '../../types';
|
||||
import { writeSessionBindingsArtifact } from './session-bindings-artifact';
|
||||
import { parseMpvInputBindingKeys } from '../../shared/mpv-input-bindings';
|
||||
import {
|
||||
reserveMpvSequencePrefixes,
|
||||
resolveSessionSequenceConflicts,
|
||||
} from '../../shared/session-key-sequences';
|
||||
import type { SessionBindingWarning } from '../../types/session-bindings';
|
||||
|
||||
export interface SessionBindingsRuntimeDeps {
|
||||
configDir: string;
|
||||
getKeybindings: () => Keybinding[];
|
||||
getConfiguredShortcuts: () => ConfiguredShortcuts;
|
||||
getResolvedConfig: () => ResolvedConfig;
|
||||
getMpvClient: () => MpvRuntimeClientLike | null;
|
||||
getMpvClient: () =>
|
||||
| (MpvRuntimeClientLike & { requestProperty: (name: string) => Promise<unknown> })
|
||||
| null;
|
||||
setSessionBindings: (bindings: CompiledSessionBinding[]) => void;
|
||||
setSessionBindingsInitialized: (initialized: boolean) => void;
|
||||
logWarn: (message: string, details?: unknown) => void;
|
||||
onBindingsChanged?: (bindings: CompiledSessionBinding[]) => void;
|
||||
onWarning?: (warning: SessionBindingWarning) => void;
|
||||
}
|
||||
|
||||
export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps): {
|
||||
@@ -24,7 +34,20 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
warnings?: ReturnType<typeof compileSessionBindings>['warnings'],
|
||||
) => void;
|
||||
refreshCurrentSessionBindings: () => void;
|
||||
refreshMpvSessionBindings: () => Promise<void>;
|
||||
} {
|
||||
let sourceBindings: CompiledSessionBinding[] = [];
|
||||
let sourceWarnings: SessionBindingWarning[] = [];
|
||||
let nativeSnapshot: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
keys: string[];
|
||||
} | null = null;
|
||||
let pending: {
|
||||
client: ReturnType<SessionBindingsRuntimeDeps['getMpvClient']>;
|
||||
promise: Promise<void>;
|
||||
} | null = null;
|
||||
let publishedSignature: string | null = null;
|
||||
let reportedWarnings = new Set<string>();
|
||||
function resolveSessionBindingPlatform(): 'darwin' | 'win32' | 'linux' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
@@ -49,8 +72,27 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
bindings: CompiledSessionBinding[],
|
||||
warnings: ReturnType<typeof compileSessionBindings>['warnings'] = [],
|
||||
): void {
|
||||
sourceBindings = bindings;
|
||||
sourceWarnings = warnings;
|
||||
publishBindings();
|
||||
}
|
||||
|
||||
function publishBindings(): void {
|
||||
const client = deps.getMpvClient();
|
||||
const keys = client?.connected && nativeSnapshot?.client === client ? nativeSnapshot.keys : [];
|
||||
const result = resolveSessionSequenceConflicts(
|
||||
sourceBindings,
|
||||
reserveMpvSequencePrefixes(keys),
|
||||
);
|
||||
const warnings = [...sourceWarnings, ...result.warnings];
|
||||
const signature = JSON.stringify([
|
||||
result.bindings,
|
||||
warnings,
|
||||
deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
]);
|
||||
if (signature === publishedSignature) return;
|
||||
const artifact = buildPluginSessionBindingsArtifact({
|
||||
bindings,
|
||||
bindings: result.bindings,
|
||||
warnings,
|
||||
numericSelectionTimeoutMs: deps.getConfiguredShortcuts().multiCopyTimeoutMs,
|
||||
});
|
||||
@@ -60,8 +102,16 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to write session bindings artifact');
|
||||
throw error;
|
||||
}
|
||||
deps.setSessionBindings(bindings);
|
||||
publishedSignature = signature;
|
||||
deps.setSessionBindings(result.bindings);
|
||||
deps.setSessionBindingsInitialized(true);
|
||||
const nextWarnings = new Set(warnings.map((warning) => warning.message));
|
||||
for (const warning of warnings) {
|
||||
if (reportedWarnings.has(warning.message)) continue;
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
deps.onWarning?.(warning);
|
||||
}
|
||||
reportedWarnings = nextWarnings;
|
||||
const mpvClient = deps.getMpvClient();
|
||||
if (mpvClient?.connected) {
|
||||
try {
|
||||
@@ -70,15 +120,41 @@ export function createSessionBindingsRuntime(deps: SessionBindingsRuntimeDeps):
|
||||
deps.logWarn('[session-bindings] Failed to notify mpv to reload session bindings', error);
|
||||
}
|
||||
}
|
||||
deps.onBindingsChanged?.(result.bindings);
|
||||
}
|
||||
|
||||
async function refreshMpvSessionBindings(): Promise<void> {
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
nativeSnapshot = null;
|
||||
publishBindings();
|
||||
return;
|
||||
}
|
||||
if (pending?.client === client) return pending.promise;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const raw = await client.requestProperty('input-bindings');
|
||||
if (client !== deps.getMpvClient() || !client.connected) return;
|
||||
nativeSnapshot = { client, keys: parseMpvInputBindingKeys(raw, { includeIgnored: false }) };
|
||||
publishBindings();
|
||||
} catch {
|
||||
// Keep the last successful snapshot if discovery is temporarily unavailable.
|
||||
}
|
||||
})();
|
||||
const request = { client, promise };
|
||||
pending = request;
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
if (pending === request) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCurrentSessionBindings(): void {
|
||||
const compiled = compileCurrentSessionBindings();
|
||||
for (const warning of compiled.warnings) {
|
||||
deps.logWarn(`[session-bindings] ${warning.message}`);
|
||||
}
|
||||
persistSessionBindings(compiled.bindings, compiled.warnings);
|
||||
void refreshMpvSessionBindings();
|
||||
}
|
||||
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings };
|
||||
return { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createSubtitleSelectionRuntime } from './subtitle-selection';
|
||||
|
||||
function setup() {
|
||||
let enabled = true;
|
||||
const properties = new Map<string, unknown>([
|
||||
['path', '/video.mkv'],
|
||||
[
|
||||
'track-list',
|
||||
[
|
||||
{ id: 1, type: 'audio' },
|
||||
{ id: 2, type: 'sub', title: 'Japanese', lang: 'ja', codec: 'ass' },
|
||||
{ id: 3, type: 'sub', title: 'English', lang: 'en', external: true },
|
||||
{ id: '4', type: 'sub' },
|
||||
],
|
||||
],
|
||||
['sid', 2],
|
||||
['secondary-sid', 3],
|
||||
]);
|
||||
const commands: unknown[][] = [];
|
||||
const client = {
|
||||
connected: true,
|
||||
requestProperty: async (name: string) => properties.get(name),
|
||||
request: async (command: unknown[]) => {
|
||||
commands.push(command);
|
||||
return { error: 'success' };
|
||||
},
|
||||
};
|
||||
const runtime = createSubtitleSelectionRuntime({
|
||||
isEnabled: () => enabled,
|
||||
getMpvClient: () => client,
|
||||
});
|
||||
return {
|
||||
runtime,
|
||||
properties,
|
||||
commands,
|
||||
client,
|
||||
disable: () => {
|
||||
enabled = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('subtitle selector lists only valid subtitle tracks and current selections', async () => {
|
||||
const { runtime, properties } = setup();
|
||||
assert.deepEqual(await runtime.getState(), {
|
||||
mediaPath: '/video.mkv',
|
||||
primary: 2,
|
||||
secondary: 3,
|
||||
tracks: [
|
||||
{ id: 2, label: '#2 · Japanese · ja · ass' },
|
||||
{ id: 3, label: '#3 · English · en · external' },
|
||||
],
|
||||
});
|
||||
properties.set('sid', 'no');
|
||||
properties.set('secondary-sid', false);
|
||||
const state = await runtime.getState();
|
||||
assert.equal(state.primary, null);
|
||||
assert.equal(state.secondary, null);
|
||||
});
|
||||
|
||||
test('subtitle selector swaps tracks and supports disabling both tracks', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 });
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['set_property', 'sid', 3],
|
||||
['set_property', 'secondary-sid', 2],
|
||||
]);
|
||||
commands.length = 0;
|
||||
await runtime.apply({ mediaPath: '/video.mkv', primary: null, secondary: null });
|
||||
assert.ok(commands.every((command) => command[2] === 'no'));
|
||||
});
|
||||
|
||||
test('subtitle selector rejects stale media, unavailable tracks, duplicate tracks and malformed requests without mutation', async () => {
|
||||
const { runtime, commands } = setup();
|
||||
for (const request of [
|
||||
{ mediaPath: '/other.mkv', primary: 2, secondary: 3 },
|
||||
{ mediaPath: '/video.mkv', primary: 99, secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: 2, secondary: 2 },
|
||||
{ mediaPath: '/video.mkv', primary: '2', secondary: null },
|
||||
{ mediaPath: '/video.mkv', primary: -1, secondary: null },
|
||||
null,
|
||||
])
|
||||
await assert.rejects(runtime.apply(request));
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('subtitle selector gates access on config and connection and propagates mpv failures', async () => {
|
||||
const { runtime, client, disable } = setup();
|
||||
client.request = async () => ({ error: 'property unavailable' });
|
||||
await assert.rejects(
|
||||
runtime.apply({ mediaPath: '/video.mkv', primary: 3, secondary: 2 }),
|
||||
/property unavailable/,
|
||||
);
|
||||
client.connected = false;
|
||||
await assert.rejects(runtime.getState(), /Connect to mpv/);
|
||||
disable();
|
||||
await assert.rejects(runtime.getState(), /Enable subtitle selection/);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IpcMain, WebContents } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseSubtitleSelectionRequest,
|
||||
type SubtitleSelectionState,
|
||||
} from '../../shared/subtitle-selection';
|
||||
import { openOverlayHostedModal, retryOverlayModalOpen } from './overlay-hosted-modal-open';
|
||||
|
||||
interface SelectionMpvClient {
|
||||
connected: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
request: (command: unknown[]) => Promise<{ error?: string }>;
|
||||
}
|
||||
|
||||
export function openSubtitleSelectionModal(
|
||||
deps: Parameters<typeof openOverlayHostedModal>[0] & Parameters<typeof retryOverlayModalOpen>[0],
|
||||
): Promise<boolean> {
|
||||
return retryOverlayModalOpen(deps, {
|
||||
modal: 'subtitle-selection',
|
||||
timeoutMs: 1500,
|
||||
retryWarning: 'Subtitle selection modal did not acknowledge opening; retrying.',
|
||||
sendOpen: () =>
|
||||
openOverlayHostedModal(deps, {
|
||||
channel: IPC_CHANNELS.event.subtitleSelectionOpen,
|
||||
modal: 'subtitle-selection',
|
||||
preferModalWindow: true,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createSubtitleSelectionRuntime(deps: {
|
||||
isEnabled: () => boolean;
|
||||
getMpvClient: () => SelectionMpvClient | null;
|
||||
}) {
|
||||
function getClient(): SelectionMpvClient {
|
||||
if (!deps.isEnabled()) throw new Error('Enable subtitle selection in Settings first.');
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) throw new Error('Connect to mpv first.');
|
||||
return client;
|
||||
}
|
||||
|
||||
async function readState(client: SelectionMpvClient): Promise<SubtitleSelectionState> {
|
||||
const mediaPath = await client.requestProperty('path');
|
||||
if (typeof mediaPath !== 'string' || !mediaPath) throw new Error('Open a video first.');
|
||||
const [rawTracks, primary, secondary] = await Promise.all([
|
||||
client.requestProperty('track-list'),
|
||||
client.requestProperty('sid'),
|
||||
client.requestProperty('secondary-sid'),
|
||||
]);
|
||||
const tracks: SubtitleSelectionState['tracks'] = [];
|
||||
const candidates: unknown[] = Array.isArray(rawTracks) ? rawTracks : [];
|
||||
for (const track of candidates) {
|
||||
if (
|
||||
typeof track !== 'object' ||
|
||||
track === null ||
|
||||
!('type' in track) ||
|
||||
track.type !== 'sub' ||
|
||||
!('id' in track) ||
|
||||
typeof track.id !== 'number' ||
|
||||
!Number.isSafeInteger(track.id) ||
|
||||
track.id <= 0
|
||||
)
|
||||
continue;
|
||||
const details = [
|
||||
'title' in track ? track.title : undefined,
|
||||
'lang' in track ? track.lang : undefined,
|
||||
'codec' in track ? track.codec : undefined,
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
if ('external' in track && track.external === true) details.push('external');
|
||||
tracks.push({ id: track.id, label: `#${track.id} · ${details.join(' · ') || 'Subtitle'}` });
|
||||
}
|
||||
if ((await client.requestProperty('path')) !== mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
const selected = (value: unknown): number | null =>
|
||||
tracks.find((track) => track.id === value)?.id ?? null;
|
||||
return { mediaPath, tracks, primary: selected(primary), secondary: selected(secondary) };
|
||||
}
|
||||
|
||||
async function apply(value: unknown): Promise<void> {
|
||||
const selection = parseSubtitleSelectionRequest(value);
|
||||
const client = getClient();
|
||||
const current = await readState(client);
|
||||
if (current.mediaPath !== selection.mediaPath)
|
||||
throw new Error('The video changed. Reopen subtitle selection.');
|
||||
for (const id of [selection.primary, selection.secondary]) {
|
||||
if (id !== null && !current.tracks.some((track) => track.id === id))
|
||||
throw new Error('A selected track is no longer available. Reopen subtitle selection.');
|
||||
}
|
||||
const set = async (property: string, id: number | null): Promise<void> => {
|
||||
const response = await client.request(['set_property', property, id ?? 'no']);
|
||||
if (response.error && response.error !== 'success') throw new Error(response.error);
|
||||
};
|
||||
// Clear secondary first so swapping the two tracks works in mpv.
|
||||
await set('secondary-sid', null);
|
||||
await set('sid', selection.primary);
|
||||
await set('secondary-sid', selection.secondary);
|
||||
}
|
||||
|
||||
return { getState: async () => readState(getClient()), apply };
|
||||
}
|
||||
|
||||
export function registerSubtitleSelectionIpc(deps: {
|
||||
ipc: Pick<IpcMain, 'handle'>;
|
||||
isAllowedSender: (sender: WebContents) => boolean;
|
||||
runtime: ReturnType<typeof createSubtitleSelectionRuntime>;
|
||||
}): void {
|
||||
deps.ipc.handle(IPC_CHANNELS.request.getSubtitleSelection, (event) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.getState();
|
||||
});
|
||||
deps.ipc.handle(IPC_CHANNELS.request.applySubtitleSelection, (event, value: unknown) => {
|
||||
if (!deps.isAllowedSender(event.sender))
|
||||
throw new Error('Subtitle selection requires the overlay.');
|
||||
return deps.runtime.apply(value);
|
||||
});
|
||||
}
|
||||
@@ -29,12 +29,12 @@ test('resolveWindowsMpvPath prefers SUBMINER_MPV_PATH', () => {
|
||||
assert.equal(resolved, 'C:\\mpv\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath prefers configured executable path before PATH', () => {
|
||||
test('resolveWindowsMpvPath prefers configured executable path before environment and PATH', () => {
|
||||
const resolved = resolveWindowsMpvPath(
|
||||
createDeps({
|
||||
getEnv: () => undefined,
|
||||
getEnv: () => 'C:\\other\\mpv.exe',
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
|
||||
fileExists: (candidate) => ['C:\\mpv\\mpv.exe', 'C:\\other\\mpv.exe'].includes(candidate),
|
||||
}),
|
||||
' C:\\mpv\\mpv.exe ',
|
||||
);
|
||||
@@ -53,6 +53,16 @@ test('resolveWindowsMpvPath falls back to where.exe output', () => {
|
||||
assert.equal(resolved, 'C:\\tools\\mpv.exe');
|
||||
});
|
||||
|
||||
test('resolveWindowsMpvPath ignores an invalid environment override but keeps config authoritative', () => {
|
||||
const deps = createDeps({
|
||||
getEnv: () => 'C:\\missing\\mpv.exe',
|
||||
runWhere: () => ({ status: 0, stdout: 'C:\\tools\\mpv.exe\r\n' }),
|
||||
fileExists: (candidate) => candidate === 'C:\\tools\\mpv.exe',
|
||||
});
|
||||
assert.equal(resolveWindowsMpvPath(deps), 'C:\\tools\\mpv.exe');
|
||||
assert.equal(resolveWindowsMpvPath(deps, 'C:\\missing\\mpv.exe'), '');
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs uses explicit SubMiner defaults and targets', () => {
|
||||
assert.deepEqual(
|
||||
buildWindowsMpvLaunchArgs(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { isLogFileEnabled } from '../../shared/log-files';
|
||||
import { canConnectSocket } from '../../shared/socket-probe';
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
@@ -8,11 +6,19 @@ import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
import {
|
||||
createWindowsMpvPathDeps,
|
||||
resolveWindowsMpvPath,
|
||||
spawnMpvProcess,
|
||||
type WindowsMpvPathDeps,
|
||||
} from './mpv-process';
|
||||
export {
|
||||
getConfiguredWindowsMpvPathStatus,
|
||||
resolveWindowsMpvPath,
|
||||
type ConfiguredWindowsMpvPathStatus,
|
||||
} from './mpv-process';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
getEnv: (name: string) => string | undefined;
|
||||
runWhere: () => { status: number | null; stdout: string; error?: Error };
|
||||
fileExists: (candidate: string) => boolean;
|
||||
export interface WindowsMpvLaunchDeps extends WindowsMpvPathDeps {
|
||||
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
|
||||
isAppControlServerAvailable?: () => Promise<boolean>;
|
||||
sendAppControlCommand?: (
|
||||
@@ -23,8 +29,6 @@ export interface WindowsMpvLaunchDeps {
|
||||
logInfo?: (message: string) => void;
|
||||
}
|
||||
|
||||
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
|
||||
|
||||
export interface WindowsMpvRuntimePluginPolicy {
|
||||
detectInstalledMpvPlugin?: (mpvPath: string) => InstalledMpvPluginDetection;
|
||||
notifyInstalledPluginDetected?: (detection: InstalledMpvPluginDetection) => void;
|
||||
@@ -38,54 +42,6 @@ function normalizeCandidate(candidate: string | undefined): string {
|
||||
return typeof candidate === 'string' ? candidate.trim() : '';
|
||||
}
|
||||
|
||||
function defaultWindowsMpvFileExists(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfiguredWindowsMpvPathStatus(
|
||||
configuredMpvPath = '',
|
||||
fileExists: (candidate: string) => boolean = defaultWindowsMpvFileExists,
|
||||
): ConfiguredWindowsMpvPathStatus {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
if (!configPath) {
|
||||
return 'blank';
|
||||
}
|
||||
return fileExists(configPath) ? 'configured' : 'invalid';
|
||||
}
|
||||
|
||||
export function resolveWindowsMpvPath(deps: WindowsMpvLaunchDeps, configuredMpvPath = ''): string {
|
||||
const configPath = normalizeCandidate(configuredMpvPath);
|
||||
const configuredPathStatus = getConfiguredWindowsMpvPathStatus(configPath, deps.fileExists);
|
||||
if (configuredPathStatus === 'configured') {
|
||||
return configPath;
|
||||
}
|
||||
if (configuredPathStatus === 'invalid') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const envPath = normalizeCandidate(deps.getEnv('SUBMINER_MPV_PATH'));
|
||||
if (envPath && deps.fileExists(envPath)) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const whereResult = deps.runWhere();
|
||||
if (whereResult.status === 0) {
|
||||
const firstPath = whereResult.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0 && deps.fileExists(line));
|
||||
if (firstPath) {
|
||||
return firstPath;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOWS_MPV_SOCKET = '\\\\.\\pipe\\subminer-socket';
|
||||
const RUNNING_APP_ATTACH_SOCKET_WAIT_MS = 10000;
|
||||
|
||||
@@ -332,19 +288,7 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
logInfo?: (message: string) => void;
|
||||
}): WindowsMpvLaunchDeps {
|
||||
return {
|
||||
getEnv: options.getEnv ?? ((name) => process.env[name]),
|
||||
runWhere: () => {
|
||||
const result = spawnSync('where.exe', ['mpv.exe'], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: result.stdout ?? '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
},
|
||||
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
|
||||
...createWindowsMpvPathDeps(options),
|
||||
isAppControlServerAvailable: options.isAppControlServerAvailable,
|
||||
sendAppControlCommand: options.sendAppControlCommand,
|
||||
waitForSocketReady,
|
||||
@@ -352,12 +296,11 @@ export function createWindowsMpvLaunchDeps(options: {
|
||||
spawnDetached: (command, args, env) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
});
|
||||
const child = spawnMpvProcess(
|
||||
command,
|
||||
args,
|
||||
env ? { ...process.env, ...env } : process.env,
|
||||
);
|
||||
let settled = false;
|
||||
child.once('error', (error) => {
|
||||
if (settled) return;
|
||||
|
||||
@@ -119,6 +119,7 @@ import { IPC_CHANNELS } from './shared/ipc/contracts';
|
||||
import type { SubtitleGenerationProgress } from './shared/subtitle-generation';
|
||||
|
||||
const overlayLayer = resolveOverlayLayerFromArgv(process.argv);
|
||||
const onSubtitleSelectionOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleSelectionOpen);
|
||||
const onSubtitleGenerationOpen = createQueuedIpcListener(IPC_CHANNELS.event.subtitleGenerationOpen);
|
||||
|
||||
type EmptyListener = () => void;
|
||||
@@ -501,6 +502,11 @@ const electronAPI: ElectronAPI = {
|
||||
) as Promise<boolean>,
|
||||
getSubtitleStyle: (): Promise<SubtitleStyleConfig | null> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleStyle),
|
||||
onSubtitleSelectionOpen,
|
||||
getSubtitleSelection: () => ipcRenderer.invoke(IPC_CHANNELS.request.getSubtitleSelection),
|
||||
applySubtitleSelection: (
|
||||
request: import('./shared/subtitle-selection').SubtitleSelectionRequest,
|
||||
) => ipcRenderer.invoke(IPC_CHANNELS.request.applySubtitleSelection, request),
|
||||
onSubsyncManualOpen: onSubsyncManualOpenEvent,
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest): Promise<SubsyncResult> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.request.runSubsyncManual, request),
|
||||
@@ -618,6 +624,14 @@ const electronAPI: ElectronAPI = {
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.reportOverlayContentBounds, measurement);
|
||||
},
|
||||
onSessionBindingsChanged: (
|
||||
callback: (bindings: import('./types').CompiledSessionBinding[]) => void,
|
||||
) => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.sessionBindingsChanged,
|
||||
(_event, bindings: import('./types').CompiledSessionBinding[]) => callback(bindings),
|
||||
);
|
||||
},
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => {
|
||||
ipcRenderer.on(
|
||||
IPC_CHANNELS.event.configHotReload,
|
||||
|
||||
@@ -4,6 +4,7 @@ type ControllerInteractionModalState = {
|
||||
jimakuModalOpen: boolean;
|
||||
kikuModalOpen: boolean;
|
||||
runtimeOptionsModalOpen: boolean;
|
||||
subtitleSelectionModalOpen?: boolean;
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleGenerationModalOpen?: boolean;
|
||||
youtubePickerModalOpen: boolean;
|
||||
@@ -18,6 +19,7 @@ export function isControllerInteractionBlocked(state: ControllerInteractionModal
|
||||
state.jimakuModalOpen ||
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subtitleSelectionModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
Boolean(state.subtitleGenerationModalOpen) ||
|
||||
state.youtubePickerModalOpen ||
|
||||
|
||||
@@ -92,6 +92,7 @@ function createEmptyShortcuts(): ConfiguredShortcuts {
|
||||
openRuntimeOptions: null,
|
||||
openJimaku: null,
|
||||
openTsukihime: null,
|
||||
openSubtitleSelection: null,
|
||||
openSubtitleGeneration: null,
|
||||
openSessionHelp: null,
|
||||
openControllerSelect: null,
|
||||
@@ -2404,3 +2405,86 @@ test('stalled mpv discovery does not delay configured overlay controls', async (
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('session binding: g-s opens subtitle selection only after the complete sequence', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.openSubtitleSelection',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, [
|
||||
{ actionId: 'openSubtitleSelection', payload: undefined },
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
handlers.updateSessionBindings([]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.equal(testGlobals.sessionActions.length, 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('single-key actions run immediately even if a conflicting sequence reaches the renderer', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'sequence',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
{
|
||||
sourcePath: 'single',
|
||||
originalKey: 'g',
|
||||
key: { code: 'KeyG', modifiers: [] },
|
||||
actionType: 'mpv-command',
|
||||
command: ['show-text', 'single'],
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
assert.deepEqual(testGlobals.mpvCommands, [['show-text', 'single']]);
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('an unfinished built-in y chord cannot start a configured sequence', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'sequence',
|
||||
originalKey: 'g-s',
|
||||
key: { code: 'KeyG-KeyS', modifiers: [] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'openSubtitleSelection',
|
||||
},
|
||||
]);
|
||||
testGlobals.dispatchKeydown({ key: 'y', code: 'KeyY' });
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.deepEqual(testGlobals.sessionActions, []);
|
||||
testGlobals.dispatchKeydown({ key: 'g', code: 'KeyG' });
|
||||
testGlobals.dispatchKeydown({ key: 's', code: 'KeyS' });
|
||||
assert.equal(testGlobals.sessionActions.length, 1);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ export function createKeyboardHandlers(
|
||||
handleRuntimeOptionsKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleCharacterDictionaryKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubsyncKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleSelectionKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleSubtitleGenerationKeydown?: (e: KeyboardEvent) => boolean;
|
||||
handleKikuKeydown: (e: KeyboardEvent) => boolean;
|
||||
handleJimakuKeydown: (e: KeyboardEvent) => boolean;
|
||||
@@ -132,7 +133,10 @@ export function createKeyboardHandlers(
|
||||
updateConfiguredShortcuts(shortcuts, statsToggleKey, markWatchedKey);
|
||||
}
|
||||
|
||||
let pendingSequence: { prefix: string; expires: number } | null = null;
|
||||
|
||||
function updateSessionBindings(bindings: CompiledSessionBinding[]): void {
|
||||
pendingSequence = null;
|
||||
ctx.state.sessionBindings = bindings;
|
||||
ctx.state.sessionBindingMap = new Map(
|
||||
bindings.map((binding) => [keyEventToStringFromBinding(binding), binding]),
|
||||
@@ -1048,7 +1052,10 @@ export function createKeyboardHandlers(
|
||||
window.addEventListener('focus', () => {
|
||||
void importedMpvBindings.refresh();
|
||||
});
|
||||
window.addEventListener('blur', importedMpvBindings.releaseAll);
|
||||
window.addEventListener('blur', () => {
|
||||
pendingSequence = null;
|
||||
importedMpvBindings.releaseAll();
|
||||
});
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearTimeout(lateScriptRefresh);
|
||||
importedMpvBindings.dispose();
|
||||
@@ -1102,6 +1109,13 @@ export function createKeyboardHandlers(
|
||||
);
|
||||
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
const sequence = pendingSequence;
|
||||
pendingSequence = null;
|
||||
if (ctx.state.subtitleSelectionModalOpen) {
|
||||
pendingSequence = null;
|
||||
options.handleSubtitleSelectionKeydown?.(e);
|
||||
return;
|
||||
}
|
||||
if (ctx.state.subtitleGenerationModalOpen) {
|
||||
options.handleSubtitleGenerationKeydown?.(e);
|
||||
return;
|
||||
@@ -1186,6 +1200,7 @@ export function createKeyboardHandlers(
|
||||
}
|
||||
|
||||
if (isTextEntryTarget(e.target)) {
|
||||
pendingSequence = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1193,6 +1208,16 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
const sequenceKey = keyEventToString(e);
|
||||
if (sequence && !ctx.state.chordPending && Date.now() <= sequence.expires && !e.repeat) {
|
||||
const binding = ctx.state.sessionBindingMap.get(`${sequence.prefix}-${sequenceKey}`);
|
||||
if (binding) {
|
||||
e.preventDefault();
|
||||
dispatchSessionBinding(binding);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isStatsOverlayToggle(e)) {
|
||||
e.preventDefault();
|
||||
window.electronAPI.toggleStatsOverlay();
|
||||
@@ -1275,6 +1300,16 @@ export function createKeyboardHandlers(
|
||||
dispatchSessionBinding(binding);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!e.repeat &&
|
||||
ctx.state.sessionBindings.some((binding) =>
|
||||
keyEventToStringFromBinding(binding).startsWith(`${sequenceKey}-`),
|
||||
)
|
||||
) {
|
||||
pendingSequence = { prefix: sequenceKey, expires: Date.now() + 1000 };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
keyboardConfigLoaded &&
|
||||
!ctx.state.playlistBrowserModalOpen &&
|
||||
|
||||
@@ -833,6 +833,43 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="subtitleSelectionModal" class="modal hidden" aria-hidden="true">
|
||||
<div
|
||||
class="modal-content subsync-modal-content"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subtitleSelectionTitle"
|
||||
>
|
||||
<div class="modal-header">
|
||||
<h2 id="subtitleSelectionTitle">Select subtitles</h2>
|
||||
<button id="subtitleSelectionClose" class="modal-close" type="button">Close</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="subsync-form">
|
||||
<label class="subsync-field">
|
||||
<span>Primary subtitle</span>
|
||||
<select id="subtitleSelectionPrimary"></select>
|
||||
</label>
|
||||
<label class="subsync-field">
|
||||
<span>Secondary subtitle</span>
|
||||
<select id="subtitleSelectionSecondary"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
id="subtitleSelectionStatus"
|
||||
class="runtime-options-status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
></div>
|
||||
<div class="subsync-footer">
|
||||
<button id="subtitleSelectionApply" class="kiku-confirm-button" type="button">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="subsyncModal" class="modal hidden" aria-hidden="true">
|
||||
<div class="modal-content subsync-modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -225,6 +225,8 @@ function describeSessionAction(
|
||||
return 'Open jimaku';
|
||||
case 'openTsukihime':
|
||||
return 'Open TsukiHime';
|
||||
case 'openSubtitleSelection':
|
||||
return 'Select subtitle tracks';
|
||||
case 'openSubtitleGeneration':
|
||||
return 'Generate Japanese subtitles';
|
||||
case 'openYoutubePicker':
|
||||
@@ -268,6 +270,7 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
|
||||
case 'openJimaku':
|
||||
case 'openTsukihime':
|
||||
case 'openCharacterDictionaryManager':
|
||||
case 'openSubtitleSelection':
|
||||
case 'openSubtitleGeneration':
|
||||
case 'openControllerSelect':
|
||||
case 'openControllerDebug':
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { SubtitleSelectionState } from '../../shared/subtitle-selection';
|
||||
import type { RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore';
|
||||
import { createModalFocusGuard } from './modal-focus-guard';
|
||||
|
||||
function element<T extends HTMLElement>(id: string, constructor: new () => T): T {
|
||||
const node = document.getElementById(id);
|
||||
if (!(node instanceof constructor)) throw new Error(`Missing subtitle selection element: ${id}`);
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createSubtitleSelectionModal(
|
||||
ctx: RendererContext,
|
||||
options: { syncSettingsModalSubtitleSuppression: () => void },
|
||||
) {
|
||||
const dom = {
|
||||
modal: element('subtitleSelectionModal', HTMLDivElement),
|
||||
primary: element('subtitleSelectionPrimary', HTMLSelectElement),
|
||||
secondary: element('subtitleSelectionSecondary', HTMLSelectElement),
|
||||
status: element('subtitleSelectionStatus', HTMLDivElement),
|
||||
apply: element('subtitleSelectionApply', HTMLButtonElement),
|
||||
close: element('subtitleSelectionClose', HTMLButtonElement),
|
||||
};
|
||||
let snapshot: SubtitleSelectionState | null = null;
|
||||
let generation = 0;
|
||||
let pending = false;
|
||||
let priorFocus: Element | null = null;
|
||||
const focus = createModalFocusGuard({
|
||||
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
||||
getModalRoot: () => dom.modal,
|
||||
getPreferredFocusTargets: () => [dom.primary, dom.secondary, dom.apply],
|
||||
getFallbackFocusTarget: () => dom.close,
|
||||
isModalLayer: ctx.platform.isModalLayer,
|
||||
});
|
||||
|
||||
function status(message: string, error = false): void {
|
||||
dom.status.textContent = message;
|
||||
dom.status.classList.toggle('error', error);
|
||||
}
|
||||
|
||||
function updateControls(): void {
|
||||
const disabled = pending || !snapshot;
|
||||
dom.primary.disabled = disabled;
|
||||
dom.secondary.disabled = disabled;
|
||||
const duplicate = dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value;
|
||||
dom.apply.disabled = disabled || duplicate;
|
||||
for (const option of dom.secondary.options)
|
||||
option.disabled = option.value !== 'no' && option.value === dom.primary.value;
|
||||
}
|
||||
|
||||
function populate(select: HTMLSelectElement, selected: number | null): void {
|
||||
select.replaceChildren();
|
||||
for (const track of [{ id: null, label: 'None' }, ...(snapshot?.tracks ?? [])]) {
|
||||
const option = document.createElement('option');
|
||||
option.value = track.id === null ? 'no' : String(track.id);
|
||||
option.textContent = track.label;
|
||||
select.append(option);
|
||||
}
|
||||
select.value = selected === null ? 'no' : String(selected);
|
||||
}
|
||||
|
||||
async function refresh(openGeneration: number): Promise<void> {
|
||||
try {
|
||||
const next = await window.electronAPI.getSubtitleSelection();
|
||||
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
|
||||
snapshot = next;
|
||||
populate(dom.primary, next.primary);
|
||||
populate(dom.secondary, next.secondary);
|
||||
status(
|
||||
next.tracks.length
|
||||
? 'Choose subtitle tracks, then apply.'
|
||||
: 'No subtitle tracks loaded in this video.',
|
||||
);
|
||||
} catch (cause) {
|
||||
if (generation !== openGeneration || !ctx.state.subtitleSelectionModalOpen) return;
|
||||
status(cause instanceof Error ? cause.message : 'Could not read subtitle tracks.', true);
|
||||
} finally {
|
||||
if (generation === openGeneration && ctx.state.subtitleSelectionModalOpen) {
|
||||
pending = false;
|
||||
updateControls();
|
||||
focus.focusFallbackTarget();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
if (ctx.state.subtitleSelectionModalOpen) return;
|
||||
priorFocus = document.activeElement;
|
||||
snapshot = null;
|
||||
pending = true;
|
||||
generation += 1;
|
||||
populate(dom.primary, null);
|
||||
populate(dom.secondary, null);
|
||||
status('Loading subtitle tracks...');
|
||||
updateControls();
|
||||
ctx.state.subtitleSelectionModalOpen = true;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.remove('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'false');
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
focus.attach();
|
||||
focus.focusFallbackTarget();
|
||||
window.electronAPI.notifyOverlayModalOpened('subtitle-selection');
|
||||
void refresh(generation);
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (!ctx.state.subtitleSelectionModalOpen) return;
|
||||
generation += 1;
|
||||
ctx.state.subtitleSelectionModalOpen = false;
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
dom.modal.classList.add('hidden');
|
||||
dom.modal.setAttribute('aria-hidden', 'true');
|
||||
focus.detach();
|
||||
window.electronAPI.notifyOverlayModalClosed('subtitle-selection');
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
if (priorFocus instanceof HTMLElement) priorFocus.focus({ preventScroll: true });
|
||||
priorFocus = null;
|
||||
}
|
||||
|
||||
async function apply(): Promise<void> {
|
||||
if (dom.apply.disabled || pending || !snapshot) return;
|
||||
const openGeneration = generation;
|
||||
pending = true;
|
||||
updateControls();
|
||||
status('Applying subtitle tracks...');
|
||||
try {
|
||||
await window.electronAPI.applySubtitleSelection({
|
||||
mediaPath: snapshot.mediaPath,
|
||||
primary: dom.primary.value === 'no' ? null : Number(dom.primary.value),
|
||||
secondary: dom.secondary.value === 'no' ? null : Number(dom.secondary.value),
|
||||
});
|
||||
if (generation === openGeneration) close();
|
||||
} catch (cause) {
|
||||
if (generation === openGeneration)
|
||||
status(cause instanceof Error ? cause.message : 'Could not select subtitle tracks.', true);
|
||||
} finally {
|
||||
if (generation === openGeneration) {
|
||||
pending = false;
|
||||
updateControls();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
close();
|
||||
} else if (
|
||||
event.key === 'Enter' &&
|
||||
!(event.target instanceof HTMLSelectElement) &&
|
||||
event.target !== dom.close
|
||||
) {
|
||||
event.preventDefault();
|
||||
void apply();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
dom.close.addEventListener('click', close);
|
||||
dom.apply.addEventListener('click', () => void apply());
|
||||
dom.primary.addEventListener('change', () => {
|
||||
if (dom.primary.value !== 'no' && dom.primary.value === dom.secondary.value)
|
||||
dom.secondary.value = 'no';
|
||||
updateControls();
|
||||
});
|
||||
dom.secondary.addEventListener('change', updateControls);
|
||||
}
|
||||
|
||||
return { open, close, handleKeydown, wireDomEvents, dispose: () => focus.detach() };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ function isBlockingOverlayModalOpen(state: RendererState): boolean {
|
||||
state.youtubePickerModalOpen ||
|
||||
state.kikuModalOpen ||
|
||||
state.runtimeOptionsModalOpen ||
|
||||
state.subtitleSelectionModalOpen ||
|
||||
state.subsyncModalOpen ||
|
||||
state.subtitleGenerationModalOpen ||
|
||||
state.sessionHelpModalOpen,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { wireSubtitleSidebarSelection } from './modals/subtitle-sidebar-selectio
|
||||
import { isControllerInteractionBlocked } from './controller-interaction-blocking.js';
|
||||
import { createCharacterDictionaryModal } from './modals/character-dictionary.js';
|
||||
import { createRuntimeOptionsModal } from './modals/runtime-options.js';
|
||||
import { createSubtitleSelectionModal } from './modals/subtitle-selection';
|
||||
import { createSubsyncModal } from './modals/subsync.js';
|
||||
import { createSubtitleGenerationModal } from './modals/subtitle-generation.js';
|
||||
import { createYoutubeTrackPickerModal } from './modals/youtube-track-picker.js';
|
||||
@@ -153,6 +154,12 @@ const modalDescriptors = [
|
||||
close: () => characterDictionaryModal.closeCharacterDictionaryModal(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'subtitle-selection',
|
||||
isOpen: () => ctx.state.subtitleSelectionModalOpen,
|
||||
close: () => subtitleSelectionModal.close(),
|
||||
suppressesSubtitles: true,
|
||||
},
|
||||
{
|
||||
id: 'subsync',
|
||||
isOpen: () => ctx.state.subsyncModalOpen,
|
||||
@@ -216,6 +223,9 @@ const characterDictionaryModal = createCharacterDictionaryModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subtitleSelectionModal = createSubtitleSelectionModal(ctx, {
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
});
|
||||
const subsyncModal = createSubsyncModal(ctx, {
|
||||
modalStateReader: { isAnyModalOpen },
|
||||
syncSettingsModalSubtitleSuppression,
|
||||
@@ -293,6 +303,7 @@ const mediaTimingReviewModal = createMediaTimingReviewModal(ctx, {
|
||||
const keyboardHandlers = createKeyboardHandlers(ctx, {
|
||||
handleRuntimeOptionsKeydown: runtimeOptionsModal.handleRuntimeOptionsKeydown,
|
||||
handleCharacterDictionaryKeydown: characterDictionaryModal.handleCharacterDictionaryKeydown,
|
||||
handleSubtitleSelectionKeydown: subtitleSelectionModal.handleKeydown,
|
||||
handleSubsyncKeydown: subsyncModal.handleSubsyncKeydown,
|
||||
handleSubtitleGenerationKeydown: subtitleGenerationModal.handleKeydown,
|
||||
handleKikuKeydown: kikuModal.handleKikuKeydown,
|
||||
@@ -624,6 +635,9 @@ function registerModalOpenHandlers(): void {
|
||||
youtubePickerModal.closeYoutubePickerModal();
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSubtitleSelectionOpen(() => {
|
||||
runGuarded('subtitle-selection:open', () => subtitleSelectionModal.open());
|
||||
});
|
||||
window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => {
|
||||
runGuarded('subsync:manual-open', () => {
|
||||
subsyncModal.openSubsyncModal(payload);
|
||||
@@ -849,6 +863,7 @@ async function init(): Promise<void> {
|
||||
playlistBrowserModal.wireDomEvents();
|
||||
kikuModal.wireDomEvents();
|
||||
runtimeOptionsModal.wireDomEvents();
|
||||
subtitleSelectionModal.wireDomEvents();
|
||||
subsyncModal.wireDomEvents();
|
||||
subtitleGenerationModal.wireDomEvents();
|
||||
controllerSelectModal.wireDomEvents();
|
||||
@@ -858,6 +873,7 @@ async function init(): Promise<void> {
|
||||
subtitleSidebarModal.wireDomEvents();
|
||||
characterDictionaryModal.wireDomEvents();
|
||||
window.addEventListener('beforeunload', () => {
|
||||
subtitleSelectionModal.dispose();
|
||||
subtitleGenerationModal.dispose();
|
||||
subtitleSidebarModal.disposeDomEvents();
|
||||
});
|
||||
@@ -867,9 +883,13 @@ async function init(): Promise<void> {
|
||||
runtimeOptionsModal.updateRuntimeOptions(options);
|
||||
});
|
||||
});
|
||||
window.electronAPI.onSessionBindingsChanged(keyboardHandlers.updateSessionBindings);
|
||||
window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => {
|
||||
runGuarded('config:hot-reload', () => {
|
||||
keyboardHandlers.updateSessionBindings(payload.sessionBindings);
|
||||
void window.electronAPI
|
||||
.getSessionBindings()
|
||||
.then(keyboardHandlers.updateSessionBindings)
|
||||
.catch((error: unknown) => console.error('Could not refresh session bindings', error));
|
||||
void keyboardHandlers.refreshConfiguredShortcuts();
|
||||
subtitleRenderer.applySubtitleStyle(payload.subtitleStyle);
|
||||
subtitleRenderer.updatePrimarySubMode(payload.primarySubMode);
|
||||
|
||||
@@ -89,6 +89,7 @@ export type RendererState = {
|
||||
characterDictionaryStatus: string;
|
||||
|
||||
subsyncModalOpen: boolean;
|
||||
subtitleSelectionModalOpen: boolean;
|
||||
subtitleGenerationModalOpen: boolean;
|
||||
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
|
||||
subsyncSubmitting: boolean;
|
||||
@@ -223,6 +224,7 @@ export function createRendererState(): RendererState {
|
||||
characterDictionaryStatus: '',
|
||||
|
||||
subsyncModalOpen: false,
|
||||
subtitleSelectionModalOpen: false,
|
||||
subtitleGenerationModalOpen: false,
|
||||
subsyncSubtitleTracks: [],
|
||||
subsyncSubmitting: false,
|
||||
|
||||
@@ -3228,6 +3228,16 @@ iframe[id^='yomitan-popup'],
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.subsync-field select:focus-visible {
|
||||
outline: 2px solid var(--ctp-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#subtitleSelectionModal .kiku-confirm-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.subsync-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeOptionId, RuntimeOptionValue } from '../../types/runtime-op
|
||||
export const OVERLAY_HOSTED_MODALS = [
|
||||
'runtime-options',
|
||||
'subsync',
|
||||
'subtitle-selection',
|
||||
'subtitle-generation',
|
||||
'jimaku',
|
||||
'tsukihime',
|
||||
@@ -51,6 +52,8 @@ export const IPC_CHANNELS = {
|
||||
dispatchSessionAction: 'session-action:dispatch',
|
||||
},
|
||||
request: {
|
||||
getSubtitleSelection: 'subtitle-selection:get',
|
||||
applySubtitleSelection: 'subtitle-selection:apply',
|
||||
requestSubtitleGenerationOpen: 'subtitle-generation:open',
|
||||
getSubtitleGenerationStatus: 'subtitle-generation:status',
|
||||
startSubtitleGeneration: 'subtitle-generation:start',
|
||||
@@ -143,6 +146,7 @@ export const IPC_CHANNELS = {
|
||||
mediaTimingReviewResolve: 'media-timing-review:resolve',
|
||||
},
|
||||
event: {
|
||||
subtitleSelectionOpen: 'subtitle-selection:opened',
|
||||
subtitleGenerationOpen: 'subtitle-generation:opened',
|
||||
subtitleGenerationProgress: 'subtitle-generation:progress',
|
||||
subtitleSet: 'subtitle:set',
|
||||
@@ -174,6 +178,7 @@ export const IPC_CHANNELS = {
|
||||
controllerDebugOpen: 'controller-debug:open',
|
||||
subtitleSidebarToggle: 'subtitle-sidebar:toggle',
|
||||
primarySubtitleBarToggle: 'primary-subtitle-bar:toggle',
|
||||
sessionBindingsChanged: 'session-bindings:changed',
|
||||
configHotReload: 'config:hot-reload',
|
||||
overlayNotification: 'overlay:notification',
|
||||
notificationHistoryToggle: 'notification-history:toggle',
|
||||
|
||||
@@ -44,6 +44,7 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'openControllerDebug',
|
||||
'openJimaku',
|
||||
'openTsukihime',
|
||||
'openSubtitleSelection',
|
||||
'openSubtitleGeneration',
|
||||
'openYoutubePicker',
|
||||
'openPlaylistBrowser',
|
||||
|
||||
@@ -103,3 +103,13 @@ test('SubMiner ownership recognizes leading mpv prefixes without matching comman
|
||||
['d', 'e', 'f', 'g'],
|
||||
);
|
||||
});
|
||||
|
||||
test('sequence conflict discovery allows winning ignore bindings used by mpv sequence prefixes', () => {
|
||||
const bindings = [
|
||||
{ key: 'g', cmd: 'show-text old', priority: 0 },
|
||||
{ key: 'g', cmd: 'no-osd ignore', priority: 1 },
|
||||
{ key: 'h', cmd: 'show-text action', priority: 0 },
|
||||
];
|
||||
assert.deepEqual(parseMpvInputBindingKeys(bindings), ['g', 'h']);
|
||||
assert.deepEqual(parseMpvInputBindingKeys(bindings, { includeIgnored: false }), ['h']);
|
||||
});
|
||||
|
||||
@@ -59,9 +59,12 @@ export function keyboardEventToMpvKey(
|
||||
return normalizeMpvInputKey([...modifiers, key].join('+'));
|
||||
}
|
||||
|
||||
export function parseMpvInputBindingKeys(value: unknown): string[] {
|
||||
export function parseMpvInputBindingKeys(
|
||||
value: unknown,
|
||||
{ includeIgnored = true }: { includeIgnored?: boolean } = {},
|
||||
): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const bindings = new Map<string, { priority: number; owned: boolean }>();
|
||||
const bindings = new Map<string, { priority: number; owned: boolean; ignored: boolean }>();
|
||||
for (const candidate of value) {
|
||||
const entry: unknown = candidate;
|
||||
if (
|
||||
@@ -94,8 +97,14 @@ export function parseMpvInputBindingKeys(value: unknown): string[] {
|
||||
entry.priority > previous.priority ||
|
||||
(entry.priority === previous.priority && owned)
|
||||
) {
|
||||
bindings.set(key, { priority: entry.priority, owned });
|
||||
bindings.set(key, {
|
||||
priority: entry.priority,
|
||||
owned,
|
||||
ignored: entry.cmd.trim().replace(MPV_COMMAND_PREFIXES, '') === 'ignore',
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...bindings].filter(([, binding]) => !binding.owned).map(([key]) => key);
|
||||
return [...bindings]
|
||||
.filter(([, binding]) => !binding.owned && (includeIgnored || !binding.ignored))
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
CompiledSessionBinding,
|
||||
SessionBindingWarning,
|
||||
SessionKeySpec,
|
||||
} from '../types/session-bindings';
|
||||
|
||||
export interface SessionKeyReservation {
|
||||
key: SessionKeySpec;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function getSessionSequencePrefix(key: SessionKeySpec): SessionKeySpec | null {
|
||||
const match = /^(Key[A-Z])-Key[A-Z]$/.exec(key.code);
|
||||
return match?.[1] ? { code: match[1], modifiers: key.modifiers } : null;
|
||||
}
|
||||
|
||||
function signature(key: SessionKeySpec): string {
|
||||
return [...key.modifiers, key.code].join('+');
|
||||
}
|
||||
|
||||
export function resolveSessionSequenceConflicts(
|
||||
bindings: CompiledSessionBinding[],
|
||||
reservations: SessionKeyReservation[] = [],
|
||||
): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[] } {
|
||||
const singles = new Map<string, string[]>();
|
||||
for (const { key, path } of [
|
||||
...bindings.map((binding) => ({ key: binding.key, path: binding.sourcePath })),
|
||||
...reservations,
|
||||
]) {
|
||||
if (getSessionSequencePrefix(key)) continue;
|
||||
const id = signature(key);
|
||||
singles.set(id, [...(singles.get(id) ?? []), path]);
|
||||
}
|
||||
const warnings: SessionBindingWarning[] = [];
|
||||
const effective = bindings.filter((binding) => {
|
||||
const prefix = getSessionSequencePrefix(binding.key);
|
||||
if (!prefix) return true;
|
||||
const conflicts = singles.get(signature(prefix));
|
||||
if (!conflicts?.length) return true;
|
||||
const paths = [...new Set(conflicts)];
|
||||
warnings.push({
|
||||
kind: 'conflict',
|
||||
path: binding.sourcePath,
|
||||
value: binding.originalKey,
|
||||
conflictingPaths: paths,
|
||||
message: `Disabled sequence "${binding.originalKey}" (${binding.sourcePath}): its first key is reserved by ${paths.join(', ')}. Single-key bindings take priority; remap the sequence or its conflicting binding.`,
|
||||
});
|
||||
return false;
|
||||
});
|
||||
return { bindings: effective, warnings };
|
||||
}
|
||||
|
||||
// Imported mpv keys preserve case: g and G are different strokes.
|
||||
export function reserveMpvSequencePrefixes(keys: string[]): SessionKeyReservation[] {
|
||||
return keys.flatMap((value) => {
|
||||
const parts = value.split('+');
|
||||
const letter = parts.pop();
|
||||
if (!letter || !/^[a-z]$/i.test(letter)) return [];
|
||||
const modifiers: SessionKeySpec['modifiers'] = [];
|
||||
if (parts.includes('ctrl')) modifiers.push('ctrl');
|
||||
if (parts.includes('alt')) modifiers.push('alt');
|
||||
if (parts.includes('shift') || /^[A-Z]$/.test(letter)) modifiers.push('shift');
|
||||
if (parts.includes('meta')) modifiers.push('meta');
|
||||
return [
|
||||
{
|
||||
key: { code: `Key${letter.toUpperCase()}`, modifiers },
|
||||
path: `mpv input binding "${value}"`,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface SubtitleSelectionState {
|
||||
mediaPath: string;
|
||||
tracks: { id: number; label: string }[];
|
||||
primary: number | null;
|
||||
secondary: number | null;
|
||||
}
|
||||
|
||||
export type SubtitleSelectionRequest = Pick<
|
||||
SubtitleSelectionState,
|
||||
'mediaPath' | 'primary' | 'secondary'
|
||||
>;
|
||||
|
||||
export function parseSubtitleSelectionRequest(value: unknown): SubtitleSelectionRequest {
|
||||
if (
|
||||
typeof value !== 'object' ||
|
||||
value === null ||
|
||||
!('mediaPath' in value) ||
|
||||
typeof value.mediaPath !== 'string' ||
|
||||
!value.mediaPath ||
|
||||
!('primary' in value) ||
|
||||
!isTrackSelection(value.primary) ||
|
||||
!('secondary' in value) ||
|
||||
!isTrackSelection(value.secondary)
|
||||
)
|
||||
throw new Error('Invalid subtitle selection.');
|
||||
if (value.primary !== null && value.primary === value.secondary)
|
||||
throw new Error('Choose different primary and secondary tracks.');
|
||||
return { mediaPath: value.mediaPath, primary: value.primary, secondary: value.secondary };
|
||||
}
|
||||
|
||||
function isTrackSelection(value: unknown): value is number | null {
|
||||
return value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value > 0);
|
||||
}
|
||||
@@ -126,6 +126,7 @@ export interface ShortcutsConfig {
|
||||
openRuntimeOptions?: string | null;
|
||||
openJimaku?: string | null;
|
||||
openTsukihime?: string | null;
|
||||
openSubtitleSelection?: string | null;
|
||||
openSubtitleGeneration?: string | null;
|
||||
openSessionHelp?: string | null;
|
||||
openControllerSelect?: string | null;
|
||||
@@ -156,6 +157,7 @@ export interface Config {
|
||||
shortcuts?: RawShortcutsConfig;
|
||||
secondarySub?: SecondarySubConfig;
|
||||
subsync?: SubsyncConfig;
|
||||
subtitleSelection?: { enabled?: boolean };
|
||||
subtitleGeneration?: Partial<SubtitleGenerationConfig>;
|
||||
startupWarmups?: StartupWarmupsConfig;
|
||||
subtitleStyle?: SubtitleStyleConfig;
|
||||
@@ -310,6 +312,7 @@ export interface ResolvedConfig {
|
||||
shortcuts: Required<ShortcutsConfig>;
|
||||
secondarySub: Required<SecondarySubConfig>;
|
||||
subsync: Required<SubsyncConfig>;
|
||||
subtitleSelection: { enabled: boolean };
|
||||
subtitleGeneration: SubtitleGenerationConfig;
|
||||
startupWarmups: {
|
||||
lowPowerMode: boolean;
|
||||
|
||||
@@ -525,6 +525,13 @@ export interface ElectronAPI {
|
||||
focusMainWindow: () => Promise<void>;
|
||||
activatePlaybackWindowForOverlayInteraction: () => Promise<boolean>;
|
||||
getSubtitleStyle: () => Promise<SubtitleRendererStyleConfig | null>;
|
||||
onSubtitleSelectionOpen: (callback: () => void) => void;
|
||||
getSubtitleSelection: () => Promise<
|
||||
import('../shared/subtitle-selection').SubtitleSelectionState
|
||||
>;
|
||||
applySubtitleSelection: (
|
||||
request: import('../shared/subtitle-selection').SubtitleSelectionRequest,
|
||||
) => Promise<void>;
|
||||
onSubsyncManualOpen: (callback: (payload: SubsyncManualPayload) => void) => void;
|
||||
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<SubsyncResult>;
|
||||
onKikuFieldGroupingRequest: (callback: (data: KikuFieldGroupingRequestData) => void) => void;
|
||||
@@ -604,6 +611,7 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-selection'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
@@ -622,6 +630,7 @@ export interface ElectronAPI {
|
||||
modal:
|
||||
| 'runtime-options'
|
||||
| 'subsync'
|
||||
| 'subtitle-selection'
|
||||
| 'subtitle-generation'
|
||||
| 'jimaku'
|
||||
| 'tsukihime'
|
||||
@@ -637,6 +646,7 @@ export interface ElectronAPI {
|
||||
| 'changelog',
|
||||
) => void;
|
||||
reportOverlayContentBounds: (measurement: OverlayContentMeasurement) => void;
|
||||
onSessionBindingsChanged: (callback: (bindings: CompiledSessionBinding[]) => void) => void;
|
||||
onConfigHotReload: (callback: (payload: ConfigHotReloadPayload) => void) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SessionActionId =
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openTsukihime'
|
||||
| 'openSubtitleSelection'
|
||||
| 'openSubtitleGeneration'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
|
||||
Reference in New Issue
Block a user