fix missing remote event handlers (#1344)

This commit is contained in:
jeffvli
2025-12-13 20:58:12 -08:00
parent 5c8d18d1c9
commit f61d34c340
4 changed files with 345 additions and 20 deletions
+170 -5
View File
@@ -3,6 +3,8 @@ import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer'; import { immer } from 'zustand/middleware/immer';
import { createWithEqualityFn } from 'zustand/traditional'; import { createWithEqualityFn } from 'zustand/traditional';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types'; import { ClientEvent, ServerEvent, SongUpdateSocket } from '/@/shared/types/remote-types';
@@ -40,6 +42,9 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
immer((set, get) => ({ immer((set, get) => ({
actions: { actions: {
reconnect: async () => { reconnect: async () => {
logFn.debug(logMsg[LogCategory.REMOTE].reconnectInitiated, {
category: LogCategory.REMOTE,
});
const existing = get().socket; const existing = get().socket;
if (existing) { if (existing) {
@@ -47,6 +52,10 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.OPEN ||
existing.readyState === WebSocket.CONNECTING existing.readyState === WebSocket.CONNECTING
) { ) {
logFn.debug(logMsg[LogCategory.REMOTE].closingExistingSocket, {
category: LogCategory.REMOTE,
meta: { readyState: existing.readyState },
});
existing.natural = true; existing.natural = true;
existing.close(4001); existing.close(4001);
} }
@@ -55,28 +64,63 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
let authHeader: string | undefined; let authHeader: string | undefined;
try { try {
logFn.debug(logMsg[LogCategory.REMOTE].fetchingCredentials, {
category: LogCategory.REMOTE,
});
const credentials = await fetch('/credentials'); const credentials = await fetch('/credentials');
authHeader = await credentials.text(); authHeader = await credentials.text();
logFn.debug(logMsg[LogCategory.REMOTE].credentialsFetched, {
category: LogCategory.REMOTE,
meta: { hasAuthHeader: !!authHeader },
});
} catch (error) { } catch (error) {
console.error('Failed to get credentials', error); logFn.error(logMsg[LogCategory.REMOTE].failedToGetCredentials, {
category: LogCategory.REMOTE,
meta: { error },
});
} }
set((state) => { set((state) => {
const socket = new WebSocket( const wsUrl = location.href.replace('http', 'ws');
location.href.replace('http', 'ws'), logFn.debug(logMsg[LogCategory.REMOTE].creatingWebSocket, {
) as StatefulWebSocket; category: LogCategory.REMOTE,
meta: { url: wsUrl },
});
const socket = new WebSocket(wsUrl) as StatefulWebSocket;
socket.natural = false; socket.natural = false;
socket.addEventListener('message', (message) => { socket.addEventListener('message', (message) => {
const { data, event } = JSON.parse(message.data) as ServerEvent; const { data, event } = JSON.parse(message.data) as ServerEvent;
logFn.debug(logMsg[LogCategory.REMOTE].webSocketMessageReceived, {
category: LogCategory.REMOTE,
meta: { data, event },
});
switch (event) { switch (event) {
case 'error': { case 'error': {
logFn.error(
logMsg[LogCategory.REMOTE].webSocketErrorEvent,
{
category: LogCategory.REMOTE,
meta: { data },
},
);
toast.error({ message: data, title: 'Socket error' }); toast.error({ message: data, title: 'Socket error' });
break; break;
} }
case 'favorite': { case 'favorite': {
logFn.debug(
logMsg[LogCategory.REMOTE].favoriteEventReceived,
{
category: LogCategory.REMOTE,
meta: {
favorite: data.favorite,
id: data.id,
},
},
);
set((state) => { set((state) => {
if (state.info.song?.id === data.id) { if (state.info.song?.id === data.id) {
state.info.song.userFavorite = data.favorite; state.info.song.userFavorite = data.favorite;
@@ -85,18 +129,39 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'playback': { case 'playback': {
logFn.debug(
logMsg[LogCategory.REMOTE].playbackEventReceived,
{
category: LogCategory.REMOTE,
meta: { status: data },
},
);
set((state) => { set((state) => {
state.info.status = data; state.info.status = data;
}); });
break; break;
} }
case 'position': { case 'position': {
logFn.debug(
logMsg[LogCategory.REMOTE].positionEventReceived,
{
category: LogCategory.REMOTE,
meta: { position: data },
},
);
set((state) => { set((state) => {
state.info.position = data; state.info.position = data;
}); });
break; break;
} }
case 'proxy': { case 'proxy': {
logFn.debug(logMsg[LogCategory.REMOTE].proxyEventReceived, {
category: LogCategory.REMOTE,
meta: {
dataLength: data?.length,
hasData: !!data,
},
});
set((state) => { set((state) => {
if (state.info.song) { if (state.info.song) {
state.info.song.imageUrl = `data:image/jpeg;base64,${data}`; state.info.song.imageUrl = `data:image/jpeg;base64,${data}`;
@@ -105,6 +170,16 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'rating': { case 'rating': {
logFn.debug(
logMsg[LogCategory.REMOTE].ratingEventReceived,
{
category: LogCategory.REMOTE,
meta: {
id: data.id,
rating: data.rating,
},
},
);
set((state) => { set((state) => {
if (state.info.song?.id === data.id) { if (state.info.song?.id === data.id) {
state.info.song.userRating = data.rating; state.info.song.userRating = data.rating;
@@ -113,30 +188,68 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
break; break;
} }
case 'repeat': { case 'repeat': {
logFn.debug(
logMsg[LogCategory.REMOTE].repeatEventReceived,
{
category: LogCategory.REMOTE,
meta: { repeat: data },
},
);
set((state) => { set((state) => {
state.info.repeat = data; state.info.repeat = data;
}); });
break; break;
} }
case 'shuffle': { case 'shuffle': {
logFn.debug(
logMsg[LogCategory.REMOTE].shuffleEventReceived,
{
category: LogCategory.REMOTE,
meta: { shuffle: data },
},
);
set((state) => { set((state) => {
state.info.shuffle = data; state.info.shuffle = data;
}); });
break; break;
} }
case 'song': { case 'song': {
logFn.debug(logMsg[LogCategory.REMOTE].songEventReceived, {
category: LogCategory.REMOTE,
meta: {
artistName: data?.artistName,
id: data?.id,
name: data?.name,
},
});
set((state) => { set((state) => {
state.info.song = data; state.info.song = data;
}); });
break; break;
} }
case 'state': { case 'state': {
logFn.debug(logMsg[LogCategory.REMOTE].stateEventReceived, {
category: LogCategory.REMOTE,
meta: {
hasSong: !!data.song,
position: data.position,
status: data.status,
volume: data.volume,
},
});
set((state) => { set((state) => {
state.info = data; state.info = data;
}); });
break; break;
} }
case 'volume': { case 'volume': {
logFn.debug(
logMsg[LogCategory.REMOTE].volumeEventReceived,
{
category: LogCategory.REMOTE,
meta: { volume: data },
},
);
set((state) => { set((state) => {
state.info.volume = data; state.info.volume = data;
}); });
@@ -145,7 +258,17 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
socket.addEventListener('open', () => { socket.addEventListener('open', () => {
logFn.debug(logMsg[LogCategory.REMOTE].webSocketOpened, {
category: LogCategory.REMOTE,
meta: {
hasAuthHeader: !!authHeader,
readyState: socket.readyState,
},
});
if (authHeader) { if (authHeader) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingAuthentication, {
category: LogCategory.REMOTE,
});
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
event: 'authenticate', event: 'authenticate',
@@ -157,14 +280,40 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
socket.addEventListener('close', (reason) => { socket.addEventListener('close', (reason) => {
logFn.debug(logMsg[LogCategory.REMOTE].webSocketClosed, {
category: LogCategory.REMOTE,
meta: {
code: reason.code,
natural: socket.natural,
reason: reason.reason,
wasClean: reason.wasClean,
},
});
if (reason.code === 4002 || reason.code === 4003) { if (reason.code === 4002 || reason.code === 4003) {
logFn.debug(logMsg[LogCategory.REMOTE].reloadingPage, {
category: LogCategory.REMOTE,
meta: { code: reason.code },
});
location.reload(); location.reload();
} else if (reason.code === 4000) { } else if (reason.code === 4000) {
logFn.warn(logMsg[LogCategory.REMOTE].serverIsDown, {
category: LogCategory.REMOTE,
});
toast.warn({ toast.warn({
message: 'Feishin remote server is down', message: 'Feishin remote server is down',
title: 'Connection closed', title: 'Connection closed',
}); });
} else if (reason.code !== 4001 && !socket.natural) { } else if (reason.code !== 4001 && !socket.natural) {
logFn.error(
logMsg[LogCategory.REMOTE].socketClosedUnexpectedly,
{
category: LogCategory.REMOTE,
meta: {
code: reason.code,
reason: reason.reason,
},
},
);
toast.error({ toast.error({
message: 'Socket closed for unexpected reason', message: 'Socket closed for unexpected reason',
title: 'Connection closed', title: 'Connection closed',
@@ -180,7 +329,23 @@ export const useRemoteStore = createWithEqualityFn<SettingsSlice>()(
}); });
}, },
send: (data: ClientEvent) => { send: (data: ClientEvent) => {
get().socket?.send(JSON.stringify(data)); const socket = get().socket;
if (socket) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingEventToServer, {
category: LogCategory.REMOTE,
meta: {
data: data,
event: data.event,
readyState: socket.readyState,
},
});
socket.send(JSON.stringify(data));
} else {
logFn.warn(logMsg[LogCategory.REMOTE].cannotSendEvent, {
category: LogCategory.REMOTE,
meta: { event: data.event },
});
}
}, },
toggleIsDark: () => { toggleIsDark: () => {
set((state) => { set((state) => {
+130 -15
View File
@@ -1,11 +1,13 @@
import isElectron from 'is-electron'; import isElectron from 'is-electron';
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation'; import { useCreateFavorite } from '/@/renderer/features/shared/mutations/create-favorite-mutation';
import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation'; import { useDeleteFavorite } from '/@/renderer/features/shared/mutations/delete-favorite-mutation';
import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation'; import { useSetRating } from '/@/renderer/features/shared/mutations/set-rating-mutation';
import { usePlayerActions, useRemoteSettings } from '/@/renderer/store'; import { usePlayerActions, usePlayerStore, useRemoteSettings } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast'; import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types'; import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerShuffle } from '/@/shared/types/types'; import { PlayerShuffle } from '/@/shared/types/types';
@@ -14,19 +16,31 @@ const remote = isElectron() ? window.api.remote : null;
const ipc = isElectron() ? window.api.ipc : null; const ipc = isElectron() ? window.api.ipc : null;
export const useRemote = () => { export const useRemote = () => {
const { mediaSkipForward, setTimestamp, setVolume } = usePlayerActions(); const { mediaSkipForward, setVolume } = usePlayerActions();
const player = usePlayerStore();
const remoteSettings = useRemoteSettings(); const remoteSettings = useRemoteSettings();
const updateRatingMutation = useSetRating({}); const updateRatingMutation = useSetRating({});
const addToFavoritesMutation = useCreateFavorite({}); const addToFavoritesMutation = useCreateFavorite({});
const removeFromFavoritesMutation = useDeleteFavorite({}); const removeFromFavoritesMutation = useDeleteFavorite({});
const isRemoteEnabled = remoteSettings.enabled;
// Initialize the remote // Initialize the remote
useEffect(() => { useEffect(() => {
if (!remote) { if (!isRemoteEnabled) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].initializingRemoteSettings, {
category: LogCategory.REMOTE,
meta: {
enabled: remoteSettings.enabled,
port: remoteSettings.port,
username: remoteSettings.username,
},
});
remote remote
?.updateSetting( ?.updateSetting(
remoteSettings.enabled, remoteSettings.enabled,
@@ -35,6 +49,10 @@ export const useRemote = () => {
remoteSettings.password, remoteSettings.password,
) )
.catch((error) => { .catch((error) => {
logFn.error(logMsg[LogCategory.REMOTE].failedToEnableRemote, {
category: LogCategory.REMOTE,
meta: { error },
});
toast.warn({ message: error, title: 'Failed to enable remote' }); toast.warn({ message: error, title: 'Failed to enable remote' });
}); });
// We only want to fire this once // We only want to fire this once
@@ -42,21 +60,33 @@ export const useRemote = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
remote.requestPosition((_e: unknown, data: { position: number }) => { remote.requestPosition((_e: unknown, data: { position: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestPositionReceived, {
category: LogCategory.REMOTE,
meta: { position: data.position },
});
const newTime = data.position; const newTime = data.position;
setTimestamp(newTime); player.mediaSeekToTimestamp(newTime);
}); });
remote.requestSeek((_e: unknown, data: { offset: number }) => { remote.requestSeek((_e: unknown, data: { offset: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestSeekReceived, {
category: LogCategory.REMOTE,
meta: { offset: data.offset },
});
mediaSkipForward(data.offset); mediaSkipForward(data.offset);
}); });
remote.requestRating( remote.requestRating(
(_e: unknown, data: { id: string; rating: number; serverId: string }) => { (_e: unknown, data: { id: string; rating: number; serverId: string }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestRatingReceived, {
category: LogCategory.REMOTE,
meta: { id: data.id, rating: data.rating, serverId: data.serverId },
});
updateRatingMutation.mutate({ updateRatingMutation.mutate({
apiClientProps: { serverId: data.serverId }, apiClientProps: { serverId: data.serverId },
query: { query: {
@@ -69,11 +99,19 @@ export const useRemote = () => {
); );
remote.requestVolume((_e: unknown, data: { volume: number }) => { remote.requestVolume((_e: unknown, data: { volume: number }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestVolumeReceived, {
category: LogCategory.REMOTE,
meta: { volume: data.volume },
});
setVolume(data.volume); setVolume(data.volume);
}); });
remote.requestFavorite( remote.requestFavorite(
(_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => { (_e: unknown, data: { favorite: boolean; id: string; serverId: string }) => {
logFn.debug(logMsg[LogCategory.REMOTE].requestFavoriteReceived, {
category: LogCategory.REMOTE,
meta: { favorite: data.favorite, id: data.id, serverId: data.serverId },
});
const mutator = data.favorite const mutator = data.favorite
? addToFavoritesMutation ? addToFavoritesMutation
: removeFromFavoritesMutation; : removeFromFavoritesMutation;
@@ -96,63 +134,140 @@ export const useRemote = () => {
}; };
}, [ }, [
addToFavoritesMutation, addToFavoritesMutation,
isRemoteEnabled,
mediaSkipForward, mediaSkipForward,
player,
removeFromFavoritesMutation, removeFromFavoritesMutation,
setTimestamp,
setVolume, setVolume,
updateRatingMutation, updateRatingMutation,
]); ]);
// Send initial song if one is already playing
const isInitializedRef = useRef(false);
useEffect(() => {
if (isInitializedRef.current || !isRemoteEnabled || !remote) {
return;
}
isInitializedRef.current = true;
const currentSong = player.getCurrentSong();
if (currentSong) {
logFn.debug(logMsg[LogCategory.REMOTE].sendingInitialSong, {
category: LogCategory.REMOTE,
meta: {
artistName: currentSong.artistName,
id: currentSong.id,
name: currentSong.name,
},
});
remote.updateSong(currentSong);
}
}, [isRemoteEnabled, player]);
usePlayerEvents( usePlayerEvents(
{ {
onPlayerProgress: (properties) => { onCurrentSongChange: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateSongSent, {
category: LogCategory.REMOTE,
meta: {
artistName: properties.song?.artistName,
id: properties.song?.id,
index: properties.index,
name: properties.song?.name,
},
});
remote.updateSong(properties.song);
},
onPlayerProgress: (properties) => {
if (!isRemoteEnabled || !remote) {
return;
}
logFn.debug(logMsg[LogCategory.REMOTE].updatePositionSent, {
category: LogCategory.REMOTE,
meta: { timestamp: properties.timestamp },
});
remote.updatePosition(properties.timestamp); remote.updatePosition(properties.timestamp);
}, },
onPlayerRepeat: (properties) => { onPlayerRepeat: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateRepeatSent, {
category: LogCategory.REMOTE,
meta: { repeat: properties.repeat },
});
remote.updateRepeat(properties.repeat); remote.updateRepeat(properties.repeat);
}, },
onPlayerShuffle: (properties) => { onPlayerShuffle: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE; const isShuffleEnabled = properties.shuffle !== PlayerShuffle.NONE;
logFn.debug(logMsg[LogCategory.REMOTE].updateShuffleSent, {
category: LogCategory.REMOTE,
meta: { isShuffleEnabled, shuffle: properties.shuffle },
});
remote.updateShuffle(isShuffleEnabled); remote.updateShuffle(isShuffleEnabled);
}, },
onPlayerStatus: (properties) => { onPlayerStatus: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updatePlaybackSent, {
category: LogCategory.REMOTE,
meta: { status: properties.status },
});
remote.updatePlayback(properties.status); remote.updatePlayback(properties.status);
}, },
onPlayerVolume: (properties) => { onPlayerVolume: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateVolumeSent, {
category: LogCategory.REMOTE,
meta: { volume: properties.volume },
});
remote.updateVolume(properties.volume); remote.updateVolume(properties.volume);
}, },
onUserFavorite: (properties) => { onUserFavorite: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateFavoriteSent, {
category: LogCategory.REMOTE,
meta: {
favorite: properties.favorite,
id: properties.id,
serverId: properties.serverId,
},
});
remote.updateFavorite(properties.favorite, properties.serverId, properties.id); remote.updateFavorite(properties.favorite, properties.serverId, properties.id);
}, },
onUserRating: (properties) => { onUserRating: (properties) => {
if (!remote) { if (!isRemoteEnabled || !remote) {
return; return;
} }
logFn.debug(logMsg[LogCategory.REMOTE].updateRatingSent, {
category: LogCategory.REMOTE,
meta: {
id: properties.id,
rating: properties.rating || 0,
serverId: properties.serverId,
},
});
remote.updateRating(properties.rating || 0, properties.serverId, properties.id); remote.updateRating(properties.rating || 0, properties.serverId, properties.id);
}, },
}, },
+44
View File
@@ -60,6 +60,50 @@ export const logMsg = {
toggleRepeat: 'Toggle repeat', toggleRepeat: 'Toggle repeat',
toggleShuffle: 'Toggle shuffle', toggleShuffle: 'Toggle shuffle',
}, },
[LogCategory.REMOTE]: {
cannotSendEvent: 'Cannot send event - socket not available',
closingExistingSocket: 'Closing existing socket',
creatingWebSocket: 'Creating new WebSocket',
credentialsFetched: 'Credentials fetched',
failedToEnableRemote: 'Failed to enable remote',
failedToGetCredentials: 'Failed to get credentials',
favoriteEventReceived: 'Favorite event received',
fetchingCredentials: 'Fetching credentials',
initializingRemoteSettings: 'Initializing remote settings',
playbackEventReceived: 'Playback event received',
positionEventReceived: 'Position event received',
proxyEventReceived: 'Proxy event received (image update)',
ratingEventReceived: 'Rating event received',
reconnectInitiated: 'Reconnect initiated',
reloadingPage: 'Reloading page due to close code',
repeatEventReceived: 'Repeat event received',
requestFavoriteReceived: 'Request favorite received',
requestPositionReceived: 'Request position received',
requestRatingReceived: 'Request rating received',
requestSeekReceived: 'Request seek received',
requestVolumeReceived: 'Request volume received',
sendingAuthentication: 'Sending authentication',
sendingEventToServer: 'Sending event to server',
sendingInitialSong: 'Sending initial song',
serverIsDown: 'Server is down',
shuffleEventReceived: 'Shuffle event received',
socketClosedUnexpectedly: 'Socket closed unexpectedly',
songEventReceived: 'Song event received',
stateEventReceived: 'State event received (full state update)',
updateFavoriteSent: 'Update favorite sent',
updatePlaybackSent: 'Update playback sent',
updatePositionSent: 'Update position sent',
updateRatingSent: 'Update rating sent',
updateRepeatSent: 'Update repeat sent',
updateShuffleSent: 'Update shuffle sent',
updateSongSent: 'Update song sent',
updateVolumeSent: 'Update volume sent',
volumeEventReceived: 'Volume event received',
webSocketClosed: 'WebSocket closed',
webSocketErrorEvent: 'WebSocket error event',
webSocketMessageReceived: 'WebSocket message received',
webSocketOpened: 'WebSocket opened',
},
[LogCategory.SCROBBLE]: { [LogCategory.SCROBBLE]: {
scrobbledPause: 'Scrobbled a pause event', scrobbledPause: 'Scrobbled a pause event',
scrobbledStart: 'Scrobbled a start event', scrobbledStart: 'Scrobbled a start event',
+1
View File
@@ -7,6 +7,7 @@ export enum LogCategory {
GENERAL = 'general', GENERAL = 'general',
OTHER = 'other', OTHER = 'other',
PLAYER = 'player', PLAYER = 'player',
REMOTE = 'remote',
SCROBBLE = 'scrobble', SCROBBLE = 'scrobble',
SYSTEM = 'system', SYSTEM = 'system',
} }