mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 04:20:12 +02:00
feat: sync play queue for navidrome/subsonic (#1335)
--------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user