From 61ab1b76fc18ae87b548504b4412e2c5f9ef401c Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 3 Apr 2026 01:12:31 -0700 Subject: [PATCH] fix: stabilize local subtitle startup and pause release --- CHANGELOG.md | 2 + README.md | 4 +- ...btitle-auto-selection-and-pause-release.md | 5 + config.example.jsonc | 8 +- docs-site/configuration.md | 7 +- docs-site/public/config.example.jsonc | 8 +- docs-site/usage.md | 8 +- src/config/config.test.ts | 2 +- src/config/definitions/options-core.ts | 3 +- src/config/definitions/template-sections.ts | 4 +- src/main.ts | 32 ++- src/main/runtime/autoplay-ready-gate.test.ts | 49 +++- src/main/runtime/autoplay-ready-gate.ts | 14 +- .../runtime/local-subtitle-selection.test.ts | 77 ++++++ src/main/runtime/local-subtitle-selection.ts | 261 ++++++++++++++++++ src/main/runtime/playlist-browser-ipc.ts | 6 + .../runtime/playlist-browser-runtime.test.ts | 51 ++++ src/main/runtime/playlist-browser-runtime.ts | 22 +- 18 files changed, 515 insertions(+), 48 deletions(-) create mode 100644 changes/271-local-subtitle-auto-selection-and-pause-release.md create mode 100644 src/main/runtime/local-subtitle-selection.test.ts create mode 100644 src/main/runtime/local-subtitle-selection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 214a470f..f6e7e7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed - AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass. +- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file. +- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary/secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess. ## v0.10.0 (2026-03-29) diff --git a/README.md b/README.md index 8f83722a..f781512b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,8 @@ Local stats dashboard — watch time, anime library, vocabulary growth, mining t Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback. +Managed local playback now reapplies your configured subtitle language priorities after mpv loads track metadata, so mixed subtitle sets can settle onto the expected primary and secondary tracks instead of staying on mpv's initial `sid=auto` guess. +
### Integrations @@ -76,7 +78,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open - + diff --git a/changes/271-local-subtitle-auto-selection-and-pause-release.md b/changes/271-local-subtitle-auto-selection-and-pause-release.md new file mode 100644 index 00000000..bebbb306 --- /dev/null +++ b/changes/271-local-subtitle-auto-selection-and-pause-release.md @@ -0,0 +1,5 @@ +type: fixed +area: playback + +- Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file. +- Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess. diff --git a/config.example.jsonc b/config.example.jsonc index de4e143f..71c16ffe 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -187,7 +187,7 @@ // ========================================== // Secondary Subtitles // Dual subtitle track options. - // Used by the YouTube subtitle loading flow as secondary language preferences. + // Used by managed subtitle loading as secondary language preferences for local and YouTube playback. // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { @@ -415,14 +415,14 @@ // ========================================== // YouTube Playback Settings - // Defaults for SubMiner YouTube subtitle loading and languages. + // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== "youtube": { "primarySubLanguages": [ "ja", "jpn" - ] // Comma-separated primary subtitle language priority for YouTube auto-loading. - }, // Defaults for SubMiner YouTube subtitle loading and languages. + ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== // Anilist diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 16022e0d..c2972d65 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -448,6 +448,8 @@ See `config.example.jsonc` for detailed configuration options. | `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track | | `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) | +`secondarySub.secondarySubLanguages` also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback. + **Display modes:** - **hidden** — Secondary subtitles not shown @@ -1342,7 +1344,7 @@ Usage notes: ### YouTube Playback Settings -Set defaults used by the `subminer` launcher for YouTube subtitle loading: +Set defaults used by managed subtitle auto-selection and the `subminer` launcher YouTube flow: ```json { @@ -1354,7 +1356,7 @@ Set defaults used by the `subminer` launcher for YouTube subtitle loading: | Option | Values | Description | | --------------------- | -------------------- | ---------------------------------------------------------------------------------------------- | -| `primarySubLanguages` | string[] | Primary subtitle language priority for YouTube auto-loading (default `["ja", "jpn"]`) | +| `primarySubLanguages` | string[] | Primary subtitle language priority for managed subtitle auto-selection (default `["ja", "jpn"]`) | Current launcher behavior: @@ -1370,6 +1372,7 @@ Language targets are derived from subtitle config: - primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`) - secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty) +- Local playback uses the same priorities after mpv reports subtitle track metadata, so sidecar/internal mixed sets can override an incorrect initial `sid=auto` pick. - Tracks are resolved and loaded before mpv starts; the older launcher mode switch has been removed. Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default. diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index de4e143f..71c16ffe 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -187,7 +187,7 @@ // ========================================== // Secondary Subtitles // Dual subtitle track options. - // Used by the YouTube subtitle loading flow as secondary language preferences. + // Used by managed subtitle loading as secondary language preferences for local and YouTube playback. // Hot-reload: defaultMode updates live while SubMiner is running. // ========================================== "secondarySub": { @@ -415,14 +415,14 @@ // ========================================== // YouTube Playback Settings - // Defaults for SubMiner YouTube subtitle loading and languages. + // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== "youtube": { "primarySubLanguages": [ "ja", "jpn" - ] // Comma-separated primary subtitle language priority for YouTube auto-loading. - }, // Defaults for SubMiner YouTube subtitle loading and languages. + ] // Comma-separated primary subtitle language priority for managed subtitle auto-selection. + }, // Defaults for managed subtitle language preferences and YouTube subtitle loading. // ========================================== // Anilist diff --git a/docs-site/usage.md b/docs-site/usage.md index c35159ad..565e7ead 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -213,10 +213,6 @@ secondary-sid=auto secondary-sub-visibility=no ``` -::: warning -`secondary-slang` is not a valid mpv option. Use `slang` with `sid=auto` / `secondary-sid=auto` to set subtitle language preferences. -::: - ### Yomitan setup SubMiner includes a bundled Yomitan extension for overlay word lookup. This bundled extension is separate from any Yomitan browser extension you may have installed. @@ -241,6 +237,8 @@ Notes: - Secondary target languages come from `secondarySub.secondarySubLanguages` (defaults to English if unset). - Configure defaults in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc`) under `youtube` and `secondarySub`. +For local video files, SubMiner now uses those same config-driven language priorities after mpv finishes reporting subtitle tracks. That means mixed internal/external subtitle sets can correct an initial `sid=auto` guess and settle onto the expected primary and secondary tracks without manual cycling. + ## Controller Support SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled. @@ -294,9 +292,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh | `Alt+Shift+O` | Toggle visible overlay | | `Alt+Shift+Y` | Open Yomitan settings | -::: tip `Alt+Shift+Y` is fixed and not configurable. All other shortcuts can be changed under `shortcuts` in your config. -::: Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 1c7e7a70..13c54660 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -2138,7 +2138,7 @@ test('template generator includes known keys', () => { ); assert.match( output, - /"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for YouTube auto-loading\./, + /"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./, ); assert.doesNotMatch(output, /"mode": "automatic"/); assert.doesNotMatch(output, /"fixWithAi": false/); diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 463eb6ed..efa53b62 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -87,7 +87,8 @@ export function buildCoreConfigOptionRegistry( path: 'youtube.primarySubLanguages', kind: 'string', defaultValue: defaultConfig.youtube.primarySubLanguages.join(','), - description: 'Comma-separated primary subtitle language priority for YouTube auto-loading.', + description: + 'Comma-separated primary subtitle language priority for managed subtitle auto-selection.', }, { path: 'controller.enabled', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index b2918db2..ff9ae797 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ title: 'Secondary Subtitles', description: [ 'Dual subtitle track options.', - 'Used by the YouTube subtitle loading flow as secondary language preferences.', + 'Used by managed subtitle loading as secondary language preferences for local and YouTube playback.', ], notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'], key: 'secondarySub', @@ -131,7 +131,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ }, { title: 'YouTube Playback Settings', - description: ['Defaults for SubMiner YouTube subtitle loading and languages.'], + description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'], key: 'youtube', }, { diff --git a/src/main.ts b/src/main.ts index ea6be4b2..b497eb16 100644 --- a/src/main.ts +++ b/src/main.ts @@ -339,6 +339,7 @@ import { startStatsServer } from './core/services/stats-server'; import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js'; import { createFirstRunSetupService, + getFirstRunSetupCompletionMessage, shouldAutoOpenFirstRunSetup, } from './main/runtime/first-run-setup-service'; import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow'; @@ -348,6 +349,7 @@ import { createYoutubePrimarySubtitleNotificationRuntime, } from './main/runtime/youtube-primary-subtitle-notification'; import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate'; +import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection'; import { buildFirstRunSetupHtml, createMaybeFocusExistingFirstRunSetupWindowHandler, @@ -1000,6 +1002,17 @@ const autoplayReadyGate = createAutoplayReadyGate({ schedule: (callback, delayMs) => setTimeout(callback, delayMs), logDebug: (message) => logger.debug(message), }); +const managedLocalSubtitleSelectionRuntime = createManagedLocalSubtitleSelectionRuntime({ + getCurrentMediaPath: () => appState.currentMediaPath, + getMpvClient: () => appState.mpvClient, + getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, + sendMpvCommand: (command) => { + sendMpvCommandRuntime(appState.mpvClient, command); + }, + schedule: (callback, delayMs) => setTimeout(callback, delayMs), + clearScheduled: (timer) => clearTimeout(timer), +}); const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ platform: process.platform, directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT, @@ -2244,15 +2257,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ firstRunSetupMessage = null; return { closeWindow: true }; } - if (snapshot.pluginStatus !== 'installed') { - firstRunSetupMessage = 'Install the mpv plugin before finishing setup.'; - return; - } - if (!snapshot.externalYomitanConfigured && snapshot.dictionaryCount < 1) { - firstRunSetupMessage = 'Install at least one Yomitan dictionary before finishing setup.'; - return; - } - firstRunSetupMessage = 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; + firstRunSetupMessage = + getFirstRunSetupCompletionMessage(snapshot) ?? + 'Finish setup requires the mpv plugin and Yomitan dictionaries.'; return; }, markSetupInProgress: async () => { @@ -3331,6 +3338,7 @@ const { updateCurrentMediaPath: (path) => { autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks(); currentMediaTokenizationGate.updateCurrentMediaPath(path); + managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path); startupOsdSequencer.reset(); subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh(); subtitlePrefetchRuntime.cancelPendingInit(); @@ -3397,6 +3405,7 @@ const { youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, onSubtitleTrackListChange: (trackList) => { + managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); }, @@ -4138,7 +4147,10 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen showMpvOsd: (text) => showMpvOsd(text), }); -const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient); +const { playlistBrowserMainDeps } = createPlaylistBrowserIpcRuntime(() => appState.mpvClient, { + getPrimarySubtitleLanguages: () => getResolvedConfig().youtube.primarySubLanguages, + getSecondarySubtitleLanguages: () => getResolvedConfig().secondarySub.secondarySubLanguages, +}); const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({ mpvCommandMainDeps: { diff --git a/src/main/runtime/autoplay-ready-gate.test.ts b/src/main/runtime/autoplay-ready-gate.test.ts index 8ad5313f..ac8f8b1f 100644 --- a/src/main/runtime/autoplay-ready-gate.test.ts +++ b/src/main/runtime/autoplay-ready-gate.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createAutoplayReadyGate } from './autoplay-ready-gate'; -test('autoplay ready gate suppresses duplicate media signals unless forced while paused', async () => { +test('autoplay ready gate suppresses duplicate media signals for the same media', async () => { const commands: Array> = []; const scheduled: Array<() => void> = []; @@ -31,7 +31,6 @@ test('autoplay ready gate suppresses duplicate media signals unless forced while gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }); - gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); await new Promise((resolve) => setTimeout(resolve, 0)); const firstScheduled = scheduled.shift(); @@ -96,3 +95,49 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async true, ); }); + +test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => { + const commands: Array> = []; + let playbackPaused = true; + + const gate = createAutoplayReadyGate({ + isAppOwnedFlowInFlight: () => false, + getCurrentMediaPath: () => '/media/video.mkv', + getCurrentVideoPath: () => null, + getPlaybackPaused: () => playbackPaused, + getMpvClient: () => + ({ + connected: true, + requestProperty: async () => playbackPaused, + send: ({ command }: { command: Array }) => { + commands.push(command); + if (command[0] === 'set_property' && command[1] === 'pause' && command[2] === false) { + playbackPaused = false; + } + }, + }) as never, + signalPluginAutoplayReady: () => { + commands.push(['script-message', 'subminer-autoplay-ready']); + }, + schedule: (callback) => { + queueMicrotask(callback); + return 1 as never; + }, + logDebug: () => {}, + }); + + gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + playbackPaused = true; + gate.maybeSignalPluginAutoplayReady({ text: '字幕その2', tokens: null }, { forceWhilePaused: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal( + commands.filter( + (command) => + command[0] === 'set_property' && command[1] === 'pause' && command[2] === false, + ).length, + 1, + ); +}); diff --git a/src/main/runtime/autoplay-ready-gate.ts b/src/main/runtime/autoplay-ready-gate.ts index cd916d69..1bc749cd 100644 --- a/src/main/runtime/autoplay-ready-gate.ts +++ b/src/main/runtime/autoplay-ready-gate.ts @@ -44,8 +44,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { deps.getCurrentVideoPath()?.trim() || '__unknown__'; const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath; - const allowDuplicateWhilePaused = - options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false; const releaseRetryDelayMs = 200; const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({ forceWhilePaused: options?.forceWhilePaused === true, @@ -104,19 +102,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) { })(); }; - if (duplicateMediaSignal && !allowDuplicateWhilePaused) { - return; - } - - if (!duplicateMediaSignal) { - autoPlayReadySignalMediaPath = mediaPath; - const playbackGeneration = ++autoPlayReadySignalGeneration; - deps.signalPluginAutoplayReady(); - attemptRelease(playbackGeneration, 0); + if (duplicateMediaSignal) { return; } + autoPlayReadySignalMediaPath = mediaPath; const playbackGeneration = ++autoPlayReadySignalGeneration; + deps.signalPluginAutoplayReady(); attemptRelease(playbackGeneration, 0); }; diff --git a/src/main/runtime/local-subtitle-selection.test.ts b/src/main/runtime/local-subtitle-selection.test.ts new file mode 100644 index 00000000..2ae42f91 --- /dev/null +++ b/src/main/runtime/local-subtitle-selection.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createManagedLocalSubtitleSelectionRuntime, + resolveManagedLocalSubtitleSelection, +} from './local-subtitle-selection'; + +const mixedLanguageTrackList = [ + { type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true }, + { type: 'sub', id: 2, lang: 'pt', title: '[Moshi Moshi]', external: false }, + { type: 'sub', id: 3, lang: 'en', title: '(Vivid)', external: false }, + { type: 'sub', id: 9, lang: 'en', title: 'English(US)', external: false }, + { type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true }, + { type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true }, +]; + +test('resolveManagedLocalSubtitleSelection prefers default Japanese primary and English secondary tracks', () => { + const result = resolveManagedLocalSubtitleSelection({ + trackList: mixedLanguageTrackList, + primaryLanguages: [], + secondaryLanguages: [], + }); + + assert.equal(result.primaryTrackId, 12); + assert.equal(result.secondaryTrackId, 11); +}); + +test('resolveManagedLocalSubtitleSelection respects configured language overrides', () => { + const result = resolveManagedLocalSubtitleSelection({ + trackList: mixedLanguageTrackList, + primaryLanguages: ['pt'], + secondaryLanguages: ['ja'], + }); + + assert.equal(result.primaryTrackId, 1); + assert.equal(result.secondaryTrackId, 12); +}); + +test('managed local subtitle selection runtime applies preferred tracks once for a local media path', async () => { + const commands: Array> = []; + const scheduled: Array<() => void> = []; + + const runtime = createManagedLocalSubtitleSelectionRuntime({ + getCurrentMediaPath: () => '/videos/example.mkv', + getMpvClient: () => + ({ + connected: true, + requestProperty: async (name: string) => { + if (name === 'track-list') { + return mixedLanguageTrackList; + } + throw new Error(`Unexpected property: ${name}`); + }, + }) as never, + getPrimarySubtitleLanguages: () => [], + getSecondarySubtitleLanguages: () => [], + sendMpvCommand: (command) => { + commands.push(command); + }, + schedule: (callback) => { + scheduled.push(callback); + return 1 as never; + }, + clearScheduled: () => {}, + }); + + runtime.handleMediaPathChange('/videos/example.mkv'); + scheduled.shift()?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + runtime.handleSubtitleTrackListChange(mixedLanguageTrackList); + + assert.deepEqual(commands, [ + ['set_property', 'sid', 12], + ['set_property', 'secondary-sid', 11], + ]); +}); diff --git a/src/main/runtime/local-subtitle-selection.ts b/src/main/runtime/local-subtitle-selection.ts new file mode 100644 index 00000000..41597b62 --- /dev/null +++ b/src/main/runtime/local-subtitle-selection.ts @@ -0,0 +1,261 @@ +import path from 'node:path'; + +import { isRemoteMediaPath } from '../../jimaku/utils'; +import { normalizeYoutubeLangCode } from '../../core/services/youtube/labels'; + +const DEFAULT_PRIMARY_SUBTITLE_LANGUAGES = ['ja', 'jpn']; +const DEFAULT_SECONDARY_SUBTITLE_LANGUAGES = ['en', 'eng', 'english', 'enus', 'en-us']; +const HEARING_IMPAIRED_PATTERN = /\b(hearing impaired|sdh|closed captions?|cc)\b/i; + +type SubtitleTrackLike = { + type?: unknown; + id?: unknown; + lang?: unknown; + title?: unknown; + external?: unknown; + selected?: unknown; +}; + +type NormalizedSubtitleTrack = { + id: number; + lang: string; + title: string; + external: boolean; + selected: boolean; +}; + +export type ManagedLocalSubtitleSelection = { + primaryTrackId: number | null; + secondaryTrackId: number | null; + hasPrimaryMatch: boolean; + hasSecondaryMatch: boolean; +}; + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value.trim()); + return Number.isInteger(parsed) ? parsed : null; + } + return null; +} + +function normalizeTrack(entry: unknown): NormalizedSubtitleTrack | null { + if (!entry || typeof entry !== 'object') { + return null; + } + const track = entry as SubtitleTrackLike; + const id = parseTrackId(track.id); + if (id === null || (track.type !== undefined && track.type !== 'sub')) { + return null; + } + return { + id, + lang: String(track.lang || '').trim(), + title: String(track.title || '').trim(), + external: track.external === true, + selected: track.selected === true, + }; +} + +function normalizeLanguageList(values: string[], fallback: string[]): string[] { + const normalized = values + .map((value) => normalizeYoutubeLangCode(value)) + .filter((value, index, items) => value.length > 0 && items.indexOf(value) === index); + if (normalized.length > 0) { + return normalized; + } + return fallback + .map((value) => normalizeYoutubeLangCode(value)) + .filter((value, index, items) => value.length > 0 && items.indexOf(value) === index); +} + +function resolveLanguageRank(language: string, preferredLanguages: string[]): number { + const normalized = normalizeYoutubeLangCode(language); + if (!normalized) { + return Number.POSITIVE_INFINITY; + } + const directIndex = preferredLanguages.indexOf(normalized); + if (directIndex >= 0) { + return directIndex; + } + const base = normalized.split('-')[0] || normalized; + const baseIndex = preferredLanguages.indexOf(base); + return baseIndex >= 0 ? baseIndex : Number.POSITIVE_INFINITY; +} + +function isLikelyHearingImpaired(title: string): boolean { + return HEARING_IMPAIRED_PATTERN.test(title); +} + +function pickBestTrackId( + tracks: NormalizedSubtitleTrack[], + preferredLanguages: string[], + excludeId: number | null = null, +): { trackId: number | null; hasMatch: boolean } { + const ranked = tracks + .filter((track) => track.id !== excludeId) + .map((track) => ({ + track, + languageRank: resolveLanguageRank(track.lang, preferredLanguages), + })) + .filter(({ languageRank }) => Number.isFinite(languageRank)) + .sort((left, right) => { + if (left.languageRank !== right.languageRank) { + return left.languageRank - right.languageRank; + } + if (left.track.external !== right.track.external) { + return left.track.external ? -1 : 1; + } + if (isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)) { + return isLikelyHearingImpaired(left.track.title) ? 1 : -1; + } + if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) { + return /\bdefault\b/i.test(left.track.title) ? -1 : 1; + } + return left.track.id - right.track.id; + }); + + return { + trackId: ranked[0]?.track.id ?? null, + hasMatch: ranked.length > 0, + }; +} + +export function resolveManagedLocalSubtitleSelection(input: { + trackList: unknown[] | null; + primaryLanguages: string[]; + secondaryLanguages: string[]; +}): ManagedLocalSubtitleSelection { + const tracks = Array.isArray(input.trackList) + ? input.trackList.map(normalizeTrack).filter((track): track is NormalizedSubtitleTrack => track !== null) + : []; + const preferredPrimaryLanguages = normalizeLanguageList( + input.primaryLanguages, + DEFAULT_PRIMARY_SUBTITLE_LANGUAGES, + ); + const preferredSecondaryLanguages = normalizeLanguageList( + input.secondaryLanguages, + DEFAULT_SECONDARY_SUBTITLE_LANGUAGES, + ); + + const primary = pickBestTrackId(tracks, preferredPrimaryLanguages); + const secondary = pickBestTrackId(tracks, preferredSecondaryLanguages, primary.trackId); + + return { + primaryTrackId: primary.trackId, + secondaryTrackId: secondary.trackId, + hasPrimaryMatch: primary.hasMatch, + hasSecondaryMatch: secondary.hasMatch, + }; +} + +function normalizeLocalMediaPath(mediaPath: string | null | undefined): string | null { + if (typeof mediaPath !== 'string') { + return null; + } + const trimmed = mediaPath.trim(); + if (!trimmed || isRemoteMediaPath(trimmed)) { + return null; + } + return path.resolve(trimmed); +} + +export function createManagedLocalSubtitleSelectionRuntime(deps: { + getCurrentMediaPath: () => string | null; + getMpvClient: () => + | { + connected?: boolean; + requestProperty?: (name: string) => Promise; + } + | null; + getPrimarySubtitleLanguages: () => string[]; + getSecondarySubtitleLanguages: () => string[]; + sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void; + schedule: (callback: () => void, delayMs: number) => ReturnType; + clearScheduled: (timer: ReturnType) => void; + delayMs?: number; +}) { + const delayMs = deps.delayMs ?? 400; + let currentMediaPath: string | null = null; + let appliedMediaPath: string | null = null; + let pendingTimer: ReturnType | null = null; + + const clearPendingTimer = (): void => { + if (!pendingTimer) { + return; + } + deps.clearScheduled(pendingTimer); + pendingTimer = null; + }; + + const maybeApplySelection = (trackList: unknown[] | null): void => { + if (!currentMediaPath || appliedMediaPath === currentMediaPath) { + return; + } + const selection = resolveManagedLocalSubtitleSelection({ + trackList, + primaryLanguages: deps.getPrimarySubtitleLanguages(), + secondaryLanguages: deps.getSecondarySubtitleLanguages(), + }); + if (!selection.hasPrimaryMatch && !selection.hasSecondaryMatch) { + return; + } + if (selection.primaryTrackId !== null) { + deps.sendMpvCommand(['set_property', 'sid', selection.primaryTrackId]); + } + if (selection.secondaryTrackId !== null) { + deps.sendMpvCommand(['set_property', 'secondary-sid', selection.secondaryTrackId]); + } + appliedMediaPath = currentMediaPath; + clearPendingTimer(); + }; + + const refreshFromMpv = async (): Promise => { + const client = deps.getMpvClient(); + if (!client?.connected || !client.requestProperty) { + return; + } + const mediaPath = normalizeLocalMediaPath(deps.getCurrentMediaPath()); + if (!mediaPath || mediaPath !== currentMediaPath) { + return; + } + try { + const trackList = await client.requestProperty('track-list'); + maybeApplySelection(Array.isArray(trackList) ? trackList : null); + } catch { + // Skip selection when mpv track inspection fails. + } + }; + + const scheduleRefresh = (): void => { + clearPendingTimer(); + if (!currentMediaPath || appliedMediaPath === currentMediaPath) { + return; + } + pendingTimer = deps.schedule(() => { + pendingTimer = null; + void refreshFromMpv(); + }, delayMs); + }; + + return { + handleMediaPathChange: (mediaPath: string | null | undefined): void => { + const normalizedPath = normalizeLocalMediaPath(mediaPath); + if (normalizedPath !== currentMediaPath) { + appliedMediaPath = null; + } + currentMediaPath = normalizedPath; + if (!currentMediaPath) { + clearPendingTimer(); + return; + } + scheduleRefresh(); + }, + handleSubtitleTrackListChange: (trackList: unknown[] | null): void => { + maybeApplySelection(trackList); + }, + }; +} diff --git a/src/main/runtime/playlist-browser-ipc.ts b/src/main/runtime/playlist-browser-ipc.ts index 3dbecf43..598e6693 100644 --- a/src/main/runtime/playlist-browser-ipc.ts +++ b/src/main/runtime/playlist-browser-ipc.ts @@ -24,9 +24,15 @@ export type PlaylistBrowserIpcRuntime = { export function createPlaylistBrowserIpcRuntime( getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'], + options?: Pick< + PlaylistBrowserRuntimeDeps, + 'getPrimarySubtitleLanguages' | 'getSecondarySubtitleLanguages' + >, ): PlaylistBrowserIpcRuntime { const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = { getMpvClient, + getPrimarySubtitleLanguages: options?.getPrimarySubtitleLanguages, + getSecondarySubtitleLanguages: options?.getSecondarySubtitleLanguages, }; return { diff --git a/src/main/runtime/playlist-browser-runtime.test.ts b/src/main/runtime/playlist-browser-runtime.test.ts index 6c0e2433..f492e44b 100644 --- a/src/main/runtime/playlist-browser-runtime.test.ts +++ b/src/main/runtime/playlist-browser-runtime.test.ts @@ -267,6 +267,7 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps ]); assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]); scheduled[0]?.callback(); + await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(mpvClient.getCommands().slice(-2), [ ['set_property', 'sid', 'auto'], ['set_property', 'secondary-sid', 'auto'], @@ -472,6 +473,7 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca scheduled[0]?.(); scheduled[1]?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual( mpvClient.getCommands().slice(-6), @@ -485,3 +487,52 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca ], ); }); + +test('playlist-browser playback reapplies configured preferred subtitle tracks when track metadata is available', async (t) => { + const dir = createTempVideoDir(t); + const episode1 = path.join(dir, 'Show - S01E01.mkv'); + const episode2 = path.join(dir, 'Show - S01E02.mkv'); + fs.writeFileSync(episode1, ''); + fs.writeFileSync(episode2, ''); + + const mpvClient = createFakeMpvClient({ + currentVideoPath: episode1, + playlist: [ + { filename: episode1, current: true, title: 'Episode 1' }, + { filename: episode2, title: 'Episode 2' }, + ], + }); + const requestProperty = mpvClient.requestProperty.bind(mpvClient); + mpvClient.requestProperty = async (name: string): Promise => { + if (name === 'track-list') { + return [ + { type: 'sub', id: 1, lang: 'pt', title: '[Infinite]', external: false, selected: true }, + { type: 'sub', id: 3, lang: 'en', title: 'English', external: false }, + { type: 'sub', id: 11, lang: 'en', title: 'en.srt', external: true }, + { type: 'sub', id: 12, lang: 'ja', title: 'ja.srt', external: true }, + ]; + } + return requestProperty(name); + }; + + const scheduled: Array<() => void> = []; + const deps = { + getMpvClient: () => mpvClient, + getPrimarySubtitleLanguages: () => [], + getSecondarySubtitleLanguages: () => [], + schedule: (callback: () => void) => { + scheduled.push(callback); + }, + }; + + const result = await playPlaylistBrowserIndexRuntime(deps, 1); + assert.equal(result.ok, true); + + scheduled[0]?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(mpvClient.getCommands().slice(-2), [ + ['set_property', 'sid', 12], + ['set_property', 'secondary-sid', 11], + ]); +}); diff --git a/src/main/runtime/playlist-browser-runtime.ts b/src/main/runtime/playlist-browser-runtime.ts index 2a9324a2..45497d2d 100644 --- a/src/main/runtime/playlist-browser-runtime.ts +++ b/src/main/runtime/playlist-browser-runtime.ts @@ -8,6 +8,7 @@ import type { } from '../../types'; import { isRemoteMediaPath } from '../../jimaku/utils'; import { hasVideoExtension } from '../../shared/video-extensions'; +import { resolveManagedLocalSubtitleSelection } from './local-subtitle-selection'; import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort'; type PlaylistLike = { @@ -28,6 +29,8 @@ type MpvPlaylistBrowserClientLike = { export type PlaylistBrowserRuntimeDeps = { getMpvClient: () => MpvPlaylistBrowserClientLike | null; schedule?: (callback: () => void, delayMs: number) => void; + getPrimarySubtitleLanguages?: () => string[]; + getSecondarySubtitleLanguages?: () => string[]; }; const pendingLocalSubtitleSelectionRearms = new WeakMap(); @@ -229,9 +232,20 @@ async function buildMutationResult( }; } -function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void { - client.send({ command: ['set_property', 'sid', 'auto'] }); - client.send({ command: ['set_property', 'secondary-sid', 'auto'] }); +async function rearmLocalSubtitleSelection( + client: MpvPlaylistBrowserClientLike, + deps: PlaylistBrowserRuntimeDeps, +): Promise { + const trackList = await readProperty(client, 'track-list'); + const selection = resolveManagedLocalSubtitleSelection({ + trackList: Array.isArray(trackList) ? trackList : null, + primaryLanguages: deps.getPrimarySubtitleLanguages?.() ?? [], + secondaryLanguages: deps.getSecondarySubtitleLanguages?.() ?? [], + }); + client.send({ command: ['set_property', 'sid', selection.primaryTrackId ?? 'auto'] }); + client.send({ + command: ['set_property', 'secondary-sid', selection.secondaryTrackId ?? 'auto'], + }); } function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void { @@ -258,7 +272,7 @@ function scheduleLocalSubtitleSelectionRearm( if (currentPath && path.resolve(currentPath) !== expectedPath) { return; } - rearmLocalSubtitleSelection(client); + void rearmLocalSubtitleSelection(client, deps); }, 400); }
YouTubeAuto-loaded yt-dlp subtitle tracks at startup with a manual overlay picker on demand (Ctrl+Alt+C)Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (Ctrl+Alt+C)
AniList