From 8acd5856303ee9b1d0711220d9bf2e9273072e28 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Thu, 28 May 2026 01:50:29 -0700 Subject: [PATCH] clean up discord rpc implementation with usePlayerEvents --- .../features/discord-rpc/use-discord-rpc.ts | 546 +++++++++--------- 1 file changed, 265 insertions(+), 281 deletions(-) diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index 1cd277f0c..ba7af726a 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -5,6 +5,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { api } from '/@/renderer/api'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; +import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { useIsRadioActive, useRadioPlayer, @@ -36,6 +37,7 @@ const DiscordStatusDisplayType = { } as const; type ActivityState = [QueueSong | undefined, number, PlayerStatus]; +type ActivityTrigger = 'initial' | 'interval' | 'seek' | 'status_change' | 'track_change'; const MAX_FIELD_LENGTH = 127; const MAX_URL_LENGTH = 256; @@ -64,22 +66,24 @@ export const useDiscordRpc = () => { const imageUrlRef = useRef(imageUrl); const previousEnabledRef = useRef(discordSettings.enabled); const intervalRef = useRef(null); - const previousActivityStateRef = useRef(null); + const discordEnabledRef = useRef(discordSettings.enabled); + const privateModeRef = useRef(privateMode); - // Update imageUrl ref when it changes useEffect(() => { imageUrlRef.current = imageUrl; }, [imageUrl]); + useEffect(() => { + discordEnabledRef.current = discordSettings.enabled; + }, [discordSettings.enabled]); + + useEffect(() => { + privateModeRef.current = privateMode; + }, [privateMode]); + const setActivity = useCallback( - async (current: ActivityState, previous: ActivityState) => { - // Check if track changed by comparing with previous state + async (current: ActivityState, trigger: ActivityTrigger) => { const song = current[0]; - const previousSong = previous[0]; - const trackChangedByState = - song && previousSong - ? song._uniqueId !== previousSong._uniqueId - : song !== previousSong; const trackChanged = song ? lastUniqueId !== song._uniqueId : false; const isPlayingRadio = isRadioActive && isRadioPlaying; @@ -103,6 +107,7 @@ export const useDiscordRpc = () => { meta: { reason, status: current[2], + trigger, }, }); return discordRpc?.clearActivity(); @@ -152,6 +157,7 @@ export const useDiscordRpc = () => { showAsListening: discordSettings.showAsListening, stationName: stationName || 'Radio', title, + trigger, }, }); discordRpc?.setActivity(activity); @@ -162,214 +168,177 @@ export const useDiscordRpc = () => { return; } - /* - 1. If the song has just started, update status - 2. If we jump more then 1.2 seconds from last state, update status to match - 3. If the current song id is completely different, update status - 4. If the player state changed, update status - */ + if (trackChanged) { + logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, { + category: LogCategory.EXTERNAL, + meta: { + artistName: song.artists?.[0]?.name, + songId: song._uniqueId, + songName: song.name, + }, + }); + setlastUniqueId(song._uniqueId); + } + + const reason = trigger; + const start = Math.round(Date.now() - current[1] * 1000); + const end = Math.round(start + song.duration); + + const artists = song?.artists.map((artist) => artist.name).join(', '); + + const statusDisplayMap = { + [DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE, + [DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME, + [DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS, + }; + + const activity: SetActivity = { + details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'), + instance: false, + largeImageKey: undefined, + largeImageText: truncate( + (song?.album && song.album.padEnd(2, ' ')) || 'Unknown album', + ), + smallImageKey: undefined, + smallImageText: undefined, + state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'), + statusDisplayType: statusDisplayMap[discordSettings.displayType], + // I would love to use the actual type as opposed to hardcoding to 2, + // but manually installing the discord-types package appears to break things + type: discordSettings.showAsListening ? 2 : 0, + }; + if ( - previous[1] === 0 || - Math.abs(current[1] - previous[1]) > 1.2 || - trackChangedByState || - trackChanged || - current[2] !== previous[2] + (discordSettings.linkType == DiscordLinkType.LAST_FM || + discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) && + song?.artistName ) { - if (trackChangedByState || trackChanged) { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcTrackChanged, { - category: LogCategory.EXTERNAL, - meta: { - artistName: song.artists?.[0]?.name, - songId: song._uniqueId, - songName: song.name, - }, - }); - setlastUniqueId(song._uniqueId); + activity.stateUrl = + 'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name); + + const detailsUrl = + 'https://www.last.fm/music/' + + encodeURIComponent(song.albumArtists[0].name) + + '/' + + encodeURIComponent(song.album || '_') + + '/' + + encodeURIComponent(song.name); + + // The details URL has a max length, only set it if it doesn't exceed it + if (detailsUrl.length <= MAX_URL_LENGTH) { + activity.detailsUrl = detailsUrl; + } + } + + if ( + discordSettings.linkType == DiscordLinkType.MBZ || + discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM + ) { + if (song?.mbzTrackId) { + activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId; + } else if (song?.mbzRecordingId) { + activity.detailsUrl = + 'https://musicbrainz.org/recording/' + song.mbzRecordingId; + } + } + + if (current[2] === PlayerStatus.PLAYING) { + if (start && end) { + activity.startTimestamp = start; + activity.endTimestamp = end; } - let reason: string; - if (trackChangedByState || trackChanged) { - reason = 'track_changed'; - } else if (previous[1] === 0) { - reason = 'song_started'; - } else if (Math.abs(current[1] - previous[1]) > 1.2) { - reason = 'time_jump'; - } else { - reason = 'player_state_changed'; - } - - const start = Math.round(Date.now() - current[1] * 1000); - const end = Math.round(start + song.duration); - - const artists = song?.artists.map((artist) => artist.name).join(', '); - - const statusDisplayMap = { - [DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE, - [DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME, - [DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS, - }; - - const activity: SetActivity = { - details: truncate((song?.name && song.name.padEnd(2, ' ')) || 'Idle'), - instance: false, - largeImageKey: undefined, - largeImageText: truncate( - (song?.album && song.album.padEnd(2, ' ')) || 'Unknown album', - ), - smallImageKey: undefined, - smallImageText: undefined, - state: truncate((artists && artists.padEnd(2, ' ')) || 'Unknown artist'), - statusDisplayType: statusDisplayMap[discordSettings.displayType], - // I would love to use the actual type as opposed to hardcoding to 2, - // but manually installing the discord-types package appears to break things - type: discordSettings.showAsListening ? 2 : 0, - }; - - if ( - (discordSettings.linkType == DiscordLinkType.LAST_FM || - discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM) && - song?.artistName - ) { - activity.stateUrl = - 'https://www.last.fm/music/' + encodeURIComponent(song.artists[0].name); - - const detailsUrl = - 'https://www.last.fm/music/' + - encodeURIComponent(song.albumArtists[0].name) + - '/' + - encodeURIComponent(song.album || '_') + - '/' + - encodeURIComponent(song.name); - - // The details URL has a max length, only set it if it doesn't exceed it - if (detailsUrl.length <= MAX_URL_LENGTH) { - activity.detailsUrl = detailsUrl; - } - } - - if ( - discordSettings.linkType == DiscordLinkType.MBZ || - discordSettings.linkType == DiscordLinkType.MBZ_LAST_FM - ) { - if (song?.mbzTrackId) { - activity.detailsUrl = 'https://musicbrainz.org/track/' + song.mbzTrackId; - } else if (song?.mbzRecordingId) { - activity.detailsUrl = - 'https://musicbrainz.org/recording/' + song.mbzRecordingId; - } - } - - if (current[2] === PlayerStatus.PLAYING) { - if (start && end) { - activity.startTimestamp = start; - activity.endTimestamp = end; - } - - if (discordSettings.showStateIcon) { - activity.smallImageKey = 'playing'; - activity.smallImageText = sentenceCase(current[2]); - } - } else { - activity.smallImageKey = 'paused'; + if (discordSettings.showStateIcon) { + activity.smallImageKey = 'playing'; activity.smallImageText = sentenceCase(current[2]); } + } else { + activity.smallImageKey = 'paused'; + activity.smallImageText = sentenceCase(current[2]); + } - if (discordSettings.showServerImage && song) { - if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) { - if (song._serverType === ServerType.JELLYFIN) { - activity.largeImageKey = imageUrlRef.current; - } else if ( - song._serverType === ServerType.NAVIDROME || - song._serverType === ServerType.SUBSONIC - ) { - try { - const info = await api.controller.getAlbumInfo({ - apiClientProps: { - forceRemoteUrl: true, - serverId: song._serverId, - }, - query: { id: song.albumId }, - }); + if (discordSettings.showServerImage && song) { + if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) { + if (song._serverType === ServerType.JELLYFIN) { + activity.largeImageKey = imageUrlRef.current; + } else if ( + song._serverType === ServerType.NAVIDROME || + song._serverType === ServerType.SUBSONIC + ) { + try { + const info = await api.controller.getAlbumInfo({ + apiClientProps: { + forceRemoteUrl: true, + serverId: song._serverId, + }, + query: { id: song.albumId }, + }); - if (info.imageUrl) { - activity.largeImageKey = info.imageUrl; - } - } catch { - /* empty */ + if (info.imageUrl) { + activity.largeImageKey = info.imageUrl; } + } catch { + /* empty */ } } } - - if ( - activity.largeImageKey === undefined && - lastfmApiKey && - song?.album && - song?.albumArtists.length - ) { - const albumInfo = await fetch( - `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`, - ); - - const albumInfoJson = await albumInfo.json(); - - if (albumInfoJson.album?.image?.[3]['#text']) { - activity.largeImageKey = albumInfoJson.album.image[3]['#text']; - } - } - - // Fall back to default icon if not set - if (!activity.largeImageKey) { - activity.largeImageKey = 'icon'; - } - - // Initialize if needed - const isConnected = await discordRpc?.isConnected(); - if (!isConnected) { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { - category: LogCategory.EXTERNAL, - meta: { - clientId: discordSettings.clientId, - }, - }); - - previousEnabledRef.current = true; - - await discordRpc?.initialize(discordSettings.clientId); - } - - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { - category: LogCategory.EXTERNAL, - meta: { - albumName: song.album, - artistName: song.artists?.[0]?.name, - currentStatus: current[2], - currentTime: current[1], - displayType: discordSettings.displayType, - hasLargeImage: !!activity.largeImageKey, - hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp), - previousStatus: previous[2], - previousTime: previous[1], - reason, - showAsListening: discordSettings.showAsListening, - songName: song.name, - trackChanged: trackChangedByState || trackChanged, - }, - }); - discordRpc?.setActivity(activity); - } else { - logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcUpdateSkipped, { - category: LogCategory.EXTERNAL, - meta: { - currentStatus: current[2], - currentTime: current[1], - previousStatus: previous[2], - previousTime: previous[1], - timeDiff: Math.abs(current[1] - previous[1]), - trackChanged: trackChangedByState || trackChanged, - }, - }); } + + if ( + activity.largeImageKey === undefined && + lastfmApiKey && + song?.album && + song?.albumArtists.length + ) { + const albumInfo = await fetch( + `https://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${lastfmApiKey}&artist=${encodeURIComponent(song.albumArtists[0].name)}&album=${encodeURIComponent(song.album)}&format=json`, + ); + + const albumInfoJson = await albumInfo.json(); + + if (albumInfoJson.album?.image?.[3]['#text']) { + activity.largeImageKey = albumInfoJson.album.image[3]['#text']; + } + } + + // Fall back to default icon if not set + if (!activity.largeImageKey) { + activity.largeImageKey = 'icon'; + } + + // Initialize if needed + const isConnected = await discordRpc?.isConnected(); + if (!isConnected) { + logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcInitialized, { + category: LogCategory.EXTERNAL, + meta: { + clientId: discordSettings.clientId, + }, + }); + + previousEnabledRef.current = true; + + await discordRpc?.initialize(discordSettings.clientId); + } + + logFn.debug(logMsg[LogCategory.EXTERNAL].discordRpcSetActivity, { + category: LogCategory.EXTERNAL, + meta: { + albumName: song.album, + artistName: song.artists?.[0]?.name, + currentStatus: current[2], + currentTime: current[1], + displayType: discordSettings.displayType, + hasLargeImage: !!activity.largeImageKey, + hasTimestamps: !!(activity.startTimestamp && activity.endTimestamp), + reason, + showAsListening: discordSettings.showAsListening, + songName: song.name, + trackChanged, + trigger, + }, + }); + discordRpc?.setActivity(activity); }, [ discordSettings.showAsListening, @@ -390,7 +359,7 @@ export const useDiscordRpc = () => { ], ); - const debouncedSetActivity = useDebouncedCallback(setActivity, 500); + const debouncedSetActivity = useDebouncedCallback(setActivity, 1000); // Quit Discord RPC if it was enabled and is now disabled useEffect(() => { @@ -409,95 +378,110 @@ export const useDiscordRpc = () => { } }, [discordSettings.clientId, privateMode, discordSettings.enabled]); + const getCurrentActivityState = useCallback((): ActivityState => { + const state = usePlayerStore.getState(); + return [ + state.getCurrentSong(), + useTimestampStoreBase.getState().timestamp, + state.player.status, + ]; + }, []); + + const clearRefreshInterval = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const emitActivityUpdateRef = useRef<(next: ActivityState, trigger: ActivityTrigger) => void>( + () => {}, + ); + + const resetRefreshInterval = useCallback(() => { + clearRefreshInterval(); + intervalRef.current = setInterval(() => { + const current = getCurrentActivityState(); + emitActivityUpdateRef.current(current, 'interval'); + }, 15000); + }, [clearRefreshInterval, getCurrentActivityState]); + + const emitActivityUpdate = useCallback( + (next: ActivityState, trigger: ActivityTrigger) => { + debouncedSetActivity(next, trigger); + resetRefreshInterval(); + }, + [debouncedSetActivity, resetRefreshInterval], + ); + + useEffect(() => { + emitActivityUpdateRef.current = emitActivityUpdate; + }, [emitActivityUpdate]); + useEffect(() => { if (!discordSettings.enabled || privateMode) { + clearRefreshInterval(); return; } - const getCurrentActivityState = (): ActivityState => { - const state = usePlayerStore.getState(); - const currentSong = state.getCurrentSong(); - const currentTime = useTimestampStoreBase.getState().timestamp; - const status = state.player.status; - return [currentSong, currentTime, status]; - }; - - const resetInterval = () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - intervalRef.current = setInterval(() => { - const current = getCurrentActivityState(); - const previous = previousActivityStateRef.current || current; - debouncedSetActivity(current, previous); - previousActivityStateRef.current = current; - }, 15000); - }; - - resetInterval(); - const initialState = getCurrentActivityState(); - let previousUniqueId = initialState[0]?._uniqueId || ''; - - previousActivityStateRef.current = initialState; - - // Set activity immediately when Discord RPC is enabled - debouncedSetActivity(initialState, initialState); - - const unsubSongChange = usePlayerStore.subscribe( - (state): ActivityState => { - const currentSong = state.getCurrentSong(); - const currentTime = useTimestampStoreBase.getState().timestamp; - const status = state.player.status; - - return [currentSong, currentTime, status]; - }, - (current, previous) => { - const currentUniqueId = current[0]?._uniqueId || ''; - const trackChanged = previousUniqueId !== currentUniqueId; - - if (trackChanged && current[0]) { - resetInterval(); - previousUniqueId = currentUniqueId; - } - - const activity: ActivityState = [ - current[0] as QueueSong, - current[1] as number, - current[2] as PlayerStatus, - ]; - - // Use the ref as the source of truth for previous state - const previousActivity: ActivityState = - previousActivityStateRef.current || - (previous - ? [ - previous[0] as QueueSong, - previous[1] as number, - previous[2] as PlayerStatus, - ] - : activity); - - debouncedSetActivity(activity, previousActivity); - - previousActivityStateRef.current = activity; - }, - ); + emitActivityUpdate(initialState, 'initial'); return () => { - unsubSongChange(); - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } + clearRefreshInterval(); }; }, [ - debouncedSetActivity, - discordSettings.clientId, + clearRefreshInterval, discordSettings.enabled, + emitActivityUpdate, + getCurrentActivityState, privateMode, - setActivity, ]); + + usePlayerEvents( + { + onCurrentSongChange: ({ song }) => { + if (!discordEnabledRef.current || privateModeRef.current) { + return; + } + + const playerState = usePlayerStore.getState(); + const activityState: ActivityState = [ + song, + useTimestampStoreBase.getState().timestamp, + playerState.player.status, + ]; + emitActivityUpdateRef.current(activityState, 'track_change'); + }, + onPlayerSeekToTimestamp: ({ timestamp }) => { + if (!discordEnabledRef.current || privateModeRef.current) { + return; + } + + const playerState = usePlayerStore.getState(); + const activityState: ActivityState = [ + playerState.getCurrentSong(), + timestamp, + playerState.player.status, + ]; + emitActivityUpdateRef.current(activityState, 'seek'); + }, + onPlayerStatus: ({ status }) => { + if (!discordEnabledRef.current || privateModeRef.current) { + return; + } + + const playerState = usePlayerStore.getState(); + const activityState: ActivityState = [ + playerState.getCurrentSong(), + useTimestampStoreBase.getState().timestamp, + status, + ]; + emitActivityUpdateRef.current(activityState, 'status_change'); + }, + }, + [], + ); }; const DiscordRpcHookInner = () => {