import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import { SecondarySubMode, SubtitlePosition } from "../../types"; import { createLogger } from "../../logger"; const logger = createLogger("main:subtitle-position"); export interface CycleSecondarySubModeDeps { getSecondarySubMode: () => SecondarySubMode; setSecondarySubMode: (mode: SecondarySubMode) => void; getLastSecondarySubToggleAtMs: () => number; setLastSecondarySubToggleAtMs: (timestampMs: number) => void; broadcastSecondarySubMode: (mode: SecondarySubMode) => void; showMpvOsd: (text: string) => void; now?: () => number; } const SECONDARY_SUB_CYCLE: SecondarySubMode[] = ["hidden", "visible", "hover"]; const SECONDARY_SUB_TOGGLE_DEBOUNCE_MS = 120; export function cycleSecondarySubMode( deps: CycleSecondarySubModeDeps, ): void { const now = deps.now ? deps.now() : Date.now(); if (now - deps.getLastSecondarySubToggleAtMs() < SECONDARY_SUB_TOGGLE_DEBOUNCE_MS) { return; } deps.setLastSecondarySubToggleAtMs(now); const currentMode = deps.getSecondarySubMode(); const currentIndex = SECONDARY_SUB_CYCLE.indexOf(currentMode); const nextMode = SECONDARY_SUB_CYCLE[(currentIndex + 1) % SECONDARY_SUB_CYCLE.length]; deps.setSecondarySubMode(nextMode); deps.broadcastSecondarySubMode(nextMode); deps.showMpvOsd(`Secondary subtitle: ${nextMode}`); } function getSubtitlePositionFilePath( mediaPath: string, subtitlePositionsDir: string, ): string { const key = normalizeMediaPathForSubtitlePosition(mediaPath); const hash = crypto.createHash("sha256").update(key).digest("hex"); return path.join(subtitlePositionsDir, `${hash}.json`); } function normalizeMediaPathForSubtitlePosition(mediaPath: string): string { const trimmed = mediaPath.trim(); if (!trimmed) return trimmed; if ( /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) || /^ytsearch:/.test(trimmed) ) { return trimmed; } const resolved = path.resolve(trimmed); let normalized = resolved; try { if (fs.existsSync(resolved)) { normalized = fs.realpathSync(resolved); } } catch { normalized = resolved; } if (process.platform === "win32") { normalized = normalized.toLowerCase(); } return normalized; } function persistSubtitlePosition( position: SubtitlePosition, currentMediaPath: string | null, subtitlePositionsDir: string, ): void { if (!currentMediaPath) return; if (!fs.existsSync(subtitlePositionsDir)) { fs.mkdirSync(subtitlePositionsDir, { recursive: true }); } const positionPath = getSubtitlePositionFilePath( currentMediaPath, subtitlePositionsDir, ); fs.writeFileSync(positionPath, JSON.stringify(position, null, 2)); } export function loadSubtitlePosition(options: { currentMediaPath: string | null; fallbackPosition: SubtitlePosition; } & { subtitlePositionsDir: string }): SubtitlePosition | null { if (!options.currentMediaPath) { return options.fallbackPosition; } try { const positionPath = getSubtitlePositionFilePath( options.currentMediaPath, options.subtitlePositionsDir, ); if (!fs.existsSync(positionPath)) { return options.fallbackPosition; } const data = fs.readFileSync(positionPath, "utf-8"); const parsed = JSON.parse(data) as Partial; if ( parsed && typeof parsed.yPercent === "number" && Number.isFinite(parsed.yPercent) ) { const position: SubtitlePosition = { yPercent: parsed.yPercent }; if ( typeof parsed.invisibleOffsetXPx === "number" && Number.isFinite(parsed.invisibleOffsetXPx) ) { position.invisibleOffsetXPx = parsed.invisibleOffsetXPx; } if ( typeof parsed.invisibleOffsetYPx === "number" && Number.isFinite(parsed.invisibleOffsetYPx) ) { position.invisibleOffsetYPx = parsed.invisibleOffsetYPx; } return position; } return options.fallbackPosition; } catch (err) { logger.error("Failed to load subtitle position:", (err as Error).message); return options.fallbackPosition; } } export function saveSubtitlePosition(options: { position: SubtitlePosition; currentMediaPath: string | null; subtitlePositionsDir: string; onQueuePending: (position: SubtitlePosition) => void; onPersisted: () => void; }): void { if (!options.currentMediaPath) { options.onQueuePending(options.position); logger.warn("Queued subtitle position save - no media path yet"); return; } try { persistSubtitlePosition( options.position, options.currentMediaPath, options.subtitlePositionsDir, ); options.onPersisted(); } catch (err) { logger.error("Failed to save subtitle position:", (err as Error).message); } } export function updateCurrentMediaPath(options: { mediaPath: unknown; currentMediaPath: string | null; pendingSubtitlePosition: SubtitlePosition | null; subtitlePositionsDir: string; loadSubtitlePosition: () => SubtitlePosition | null; setCurrentMediaPath: (mediaPath: string | null) => void; clearPendingSubtitlePosition: () => void; setSubtitlePosition: (position: SubtitlePosition | null) => void; broadcastSubtitlePosition: (position: SubtitlePosition | null) => void; }): void { const nextPath = typeof options.mediaPath === "string" && options.mediaPath.trim().length > 0 ? options.mediaPath : null; if (nextPath === options.currentMediaPath) return; options.setCurrentMediaPath(nextPath); if (nextPath && options.pendingSubtitlePosition) { try { persistSubtitlePosition( options.pendingSubtitlePosition, nextPath, options.subtitlePositionsDir, ); options.setSubtitlePosition(options.pendingSubtitlePosition); options.clearPendingSubtitlePosition(); } catch (err) { logger.error( "Failed to persist queued subtitle position:", (err as Error).message, ); } } const position = options.loadSubtitlePosition(); options.broadcastSubtitlePosition(position); }