mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-19 22:22:06 +00:00
feat: refactor
This commit is contained in:
406
src/plugins/discord/discord-service.ts
Normal file
406
src/plugins/discord/discord-service.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user