mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00:55:16 -07:00
b1bdeabca8
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb - Show visible overlay automatically during Jellyfin playback so subtitleStyle applies - Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus - Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles - Mark ffsubsync unavailable in subsync modal for remote media paths - Drain queued second-instance commands even when onReady throws * fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause - Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open * fix(jellyfin): fix discovery loop, device identity, tray state, and Disc - Derive device identity from OS hostname; remove legacy configurable client/device fields - Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores - Restart stale tray discovery sessions without re-login when server drops SubMiner cast target - Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes - Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads - Fix picker library discovery when log level is above info - Fix config.example.jsonc trailing commas and array formatting * docs(release): trim and consolidate prerelease notes for 0.15.0 - Remove breaking changes section and several redundant bullet points - Consolidate per-platform updater notes into a single entry - Normalize em-dash separators to hyphens in section headers * fix(config): remove trailing commas from config.example.jsonc - Strip trailing commas throughout both config.example.jsonc copies - Reformat inline arrays to multi-line for JSON strictness - Update Jellyfin subtitle preload and playback launch tests and impl * fix(tokenizer): preserve known-word highlight when POS filters suppress - Known-word cache matches now set isKnown=true even for tokens excluded by POS filters - POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate - Jellyfin subtitle preload continues after cleanup failures instead of aborting - Update config docs and option description to document the known-word bypass behavior * fix(jellyfin): send explicit hide/show overlay instead of toggle - Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known - Prevent paused Jellyfin playback from resuming on overlay hide - Fix subtitle cache cleanup to only remove dirs after successful cleanup * fix(jellyfin): fix remote progress sync, seek reporting, and startup sto - arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events - force immediate progress report on seek-like position jumps at the mpv time-pos level - send positionTicks and failed=false in reportStopped payload - remove EventName from HTTP timeline payloads (websocket-only field) - add startup grace window to drop stop events before media finishes loading * fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi - Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift - Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress - Preserve manual hide across Jellyfin path-changing redirects even when media-title drops - Rearm managed subtitle defaults on path-changing redirects - Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC - Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus - Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary - Add stats window layer management so delete/update dialogs appear above stats window - Fix Jellyfin remote progress sync during Linux websocket reconnect windows * Fix CodeRabbit review feedback * fix(jellyfin): subtitle timing, resume progress, and overlay sync - Add per-stream subtitle delay persistence and auto timeline-offset correction - Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload - Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports - Keep Play vs Resume distinct to avoid early seek race on normal play - Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress - Deduplicate show/hide overlay commands using recorded visibility state - Rewrite docs-site Jellyfin page around cast-to-device UX * test: update lifecycle cleanup assertion * fix: clear aborted playback state, fix overlay passthrough, and guard du - Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item - Record visible overlay action only after command succeeds, not before - Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering) - Defer activeParsedSubtitleMediaPath assignment until after prefetch completes - Move autoplay gate release into the hide branch of toggleVisibleOverlay - Clear active Jellyfin playback when stopping media that never loaded - Reset managed subtitle delay and delay key when no external tracks are available - Await async removeDir in subtitle cache cleanup - Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs - Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
type SubtitleDelayShiftDirection = 'next' | 'previous';
|
|
|
|
type MpvClientLike = {
|
|
connected: boolean;
|
|
requestProperty: (name: string) => Promise<unknown>;
|
|
};
|
|
|
|
type MpvSubtitleTrackLike = {
|
|
type?: unknown;
|
|
id?: unknown;
|
|
external?: unknown;
|
|
'external-filename'?: unknown;
|
|
};
|
|
|
|
type SubtitleCueCacheEntry = {
|
|
starts: number[];
|
|
};
|
|
|
|
type SubtitleDelayShiftDeps = {
|
|
getMpvClient: () => MpvClientLike | null;
|
|
loadSubtitleSourceText: (source: string) => Promise<string>;
|
|
sendMpvCommand: (command: Array<string | number>) => void;
|
|
showMpvOsd: (text: string) => void;
|
|
onSubtitleDelayShifted?: (delaySeconds: number) => void;
|
|
};
|
|
|
|
function asTrackId(value: unknown): number | null {
|
|
if (typeof value === 'number' && Number.isInteger(value)) return value;
|
|
if (typeof value === 'string') {
|
|
const parsed = Number(value.trim());
|
|
if (Number.isInteger(parsed)) return parsed;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function parseSrtOrVttStartTimes(content: string): number[] {
|
|
const starts: number[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(
|
|
/^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/,
|
|
);
|
|
if (!match) continue;
|
|
const hours = Number(match[1] || 0);
|
|
const minutes = Number(match[2] || 0);
|
|
const seconds = Number(match[3] || 0);
|
|
const millis = Number(String(match[4]).padEnd(3, '0'));
|
|
starts.push(hours * 3600 + minutes * 60 + seconds + millis / 1000);
|
|
}
|
|
return starts;
|
|
}
|
|
|
|
function parseAssStartTimes(content: string): number[] {
|
|
const starts: number[] = [];
|
|
const lines = content.split(/\r?\n/);
|
|
for (const line of lines) {
|
|
const match = line.match(
|
|
/^Dialogue:[^,]*,(\d+:\d{2}:\d{2}\.\d{1,2}),\d+:\d{2}:\d{2}\.\d{1,2},/,
|
|
);
|
|
if (!match) continue;
|
|
const [hoursRaw, minutesRaw, secondsRaw] = match[1]!.split(':');
|
|
if (secondsRaw === undefined) continue;
|
|
const [wholeSecondsRaw, fractionRaw = '0'] = secondsRaw.split('.');
|
|
const hours = Number(hoursRaw);
|
|
const minutes = Number(minutesRaw);
|
|
const wholeSeconds = Number(wholeSecondsRaw);
|
|
const fraction = Number(`0.${fractionRaw}`);
|
|
starts.push(hours * 3600 + minutes * 60 + wholeSeconds + fraction);
|
|
}
|
|
return starts;
|
|
}
|
|
|
|
function normalizeCueStarts(starts: number[]): number[] {
|
|
const sorted = starts
|
|
.filter((value) => Number.isFinite(value) && value >= 0)
|
|
.sort((a, b) => a - b);
|
|
if (sorted.length === 0) return [];
|
|
|
|
const deduped: number[] = [sorted[0]!];
|
|
for (let i = 1; i < sorted.length; i += 1) {
|
|
const current = sorted[i]!;
|
|
const previous = deduped[deduped.length - 1]!;
|
|
if (Math.abs(current - previous) > 0.0005) {
|
|
deduped.push(current);
|
|
}
|
|
}
|
|
return deduped;
|
|
}
|
|
|
|
function parseCueStarts(content: string, source: string): number[] {
|
|
const normalizedSource = source.toLowerCase().split('?')[0] || '';
|
|
const parseSrtLike = () => parseSrtOrVttStartTimes(content);
|
|
const parseAssLike = () => parseAssStartTimes(content);
|
|
|
|
let starts: number[] = [];
|
|
if (normalizedSource.endsWith('.ass') || normalizedSource.endsWith('.ssa')) {
|
|
starts = parseAssLike();
|
|
if (starts.length === 0) {
|
|
starts = parseSrtLike();
|
|
}
|
|
} else {
|
|
starts = parseSrtLike();
|
|
if (starts.length === 0) {
|
|
starts = parseAssLike();
|
|
}
|
|
}
|
|
|
|
const normalized = normalizeCueStarts(starts);
|
|
if (normalized.length === 0) {
|
|
throw new Error('Could not parse subtitle cue timings from active subtitle source.');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function getActiveSubtitleSource(trackListRaw: unknown, sidRaw: unknown): string {
|
|
const sid = asTrackId(sidRaw);
|
|
if (sid === null) {
|
|
throw new Error('No active subtitle track selected.');
|
|
}
|
|
if (!Array.isArray(trackListRaw)) {
|
|
throw new Error('Could not inspect subtitle track list.');
|
|
}
|
|
|
|
const activeTrack = trackListRaw.find((entry): entry is MpvSubtitleTrackLike => {
|
|
if (!entry || typeof entry !== 'object') return false;
|
|
const track = entry as MpvSubtitleTrackLike;
|
|
return track.type === 'sub' && asTrackId(track.id) === sid;
|
|
});
|
|
|
|
if (!activeTrack) {
|
|
throw new Error('No active subtitle track found in mpv track list.');
|
|
}
|
|
if (activeTrack.external !== true) {
|
|
throw new Error('Active subtitle track is internal and has no direct subtitle file source.');
|
|
}
|
|
|
|
const source =
|
|
typeof activeTrack['external-filename'] === 'string'
|
|
? activeTrack['external-filename'].trim()
|
|
: '';
|
|
if (!source) {
|
|
throw new Error('Active subtitle track has no external subtitle source path.');
|
|
}
|
|
return source;
|
|
}
|
|
|
|
function findAdjacentCueStart(
|
|
starts: number[],
|
|
currentStart: number,
|
|
direction: SubtitleDelayShiftDirection,
|
|
): number {
|
|
const epsilon = 0.0005;
|
|
if (direction === 'next') {
|
|
const target = starts.find((value) => value > currentStart + epsilon);
|
|
if (target === undefined) {
|
|
throw new Error('No next subtitle cue found for active subtitle source.');
|
|
}
|
|
return target;
|
|
}
|
|
|
|
for (let index = starts.length - 1; index >= 0; index -= 1) {
|
|
const value = starts[index]!;
|
|
if (value < currentStart - epsilon) {
|
|
return value;
|
|
}
|
|
}
|
|
throw new Error('No previous subtitle cue found for active subtitle source.');
|
|
}
|
|
|
|
export function createShiftSubtitleDelayToAdjacentCueHandler(deps: SubtitleDelayShiftDeps) {
|
|
const cueCache = new Map<string, SubtitleCueCacheEntry>();
|
|
|
|
return async (direction: SubtitleDelayShiftDirection): Promise<void> => {
|
|
const client = deps.getMpvClient();
|
|
if (!client || !client.connected) {
|
|
throw new Error('MPV not connected.');
|
|
}
|
|
|
|
const [trackListRaw, sidRaw, subStartRaw, subDelayRaw] = await Promise.all([
|
|
client.requestProperty('track-list'),
|
|
client.requestProperty('sid'),
|
|
client.requestProperty('sub-start'),
|
|
client.requestProperty('sub-delay'),
|
|
]);
|
|
|
|
const currentStart =
|
|
typeof subStartRaw === 'number' && Number.isFinite(subStartRaw) ? subStartRaw : null;
|
|
if (currentStart === null) {
|
|
throw new Error('Current subtitle start time is unavailable.');
|
|
}
|
|
|
|
const source = getActiveSubtitleSource(trackListRaw, sidRaw);
|
|
let cueStarts = cueCache.get(source)?.starts;
|
|
if (!cueStarts) {
|
|
const content = await deps.loadSubtitleSourceText(source);
|
|
cueStarts = parseCueStarts(content, source);
|
|
cueCache.set(source, { starts: cueStarts });
|
|
}
|
|
|
|
const targetStart = findAdjacentCueStart(cueStarts, currentStart, direction);
|
|
const delta = targetStart - currentStart;
|
|
deps.sendMpvCommand(['add', 'sub-delay', delta]);
|
|
const currentDelay =
|
|
typeof subDelayRaw === 'number' && Number.isFinite(subDelayRaw) ? subDelayRaw : 0;
|
|
try {
|
|
deps.onSubtitleDelayShifted?.(currentDelay + delta);
|
|
} catch {}
|
|
deps.showMpvOsd('Subtitle delay: ${sub-delay}');
|
|
};
|
|
}
|