Fix Windows YouTube playback flow and overlay pointer tracking

This commit is contained in:
2026-03-25 15:25:17 -07:00
committed by sudacode
parent 5ee4617607
commit c95518e94a
26 changed files with 1044 additions and 36 deletions

View File

@@ -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'));

View File

@@ -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();

View File

@@ -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'),

View File

@@ -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;

View File

@@ -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,

View File

@@ -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({

View File

@@ -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;

View File

@@ -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');

View File

@@ -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(),

View File

@@ -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]);
});

View File

@@ -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),

View 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);
});

View 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;
}

View File

@@ -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[] = [];

View File

@@ -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);
}

View 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']);
});

View 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;
};
}

View File

@@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
}
schedulePendingCheck();
},
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
};
}