mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-03 06:12:07 -07:00
fix: stabilize local subtitle startup and pause release
This commit is contained in:
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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)
|
## v0.10.0 (2026-03-29)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
### Integrations
|
### Integrations
|
||||||
@@ -76,7 +78,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>YouTube</b></td>
|
<td><b>YouTube</b></td>
|
||||||
<td>Auto-loaded yt-dlp subtitle tracks at startup with a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
<td>Auto-loaded yt-dlp subtitle tracks at startup with config-driven primary/secondary language priorities and a manual overlay picker on demand (<code>Ctrl+Alt+C</code>)</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>AniList</b></td>
|
<td><b>AniList</b></td>
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Secondary Subtitles
|
// Secondary Subtitles
|
||||||
// Dual subtitle track options.
|
// 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.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -415,14 +415,14 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Anilist
|
// Anilist
|
||||||
|
|||||||
@@ -448,6 +448,8 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `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:**
|
**Display modes:**
|
||||||
|
|
||||||
- **hidden** — Secondary subtitles not shown
|
- **hidden** — Secondary subtitles not shown
|
||||||
@@ -1342,7 +1344,7 @@ Usage notes:
|
|||||||
|
|
||||||
### YouTube Playback Settings
|
### 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -1354,7 +1356,7 @@ Set defaults used by the `subminer` launcher for YouTube subtitle loading:
|
|||||||
|
|
||||||
| Option | Values | Description |
|
| 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:
|
Current launcher behavior:
|
||||||
|
|
||||||
@@ -1370,6 +1372,7 @@ Language targets are derived from subtitle config:
|
|||||||
|
|
||||||
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
- primary track: `youtube.primarySubLanguages` (falls back to `["ja","jpn"]`)
|
||||||
- secondary track: `secondarySub.secondarySubLanguages` (falls back to English when empty)
|
- 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.
|
- 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.
|
Precedence for launcher defaults is: CLI flag > environment variable > `config.jsonc` > built-in default.
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// Secondary Subtitles
|
// Secondary Subtitles
|
||||||
// Dual subtitle track options.
|
// 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.
|
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -415,14 +415,14 @@
|
|||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// YouTube Playback Settings
|
// YouTube Playback Settings
|
||||||
// Defaults for SubMiner YouTube subtitle loading and languages.
|
// Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"primarySubLanguages": [
|
"primarySubLanguages": [
|
||||||
"ja",
|
"ja",
|
||||||
"jpn"
|
"jpn"
|
||||||
] // Comma-separated primary subtitle language priority for YouTube auto-loading.
|
] // Comma-separated primary subtitle language priority for managed subtitle auto-selection.
|
||||||
}, // Defaults for SubMiner YouTube subtitle loading and languages.
|
}, // Defaults for managed subtitle language preferences and YouTube subtitle loading.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Anilist
|
// Anilist
|
||||||
|
|||||||
@@ -213,10 +213,6 @@ secondary-sid=auto
|
|||||||
secondary-sub-visibility=no
|
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
|
### 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.
|
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).
|
- 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`.
|
- 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
|
## 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.
|
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+O` | Toggle visible overlay |
|
||||||
| `Alt+Shift+Y` | Open Yomitan settings |
|
| `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.
|
`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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -2138,7 +2138,7 @@ test('template generator includes known keys', () => {
|
|||||||
);
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
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, /"mode": "automatic"/);
|
||||||
assert.doesNotMatch(output, /"fixWithAi": false/);
|
assert.doesNotMatch(output, /"fixWithAi": false/);
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
path: 'youtube.primarySubLanguages',
|
path: 'youtube.primarySubLanguages',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.youtube.primarySubLanguages.join(','),
|
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',
|
path: 'controller.enabled',
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
title: 'Secondary Subtitles',
|
title: 'Secondary Subtitles',
|
||||||
description: [
|
description: [
|
||||||
'Dual subtitle track options.',
|
'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.'],
|
notes: ['Hot-reload: defaultMode updates live while SubMiner is running.'],
|
||||||
key: 'secondarySub',
|
key: 'secondarySub',
|
||||||
@@ -131,7 +131,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'YouTube Playback Settings',
|
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',
|
key: 'youtube',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
32
src/main.ts
32
src/main.ts
@@ -339,6 +339,7 @@ import { startStatsServer } from './core/services/stats-server';
|
|||||||
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
|
||||||
import {
|
import {
|
||||||
createFirstRunSetupService,
|
createFirstRunSetupService,
|
||||||
|
getFirstRunSetupCompletionMessage,
|
||||||
shouldAutoOpenFirstRunSetup,
|
shouldAutoOpenFirstRunSetup,
|
||||||
} from './main/runtime/first-run-setup-service';
|
} from './main/runtime/first-run-setup-service';
|
||||||
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
|
||||||
@@ -348,6 +349,7 @@ import {
|
|||||||
createYoutubePrimarySubtitleNotificationRuntime,
|
createYoutubePrimarySubtitleNotificationRuntime,
|
||||||
} from './main/runtime/youtube-primary-subtitle-notification';
|
} from './main/runtime/youtube-primary-subtitle-notification';
|
||||||
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
|
||||||
|
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
|
||||||
import {
|
import {
|
||||||
buildFirstRunSetupHtml,
|
buildFirstRunSetupHtml,
|
||||||
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
createMaybeFocusExistingFirstRunSetupWindowHandler,
|
||||||
@@ -1000,6 +1002,17 @@ const autoplayReadyGate = createAutoplayReadyGate({
|
|||||||
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
schedule: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
logDebug: (message) => logger.debug(message),
|
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({
|
const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
directPlaybackFormat: YOUTUBE_DIRECT_PLAYBACK_FORMAT,
|
||||||
@@ -2244,15 +2257,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
|
|||||||
firstRunSetupMessage = null;
|
firstRunSetupMessage = null;
|
||||||
return { closeWindow: true };
|
return { closeWindow: true };
|
||||||
}
|
}
|
||||||
if (snapshot.pluginStatus !== 'installed') {
|
firstRunSetupMessage =
|
||||||
firstRunSetupMessage = 'Install the mpv plugin before finishing setup.';
|
getFirstRunSetupCompletionMessage(snapshot) ??
|
||||||
return;
|
'Finish setup requires the mpv plugin and Yomitan dictionaries.';
|
||||||
}
|
|
||||||
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.';
|
|
||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
markSetupInProgress: async () => {
|
markSetupInProgress: async () => {
|
||||||
@@ -3331,6 +3338,7 @@ const {
|
|||||||
updateCurrentMediaPath: (path) => {
|
updateCurrentMediaPath: (path) => {
|
||||||
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
|
||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
|
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
subtitlePrefetchRuntime.clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchRuntime.cancelPendingInit();
|
subtitlePrefetchRuntime.cancelPendingInit();
|
||||||
@@ -3397,6 +3405,7 @@ const {
|
|||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||||
},
|
},
|
||||||
onSubtitleTrackListChange: (trackList) => {
|
onSubtitleTrackListChange: (trackList) => {
|
||||||
|
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
scheduleSubtitlePrefetchRefresh();
|
scheduleSubtitlePrefetchRefresh();
|
||||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||||
},
|
},
|
||||||
@@ -4138,7 +4147,10 @@ const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacen
|
|||||||
showMpvOsd: (text) => showMpvOsd(text),
|
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({
|
const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||||
mpvCommandMainDeps: {
|
mpvCommandMainDeps: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import { createAutoplayReadyGate } from './autoplay-ready-gate';
|
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<Array<string | boolean>> = [];
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
const scheduled: Array<() => void> = [];
|
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 });
|
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null });
|
||||||
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const firstScheduled = scheduled.shift();
|
const firstScheduled = scheduled.shift();
|
||||||
@@ -96,3 +95,49 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autoplay ready gate does not unpause again after a later manual pause on the same media', async () => {
|
||||||
|
const commands: Array<Array<string | boolean>> = [];
|
||||||
|
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<string | boolean> }) => {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
deps.getCurrentVideoPath()?.trim() ||
|
deps.getCurrentVideoPath()?.trim() ||
|
||||||
'__unknown__';
|
'__unknown__';
|
||||||
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
|
||||||
const allowDuplicateWhilePaused =
|
|
||||||
options?.forceWhilePaused === true && deps.getPlaybackPaused() !== false;
|
|
||||||
const releaseRetryDelayMs = 200;
|
const releaseRetryDelayMs = 200;
|
||||||
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
|
||||||
forceWhilePaused: options?.forceWhilePaused === true,
|
forceWhilePaused: options?.forceWhilePaused === true,
|
||||||
@@ -104,19 +102,13 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
|
|||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (duplicateMediaSignal && !allowDuplicateWhilePaused) {
|
if (duplicateMediaSignal) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!duplicateMediaSignal) {
|
|
||||||
autoPlayReadySignalMediaPath = mediaPath;
|
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
|
||||||
deps.signalPluginAutoplayReady();
|
|
||||||
attemptRelease(playbackGeneration, 0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
autoPlayReadySignalMediaPath = mediaPath;
|
||||||
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
const playbackGeneration = ++autoPlayReadySignalGeneration;
|
||||||
|
deps.signalPluginAutoplayReady();
|
||||||
attemptRelease(playbackGeneration, 0);
|
attemptRelease(playbackGeneration, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
77
src/main/runtime/local-subtitle-selection.test.ts
Normal file
@@ -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<Array<string | number>> = [];
|
||||||
|
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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
261
src/main/runtime/local-subtitle-selection.ts
Normal file
261
src/main/runtime/local-subtitle-selection.ts
Normal file
@@ -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<unknown>;
|
||||||
|
}
|
||||||
|
| null;
|
||||||
|
getPrimarySubtitleLanguages: () => string[];
|
||||||
|
getSecondarySubtitleLanguages: () => string[];
|
||||||
|
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;
|
||||||
|
schedule: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
|
||||||
|
clearScheduled: (timer: ReturnType<typeof setTimeout>) => void;
|
||||||
|
delayMs?: number;
|
||||||
|
}) {
|
||||||
|
const delayMs = deps.delayMs ?? 400;
|
||||||
|
let currentMediaPath: string | null = null;
|
||||||
|
let appliedMediaPath: string | null = null;
|
||||||
|
let pendingTimer: ReturnType<typeof setTimeout> | 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<void> => {
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,9 +24,15 @@ export type PlaylistBrowserIpcRuntime = {
|
|||||||
|
|
||||||
export function createPlaylistBrowserIpcRuntime(
|
export function createPlaylistBrowserIpcRuntime(
|
||||||
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
getMpvClient: PlaylistBrowserRuntimeDeps['getMpvClient'],
|
||||||
|
options?: Pick<
|
||||||
|
PlaylistBrowserRuntimeDeps,
|
||||||
|
'getPrimarySubtitleLanguages' | 'getSecondarySubtitleLanguages'
|
||||||
|
>,
|
||||||
): PlaylistBrowserIpcRuntime {
|
): PlaylistBrowserIpcRuntime {
|
||||||
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
const playlistBrowserRuntimeDeps: PlaylistBrowserRuntimeDeps = {
|
||||||
getMpvClient,
|
getMpvClient,
|
||||||
|
getPrimarySubtitleLanguages: options?.getPrimarySubtitleLanguages,
|
||||||
|
getSecondarySubtitleLanguages: options?.getSecondarySubtitleLanguages,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -267,6 +267,7 @@ test('playlist-browser mutation runtimes mutate queue and return refreshed snaps
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
assert.deepEqual(scheduled.map((entry) => entry.delayMs), [400]);
|
||||||
scheduled[0]?.callback();
|
scheduled[0]?.callback();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
assert.deepEqual(mpvClient.getCommands().slice(-2), [
|
||||||
['set_property', 'sid', 'auto'],
|
['set_property', 'sid', 'auto'],
|
||||||
['set_property', 'secondary-sid', 'auto'],
|
['set_property', 'secondary-sid', 'auto'],
|
||||||
@@ -472,6 +473,7 @@ test('playPlaylistBrowserIndexRuntime ignores superseded local subtitle rearm ca
|
|||||||
|
|
||||||
scheduled[0]?.();
|
scheduled[0]?.();
|
||||||
scheduled[1]?.();
|
scheduled[1]?.();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
mpvClient.getCommands().slice(-6),
|
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<unknown> => {
|
||||||
|
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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from '../../types';
|
} from '../../types';
|
||||||
import { isRemoteMediaPath } from '../../jimaku/utils';
|
import { isRemoteMediaPath } from '../../jimaku/utils';
|
||||||
import { hasVideoExtension } from '../../shared/video-extensions';
|
import { hasVideoExtension } from '../../shared/video-extensions';
|
||||||
|
import { resolveManagedLocalSubtitleSelection } from './local-subtitle-selection';
|
||||||
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
import { sortPlaylistBrowserDirectoryItems } from './playlist-browser-sort';
|
||||||
|
|
||||||
type PlaylistLike = {
|
type PlaylistLike = {
|
||||||
@@ -28,6 +29,8 @@ type MpvPlaylistBrowserClientLike = {
|
|||||||
export type PlaylistBrowserRuntimeDeps = {
|
export type PlaylistBrowserRuntimeDeps = {
|
||||||
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
getMpvClient: () => MpvPlaylistBrowserClientLike | null;
|
||||||
schedule?: (callback: () => void, delayMs: number) => void;
|
schedule?: (callback: () => void, delayMs: number) => void;
|
||||||
|
getPrimarySubtitleLanguages?: () => string[];
|
||||||
|
getSecondarySubtitleLanguages?: () => string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
const pendingLocalSubtitleSelectionRearms = new WeakMap<MpvPlaylistBrowserClientLike, number>();
|
||||||
@@ -229,9 +232,20 @@ async function buildMutationResult(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function rearmLocalSubtitleSelection(client: MpvPlaylistBrowserClientLike): void {
|
async function rearmLocalSubtitleSelection(
|
||||||
client.send({ command: ['set_property', 'sid', 'auto'] });
|
client: MpvPlaylistBrowserClientLike,
|
||||||
client.send({ command: ['set_property', 'secondary-sid', 'auto'] });
|
deps: PlaylistBrowserRuntimeDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
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 {
|
function prepareLocalSubtitleAutoload(client: MpvPlaylistBrowserClientLike): void {
|
||||||
@@ -258,7 +272,7 @@ function scheduleLocalSubtitleSelectionRearm(
|
|||||||
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
if (currentPath && path.resolve(currentPath) !== expectedPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rearmLocalSubtitleSelection(client);
|
void rearmLocalSubtitleSelection(client, deps);
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user