mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-07 13:08:54 -07:00
feat(youtube): notify on manual picker open and show success after track load (#133)
This commit is contained in:
@@ -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.
|
||||||
@@ -74,6 +74,10 @@ Press **Ctrl+Alt+C** during YouTube playback to open the subtitle picker overlay
|
|||||||
- Select different primary and secondary tracks
|
- Select different primary and secondary tracks
|
||||||
- Retry track loading if the auto-load failed or picked the wrong track
|
- 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.
|
The picker displays each track with its language, kind (manual/auto), and title when available.
|
||||||
|
|
||||||
## Subtitle Format Handling
|
## Subtitle Format Handling
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
} from './configured-status-notification';
|
} from './configured-status-notification';
|
||||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
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'), {});
|
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', () => {
|
test('notifyConfiguredStatus falls back to desktop if overlay is unavailable', () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ export function getPlaybackFeedbackNotificationOptions(
|
|||||||
return {};
|
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(
|
export function notifyConfiguredStatus(
|
||||||
message: string,
|
message: string,
|
||||||
deps: ConfiguredStatusNotificationDeps,
|
deps: ConfiguredStatusNotificationDeps,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { withConfiguredOverlayNotificationPosition } from './overlay-notificatio
|
|||||||
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
import { createOverlayNotificationDelivery } from './overlay-notification-delivery';
|
||||||
import {
|
import {
|
||||||
getPlaybackFeedbackNotificationOptions,
|
getPlaybackFeedbackNotificationOptions,
|
||||||
|
getYoutubeFlowStatusNotificationOptions,
|
||||||
notifyConfiguredStatus,
|
notifyConfiguredStatus,
|
||||||
type ConfiguredStatusNotificationOptions,
|
type ConfiguredStatusNotificationOptions,
|
||||||
} from './configured-status-notification';
|
} from './configured-status-notification';
|
||||||
@@ -206,18 +207,7 @@ export function createOverlayNotificationsRuntime(deps: OverlayNotificationsRunt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showYoutubeFlowStatusNotification(message: string): void {
|
function showYoutubeFlowStatusNotification(message: string): void {
|
||||||
const progress =
|
showConfiguredStatusNotification(message, getYoutubeFlowStatusNotificationOptions(message));
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
function getOverlayLoadingOsdController(): ReturnType<typeof createOverlayLoadingOsdController> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createYoutubeFlowRuntime } from './youtube-flow';
|
import { createYoutubeFlowRuntime } from './youtube-flow';
|
||||||
|
import type { YoutubeTrackProbeResult } from '../../core/services/youtube/track-probe';
|
||||||
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
import type { YoutubePickerOpenPayload, YoutubeTrackOption } from '../../types';
|
||||||
|
|
||||||
const primaryTrack: YoutubeTrackOption = {
|
const primaryTrack: YoutubeTrackOption = {
|
||||||
@@ -20,6 +21,66 @@ const secondaryTrack: YoutubeTrackOption = {
|
|||||||
label: 'English (manual)',
|
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<YoutubeTrackProbeResult>((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 () => {
|
test('youtube flow can open a manual picker session and load the selected subtitles', async () => {
|
||||||
const commands: Array<Array<string | number>> = [];
|
const commands: Array<Array<string | number>> = [];
|
||||||
const focusOverlayCalls: string[] = [];
|
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.equal(openedPayloads[0]?.defaultSecondaryTrackId, secondaryTrack.id);
|
||||||
assert.ok(waits.includes(150));
|
assert.ok(waits.includes(150));
|
||||||
assert.deepEqual(osdMessages, [
|
assert.deepEqual(osdMessages, [
|
||||||
|
'Opening YouTube subtitle picker...',
|
||||||
'Getting subtitles...',
|
'Getting subtitles...',
|
||||||
'Downloading subtitles...',
|
'Downloading subtitles...',
|
||||||
'Loading subtitles...',
|
'Loading subtitles...',
|
||||||
|
|||||||
@@ -755,6 +755,8 @@ export function createYoutubeFlowRuntime(deps: YoutubeFlowDeps) {
|
|||||||
url: string;
|
url: string;
|
||||||
mode?: YoutubeFlowMode;
|
mode?: YoutubeFlowMode;
|
||||||
}): Promise<void> => {
|
}): Promise<void> => {
|
||||||
|
deps.showMpvOsd('Opening YouTube subtitle picker...');
|
||||||
|
|
||||||
let probe: YoutubeTrackProbeResult;
|
let probe: YoutubeTrackProbeResult;
|
||||||
try {
|
try {
|
||||||
probe = await deps.probeYoutubeTracks(input.url);
|
probe = await deps.probeYoutubeTracks(input.url);
|
||||||
|
|||||||
@@ -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<number, { callback: () => 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', () => {
|
test('overlay notification cards use larger display dimensions', () => {
|
||||||
assert.match(
|
assert.match(
|
||||||
overlayNotificationCss,
|
overlayNotificationCss,
|
||||||
|
|||||||
Reference in New Issue
Block a user