From f65afa6046ac4ec3d6834e16a04d20da46845055 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 28 Jun 2026 22:43:16 -0700 Subject: [PATCH] feat(youtube): notify on manual picker open and show success after track load (#133) --- ...-29-youtube-picker-opening-notification.md | 4 + docs-site/youtube-integration.md | 4 + .../configured-status-notification.test.ts | 31 +++++++ .../runtime/configured-status-notification.ts | 19 ++++ .../runtime/overlay-notifications-runtime.ts | 14 +-- src/main/runtime/youtube-flow.test.ts | 62 +++++++++++++ src/main/runtime/youtube-flow.ts | 2 + src/renderer/overlay-notifications.test.ts | 86 +++++++++++++++++++ 8 files changed, 210 insertions(+), 12 deletions(-) create mode 100644 changes/2026-06-29-youtube-picker-opening-notification.md diff --git a/changes/2026-06-29-youtube-picker-opening-notification.md b/changes/2026-06-29-youtube-picker-opening-notification.md new file mode 100644 index 00000000..df194abb --- /dev/null +++ b/changes/2026-06-29-youtube-picker-opening-notification.md @@ -0,0 +1,4 @@ +type: fixed +area: youtube + +- Manual YouTube subtitle picker requests now show an immediate configured notification while SubMiner probes tracks and opens the modal, then replace subtitle download progress with a transient success notification after tracks load. diff --git a/docs-site/youtube-integration.md b/docs-site/youtube-integration.md index a5ce157a..b92ee3b8 100644 --- a/docs-site/youtube-integration.md +++ b/docs-site/youtube-integration.md @@ -74,6 +74,10 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay - Select different primary and secondary tracks - Retry track loading if the auto-load failed or picked the wrong track +SubMiner shows an "Opening YouTube subtitle picker..." status through your configured notification +surface while it probes tracks and prepares the modal, then updates the subtitle download progress +card to a success notification after the selected tracks load. + The picker displays each track with its language, kind (manual/auto), and title when available. ## Subtitle Format Handling diff --git a/src/main/runtime/configured-status-notification.test.ts b/src/main/runtime/configured-status-notification.test.ts index 3170e77c..f88afadd 100644 --- a/src/main/runtime/configured-status-notification.test.ts +++ b/src/main/runtime/configured-status-notification.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { getPlaybackFeedbackNotificationOptions, + getYoutubeFlowStatusNotificationOptions, notifyConfiguredStatus, } from './configured-status-notification'; import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; @@ -226,6 +227,36 @@ test('playback feedback options reuse subtitle mode notification ids', () => { assert.deepEqual(getPlaybackFeedbackNotificationOptions('Secondary subtitle track: English'), {}); }); +test('youtube flow status options route picker opening as one-shot configured status', () => { + assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Opening YouTube subtitle picker...'), { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: 'info', + persistent: false, + desktop: true, + }); +}); + +test('youtube flow status options route loaded messages as transient success', () => { + assert.deepEqual(getYoutubeFlowStatusNotificationOptions('Subtitles loaded.'), { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: 'success', + persistent: false, + desktop: true, + }); + assert.deepEqual( + getYoutubeFlowStatusNotificationOptions('Primary and secondary subtitles loaded.'), + { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: 'success', + persistent: false, + desktop: true, + }, + ); +}); + test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => { const calls: string[] = []; diff --git a/src/main/runtime/configured-status-notification.ts b/src/main/runtime/configured-status-notification.ts index 8b5ac49f..94b835c6 100644 --- a/src/main/runtime/configured-status-notification.ts +++ b/src/main/runtime/configured-status-notification.ts @@ -31,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions( return {}; } +export function getYoutubeFlowStatusNotificationOptions( + message: string, +): ConfiguredStatusNotificationOptions { + const success = + message === 'Subtitles loaded.' || message === 'Primary and secondary subtitles loaded.'; + const progress = + message.startsWith('Downloading subtitles') || + message.startsWith('Loading subtitles') || + message.startsWith('Getting subtitles') || + message === 'Opening YouTube video'; + return { + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + variant: success ? 'success' : progress ? 'progress' : 'info', + persistent: progress, + desktop: !progress, + }; +} + export function notifyConfiguredStatus( message: string, deps: ConfiguredStatusNotificationDeps, diff --git a/src/main/runtime/overlay-notifications-runtime.ts b/src/main/runtime/overlay-notifications-runtime.ts index 5c03ebe1..062ca74b 100644 --- a/src/main/runtime/overlay-notifications-runtime.ts +++ b/src/main/runtime/overlay-notifications-runtime.ts @@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio import { createOverlayNotificationDelivery } from './overlay-notification-delivery'; import { getPlaybackFeedbackNotificationOptions, + getYoutubeFlowStatusNotificationOptions, notifyConfiguredStatus, type ConfiguredStatusNotificationOptions, } from './configured-status-notification'; @@ -206,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt } function showYoutubeFlowStatusNotification(message: string): void { - const progress = - message.startsWith('Downloading subtitles') || - message.startsWith('Loading subtitles') || - message.startsWith('Getting subtitles') || - message === 'Opening YouTube video'; - showConfiguredStatusNotification(message, { - id: 'youtube-subtitles-status', - title: 'YouTube subtitles', - variant: progress ? 'progress' : 'info', - persistent: progress, - desktop: !progress, - }); + showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message)); } function getOverlayLoadingOsdController(): ReturnType { diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index 5150035a..f4220a6b 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import path from 'node:path'; import test from 'node:test'; import { createYoutubeFlowRuntime } from './youtube-flow'; +import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe'; import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types'; const primaryTrack: YoutubeTrackOption = { @@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = { label: 'English (manual)', }; +test('youtube flow announces manual picker opening before probing tracks', async () => { + const osdMessages: string[] = []; + let resolveProbe: (probe: YoutubeTrackProbeResult) => void = () => {}; + const probePromise = new Promise((resolve) => { + resolveProbe = resolve; + }); + + const runtime = createYoutubeFlowRuntime({ + probeYoutubeTracks: async () => await probePromise, + acquireYoutubeSubtitleTracks: async () => new Map(), + acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/unused.vtt' }), + openPicker: async (payload) => { + queueMicrotask(() => { + void runtime.resolveActivePicker({ + sessionId: payload.sessionId, + action: 'continue-without-subtitles', + primaryTrackId: null, + secondaryTrackId: null, + }); + }); + return true; + }, + pauseMpv: () => {}, + resumeMpv: () => {}, + sendMpvCommand: () => {}, + requestMpvProperty: async () => null, + refreshCurrentSubtitle: () => {}, + startTokenizationWarmups: async () => {}, + waitForTokenizationReady: async () => {}, + waitForAnkiReady: async () => {}, + wait: async () => {}, + waitForPlaybackWindowReady: async () => {}, + waitForOverlayGeometryReady: async () => {}, + focusOverlayWindow: () => {}, + showMpvOsd: (text) => { + osdMessages.push(text); + }, + reportSubtitleFailure: (message) => { + throw new Error(message); + }, + warn: (message) => { + throw new Error(message); + }, + log: () => {}, + getYoutubeOutputDir: () => '/tmp', + }); + + const pending = runtime.openManualPicker({ url: 'https://example.com' }); + await Promise.resolve(); + + assert.deepEqual(osdMessages, ['Opening YouTube subtitle picker...']); + + resolveProbe({ + videoId: 'video123', + title: 'Video 123', + tracks: [], + }); + await pending; +}); + test('youtube flow can open a manual picker session and load the selected subtitles', async () => { const commands: Array> = []; const focusOverlayCalls: string[] = []; @@ -126,6 +187,7 @@ test('youtube flow can open a manual picker session and load the selected subtit assert.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id); assert.ok(waits.includes(150)); assert.deepEqual(osdMessages, [ + 'Opening YouTube subtitle picker...', 'Getting subtitles...', 'Downloading subtitles...', 'Loading subtitles...', diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index 625619da..12dceb2f 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) { url: string; mode?: YoutubeFlowMode; }): Promise => { + deps.showMpvOsd('Opening YouTube subtitle picker...'); + let probe: YoutubeTrackProbeResult; try { probe = await deps.probeYoutubeTracks(input.url); diff --git a/src/renderer/overlay-notifications.test.ts b/src/renderer/overlay-notifications.test.ts index c2ac00ee..52f3b41f 100644 --- a/src/renderer/overlay-notifications.test.ts +++ b/src/renderer/overlay-notifications.test.ts @@ -384,6 +384,92 @@ test('overlay notification renderer updates same-id progress without replacing t } }); +test('overlay notification renderer auto-dismisses same-id terminal update after persistent progress', () => { + const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document'); + const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const stack = createFakeElement(); + let nextTimerId = 1; + const timers = new Map void; delayMs: number }>(); + + Object.defineProperty(globalThis, 'document', { + configurable: true, + writable: true, + value: { + createElement: (tagName: string) => createFakeElement(tagName), + }, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: { + clearTimeout: (id: number) => { + timers.delete(id); + }, + setTimeout: (callback: () => void, delayMs: number) => { + const id = nextTimerId; + nextTimerId += 1; + timers.set(id, { callback, delayMs }); + return id; + }, + }, + }); + + try { + const renderer = createOverlayNotificationRenderer({ + dom: { + overlayNotificationStack: stack, + }, + state: { + isOverOverlayNotification: false, + }, + } as never); + + renderer.show({ + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + body: 'Downloading subtitles...', + variant: 'progress', + persistent: true, + }); + const card = stack.children[0]; + if (!card) { + assert.fail('Expected overlay notification card.'); + } + + renderer.show({ + id: 'youtube-subtitles-status', + title: 'YouTube subtitles', + body: 'Subtitles loaded.', + variant: 'success', + persistent: false, + }); + + const autoDismissTimer = [...timers.values()].find((timer) => timer.delayMs === 3000); + assert.ok(autoDismissTimer, 'Expected terminal update to schedule an auto-dismiss timer.'); + assert.equal(stack.children[0], card); + assert.equal( + findChildByClass(card, 'overlay-notification-body')?.textContent, + 'Subtitles loaded.', + ); + assert.equal(card.classList.contains('success'), true); + + autoDismissTimer.callback(); + + assert.equal(card.classList.contains('leaving'), true); + } finally { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', originalDocument); + } else { + delete (globalThis as { document?: unknown }).document; + } + if (originalWindow) { + Object.defineProperty(globalThis, 'window', originalWindow); + } else { + delete (globalThis as { window?: unknown }).window; + } + } +}); + test('overlay notification cards use larger display dimensions', () => { assert.match( overlayNotificationCss,