mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-18 00:21:41 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ebf6a45fe
|
||
|
|
fc7fde30d5
|
||
|
|
f335b26fe3
|
||
|
|
00b1b79bf4 |
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed the overlay getting stuck on "Overlay loading" forever when startup stalls: mpv IPC connection attempts now time out and retry, switching sockets aborts obsolete attempts, and the plugin replaces its spinner with an actionable error if overlay content is still not ready after 30 seconds.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-04
|
||||
Last verified: 2026-08-17
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -77,6 +77,25 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||
or character images, but cue visibility does not wait for that work.
|
||||
|
||||
## Secondary Subtitle Flow
|
||||
|
||||
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
|
||||
still appear without waiting for file resolution.
|
||||
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
|
||||
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
|
||||
source resolver used by primary subtitle prefetching.
|
||||
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
|
||||
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
|
||||
`secondary-sub-delay`.
|
||||
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
|
||||
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
|
||||
text when a readable source is available.
|
||||
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
|
||||
track-list changes refresh without discarding an unchanged source. Observed
|
||||
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
|
||||
extraction, or parsing fails, the controller returns to live mpv text and the renderer's
|
||||
conservative short stack heuristic remains the final display fallback.
|
||||
|
||||
## Emitted State
|
||||
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||
@@ -84,8 +103,8 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
||||
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||
when text and cue timing match.
|
||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||
- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track
|
||||
controller. A parsed active cue replaces the live text when the selected source is readable.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
written.
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local OVERLAY_LOADING_OSD_PREFIX = "Overlay loading "
|
||||
local OVERLAY_LOADING_OSD_FRAMES = { "|", "/", "-", "\\" }
|
||||
local OVERLAY_LOADING_OSD_REFRESH_SECONDS = 0.18
|
||||
local OVERLAY_LOADING_OSD_DEADLINE_SECONDS = 30
|
||||
local OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE = "Overlay did not become ready; check SubMiner logs"
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 30
|
||||
@@ -265,10 +267,19 @@ function M.create(ctx)
|
||||
state.overlay_loading_osd_timer = nil
|
||||
end
|
||||
|
||||
local function clear_overlay_loading_osd_deadline()
|
||||
local timeout = state.overlay_loading_osd_deadline
|
||||
if timeout and timeout.kill then
|
||||
timeout:kill()
|
||||
end
|
||||
state.overlay_loading_osd_deadline = nil
|
||||
end
|
||||
|
||||
local function stop_overlay_loading_osd()
|
||||
state.overlay_loading_osd_active = false
|
||||
state.overlay_loading_osd_frame = 1
|
||||
clear_overlay_loading_osd_timer()
|
||||
clear_overlay_loading_osd_deadline()
|
||||
end
|
||||
|
||||
local function start_overlay_loading_osd()
|
||||
@@ -291,6 +302,21 @@ function M.create(ctx)
|
||||
end
|
||||
end)
|
||||
end
|
||||
if type(mp.add_timeout) == "function" then
|
||||
state.overlay_loading_osd_deadline = mp.add_timeout(OVERLAY_LOADING_OSD_DEADLINE_SECONDS, function()
|
||||
if not state.overlay_loading_osd_active then
|
||||
return
|
||||
end
|
||||
state.overlay_loading_osd_deadline = nil
|
||||
stop_overlay_loading_osd()
|
||||
subminer_log(
|
||||
"warn",
|
||||
"process",
|
||||
"Overlay loading deadline expired before the app reported content ready"
|
||||
)
|
||||
show_osd(OVERLAY_LOADING_OSD_TIMEOUT_MESSAGE, { force = true })
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
local function disarm_auto_play_ready_gate(options)
|
||||
|
||||
@@ -26,6 +26,7 @@ function M.new()
|
||||
auto_play_ready_initial_pause_ownership_consumed = false,
|
||||
overlay_loading_osd_active = false,
|
||||
overlay_loading_osd_timer = nil,
|
||||
overlay_loading_osd_deadline = nil,
|
||||
overlay_loading_osd_frame = 1,
|
||||
pending_visible_overlay_hide_timer = nil,
|
||||
pending_visible_overlay_hide_generation = 0,
|
||||
|
||||
@@ -130,7 +130,9 @@ local function run_plugin_scenario(config)
|
||||
|
||||
function mp.add_timeout(seconds, callback)
|
||||
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||
local delay = tonumber(seconds) or 0
|
||||
local timeout = {
|
||||
seconds = delay,
|
||||
killed = false,
|
||||
callback = callback,
|
||||
}
|
||||
@@ -138,7 +140,6 @@ local function run_plugin_scenario(config)
|
||||
self.killed = true
|
||||
end
|
||||
|
||||
local delay = tonumber(seconds) or 0
|
||||
if callback and delay < 5 and not config.defer_timeouts then
|
||||
callback()
|
||||
end
|
||||
@@ -514,6 +515,15 @@ local function has_timeout(timeouts, target)
|
||||
return false
|
||||
end
|
||||
|
||||
local function find_timeout_handle(recorded, target)
|
||||
for _, timeout in ipairs(recorded.timeout_handles) do
|
||||
if math.abs(timeout.seconds - target) < 0.0001 then
|
||||
return timeout
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function env_has(call, target)
|
||||
local env = (call and call.env) or {}
|
||||
for _, value in ipairs(env) do
|
||||
@@ -1636,6 +1646,8 @@ do
|
||||
#recorded.periodic_timers == 1,
|
||||
"auto-start visible overlay should refresh the early overlay loading OSD"
|
||||
)
|
||||
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
|
||||
assert_true(overlay_loading_deadline ~= nil, "overlay loading OSD should have a bounded deadline")
|
||||
local overlay_loading_timer = recorded.periodic_timers[1]
|
||||
recorded.periodic_timers[1].callback()
|
||||
assert_true(
|
||||
@@ -1670,6 +1682,46 @@ do
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading ready should stop the early overlay loading OSD refresher"
|
||||
)
|
||||
assert_true(
|
||||
overlay_loading_deadline.killed == true,
|
||||
"overlay loading ready should cancel the bounded loading deadline"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
defer_timeouts = true,
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
osd_messages = false,
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for overlay loading deadline scenario: " .. tostring(err))
|
||||
fire_event(recorded, "start-file")
|
||||
local overlay_loading_deadline = find_timeout_handle(recorded, 30)
|
||||
assert_true(overlay_loading_deadline ~= nil, "overlay loading deadline should be scheduled")
|
||||
overlay_loading_deadline.callback()
|
||||
assert_true(
|
||||
recorded.periodic_timers[1].killed == true,
|
||||
"overlay loading deadline should stop the loading spinner"
|
||||
)
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Overlay did not become ready; check SubMiner logs"),
|
||||
"overlay loading deadline should replace the spinner with actionable feedback"
|
||||
)
|
||||
assert_true(
|
||||
has_log_containing(recorded.logs, "Overlay loading deadline expired"),
|
||||
"overlay loading deadline should leave a diagnostic log entry"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -125,9 +125,40 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
|
||||
test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
let requestedRawSecondaryText = false;
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: 'Your\nmosaic',
|
||||
requestProperty: async () => {
|
||||
requestedRawSecondaryText = true;
|
||||
return 'Your\nYour\nYour\nYour\nmosaic';
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.equal(requestedRawSecondaryText, false);
|
||||
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
const requestedProperties: string[] = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
@@ -145,43 +176,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: '日本語字幕',
|
||||
requestProperty: async (name: string) => {
|
||||
requestedProperties.push(name);
|
||||
return name === 'secondary-sub-text' ? 'English subtitle' : null;
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(requestedProperties, ['secondary-sub-text']);
|
||||
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]);
|
||||
});
|
||||
|
||||
test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => {
|
||||
const created: Array<{ sentence: string; secondarySub?: string }> = [];
|
||||
|
||||
await mineSentenceCard({
|
||||
ankiIntegration: {
|
||||
updateLastAddedFromClipboard: async () => {},
|
||||
triggerFieldGroupingForLastAddedCard: async () => {},
|
||||
markLastCardAsAudioCard: async () => {},
|
||||
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
|
||||
created.push({ sentence, secondarySub });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
mpvClient: {
|
||||
connected: true,
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSecondarySubText: 'stale cached subtitle',
|
||||
requestProperty: async (name: string) => {
|
||||
if (name === 'secondary-sub-text') {
|
||||
return '';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
});
|
||||
|
||||
@@ -129,19 +129,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function getCurrentSecondarySubTextForSentenceCard(
|
||||
mpvClient: MpvClientLike,
|
||||
): Promise<string | undefined> {
|
||||
const primaryText = mpvClient.currentSubText;
|
||||
if (mpvClient.requestProperty) {
|
||||
try {
|
||||
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
|
||||
return normalizeSecondarySubText(latestSecondaryText, primaryText);
|
||||
} catch {
|
||||
// Fall back to the cached secondary subtitle below.
|
||||
}
|
||||
}
|
||||
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
|
||||
function getCurrentSecondarySubTextForSentenceCard(mpvClient: MpvClientLike): string | undefined {
|
||||
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, mpvClient.currentSubText);
|
||||
}
|
||||
|
||||
export async function updateLastCardFromClipboard(deps: {
|
||||
@@ -190,7 +179,7 @@ export async function mineSentenceCard(deps: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient);
|
||||
return await anki.createSentenceCard(
|
||||
mpvClient.currentSubText,
|
||||
mpvClient.currentSubStart,
|
||||
|
||||
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'secondary-sub-visibility',
|
||||
'sub-visibility',
|
||||
'sid',
|
||||
'secondary-sid',
|
||||
'secondary-sub-delay',
|
||||
'track-list',
|
||||
];
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
emitSubtitleTiming: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
|
||||
emitSubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload),
|
||||
emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload),
|
||||
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
|
||||
getCurrentSubText: () => state.subText,
|
||||
setCurrentSubText: (text) => {
|
||||
@@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sub-delay', data: '0.5' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
|
||||
assert.deepEqual(state.events, [
|
||||
{ sid: 3 },
|
||||
{ sid: 4 },
|
||||
{ delay: 0.5 },
|
||||
{ trackList: [{ type: 'sub', id: 3 }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: '4.5' },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'secondary-sid', data: 4.5 },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
|
||||
|
||||
@@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
emitSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
|
||||
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
getCurrentSubText: () => string;
|
||||
setCurrentSubText: (text: string) => void;
|
||||
@@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage(
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: null;
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
|
||||
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
|
||||
} else if (msg.name === 'secondary-sid') {
|
||||
const sid =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: null;
|
||||
deps.emitSecondarySubtitleTrackChange({
|
||||
sid: sid !== null && Number.isInteger(sid) ? sid : null,
|
||||
});
|
||||
} else if (msg.name === 'secondary-sub-delay') {
|
||||
const delay =
|
||||
typeof msg.data === 'number'
|
||||
? msg.data
|
||||
: typeof msg.data === 'string'
|
||||
? Number(msg.data)
|
||||
: 0;
|
||||
deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 });
|
||||
} else if (msg.name === 'track-list') {
|
||||
deps.emitSubtitleTrackListChange({
|
||||
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
|
||||
|
||||
@@ -38,7 +38,15 @@ class ManualCloseSocket extends FakeSocket {
|
||||
}
|
||||
}
|
||||
|
||||
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
class HangingSocket extends FakeSocket {
|
||||
override connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
// Never emits 'connect', 'error', or 'close' on its own: models a named
|
||||
// pipe dial that stalls indefinitely.
|
||||
}
|
||||
}
|
||||
|
||||
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
test('getMpvReconnectDelay follows existing reconnect ramp', () => {
|
||||
assert.equal(getMpvReconnectDelay(0, true), 1000);
|
||||
@@ -232,6 +240,75 @@ test('MpvSocketTransport.shutdown clears socket and lifecycle flags', async () =
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
test('MpvSocketTransport aborts a hung connect after the timeout and allows a fresh dial', async () => {
|
||||
const events: string[] = [];
|
||||
const errors: Error[] = [];
|
||||
const sockets: HangingSocket[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
connectTimeoutMs: 5,
|
||||
onConnect: () => {
|
||||
events.push('connect');
|
||||
},
|
||||
onData: () => {},
|
||||
onError: (error) => {
|
||||
events.push('error');
|
||||
errors.push(error);
|
||||
},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => {
|
||||
const socket = new HangingSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as net.Socket;
|
||||
},
|
||||
});
|
||||
|
||||
transport.connect();
|
||||
assert.equal(transport.isConnecting, true);
|
||||
|
||||
await wait(20);
|
||||
|
||||
assert.deepEqual(events, ['error', 'close']);
|
||||
assert.match(errors[0]!.message, /connect timed out/);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
assert.equal(transport.isConnecting, false);
|
||||
assert.equal(transport.isConnected, false);
|
||||
|
||||
transport.connect();
|
||||
assert.equal(transport.isConnecting, true);
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
|
||||
transport.shutdown();
|
||||
});
|
||||
|
||||
test('MpvSocketTransport does not fire the connect timeout after a successful connect', async () => {
|
||||
const events: string[] = [];
|
||||
const transport = new MpvSocketTransport({
|
||||
socketPath: '/tmp/mpv.sock',
|
||||
connectTimeoutMs: 5,
|
||||
onConnect: () => {
|
||||
events.push('connect');
|
||||
},
|
||||
onData: () => {},
|
||||
onError: () => {
|
||||
events.push('error');
|
||||
},
|
||||
onClose: () => {
|
||||
events.push('close');
|
||||
},
|
||||
socketFactory: () => new FakeSocket() as unknown as net.Socket,
|
||||
});
|
||||
|
||||
transport.connect();
|
||||
await wait(20);
|
||||
|
||||
assert.deepEqual(events, ['connect']);
|
||||
assert.equal(transport.isConnected, true);
|
||||
});
|
||||
|
||||
test('MpvSocketTransport ignores stale socket events after shutdown and reconnect', async () => {
|
||||
const events: string[] = [];
|
||||
const sockets: ManualCloseSocket[] = [];
|
||||
|
||||
@@ -62,6 +62,8 @@ interface MpvSocketTransportEvents {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const MPV_CONNECT_TIMEOUT_MS = 5000;
|
||||
|
||||
export interface MpvSocketTransportOptions {
|
||||
socketPath: string;
|
||||
onConnect: () => void;
|
||||
@@ -69,13 +71,16 @@ export interface MpvSocketTransportOptions {
|
||||
onError: (error: Error) => void;
|
||||
onClose: () => void;
|
||||
socketFactory?: () => net.Socket;
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export class MpvSocketTransport {
|
||||
private socketPath: string;
|
||||
private readonly callbacks: MpvSocketTransportEvents;
|
||||
private readonly socketFactory: () => net.Socket;
|
||||
private readonly connectTimeoutMs: number;
|
||||
private socketRef: net.Socket | null = null;
|
||||
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
public socket: net.Socket | null = null;
|
||||
public connected = false;
|
||||
public connecting = false;
|
||||
@@ -83,6 +88,7 @@ export class MpvSocketTransport {
|
||||
constructor(options: MpvSocketTransportOptions) {
|
||||
this.socketPath = options.socketPath;
|
||||
this.socketFactory = options.socketFactory ?? (() => new net.Socket());
|
||||
this.connectTimeoutMs = options.connectTimeoutMs ?? MPV_CONNECT_TIMEOUT_MS;
|
||||
this.callbacks = {
|
||||
onConnect: options.onConnect,
|
||||
onData: options.onData,
|
||||
@@ -91,6 +97,31 @@ export class MpvSocketTransport {
|
||||
};
|
||||
}
|
||||
|
||||
private clearConnectTimeout(): void {
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A named-pipe/socket dial that neither connects nor errors would otherwise
|
||||
// latch `connecting` forever and silently block every future connect().
|
||||
private armConnectTimeout(socket: net.Socket): void {
|
||||
this.clearConnectTimeout();
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.connectTimer = null;
|
||||
if (this.socketRef !== socket || this.connected) return;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(
|
||||
new Error(`MPV IPC connect timed out after ${this.connectTimeoutMs}ms: ${this.socketPath}`),
|
||||
);
|
||||
// Destroying the socket emits 'close', which drives the normal
|
||||
// disconnect path (including reconnect scheduling) upstream.
|
||||
socket.destroy();
|
||||
}, this.connectTimeoutMs);
|
||||
this.connectTimer.unref?.();
|
||||
}
|
||||
|
||||
setSocketPath(socketPath: string): void {
|
||||
this.socketPath = socketPath;
|
||||
}
|
||||
@@ -111,6 +142,7 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('connect', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.callbacks.onConnect();
|
||||
@@ -123,6 +155,7 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onError(error);
|
||||
@@ -130,12 +163,14 @@ export class MpvSocketTransport {
|
||||
|
||||
socket.on('close', () => {
|
||||
if (this.socketRef !== socket) return;
|
||||
this.clearConnectTimeout();
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.callbacks.onClose();
|
||||
});
|
||||
|
||||
socket.connect(this.socketPath);
|
||||
this.armConnectTimeout(socket);
|
||||
}
|
||||
|
||||
send(payload: MpvSocketMessagePayload): boolean {
|
||||
@@ -149,6 +184,7 @@ export class MpvSocketTransport {
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.clearConnectTimeout();
|
||||
const socket = this.socketRef;
|
||||
this.socketRef = null;
|
||||
this.socket = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import {
|
||||
MpvIpcClient,
|
||||
MpvIpcClientDeps,
|
||||
@@ -23,6 +24,18 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
|
||||
};
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error('Timed out waiting for MPV retry connection');
|
||||
}
|
||||
await wait(10);
|
||||
}
|
||||
}
|
||||
|
||||
function captureWarnLogs(run: () => void): string[] {
|
||||
const originalWarn = console.warn;
|
||||
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
@@ -756,3 +769,117 @@ test('MpvIpcClient playNextSubtitle still auto-pauses at end while already playi
|
||||
assert.equal((client as any).pendingPauseAtSubEnd, true);
|
||||
assert.deepEqual(commands, [{ command: ['sub-seek', 1] }]);
|
||||
});
|
||||
|
||||
class HangingTestSocket extends EventEmitter {
|
||||
public connectedPaths: string[] = [];
|
||||
public destroyed = false;
|
||||
|
||||
connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
// Never resolves: models a stalled named-pipe dial.
|
||||
}
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RetryTestSocket extends EventEmitter {
|
||||
public connectedPaths: string[] = [];
|
||||
public destroyed = false;
|
||||
|
||||
constructor(private readonly shouldConnect: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
connect(path: string): void {
|
||||
this.connectedPaths.push(path);
|
||||
if (this.shouldConnect) {
|
||||
setTimeout(() => this.emit('connect'), 0);
|
||||
}
|
||||
}
|
||||
|
||||
write(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.emit('close');
|
||||
}
|
||||
}
|
||||
|
||||
test('MpvIpcClient automatically retries the same socket path after a connect timeout', async () => {
|
||||
const sockets: RetryTestSocket[] = [];
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv.sock',
|
||||
makeDeps({
|
||||
connectTimeoutMs: 5,
|
||||
getReconnectTimer: () => reconnectTimer,
|
||||
setReconnectTimer: (timer) => {
|
||||
reconnectTimer = timer;
|
||||
},
|
||||
socketFactory: () => {
|
||||
const socket = new RetryTestSocket(sockets.length > 0);
|
||||
sockets.push(socket);
|
||||
return socket as unknown as import('node:net').Socket;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.SUBMINER_LOG_LEVEL = 'error';
|
||||
try {
|
||||
client.connect();
|
||||
await waitFor(() => client.connected);
|
||||
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv.sock');
|
||||
assert.equal(client.connected, true);
|
||||
} finally {
|
||||
if (originalLogLevel === undefined) {
|
||||
delete process.env.SUBMINER_LOG_LEVEL;
|
||||
} else {
|
||||
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
|
||||
}
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
(client as any).transport.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test('MpvIpcClient.setSocketPath aborts an in-flight connect so the next dial targets the new path', () => {
|
||||
const sockets: HangingTestSocket[] = [];
|
||||
const client = new MpvIpcClient(
|
||||
'/tmp/mpv-old.sock',
|
||||
makeDeps({
|
||||
socketFactory: () => {
|
||||
const socket = new HangingTestSocket();
|
||||
sockets.push(socket);
|
||||
return socket as unknown as import('node:net').Socket;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
client.connect();
|
||||
assert.equal(sockets.length, 1);
|
||||
assert.equal(sockets[0]!.connectedPaths.at(0), '/tmp/mpv-old.sock');
|
||||
assert.equal((client as any).connecting, true);
|
||||
|
||||
client.setSocketPath('/tmp/mpv-new.sock');
|
||||
assert.equal((client as any).connecting, false);
|
||||
assert.equal(sockets[0]!.destroyed, true);
|
||||
|
||||
client.connect();
|
||||
assert.equal(sockets.length, 2);
|
||||
assert.equal(sockets[1]!.connectedPaths.at(0), '/tmp/mpv-new.sock');
|
||||
|
||||
(client as any).transport.shutdown();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
splitMpvMessagesFromBuffer,
|
||||
} from './mpv-protocol';
|
||||
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
|
||||
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
|
||||
import {
|
||||
scheduleMpvReconnect,
|
||||
MpvSocketTransport,
|
||||
MpvSocketTransportOptions,
|
||||
} from './mpv-transport';
|
||||
import { createLogger } from '../../logger';
|
||||
|
||||
const logger = createLogger('main:mpv');
|
||||
@@ -110,6 +114,8 @@ export interface MpvIpcClientProtocolDeps {
|
||||
shouldAutoLoadSecondarySubTrack?: (path: string) => boolean;
|
||||
shouldQuitOnMpvShutdown?: () => boolean;
|
||||
requestAppQuit?: () => void;
|
||||
socketFactory?: MpvSocketTransportOptions['socketFactory'];
|
||||
connectTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
@@ -125,6 +131,8 @@ export interface MpvIpcClientEventMap {
|
||||
'fullscreen-change': { fullscreen: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-delay-change': { delay: number };
|
||||
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||
'media-path-change': { path: string };
|
||||
'media-title-change': { title: string | null };
|
||||
@@ -188,6 +196,8 @@ export class MpvIpcClient implements MpvClient {
|
||||
|
||||
this.transport = new MpvSocketTransport({
|
||||
socketPath,
|
||||
socketFactory: deps.socketFactory,
|
||||
connectTimeoutMs: deps.connectTimeoutMs,
|
||||
onConnect: () => {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
@@ -289,6 +299,14 @@ export class MpvIpcClient implements MpvClient {
|
||||
previousSocketPath: this.socketPath,
|
||||
socketPath,
|
||||
});
|
||||
if (this.connecting && !this.connected) {
|
||||
// Abort the in-flight dial to the old path; otherwise the connecting
|
||||
// latch turns every later connect() into a no-op while we hang on a
|
||||
// stale socket.
|
||||
logger.debug('Aborting in-flight MPV IPC connect for socket path change.');
|
||||
this.transport.shutdown();
|
||||
this.connecting = false;
|
||||
}
|
||||
}
|
||||
this.socketPath = socketPath;
|
||||
this.transport.setSocketPath(socketPath);
|
||||
@@ -422,6 +440,12 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitSubtitleTrackChange: (payload) => {
|
||||
this.emit('subtitle-track-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleTrackChange: (payload) => {
|
||||
this.emit('secondary-subtitle-track-change', payload);
|
||||
},
|
||||
emitSecondarySubtitleDelayChange: (payload) => {
|
||||
this.emit('secondary-subtitle-delay-change', payload);
|
||||
},
|
||||
emitSubtitleTrackListChange: (payload) => {
|
||||
this.emit('subtitle-track-list-change', payload);
|
||||
},
|
||||
|
||||
+35
-1
@@ -527,6 +527,7 @@ import {
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler,
|
||||
createResolveActiveSubtitleSidebarSourceHandler,
|
||||
} from './main/runtime/subtitle-prefetch-runtime';
|
||||
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
|
||||
import {
|
||||
createCreateAnilistSetupWindowHandler,
|
||||
createCreateConfigSettingsWindowHandler,
|
||||
@@ -1943,7 +1944,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
|
||||
getLastObservedTimePos: () => lastObservedTimePos,
|
||||
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
|
||||
emitSecondarySubtitle: (text) => {
|
||||
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
secondarySubtitleTrackController.handleLiveText(text);
|
||||
},
|
||||
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
|
||||
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
|
||||
@@ -2001,6 +2002,24 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
|
||||
logDebug: (message) => logger.debug(message),
|
||||
});
|
||||
|
||||
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
|
||||
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
|
||||
loadSubtitleSourceText,
|
||||
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
|
||||
setCurrentSecondaryText: (text) => {
|
||||
if (appState.mpvClient) {
|
||||
appState.mpvClient.currentSecondarySubText = text;
|
||||
}
|
||||
},
|
||||
broadcastSecondaryText: (text) => {
|
||||
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
},
|
||||
logDebug: (message) => logger.debug(message),
|
||||
logWarn: (message, error) => logger.warn(message, error),
|
||||
});
|
||||
|
||||
const refreshSubtitlePrefetchFromActiveTrackHandler =
|
||||
createRefreshSubtitlePrefetchFromActiveTrackHandler({
|
||||
getMpvClient: () => appState.mpvClient,
|
||||
@@ -4383,6 +4402,7 @@ const {
|
||||
onMpvConnected: () => {
|
||||
maybeStartOverlayLoadingOsd();
|
||||
flushQueuedMpvOsdNotifications();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
if (appState.sessionBindingsInitialized) {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
@@ -4401,6 +4421,9 @@ const {
|
||||
broadcastToOverlayWindows: (channel, payload) => {
|
||||
overlayManager.broadcastToOverlayWindows(channel, payload);
|
||||
},
|
||||
onSecondarySubtitleChange: (text) => {
|
||||
secondarySubtitleTrackController.handleLiveText(text);
|
||||
},
|
||||
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
|
||||
emitImmediateSubtitle: (payload) => {
|
||||
emitSubtitlePayload(payload);
|
||||
@@ -4434,6 +4457,7 @@ const {
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
);
|
||||
if ((normalizedPath || null) !== previousPath) {
|
||||
secondarySubtitleTrackController.reset();
|
||||
const resetSubtitlePayload = { text: '', tokens: null };
|
||||
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
|
||||
const frequencyOptions = {
|
||||
@@ -4468,6 +4492,7 @@ const {
|
||||
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
|
||||
if (path) {
|
||||
ensureImmersionTrackerStarted();
|
||||
secondarySubtitleTrackController.scheduleRefresh();
|
||||
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
|
||||
// Retry after a short delay because MPV can populate track-list after path.
|
||||
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
|
||||
@@ -4522,6 +4547,7 @@ const {
|
||||
subtitlePrefetchService.onSeek(time);
|
||||
}
|
||||
lastObservedTimePos = time;
|
||||
secondarySubtitleTrackController.handleTimePos(time);
|
||||
},
|
||||
onFullscreenChange: (fullscreen) => {
|
||||
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
|
||||
@@ -4549,6 +4575,13 @@ const {
|
||||
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
|
||||
},
|
||||
onSecondarySubtitleTrackChange: () => {
|
||||
secondarySubtitleTrackController.handleTrackChange();
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
},
|
||||
onSecondarySubtitleDelayChange: (delay) => {
|
||||
secondarySubtitleTrackController.handleDelayChange(delay);
|
||||
},
|
||||
onSubtitleTrackListChange: (trackList) => {
|
||||
const diagnostics = buildSubtitleTrackDiagnostics(
|
||||
lastObservedPrimarySubtitleTrackId,
|
||||
@@ -4562,6 +4595,7 @@ const {
|
||||
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
|
||||
}
|
||||
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
|
||||
secondarySubtitleTrackController.scheduleRefresh(0);
|
||||
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
|
||||
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
|
||||
},
|
||||
|
||||
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
onSubtitleAssChange: () => {},
|
||||
onSecondarySubtitleChange: () => {},
|
||||
onSubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleTrackChange: () => {},
|
||||
onSecondarySubtitleDelayChange: () => {},
|
||||
onSubtitleTrackListChange: () => {},
|
||||
onSubtitleTiming: () => {},
|
||||
onMediaPathChange: () => {},
|
||||
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
|
||||
'subtitle-ass-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'secondary-subtitle-track-change',
|
||||
'secondary-subtitle-delay-change',
|
||||
'subtitle-track-list-change',
|
||||
'subtitle-timing',
|
||||
'media-path-change',
|
||||
|
||||
@@ -4,6 +4,8 @@ type MpvBindingEventName =
|
||||
| 'subtitle-ass-change'
|
||||
| 'secondary-subtitle-change'
|
||||
| 'subtitle-track-change'
|
||||
| 'secondary-subtitle-track-change'
|
||||
| 'secondary-subtitle-delay-change'
|
||||
| 'subtitle-track-list-change'
|
||||
| 'subtitle-timing'
|
||||
| 'media-path-change'
|
||||
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
onSubtitleAssChange: (payload: { text: string }) => void;
|
||||
onSecondarySubtitleChange: (payload: { text: string }) => void;
|
||||
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
|
||||
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
|
||||
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
|
||||
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
|
||||
onMediaPathChange: (payload: { path: string | null }) => void;
|
||||
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
|
||||
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
|
||||
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
|
||||
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
|
||||
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
|
||||
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
|
||||
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
|
||||
mpvClient.on('media-path-change', deps.onMediaPathChange);
|
||||
|
||||
@@ -37,6 +37,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
|
||||
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
|
||||
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
|
||||
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
|
||||
onSecondarySubtitleDelayChange: (delay) =>
|
||||
calls.push(`secondary-subtitle-delay-change:${delay}`),
|
||||
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
|
||||
|
||||
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
|
||||
@@ -73,6 +76,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
handlers.get('connection-change')?.({ connected: true });
|
||||
handlers.get('subtitle-change')?.({ text: 'line' });
|
||||
handlers.get('subtitle-track-change')?.({ sid: 3 });
|
||||
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
|
||||
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
|
||||
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
|
||||
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
|
||||
handlers.get('media-path-change')?.({ path: '' });
|
||||
@@ -86,6 +91,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.equal(calls.includes('broadcast-sub:line'), true);
|
||||
assert.ok(calls.includes('subtitle-change:line'));
|
||||
assert.ok(calls.includes('subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-track-change'));
|
||||
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
|
||||
assert.ok(calls.includes('subtitle-track-list-change'));
|
||||
assert.ok(calls.includes('media-title:Episode 1'));
|
||||
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
|
||||
|
||||
@@ -54,6 +54,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
broadcastSubtitleAss: (text: string) => void;
|
||||
broadcastSecondarySubtitle: (text: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
@@ -189,6 +191,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
onSubtitleAssChange: handleMpvSubtitleAssChange,
|
||||
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
|
||||
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
|
||||
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
|
||||
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
|
||||
onSubtitleTiming: handleMpvSubtitleTiming,
|
||||
onMediaPathChange: handleMpvMediaPathChange,
|
||||
|
||||
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
|
||||
broadcastToOverlayWindows: (channel, payload) =>
|
||||
calls.push(`broadcast:${channel}:${String(payload)}`),
|
||||
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
|
||||
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
|
||||
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
|
||||
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
|
||||
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
|
||||
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
|
||||
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
deps.setCurrentSubAssText('ass');
|
||||
deps.broadcastSubtitleAss('ass');
|
||||
deps.broadcastSecondarySubtitle('sec');
|
||||
deps.onSecondarySubtitleTrackChange?.(4);
|
||||
deps.onSecondarySubtitleDelayChange?.(0.5);
|
||||
deps.updateCurrentMediaPath('/tmp/video');
|
||||
deps.restoreMpvSubVisibility();
|
||||
deps.resetSubtitleSidebarEmbeddedLayout();
|
||||
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
assert.ok(calls.includes('sync-overlay-mpv-sub'));
|
||||
assert.ok(calls.includes('anilist-post-watch'));
|
||||
assert.ok(calls.includes('timing:y:secondary'));
|
||||
assert.ok(calls.includes('secondary:sec'));
|
||||
assert.ok(calls.includes('secondary-track:4'));
|
||||
assert.ok(calls.includes('secondary-delay:0.5'));
|
||||
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
|
||||
assert.ok(calls.includes('ensure-immersion'));
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('autoplay:/tmp/video'));
|
||||
|
||||
@@ -53,11 +53,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
||||
onSecondarySubtitleChange?: (text: string) => void;
|
||||
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
||||
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
||||
onSubtitleChange: (text: string) => void;
|
||||
logSubtitleProcessingDebug?: (message: string) => void;
|
||||
onSubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
|
||||
onSecondarySubtitleDelayChange?: (delay: number) => void;
|
||||
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
||||
updateCurrentMediaPath: (path: string) => void;
|
||||
restoreMpvSubVisibility: () => void;
|
||||
@@ -173,6 +176,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.onSubtitleTrackChange?.(sid);
|
||||
},
|
||||
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
|
||||
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
|
||||
: undefined,
|
||||
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
|
||||
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
|
||||
: undefined,
|
||||
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
||||
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
||||
: undefined,
|
||||
@@ -182,8 +191,13 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
},
|
||||
broadcastSubtitleAss: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) =>
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
||||
broadcastSecondarySubtitle: (text: string) => {
|
||||
if (deps.onSecondarySubtitleChange) {
|
||||
deps.onSecondarySubtitleChange(text);
|
||||
return;
|
||||
}
|
||||
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
|
||||
},
|
||||
updateCurrentMediaPath: (path: string) => {
|
||||
immersionLineDedupGate.reset();
|
||||
deps.updateCurrentMediaPath(path);
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
|
||||
import {
|
||||
createSecondarySubtitleTrackController,
|
||||
findActiveSubtitleText,
|
||||
} from './secondary-subtitle-track';
|
||||
|
||||
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
||||
assert.equal(
|
||||
findActiveSubtitleText(
|
||||
[
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'Your' },
|
||||
{ startTime: 1, endTime: 3, text: 'mosaic' },
|
||||
],
|
||||
2,
|
||||
),
|
||||
'Your\nmosaic',
|
||||
);
|
||||
});
|
||||
|
||||
test('secondary track controller parses the selected ASS file before publishing', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
|
||||
const ass = `[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
|
||||
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async (input) => {
|
||||
resolverInputs.push(input);
|
||||
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => ass,
|
||||
parseSubtitleCues,
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
|
||||
|
||||
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
|
||||
assert.equal(currentText, 'Your\nmosaic');
|
||||
assert.deepEqual(broadcasts, ['Your\nmosaic']);
|
||||
});
|
||||
|
||||
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let time = 2.25;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0.5;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => time,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [
|
||||
{ startTime: 1, endTime: 2, text: 'first' },
|
||||
{ startTime: 2, endTime: 3, text: 'second' },
|
||||
],
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleDelayChange(0);
|
||||
time = 3.25;
|
||||
controller.handleTimePos(time);
|
||||
|
||||
assert.deepEqual(broadcasts, ['first', 'second', '']);
|
||||
});
|
||||
|
||||
test('secondary track controller clears old parsed text immediately on a track change', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
controller.handleTrackChange();
|
||||
controller.handleLiveText('new live text');
|
||||
|
||||
assert.equal(currentText, 'new live text');
|
||||
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
|
||||
});
|
||||
|
||||
test('secondary track controller falls back to live mpv text without a readable source', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let currentText = '';
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 'no';
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => null,
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => [],
|
||||
setCurrentSecondaryText: (text) => {
|
||||
currentText = text;
|
||||
},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
controller.handleLiveText('live fallback');
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(currentText, 'live fallback');
|
||||
assert.deepEqual(broadcasts, ['live fallback']);
|
||||
});
|
||||
|
||||
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
||||
let resolveCalls = 0;
|
||||
let parseCalls = 0;
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') {
|
||||
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
|
||||
}
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
resolveCalls += 1;
|
||||
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: () => {},
|
||||
});
|
||||
|
||||
await controller.refresh();
|
||||
await controller.refresh();
|
||||
|
||||
assert.equal(resolveCalls, 1);
|
||||
assert.equal(parseCalls, 1);
|
||||
});
|
||||
|
||||
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
|
||||
const broadcasts: string[] = [];
|
||||
let notifyResolveStarted: (() => void) | undefined;
|
||||
let releaseResolve: (() => void) | undefined;
|
||||
let cleanupCalls = 0;
|
||||
let parseCalls = 0;
|
||||
const resolveStarted = new Promise<void>((resolve) => {
|
||||
notifyResolveStarted = resolve;
|
||||
});
|
||||
const resolveGate = new Promise<void>((resolve) => {
|
||||
releaseResolve = resolve;
|
||||
});
|
||||
const controller = createSecondarySubtitleTrackController({
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async (name) => {
|
||||
if (name === 'secondary-sid') return 2;
|
||||
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
|
||||
if (name === 'path') return '/media/video.mkv';
|
||||
if (name === 'secondary-sub-delay') return 0;
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
getCurrentTimePos: () => 2,
|
||||
resolveSubtitleSource: async () => {
|
||||
notifyResolveStarted?.();
|
||||
await resolveGate;
|
||||
return {
|
||||
path: '/subs/secondary.ass',
|
||||
sourceKey: 'secondary',
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
loadSubtitleSourceText: async () => '',
|
||||
parseSubtitleCues: () => {
|
||||
parseCalls += 1;
|
||||
return [{ startTime: 1, endTime: 3, text: 'stale' }];
|
||||
},
|
||||
setCurrentSecondaryText: () => {},
|
||||
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||
});
|
||||
|
||||
const refresh = controller.refresh();
|
||||
await resolveStarted;
|
||||
controller.reset();
|
||||
releaseResolve?.();
|
||||
await refresh;
|
||||
|
||||
assert.deepEqual(broadcasts, ['']);
|
||||
assert.equal(parseCalls, 0);
|
||||
assert.equal(cleanupCalls, 1);
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { SubtitleCue } from '../../types/subtitle';
|
||||
|
||||
type SecondarySubtitleMpvClient = {
|
||||
connected?: boolean;
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ResolvedSubtitleSource = {
|
||||
path: string;
|
||||
sourceKey: string;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
type SecondarySubtitleSourceInput = {
|
||||
currentExternalFilenameRaw: unknown;
|
||||
currentTrackRaw: unknown;
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_REFRESH_DELAY_MS = 500;
|
||||
|
||||
function finiteNumber(value: unknown, fallback = 0): number {
|
||||
const number = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
function trackId(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
const number = typeof value === 'number' ? value : Number(value.trim());
|
||||
return Number.isInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
function buildSelectedTrackIdentity(
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
videoPath: string,
|
||||
): string | null {
|
||||
if (!Array.isArray(trackListRaw)) return null;
|
||||
const sid = trackId(sidRaw);
|
||||
if (sid === null) return null;
|
||||
|
||||
const selectedTrack = trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') return false;
|
||||
const track = entry as Record<string, unknown>;
|
||||
return track.type === 'sub' && trackId(track.id) === sid;
|
||||
}) as Record<string, unknown> | undefined;
|
||||
if (!selectedTrack) return null;
|
||||
|
||||
return JSON.stringify([
|
||||
videoPath,
|
||||
sid,
|
||||
selectedTrack.external === true,
|
||||
selectedTrack['external-filename'] ?? null,
|
||||
trackId(selectedTrack['ff-index']),
|
||||
]);
|
||||
}
|
||||
|
||||
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
|
||||
if (!Number.isFinite(timeSeconds)) return '';
|
||||
|
||||
const seen = new Set<string>();
|
||||
const activeText: string[] = [];
|
||||
for (const cue of cues) {
|
||||
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
|
||||
const text = cue.text.trim();
|
||||
if (!text || seen.has(text)) continue;
|
||||
seen.add(text);
|
||||
activeText.push(text);
|
||||
}
|
||||
return activeText.join('\n');
|
||||
}
|
||||
|
||||
export function createSecondarySubtitleTrackController(deps: {
|
||||
getMpvClient: () => SecondarySubtitleMpvClient | null;
|
||||
getCurrentTimePos: () => number;
|
||||
resolveSubtitleSource: (
|
||||
input: SecondarySubtitleSourceInput,
|
||||
) => Promise<ResolvedSubtitleSource | null>;
|
||||
loadSubtitleSourceText: (source: string) => Promise<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
setCurrentSecondaryText: (text: string) => void;
|
||||
broadcastSecondaryText: (text: string) => void;
|
||||
logDebug?: (message: string) => void;
|
||||
logWarn?: (message: string, error: unknown) => void;
|
||||
}) {
|
||||
let parsedCues: SubtitleCue[] | null = null;
|
||||
let parsedSourceKey: string | null = null;
|
||||
let parsedTrackIdentity: string | null = null;
|
||||
let secondaryDelaySeconds = 0;
|
||||
let lastLiveText = '';
|
||||
let lastBroadcastText: string | null = null;
|
||||
let refreshGeneration = 0;
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const publish = (text: string): void => {
|
||||
deps.setCurrentSecondaryText(text);
|
||||
if (text === lastBroadcastText) return;
|
||||
lastBroadcastText = text;
|
||||
deps.broadcastSecondaryText(text);
|
||||
};
|
||||
|
||||
const resolveAtTime = (timeSeconds: number): string => {
|
||||
if (!parsedCues) return lastLiveText;
|
||||
return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds);
|
||||
};
|
||||
|
||||
const useLiveFallback = (): void => {
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
publish(lastLiveText);
|
||||
};
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
const generation = ++refreshGeneration;
|
||||
const client = deps.getMpvClient();
|
||||
if (!client?.connected) {
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedSource: ResolvedSubtitleSource | null = null;
|
||||
try {
|
||||
const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([
|
||||
client.requestProperty('secondary-sid').catch(() => null),
|
||||
client.requestProperty('track-list').catch(() => null),
|
||||
client.requestProperty('path').catch(() => null),
|
||||
client.requestProperty('secondary-sub-delay').catch(() => 0),
|
||||
]);
|
||||
if (generation !== refreshGeneration) return;
|
||||
|
||||
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
|
||||
if (!videoPath || secondarySid === null || secondarySid === 'no') {
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
secondaryDelaySeconds = finiteNumber(secondaryDelayRaw);
|
||||
const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath);
|
||||
if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
return;
|
||||
}
|
||||
|
||||
resolvedSource = await deps.resolveSubtitleSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: trackList,
|
||||
sidRaw: secondarySid,
|
||||
videoPath,
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
if (generation !== refreshGeneration) return;
|
||||
if (!resolvedSource) {
|
||||
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
|
||||
parsedTrackIdentity = selectedTrackIdentity;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await deps.loadSubtitleSourceText(resolvedSource.path);
|
||||
const cues = deps.parseSubtitleCues(content, resolvedSource.path);
|
||||
if (generation !== refreshGeneration) return;
|
||||
if (cues.length === 0) {
|
||||
deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues');
|
||||
useLiveFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
parsedCues = cues;
|
||||
parsedSourceKey = resolvedSource.sourceKey;
|
||||
parsedTrackIdentity = selectedTrackIdentity;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
} catch (error) {
|
||||
if (generation !== refreshGeneration) return;
|
||||
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
|
||||
useLiveFallback();
|
||||
} finally {
|
||||
await resolvedSource?.cleanup?.().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
void refresh();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const clearSelectedTrack = (): void => {
|
||||
refreshGeneration += 1;
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
parsedCues = null;
|
||||
parsedSourceKey = null;
|
||||
parsedTrackIdentity = null;
|
||||
secondaryDelaySeconds = 0;
|
||||
lastLiveText = '';
|
||||
publish('');
|
||||
};
|
||||
|
||||
return {
|
||||
refresh,
|
||||
scheduleRefresh,
|
||||
handleLiveText(text: string): void {
|
||||
lastLiveText = text;
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
},
|
||||
handleTimePos(timeSeconds: number): void {
|
||||
if (!parsedCues) return;
|
||||
publish(resolveAtTime(timeSeconds));
|
||||
},
|
||||
handleTrackChange(): void {
|
||||
clearSelectedTrack();
|
||||
},
|
||||
handleDelayChange(delaySeconds: number): void {
|
||||
secondaryDelaySeconds = finiteNumber(delaySeconds);
|
||||
if (parsedCues) {
|
||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||
}
|
||||
},
|
||||
reset: clearSelectedTrack,
|
||||
};
|
||||
}
|
||||
@@ -248,3 +248,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec
|
||||
assert.equal(debugs.length, 1);
|
||||
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
|
||||
});
|
||||
|
||||
test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => {
|
||||
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
|
||||
getFfmpegPath: () => 'ffmpeg',
|
||||
extractInternalSubtitleTrack: async () => {
|
||||
throw new Error('should not extract the primary track');
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveSource({
|
||||
currentExternalFilenameRaw: null,
|
||||
currentTrackRaw: null,
|
||||
trackListRaw: [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 1,
|
||||
selected: true,
|
||||
external: true,
|
||||
'external-filename': '/subs/primary.ass',
|
||||
},
|
||||
],
|
||||
sidRaw: null,
|
||||
videoPath: '/media/video.mkv',
|
||||
allowSelectedFallback: false,
|
||||
});
|
||||
|
||||
assert.equal(resolved, null);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ function getActiveSubtitleTrack(
|
||||
currentTrackRaw: unknown,
|
||||
trackListRaw: unknown,
|
||||
sidRaw: unknown,
|
||||
allowSelectedFallback: boolean,
|
||||
): MpvSubtitleTrackLike | null {
|
||||
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
|
||||
const track = currentTrackRaw as MpvSubtitleTrackLike;
|
||||
@@ -68,6 +69,10 @@ function getActiveSubtitleTrack(
|
||||
return bySid;
|
||||
}
|
||||
|
||||
if (!allowSelectedFallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
(trackListRaw.find((entry: unknown) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
@@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
trackListRaw: unknown;
|
||||
sidRaw: unknown;
|
||||
videoPath: string;
|
||||
allowSelectedFallback?: boolean;
|
||||
}): Promise<ActiveSubtitleSidebarSource | null> => {
|
||||
const currentExternalFilename =
|
||||
typeof input.currentExternalFilenameRaw === 'string'
|
||||
@@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
|
||||
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
|
||||
}
|
||||
|
||||
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
|
||||
const track = getActiveSubtitleTrack(
|
||||
input.currentTrackRaw,
|
||||
input.trackListRaw,
|
||||
input.sidRaw,
|
||||
input.allowSelectedFallback !== false,
|
||||
);
|
||||
if (!track) {
|
||||
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
|
||||
return null;
|
||||
|
||||
@@ -1434,6 +1434,18 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
|
||||
const dialogue = ['Wait', 'Wait', 'Wait'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
|
||||
const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
|
||||
|
||||
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
|
||||
});
|
||||
|
||||
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
|
||||
const dialogue = ' I never expected this. \\N\\N But here we are. ';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user