mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
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.
This commit is contained in:
200
src/core/services/subtitle-position.ts
Normal file
200
src/core/services/subtitle-position.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user