diff --git a/src/plugins/discord/constants.ts b/src/plugins/discord/constants.ts new file mode 100644 index 00000000..c2abcc02 --- /dev/null +++ b/src/plugins/discord/constants.ts @@ -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 +} diff --git a/src/plugins/discord/discord-service.ts b/src/plugins/discord/discord-service.ts new file mode 100644 index 00000000..9b648a48 --- /dev/null +++ b/src/plugins/discord/discord-service.ts @@ -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 { + 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 = []; + } +} diff --git a/src/plugins/discord/main.ts b/src/plugins/discord/main.ts index 44c2f120..dbd523af 100644 --- a/src/plugins/discord/main.ts +++ b/src/plugins/discord/main.ts @@ -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(), - 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(); } }, }); diff --git a/src/plugins/discord/menu.ts b/src/plugins/discord/menu.ts index 47d5848d..25a34fe3 100644 --- a/src/plugins/discord/menu.ts +++ b/src/plugins/discord/menu.ts @@ -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'), diff --git a/src/plugins/discord/timer-manager.ts b/src/plugins/discord/timer-manager.ts new file mode 100644 index 00000000..12581799 --- /dev/null +++ b/src/plugins/discord/timer-manager.ts @@ -0,0 +1,41 @@ +import { TimerKey } from './constants'; + +/** + * Manages NodeJS Timers, ensuring only one timer exists per key. + */ +export class TimerManager { + timers = new Map(); + + /** + * 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(); + } +} diff --git a/src/plugins/discord/utils.ts b/src/plugins/discord/utils.ts new file mode 100644 index 00000000..2d911ebb --- /dev/null +++ b/src/plugins/discord/utils.ts @@ -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; +};