mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 18:12:05 -07:00
Fix Windows YouTube playback flow and overlay pointer tracking
This commit is contained in:
@@ -16,6 +16,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
|
||||
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
|
||||
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||
@@ -38,7 +40,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 26);
|
||||
assert.equal(calls.length, 28);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
|
||||
@@ -6,6 +6,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
clearYomitanParserState: () => void;
|
||||
stopWindowTracker: () => void;
|
||||
@@ -34,6 +36,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
deps.clearYomitanParserState();
|
||||
deps.stopWindowTracker();
|
||||
|
||||
@@ -18,6 +18,16 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
stopTexthookerService: () => calls.push('stop-texthooker'),
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
}),
|
||||
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||
getModalOverlayWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||
}),
|
||||
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => false,
|
||||
@@ -61,6 +71,10 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
cleanup();
|
||||
|
||||
assert.ok(calls.includes('destroy-tray'));
|
||||
assert.ok(calls.includes('destroy-main-overlay-window'));
|
||||
assert.ok(calls.includes('clear-main-overlay-window'));
|
||||
assert.ok(calls.includes('destroy-modal-overlay-window'));
|
||||
assert.ok(calls.includes('clear-modal-overlay-window'));
|
||||
assert.ok(calls.includes('destroy-yomitan-window'));
|
||||
assert.ok(calls.includes('flush-mpv-log'));
|
||||
assert.ok(calls.includes('destroy-socket'));
|
||||
@@ -85,6 +99,16 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getMainOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-main-overlay-window'),
|
||||
}),
|
||||
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
|
||||
getModalOverlayWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-modal-overlay-window'),
|
||||
}),
|
||||
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
|
||||
getYomitanParserWindow: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
|
||||
@@ -25,6 +25,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
clearModalOverlayWindow: () => void;
|
||||
|
||||
getYomitanParserWindow: () => DestroyableWindow | null;
|
||||
clearYomitanParserState: () => void;
|
||||
@@ -60,6 +64,20 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
stopTexthookerService: () => deps.stopTexthookerService(),
|
||||
destroyMainOverlayWindow: () => {
|
||||
const window = deps.getMainOverlayWindow();
|
||||
if (!window) return;
|
||||
if (window.isDestroyed()) return;
|
||||
window.destroy();
|
||||
deps.clearMainOverlayWindow();
|
||||
},
|
||||
destroyModalOverlayWindow: () => {
|
||||
const window = deps.getModalOverlayWindow();
|
||||
if (!window) return;
|
||||
if (window.isDestroyed()) return;
|
||||
window.destroy();
|
||||
deps.clearModalOverlayWindow();
|
||||
},
|
||||
destroyYomitanParserWindow: () => {
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -20,6 +20,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
clearModalOverlayWindow: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
|
||||
@@ -12,8 +12,9 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
@@ -36,8 +37,9 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: () => {
|
||||
calls.push('schedule');
|
||||
@@ -52,6 +54,28 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
|
||||
});
|
||||
|
||||
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => true,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => true,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
callback();
|
||||
},
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
handler({ connected: false });
|
||||
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
|
||||
});
|
||||
|
||||
test('mpv subtitle timing handler ignores blank subtitle lines', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
|
||||
@@ -22,8 +22,9 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -36,8 +37,13 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
return;
|
||||
}
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
if (!deps.hasInitialJellyfinPlayArg()) return;
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
if (!deps.hasInitialPlaybackQuitOnDisconnectArg()) return;
|
||||
if (
|
||||
deps.isOverlayRuntimeInitialized() &&
|
||||
!deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!deps.isQuitOnDisconnectArmed()) return;
|
||||
deps.scheduleQuitCheck(() => {
|
||||
if (deps.isMpvConnected()) return;
|
||||
|
||||
@@ -10,8 +10,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialJellyfinPlayArg: () => false,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {
|
||||
calls.push('schedule-quit-check');
|
||||
|
||||
@@ -23,8 +23,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -77,8 +78,11 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||
deps.hasInitialPlaybackQuitOnDisconnectArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(),
|
||||
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
|
||||
@@ -61,7 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
|
||||
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isQuitOnDisconnectArmed(), true);
|
||||
assert.equal(deps.isMpvConnected(), true);
|
||||
@@ -158,3 +158,59 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
|
||||
deps.setCurrentSubText('sub');
|
||||
assert.equal(typeof deps.setCurrentSubText, 'function');
|
||||
});
|
||||
|
||||
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
|
||||
const recorded: number[] = [];
|
||||
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
|
||||
appState: {
|
||||
initialArgs: null,
|
||||
overlayRuntimeInitialized: true,
|
||||
mpvClient: {
|
||||
connected: false,
|
||||
currentTimePos: 42,
|
||||
requestProperty: async () => {
|
||||
throw new Error('disconnected');
|
||||
},
|
||||
},
|
||||
immersionTracker: {
|
||||
recordPlaybackPosition: (time: number) => {
|
||||
recorded.push(time);
|
||||
},
|
||||
},
|
||||
subtitleTimingTracker: null,
|
||||
currentMediaPath: '',
|
||||
currentSubText: '',
|
||||
currentSubAssText: '',
|
||||
playbackPaused: null,
|
||||
previousSecondarySubVisibility: false,
|
||||
},
|
||||
getQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
quitApp: () => {},
|
||||
reportJellyfinRemoteStopped: () => {},
|
||||
syncOverlayMpvSubtitleSuppression: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
broadcastToOverlayWindows: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
ensureImmersionTrackerInitialized: () => {},
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
resetSubtitleSidebarEmbeddedLayout: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
refreshDiscordPresence: () => {},
|
||||
})();
|
||||
|
||||
deps.flushPlaybackPositionOnMediaPathClear?.('');
|
||||
await Promise.resolve();
|
||||
|
||||
assert.deepEqual(recorded, [42]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MergedToken, SubtitleData } from '../../types';
|
||||
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
||||
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient:
|
||||
| {
|
||||
@@ -79,8 +79,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
Boolean(deps.appState.initialArgs?.youtubePlay),
|
||||
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
@@ -187,17 +190,26 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
if (!mpvClient?.requestProperty) {
|
||||
return;
|
||||
}
|
||||
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
const resolvedTime = Number(timePos);
|
||||
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
||||
return;
|
||||
}
|
||||
writePlaybackPositionFromMpv(resolvedTime);
|
||||
});
|
||||
void mpvClient
|
||||
.requestProperty('time-pos')
|
||||
.then((timePos) => {
|
||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
const resolvedTime = Number(timePos);
|
||||
if (
|
||||
Number.isFinite(currentKnownTime) &&
|
||||
Number.isFinite(resolvedTime) &&
|
||||
currentKnownTime === resolvedTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
writePlaybackPositionFromMpv(resolvedTime);
|
||||
})
|
||||
.catch(() => {
|
||||
// mpv can disconnect while clearing media; keep the last cached position.
|
||||
});
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||
deps.updateSubtitleRenderMetrics(patch),
|
||||
|
||||
20
src/main/runtime/startup-tray-policy.test.ts
Normal file
20
src/main/runtime/startup-tray-policy.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
|
||||
|
||||
test('startup tray policy enables tray on Windows by default', () => {
|
||||
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
|
||||
});
|
||||
|
||||
test('startup tray policy skips tray for direct youtube playback on Windows', () => {
|
||||
assert.equal(
|
||||
shouldEnsureTrayOnStartupForInitialArgs('win32', {
|
||||
youtubePlay: 'https://www.youtube.com/watch?v=abc',
|
||||
} as never),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('startup tray policy skips tray outside Windows', () => {
|
||||
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
|
||||
});
|
||||
14
src/main/runtime/startup-tray-policy.ts
Normal file
14
src/main/runtime/startup-tray-policy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CliArgs } from '../../cli/args';
|
||||
|
||||
export function shouldEnsureTrayOnStartupForInitialArgs(
|
||||
platform: NodeJS.Platform,
|
||||
initialArgs: CliArgs | null,
|
||||
): boolean {
|
||||
if (platform !== 'win32') {
|
||||
return false;
|
||||
}
|
||||
if (initialArgs?.youtubePlay) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -378,6 +378,73 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
const runtime = createYoutubeFlowRuntime({
|
||||
probeYoutubeTracks: async () => ({
|
||||
videoId: 'video123',
|
||||
title: 'Video 123',
|
||||
tracks: [primaryTrack],
|
||||
}),
|
||||
acquireYoutubeSubtitleTracks: async () => new Map(),
|
||||
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
|
||||
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
|
||||
openPicker: async (payload) => {
|
||||
queueMicrotask(() => {
|
||||
void runtime.resolveActivePicker({
|
||||
sessionId: payload.sessionId,
|
||||
action: 'use-selected',
|
||||
primaryTrackId: primaryTrack.id,
|
||||
secondaryTrackId: null,
|
||||
});
|
||||
});
|
||||
return true;
|
||||
},
|
||||
pauseMpv: () => {},
|
||||
resumeMpv: () => {},
|
||||
sendMpvCommand: () => {},
|
||||
requestMpvProperty: async (name) => {
|
||||
if (name === 'sub-text') {
|
||||
throw new Error("Failed to read MPV property 'sub-text': property unavailable");
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 5,
|
||||
lang: 'ja-orig',
|
||||
title: 'primary',
|
||||
external: true,
|
||||
'external-filename': '/tmp/auto-ja-orig.vtt',
|
||||
},
|
||||
];
|
||||
},
|
||||
refreshCurrentSubtitle: () => {
|
||||
throw new Error('should not refresh when sub-text is unavailable');
|
||||
},
|
||||
startTokenizationWarmups: async () => {},
|
||||
waitForTokenizationReady: async () => {},
|
||||
waitForAnkiReady: async () => {},
|
||||
wait: async () => {},
|
||||
waitForPlaybackWindowReady: async () => {},
|
||||
waitForOverlayGeometryReady: async () => {},
|
||||
focusOverlayWindow: () => {},
|
||||
showMpvOsd: () => {},
|
||||
reportSubtitleFailure: (message) => {
|
||||
failures.push(message);
|
||||
},
|
||||
warn: (message) => {
|
||||
throw new Error(message);
|
||||
},
|
||||
log: () => {},
|
||||
getYoutubeOutputDir: () => '/tmp',
|
||||
});
|
||||
|
||||
await runtime.openManualPicker({ url: 'https://example.com' });
|
||||
|
||||
assert.deepEqual(failures, []);
|
||||
});
|
||||
|
||||
test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const waits: number[] = [];
|
||||
|
||||
@@ -417,7 +417,7 @@ async function injectDownloadedSubtitles(
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentSubText = await deps.requestMpvProperty('sub-text');
|
||||
const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
|
||||
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
|
||||
deps.refreshCurrentSubtitle(currentSubText);
|
||||
}
|
||||
|
||||
169
src/main/runtime/youtube-playback-launch.test.ts
Normal file
169
src/main/runtime/youtube-playback-launch.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPrepareYoutubePlaybackInMpvHandler } from './youtube-playback-launch';
|
||||
|
||||
function createWaitStub() {
|
||||
return async (_ms: number): Promise<void> => {};
|
||||
}
|
||||
|
||||
test('prepare youtube playback skips load when current path already matches exact URL', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => 'https://www.youtube.com/watch?v=abc123',
|
||||
requestProperty: async () => [{ type: 'video', id: 1 }],
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('prepare youtube playback treats matching video IDs as already loaded', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => 'https://youtu.be/abc123?t=5',
|
||||
requestProperty: async () => [{ type: 'video', id: 1 }],
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands, []);
|
||||
});
|
||||
|
||||
test('prepare youtube playback replaces media and waits for path switch', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const observedPaths = [
|
||||
'/videos/episode01.mkv',
|
||||
'/videos/episode01.mkv',
|
||||
'https://www.youtube.com/watch?v=newvid',
|
||||
];
|
||||
const observedTrackLists = [null, [], [{ type: 'video', id: 1 }]];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||
requestCount += 1;
|
||||
return value;
|
||||
},
|
||||
requestProperty: async (name) => {
|
||||
if (name !== 'track-list') return null;
|
||||
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: 'https://www.youtube.com/watch?v=newvid',
|
||||
timeoutMs: 1500,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands, [
|
||||
['set_property', 'pause', 'yes'],
|
||||
['set_property', 'sub-auto', 'no'],
|
||||
['set_property', 'sid', 'no'],
|
||||
['set_property', 'secondary-sid', 'no'],
|
||||
['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback returns false after timeout when path never updates', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
let nowTick = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => '/videos/episode01.mkv',
|
||||
requestProperty: async () => [],
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
now: () => {
|
||||
nowTick += 100;
|
||||
return nowTick;
|
||||
},
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: 'https://www.youtube.com/watch?v=never-switches',
|
||||
timeoutMs: 350,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, false);
|
||||
assert.deepEqual(commands[4], [
|
||||
'loadfile',
|
||||
'https://www.youtube.com/watch?v=never-switches',
|
||||
'replace',
|
||||
]);
|
||||
});
|
||||
|
||||
test('prepare youtube playback waits for playable media tracks after youtube path matches', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const observedPaths = [
|
||||
'/videos/episode01.mkv',
|
||||
'https://www.youtube.com/watch?v=newvid',
|
||||
'https://www.youtube.com/watch?v=newvid',
|
||||
];
|
||||
const observedTrackLists = [[], [], [{ type: 'audio', id: 1 }]];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||
requestCount += 1;
|
||||
return value;
|
||||
},
|
||||
requestProperty: async (name) => {
|
||||
if (name !== 'track-list') return null;
|
||||
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: 'https://www.youtube.com/watch?v=newvid',
|
||||
timeoutMs: 1500,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands[4], ['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace']);
|
||||
});
|
||||
|
||||
test('prepare youtube playback accepts a non-youtube resolved path once playable tracks exist', async () => {
|
||||
const commands: Array<Array<string>> = [];
|
||||
const observedPaths = [
|
||||
'/videos/episode01.mkv',
|
||||
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
|
||||
];
|
||||
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
|
||||
let requestCount = 0;
|
||||
const prepare = createPrepareYoutubePlaybackInMpvHandler({
|
||||
requestPath: async () => {
|
||||
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
|
||||
requestCount += 1;
|
||||
return value;
|
||||
},
|
||||
requestProperty: async (name) => {
|
||||
if (name !== 'track-list') return null;
|
||||
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
|
||||
},
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
wait: createWaitStub(),
|
||||
});
|
||||
|
||||
const ok = await prepare({
|
||||
url: 'https://www.youtube.com/watch?v=newvid',
|
||||
timeoutMs: 1500,
|
||||
pollIntervalMs: 1,
|
||||
});
|
||||
|
||||
assert.equal(ok, true);
|
||||
assert.deepEqual(commands[4], ['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace']);
|
||||
});
|
||||
153
src/main/runtime/youtube-playback-launch.ts
Normal file
153
src/main/runtime/youtube-playback-launch.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { isYoutubeMediaPath } from './youtube-playback';
|
||||
|
||||
type YoutubePlaybackLaunchInput = {
|
||||
url: string;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
};
|
||||
|
||||
type YoutubePlaybackLaunchDeps = {
|
||||
requestPath: () => Promise<string | null>;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
sendMpvCommand: (command: Array<string>) => void;
|
||||
wait: (ms: number) => Promise<void>;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
function normalizePath(value: string | null | undefined): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function extractYoutubeVideoId(url: string): string | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const path = parsed.pathname.replace(/^\/+/, '');
|
||||
|
||||
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
|
||||
const id = path.split('/')[0]?.trim() || '';
|
||||
return id || null;
|
||||
}
|
||||
|
||||
const youtubeHost =
|
||||
host === 'youtube.com' ||
|
||||
host.endsWith('.youtube.com') ||
|
||||
host === 'youtube-nocookie.com' ||
|
||||
host.endsWith('.youtube-nocookie.com');
|
||||
if (!youtubeHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.pathname === '/watch') {
|
||||
const id = parsed.searchParams.get('v')?.trim() || '';
|
||||
return id || null;
|
||||
}
|
||||
|
||||
if (path.startsWith('shorts/') || path.startsWith('embed/')) {
|
||||
const id = path.split('/')[1]?.trim() || '';
|
||||
return id || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function targetsSameYoutubeVideo(currentPath: string, targetUrl: string): boolean {
|
||||
const currentId = extractYoutubeVideoId(currentPath);
|
||||
const targetId = extractYoutubeVideoId(targetUrl);
|
||||
if (!currentId || !targetId) return false;
|
||||
return currentId === targetId;
|
||||
}
|
||||
|
||||
function pathMatchesYoutubeTarget(currentPath: string, targetUrl: string): boolean {
|
||||
if (!currentPath) return false;
|
||||
if (currentPath === targetUrl) return true;
|
||||
return targetsSameYoutubeVideo(currentPath, targetUrl);
|
||||
}
|
||||
|
||||
function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
|
||||
if (!Array.isArray(trackListRaw)) return false;
|
||||
return trackListRaw.some((track) => {
|
||||
if (!track || typeof track !== 'object') return false;
|
||||
const type = String((track as Record<string, unknown>).type || '').trim().toLowerCase();
|
||||
return type === 'video' || type === 'audio';
|
||||
});
|
||||
}
|
||||
|
||||
export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLaunchDeps) {
|
||||
const now = deps.now ?? (() => Date.now());
|
||||
return async (input: YoutubePlaybackLaunchInput): Promise<boolean> => {
|
||||
const targetUrl = input.url.trim();
|
||||
if (!targetUrl) return false;
|
||||
|
||||
const timeoutMs = Math.max(200, input.timeoutMs ?? 5000);
|
||||
const pollIntervalMs = Math.max(25, input.pollIntervalMs ?? 100);
|
||||
|
||||
let previousPath = '';
|
||||
try {
|
||||
previousPath = normalizePath(await deps.requestPath());
|
||||
} catch {
|
||||
// Ignore transient path request failures and continue with bootstrap commands.
|
||||
}
|
||||
|
||||
if (pathMatchesYoutubeTarget(previousPath, targetUrl)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
|
||||
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'sid', 'no']);
|
||||
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
|
||||
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
|
||||
|
||||
const deadline = now() + timeoutMs;
|
||||
while (now() < deadline) {
|
||||
await deps.wait(pollIntervalMs);
|
||||
let currentPath = '';
|
||||
try {
|
||||
currentPath = normalizePath(await deps.requestPath());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!currentPath) continue;
|
||||
if (pathMatchesYoutubeTarget(currentPath, targetUrl)) {
|
||||
if (!deps.requestProperty) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const trackList = await deps.requestProperty('track-list');
|
||||
if (hasPlayableMediaTracks(trackList)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Continue polling until media tracks are actually available.
|
||||
}
|
||||
}
|
||||
if (previousPath && currentPath !== previousPath) {
|
||||
if (
|
||||
isYoutubeMediaPath(currentPath) &&
|
||||
isYoutubeMediaPath(targetUrl)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (deps.requestProperty) {
|
||||
try {
|
||||
const trackList = await deps.requestProperty('track-list');
|
||||
if (hasPlayableMediaTracks(trackList)) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Continue polling until media tracks are actually available.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
}
|
||||
schedulePendingCheck();
|
||||
},
|
||||
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user