feat: sync play queue for navidrome/subsonic (#1335)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner
2025-12-13 05:05:00 +00:00
committed by GitHub
parent 13afd3d9c4
commit ed5d590a6b
31 changed files with 648 additions and 107 deletions
@@ -14,7 +14,7 @@ import {
subscribePlayerStatus,
subscribePlayerVolume,
} from '/@/renderer/store';
import { LibraryItem, QueueData, QueueSong } from '/@/shared/types/domain-types';
import { LibraryItem, QueueData, QueueSong, Song } from '/@/shared/types/domain-types';
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
interface PlayerEvents {
@@ -46,6 +46,7 @@ interface PlayerEventsCallbacks {
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;
onUserFavorite?: (properties: {
favorite: boolean;
id: string[];
@@ -152,6 +153,10 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
eventEmitter.on('PLAYER_PLAY', callbacks.onPlayerPlay);
}
if (callbacks.onQueueRestored) {
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
}
if (callbacks.onUserFavorite) {
eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite);
}
@@ -172,6 +177,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
if (callbacks.onPlayerPlay) {
eventEmitter.off('PLAYER_PLAY', callbacks.onPlayerPlay);
}
if (callbacks.onQueueRestored) {
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
}
if (callbacks.onUserFavorite) {
eventEmitter.off('USER_FAVORITE', callbacks.onUserFavorite);
}
@@ -12,6 +12,7 @@ import { useMediaSession } from '/@/renderer/features/player/hooks/use-media-ses
import { useMPRIS } from '/@/renderer/features/player/hooks/use-mpris';
import { usePlaybackHotkeys } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker';
import { useQueueRestoreTimestamp } from '/@/renderer/features/player/hooks/use-queue-restore';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import {
@@ -46,6 +47,7 @@ export const AudioPlayers = () => {
useMediaSession();
usePlaybackHotkeys();
useAutoDJ();
useQueueRestoreTimestamp();
useEffect(() => {
if (webAudio && 'AudioContext' in window) {
@@ -37,7 +37,7 @@ import { useThrottledCallback } from '/@/shared/hooks/use-throttled-callback';
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
let volumeToSet: number;
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
if (newVolumeGreaterThanHundred) {
volumeToSet = 100;
@@ -49,7 +49,7 @@ const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
};
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
let volumeToSet;
let volumeToSet: number;
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
if (newVolumeLessThanZero) {
volumeToSet = 0;
@@ -80,6 +80,7 @@ export interface PlayerContext {
itemType: LibraryItem,
isFavorite: boolean,
) => void;
setQueue: (data: Song[], index?: number, position?: number) => void;
setRating: (serverId: string, id: string[], itemType: LibraryItem, rating: number) => void;
setRepeat: (repeat: PlayerRepeat) => void;
setShuffle: (shuffle: PlayerShuffle) => void;
@@ -116,6 +117,7 @@ export const PlayerContext = createContext<PlayerContext>({
moveSelectedToNext: () => {},
moveSelectedToTop: () => {},
setFavorite: () => {},
setQueue: () => {},
setRating: () => {},
setRepeat: () => {},
setShuffle: () => {},
@@ -642,6 +644,22 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
storeActions.mediaSkipForward();
}, [storeActions]);
const setQueue = useCallback(
(data: Song[], index?: number, position?: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].setQueue, {
category: LogCategory.PLAYER,
meta: {
data: data.length,
index,
position,
},
});
storeActions.setQueue(data, index, position);
},
[storeActions],
);
const setSpeed = useCallback(
(speed: number) => {
logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, {
@@ -855,6 +873,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
moveSelectedToNext,
moveSelectedToTop,
setFavorite,
setQueue,
setRating,
setRepeat,
setShuffle,
@@ -873,7 +892,6 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
clearQueue,
clearSelected,
decreaseVolume,
setSpeed,
increaseVolume,
mediaNext,
mediaPause,
@@ -891,9 +909,11 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
moveSelectedToNext,
moveSelectedToTop,
setFavorite,
setQueue,
setRating,
setRepeat,
setShuffle,
setSpeed,
setVolume,
shuffle,
shuffleAll,
@@ -0,0 +1,132 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { useCallback } from 'react';
import { api } from '/@/renderer/api';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import {
setTimestamp,
useCurrentServerId,
usePlayerStore,
useTimestampStoreBase,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
export const useQueueRestoreTimestamp = () => {
const player = usePlayerStore();
usePlayerEvents(
{
onQueueRestored: (properties) => {
const { position } = properties;
setTimeout(() => {
setTimestamp(position);
player.mediaSeekToTimestamp(position);
}, 100);
},
},
[],
);
};
export const useSaveQueue = () => {
const serverId = useCurrentServerId();
const mutation = useMutation({
mutationFn: async () => {
if (!serverId) {
throw new Error(t('error.serverRequired', { postProcess: 'sentenceCase' }));
}
const { player, queue } = usePlayerStore.getState();
let uniqueIds: string[] = [];
if (queue.shuffled.length > 0) {
for (const shuffledIndex of queue.shuffled) {
uniqueIds.push(queue.default[shuffledIndex]);
}
} else {
uniqueIds = queue.default;
}
const songs: string[] = [];
if (uniqueIds.length > 0) {
for (const song of uniqueIds) {
if (queue.songs[song]._serverId !== serverId) {
toast.error({
message: t('error.multipleServerSaveQueueError', {
postProcess: 'sentenceCase',
}),
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
throw new Error(
`${t('error.multipleServerSaveQueueError', { postProcess: 'sentenceCase' })}`,
);
}
songs?.push(queue.songs[song].id);
}
}
try {
await api.controller.savePlayQueue({
apiClientProps: { serverId },
query: {
currentIndex: queue.default.length > 0 ? player.index : undefined,
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
songs,
},
});
toast.success({
message: '',
title: t('form.saveQueue.success', { postProcess: 'sentenceCase' }),
});
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.saveQueueFailed', { postProcess: 'sentenceCase' }),
});
throw error;
}
},
});
return mutation;
};
export const useRestoreQueue = () => {
const serverId = useCurrentServerId();
const player = usePlayer();
const queryClient = useQueryClient();
const handleRestoreQueue = useCallback(async () => {
if (!serverId) return;
try {
const queue = await queryClient.fetchQuery(
songsQueries.getQueue({ query: {}, serverId }),
);
if (queue) {
player.setQueue(
queue.entry,
queue.currentIndex,
queue.positionMs !== undefined ? queue.positionMs / 1000 : undefined,
);
}
} catch (error) {
toast.error({
message: (error as Error).message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
}
}, [player, queryClient, serverId]);
return handleRestoreQueue;
};