diff --git a/changes/youtube-windows-queue-preserve.md b/changes/youtube-windows-queue-preserve.md new file mode 100644 index 00000000..9ab3f70a --- /dev/null +++ b/changes/youtube-windows-queue-preserve.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- Fixed Windows YouTube playback dropping the rest of mpv's queue when it advanced to a video queued from the YouTube browser window. The direct stream now replaces only that playlist entry. diff --git a/src/main/runtime/youtube-playback-launch.test.ts b/src/main/runtime/youtube-playback-launch.test.ts index 3bf9d9a1..0a757288 100644 --- a/src/main/runtime/youtube-playback-launch.test.ts +++ b/src/main/runtime/youtube-playback-launch.test.ts @@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct 'replace', ]); }); + +test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => { + const commands: Array> = []; + const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc'; + const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl]; + 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 === 'playlist') { + return [ + { filename: 'https://www.youtube.com/watch?v=queued1' }, + { filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true }, + { filename: 'https://www.youtube.com/watch?v=queued3' }, + ]; + } + if (name === 'track-list') return [{ type: 'video', id: 1 }]; + return null; + }, + sendMpvCommand: (command) => commands.push(command), + wait: createWaitStub(), + }); + + const ok = await prepare({ + url: directUrl, + sourceUrl: 'https://www.youtube.com/watch?v=queued2', + timeoutMs: 1500, + pollIntervalMs: 1, + }); + + assert.equal(ok, true); + assert.deepEqual(commands.slice(4), [ + ['loadfile', directUrl, 'append'], + ['playlist-move', '3', '2'], + ['playlist-remove', '1'], + ]); +}); + +test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => { + const commands: Array> = []; + const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc'; + let nowTick = 0; + const prepare = createPrepareYoutubePlaybackInMpvHandler({ + requestPath: async () => 'https://www.youtube.com/watch?v=other', + requestProperty: async (name) => { + if (name === 'playlist') { + return [ + { filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true }, + ]; + } + return []; + }, + sendMpvCommand: (command) => commands.push(command), + wait: createWaitStub(), + now: () => { + nowTick += 100; + return nowTick; + }, + }); + + await prepare({ + url: directUrl, + sourceUrl: 'https://www.youtube.com/watch?v=picked', + timeoutMs: 200, + pollIntervalMs: 1, + }); + + assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]); +}); + +test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => { + const commands: Array> = []; + const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc'; + let nowTick = 0; + const prepare = createPrepareYoutubePlaybackInMpvHandler({ + // mpv's path still reports the queued video, but it is mid-advance: the selected entry moved + // on and the playing entry is another queued video. + requestPath: async () => 'https://www.youtube.com/watch?v=queued2', + requestProperty: async (name) => { + if (name === 'playlist') { + return [ + { filename: 'https://www.youtube.com/watch?v=queued1', playing: true }, + { filename: 'https://www.youtube.com/watch?v=queued2', current: true }, + { filename: 'https://www.youtube.com/watch?v=queued3' }, + ]; + } + return []; + }, + sendMpvCommand: (command) => commands.push(command), + wait: createWaitStub(), + now: () => { + nowTick += 100; + return nowTick; + }, + }); + + await prepare({ + url: directUrl, + sourceUrl: 'https://www.youtube.com/watch?v=queued2', + timeoutMs: 200, + pollIntervalMs: 1, + }); + + assert.equal( + commands.some((command) => command[0] === 'playlist-remove'), + false, + ); + assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]); +}); diff --git a/src/main/runtime/youtube-playback-launch.ts b/src/main/runtime/youtube-playback-launch.ts index 166968bd..f283a56a 100644 --- a/src/main/runtime/youtube-playback-launch.ts +++ b/src/main/runtime/youtube-playback-launch.ts @@ -2,6 +2,11 @@ import { isYoutubeMediaPath } from './youtube-playback'; type YoutubePlaybackLaunchInput = { url: string; + /** + * YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist + * entry for the same video be swapped in place instead of replacing the whole playlist. + */ + sourceUrl?: string; timeoutMs?: number; pollIntervalMs?: number; }; @@ -81,6 +86,52 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean { }); } +/** Index of the entry mpv is actually playing, or null while it is between entries. */ +function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null { + const index = playlist.findIndex( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as Record).playing === true, + ); + if (index < 0) return null; + const filename = (playlist[index] as Record).filename; + return typeof filename === 'string' ? { index, filename } : null; +} + +/** + * Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g. + * a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only + * long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+). + * Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller + * falls back to `replace` instead of removing some other queued entry. + */ +async function swapCurrentPlaylistEntry( + deps: YoutubePlaybackLaunchDeps, + targetUrl: string, + sourceUrl: string, +): Promise { + if (!deps.requestProperty) return false; + let playlist: unknown; + try { + // One snapshot so the position, count, and entry identity agree with each other. + playlist = await deps.requestProperty('playlist'); + } catch { + return false; + } + if (!Array.isArray(playlist)) return false; + const playing = findPlayingPlaylistEntry(playlist); + if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false; + + // The appended entry lands at index `count`; move it right after the playing entry, then remove + // the playing entry so mpv advances onto it. + const count = playlist.length; + deps.sendMpvCommand(['loadfile', targetUrl, 'append']); + deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]); + deps.sendMpvCommand(['playlist-remove', String(playing.index)]); + return true; +} + function sendPlaybackPrepCommands(sendMpvCommand: (command: Array) => void): void { sendMpvCommand(['set_property', 'pause', 'yes']); sendMpvCommand(['set_property', 'sub-auto', 'no']); @@ -120,7 +171,14 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa // Keep polling; mpv can report the target path before tracks are ready. } } else { - deps.sendMpvCommand(['loadfile', targetUrl, 'replace']); + const sourceUrl = input.sourceUrl?.trim() ?? ''; + const swapped = + sourceUrl !== '' && + targetsSameYoutubeVideo(previousPath, sourceUrl) && + (await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl)); + if (!swapped) { + deps.sendMpvCommand(['loadfile', targetUrl, 'replace']); + } } const deadline = now() + timeoutMs; diff --git a/src/main/runtime/youtube-playback-runtime.test.ts b/src/main/runtime/youtube-playback-runtime.test.ts index 28b35a5f..278f1b9f 100644 --- a/src/main/runtime/youtube-playback-runtime.test.ts +++ b/src/main/runtime/youtube-playback-runtime.test.ts @@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start calls.push(`wait-connected:${timeoutMs}`); return true; }, - prepareYoutubePlaybackInMpv: async ({ url }) => { - calls.push(`prepare:${url}`); + prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => { + calls.push(`prepare:${url}:${sourceUrl}`); return true; }, runYoutubePlaybackFlow: async ({ url }) => { @@ -143,6 +143,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start }); assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock'))); + // The page URL rides along so prepare can swap a queued entry for the direct stream in place. + assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo')); }); test('youtube playback runtime maps resolved windows streams back to their page urls', async () => { diff --git a/src/main/runtime/youtube-playback-runtime.ts b/src/main/runtime/youtube-playback-runtime.ts index 69da7952..64ba3c6f 100644 --- a/src/main/runtime/youtube-playback-runtime.ts +++ b/src/main/runtime/youtube-playback-runtime.ts @@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = { resolveYoutubePlaybackUrl: (url: string, format: string) => Promise; launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise; waitForYoutubeMpvConnected: (timeoutMs: number) => Promise; - prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise; + prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise; startYoutubeMediaCache?: (url: string) => void | Promise; runYoutubePlaybackFlow: (request: { url: string }) => Promise; logInfo: (message: string) => void; @@ -127,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) { }, 3000); } - const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl }); + const mediaReady = await deps.prepareYoutubePlaybackInMpv({ + url: playbackUrl, + sourceUrl: request.url, + }); if (!mediaReady) { throw new Error('Timed out waiting for mpv to load the requested YouTube URL.'); }