feat: refactor

This commit is contained in:
JellyBrick
2025-05-20 14:27:30 +09:00
parent bb69f31f3a
commit 51b3f53569
6 changed files with 591 additions and 340 deletions

View File

@ -0,0 +1,25 @@
/**
* Application ID registered by @th-ch/youtube-music dev team
*/
export const clientId = '1177081335727267940';
/**
* Throttle time for progress updates in milliseconds
*/
export const PROGRESS_THROTTLE_MS = 15_000;
/**
* Time in milliseconds to wait before sending a time update
*/
export const TIME_UPDATE_DEBOUNCE_MS = 5000;
/**
* Filler character for padding short Hangul strings (Discord requires min 2 chars)
*/
export const HANGUL_FILLER = '\u3164';
/**
* Enum for keys used in TimerManager.
*/
export enum TimerKey {
ClearActivity = 'clearActivity', // Timer to clear activity when paused
UpdateTimeout = 'updateTimeout', // Timer for throttled activity updates
DiscordConnectRetry = 'discordConnectRetry', // Timer for Discord connection retries
}

View File

@ -0,0 +1,406 @@
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
import { ActivityType } from 'discord-api-types/v10';
import { t } from '@/i18n';
import { LoggerPrefix } from '@/utils';
import { clientId, PROGRESS_THROTTLE_MS, TimerKey } from './constants';
import { TimerManager } from './timer-manager';
import {
buildDiscordButtons,
padHangulFields,
truncateString,
isSeek,
} from './utils';
import type { DiscordPluginConfig } from './index';
import type { SongInfo } from '@/providers/song-info';
import type { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
// Public API definition for the Discord Service
export class DiscordService {
/**
* Discord RPC client instance.
*/
rpc = new DiscordClient({ clientId });
/**
* Indicates if the service is ready to send activity updates.
*/
ready = false;
/**
* Indicates if the service should attempt to reconnect automatically.
*/
autoReconnect = true;
/**
* Cached song information from the last activity update.
*/
lastSongInfo?: SongInfo;
/**
* Timestamp of the last progress update sent to Discord.
*/
lastProgressUpdate = 0;
/**
* Plugin configuration.
*/
config?: DiscordPluginConfig;
refreshCallbacks: (() => void)[] = [];
timerManager = new TimerManager();
mainWindow: Electron.BrowserWindow;
/**
* Initializes the Discord service with configuration and main window reference.
* Sets up RPC event listeners.
* @param mainWindow - Electron BrowserWindow instance.
* @param config - Plugin configuration.
*/
constructor(
mainWindow: Electron.BrowserWindow,
config?: DiscordPluginConfig,
) {
this.config = config;
this.mainWindow = mainWindow;
this.autoReconnect = config?.autoReconnect ?? true; // Default autoReconnect to true
this.rpc.on('connected', () => {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
}
this.refreshCallbacks.forEach((cb) => cb());
});
this.rpc.on('ready', () => {
this.ready = true;
if (this.lastSongInfo && this.config) {
this.updateActivity(this.lastSongInfo);
}
});
this.rpc.on('disconnected', () => {
this.resetInfo();
if (this.autoReconnect) {
this.connectRecursive();
}
});
}
/**
* Builds the SetActivity payload for Discord Rich Presence.
* @param songInfo - Current song information.
* @param config - Plugin configuration.
* @returns The SetActivity object.
*/
private buildActivityInfo(
songInfo: SongInfo,
config: DiscordPluginConfig,
): SetActivity {
padHangulFields(songInfo);
const activityInfo: SetActivity = {
type: ActivityType.Listening,
details: truncateString(songInfo.title, 128), // Song title
state: truncateString(songInfo.artist, 128), // Artist name
largeImageKey: songInfo.imageSrc ?? undefined,
largeImageText: songInfo.album
? truncateString(songInfo.album, 128)
: undefined,
buttons: buildDiscordButtons(config, songInfo),
};
// Handle paused state display
if (songInfo.isPaused) {
activityInfo.largeImageText = '⏸︎';
} else if (
!config.hideDurationLeft &&
songInfo.songDuration > 0 &&
typeof songInfo.elapsedSeconds === 'number'
) {
const songStartTime = Date.now() - songInfo.elapsedSeconds * 1000;
activityInfo.startTimestamp = Math.floor(songStartTime / 1000);
activityInfo.endTimestamp = Math.floor(
(songStartTime + songInfo.songDuration * 1000) / 1000,
);
}
return activityInfo;
}
/**
* Sets a timer to clear Discord activity if the music is paused for too long,
* based on the plugin configuration.
*/
private setActivityTimeout() {
this.timerManager.clear(TimerKey.ClearActivity); // Clear any existing timeout
if (
this.lastSongInfo?.isPaused === true && // Music must be paused
this.config?.activityTimeoutEnabled && // Timeout must be enabled in config
this.config?.activityTimeoutTime && // Timeout duration must be set
this.config.activityTimeoutTime > 0 // Timeout duration must be positive
) {
this.timerManager.set(
TimerKey.ClearActivity,
() => {
this.clearActivity();
},
this.config.activityTimeoutTime,
);
}
}
/**
* Resets the internal state (except config and mainWindow), clears timers, and logs disconnection.
*/
private resetInfo() {
this.ready = false;
this.lastSongInfo = undefined;
this.lastProgressUpdate = 0;
this.timerManager.clearAll();
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.disconnected'));
}
}
/**
* Attempts to connect to Discord RPC after a delay, used for retries.
* @returns Promise that resolves on successful login or rejects on failure/cancellation.
*/
private connectWithRetry(): Promise<void> {
return new Promise((resolve, reject) => {
this.timerManager.set(
TimerKey.DiscordConnectRetry,
() => {
// Stop retrying if auto-reconnect is disabled or already connected
if (!this.autoReconnect || this.rpc.isConnected) {
this.timerManager.clear(TimerKey.DiscordConnectRetry);
if (this.rpc.isConnected) resolve();
else
reject(
new Error('Auto-reconnect disabled or already connected.'),
);
return;
}
// Attempt to login
this.rpc
.login()
.then(() => {
this.timerManager.clear(TimerKey.DiscordConnectRetry); // Success, stop retrying
resolve();
})
.catch(() => {
this.connectRecursive();
});
},
5000, // 5-second delay before retrying
);
});
}
/**
* Recursively attempts to connect to Discord RPC if auto-reconnect is enabled and not connected.
*/
private connectRecursive = () => {
if (!this.autoReconnect || this.rpc.isConnected) {
this.timerManager.clear(TimerKey.DiscordConnectRetry);
return;
}
this.connectWithRetry();
};
/**
* Connects to Discord RPC. Shows an error dialog on failure if specified and not auto-reconnecting.
* @param showErrorDialog - Whether to show an error dialog on initial connection failure.
*/
connect(showErrorDialog = false): void {
if (this.rpc.isConnected) {
if (dev()) {
console.log(
LoggerPrefix,
t('plugins.discord.backend.already-connected'),
);
}
return;
}
if (!this.config) {
return;
}
this.ready = false;
this.timerManager.clear(TimerKey.DiscordConnectRetry);
this.rpc.login().catch(() => {
this.resetInfo();
if (this.autoReconnect) {
this.connectRecursive();
} else if (showErrorDialog && this.mainWindow) {
// connection failed
}
});
}
/**
* Disconnects from Discord RPC, prevents auto-reconnection, and clears timers.
*/
disconnect(): void {
this.autoReconnect = false;
this.timerManager.clear(TimerKey.DiscordConnectRetry);
this.timerManager.clear(TimerKey.ClearActivity);
if (this.rpc.isConnected) {
try {
this.rpc.destroy();
} catch {
// ignored
}
}
this.resetInfo(); // Reset internal state
}
/**
* Updates the Discord Rich Presence based on the current song information.
* Handles throttling logic to avoid excessive updates.
* Detects changes in song, pause state, or seeks for immediate updates.
* @param songInfo - The current song information.
*/
updateActivity(songInfo: SongInfo): void {
if (!this.config) return;
if (!songInfo.title && !songInfo.artist) {
if (this.lastSongInfo?.videoId) {
this.clearActivity();
this.lastSongInfo = undefined;
}
return;
}
// Cache the latest song info
this.lastSongInfo = songInfo;
this.timerManager.clear(TimerKey.ClearActivity);
if (!this.rpc || !this.ready) {
// skip update if not ready
return;
}
const now = Date.now();
const elapsedSeconds = songInfo.elapsedSeconds ?? 0;
const songChanged = songInfo.videoId !== this.lastSongInfo?.videoId;
const pauseChanged = songInfo.isPaused !== this.lastSongInfo?.isPaused;
const seeked =
!songChanged &&
isSeek(this.lastSongInfo?.elapsedSeconds ?? 0, elapsedSeconds);
if (songChanged || pauseChanged || seeked) {
this.timerManager.clear(TimerKey.UpdateTimeout);
const activityInfo = this.buildActivityInfo(songInfo, this.config);
this.rpc.user
?.setActivity(activityInfo)
.catch((err) =>
console.error(LoggerPrefix, 'Failed to set activity:', err),
);
this.lastSongInfo.videoId = songInfo.videoId;
this.lastSongInfo.isPaused = songInfo.isPaused ?? false;
this.lastSongInfo.elapsedSeconds = elapsedSeconds;
this.lastProgressUpdate = now;
this.setActivityTimeout();
} else if (now - this.lastProgressUpdate > PROGRESS_THROTTLE_MS) {
this.timerManager.clear(TimerKey.UpdateTimeout);
const activityInfo = this.buildActivityInfo(songInfo, this.config);
this.rpc.user
?.setActivity(activityInfo)
.catch((err) =>
console.error(LoggerPrefix, 'Failed to set throttled activity:', err),
);
this.lastSongInfo.elapsedSeconds = elapsedSeconds;
this.lastProgressUpdate = now;
this.setActivityTimeout();
} else {
const remainingThrottle =
PROGRESS_THROTTLE_MS - (now - this.lastProgressUpdate);
const songInfoSnapshot = { ...songInfo };
this.timerManager.set(
TimerKey.UpdateTimeout,
() => {
if (
this.lastSongInfo?.videoId === songInfoSnapshot.videoId &&
this.lastSongInfo?.isPaused === songInfoSnapshot.isPaused &&
this.config
) {
const activityInfo = this.buildActivityInfo(
songInfoSnapshot,
this.config,
);
this.rpc.user?.setActivity(activityInfo);
this.lastProgressUpdate = Date.now();
this.lastSongInfo.elapsedSeconds =
songInfoSnapshot.elapsedSeconds ?? 0;
this.setActivityTimeout();
}
},
remainingThrottle,
);
}
}
/**
* Clears the Discord Rich Presence activity.
*/
clearActivity(): void {
if (this.rpc.isConnected && this.ready) {
this.rpc.user?.clearActivity();
}
this.lastProgressUpdate = 0;
this.lastSongInfo = undefined;
this.timerManager.clear(TimerKey.ClearActivity);
this.timerManager.clear(TimerKey.UpdateTimeout);
}
/**
* Updates the configuration used by the service and re-evaluates activity/timeouts.
* @param newConfig - The new plugin configuration.
*/
onConfigChange(newConfig: DiscordPluginConfig): void {
this.config = newConfig;
this.autoReconnect = newConfig.autoReconnect ?? true;
if (this.lastSongInfo && this.ready && this.rpc.isConnected) {
this.updateActivity(this.lastSongInfo);
}
this.setActivityTimeout();
}
/**
* Registers a callback function to be called when the RPC connection status changes (connected/disconnected).
* @param cb - The callback function.
*/
registerRefreshCallback(cb: () => void): void {
this.refreshCallbacks.push(cb);
}
/**
* Checks if the Discord RPC client is currently connected and ready.
* @returns True if connected and ready, false otherwise.
*/
isConnected(): boolean {
// Consider both connection and readiness state
return this.rpc.isConnected && this.ready;
}
/**
* Cleans up resources: disconnects RPC, clears all timers, and clears callbacks.
* Should be called when the plugin stops or the application quits.
*/
cleanup(): void {
this.disconnect();
this.refreshCallbacks = [];
}
}

View File

@ -1,362 +1,71 @@
/* eslint-disable stylistic/no-mixed-operators */
import { app, dialog } from 'electron';
import { Client as DiscordClient } from '@xhayper/discord-rpc';
import { dev } from 'electron-is';
import { app } from 'electron';
import { ActivityType, GatewayActivityButton } from 'discord-api-types/v10';
import registerCallback, { SongInfoEvent } from '@/providers/song-info';
import { createBackend } from '@/utils';
import registerCallback, {
type SongInfo,
SongInfoEvent,
} from '@/providers/song-info';
import { createBackend, LoggerPrefix } from '@/utils';
import { t } from '@/i18n';
import { DiscordService } from './discord-service';
import { TIME_UPDATE_DEBOUNCE_MS } from './constants';
import type { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
import type { DiscordPluginConfig } from './index';
// Application ID registered by @th-ch/youtube-music dev team
const clientId = '1177081335727267940';
// --- Factored utilities ---
function buildDiscordButtons(
config: DiscordPluginConfig,
songInfo: SongInfo,
): GatewayActivityButton[] | undefined {
const buttons: GatewayActivityButton[] = [];
if (config.playOnYouTubeMusic) {
buttons.push({
label: 'Play on YouTube Music',
url: songInfo.url ?? 'https://music.youtube.com',
});
}
if (!config.hideGitHubButton) {
buttons.push({
label: 'View App On GitHub',
url: 'https://github.com/th-ch/youtube-music',
});
}
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;
function buildActivityInfo(
songInfo: SongInfo,
config: DiscordPluginConfig,
pausedKey: 'smallImageKey' | 'largeImageKey' = 'smallImageKey',
): SetActivity {
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[pausedKey] = 'paused';
if (pausedKey === 'smallImageKey') {
activityInfo.smallImageText = 'Paused';
} else {
activityInfo.largeImageText = '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;
}
return activityInfo;
}
function updateDiscordRichPresence(
songInfo: SongInfo,
config: DiscordPluginConfig,
) {
if (songInfo.title.length === 0 && songInfo.artist.length === 0) return;
discordState.lastSongInfo = songInfo;
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 (songChanged || pauseChanged || seeked) {
TimerManager.clear('updateTimeout');
const activityInfo = buildActivityInfo(
songInfo,
config,
songInfo.isPaused ? 'largeImageKey' : 'smallImageKey',
);
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;
}
if (now - lastProgressUpdate > PROGRESS_THROTTLE_MS) {
const activityInfo = buildActivityInfo(
songInfo,
config,
songInfo.isPaused ? 'largeImageKey' : 'smallImageKey',
);
discordState.rpc.user?.setActivity(activityInfo).catch(console.error);
lastProgressUpdate = now;
lastElapsedSeconds = songInfo.elapsedSeconds ?? 0;
setActivityTimeoutCentral(songInfo.isPaused, config);
} else {
TimerManager.clear('updateTimeout');
const songInfoSnapshot = { ...songInfo };
TimerManager.set(
'updateTimeout',
() => {
if (
discordState.lastSongInfo?.videoId === songInfoSnapshot.videoId &&
discordState.lastSongInfo?.isPaused === songInfoSnapshot.isPaused
) {
const activityInfo = buildActivityInfo(songInfoSnapshot, config);
discordState.rpc.user?.setActivity(activityInfo).catch(console.error);
lastProgressUpdate = Date.now();
lastElapsedSeconds = songInfoSnapshot.elapsedSeconds ?? 0;
setActivityTimeoutCentral(songInfoSnapshot.isPaused, config);
}
},
PROGRESS_THROTTLE_MS - (now - lastProgressUpdate),
);
}
}
/**
* 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 - oldSec) > 2;
}
export let discordService = null as DiscordService | null;
export const backend = createBackend<
{
config?: DiscordPluginConfig;
updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void;
lastTimeUpdateSent: number;
},
DiscordPluginConfig
>({
updateActivity: (songInfo, config) =>
updateDiscordRichPresence(songInfo, config),
lastTimeUpdateSent: 0,
async start(ctx) {
this.config = await ctx.getConfig();
// Get initial configuration from the context
const config = await ctx.getConfig();
discordService = new DiscordService(ctx.window, config);
discordState.rpc.on('connected', () => {
if (dev()) {
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
}
if (config.enabled) {
ctx.window.once('ready-to-show', () => {
discordService?.connect(!config.autoReconnect);
for (const cb of refreshCallbacks) {
cb();
}
});
registerCallback((songInfo, event) => {
if (!discordService?.isConnected()) return;
discordState.rpc.on('ready', () => {
discordState.ready = true;
if (discordState.lastSongInfo && this.config) {
this.updateActivity(discordState.lastSongInfo, this.config);
}
});
discordState.rpc.on('disconnected', () => {
resetInfo();
if (discordState.autoReconnect) {
connectTimeout();
}
});
discordState.autoReconnect = this.config.autoReconnect;
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) {
discordState.lastSongInfo = songInfo;
if (this.config) this.updateActivity(songInfo, this.config);
} else {
const currentTime = Date.now();
// if lastSent is more than 5 seconds ago, send the new time
if (currentTime - lastSent > 5000) {
lastSent = currentTime;
if (songInfo) {
discordState.lastSongInfo = songInfo;
if (this.config) this.updateActivity(songInfo, this.config);
if (event !== SongInfoEvent.TimeChanged) {
discordService?.updateActivity(songInfo);
this.lastTimeUpdateSent = Date.now();
} else {
const now = Date.now();
if (now - this.lastTimeUpdateSent > TIME_UPDATE_DEBOUNCE_MS) {
discordService?.updateActivity(songInfo);
this.lastTimeUpdateSent = now; // Record the time of this debounced update
}
}
}
});
});
connect();
}
ctx.ipc.on('ytmd:player-api-loaded', () => {
ctx.ipc.send('ytmd:setup-time-changed-listener');
});
app.on('before-quit', () => {
discordService?.cleanup();
});
ctx.ipc.on('ytmd:player-api-loaded', () =>
ctx.ipc.send('ytmd:setup-time-changed-listener'),
);
app.on('window-all-closed', clear);
},
stop() {
resetInfo();
discordService?.cleanup();
},
onConfigChange(newConfig) {
this.config = newConfig;
discordState.autoReconnect = newConfig.autoReconnect;
if (discordState.lastSongInfo) {
this.updateActivity(discordState.lastSongInfo, newConfig);
discordService?.onConfigChange(newConfig);
const currentlyConnected = discordService?.isConnected() ?? false;
if (newConfig.enabled && !currentlyConnected) {
discordService?.connect(!newConfig.autoReconnect);
} else if (!newConfig.enabled && currentlyConnected) {
discordService?.disconnect();
}
},
});

View File

@ -1,6 +1,6 @@
import prompt from 'custom-electron-prompt';
import { clear, connect, isConnected, registerRefresh } from './main';
import { discordService } from './main';
import { singleton } from '@/providers/decorators';
import promptOptions from '@/providers/prompt-options';
@ -14,7 +14,7 @@ import type { DiscordPluginConfig } from './index';
import type { MenuTemplate } from '@/menu';
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
registerRefresh(refreshMenu);
discordService?.registerRefreshCallback(refreshMenu);
});
export const onMenu = async ({
@ -28,11 +28,11 @@ export const onMenu = async ({
return [
{
label: isConnected()
label: discordService?.isConnected()
? t('plugins.discord.menu.connected')
: t('plugins.discord.menu.disconnected'),
enabled: !isConnected(),
click: () => connect(),
enabled: !discordService?.isConnected(),
click: () => discordService?.connect(true),
},
{
label: t('plugins.discord.menu.auto-reconnect'),
@ -46,7 +46,7 @@ export const onMenu = async ({
},
{
label: t('plugins.discord.menu.clear-activity'),
click: clear,
click: () => discordService?.clearActivity(),
},
{
label: t('plugins.discord.menu.clear-activity-after-timeout'),

View File

@ -0,0 +1,41 @@
import { TimerKey } from './constants';
/**
* Manages NodeJS Timers, ensuring only one timer exists per key.
*/
export class TimerManager {
timers = new Map<TimerKey, NodeJS.Timeout>();
/**
* Sets a timer for a given key, clearing any existing timer with the same key.
* @param key - The unique key for the timer (using TimerKey enum).
* @param fn - The function to execute after the delay.
* @param delay - The delay in milliseconds.
*/
set(key: TimerKey, fn: () => void, delay: number): void {
this.clear(key);
this.timers.set(key, setTimeout(fn, delay));
}
/**
* Clears the timer associated with the given key.
* @param key - The key of the timer to clear (using TimerKey enum).
*/
clear(key: TimerKey): void {
const timer = this.timers.get(key);
if (timer) {
clearTimeout(timer);
this.timers.delete(key);
}
}
/**
* Clears all managed timers.
*/
clearAll(): void {
for (const timer of this.timers.values()) {
clearTimeout(timer);
}
this.timers.clear();
}
}

View File

@ -0,0 +1,70 @@
import { HANGUL_FILLER } from './constants';
import type { GatewayActivityButton } from 'discord-api-types/v10';
import type { SongInfo } from '@/providers/song-info';
import type { DiscordPluginConfig } from './index';
/**
* Truncates a string to a specified length, adding ellipsis if truncated.
* @param str - The string to truncate.
* @param length - The maximum allowed length.
* @returns The truncated string.
*/
export const truncateString = (str: string, length: number): string => {
if (str.length > length) {
return `${str.substring(0, length - 3)}...`;
}
return str;
};
/**
* Builds the array of buttons for the Discord Rich Presence activity.
* @param config - The plugin configuration.
* @param songInfo - The current song information.
* @returns An array of buttons or undefined if no buttons are configured.
*/
export const buildDiscordButtons = (
config: DiscordPluginConfig,
songInfo: SongInfo,
): GatewayActivityButton[] | undefined => {
const buttons: GatewayActivityButton[] = [];
if (config.playOnYouTubeMusic && songInfo.url) {
buttons.push({
label: 'Play on YouTube Music',
url: songInfo.url,
});
}
if (!config.hideGitHubButton) {
buttons.push({
label: 'View App On GitHub',
url: 'https://github.com/th-ch/youtube-music',
});
}
return buttons.length ? buttons : undefined;
};
/**
* Pads Hangul fields (title, artist, album) in SongInfo if they are less than 2 characters long.
* Discord requires fields to be at least 2 characters.
* @param songInfo - The song information object (will be mutated).
*/
export const padHangulFields = (songInfo: SongInfo): void => {
(['title', 'artist', 'album'] as const).forEach((key) => {
const value = songInfo[key];
if (typeof value === 'string' && value.length > 0 && value.length < 2) {
songInfo[key] = value + HANGUL_FILLER.repeat(2 - value.length);
}
});
};
/**
* Checks if the difference between two time values indicates a seek operation.
* @param oldSeconds - The previous elapsed time in seconds.
* @param newSeconds - The current elapsed time in seconds.
* @returns True if the time difference suggests a seek, false otherwise.
*/
export const isSeek = (oldSeconds: number, newSeconds: number): boolean => {
// Consider it a seek if the time difference is greater than 2 seconds
// (allowing for minor discrepancies in reporting)
return Math.abs(newSeconds - oldSeconds) > 2;
};