Files
SubMiner/src/core/services/subtitle-position.ts
sudacode a359e91b14 refactor(core): normalize core service naming
Standardize core service module and export names to reduce naming ambiguity and make imports predictable across runtime, tests, scripts, and docs.
2026-02-17 04:03:37 -08:00

201 lines
6.0 KiB
TypeScript

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<SubtitlePosition>;
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);
}