mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-27 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
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
import type { MergedToken, SubtitleData } from '../../types';
|
|
|
|
type AnilistPostWatchRunOptions = {
|
|
watchedSeconds?: number;
|
|
};
|
|
|
|
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|
appState: {
|
|
initialArgs?: {
|
|
jellyfinPlay?: unknown;
|
|
managedPlayback?: unknown;
|
|
youtubePlay?: unknown;
|
|
} | null;
|
|
overlayRuntimeInitialized: boolean;
|
|
mpvClient: {
|
|
connected?: boolean;
|
|
currentSecondarySubText?: string;
|
|
currentTimePos?: number;
|
|
requestProperty?: (name: string) => Promise<unknown>;
|
|
} | null;
|
|
immersionTracker: {
|
|
recordSubtitleLine?: (
|
|
text: string,
|
|
start: number,
|
|
end: number,
|
|
tokens?: MergedToken[] | null,
|
|
secondaryText?: string | null,
|
|
) => void;
|
|
handleMediaTitleUpdate?: (title: string) => void;
|
|
recordPlaybackPosition?: (time: number) => void;
|
|
recordMediaDuration?: (durationSec: number) => void;
|
|
recordPauseState?: (paused: boolean) => void;
|
|
} | null;
|
|
subtitleTimingTracker: {
|
|
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
|
|
} | null;
|
|
currentMediaPath?: string | null;
|
|
currentSubText: string;
|
|
currentSubAssText: string;
|
|
currentSubtitleData?: SubtitleData | null;
|
|
playbackPaused: boolean | null;
|
|
previousSecondarySubVisibility: boolean | null;
|
|
};
|
|
getQuitOnDisconnectArmed: () => boolean;
|
|
scheduleQuitCheck: (callback: () => void) => void;
|
|
quitApp: () => void;
|
|
reportJellyfinRemoteStopped: () => void;
|
|
syncOverlayMpvSubtitleSuppression: () => void;
|
|
onMpvConnected?: () => void;
|
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
|
recordAnilistMediaDuration?: (durationSec: number) => void;
|
|
logSubtitleTimingError: (message: string, error: unknown) => void;
|
|
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
|
|
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
|
|
emitImmediateSubtitle?: (payload: SubtitleData) => void;
|
|
onSubtitleChange: (text: string) => void;
|
|
onSubtitleTrackChange?: (sid: number | null) => void;
|
|
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
|
|
updateCurrentMediaPath: (path: string) => void;
|
|
restoreMpvSubVisibility: () => void;
|
|
resetSubtitleSidebarEmbeddedLayout?: () => void;
|
|
getCurrentAnilistMediaKey: () => string | null;
|
|
resetAnilistMediaTracking: (mediaKey: string | null) => void;
|
|
maybeProbeAnilistDuration: (mediaKey: string) => void;
|
|
ensureAnilistMediaGuess: (mediaKey: string) => void;
|
|
syncImmersionMediaState: () => void;
|
|
signalAutoplayReadyIfWarm?: (path: string) => void;
|
|
markJellyfinRemotePlaybackLoaded?: (path: string) => void;
|
|
scheduleCharacterDictionarySync?: () => void;
|
|
updateCurrentMediaTitle: (title: string) => void;
|
|
resetAnilistMediaGuessState: () => void;
|
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) => void;
|
|
onTimePosUpdate?: (time: number) => void;
|
|
onFullscreenChange?: (fullscreen: boolean) => void;
|
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => void;
|
|
refreshDiscordPresence: () => void;
|
|
ensureImmersionTrackerInitialized: () => void;
|
|
tokenizeSubtitleForImmersion?: (text: string) => Promise<SubtitleData | null>;
|
|
}) {
|
|
const writePlaybackPositionFromMpv = (timeSec: unknown): void => {
|
|
const normalizedTimeSec = Number(timeSec);
|
|
if (!Number.isFinite(normalizedTimeSec)) {
|
|
return;
|
|
}
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPlaybackPosition?.(normalizedTimeSec);
|
|
};
|
|
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
|
|
Boolean(
|
|
deps.appState.initialArgs?.managedPlayback ||
|
|
deps.appState.initialArgs?.jellyfinPlay ||
|
|
deps.appState.initialArgs?.youtubePlay,
|
|
);
|
|
|
|
return () => ({
|
|
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
|
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
|
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
|
|
hasInitialPlaybackQuitOnDisconnectArg,
|
|
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
|
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
|
|
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
|
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
|
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
|
quitApp: () => deps.quitApp(),
|
|
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
const tracker = deps.appState.immersionTracker;
|
|
if (!tracker?.recordSubtitleLine) {
|
|
return;
|
|
}
|
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
|
|
const cachedTokens =
|
|
deps.appState.currentSubtitleData?.text === text
|
|
? deps.appState.currentSubtitleData.tokens
|
|
: null;
|
|
if (cachedTokens) {
|
|
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
|
|
return;
|
|
}
|
|
if (!deps.tokenizeSubtitleForImmersion) {
|
|
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
|
|
return;
|
|
}
|
|
void deps
|
|
.tokenizeSubtitleForImmersion(text)
|
|
.then((payload) => {
|
|
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
|
|
})
|
|
.catch(() => {
|
|
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
|
|
});
|
|
},
|
|
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
|
recordSubtitleTiming: (text: string, start: number, end: number) =>
|
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
|
text,
|
|
start,
|
|
end,
|
|
deps.appState.mpvClient?.currentSecondarySubText || undefined,
|
|
),
|
|
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
|
|
deps.maybeRunAnilistPostWatchUpdate(options),
|
|
logSubtitleTimingError: (message: string, error: unknown) =>
|
|
deps.logSubtitleTimingError(message, error),
|
|
setCurrentSubText: (text: string) => {
|
|
deps.appState.currentSubText = text;
|
|
},
|
|
getImmediateSubtitlePayload: deps.getImmediateSubtitlePayload
|
|
? (text: string) => deps.getImmediateSubtitlePayload!(text)
|
|
: undefined,
|
|
emitImmediateSubtitle: deps.emitImmediateSubtitle
|
|
? (payload: SubtitleData) => deps.emitImmediateSubtitle!(payload)
|
|
: undefined,
|
|
broadcastSubtitle: (payload: SubtitleData) =>
|
|
deps.broadcastToOverlayWindows('subtitle:set', payload),
|
|
onSubtitleChange: (text: string) => deps.onSubtitleChange(text),
|
|
onSubtitleTrackChange: deps.onSubtitleTrackChange
|
|
? (sid: number | null) => deps.onSubtitleTrackChange!(sid)
|
|
: undefined,
|
|
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
|
|
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
|
|
: undefined,
|
|
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
|
setCurrentSubAssText: (text: string) => {
|
|
deps.appState.currentSubAssText = text;
|
|
},
|
|
broadcastSubtitleAss: (text: string) =>
|
|
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
|
|
broadcastSecondarySubtitle: (text: string) =>
|
|
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
|
|
updateCurrentMediaPath: (path: string) => deps.updateCurrentMediaPath(path),
|
|
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
|
|
resetSubtitleSidebarEmbeddedLayout: () => deps.resetSubtitleSidebarEmbeddedLayout?.(),
|
|
getCurrentAnilistMediaKey: () => deps.getCurrentAnilistMediaKey(),
|
|
resetAnilistMediaTracking: (mediaKey: string | null) =>
|
|
deps.resetAnilistMediaTracking(mediaKey),
|
|
maybeProbeAnilistDuration: (mediaKey: string) => deps.maybeProbeAnilistDuration(mediaKey),
|
|
ensureAnilistMediaGuess: (mediaKey: string) => deps.ensureAnilistMediaGuess(mediaKey),
|
|
syncImmersionMediaState: () => deps.syncImmersionMediaState(),
|
|
signalAutoplayReadyIfWarm: (path: string) => deps.signalAutoplayReadyIfWarm?.(path),
|
|
markJellyfinRemotePlaybackLoaded: (path: string) =>
|
|
deps.markJellyfinRemotePlaybackLoaded?.(path),
|
|
scheduleCharacterDictionarySync: () => deps.scheduleCharacterDictionarySync?.(),
|
|
updateCurrentMediaTitle: (title: string) => deps.updateCurrentMediaTitle(title),
|
|
resetAnilistMediaGuessState: () => deps.resetAnilistMediaGuessState(),
|
|
notifyImmersionTitleUpdate: (title: string) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.handleMediaTitleUpdate?.(title);
|
|
},
|
|
recordPlaybackPosition: (time: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPlaybackPosition?.(time);
|
|
},
|
|
recordMediaDuration: (durationSec: number) => {
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordMediaDuration?.(durationSec);
|
|
deps.recordAnilistMediaDuration?.(durationSec);
|
|
},
|
|
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
|
|
deps.reportJellyfinRemoteProgress(forceImmediate),
|
|
onTimePosUpdate: deps.onTimePosUpdate
|
|
? (time: number) => deps.onTimePosUpdate!(time)
|
|
: undefined,
|
|
onFullscreenChange: deps.onFullscreenChange
|
|
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
|
|
: undefined,
|
|
recordPauseState: (paused: boolean) => {
|
|
deps.appState.playbackPaused = paused;
|
|
deps.ensureImmersionTrackerInitialized();
|
|
deps.appState.immersionTracker?.recordPauseState?.(paused);
|
|
},
|
|
flushPlaybackPositionOnMediaPathClear: (mediaPath: string) => {
|
|
const mpvClient = deps.appState.mpvClient;
|
|
const currentKnownTime = Number(mpvClient?.currentTimePos);
|
|
writePlaybackPositionFromMpv(currentKnownTime);
|
|
if (!mpvClient?.requestProperty) {
|
|
return;
|
|
}
|
|
void mpvClient
|
|
.requestProperty('time-pos')
|
|
.then((timePos) => {
|
|
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
|
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
|
return;
|
|
}
|
|
const resolvedTime = Number(timePos);
|
|
if (
|
|
Number.isFinite(currentKnownTime) &&
|
|
Number.isFinite(resolvedTime) &&
|
|
currentKnownTime === resolvedTime
|
|
) {
|
|
return;
|
|
}
|
|
writePlaybackPositionFromMpv(resolvedTime);
|
|
})
|
|
.catch(() => {
|
|
// mpv can disconnect while clearing media; keep the last cached position.
|
|
});
|
|
},
|
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
|
deps.updateSubtitleRenderMetrics(patch),
|
|
setPreviousSecondarySubVisibility: (visible: boolean) => {
|
|
deps.appState.previousSecondarySubVisibility = visible;
|
|
},
|
|
});
|
|
}
|