mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-11 18:41: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 } from 'electron';
|
||||||
import { app, dialog } from 'electron';
|
|
||||||
import { Client as DiscordClient } from '@xhayper/discord-rpc';
|
|
||||||
import { dev } from 'electron-is';
|
|
||||||
|
|
||||||
import { ActivityType, GatewayActivityButton } from 'discord-api-types/v10';
|
import registerCallback, { SongInfoEvent } from '@/providers/song-info';
|
||||||
|
import { createBackend } from '@/utils';
|
||||||
|
|
||||||
import registerCallback, {
|
import { DiscordService } from './discord-service';
|
||||||
type SongInfo,
|
import { TIME_UPDATE_DEBOUNCE_MS } from './constants';
|
||||||
SongInfoEvent,
|
|
||||||
} from '@/providers/song-info';
|
|
||||||
import { createBackend, LoggerPrefix } from '@/utils';
|
|
||||||
import { t } from '@/i18n';
|
|
||||||
|
|
||||||
import type { SetActivity } from '@xhayper/discord-rpc/dist/structures/ClientUser';
|
|
||||||
import type { DiscordPluginConfig } from './index';
|
import type { DiscordPluginConfig } from './index';
|
||||||
|
|
||||||
// Application ID registered by @th-ch/youtube-music dev team
|
export let discordService = null as DiscordService | null;
|
||||||
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 const backend = createBackend<
|
export const backend = createBackend<
|
||||||
{
|
{
|
||||||
config?: DiscordPluginConfig;
|
config?: DiscordPluginConfig;
|
||||||
updateActivity: (songInfo: SongInfo, config: DiscordPluginConfig) => void;
|
lastTimeUpdateSent: number;
|
||||||
},
|
},
|
||||||
DiscordPluginConfig
|
DiscordPluginConfig
|
||||||
>({
|
>({
|
||||||
updateActivity: (songInfo, config) =>
|
lastTimeUpdateSent: 0,
|
||||||
updateDiscordRichPresence(songInfo, config),
|
|
||||||
|
|
||||||
async start(ctx) {
|
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 (config.enabled) {
|
||||||
if (dev()) {
|
ctx.window.once('ready-to-show', () => {
|
||||||
console.log(LoggerPrefix, t('plugins.discord.backend.connected'));
|
discordService?.connect(!config.autoReconnect);
|
||||||
}
|
|
||||||
|
|
||||||
for (const cb of refreshCallbacks) {
|
registerCallback((songInfo, event) => {
|
||||||
cb();
|
if (!discordService?.isConnected()) return;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
discordState.rpc.on('ready', () => {
|
if (event !== SongInfoEvent.TimeChanged) {
|
||||||
discordState.ready = true;
|
discordService?.updateActivity(songInfo);
|
||||||
if (discordState.lastSongInfo && this.config) {
|
this.lastTimeUpdateSent = Date.now();
|
||||||
this.updateActivity(discordState.lastSongInfo, this.config);
|
} else {
|
||||||
}
|
const now = Date.now();
|
||||||
});
|
if (now - this.lastTimeUpdateSent > TIME_UPDATE_DEBOUNCE_MS) {
|
||||||
|
discordService?.updateActivity(songInfo);
|
||||||
discordState.rpc.on('disconnected', () => {
|
this.lastTimeUpdateSent = now; // Record the time of this debounced update
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
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() {
|
stop() {
|
||||||
resetInfo();
|
discordService?.cleanup();
|
||||||
},
|
},
|
||||||
|
|
||||||
onConfigChange(newConfig) {
|
onConfigChange(newConfig) {
|
||||||
this.config = newConfig;
|
discordService?.onConfigChange(newConfig);
|
||||||
discordState.autoReconnect = newConfig.autoReconnect;
|
|
||||||
if (discordState.lastSongInfo) {
|
const currentlyConnected = discordService?.isConnected() ?? false;
|
||||||
this.updateActivity(discordState.lastSongInfo, newConfig);
|
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 prompt from 'custom-electron-prompt';
|
||||||
|
|
||||||
import { clear, connect, isConnected, registerRefresh } from './main';
|
import { discordService } from './main';
|
||||||
|
|
||||||
import { singleton } from '@/providers/decorators';
|
import { singleton } from '@/providers/decorators';
|
||||||
import promptOptions from '@/providers/prompt-options';
|
import promptOptions from '@/providers/prompt-options';
|
||||||
@ -14,7 +14,7 @@ import type { DiscordPluginConfig } from './index';
|
|||||||
import type { MenuTemplate } from '@/menu';
|
import type { MenuTemplate } from '@/menu';
|
||||||
|
|
||||||
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
const registerRefreshOnce = singleton((refreshMenu: () => void) => {
|
||||||
registerRefresh(refreshMenu);
|
discordService?.registerRefreshCallback(refreshMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const onMenu = async ({
|
export const onMenu = async ({
|
||||||
@ -28,11 +28,11 @@ export const onMenu = async ({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
label: isConnected()
|
label: discordService?.isConnected()
|
||||||
? t('plugins.discord.menu.connected')
|
? t('plugins.discord.menu.connected')
|
||||||
: t('plugins.discord.menu.disconnected'),
|
: t('plugins.discord.menu.disconnected'),
|
||||||
enabled: !isConnected(),
|
enabled: !discordService?.isConnected(),
|
||||||
click: () => connect(),
|
click: () => discordService?.connect(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('plugins.discord.menu.auto-reconnect'),
|
label: t('plugins.discord.menu.auto-reconnect'),
|
||||||
@ -46,7 +46,7 @@ export const onMenu = async ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('plugins.discord.menu.clear-activity'),
|
label: t('plugins.discord.menu.clear-activity'),
|
||||||
click: clear,
|
click: () => discordService?.clearActivity(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t('plugins.discord.menu.clear-activity-after-timeout'),
|
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