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
+7
View File
@@ -199,6 +199,7 @@
"localFontAccessDenied": "access denied to local fonts",
"loginRateError": "too many login attempts, please try again in a few seconds",
"mpvRequired": "MPV required",
"multipleServerSaveQueueError": "the play queue has one or more songs which are not from the current server. this is not supported",
"networkError": "a network error occurred",
"notificationDenied": "permissions for notifications were denied. this setting has no effect",
"openError": "could not open file",
@@ -207,6 +208,7 @@
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
"remotePortError": "an error occurred when trying to set the remote server port",
"remotePortWarning": "restart the server to apply the new port",
"saveQueueFailed": "failed to save queue",
"serverNotSelectedError": "no server selected",
"serverRequired": "server required",
"sessionExpiredError": "your session has expired",
@@ -339,6 +341,9 @@
"resetToDefault": "reset to default",
"clearFilters": "clear filters"
},
"saveQueue": {
"success": "saved play queue to server"
},
"shareItem": {
"allowDownloading": "allow downloading",
"description": "description",
@@ -586,6 +591,8 @@
"repeat_off": "repeat disabled",
"repeat_one": "repeat one",
"repeat_other": "",
"restoreQueueFromServer": "restore queue from server",
"saveQueueToServer": "save queue to server",
"shuffle": "play (shuffled)",
"shuffle_off": "shuffle disabled",
"skip": "skip",
+28
View File
@@ -426,6 +426,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getPlayQueue(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlayQueue`,
);
}
return apiController(
'getPlayQueue',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getRandomSongList(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -656,6 +670,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
savePlayQueue(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: savePlayQueue`,
);
}
return apiController(
'savePlayQueue',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
scrobble(args) {
const server = getServerById(args.apiClientProps.serverId);
+18
View File
@@ -178,6 +178,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getPlayQueue: {
method: 'GET',
path: 'sessions',
query: jfType._parameters.getQueue,
responses: {
200: jfType._response.getSessions,
400: jfType._response.error,
},
},
getServerInfo: {
method: 'GET',
path: 'system/info',
@@ -283,6 +292,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
savePlayQueue: {
body: jfType._parameters.saveQueue,
method: 'POST',
path: 'sessions/playing',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
scrobblePlaying: {
body: jfType._parameters.scrobble,
method: 'POST',
@@ -772,6 +772,9 @@ export const JellyfinController: InternalControllerEndpoint = {
totalRecordCount: res.body.TotalRecordCount,
};
},
getPlayQueue: async () => {
throw new Error('Not supported');
},
getRandomSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -1292,6 +1295,9 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async () => {
throw new Error('Not supported');
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
@@ -123,6 +123,14 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
getQueue: {
method: 'GET',
path: 'queue',
responses: {
200: resultWithHeaders(ndType._response.queue),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
@@ -177,6 +185,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
saveQueue: {
body: ndType._parameters.saveQueue,
method: 'POST',
path: 'queue',
responses: {
200: resultWithHeaders(ndType._response.saveQueue),
500: resultWithHeaders(ndType._response.error),
},
},
shareItem: {
body: ndType._parameters.shareItem,
method: 'POST',
@@ -6,7 +6,7 @@ import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
@@ -25,6 +25,9 @@ import {
import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [
// Why 2? Subsonic controller will return 1 for its own implementation
// Use 2 to denote that Navidrome's own API has a different endpoint
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
@@ -527,6 +530,32 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getPlayQueue: async (args) => {
const { apiClientProps } = args;
if (hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2)) {
const res = await ndApiClient(apiClientProps).getQueue();
if (res.status !== 200) {
throw new Error('Failed to get play queue');
}
const { changedBy, current, items, position, updatedAt } = res.body.data;
const entries = items.map((song) => ndNormalize.song(song, apiClientProps.server));
return {
changed: updatedAt,
changedBy,
currentIndex: current !== undefined ? current : 0,
entry: entries,
positionMs: position,
username: apiClientProps.server?.username ?? '',
};
}
return SubsonicController.getPlayQueue(args);
},
getRandomSongList: SubsonicController.getRandomSongList,
getRoles: async ({ apiClientProps }) =>
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
@@ -548,12 +577,18 @@ export const NavidromeController: InternalControllerEndpoint = {
const subsonicArgs = await SubsonicController.getServerInfo(args);
const features = {
...navidromeFeatures,
...subsonicArgs.features,
...navidromeFeatures,
publicPlaylist: [1],
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
};
if (subsonicArgs.features.serverPlayQueue && navidromeFeatures.serverPlayQueue) {
features.serverPlayQueue = navidromeFeatures.serverPlayQueue.concat(
subsonicArgs.features.serverPlayQueue,
);
}
return {
features,
id: apiClientProps.serverId,
@@ -847,6 +882,31 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async (args) => {
const { apiClientProps, query } = args;
// Prefer using Navidrome's API only in the situation where the OpenSubsonic extension is not present
// OpenSubsonic extension is preferable as the credentials never expire
if (
hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2) &&
!hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 1)
) {
const res = await ndApiClient(apiClientProps).saveQueue({
body: {
current: query.currentIndex !== undefined ? query.currentIndex : undefined,
ids: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
return;
}
return SubsonicController.savePlayQueue(args);
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
+30
View File
@@ -141,6 +141,20 @@ export const contract = c.router({
200: ssType._response.getPlaylists,
},
},
getPlayQueue: {
method: 'GET',
path: 'getPlayQueue.view',
responses: {
200: ssType._response.playQueue,
},
},
getPlayQueueByIndex: {
method: 'GET',
path: 'getPlayQueueByIndex.view',
responses: {
200: ssType._response.playQueueByIndex,
},
},
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
@@ -227,6 +241,22 @@ export const contract = c.router({
200: ssType._response.removeFavorite,
},
},
savePlayQueue: {
method: 'GET',
path: 'savePlayQueue.view',
query: ssType._parameters.saveQueue,
responses: {
200: ssType._response.saveQueue,
},
},
savePlayQueueByIndex: {
method: 'GET',
path: 'savePlayQueueByIndex.view',
query: ssType._parameters.savePlayQueueByIndex,
responses: {
200: ssType._response.saveQueue,
},
},
scrobble: {
method: 'GET',
path: 'scrobble.view',
@@ -15,7 +15,7 @@ import {
ssType,
SubsonicExtensions,
} from '/@/shared/api/subsonic/subsonic-types';
import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
import {
AlbumListSort,
GenreListSort,
@@ -27,7 +27,7 @@ import {
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
@@ -913,6 +913,44 @@ export const SubsonicController: InternalControllerEndpoint = {
totalRecordCount: items.length,
};
},
getPlayQueue: async ({ apiClientProps }) => {
if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
const { changed, changedBy, currentIndex, entry, position, username } =
res.body.playQueueByIndex;
return {
changed,
changedBy,
currentIndex: currentIndex ?? 0,
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
positionMs: position ?? 0,
username,
};
} else {
const res = await ssApiClient(apiClientProps).getPlayQueue();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
const { changed, changedBy, current, entry, position, username } = res.body.playQueue;
return {
changed,
changedBy,
currentIndex: current ? entry.findIndex((item) => item.id === current) : 0,
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
positionMs: position ?? 0,
username,
};
}
},
getRandomSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -967,6 +1005,7 @@ export const SubsonicController: InternalControllerEndpoint = {
final.splice(0, 0, { label: 'all artists', value: '' });
return final;
},
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -1003,6 +1042,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.osFormPost = [1];
}
if (subsonicFeatures[SubsonicExtensions.INDEX_BASED_QUEUE]) {
features.serverPlayQueue = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
},
getSimilarSongs: async (args) => {
@@ -1586,6 +1629,36 @@ export const SubsonicController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async ({ apiClientProps, query }) => {
if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {
const res = await ssApiClient(apiClientProps).savePlayQueueByIndex({
query: {
currentIndex: query.currentIndex !== undefined ? query.currentIndex : undefined,
id: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
} else {
const res = await ssApiClient(apiClientProps).savePlayQueue({
query: {
current:
query.currentIndex !== undefined && query.currentIndex < query.songs.length
? query.songs[query.currentIndex]
: undefined,
id: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
}
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
+8 -1
View File
@@ -1,4 +1,4 @@
import { LibraryItem } from '/@/shared/types/domain-types';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
export type EventMap = {
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
@@ -11,6 +11,7 @@ export type EventMap = {
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
PLAYLIST_REORDER: PlaylistReorderEventPayload;
QUEUE_RESTORED: QueueRestoredEventPayload;
USER_FAVORITE: UserFavoriteEventPayload;
USER_RATING: UserRatingEventPayload;
};
@@ -52,6 +53,12 @@ export type PlaylistReorderEventPayload = {
targetId: string;
};
export type QueueRestoredEventPayload = {
data: Song[];
index: number;
position: number;
};
export type UserFavoriteEventPayload = {
favorite: boolean;
id: string[];
@@ -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 }) => {
+23
View File
@@ -68,6 +68,7 @@ interface Actions {
moveSelectedToTop: (items: QueueSong[]) => void;
setCrossfadeDuration: (duration: number) => void;
setCrossfadeStyle: (style: CrossfadeStyle) => void;
setQueue: (data: Song[], index?: number, position?: number) => void;
setQueueType: (queueType: PlayerQueueType) => void;
setRepeat: (repeat: PlayerRepeat) => void;
setShuffle: (shuffle: PlayerShuffle) => void;
@@ -1943,6 +1944,27 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
}
});
},
setQueue: (items, index, position) => {
const newItems = items.map(toQueueSong);
const newUniqueIds = newItems.map((item) => item._uniqueId);
set((state) => {
newItems.forEach((item) => {
state.queue.songs[item._uniqueId] = item;
});
state.player.index = index ?? 0;
state.player.status = PlayerStatus.PLAYING;
state.player.playerNum = 1;
state.queue.default = newUniqueIds;
});
eventEmitter.emit('QUEUE_RESTORED', {
data: items,
index: index ?? 0,
position: position ?? 0,
});
},
...initialState,
setCrossfadeDuration: (duration: number) => {
set((state) => {
@@ -2304,6 +2326,7 @@ export const usePlayerActions = () => {
moveSelectedToTop: state.moveSelectedToTop,
setCrossfadeDuration: state.setCrossfadeDuration,
setCrossfadeStyle: state.setCrossfadeStyle,
setQueue: state.setQueue,
setQueueType: state.setQueueType,
setRepeat: state.setRepeat,
setShuffle: state.setShuffle,
+1
View File
@@ -48,6 +48,7 @@ export const logMsg = {
playbackError: 'An error occurred during playback',
playerFiltersApplied: 'Player filters applied',
setFavorite: 'Set favorite',
setQueue: 'Set queue',
setRating: 'Set rating',
setRepeat: 'Set repeat',
setShuffle: 'Set shuffle',
@@ -14,6 +14,8 @@ import {
} from '/@/shared/types/domain-types';
import { ServerListItem, ServerType } from '/@/shared/types/types';
const TICKS_PER_MS = 10000;
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
@@ -221,7 +223,7 @@ const normalizeSong = (
createdAt: item.DateCreated,
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
discSubtitle: null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
explicitStatus: null,
gain:
item.NormalizationGain !== undefined
@@ -294,7 +296,7 @@ const normalizeAlbum = (
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
explicitStatus: null,
genres:
item.GenreItems?.map((entry) => ({
@@ -363,7 +365,7 @@ const normalizeAlbumArtist = (
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
_itemType: LibraryItem.GENRE,
_serverId: server?.id || '',
@@ -409,7 +411,7 @@ const normalizePlaylist = (
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
description: item.Overview || null,
duration: item.RunTimeTicks / 10000,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
_itemType: LibraryItem.GENRE,
_serverId: server?.id || '',
+26
View File
@@ -253,6 +253,7 @@ const sessionInfo = z.object({
CanSeek: z.boolean(),
IsMuted: z.boolean(),
IsPaused: z.boolean(),
PositionTicks: z.number().optional(),
RepeatMode: z.string(),
}),
RemoteEndPoint: z.string(),
@@ -801,6 +802,28 @@ const folderParameters = z.object({
SortOrder: z.enum(sortOrderValues).optional(),
});
const queueItem = z.object({
Id: z.string(),
PlaylistItemId: z.string().optional(),
});
const saveQueueParameters = scrobbleParameters.merge(
z.object({
NowPlayingQueue: z.array(queueItem),
PlaylistItemId: z.string().optional(),
}),
);
const getQueueParameters = z.object({});
const getSessions = z.array(
sessionInfo.merge(
z.object({
PlaylistItemId: z.string().optional(),
}),
),
);
export const jfType = {
_enum: {
albumArtistList: albumArtistListSort,
@@ -825,10 +848,12 @@ export const jfType = {
filterList: filterListParameters,
folder: folderParameters,
genreList: genreListParameters,
getQueue: getQueueParameters,
musicFolderList: musicFolderListParameters,
playlistDetail: playlistDetailParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters,
scrobble: scrobbleParameters,
search: searchParameters,
similarArtistList: similarArtistListParameters,
@@ -853,6 +878,7 @@ export const jfType = {
folderList,
genre,
genreList,
getSessions,
lyrics,
moveItem,
musicFolder,
@@ -676,6 +676,25 @@ const tagListParameters = optionalPaginationParameters.extend({
tag_value: z.string().optional(), // Search
});
const saveQueueParameters = z.object({
current: z.number().optional(),
ids: z.array(z.string()).optional(),
position: z.number().optional(),
});
const saveQueue = z.null();
const queue = z.object({
changedBy: z.string(),
createdAt: z.string(),
current: z.number(),
id: z.string(),
items: z.array(song),
position: z.number(),
updatedAt: z.string(),
userId: z.string(),
});
export const ndType = {
_enum: {
albumArtistList: NDAlbumArtistListSort,
@@ -696,6 +715,7 @@ export const ndType = {
moveItem: moveItemParameters,
playlistList: playlistListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters,
shareItem: shareItemParameters,
songList: songListParameters,
tagList: tagListParameters,
@@ -719,7 +739,9 @@ export const ndType = {
playlistList,
playlistSong,
playlistSongList,
queue,
removeFromPlaylist,
saveQueue,
shareItem,
song,
songList,
+42
View File
@@ -356,6 +356,7 @@ const similarSongs = z.object({
export enum SubsonicExtensions {
FORM_POST = 'formPost',
INDEX_BASED_QUEUE = 'indexBasedQueue',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
}
@@ -617,6 +618,42 @@ const getIndexesParameters = z.object({
musicFolderId: z.string().optional(),
});
const saveQueueParameters = z.object({
current: z.string().optional(),
id: z.string().array(),
position: z.number().optional(),
});
const savePlayQueueByIndexParameters = z.object({
currentIndex: z.number().optional(),
id: z.string().array().optional(),
position: z.number().optional(),
});
const saveQueue = z.null();
const playQueue = z.object({
playQueue: z.object({
changed: z.string(),
changedBy: z.string(),
current: z.string().optional(),
entry: song.array(),
position: z.number().optional(),
username: z.string(),
}),
});
const playQueueByIndex = z.object({
playQueueByIndex: z.object({
changed: z.string(),
changedBy: z.string(),
currentIndex: z.number().optional(),
entry: song.array().optional(),
position: z.number().optional(),
username: z.string(),
}),
});
export const ssType = {
_parameters: {
albumInfo: albumInfoParameters,
@@ -641,6 +678,8 @@ export const ssType = {
getStarred: getStarredParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters,
saveQueue: saveQueueParameters,
scrobble: scrobbleParameters,
search3: search3Parameters,
setRating: setRatingParameters,
@@ -681,8 +720,11 @@ export const ssType = {
ping,
playlist,
playlistListEntry,
playQueue,
playQueueByIndex,
randomSongList,
removeFavorite,
saveQueue,
scrobble,
search3,
serverInfo,
+12
View File
@@ -49,6 +49,18 @@ export const hasFeature = (server: null | ServerListItem, feature: ServerFeature
return (server.features[feature]?.length || 0) > 0;
};
export const hasFeatureWithVersion = (
server: null | ServerListItem,
feature: ServerFeature,
version: number,
): boolean => {
if (!server || !server.features) {
return false;
}
return (server.features[feature] ?? []).includes(version);
};
export type VersionInfo = ReadonlyArray<
[string, Partial<Record<ServerFeature, readonly number[]>>]
>;
@@ -12,7 +12,7 @@ interface DragDropZoneProps {
}
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
const zoneFileInput = useRef<HTMLInputElement | null>();
const zoneFileInput = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string>('');
const processItem = useCallback(
@@ -122,7 +122,9 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
) : null}
<input
onChange={onZoneInputChange}
ref={(self) => (zoneFileInput.current = self)}
ref={(self) => {
zoneFileInput.current = self;
}}
style={{ display: 'none' }}
type="file"
/>
+2
View File
@@ -100,6 +100,7 @@ import {
LuSun,
LuTable,
LuTriangleAlert,
LuUpload,
LuUser,
LuUserPen,
LuUserRoundCog,
@@ -227,6 +228,7 @@ export const AppIcon = {
themeLight: LuSun,
track: LuMusic2,
unfavorite: LuHeartCrack,
upload: LuUpload,
user: LuUser,
userManage: LuUserRoundCog,
visibility: MdOutlineVisibility,
+27
View File
@@ -1286,6 +1286,7 @@ export type ControllerEndpoint = {
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
@@ -1303,6 +1304,7 @@ export type ControllerEndpoint = {
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
search: (args: SearchArgs) => Promise<SearchResponse>;
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
@@ -1327,6 +1329,19 @@ export type FontData = {
style: string;
};
export type GetQueueArgs = BaseEndpointArgs;
export interface GetQueueQuery {}
export type GetQueueResponse = {
changed: string;
changedBy: string;
currentIndex: number;
entry: Song[];
positionMs: number;
username: string;
};
export type InternalControllerEndpoint = {
addToPlaylist: (
args: ReplaceApiClientProps<AddToPlaylistArgs>,
@@ -1376,6 +1391,7 @@ export type InternalControllerEndpoint = {
getPlaylistSongList: (
args: ReplaceApiClientProps<PlaylistSongListArgs>,
) => Promise<SongListResponse>;
getPlayQueue: (args: ReplaceApiClientProps<GetQueueArgs>) => Promise<GetQueueResponse>;
getRandomSongList: (
args: ReplaceApiClientProps<RandomSongListArgs>,
) => Promise<SongListResponse>;
@@ -1402,6 +1418,7 @@ export type InternalControllerEndpoint = {
replacePlaylist: (
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
) => Promise<ReplacePlaylistResponse>;
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
@@ -1439,6 +1456,16 @@ export type MoveItemQuery = {
export type ReplaceApiClientProps<T> = BaseEndpointArgsWithServer & Omit<T, 'apiClientProps'>;
export type SaveQueueArgs = BaseEndpointArgs & {
query: SaveQueueQuery;
};
export type SaveQueueQuery = {
currentIndex?: number;
positionMs?: number;
songs: string[];
};
export type ServerInfo = {
features: ServerFeatures;
id?: string;
+1
View File
@@ -8,6 +8,7 @@ export enum ServerFeature {
OS_FORM_POST = 'osFormPost',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SERVER_PLAY_QUEUE = 'serverPlayQueue',
SHARING_ALBUM_SONG = 'sharingAlbumSong',
TAGS = 'tags',
TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',