mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-08 21:10:12 +02:00
feat: sync play queue for navidrome/subsonic (#1335)
--------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -21,7 +21,6 @@ export const DrawerPlayQueue = () => {
|
||||
<PlayQueueListControls
|
||||
handleSearch={setSearch}
|
||||
searchTerm={search}
|
||||
tableRef={queueRef}
|
||||
type={ItemListKey.SIDE_QUEUE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,77 +1,31 @@
|
||||
import { RefObject } from 'react';
|
||||
import { t } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
||||
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
||||
import { usePlayerSong, usePlayerStoreBase } from '/@/renderer/store';
|
||||
import { useCurrentServer } from '/@/renderer/store';
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { QueueSong } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
interface PlayQueueListOptionsProps {
|
||||
handleSearch: (value: string) => void;
|
||||
searchTerm?: string;
|
||||
tableRef: RefObject<ItemListHandle | null>;
|
||||
type: ItemListKey;
|
||||
}
|
||||
|
||||
export const PlayQueueListControls = ({
|
||||
handleSearch,
|
||||
searchTerm,
|
||||
tableRef,
|
||||
type,
|
||||
}: PlayQueueListOptionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const currentSong = usePlayerSong();
|
||||
|
||||
const handleMoveToNext = () => {
|
||||
const selectedItems = tableRef?.current?.internalState.getSelected() as
|
||||
| QueueSong[]
|
||||
| undefined;
|
||||
if (!selectedItems || selectedItems.length === 0) return;
|
||||
player.moveSelectedToNext(selectedItems);
|
||||
};
|
||||
|
||||
const handleMoveToBottom = () => {
|
||||
const selectedItems = tableRef?.current?.internalState.getSelected() as
|
||||
| QueueSong[]
|
||||
| undefined;
|
||||
if (!selectedItems || selectedItems.length === 0) return;
|
||||
player.moveSelectedToBottom(selectedItems);
|
||||
};
|
||||
|
||||
const handleMoveToTop = () => {
|
||||
const selectedItems = tableRef?.current?.internalState.getSelected() as
|
||||
| QueueSong[]
|
||||
| undefined;
|
||||
if (!selectedItems || selectedItems.length === 0) return;
|
||||
player.moveSelectedToTop(selectedItems);
|
||||
};
|
||||
|
||||
const handleRemoveSelected = () => {
|
||||
const selectedItems = tableRef?.current?.internalState.getSelected() as
|
||||
| QueueSong[]
|
||||
| undefined;
|
||||
if (!selectedItems || selectedItems.length === 0) return;
|
||||
|
||||
const selectedUniqueIds = selectedItems.map((item) => item._uniqueId);
|
||||
const isCurrentSongRemoved =
|
||||
currentSong && selectedUniqueIds.includes(currentSong._uniqueId);
|
||||
|
||||
player.clearSelected(selectedItems);
|
||||
|
||||
if (isCurrentSongRemoved) {
|
||||
// Get the new current song after removal
|
||||
const newCurrentSong = usePlayerStoreBase.getState().getCurrentSong();
|
||||
updateSong(newCurrentSong);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearQueue = () => {
|
||||
player.clearQueue();
|
||||
@@ -84,6 +38,7 @@ export const PlayQueueListControls = ({
|
||||
return (
|
||||
<Group justify="space-between" px="1rem" py="1rem" w="100%">
|
||||
<Group gap="xs">
|
||||
<QueueRestoreActions />
|
||||
<ActionIcon
|
||||
icon="mediaShuffle"
|
||||
iconProps={{ size: 'lg' }}
|
||||
@@ -91,39 +46,6 @@ export const PlayQueueListControls = ({
|
||||
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
// disabled={hasSearch}
|
||||
icon="mediaPlayNext"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToNext}
|
||||
tooltip={{ label: t('action.moveToNext', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
// disabled={hasSearch}
|
||||
icon="arrowDownToLine"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToBottom}
|
||||
tooltip={{ label: t('action.moveToBottom', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
// disabled={hasSearch}
|
||||
icon="arrowUpToLine"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleMoveToTop}
|
||||
tooltip={{ label: t('action.moveToTop', { postProcess: 'sentenceCase' }) }}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="delete"
|
||||
iconProps={{ size: 'lg' }}
|
||||
onClick={handleRemoveSelected}
|
||||
tooltip={{
|
||||
label: t('action.removeFromQueue', { postProcess: 'sentenceCase' }),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
icon="x"
|
||||
iconProps={{ size: 'lg' }}
|
||||
@@ -158,3 +80,49 @@ export const PlayQueueListControls = ({
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueRestoreActions = () => {
|
||||
const server = useCurrentServer();
|
||||
const supportsQueue = hasFeature(server, ServerFeature.SERVER_PLAY_QUEUE);
|
||||
|
||||
const isFetching = useIsPlayerFetching();
|
||||
|
||||
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
|
||||
|
||||
const handleRestoreQueue = useRestoreQueue();
|
||||
|
||||
if (!supportsQueue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionIcon
|
||||
disabled={isFetching}
|
||||
icon="upload"
|
||||
iconProps={{ size: 'lg' }}
|
||||
loading={isSavingQueue}
|
||||
onClick={() => handleSaveQueue()}
|
||||
tooltip={{
|
||||
label: t('player.saveQueueToServer', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
<ActionIcon
|
||||
disabled={isSavingQueue}
|
||||
icon="download"
|
||||
iconProps={{ size: 'lg' }}
|
||||
loading={isFetching}
|
||||
onClick={handleRestoreQueue}
|
||||
tooltip={{
|
||||
label: t('player.restoreQueueFromServer', {
|
||||
postProcess: 'sentenceCase',
|
||||
}),
|
||||
}}
|
||||
variant="subtle"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,7 +45,6 @@ export const PopoverPlayQueue = () => {
|
||||
<PlayQueueListControls
|
||||
handleSearch={setSearch}
|
||||
searchTerm={search}
|
||||
tableRef={queueRef}
|
||||
type={ItemListKey.SIDE_QUEUE}
|
||||
/>
|
||||
<PlayQueue
|
||||
|
||||
@@ -29,7 +29,6 @@ export const SidebarPlayQueue = () => {
|
||||
<PlayQueueListControls
|
||||
handleSearch={setSearch}
|
||||
searchTerm={search}
|
||||
tableRef={tableRef}
|
||||
type={ItemListKey.SIDE_QUEUE}
|
||||
/>
|
||||
<Flex direction="column" style={{ flex: 1, minHeight: 0 }}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
||||
import { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';
|
||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
|
||||
@@ -10,7 +9,6 @@ import { useAppStoreActions } from '/@/renderer/store';
|
||||
import { ItemListKey } from '/@/shared/types/types';
|
||||
|
||||
const NowPlayingRoute = () => {
|
||||
const queueRef = useRef<ItemListHandle | null>(null);
|
||||
const [search, setSearch] = useState<string | undefined>(undefined);
|
||||
const { setSideBar } = useAppStoreActions();
|
||||
|
||||
@@ -30,7 +28,6 @@ const NowPlayingRoute = () => {
|
||||
<PlayQueueListControls
|
||||
handleSearch={setSearch}
|
||||
searchTerm={search}
|
||||
tableRef={queueRef}
|
||||
type={ItemListKey.QUEUE_SONG}
|
||||
/>
|
||||
<PlayQueue listKey={ItemListKey.QUEUE_SONG} searchTerm={search} />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { controller } from '/@/renderer/api/controller';
|
||||
import { queryKeys } from '/@/renderer/api/query-keys';
|
||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||
import {
|
||||
GetQueueQuery,
|
||||
ListCountQuery,
|
||||
RandomSongListQuery,
|
||||
SimilarSongsQuery,
|
||||
@@ -12,6 +13,16 @@ import {
|
||||
} from '/@/shared/types/domain-types';
|
||||
|
||||
export const songsQueries = {
|
||||
getQueue: (args: QueryHookArgs<GetQueueQuery>) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
return api.controller.getPlayQueue({
|
||||
apiClientProps: { serverId: args.serverId, signal },
|
||||
});
|
||||
},
|
||||
queryKey: queryKeys.player.fetch({ type: 'queue' }),
|
||||
});
|
||||
},
|
||||
list: (args: QueryHookArgs<SongListQuery>, imageSize?: number) => {
|
||||
return queryOptions({
|
||||
queryFn: ({ signal }) => {
|
||||
|
||||
Reference in New Issue
Block a user