mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-13 22:20:13 +02:00
Compare commits
7 Commits
5ac0aaeec0
...
b9312d86fd
| Author | SHA1 | Date | |
|---|---|---|---|
| b9312d86fd | |||
| 30a1bda93d | |||
| 0e24eeeb1c | |||
| 58d4dea09a | |||
| c4da44a443 | |||
| be3f959354 | |||
| deb69ef8ea |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.12.1",
|
||||
"version": "1.13.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
|
||||
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
|
||||
import de from './locales/de.json';
|
||||
import en from './locales/en.json';
|
||||
import es from './locales/es.json';
|
||||
import et from './locales/et.json';
|
||||
import eu from './locales/eu.json';
|
||||
import fa from './locales/fa.json';
|
||||
import fi from './locales/fi.json';
|
||||
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
|
||||
import sr from './locales/sr.json';
|
||||
import sv from './locales/sv.json';
|
||||
import ta from './locales/ta.json';
|
||||
import th from './locales/th.json';
|
||||
import tl from './locales/tl.json';
|
||||
import tr from './locales/tr.json';
|
||||
import zhHans from './locales/zh-Hans.json';
|
||||
import zhHant from './locales/zh-Hant.json';
|
||||
@@ -38,6 +41,7 @@ const resources = {
|
||||
de: { translation: de },
|
||||
en: { translation: en },
|
||||
es: { translation: es },
|
||||
et: { translation: et },
|
||||
eu: { translation: eu },
|
||||
fa: { translation: fa },
|
||||
fi: { translation: fi },
|
||||
@@ -57,6 +61,8 @@ const resources = {
|
||||
sr: { translation: sr },
|
||||
sv: { translation: sv },
|
||||
ta: { translation: ta },
|
||||
th: { translation: th },
|
||||
tl: { translation: tl },
|
||||
tr: { translation: tr },
|
||||
'zh-Hans': { translation: zhHans },
|
||||
'zh-Hant': { translation: zhHant },
|
||||
@@ -87,6 +93,10 @@ export const languages = [
|
||||
label: 'Español',
|
||||
value: 'es',
|
||||
},
|
||||
{
|
||||
label: 'Eesti',
|
||||
value: 'et',
|
||||
},
|
||||
{
|
||||
label: 'Basque',
|
||||
value: 'eu',
|
||||
@@ -163,6 +173,14 @@ export const languages = [
|
||||
label: 'Tamil',
|
||||
value: 'ta',
|
||||
},
|
||||
{
|
||||
label: 'Thai',
|
||||
value: 'th',
|
||||
},
|
||||
{
|
||||
label: 'Tagalog',
|
||||
value: 'tl',
|
||||
},
|
||||
{
|
||||
label: 'Türkçe',
|
||||
value: 'tr',
|
||||
|
||||
@@ -699,6 +699,7 @@
|
||||
"viewQueue": "View queue",
|
||||
"sleepTimer": "Sleep timer",
|
||||
"sleepTimer_endOfSong": "End of current song",
|
||||
"sleepTimer_endOfAlbum": "End of current album",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_hours": "{{count}} hr",
|
||||
"sleepTimer_custom": "Custom",
|
||||
|
||||
@@ -288,6 +288,29 @@
|
||||
"releaseDate": "Ilmumiskuupäev",
|
||||
"releaseYear": "Ilmumisaasta",
|
||||
"search": "Otsing",
|
||||
"songCount": "Lugude arv"
|
||||
"songCount": "Lugude arv",
|
||||
"explicitStatus": "$t(common.explicitStatus)",
|
||||
"title": "Pealkiri",
|
||||
"sortName": "Sortimisnimi",
|
||||
"trackNumber": "Lugu"
|
||||
},
|
||||
"filterOperator": {
|
||||
"contains": "Sisaldab",
|
||||
"endsWith": "Lõpeb",
|
||||
"is": "On",
|
||||
"isNot": "Ei ole",
|
||||
"isGreaterThan": "On suurem kui",
|
||||
"isLessThan": "On väiksem kui",
|
||||
"matchesRegex": "Vastab regulaaravaldisele",
|
||||
"notContains": "Ei sisalda",
|
||||
"inTheRangeDate": "Jääb vahemikku (kuupäev)",
|
||||
"inTheRange": "Jääb vahemikku",
|
||||
"inTheLast": "Viimase",
|
||||
"inPlaylist": "On",
|
||||
"beforeDate": "On enne (kuupäeva)",
|
||||
"before": "On enne",
|
||||
"afterDate": "On pärast (kuupäeva)",
|
||||
"after": "On pärast",
|
||||
"notInPlaylist": "Ei ole"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +420,8 @@
|
||||
"sleepTimer_setCustom": "設定定時器",
|
||||
"sleepTimer_cancel": "取消定時器",
|
||||
"albumRadio": "專輯電台",
|
||||
"scrobbleForceSubmit": "強制紀錄"
|
||||
"scrobbleForceSubmit": "強制紀錄",
|
||||
"sleepTimer_endOfAlbum": "專輯播完時"
|
||||
},
|
||||
"setting": {
|
||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||
|
||||
@@ -54,6 +54,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deleteArtistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deleteArtistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
@@ -63,6 +72,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
@@ -356,6 +374,24 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
uploadArtistImage: {
|
||||
body: z.string(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.uploadArtistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
uploadPlaylistImage: {
|
||||
body: z.string(),
|
||||
method: 'POST',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.uploadPlaylistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const axiosClient = axios.create({});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { set } from 'idb-keyval';
|
||||
import chunk from 'lodash/chunk';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
DeleteArtistImageArgs,
|
||||
DeleteArtistImageResponse,
|
||||
DeletePlaylistImageArgs,
|
||||
DeletePlaylistImageResponse,
|
||||
Folder,
|
||||
genreListSortMap,
|
||||
ImageArgs,
|
||||
@@ -29,6 +34,10 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
Tag,
|
||||
UploadArtistImageArgs,
|
||||
UploadArtistImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
UploadPlaylistImageResponse,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
|
||||
return value.join(',');
|
||||
};
|
||||
|
||||
const getImageContentType = (bytes: Uint8Array): string => {
|
||||
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
|
||||
return 'image/gif';
|
||||
}
|
||||
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
|
||||
return 'image/webp';
|
||||
}
|
||||
|
||||
return 'image/jpeg';
|
||||
};
|
||||
|
||||
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
|
||||
|
||||
const deleteItemPrimaryImage = async (
|
||||
apiClientProps: JellyfinApiClientProps,
|
||||
id: string,
|
||||
errorMessage: string,
|
||||
): Promise<boolean> => {
|
||||
const res = await jfApiClient({
|
||||
...apiClientProps,
|
||||
server: apiClientProps.server ?? null,
|
||||
}).deleteArtistImage({
|
||||
params: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const uploadItemPrimaryImage = async (
|
||||
apiClientProps: JellyfinApiClientProps,
|
||||
id: string,
|
||||
image: Uint8Array,
|
||||
errorMessage: string,
|
||||
): Promise<boolean> => {
|
||||
const server = apiClientProps.server;
|
||||
const serverUrl = getServerUrl(server);
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error('Server is required');
|
||||
}
|
||||
|
||||
const contentType = getImageContentType(image);
|
||||
const base64 = uint8ArrayToBase64(image);
|
||||
|
||||
const authHeader = createAuthHeader();
|
||||
const authorization = server?.credential
|
||||
? authHeader.concat(`, Token="${server.credential}"`)
|
||||
: authHeader;
|
||||
|
||||
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
signal: apiClientProps.signal,
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Limit the query to 50 at a time to be *extremely* conservative on the
|
||||
// length of the full URL, since the ids are part of the query string and
|
||||
// not the POST body
|
||||
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
|
||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
||||
},
|
||||
],
|
||||
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
||||
[
|
||||
'10.0.0',
|
||||
{
|
||||
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.TAGS]: [1],
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const JF_FIELDS = {
|
||||
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
id: res.body.Id,
|
||||
};
|
||||
},
|
||||
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
|
||||
},
|
||||
deleteFavorite: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
deletePlaylistImage: async (
|
||||
args: DeletePlaylistImageArgs,
|
||||
): Promise<DeletePlaylistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
|
||||
},
|
||||
getAlbumArtistDetail: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -1847,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
return uploadItemPrimaryImage(
|
||||
apiClientProps,
|
||||
query.id,
|
||||
body.image,
|
||||
'Failed to upload artist image',
|
||||
);
|
||||
},
|
||||
uploadPlaylistImage: async (
|
||||
args: UploadPlaylistImageArgs,
|
||||
): Promise<UploadPlaylistImageResponse> => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
return uploadItemPrimaryImage(
|
||||
apiClientProps,
|
||||
query.id,
|
||||
body.image,
|
||||
'Failed to upload playlist image',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
function getLibraryId(musicFolderId?: string | string[]) {
|
||||
|
||||
@@ -109,12 +109,8 @@ export function computeSelectedFromResult(
|
||||
};
|
||||
}
|
||||
|
||||
const hasLocalLocal =
|
||||
(Array.isArray(local) && local.length > 0) ||
|
||||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics));
|
||||
|
||||
// If setting is set to prefer local lyrics, return the local lyrics if available
|
||||
if (preferLocalLyrics && hasLocalLocal) {
|
||||
if (preferLocalLyrics && hasLocalLyrics(local)) {
|
||||
if (Array.isArray(local) && local.length > 0) {
|
||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||
return { selected: item, selectedSynced: item.synced };
|
||||
@@ -236,6 +232,13 @@ export function getDisplayOffset(
|
||||
return storedOffsetMs;
|
||||
}
|
||||
|
||||
export function hasLocalLyrics(local: FullLyricsMetadata | null | StructuredLyric[]): boolean {
|
||||
return (
|
||||
(Array.isArray(local) && local.length > 0) ||
|
||||
(local != null && !Array.isArray(local) && 'lyrics' in local && Boolean(local.lyrics))
|
||||
);
|
||||
}
|
||||
|
||||
const emptyResult = (): LyricsQueryResult => ({
|
||||
local: null,
|
||||
overrideData: null,
|
||||
@@ -277,16 +280,11 @@ export const lyricsQueries = {
|
||||
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
||||
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
||||
|
||||
// Fetch local lyrics
|
||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||
|
||||
// Fetch remote auto lyrics
|
||||
const remoteAutoPromise =
|
||||
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
||||
? null
|
||||
: fetchRemoteLyricsAuto(song);
|
||||
|
||||
// Fetch override data
|
||||
const overrideDataPromise = overrideSelection
|
||||
? fetchRemoteLyricsById({
|
||||
remoteSongId: overrideSelection.id,
|
||||
@@ -295,11 +293,40 @@ export const lyricsQueries = {
|
||||
})
|
||||
: null;
|
||||
|
||||
const [local, remoteAuto, overrideData] = await Promise.all([
|
||||
localPromise,
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||
|
||||
let local: FullLyricsMetadata | null | StructuredLyric[];
|
||||
let remoteAuto: FullLyricsMetadata | null;
|
||||
let overrideData: LyricsResponse | null;
|
||||
|
||||
if (preferLocalLyrics) {
|
||||
local = await localPromise;
|
||||
|
||||
if (hasLocalLyrics(local)) {
|
||||
overrideData = overrideDataPromise ? await overrideDataPromise : null;
|
||||
remoteAuto = null;
|
||||
|
||||
if (remoteAutoPromise) {
|
||||
void remoteAutoPromise.then((fetchedRemoteAuto) => {
|
||||
if (signal.aborted || !fetchedRemoteAuto) return;
|
||||
queryClient.setQueryData<LyricsQueryResult>(lyricsKey, (prev) =>
|
||||
prev ? { ...prev, remoteAuto: fetchedRemoteAuto } : prev,
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
[remoteAuto, overrideData] = await Promise.all([
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
[local, remoteAuto, overrideData] = await Promise.all([
|
||||
localPromise,
|
||||
remoteAutoPromise,
|
||||
overrideDataPromise,
|
||||
]);
|
||||
}
|
||||
|
||||
const partial: Pick<
|
||||
LyricsQueryResult,
|
||||
@@ -320,13 +347,12 @@ export const lyricsQueries = {
|
||||
preferLocalLyrics,
|
||||
selectedStructuredIndex,
|
||||
);
|
||||
const displayOffset = getDisplayOffset(
|
||||
const resultSelectedOffsetMs = getDisplayOffset(
|
||||
selected,
|
||||
selectedOffsetMs,
|
||||
selectedStructuredIndex,
|
||||
local,
|
||||
);
|
||||
const resultSelectedOffsetMs = displayOffset;
|
||||
|
||||
return {
|
||||
...emptyResult(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useIsFetching } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { RefObject } from 'react';
|
||||
import { RefObject, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import styles from './play-queue-list-controls.module.css';
|
||||
@@ -21,6 +21,7 @@ import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { Divider } from '/@/shared/components/divider/divider';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||
|
||||
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
|
||||
|
||||
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
|
||||
|
||||
const { isPending: isSavingQueue, mutate: handleSaveQueue } = useSaveQueue();
|
||||
const { isPending: isSavingQueue, mutate: saveQueue } = useSaveQueue();
|
||||
|
||||
const handleSaveQueue = useCallback(() => {
|
||||
saveQueue(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.success({
|
||||
message: t('form.saveQueue.success'),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [saveQueue]);
|
||||
|
||||
const handleRestoreQueue = useRestoreQueue();
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import { usePlayerStatus, usePlayerStoreBase } from '/@/renderer/store/player.store';
|
||||
import {
|
||||
usePlayerShuffle,
|
||||
usePlayerStatus,
|
||||
usePlayerStoreBase,
|
||||
} from '/@/renderer/store/player.store';
|
||||
import {
|
||||
useSleepTimerActions,
|
||||
useSleepTimerActive,
|
||||
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
|
||||
import { Popover } from '/@/shared/components/popover/popover';
|
||||
import { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const PRESET_OPTIONS = [
|
||||
{ minutes: 0, mode: 'endOfSong' as const },
|
||||
{ minutes: 0, mode: 'endOfAlbum' as const },
|
||||
{ minutes: 5, mode: 'timed' as const },
|
||||
{ minutes: 10, mode: 'timed' as const },
|
||||
{ minutes: 15, mode: 'timed' as const },
|
||||
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
|
||||
const useSleepTimer = () => {
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const { cancelTimer, setRemaining } = useSleepTimerActions();
|
||||
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
|
||||
const mediaPauseRef = useRef(mediaPause);
|
||||
mediaPauseRef.current = mediaPause;
|
||||
|
||||
// End of album mode. Set the pauseOnNextSongEnd flag whenever the current track
|
||||
// is the last one of the target album.
|
||||
const evaluateEndOfAlbum = useCallback(() => {
|
||||
const { currentSong, nextSong } = usePlayerStoreBase.getState().getPlayerData();
|
||||
|
||||
if (!currentSong) {
|
||||
return;
|
||||
}
|
||||
|
||||
let target = useSleepTimerStore.getState().targetAlbumId;
|
||||
|
||||
if (target === null) {
|
||||
target = currentSong.albumId;
|
||||
setTargetAlbumId(target);
|
||||
}
|
||||
|
||||
if (currentSong.albumId !== target) {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
cancelTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const isLastOfAlbum = !nextSong || nextSong.albumId !== currentSong.albumId;
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(isLastOfAlbum);
|
||||
}, [cancelTimer, setTargetAlbumId]);
|
||||
|
||||
const handleOnCurrentSongChange = useCallback(() => {
|
||||
if (!active) {
|
||||
return;
|
||||
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
|
||||
if (mode === 'endOfSong') {
|
||||
cancelTimer();
|
||||
mediaPauseRef.current();
|
||||
return;
|
||||
}
|
||||
}, [active, mode, cancelTimer, mediaPauseRef]);
|
||||
|
||||
// Cancel and pause song change in end-of-album mode
|
||||
if (mode === 'endOfAlbum') {
|
||||
evaluateEndOfAlbum();
|
||||
}
|
||||
}, [active, mode, cancelTimer, evaluateEndOfAlbum, mediaPauseRef]);
|
||||
|
||||
const status = usePlayerStatus();
|
||||
|
||||
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
|
||||
// mediaAutoNext returns PAUSED status when the current song ends.
|
||||
// This is a generic player mechanism — the web player handles it
|
||||
// without needing to know about the sleep timer.
|
||||
// End-of-album mode: set the same flag conditionally, here we run
|
||||
// the intial evaluation in case the timer was started while already
|
||||
// on the last track of the album
|
||||
useEffect(() => {
|
||||
if (!active || mode !== 'endOfSong') return;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||
if (mode === 'endOfSong') {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}, [active, mode]);
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'endOfAlbum') {
|
||||
evaluateEndOfAlbum();
|
||||
|
||||
return () => {
|
||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [active, mode, evaluateEndOfAlbum]);
|
||||
};
|
||||
|
||||
export const SleepTimerHookInner = () => {
|
||||
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
|
||||
const active = useSleepTimerActive();
|
||||
const mode = useSleepTimerMode();
|
||||
const remaining = useSleepTimerRemaining();
|
||||
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
|
||||
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
|
||||
useSleepTimerActions();
|
||||
const { mediaPause } = usePlayer();
|
||||
const shuffle = usePlayerShuffle();
|
||||
// Track level shuffle scatters and album across a play queue making 'end-of-album'
|
||||
// meaningless. Album shuffle keeps each album intact, so keep 'end-of-'album
|
||||
// enabled there
|
||||
const isTrackShuffle = shuffle === PlayerShuffle.TRACK;
|
||||
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [customHours, setCustomHours] = useState<number>(0);
|
||||
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
|
||||
(option: (typeof PRESET_OPTIONS)[number]) => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
startEndOfSongTimer();
|
||||
} else if (option.mode === 'endOfAlbum') {
|
||||
startEndOfAlbumTimer();
|
||||
} else {
|
||||
startTimedTimer(option.minutes * 60);
|
||||
}
|
||||
setShowCustom(false);
|
||||
setOpened(false);
|
||||
},
|
||||
[startEndOfSongTimer, startTimedTimer],
|
||||
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
|
||||
);
|
||||
|
||||
const handleCustomStart = useCallback(() => {
|
||||
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
|
||||
if (option.mode === 'endOfSong') {
|
||||
return t('player.sleepTimer_endOfSong');
|
||||
}
|
||||
if (option.mode === 'endOfAlbum') {
|
||||
return t('player.sleepTimer_endOfAlbum');
|
||||
}
|
||||
if (option.minutes >= 60) {
|
||||
return t('player.sleepTimer_hours', {
|
||||
count: option.minutes / 60,
|
||||
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
|
||||
<Text c="primary" size="sm">
|
||||
{t('player.sleepTimer_endOfSong')}
|
||||
</Text>
|
||||
) : mode === 'endOfAlbum' ? (
|
||||
<Text c="primary" size="sm">
|
||||
{t('player.sleepTimer_endOfAlbum')}
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="primary" fw="600" size="lg">
|
||||
{formatRemaining(remaining)}
|
||||
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
|
||||
(option, index) => (
|
||||
{PRESET_OPTIONS.filter(
|
||||
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
|
||||
).map((option) => {
|
||||
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
key={index}
|
||||
key={option.mode}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreset(option);
|
||||
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
|
||||
>
|
||||
{getPresetLabel(option)}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider my="md" />
|
||||
|
||||
|
||||
@@ -170,26 +170,20 @@ export const useSaveQueue = () => {
|
||||
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.controller.savePlayQueue({
|
||||
apiClientProps: { serverId },
|
||||
query: {
|
||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||
songs: queue.items.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
|
||||
toast.success({
|
||||
message: t('form.saveQueue.success'),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.saveQueueFailed'),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
return api.controller.savePlayQueue({
|
||||
apiClientProps: { serverId },
|
||||
query: {
|
||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||
songs: queue.items.map((item) => item.id),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.saveQueueFailed'),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { createWithEqualityFn } from 'zustand/traditional';
|
||||
|
||||
export type SleepTimerMode = 'endOfSong' | 'timed';
|
||||
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
|
||||
|
||||
interface SleepTimerActions {
|
||||
cancelTimer: () => void;
|
||||
setRemaining: (remaining: number) => void;
|
||||
setTargetAlbumId: (albumId: null | string) => void;
|
||||
startEndOfAlbumTimer: () => void;
|
||||
startEndOfSongTimer: () => void;
|
||||
startTimedTimer: (durationSeconds: number) => void;
|
||||
}
|
||||
@@ -17,6 +19,8 @@ interface SleepTimerState {
|
||||
mode: SleepTimerMode;
|
||||
/** Remaining seconds (only ticks while playing) */
|
||||
remaining: number;
|
||||
/** Album Id for song when mode activated */
|
||||
targetAlbumId: null | string;
|
||||
}
|
||||
|
||||
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
||||
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
active: false,
|
||||
mode: 'timed',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
mode: 'timed',
|
||||
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
set({ remaining });
|
||||
},
|
||||
|
||||
setTargetAlbumId: (albumId: null | string) => {
|
||||
set({ targetAlbumId: albumId });
|
||||
},
|
||||
|
||||
startEndOfAlbumTimer: () => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'endOfAlbum',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
startEndOfSongTimer: () => {
|
||||
set({
|
||||
active: true,
|
||||
mode: 'endOfSong',
|
||||
remaining: 0,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
||||
active: true,
|
||||
mode: 'timed',
|
||||
remaining: durationSeconds,
|
||||
targetAlbumId: null,
|
||||
});
|
||||
},
|
||||
|
||||
targetAlbumId: null,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
|
||||
useShallow((s) => ({
|
||||
cancelTimer: s.cancelTimer,
|
||||
setRemaining: s.setRemaining,
|
||||
setTargetAlbumId: s.setTargetAlbumId,
|
||||
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
|
||||
startEndOfSongTimer: s.startEndOfSongTimer,
|
||||
startTimedTimer: s.startTimedTimer,
|
||||
})),
|
||||
|
||||
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
similarArtists,
|
||||
songCount: item.SongCount ?? null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
@@ -434,6 +435,7 @@ const normalizePlaylist = (
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
|
||||
const deletePlaylistImage = z.null();
|
||||
|
||||
const deleteArtistImage = deletePlaylistImage;
|
||||
|
||||
const uploadPlaylistImage = z.null();
|
||||
|
||||
const uploadArtistImage = uploadPlaylistImage;
|
||||
|
||||
const deletePlaylistParameters = z.object({
|
||||
Id: z.string(),
|
||||
});
|
||||
@@ -886,7 +894,9 @@ export const jfType = {
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deleteArtistImage,
|
||||
deletePlaylist,
|
||||
deletePlaylistImage,
|
||||
error,
|
||||
favorite,
|
||||
filters,
|
||||
@@ -912,6 +922,8 @@ export const jfType = {
|
||||
studioList,
|
||||
topSongsList,
|
||||
updatePlaylist,
|
||||
uploadArtistImage,
|
||||
uploadPlaylistImage,
|
||||
user,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user