refactor(discord): centralize Discord logic and improve activity update handling

This commit is contained in:
MindLated
2025-05-12 03:02:55 +02:00
parent d88406447e
commit 42cb9b0ea8

View File

@ -1,3 +1,4 @@
/* eslint-disable stylistic/no-mixed-operators */
import { app, dialog } from 'electron';
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
@ -17,158 +18,12 @@ import type { DiscordPluginConfig } from './index';
// Application ID registered by @th-ch/youtube-music dev team
const clientId = '1177081335727267940';
export interface Info {
rpc: DiscordClient;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: SongInfo;
}
const info: Info = {
rpc: new DiscordClient({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: undefined,
};
/**
* @type {(() => void)[]}
*/
const refreshCallbacks: (() => void)[] = [];
const truncateString = (str: string, length: number): string => {
if (str.length > length) return `${str.substring(0, length - 3)}...`;
return str;
};
const resetInfo = () => {
info.ready = false;
clearTimeout(clearActivity);
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.disconnected'));
}
for (const cb of refreshCallbacks) {
cb();
}
};
const connectTimeout = () =>
new Promise((resolve, reject) =>
setTimeout(() => {
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
info.rpc.login().then(resolve).catch(reject);
}, 5000),
);
const connectRecursive = () => {
if (!info.autoReconnect || info.rpc.isConnected) {
return;
}
connectTimeout().catch(connectRecursive);
};
let window: Electron.BrowserWindow;
export const connect = (showError = false) => {
if (info.rpc.isConnected) {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.already-connected'));
}
return;
}
info.ready = false;
// Startup the rpc client
info.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (info.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(window, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
let clearActivity: NodeJS.Timeout | undefined;
export const clear = () => {
if (info.rpc) {
info.rpc.user?.clearActivity();
}
clearTimeout(clearActivity);
};
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const isConnected = () => info.rpc?.isConnected;
let lastActivitySongId: string | null = null;
let lastPausedState: boolean = false; // Initialize with a default boolean
let lastElapsedSeconds: number = 0;
let updateTimeout: NodeJS.Timeout | null = null;
let lastProgressUpdate: number = 0; // timestamp of the last throttled update
const PROGRESS_THROTTLE_MS = 15000; // 15s to respect Discord's rate limit
function isSeek(oldSec: number, newSec: number) {
return Math.abs((newSec ?? 0) - (oldSec ?? 0)) > 2;
}
function manageActivityClearTimer(
isPaused: boolean | undefined,
// --- Factored utilities ---
function buildDiscordButtons(
config: DiscordPluginConfig,
) {
clearTimeout(clearActivity); // Clear any existing timer
// Set a new timer only if the song is paused and the feature is enabled
if (isPaused === true && config.activityTimeoutEnabled) {
clearActivity = setTimeout(
() => info.rpc.user?.clearActivity().catch(console.error),
config.activityTimeoutTime ?? 10_000,
);
}
}
function sendActivityToDiscord(
songInfo: SongInfo,
config: DiscordPluginConfig,
) {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
clearTimeout(clearActivity);
if (!info.rpc || !info.ready) {
return;
}
// Song information changed, so lets update the rich presence
// @see https://discord.com/developers/docs/topics/gateway#activity-object
// not all options are transfered through https://github.com/discordjs/RPC/blob/6f83d8d812c87cb7ae22064acd132600407d7d05/src/client.js#L518-530
const hangulFillerUnicodeCharacter = '\u3164'; // This is an empty character
const paddedInfoKeys: (keyof SongInfo)[] = ['title', 'artist', 'album'];
for (const key of paddedInfoKeys) {
const keyLength = (songInfo[key] as string)?.length;
if (keyLength < 2) {
(songInfo[key] as string) += hangulFillerUnicodeCharacter.repeat(
2 - keyLength,
);
}
}
let buttons: GatewayActivityButton[] | undefined = [];
): GatewayActivityButton[] | undefined {
const buttons: GatewayActivityButton[] = [];
if (config.playOnYouTubeMusic) {
buttons.push({
label: 'Play on YouTube Music',
@ -181,27 +36,277 @@ function sendActivityToDiscord(
url: 'https://github.com/th-ch/youtube-music',
});
}
if (buttons.length === 0) {
buttons = undefined;
return buttons.length ? buttons : undefined;
}
function padHangulFields(songInfo: SongInfo): void {
const hangulFiller = '\u3164';
(['title', 'artist', 'album'] as (keyof SongInfo)[]).forEach((key) => {
const value = songInfo[key];
if (typeof value === 'string' && value.length < 2) {
// @ts-expect-error: dynamic assignment for SongInfo fields
songInfo[key] = value + hangulFiller.repeat(2 - value.length);
}
});
}
// Centralized timer management
const TimerManager = {
timers: new Map<string, NodeJS.Timeout>(),
set(key: string, fn: () => void, delay: number) {
this.clear(key);
this.timers.set(key, setTimeout(fn, delay));
},
clear(key: string) {
const t = this.timers.get(key);
if (t) clearTimeout(t);
this.timers.delete(key);
},
clearAll() {
for (const t of this.timers.values()) clearTimeout(t);
this.timers.clear();
},
};
// --- Centralization of Discord logic ---
interface DiscordState {
rpc: DiscordClient;
ready: boolean;
autoReconnect: boolean;
lastSongInfo?: SongInfo;
}
const discordState: DiscordState = {
rpc: new DiscordClient({
clientId,
}),
ready: false,
autoReconnect: true,
lastSongInfo: undefined,
};
let mainWindow: Electron.BrowserWindow;
let lastActivitySongId: string | null = null;
let lastPausedState: boolean = false;
let lastElapsedSeconds: number = 0;
let lastProgressUpdate: number = 0;
const PROGRESS_THROTTLE_MS = 15000;
/**
* Main Discord presence update logic.
* Handles throttling, snapshotting, and timer management to avoid spamming Discord's API.
* - Throttling: Ensures updates are not sent more than once every PROGRESS_THROTTLE_MS ms.
* - Snapshot: If an update is requested too soon, schedules a delayed update with a snapshot of the current song state.
* - Timers: Uses TimerManager to ensure only one timer per type is active and to clean up properly.
*/
function updateDiscordRichPresence(
songInfo: SongInfo,
config: DiscordPluginConfig,
) {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) return;
discordState.lastSongInfo = songInfo;
// Always clear any pending activity-clear timer before updating presence
TimerManager.clear('clearActivity');
if (!discordState.rpc || !discordState.ready) return;
const now = Date.now();
const songChanged = songInfo.videoId !== lastActivitySongId;
const pauseChanged = songInfo.isPaused !== lastPausedState;
const seeked = isSeek(lastElapsedSeconds, songInfo.elapsedSeconds ?? 0);
// If the song changed, pause state changed, or user seeked, update immediately and reset throttle
if (songChanged || pauseChanged || seeked) {
// Cancel any pending throttled update
TimerManager.clear('updateTimeout');
padHangulFields(songInfo);
const activityInfo: SetActivity = {
type: ActivityType.Listening,
details: truncateString(songInfo.title, 128),
state: truncateString(songInfo.artist, 128),
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: buildDiscordButtons(config, songInfo),
};
if (songInfo.isPaused) {
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
} else if (!config.hideDurationLeft) {
// Set start/end timestamps for progress bar
const songStartTime = Date.now() - (songInfo.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
}
discordState.rpc.user?.setActivity(activityInfo).catch(console.error);
lastActivitySongId = songInfo.videoId;
if (typeof songInfo.isPaused === 'boolean') {
lastPausedState = songInfo.isPaused;
}
lastElapsedSeconds = songInfo.elapsedSeconds ?? 0;
lastProgressUpdate = now;
setActivityTimeoutCentral(songInfo.isPaused, config);
return;
}
const activityInfo: SetActivity = {
type: ActivityType.Listening,
details: truncateString(songInfo.title, 128),
state: truncateString(songInfo.artist, 128),
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons,
};
if (songInfo.isPaused) {
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
// No timestamps when paused
} else if (!config.hideDurationLeft) {
const songStartTime = Date.now() - (songInfo.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
// Throttling: Only allow a full update if enough time has passed
if (now - lastProgressUpdate > PROGRESS_THROTTLE_MS) {
padHangulFields(songInfo);
const activityInfo: SetActivity = {
type: ActivityType.Listening,
details: truncateString(songInfo.title, 128),
state: truncateString(songInfo.artist, 128),
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: buildDiscordButtons(config, songInfo),
};
if (songInfo.isPaused) {
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
} else if (!config.hideDurationLeft) {
// Set start/end timestamps for progress bar
const songStartTime = Date.now() - (songInfo.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp = songStartTime + songInfo.songDuration * 1000;
}
discordState.rpc.user?.setActivity(activityInfo).catch(console.error);
lastProgressUpdate = now;
lastElapsedSeconds = songInfo.elapsedSeconds ?? 0;
setActivityTimeoutCentral(songInfo.isPaused, config);
} else {
// Snapshot logic: If throttled, schedule a delayed update with a snapshot of the current song state
TimerManager.clear('updateTimeout');
const songInfoSnapshot = { ...songInfo };
TimerManager.set(
'updateTimeout',
() => {
// Only send if the global state still matches the snapshot
if (
discordState.lastSongInfo?.videoId === songInfoSnapshot.videoId &&
discordState.lastSongInfo?.isPaused === songInfoSnapshot.isPaused
) {
padHangulFields(songInfoSnapshot);
const activityInfo: SetActivity = {
type: ActivityType.Listening,
details: truncateString(songInfoSnapshot.title, 128),
state: truncateString(songInfoSnapshot.artist, 128),
largeImageKey: songInfoSnapshot.imageSrc ?? '',
largeImageText: songInfoSnapshot.album ?? '',
buttons: buildDiscordButtons(config, songInfoSnapshot),
};
if (songInfoSnapshot.isPaused) {
activityInfo.smallImageKey = 'paused';
activityInfo.smallImageText = 'Paused';
} else if (!config.hideDurationLeft) {
// Set start/end timestamps for progress bar
const songStartTime =
Date.now() - (songInfoSnapshot.elapsedSeconds ?? 0) * 1000;
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp =
songStartTime + songInfoSnapshot.songDuration * 1000;
}
discordState.rpc.user?.setActivity(activityInfo).catch(console.error);
lastProgressUpdate = Date.now();
lastElapsedSeconds = songInfoSnapshot.elapsedSeconds ?? 0;
setActivityTimeoutCentral(songInfoSnapshot.isPaused, config);
}
},
PROGRESS_THROTTLE_MS - (now - lastProgressUpdate),
);
}
info.rpc.user?.setActivity(activityInfo).catch(console.error);
}
/**
* Sets a timer to clear Discord activity if paused for too long.
* Uses TimerManager to ensure only one clear-activity timer is active at a time.
*/
function setActivityTimeoutCentral(
isPaused: boolean | undefined,
config: DiscordPluginConfig,
) {
TimerManager.clear('clearActivity');
if (isPaused === true && config.activityTimeoutEnabled) {
TimerManager.set(
'clearActivity',
() => {
discordState.rpc.user?.clearActivity().catch(console.error);
},
config.activityTimeoutTime ?? 10_000,
);
}
}
const truncateString = (str: string, length: number): string => {
if (str.length > length) return `${str.substring(0, length - 3)}...`;
return str;
};
const resetInfo = () => {
discordState.ready = false;
TimerManager.clearAll();
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.disconnected'));
}
};
const connectTimeout = () =>
new Promise((resolve, reject) =>
setTimeout(() => {
if (!discordState.autoReconnect || discordState.rpc.isConnected) {
return;
}
discordState.rpc.login().then(resolve).catch(reject);
}, 5000),
);
const connectRecursive = () => {
if (!discordState.autoReconnect || discordState.rpc.isConnected) {
return;
}
connectTimeout().catch(connectRecursive);
};
export const connect = (showError = false) => {
if (discordState.rpc.isConnected) {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.already-connected'));
}
return;
}
discordState.ready = false;
// Startup the rpc client
discordState.rpc.login().catch((error: Error) => {
resetInfo();
if (dev()) {
console.error(error);
}
if (discordState.autoReconnect) {
connectRecursive();
} else if (showError) {
dialog.showMessageBox(mainWindow, {
title: 'Connection failed',
message: error.message || String(error),
type: 'error',
});
}
});
};
export const clear = () => {
if (discordState.rpc) {
discordState.rpc.user?.clearActivity();
}
TimerManager.clearAll();
};
export const registerRefresh = (cb: () => void) => refreshCallbacks.push(cb);
export const isConnected = () => discordState.rpc?.isConnected;
const refreshCallbacks: (() => void)[] = [];
function isSeek(oldSec: number, newSec: number) {
return Math.abs((newSec ?? 0) - (oldSec ?? 0)) > 2;
}
export const backend = createBackend<
@ -211,70 +316,13 @@ export const backend = createBackend<
},
DiscordPluginConfig
>({
/**
* We get multiple events
* Next song: PAUSE(n), PAUSE(n+1), PLAY(n+1)
* Skip time: PAUSE(N), PLAY(N)
*/
updateActivity: (songInfo, config) => {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) {
return;
}
info.lastSongInfo = songInfo;
clearTimeout(clearActivity);
if (!info.rpc || !info.ready) {
return;
}
const now = Date.now();
const songChanged = songInfo.videoId !== lastActivitySongId;
const pauseChanged = songInfo.isPaused !== lastPausedState;
const seeked = isSeek(lastElapsedSeconds, songInfo.elapsedSeconds ?? 0);
if (songChanged || pauseChanged || seeked) {
// Always cancel any pending throttle on important event
if (updateTimeout) clearTimeout(updateTimeout);
updateTimeout = null;
sendActivityToDiscord(songInfo, config);
lastActivitySongId = songInfo.videoId;
// Update lastPausedState only if songInfo.isPaused is a boolean
if (typeof songInfo.isPaused === 'boolean') {
lastPausedState = songInfo.isPaused;
}
lastElapsedSeconds = songInfo.elapsedSeconds ?? 0;
lastProgressUpdate = now;
return;
}
// Normal progression: throttle
if (now - lastProgressUpdate > PROGRESS_THROTTLE_MS) {
sendActivityToDiscord(songInfo, config);
lastProgressUpdate = now;
lastElapsedSeconds = songInfo.elapsedSeconds ?? 0;
} else {
if (updateTimeout) clearTimeout(updateTimeout);
// Capture a snapshot of songInfo for the timeout callback
const songInfoSnapshot = { ...songInfo };
updateTimeout = setTimeout(
() => {
// Only send if the current global song state still matches what was expected
// at the time the timeout was set (i.e., the snapshot's state)
if (
info.lastSongInfo?.videoId === songInfoSnapshot.videoId &&
info.lastSongInfo?.isPaused === songInfoSnapshot.isPaused
) {
// Use the snapshot for sending activity and updating last elapsed time
sendActivityToDiscord(songInfoSnapshot, config);
lastProgressUpdate = Date.now();
lastElapsedSeconds = songInfoSnapshot.elapsedSeconds ?? 0;
}
},
PROGRESS_THROTTLE_MS - (now - lastProgressUpdate),
);
}
manageActivityClearTimer(songInfo.isPaused, config);
},
updateActivity: (songInfo, config) =>
updateDiscordRichPresence(songInfo, config),
async start(ctx) {
this.config = await ctx.getConfig();
info.rpc.on('connected', () => {
discordState.rpc.on('connected', () => {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
}
@ -284,31 +332,31 @@ export const backend = createBackend<
}
});
info.rpc.on('ready', () => {
info.ready = true;
if (info.lastSongInfo && this.config) {
this.updateActivity(info.lastSongInfo, this.config);
discordState.rpc.on('ready', () => {
discordState.ready = true;
if (discordState.lastSongInfo && this.config) {
this.updateActivity(discordState.lastSongInfo, this.config);
}
});
info.rpc.on('disconnected', () => {
discordState.rpc.on('disconnected', () => {
resetInfo();
if (info.autoReconnect) {
if (discordState.autoReconnect) {
connectTimeout();
}
});
info.autoReconnect = this.config.autoReconnect;
discordState.autoReconnect = this.config.autoReconnect;
window = ctx.window;
mainWindow = ctx.window;
// If the page is ready, register the callback
ctx.window.once('ready-to-show', () => {
let lastSent = Date.now();
registerCallback((songInfo, event) => {
if (event !== SongInfoEvent.TimeChanged) {
info.lastSongInfo = songInfo;
discordState.lastSongInfo = songInfo;
if (this.config) this.updateActivity(songInfo, this.config);
} else {
const currentTime = Date.now();
@ -316,7 +364,7 @@ export const backend = createBackend<
if (currentTime - lastSent > 5000) {
lastSent = currentTime;
if (songInfo) {
info.lastSongInfo = songInfo;
discordState.lastSongInfo = songInfo;
if (this.config) this.updateActivity(songInfo, this.config);
}
}
@ -334,9 +382,9 @@ export const backend = createBackend<
},
onConfigChange(newConfig) {
this.config = newConfig;
info.autoReconnect = newConfig.autoReconnect;
if (info.lastSongInfo) {
this.updateActivity(info.lastSongInfo, newConfig);
discordState.autoReconnect = newConfig.autoReconnect;
if (discordState.lastSongInfo) {
this.updateActivity(discordState.lastSongInfo, newConfig);
}
},
});