From ed5d590a6bf68d84c65f2c0cbb2e9e106547f9ab Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Sat, 13 Dec 2025 05:05:00 +0000 Subject: [PATCH] feat: sync play queue for navidrome/subsonic (#1335) --------- Co-authored-by: jeffvli --- src/i18n/locales/en.json | 7 + src/renderer/api/controller.ts | 28 ++++ src/renderer/api/jellyfin/jellyfin-api.ts | 18 +++ .../api/jellyfin/jellyfin-controller.ts | 6 + src/renderer/api/navidrome/navidrome-api.ts | 17 +++ .../api/navidrome/navidrome-controller.ts | 64 +++++++- src/renderer/api/subsonic/subsonic-api.ts | 30 ++++ .../api/subsonic/subsonic-controller.ts | 77 +++++++++- src/renderer/events/events.ts | 9 +- .../components/drawer-play-queue.tsx | 1 - .../components/play-queue-list-controls.tsx | 138 +++++++----------- .../components/popover-play-queue.tsx | 1 - .../components/sidebar-play-queue.tsx | 1 - .../now-playing/routes/now-playing-route.tsx | 5 +- .../audio-player/hooks/use-player-events.ts | 10 +- .../player/components/audio-players.tsx | 2 + .../player/components/right-controls.tsx | 4 +- .../player/context/player-context.tsx | 22 ++- .../player/hooks/use-queue-restore.ts | 132 +++++++++++++++++ src/renderer/features/songs/api/songs-api.ts | 11 ++ src/renderer/store/player.store.ts | 23 +++ src/renderer/utils/logger-message.ts | 1 + src/shared/api/jellyfin/jellyfin-normalize.ts | 10 +- src/shared/api/jellyfin/jellyfin-types.ts | 26 ++++ src/shared/api/navidrome/navidrome-types.ts | 22 +++ src/shared/api/subsonic/subsonic-types.ts | 42 ++++++ src/shared/api/utils.ts | 12 ++ .../drag-drop-zone/drag-drop-zone.tsx | 6 +- src/shared/components/icon/icon.tsx | 2 + src/shared/types/domain-types.ts | 27 ++++ src/shared/types/features-types.ts | 1 + 31 files changed, 648 insertions(+), 107 deletions(-) create mode 100644 src/renderer/features/player/hooks/use-queue-restore.ts diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 192d3ea6f..dd5787569 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 58b5e0a16..189a0a184 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -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); diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index aa525e8b3..b1eca8054 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -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', diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 4f77d7035..84c4de448 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -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; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 3120952a7..1a9a4d442 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -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', diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index c4c38d204..80ba3bc78 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -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, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 78fad3e13..3836f8cae 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -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', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 2745f181b..2e91e1e72 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -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.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; diff --git a/src/renderer/events/events.ts b/src/renderer/events/events.ts index 2c3219f09..251270fb2 100644 --- a/src/renderer/events/events.ts +++ b/src/renderer/events/events.ts @@ -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[]; diff --git a/src/renderer/features/now-playing/components/drawer-play-queue.tsx b/src/renderer/features/now-playing/components/drawer-play-queue.tsx index b2ab37852..a703a1087 100644 --- a/src/renderer/features/now-playing/components/drawer-play-queue.tsx +++ b/src/renderer/features/now-playing/components/drawer-play-queue.tsx @@ -21,7 +21,6 @@ export const DrawerPlayQueue = () => { diff --git a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx index 030afc07b..62b87d9d5 100644 --- a/src/renderer/features/now-playing/components/play-queue-list-controls.tsx +++ b/src/renderer/features/now-playing/components/play-queue-list-controls.tsx @@ -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; 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 ( + - - - - ); }; + +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 ( + <> + handleSaveQueue()} + tooltip={{ + label: t('player.saveQueueToServer', { + postProcess: 'sentenceCase', + }), + }} + variant="subtle" + /> + + + ); +}; diff --git a/src/renderer/features/now-playing/components/popover-play-queue.tsx b/src/renderer/features/now-playing/components/popover-play-queue.tsx index 65e2c9dc6..e8b531b48 100644 --- a/src/renderer/features/now-playing/components/popover-play-queue.tsx +++ b/src/renderer/features/now-playing/components/popover-play-queue.tsx @@ -45,7 +45,6 @@ export const PopoverPlayQueue = () => { { diff --git a/src/renderer/features/now-playing/routes/now-playing-route.tsx b/src/renderer/features/now-playing/routes/now-playing-route.tsx index e52738a2f..dbd83ada8 100644 --- a/src/renderer/features/now-playing/routes/now-playing-route.tsx +++ b/src/renderer/features/now-playing/routes/now-playing-route.tsx @@ -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(null); const [search, setSearch] = useState(undefined); const { setSideBar } = useAppStoreActions(); @@ -30,7 +28,6 @@ const NowPlayingRoute = () => { diff --git a/src/renderer/features/player/audio-player/hooks/use-player-events.ts b/src/renderer/features/player/audio-player/hooks/use-player-events.ts index aaa326362..b9b32b9c4 100644 --- a/src/renderer/features/player/audio-player/hooks/use-player-events.ts +++ b/src/renderer/features/player/audio-player/hooks/use-player-events.ts @@ -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); } diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 70db0c5f5..5dbe83e4a 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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) { diff --git a/src/renderer/features/player/components/right-controls.tsx b/src/renderer/features/player/components/right-controls.tsx index 7cf3ea41a..2db238e4a 100644 --- a/src/renderer/features/player/components/right-controls.tsx +++ b/src/renderer/features/player/components/right-controls.tsx @@ -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; diff --git a/src/renderer/features/player/context/player-context.tsx b/src/renderer/features/player/context/player-context.tsx index ef0be0357..a753209a4 100644 --- a/src/renderer/features/player/context/player-context.tsx +++ b/src/renderer/features/player/context/player-context.tsx @@ -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({ 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, diff --git a/src/renderer/features/player/hooks/use-queue-restore.ts b/src/renderer/features/player/hooks/use-queue-restore.ts new file mode 100644 index 000000000..361e4904f --- /dev/null +++ b/src/renderer/features/player/hooks/use-queue-restore.ts @@ -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; +}; diff --git a/src/renderer/features/songs/api/songs-api.ts b/src/renderer/features/songs/api/songs-api.ts index 870d747b2..c885bdd34 100644 --- a/src/renderer/features/songs/api/songs-api.ts +++ b/src/renderer/features/songs/api/songs-api.ts @@ -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) => { + return queryOptions({ + queryFn: ({ signal }) => { + return api.controller.getPlayQueue({ + apiClientProps: { serverId: args.serverId, signal }, + }); + }, + queryKey: queryKeys.player.fetch({ type: 'queue' }), + }); + }, list: (args: QueryHookArgs, imageSize?: number) => { return queryOptions({ queryFn: ({ signal }) => { diff --git a/src/renderer/store/player.store.ts b/src/renderer/store/player.store.ts index d6dc04b1c..730d726a2 100644 --- a/src/renderer/store/player.store.ts +++ b/src/renderer/store/player.store.ts @@ -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()( } }); }, + 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, diff --git a/src/renderer/utils/logger-message.ts b/src/renderer/utils/logger-message.ts index b516a3412..947aba719 100644 --- a/src/renderer/utils/logger-message.ts +++ b/src/renderer/utils/logger-message.ts @@ -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', diff --git a/src/shared/api/jellyfin/jellyfin-normalize.ts b/src/shared/api/jellyfin/jellyfin-normalize.ts index 3baa5a316..635297961 100644 --- a/src/shared/api/jellyfin/jellyfin-normalize.ts +++ b/src/shared/api/jellyfin/jellyfin-normalize.ts @@ -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; @@ -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 || '', diff --git a/src/shared/api/jellyfin/jellyfin-types.ts b/src/shared/api/jellyfin/jellyfin-types.ts index 494075ad9..1c261e06a 100644 --- a/src/shared/api/jellyfin/jellyfin-types.ts +++ b/src/shared/api/jellyfin/jellyfin-types.ts @@ -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, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index 056bfa121..6f9611cee 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -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, diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index fbb5c4983..229a54182 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -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, diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index fada28d09..049c8ae6c 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -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>] >; diff --git a/src/shared/components/drag-drop-zone/drag-drop-zone.tsx b/src/shared/components/drag-drop-zone/drag-drop-zone.tsx index 3f5be0ee2..629d3c31a 100644 --- a/src/shared/components/drag-drop-zone/drag-drop-zone.tsx +++ b/src/shared/components/drag-drop-zone/drag-drop-zone.tsx @@ -12,7 +12,7 @@ interface DragDropZoneProps { } export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => { - const zoneFileInput = useRef(); + const zoneFileInput = useRef(null); const [error, setError] = useState(''); const processItem = useCallback( @@ -122,7 +122,9 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon ) : null} (zoneFileInput.current = self)} + ref={(self) => { + zoneFileInput.current = self; + }} style={{ display: 'none' }} type="file" /> diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index 4c68c3c6d..000fb21e2 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -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, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 6e122b5ce..708932080 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1286,6 +1286,7 @@ export type ControllerEndpoint = { getPlaylistList: (args: PlaylistListArgs) => Promise; getPlaylistListCount: (args: PlaylistListCountArgs) => Promise; getPlaylistSongList: (args: PlaylistSongListArgs) => Promise; + getPlayQueue: (args: GetQueueArgs) => Promise; getRandomSongList: (args: RandomSongListArgs) => Promise; getRoles: (args: BaseEndpointArgs) => Promise>; getServerInfo: (args: ServerInfoArgs) => Promise; @@ -1303,6 +1304,7 @@ export type ControllerEndpoint = { movePlaylistItem?: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; replacePlaylist: (args: ReplacePlaylistArgs) => Promise; + savePlayQueue: (args: SaveQueueArgs) => Promise; scrobble: (args: ScrobbleArgs) => Promise; search: (args: SearchArgs) => Promise; setRating?: (args: SetRatingArgs) => Promise; @@ -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, @@ -1376,6 +1391,7 @@ export type InternalControllerEndpoint = { getPlaylistSongList: ( args: ReplaceApiClientProps, ) => Promise; + getPlayQueue: (args: ReplaceApiClientProps) => Promise; getRandomSongList: ( args: ReplaceApiClientProps, ) => Promise; @@ -1402,6 +1418,7 @@ export type InternalControllerEndpoint = { replacePlaylist: ( args: ReplaceApiClientProps, ) => Promise; + savePlayQueue: (args: ReplaceApiClientProps) => Promise; scrobble: (args: ReplaceApiClientProps) => Promise; search: (args: ReplaceApiClientProps) => Promise; setRating?: (args: ReplaceApiClientProps) => Promise; @@ -1439,6 +1456,16 @@ export type MoveItemQuery = { export type ReplaceApiClientProps = BaseEndpointArgsWithServer & Omit; +export type SaveQueueArgs = BaseEndpointArgs & { + query: SaveQueueQuery; +}; + +export type SaveQueueQuery = { + currentIndex?: number; + positionMs?: number; + songs: string[]; +}; + export type ServerInfo = { features: ServerFeatures; id?: string; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 0b1d8469c..c7cfcb959 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -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',