clean up discord rpc implementation with usePlayerEvents

This commit is contained in:
jeffvli
2026-05-28 01:50:29 -07:00
parent 1f5907716f
commit 8acd585630
@@ -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<null | string | undefined>(imageUrl);
const previousEnabledRef = useRef<boolean>(discordSettings.enabled);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const previousActivityStateRef = useRef<ActivityState | null>(null);
const discordEnabledRef = useRef<boolean>(discordSettings.enabled);
const privateModeRef = useRef<boolean>(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 = () => {