mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
import type { ResolvedConfig } from '../../types';
|
|
|
|
export interface DiscordPresenceSnapshot {
|
|
mediaTitle: string | null;
|
|
mediaPath: string | null;
|
|
subtitleText: string;
|
|
currentTimeSec?: number | null;
|
|
mediaDurationSec?: number | null;
|
|
paused: boolean | null;
|
|
connected: boolean;
|
|
sessionStartedAtMs: number;
|
|
}
|
|
|
|
type DiscordPresenceConfig = ResolvedConfig['discordPresence'];
|
|
|
|
export interface DiscordActivityPayload {
|
|
details: string;
|
|
state: string;
|
|
startTimestamp: number;
|
|
largeImageKey?: string;
|
|
largeImageText?: string;
|
|
smallImageKey?: string;
|
|
smallImageText?: string;
|
|
buttons?: Array<{ label: string; url: string }>;
|
|
}
|
|
|
|
type DiscordClient = {
|
|
login: () => Promise<void>;
|
|
setActivity: (activity: DiscordActivityPayload) => Promise<void>;
|
|
clearActivity: () => Promise<void>;
|
|
destroy: () => void;
|
|
};
|
|
|
|
type TimeoutLike = ReturnType<typeof setTimeout>;
|
|
|
|
const DISCORD_PRESENCE_STYLE = {
|
|
fallbackDetails: 'Mining and crafting (Anki cards)',
|
|
largeImageKey: 'subminer-logo',
|
|
largeImageText: 'SubMiner',
|
|
smallImageKey: 'study',
|
|
smallImageText: 'Sentence Mining',
|
|
buttonLabel: '',
|
|
buttonUrl: '',
|
|
} as const;
|
|
|
|
function trimField(value: string, maxLength = 128): string {
|
|
if (value.length <= maxLength) return value;
|
|
return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
|
|
}
|
|
|
|
function sanitizeText(value: string | null | undefined, fallback: string): string {
|
|
const text = value?.trim();
|
|
if (!text) return fallback;
|
|
return text;
|
|
}
|
|
|
|
function basename(filePath: string | null): string {
|
|
if (!filePath) return '';
|
|
const parts = filePath.split(/[\\/]/);
|
|
return parts[parts.length - 1] ?? '';
|
|
}
|
|
|
|
function buildStatus(snapshot: DiscordPresenceSnapshot): string {
|
|
if (!snapshot.connected || !snapshot.mediaPath) return 'Idle';
|
|
if (snapshot.paused) return 'Paused';
|
|
return 'Playing';
|
|
}
|
|
|
|
function formatClock(totalSeconds: number | null | undefined): string {
|
|
if (!Number.isFinite(totalSeconds) || (totalSeconds ?? -1) < 0) return '--:--';
|
|
const rounded = Math.floor(totalSeconds as number);
|
|
const hours = Math.floor(rounded / 3600);
|
|
const minutes = Math.floor((rounded % 3600) / 60);
|
|
const seconds = rounded % 60;
|
|
if (hours > 0) {
|
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
}
|
|
|
|
export function buildDiscordPresenceActivity(
|
|
_config: DiscordPresenceConfig,
|
|
snapshot: DiscordPresenceSnapshot,
|
|
): DiscordActivityPayload {
|
|
const status = buildStatus(snapshot);
|
|
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
|
|
const details =
|
|
snapshot.connected && snapshot.mediaPath
|
|
? trimField(title)
|
|
: DISCORD_PRESENCE_STYLE.fallbackDetails;
|
|
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
|
|
const state =
|
|
snapshot.connected && snapshot.mediaPath
|
|
? trimField(`${status} ${timeline}`)
|
|
: trimField(status);
|
|
|
|
const activity: DiscordActivityPayload = {
|
|
details,
|
|
state,
|
|
startTimestamp: Math.floor(snapshot.sessionStartedAtMs / 1000),
|
|
};
|
|
|
|
if (DISCORD_PRESENCE_STYLE.largeImageKey.trim().length > 0) {
|
|
activity.largeImageKey = DISCORD_PRESENCE_STYLE.largeImageKey.trim();
|
|
}
|
|
if (DISCORD_PRESENCE_STYLE.largeImageText.trim().length > 0) {
|
|
activity.largeImageText = trimField(DISCORD_PRESENCE_STYLE.largeImageText.trim());
|
|
}
|
|
if (DISCORD_PRESENCE_STYLE.smallImageKey.trim().length > 0) {
|
|
activity.smallImageKey = DISCORD_PRESENCE_STYLE.smallImageKey.trim();
|
|
}
|
|
if (DISCORD_PRESENCE_STYLE.smallImageText.trim().length > 0) {
|
|
activity.smallImageText = trimField(DISCORD_PRESENCE_STYLE.smallImageText.trim());
|
|
}
|
|
if (
|
|
DISCORD_PRESENCE_STYLE.buttonLabel.trim().length > 0 &&
|
|
/^https?:\/\//.test(DISCORD_PRESENCE_STYLE.buttonUrl.trim())
|
|
) {
|
|
activity.buttons = [
|
|
{
|
|
label: trimField(DISCORD_PRESENCE_STYLE.buttonLabel.trim(), 32),
|
|
url: DISCORD_PRESENCE_STYLE.buttonUrl.trim(),
|
|
},
|
|
];
|
|
}
|
|
|
|
return activity;
|
|
}
|
|
|
|
export function createDiscordPresenceService(deps: {
|
|
config: DiscordPresenceConfig;
|
|
createClient: () => DiscordClient;
|
|
now?: () => number;
|
|
setTimeoutFn?: (callback: () => void, delayMs: number) => TimeoutLike;
|
|
clearTimeoutFn?: (timer: TimeoutLike) => void;
|
|
logDebug?: (message: string, meta?: unknown) => void;
|
|
}) {
|
|
const now = deps.now ?? (() => Date.now());
|
|
const setTimeoutFn = deps.setTimeoutFn ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
|
const clearTimeoutFn = deps.clearTimeoutFn ?? ((timer) => clearTimeout(timer));
|
|
const logDebug = deps.logDebug ?? (() => {});
|
|
|
|
let client: DiscordClient | null = null;
|
|
let pendingSnapshot: DiscordPresenceSnapshot | null = null;
|
|
let debounceTimer: TimeoutLike | null = null;
|
|
let intervalTimer: TimeoutLike | null = null;
|
|
let lastActivityKey = '';
|
|
let lastSentAtMs = 0;
|
|
|
|
async function flush(): Promise<void> {
|
|
if (!client || !pendingSnapshot) return;
|
|
const elapsed = now() - lastSentAtMs;
|
|
if (elapsed < deps.config.updateIntervalMs) {
|
|
const delay = Math.max(0, deps.config.updateIntervalMs - elapsed);
|
|
if (intervalTimer) clearTimeoutFn(intervalTimer);
|
|
intervalTimer = setTimeoutFn(() => {
|
|
void flush();
|
|
}, delay);
|
|
return;
|
|
}
|
|
|
|
const payload = buildDiscordPresenceActivity(deps.config, pendingSnapshot);
|
|
const activityKey = JSON.stringify(payload);
|
|
if (activityKey === lastActivityKey) return;
|
|
|
|
try {
|
|
await client.setActivity(payload);
|
|
lastSentAtMs = now();
|
|
lastActivityKey = activityKey;
|
|
} catch (error) {
|
|
logDebug('[discord-presence] failed to set activity', error);
|
|
}
|
|
}
|
|
|
|
function scheduleFlush(snapshot: DiscordPresenceSnapshot): void {
|
|
pendingSnapshot = snapshot;
|
|
if (debounceTimer) {
|
|
clearTimeoutFn(debounceTimer);
|
|
}
|
|
debounceTimer = setTimeoutFn(() => {
|
|
debounceTimer = null;
|
|
void flush();
|
|
}, deps.config.debounceMs);
|
|
}
|
|
|
|
return {
|
|
async start(): Promise<void> {
|
|
if (!deps.config.enabled) return;
|
|
try {
|
|
client = deps.createClient();
|
|
await client.login();
|
|
} catch (error) {
|
|
client = null;
|
|
logDebug('[discord-presence] login failed', error);
|
|
}
|
|
},
|
|
publish(snapshot: DiscordPresenceSnapshot): void {
|
|
if (!client) return;
|
|
scheduleFlush(snapshot);
|
|
},
|
|
async stop(): Promise<void> {
|
|
if (debounceTimer) {
|
|
clearTimeoutFn(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
if (intervalTimer) {
|
|
clearTimeoutFn(intervalTimer);
|
|
intervalTimer = null;
|
|
}
|
|
pendingSnapshot = null;
|
|
lastActivityKey = '';
|
|
lastSentAtMs = 0;
|
|
if (!client) return;
|
|
try {
|
|
await client.clearActivity();
|
|
} catch (error) {
|
|
logDebug('[discord-presence] clear activity failed', error);
|
|
}
|
|
client.destroy();
|
|
client = null;
|
|
},
|
|
};
|
|
}
|