mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-10 18:21:47 +00:00
feat: refactor
This commit is contained in:
25
src/plugins/discord/constants.ts
Normal file
25
src/plugins/discord/constants.ts
Normal 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
|
||||
}
|
||||
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 = [];
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -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'),
|
||||
|
||||
41
src/plugins/discord/timer-manager.ts
Normal file
41
src/plugins/discord/timer-manager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
70
src/plugins/discord/utils.ts
Normal file
70
src/plugins/discord/utils.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user