mirror of
https://github.com/jeffvli/feishin.git
synced 2026-08-05 03:43:30 +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",
|
"localFontAccessDenied": "access denied to local fonts",
|
||||||
"loginRateError": "too many login attempts, please try again in a few seconds",
|
"loginRateError": "too many login attempts, please try again in a few seconds",
|
||||||
"mpvRequired": "MPV required",
|
"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",
|
"networkError": "a network error occurred",
|
||||||
"notificationDenied": "permissions for notifications were denied. this setting has no effect",
|
"notificationDenied": "permissions for notifications were denied. this setting has no effect",
|
||||||
"openError": "could not open file",
|
"openError": "could not open file",
|
||||||
@@ -207,6 +208,7 @@
|
|||||||
"remoteEnableError": "an error occurred when trying to $t(common.enable) the remote server",
|
"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",
|
"remotePortError": "an error occurred when trying to set the remote server port",
|
||||||
"remotePortWarning": "restart the server to apply the new port",
|
"remotePortWarning": "restart the server to apply the new port",
|
||||||
|
"saveQueueFailed": "failed to save queue",
|
||||||
"serverNotSelectedError": "no server selected",
|
"serverNotSelectedError": "no server selected",
|
||||||
"serverRequired": "server required",
|
"serverRequired": "server required",
|
||||||
"sessionExpiredError": "your session has expired",
|
"sessionExpiredError": "your session has expired",
|
||||||
@@ -339,6 +341,9 @@
|
|||||||
"resetToDefault": "reset to default",
|
"resetToDefault": "reset to default",
|
||||||
"clearFilters": "clear filters"
|
"clearFilters": "clear filters"
|
||||||
},
|
},
|
||||||
|
"saveQueue": {
|
||||||
|
"success": "saved play queue to server"
|
||||||
|
},
|
||||||
"shareItem": {
|
"shareItem": {
|
||||||
"allowDownloading": "allow downloading",
|
"allowDownloading": "allow downloading",
|
||||||
"description": "description",
|
"description": "description",
|
||||||
@@ -586,6 +591,8 @@
|
|||||||
"repeat_off": "repeat disabled",
|
"repeat_off": "repeat disabled",
|
||||||
"repeat_one": "repeat one",
|
"repeat_one": "repeat one",
|
||||||
"repeat_other": "",
|
"repeat_other": "",
|
||||||
|
"restoreQueueFromServer": "restore queue from server",
|
||||||
|
"saveQueueToServer": "save queue to server",
|
||||||
"shuffle": "play (shuffled)",
|
"shuffle": "play (shuffled)",
|
||||||
"shuffle_off": "shuffle disabled",
|
"shuffle_off": "shuffle disabled",
|
||||||
"skip": "skip",
|
"skip": "skip",
|
||||||
|
|||||||
@@ -426,6 +426,20 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
)?.({ ...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) {
|
getRandomSongList(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
@@ -656,6 +670,20 @@ export const controller: GeneralController = {
|
|||||||
server.type,
|
server.type,
|
||||||
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
|
)?.({ ...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) {
|
scrobble(args) {
|
||||||
const server = getServerById(args.apiClientProps.serverId);
|
const server = getServerById(args.apiClientProps.serverId);
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getPlayQueue: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'sessions',
|
||||||
|
query: jfType._parameters.getQueue,
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.getSessions,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getServerInfo: {
|
getServerInfo: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'system/info',
|
path: 'system/info',
|
||||||
@@ -283,6 +292,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
savePlayQueue: {
|
||||||
|
body: jfType._parameters.saveQueue,
|
||||||
|
method: 'POST',
|
||||||
|
path: 'sessions/playing',
|
||||||
|
responses: {
|
||||||
|
200: jfType._response.scrobble,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
scrobblePlaying: {
|
scrobblePlaying: {
|
||||||
body: jfType._parameters.scrobble,
|
body: jfType._parameters.scrobble,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -772,6 +772,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.body.TotalRecordCount,
|
totalRecordCount: res.body.TotalRecordCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getPlayQueue: async () => {
|
||||||
|
throw new Error('Not supported');
|
||||||
|
},
|
||||||
getRandomSongList: async (args) => {
|
getRandomSongList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -1292,6 +1295,9 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
savePlayQueue: async () => {
|
||||||
|
throw new Error('Not supported');
|
||||||
|
},
|
||||||
scrobble: async (args) => {
|
scrobble: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ export const contract = c.router({
|
|||||||
500: resultWithHeaders(ndType._response.error),
|
500: resultWithHeaders(ndType._response.error),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getQueue: {
|
||||||
|
method: 'GET',
|
||||||
|
path: 'queue',
|
||||||
|
responses: {
|
||||||
|
200: resultWithHeaders(ndType._response.queue),
|
||||||
|
500: resultWithHeaders(ndType._response.error),
|
||||||
|
},
|
||||||
|
},
|
||||||
getSongDetail: {
|
getSongDetail: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'song/:id',
|
path: 'song/:id',
|
||||||
@@ -177,6 +185,15 @@ export const contract = c.router({
|
|||||||
500: resultWithHeaders(ndType._response.error),
|
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: {
|
shareItem: {
|
||||||
body: ndType._parameters.shareItem,
|
body: ndType._parameters.shareItem,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller
|
|||||||
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
|
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
|
||||||
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
|
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
|
||||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
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 {
|
import {
|
||||||
albumArtistListSortMap,
|
albumArtistListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
@@ -25,6 +25,9 @@ import {
|
|||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
const VERSION_INFO: VersionInfo = [
|
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.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
|
||||||
['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
|
['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
|
||||||
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [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),
|
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,
|
getRandomSongList: SubsonicController.getRandomSongList,
|
||||||
getRoles: async ({ apiClientProps }) =>
|
getRoles: async ({ apiClientProps }) =>
|
||||||
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
|
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
|
||||||
@@ -548,12 +577,18 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
const subsonicArgs = await SubsonicController.getServerInfo(args);
|
const subsonicArgs = await SubsonicController.getServerInfo(args);
|
||||||
|
|
||||||
const features = {
|
const features = {
|
||||||
...navidromeFeatures,
|
|
||||||
...subsonicArgs.features,
|
...subsonicArgs.features,
|
||||||
|
...navidromeFeatures,
|
||||||
publicPlaylist: [1],
|
publicPlaylist: [1],
|
||||||
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
|
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (subsonicArgs.features.serverPlayQueue && navidromeFeatures.serverPlayQueue) {
|
||||||
|
features.serverPlayQueue = navidromeFeatures.serverPlayQueue.concat(
|
||||||
|
subsonicArgs.features.serverPlayQueue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
features,
|
features,
|
||||||
id: apiClientProps.serverId,
|
id: apiClientProps.serverId,
|
||||||
@@ -847,6 +882,31 @@ export const NavidromeController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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,
|
scrobble: SubsonicController.scrobble,
|
||||||
search: SubsonicController.search,
|
search: SubsonicController.search,
|
||||||
setRating: SubsonicController.setRating,
|
setRating: SubsonicController.setRating,
|
||||||
|
|||||||
@@ -141,6 +141,20 @@ export const contract = c.router({
|
|||||||
200: ssType._response.getPlaylists,
|
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: {
|
getRandomSongList: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'getRandomSongs.view',
|
path: 'getRandomSongs.view',
|
||||||
@@ -227,6 +241,22 @@ export const contract = c.router({
|
|||||||
200: ssType._response.removeFavorite,
|
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: {
|
scrobble: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'scrobble.view',
|
path: 'scrobble.view',
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
ssType,
|
ssType,
|
||||||
SubsonicExtensions,
|
SubsonicExtensions,
|
||||||
} from '/@/shared/api/subsonic/subsonic-types';
|
} from '/@/shared/api/subsonic/subsonic-types';
|
||||||
import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
|
import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
|
||||||
import {
|
import {
|
||||||
AlbumListSort,
|
AlbumListSort,
|
||||||
GenreListSort,
|
GenreListSort,
|
||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
SongListSort,
|
SongListSort,
|
||||||
SortOrder,
|
SortOrder,
|
||||||
} from '/@/shared/types/domain-types';
|
} 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> = {
|
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
|
||||||
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
|
||||||
@@ -913,6 +913,44 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: items.length,
|
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) => {
|
getRandomSongList: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -967,6 +1005,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
final.splice(0, 0, { label: 'all artists', value: '' });
|
final.splice(0, 0, { label: 'all artists', value: '' });
|
||||||
return final;
|
return final;
|
||||||
},
|
},
|
||||||
|
|
||||||
getServerInfo: async (args) => {
|
getServerInfo: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|
||||||
@@ -1003,6 +1042,10 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
features.osFormPost = [1];
|
features.osFormPost = [1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (subsonicFeatures[SubsonicExtensions.INDEX_BASED_QUEUE]) {
|
||||||
|
features.serverPlayQueue = [1];
|
||||||
|
}
|
||||||
|
|
||||||
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
|
||||||
},
|
},
|
||||||
getSimilarSongs: async (args) => {
|
getSimilarSongs: async (args) => {
|
||||||
@@ -1586,6 +1629,36 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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) => {
|
scrobble: async (args) => {
|
||||||
const { apiClientProps, query } = 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 = {
|
export type EventMap = {
|
||||||
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
|
ITEM_LIST_REFRESH: ItemListRefreshEventPayload;
|
||||||
@@ -11,6 +11,7 @@ export type EventMap = {
|
|||||||
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
|
PLAYLIST_MOVE_TO_TOP: PlaylistMoveEventPayload;
|
||||||
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
PLAYLIST_MOVE_UP: PlaylistMoveEventPayload;
|
||||||
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
PLAYLIST_REORDER: PlaylistReorderEventPayload;
|
||||||
|
QUEUE_RESTORED: QueueRestoredEventPayload;
|
||||||
USER_FAVORITE: UserFavoriteEventPayload;
|
USER_FAVORITE: UserFavoriteEventPayload;
|
||||||
USER_RATING: UserRatingEventPayload;
|
USER_RATING: UserRatingEventPayload;
|
||||||
};
|
};
|
||||||
@@ -52,6 +53,12 @@ export type PlaylistReorderEventPayload = {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QueueRestoredEventPayload = {
|
||||||
|
data: Song[];
|
||||||
|
index: number;
|
||||||
|
position: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserFavoriteEventPayload = {
|
export type UserFavoriteEventPayload = {
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
id: string[];
|
id: string[];
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const DrawerPlayQueue = () => {
|
|||||||
<PlayQueueListControls
|
<PlayQueueListControls
|
||||||
handleSearch={setSearch}
|
handleSearch={setSearch}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
tableRef={queueRef}
|
|
||||||
type={ItemListKey.SIDE_QUEUE}
|
type={ItemListKey.SIDE_QUEUE}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,77 +1,31 @@
|
|||||||
import { RefObject } from 'react';
|
import { t } from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns';
|
||||||
import { ItemListHandle } from '/@/renderer/components/item-list/types';
|
import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
import { useRestoreQueue, useSaveQueue } from '/@/renderer/features/player/hooks/use-queue-restore';
|
||||||
import { updateSong } from '/@/renderer/features/player/update-remote-song';
|
|
||||||
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu';
|
||||||
import { SearchInput } from '/@/renderer/features/shared/components/search-input';
|
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 { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
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';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlayQueueListOptionsProps {
|
interface PlayQueueListOptionsProps {
|
||||||
handleSearch: (value: string) => void;
|
handleSearch: (value: string) => void;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
tableRef: RefObject<ItemListHandle | null>;
|
|
||||||
type: ItemListKey;
|
type: ItemListKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlayQueueListControls = ({
|
export const PlayQueueListControls = ({
|
||||||
handleSearch,
|
handleSearch,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
tableRef,
|
|
||||||
type,
|
type,
|
||||||
}: PlayQueueListOptionsProps) => {
|
}: PlayQueueListOptionsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
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 = () => {
|
const handleClearQueue = () => {
|
||||||
player.clearQueue();
|
player.clearQueue();
|
||||||
@@ -84,6 +38,7 @@ export const PlayQueueListControls = ({
|
|||||||
return (
|
return (
|
||||||
<Group justify="space-between" px="1rem" py="1rem" w="100%">
|
<Group justify="space-between" px="1rem" py="1rem" w="100%">
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
<QueueRestoreActions />
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
icon="mediaShuffle"
|
icon="mediaShuffle"
|
||||||
iconProps={{ size: 'lg' }}
|
iconProps={{ size: 'lg' }}
|
||||||
@@ -91,39 +46,6 @@ export const PlayQueueListControls = ({
|
|||||||
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
|
tooltip={{ label: t('player.shuffle', { postProcess: 'sentenceCase' }) }}
|
||||||
variant="subtle"
|
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
|
<ActionIcon
|
||||||
icon="x"
|
icon="x"
|
||||||
iconProps={{ size: 'lg' }}
|
iconProps={{ size: 'lg' }}
|
||||||
@@ -158,3 +80,49 @@ export const PlayQueueListControls = ({
|
|||||||
</Group>
|
</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
|
<PlayQueueListControls
|
||||||
handleSearch={setSearch}
|
handleSearch={setSearch}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
tableRef={queueRef}
|
|
||||||
type={ItemListKey.SIDE_QUEUE}
|
type={ItemListKey.SIDE_QUEUE}
|
||||||
/>
|
/>
|
||||||
<PlayQueue
|
<PlayQueue
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export const SidebarPlayQueue = () => {
|
|||||||
<PlayQueueListControls
|
<PlayQueueListControls
|
||||||
handleSearch={setSearch}
|
handleSearch={setSearch}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
tableRef={tableRef}
|
|
||||||
type={ItemListKey.SIDE_QUEUE}
|
type={ItemListKey.SIDE_QUEUE}
|
||||||
/>
|
/>
|
||||||
<Flex direction="column" style={{ flex: 1, minHeight: 0 }}>
|
<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 { NowPlayingHeader } from '/@/renderer/features/now-playing/components/now-playing-header';
|
||||||
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
import { PlayQueue } from '/@/renderer/features/now-playing/components/play-queue';
|
||||||
import { PlayQueueListControls } from '/@/renderer/features/now-playing/components/play-queue-list-controls';
|
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';
|
import { ItemListKey } from '/@/shared/types/types';
|
||||||
|
|
||||||
const NowPlayingRoute = () => {
|
const NowPlayingRoute = () => {
|
||||||
const queueRef = useRef<ItemListHandle | null>(null);
|
|
||||||
const [search, setSearch] = useState<string | undefined>(undefined);
|
const [search, setSearch] = useState<string | undefined>(undefined);
|
||||||
const { setSideBar } = useAppStoreActions();
|
const { setSideBar } = useAppStoreActions();
|
||||||
|
|
||||||
@@ -30,7 +28,6 @@ const NowPlayingRoute = () => {
|
|||||||
<PlayQueueListControls
|
<PlayQueueListControls
|
||||||
handleSearch={setSearch}
|
handleSearch={setSearch}
|
||||||
searchTerm={search}
|
searchTerm={search}
|
||||||
tableRef={queueRef}
|
|
||||||
type={ItemListKey.QUEUE_SONG}
|
type={ItemListKey.QUEUE_SONG}
|
||||||
/>
|
/>
|
||||||
<PlayQueue listKey={ItemListKey.QUEUE_SONG} searchTerm={search} />
|
<PlayQueue listKey={ItemListKey.QUEUE_SONG} searchTerm={search} />
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
subscribePlayerStatus,
|
subscribePlayerStatus,
|
||||||
subscribePlayerVolume,
|
subscribePlayerVolume,
|
||||||
} from '/@/renderer/store';
|
} 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';
|
import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
interface PlayerEvents {
|
interface PlayerEvents {
|
||||||
@@ -46,6 +46,7 @@ interface PlayerEventsCallbacks {
|
|||||||
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
|
onPlayerSpeed?: (properties: { speed: number }, prev: { speed: number }) => void;
|
||||||
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
|
onPlayerStatus?: (properties: { status: PlayerStatus }, prev: { status: PlayerStatus }) => void;
|
||||||
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
|
onPlayerVolume?: (properties: { volume: number }, prev: { volume: number }) => void;
|
||||||
|
onQueueRestored?: (properties: { data: Song[]; index: number; position: number }) => void;
|
||||||
onUserFavorite?: (properties: {
|
onUserFavorite?: (properties: {
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
id: string[];
|
id: string[];
|
||||||
@@ -152,6 +153,10 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
|
|||||||
eventEmitter.on('PLAYER_PLAY', callbacks.onPlayerPlay);
|
eventEmitter.on('PLAYER_PLAY', callbacks.onPlayerPlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (callbacks.onQueueRestored) {
|
||||||
|
eventEmitter.on('QUEUE_RESTORED', callbacks.onQueueRestored);
|
||||||
|
}
|
||||||
|
|
||||||
if (callbacks.onUserFavorite) {
|
if (callbacks.onUserFavorite) {
|
||||||
eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite);
|
eventEmitter.on('USER_FAVORITE', callbacks.onUserFavorite);
|
||||||
}
|
}
|
||||||
@@ -172,6 +177,9 @@ function createPlayerEvents(callbacks: PlayerEventsCallbacks): PlayerEvents {
|
|||||||
if (callbacks.onPlayerPlay) {
|
if (callbacks.onPlayerPlay) {
|
||||||
eventEmitter.off('PLAYER_PLAY', callbacks.onPlayerPlay);
|
eventEmitter.off('PLAYER_PLAY', callbacks.onPlayerPlay);
|
||||||
}
|
}
|
||||||
|
if (callbacks.onQueueRestored) {
|
||||||
|
eventEmitter.off('QUEUE_RESTORED', callbacks.onQueueRestored);
|
||||||
|
}
|
||||||
if (callbacks.onUserFavorite) {
|
if (callbacks.onUserFavorite) {
|
||||||
eventEmitter.off('USER_FAVORITE', 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 { useMPRIS } from '/@/renderer/features/player/hooks/use-mpris';
|
||||||
import { usePlaybackHotkeys } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
import { usePlaybackHotkeys } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
|
||||||
import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power-save-blocker';
|
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 { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
|
||||||
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
|
||||||
import {
|
import {
|
||||||
@@ -46,6 +47,7 @@ export const AudioPlayers = () => {
|
|||||||
useMediaSession();
|
useMediaSession();
|
||||||
usePlaybackHotkeys();
|
usePlaybackHotkeys();
|
||||||
useAutoDJ();
|
useAutoDJ();
|
||||||
|
useQueueRestoreTimestamp();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webAudio && 'AudioContext' in window) {
|
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';
|
import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
||||||
let volumeToSet;
|
let volumeToSet: number;
|
||||||
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
|
const newVolumeGreaterThanHundred = volume + volumeWheelStep > 100;
|
||||||
if (newVolumeGreaterThanHundred) {
|
if (newVolumeGreaterThanHundred) {
|
||||||
volumeToSet = 100;
|
volumeToSet = 100;
|
||||||
@@ -49,7 +49,7 @@ const calculateVolumeUp = (volume: number, volumeWheelStep: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
|
const calculateVolumeDown = (volume: number, volumeWheelStep: number) => {
|
||||||
let volumeToSet;
|
let volumeToSet: number;
|
||||||
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
|
const newVolumeLessThanZero = volume - volumeWheelStep < 0;
|
||||||
if (newVolumeLessThanZero) {
|
if (newVolumeLessThanZero) {
|
||||||
volumeToSet = 0;
|
volumeToSet = 0;
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export interface PlayerContext {
|
|||||||
itemType: LibraryItem,
|
itemType: LibraryItem,
|
||||||
isFavorite: boolean,
|
isFavorite: boolean,
|
||||||
) => void;
|
) => void;
|
||||||
|
setQueue: (data: Song[], index?: number, position?: number) => void;
|
||||||
setRating: (serverId: string, id: string[], itemType: LibraryItem, rating: number) => void;
|
setRating: (serverId: string, id: string[], itemType: LibraryItem, rating: number) => void;
|
||||||
setRepeat: (repeat: PlayerRepeat) => void;
|
setRepeat: (repeat: PlayerRepeat) => void;
|
||||||
setShuffle: (shuffle: PlayerShuffle) => void;
|
setShuffle: (shuffle: PlayerShuffle) => void;
|
||||||
@@ -116,6 +117,7 @@ export const PlayerContext = createContext<PlayerContext>({
|
|||||||
moveSelectedToNext: () => {},
|
moveSelectedToNext: () => {},
|
||||||
moveSelectedToTop: () => {},
|
moveSelectedToTop: () => {},
|
||||||
setFavorite: () => {},
|
setFavorite: () => {},
|
||||||
|
setQueue: () => {},
|
||||||
setRating: () => {},
|
setRating: () => {},
|
||||||
setRepeat: () => {},
|
setRepeat: () => {},
|
||||||
setShuffle: () => {},
|
setShuffle: () => {},
|
||||||
@@ -642,6 +644,22 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
storeActions.mediaSkipForward();
|
storeActions.mediaSkipForward();
|
||||||
}, [storeActions]);
|
}, [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(
|
const setSpeed = useCallback(
|
||||||
(speed: number) => {
|
(speed: number) => {
|
||||||
logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, {
|
logFn.debug(logMsg[LogCategory.PLAYER].setSpeed, {
|
||||||
@@ -855,6 +873,7 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
moveSelectedToNext,
|
moveSelectedToNext,
|
||||||
moveSelectedToTop,
|
moveSelectedToTop,
|
||||||
setFavorite,
|
setFavorite,
|
||||||
|
setQueue,
|
||||||
setRating,
|
setRating,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
setShuffle,
|
setShuffle,
|
||||||
@@ -873,7 +892,6 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
clearQueue,
|
clearQueue,
|
||||||
clearSelected,
|
clearSelected,
|
||||||
decreaseVolume,
|
decreaseVolume,
|
||||||
setSpeed,
|
|
||||||
increaseVolume,
|
increaseVolume,
|
||||||
mediaNext,
|
mediaNext,
|
||||||
mediaPause,
|
mediaPause,
|
||||||
@@ -891,9 +909,11 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
moveSelectedToNext,
|
moveSelectedToNext,
|
||||||
moveSelectedToTop,
|
moveSelectedToTop,
|
||||||
setFavorite,
|
setFavorite,
|
||||||
|
setQueue,
|
||||||
setRating,
|
setRating,
|
||||||
setRepeat,
|
setRepeat,
|
||||||
setShuffle,
|
setShuffle,
|
||||||
|
setSpeed,
|
||||||
setVolume,
|
setVolume,
|
||||||
shuffle,
|
shuffle,
|
||||||
shuffleAll,
|
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 { queryKeys } from '/@/renderer/api/query-keys';
|
||||||
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
import { QueryHookArgs } from '/@/renderer/lib/react-query';
|
||||||
import {
|
import {
|
||||||
|
GetQueueQuery,
|
||||||
ListCountQuery,
|
ListCountQuery,
|
||||||
RandomSongListQuery,
|
RandomSongListQuery,
|
||||||
SimilarSongsQuery,
|
SimilarSongsQuery,
|
||||||
@@ -12,6 +13,16 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const songsQueries = {
|
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) => {
|
list: (args: QueryHookArgs<SongListQuery>, imageSize?: number) => {
|
||||||
return queryOptions({
|
return queryOptions({
|
||||||
queryFn: ({ signal }) => {
|
queryFn: ({ signal }) => {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ interface Actions {
|
|||||||
moveSelectedToTop: (items: QueueSong[]) => void;
|
moveSelectedToTop: (items: QueueSong[]) => void;
|
||||||
setCrossfadeDuration: (duration: number) => void;
|
setCrossfadeDuration: (duration: number) => void;
|
||||||
setCrossfadeStyle: (style: CrossfadeStyle) => void;
|
setCrossfadeStyle: (style: CrossfadeStyle) => void;
|
||||||
|
setQueue: (data: Song[], index?: number, position?: number) => void;
|
||||||
setQueueType: (queueType: PlayerQueueType) => void;
|
setQueueType: (queueType: PlayerQueueType) => void;
|
||||||
setRepeat: (repeat: PlayerRepeat) => void;
|
setRepeat: (repeat: PlayerRepeat) => void;
|
||||||
setShuffle: (shuffle: PlayerShuffle) => 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,
|
...initialState,
|
||||||
setCrossfadeDuration: (duration: number) => {
|
setCrossfadeDuration: (duration: number) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -2304,6 +2326,7 @@ export const usePlayerActions = () => {
|
|||||||
moveSelectedToTop: state.moveSelectedToTop,
|
moveSelectedToTop: state.moveSelectedToTop,
|
||||||
setCrossfadeDuration: state.setCrossfadeDuration,
|
setCrossfadeDuration: state.setCrossfadeDuration,
|
||||||
setCrossfadeStyle: state.setCrossfadeStyle,
|
setCrossfadeStyle: state.setCrossfadeStyle,
|
||||||
|
setQueue: state.setQueue,
|
||||||
setQueueType: state.setQueueType,
|
setQueueType: state.setQueueType,
|
||||||
setRepeat: state.setRepeat,
|
setRepeat: state.setRepeat,
|
||||||
setShuffle: state.setShuffle,
|
setShuffle: state.setShuffle,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export const logMsg = {
|
|||||||
playbackError: 'An error occurred during playback',
|
playbackError: 'An error occurred during playback',
|
||||||
playerFiltersApplied: 'Player filters applied',
|
playerFiltersApplied: 'Player filters applied',
|
||||||
setFavorite: 'Set favorite',
|
setFavorite: 'Set favorite',
|
||||||
|
setQueue: 'Set queue',
|
||||||
setRating: 'Set rating',
|
setRating: 'Set rating',
|
||||||
setRepeat: 'Set repeat',
|
setRepeat: 'Set repeat',
|
||||||
setShuffle: 'Set shuffle',
|
setShuffle: 'Set shuffle',
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerListItem, ServerType } from '/@/shared/types/types';
|
import { ServerListItem, ServerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const TICKS_PER_MS = 10000;
|
||||||
|
|
||||||
const getAlbumArtistCoverArtUrl = (args: {
|
const getAlbumArtistCoverArtUrl = (args: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
item: z.infer<typeof jfType._response.albumArtist>;
|
item: z.infer<typeof jfType._response.albumArtist>;
|
||||||
@@ -221,7 +223,7 @@ const normalizeSong = (
|
|||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
discNumber: (item.ParentIndexNumber && item.ParentIndexNumber) || 1,
|
||||||
discSubtitle: null,
|
discSubtitle: null,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / TICKS_PER_MS,
|
||||||
explicitStatus: null,
|
explicitStatus: null,
|
||||||
gain:
|
gain:
|
||||||
item.NormalizationGain !== undefined
|
item.NormalizationGain !== undefined
|
||||||
@@ -294,7 +296,7 @@ const normalizeAlbum = (
|
|||||||
backdropImageUrl: null,
|
backdropImageUrl: null,
|
||||||
comment: null,
|
comment: null,
|
||||||
createdAt: item.DateCreated,
|
createdAt: item.DateCreated,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / TICKS_PER_MS,
|
||||||
explicitStatus: null,
|
explicitStatus: null,
|
||||||
genres:
|
genres:
|
||||||
item.GenreItems?.map((entry) => ({
|
item.GenreItems?.map((entry) => ({
|
||||||
@@ -363,7 +365,7 @@ const normalizeAlbumArtist = (
|
|||||||
albumCount: item.AlbumCount ?? null,
|
albumCount: item.AlbumCount ?? null,
|
||||||
backgroundImageUrl: null,
|
backgroundImageUrl: null,
|
||||||
biography: item.Overview || null,
|
biography: item.Overview || null,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / TICKS_PER_MS,
|
||||||
genres: item.GenreItems?.map((entry) => ({
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
_itemType: LibraryItem.GENRE,
|
_itemType: LibraryItem.GENRE,
|
||||||
_serverId: server?.id || '',
|
_serverId: server?.id || '',
|
||||||
@@ -409,7 +411,7 @@ const normalizePlaylist = (
|
|||||||
_serverId: server?.id || '',
|
_serverId: server?.id || '',
|
||||||
_serverType: ServerType.JELLYFIN,
|
_serverType: ServerType.JELLYFIN,
|
||||||
description: item.Overview || null,
|
description: item.Overview || null,
|
||||||
duration: item.RunTimeTicks / 10000,
|
duration: item.RunTimeTicks / TICKS_PER_MS,
|
||||||
genres: item.GenreItems?.map((entry) => ({
|
genres: item.GenreItems?.map((entry) => ({
|
||||||
_itemType: LibraryItem.GENRE,
|
_itemType: LibraryItem.GENRE,
|
||||||
_serverId: server?.id || '',
|
_serverId: server?.id || '',
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ const sessionInfo = z.object({
|
|||||||
CanSeek: z.boolean(),
|
CanSeek: z.boolean(),
|
||||||
IsMuted: z.boolean(),
|
IsMuted: z.boolean(),
|
||||||
IsPaused: z.boolean(),
|
IsPaused: z.boolean(),
|
||||||
|
PositionTicks: z.number().optional(),
|
||||||
RepeatMode: z.string(),
|
RepeatMode: z.string(),
|
||||||
}),
|
}),
|
||||||
RemoteEndPoint: z.string(),
|
RemoteEndPoint: z.string(),
|
||||||
@@ -801,6 +802,28 @@ const folderParameters = z.object({
|
|||||||
SortOrder: z.enum(sortOrderValues).optional(),
|
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 = {
|
export const jfType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: albumArtistListSort,
|
albumArtistList: albumArtistListSort,
|
||||||
@@ -825,10 +848,12 @@ export const jfType = {
|
|||||||
filterList: filterListParameters,
|
filterList: filterListParameters,
|
||||||
folder: folderParameters,
|
folder: folderParameters,
|
||||||
genreList: genreListParameters,
|
genreList: genreListParameters,
|
||||||
|
getQueue: getQueueParameters,
|
||||||
musicFolderList: musicFolderListParameters,
|
musicFolderList: musicFolderListParameters,
|
||||||
playlistDetail: playlistDetailParameters,
|
playlistDetail: playlistDetailParameters,
|
||||||
playlistList: playlistListParameters,
|
playlistList: playlistListParameters,
|
||||||
removeFromPlaylist: removeFromPlaylistParameters,
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
|
saveQueue: saveQueueParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
search: searchParameters,
|
search: searchParameters,
|
||||||
similarArtistList: similarArtistListParameters,
|
similarArtistList: similarArtistListParameters,
|
||||||
@@ -853,6 +878,7 @@ export const jfType = {
|
|||||||
folderList,
|
folderList,
|
||||||
genre,
|
genre,
|
||||||
genreList,
|
genreList,
|
||||||
|
getSessions,
|
||||||
lyrics,
|
lyrics,
|
||||||
moveItem,
|
moveItem,
|
||||||
musicFolder,
|
musicFolder,
|
||||||
|
|||||||
@@ -676,6 +676,25 @@ const tagListParameters = optionalPaginationParameters.extend({
|
|||||||
tag_value: z.string().optional(), // Search
|
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 = {
|
export const ndType = {
|
||||||
_enum: {
|
_enum: {
|
||||||
albumArtistList: NDAlbumArtistListSort,
|
albumArtistList: NDAlbumArtistListSort,
|
||||||
@@ -696,6 +715,7 @@ export const ndType = {
|
|||||||
moveItem: moveItemParameters,
|
moveItem: moveItemParameters,
|
||||||
playlistList: playlistListParameters,
|
playlistList: playlistListParameters,
|
||||||
removeFromPlaylist: removeFromPlaylistParameters,
|
removeFromPlaylist: removeFromPlaylistParameters,
|
||||||
|
saveQueue: saveQueueParameters,
|
||||||
shareItem: shareItemParameters,
|
shareItem: shareItemParameters,
|
||||||
songList: songListParameters,
|
songList: songListParameters,
|
||||||
tagList: tagListParameters,
|
tagList: tagListParameters,
|
||||||
@@ -719,7 +739,9 @@ export const ndType = {
|
|||||||
playlistList,
|
playlistList,
|
||||||
playlistSong,
|
playlistSong,
|
||||||
playlistSongList,
|
playlistSongList,
|
||||||
|
queue,
|
||||||
removeFromPlaylist,
|
removeFromPlaylist,
|
||||||
|
saveQueue,
|
||||||
shareItem,
|
shareItem,
|
||||||
song,
|
song,
|
||||||
songList,
|
songList,
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ const similarSongs = z.object({
|
|||||||
|
|
||||||
export enum SubsonicExtensions {
|
export enum SubsonicExtensions {
|
||||||
FORM_POST = 'formPost',
|
FORM_POST = 'formPost',
|
||||||
|
INDEX_BASED_QUEUE = 'indexBasedQueue',
|
||||||
SONG_LYRICS = 'songLyrics',
|
SONG_LYRICS = 'songLyrics',
|
||||||
TRANSCODE_OFFSET = 'transcodeOffset',
|
TRANSCODE_OFFSET = 'transcodeOffset',
|
||||||
}
|
}
|
||||||
@@ -617,6 +618,42 @@ const getIndexesParameters = z.object({
|
|||||||
musicFolderId: z.string().optional(),
|
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 = {
|
export const ssType = {
|
||||||
_parameters: {
|
_parameters: {
|
||||||
albumInfo: albumInfoParameters,
|
albumInfo: albumInfoParameters,
|
||||||
@@ -641,6 +678,8 @@ export const ssType = {
|
|||||||
getStarred: getStarredParameters,
|
getStarred: getStarredParameters,
|
||||||
randomSongList: randomSongListParameters,
|
randomSongList: randomSongListParameters,
|
||||||
removeFavorite: removeFavoriteParameters,
|
removeFavorite: removeFavoriteParameters,
|
||||||
|
savePlayQueueByIndex: savePlayQueueByIndexParameters,
|
||||||
|
saveQueue: saveQueueParameters,
|
||||||
scrobble: scrobbleParameters,
|
scrobble: scrobbleParameters,
|
||||||
search3: search3Parameters,
|
search3: search3Parameters,
|
||||||
setRating: setRatingParameters,
|
setRating: setRatingParameters,
|
||||||
@@ -681,8 +720,11 @@ export const ssType = {
|
|||||||
ping,
|
ping,
|
||||||
playlist,
|
playlist,
|
||||||
playlistListEntry,
|
playlistListEntry,
|
||||||
|
playQueue,
|
||||||
|
playQueueByIndex,
|
||||||
randomSongList,
|
randomSongList,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
|
saveQueue,
|
||||||
scrobble,
|
scrobble,
|
||||||
search3,
|
search3,
|
||||||
serverInfo,
|
serverInfo,
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ export const hasFeature = (server: null | ServerListItem, feature: ServerFeature
|
|||||||
return (server.features[feature]?.length || 0) > 0;
|
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<
|
export type VersionInfo = ReadonlyArray<
|
||||||
[string, Partial<Record<ServerFeature, readonly number[]>>]
|
[string, Partial<Record<ServerFeature, readonly number[]>>]
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface DragDropZoneProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DragDropZone = ({ icon, onItemSelected, validateItem }: 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 [error, setError] = useState<string>('');
|
||||||
|
|
||||||
const processItem = useCallback(
|
const processItem = useCallback(
|
||||||
@@ -122,7 +122,9 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
|||||||
) : null}
|
) : null}
|
||||||
<input
|
<input
|
||||||
onChange={onZoneInputChange}
|
onChange={onZoneInputChange}
|
||||||
ref={(self) => (zoneFileInput.current = self)}
|
ref={(self) => {
|
||||||
|
zoneFileInput.current = self;
|
||||||
|
}}
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
type="file"
|
type="file"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import {
|
|||||||
LuSun,
|
LuSun,
|
||||||
LuTable,
|
LuTable,
|
||||||
LuTriangleAlert,
|
LuTriangleAlert,
|
||||||
|
LuUpload,
|
||||||
LuUser,
|
LuUser,
|
||||||
LuUserPen,
|
LuUserPen,
|
||||||
LuUserRoundCog,
|
LuUserRoundCog,
|
||||||
@@ -227,6 +228,7 @@ export const AppIcon = {
|
|||||||
themeLight: LuSun,
|
themeLight: LuSun,
|
||||||
track: LuMusic2,
|
track: LuMusic2,
|
||||||
unfavorite: LuHeartCrack,
|
unfavorite: LuHeartCrack,
|
||||||
|
upload: LuUpload,
|
||||||
user: LuUser,
|
user: LuUser,
|
||||||
userManage: LuUserRoundCog,
|
userManage: LuUserRoundCog,
|
||||||
visibility: MdOutlineVisibility,
|
visibility: MdOutlineVisibility,
|
||||||
|
|||||||
@@ -1286,6 +1286,7 @@ export type ControllerEndpoint = {
|
|||||||
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
getPlaylistList: (args: PlaylistListArgs) => Promise<PlaylistListResponse>;
|
||||||
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
|
getPlaylistListCount: (args: PlaylistListCountArgs) => Promise<number>;
|
||||||
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
getPlaylistSongList: (args: PlaylistSongListArgs) => Promise<SongListResponse>;
|
||||||
|
getPlayQueue: (args: GetQueueArgs) => Promise<GetQueueResponse>;
|
||||||
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
getRandomSongList: (args: RandomSongListArgs) => Promise<SongListResponse>;
|
||||||
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
|
getRoles: (args: BaseEndpointArgs) => Promise<Array<string | { label: string; value: string }>>;
|
||||||
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
getServerInfo: (args: ServerInfoArgs) => Promise<ServerInfo>;
|
||||||
@@ -1303,6 +1304,7 @@ export type ControllerEndpoint = {
|
|||||||
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
|
||||||
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise<RemoveFromPlaylistResponse>;
|
||||||
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
replacePlaylist: (args: ReplacePlaylistArgs) => Promise<ReplacePlaylistResponse>;
|
||||||
|
savePlayQueue: (args: SaveQueueArgs) => Promise<void>;
|
||||||
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
scrobble: (args: ScrobbleArgs) => Promise<ScrobbleResponse>;
|
||||||
search: (args: SearchArgs) => Promise<SearchResponse>;
|
search: (args: SearchArgs) => Promise<SearchResponse>;
|
||||||
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
setRating?: (args: SetRatingArgs) => Promise<RatingResponse>;
|
||||||
@@ -1327,6 +1329,19 @@ export type FontData = {
|
|||||||
style: string;
|
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 = {
|
export type InternalControllerEndpoint = {
|
||||||
addToPlaylist: (
|
addToPlaylist: (
|
||||||
args: ReplaceApiClientProps<AddToPlaylistArgs>,
|
args: ReplaceApiClientProps<AddToPlaylistArgs>,
|
||||||
@@ -1376,6 +1391,7 @@ export type InternalControllerEndpoint = {
|
|||||||
getPlaylistSongList: (
|
getPlaylistSongList: (
|
||||||
args: ReplaceApiClientProps<PlaylistSongListArgs>,
|
args: ReplaceApiClientProps<PlaylistSongListArgs>,
|
||||||
) => Promise<SongListResponse>;
|
) => Promise<SongListResponse>;
|
||||||
|
getPlayQueue: (args: ReplaceApiClientProps<GetQueueArgs>) => Promise<GetQueueResponse>;
|
||||||
getRandomSongList: (
|
getRandomSongList: (
|
||||||
args: ReplaceApiClientProps<RandomSongListArgs>,
|
args: ReplaceApiClientProps<RandomSongListArgs>,
|
||||||
) => Promise<SongListResponse>;
|
) => Promise<SongListResponse>;
|
||||||
@@ -1402,6 +1418,7 @@ export type InternalControllerEndpoint = {
|
|||||||
replacePlaylist: (
|
replacePlaylist: (
|
||||||
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
|
args: ReplaceApiClientProps<ReplacePlaylistArgs>,
|
||||||
) => Promise<ReplacePlaylistResponse>;
|
) => Promise<ReplacePlaylistResponse>;
|
||||||
|
savePlayQueue: (args: ReplaceApiClientProps<SaveQueueArgs>) => Promise<void>;
|
||||||
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
|
scrobble: (args: ReplaceApiClientProps<ScrobbleArgs>) => Promise<ScrobbleResponse>;
|
||||||
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
|
search: (args: ReplaceApiClientProps<SearchArgs>) => Promise<SearchResponse>;
|
||||||
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
|
setRating?: (args: ReplaceApiClientProps<SetRatingArgs>) => Promise<RatingResponse>;
|
||||||
@@ -1439,6 +1456,16 @@ export type MoveItemQuery = {
|
|||||||
|
|
||||||
export type ReplaceApiClientProps<T> = BaseEndpointArgsWithServer & Omit<T, 'apiClientProps'>;
|
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 = {
|
export type ServerInfo = {
|
||||||
features: ServerFeatures;
|
features: ServerFeatures;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export enum ServerFeature {
|
|||||||
OS_FORM_POST = 'osFormPost',
|
OS_FORM_POST = 'osFormPost',
|
||||||
PLAYLISTS_SMART = 'playlistsSmart',
|
PLAYLISTS_SMART = 'playlistsSmart',
|
||||||
PUBLIC_PLAYLIST = 'publicPlaylist',
|
PUBLIC_PLAYLIST = 'publicPlaylist',
|
||||||
|
SERVER_PLAY_QUEUE = 'serverPlayQueue',
|
||||||
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
SHARING_ALBUM_SONG = 'sharingAlbumSong',
|
||||||
TAGS = 'tags',
|
TAGS = 'tags',
|
||||||
TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',
|
TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch',
|
||||||
|
|||||||
Reference in New Issue
Block a user