mirror of
https://github.com/jeffvli/feishin.git
synced 2026-07-20 17:36:34 +02:00
Compare commits
9 Commits
4b4d64c7fc
...
v1.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b9312d86fd | |||
| 30a1bda93d | |||
| 0e24eeeb1c | |||
| 58d4dea09a | |||
| c4da44a443 | |||
| be3f959354 | |||
| deb69ef8ea | |||
| 5ac0aaeec0 | |||
| 515cadb916 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "feishin",
|
"name": "feishin",
|
||||||
"version": "1.12.1",
|
"version": "1.13.0",
|
||||||
"description": "A modern self-hosted music player.",
|
"description": "A modern self-hosted music player.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"subsonic",
|
"subsonic",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import cs from './locales/cs.json';
|
|||||||
import de from './locales/de.json';
|
import de from './locales/de.json';
|
||||||
import en from './locales/en.json';
|
import en from './locales/en.json';
|
||||||
import es from './locales/es.json';
|
import es from './locales/es.json';
|
||||||
|
import et from './locales/et.json';
|
||||||
import eu from './locales/eu.json';
|
import eu from './locales/eu.json';
|
||||||
import fa from './locales/fa.json';
|
import fa from './locales/fa.json';
|
||||||
import fi from './locales/fi.json';
|
import fi from './locales/fi.json';
|
||||||
@@ -27,6 +28,8 @@ import sl from './locales/sl.json';
|
|||||||
import sr from './locales/sr.json';
|
import sr from './locales/sr.json';
|
||||||
import sv from './locales/sv.json';
|
import sv from './locales/sv.json';
|
||||||
import ta from './locales/ta.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 tr from './locales/tr.json';
|
||||||
import zhHans from './locales/zh-Hans.json';
|
import zhHans from './locales/zh-Hans.json';
|
||||||
import zhHant from './locales/zh-Hant.json';
|
import zhHant from './locales/zh-Hant.json';
|
||||||
@@ -38,6 +41,7 @@ const resources = {
|
|||||||
de: { translation: de },
|
de: { translation: de },
|
||||||
en: { translation: en },
|
en: { translation: en },
|
||||||
es: { translation: es },
|
es: { translation: es },
|
||||||
|
et: { translation: et },
|
||||||
eu: { translation: eu },
|
eu: { translation: eu },
|
||||||
fa: { translation: fa },
|
fa: { translation: fa },
|
||||||
fi: { translation: fi },
|
fi: { translation: fi },
|
||||||
@@ -57,6 +61,8 @@ const resources = {
|
|||||||
sr: { translation: sr },
|
sr: { translation: sr },
|
||||||
sv: { translation: sv },
|
sv: { translation: sv },
|
||||||
ta: { translation: ta },
|
ta: { translation: ta },
|
||||||
|
th: { translation: th },
|
||||||
|
tl: { translation: tl },
|
||||||
tr: { translation: tr },
|
tr: { translation: tr },
|
||||||
'zh-Hans': { translation: zhHans },
|
'zh-Hans': { translation: zhHans },
|
||||||
'zh-Hant': { translation: zhHant },
|
'zh-Hant': { translation: zhHant },
|
||||||
@@ -87,6 +93,10 @@ export const languages = [
|
|||||||
label: 'Español',
|
label: 'Español',
|
||||||
value: 'es',
|
value: 'es',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Eesti',
|
||||||
|
value: 'et',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Basque',
|
label: 'Basque',
|
||||||
value: 'eu',
|
value: 'eu',
|
||||||
@@ -163,6 +173,14 @@ export const languages = [
|
|||||||
label: 'Tamil',
|
label: 'Tamil',
|
||||||
value: 'ta',
|
value: 'ta',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Thai',
|
||||||
|
value: 'th',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Tagalog',
|
||||||
|
value: 'tl',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Türkçe',
|
label: 'Türkçe',
|
||||||
value: 'tr',
|
value: 'tr',
|
||||||
|
|||||||
@@ -699,6 +699,7 @@
|
|||||||
"viewQueue": "View queue",
|
"viewQueue": "View queue",
|
||||||
"sleepTimer": "Sleep timer",
|
"sleepTimer": "Sleep timer",
|
||||||
"sleepTimer_endOfSong": "End of current song",
|
"sleepTimer_endOfSong": "End of current song",
|
||||||
|
"sleepTimer_endOfAlbum": "End of current album",
|
||||||
"sleepTimer_minutes": "{{count}} min",
|
"sleepTimer_minutes": "{{count}} min",
|
||||||
"sleepTimer_hours": "{{count}} hr",
|
"sleepTimer_hours": "{{count}} hr",
|
||||||
"sleepTimer_custom": "Custom",
|
"sleepTimer_custom": "Custom",
|
||||||
|
|||||||
@@ -288,6 +288,29 @@
|
|||||||
"releaseDate": "Ilmumiskuupäev",
|
"releaseDate": "Ilmumiskuupäev",
|
||||||
"releaseYear": "Ilmumisaasta",
|
"releaseYear": "Ilmumisaasta",
|
||||||
"search": "Otsing",
|
"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_setCustom": "設定定時器",
|
||||||
"sleepTimer_cancel": "取消定時器",
|
"sleepTimer_cancel": "取消定時器",
|
||||||
"albumRadio": "專輯電台",
|
"albumRadio": "專輯電台",
|
||||||
"scrobbleForceSubmit": "強制紀錄"
|
"scrobbleForceSubmit": "強制紀錄",
|
||||||
|
"sleepTimer_endOfAlbum": "專輯播完時"
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
"audioPlayer_description": "選擇用於播放的音訊播放器",
|
||||||
|
|||||||
@@ -141,6 +141,14 @@ ipcMain.on('settings-set', (__event, data: { property: string; value: any }) =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('settings-set-sync', (__event, data: { property: string; value: any }) => {
|
||||||
|
if (data.value === null) {
|
||||||
|
store.delete(data.property);
|
||||||
|
} else {
|
||||||
|
store.set(data.property, data.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('password-get', (_event, server: string): null | string => {
|
ipcMain.handle('password-get', (_event, server: string): null | string => {
|
||||||
if (safeStorage.isEncryptionAvailable()) {
|
if (safeStorage.isEncryptionAvailable()) {
|
||||||
const servers = store.get('server') as Record<string, string> | undefined;
|
const servers = store.get('server') as Record<string, string> | undefined;
|
||||||
|
|||||||
+59
-10
@@ -252,7 +252,9 @@ function createAlphaUpdaterInstance(): AppImageUpdater | MacUpdater | NsisUpdate
|
|||||||
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
|
return new NsisUpdater(ALPHA_UPDATER_CONFIG);
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol.registerSchemesAsPrivileged([{ privileges: { bypassCSP: true }, scheme: 'feishin' }]);
|
protocol.registerSchemesAsPrivileged([
|
||||||
|
{ privileges: { bypassCSP: true, corsEnabled: true }, scheme: 'feishin' },
|
||||||
|
]);
|
||||||
|
|
||||||
process.on('uncaughtException', (error: any) => {
|
process.on('uncaughtException', (error: any) => {
|
||||||
console.error('Error in main process', error);
|
console.error('Error in main process', error);
|
||||||
@@ -989,14 +991,33 @@ app.on('window-all-closed', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const FONT_HEADERS = [
|
const FONT_HEADERS = new Set([
|
||||||
'font/collection',
|
'font/collection',
|
||||||
'font/otf',
|
'font/otf',
|
||||||
'font/sfnt',
|
'font/sfnt',
|
||||||
'font/ttf',
|
'font/ttf',
|
||||||
'font/woff',
|
'font/woff',
|
||||||
'font/woff2',
|
'font/woff2',
|
||||||
];
|
]);
|
||||||
|
|
||||||
|
const bytesToInt = (array: Uint8Array, length: number): number => {
|
||||||
|
let value = 0;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
value = (value << 8) + array[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FONT_FOUR_BYTE_MAGIC_NUMBERS = new Set([
|
||||||
|
0x4f54544f, // font/otf
|
||||||
|
0x774f4632, // font/woff2
|
||||||
|
0x774f4646, // font/woff
|
||||||
|
]);
|
||||||
|
|
||||||
|
const FONT_FIVE_BYTE_MAGIC_NUMBERS = new Set([
|
||||||
|
0x0001000000, // ttf, collection, sfnt
|
||||||
|
]);
|
||||||
|
|
||||||
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
|
const singleInstance = isDevelopment ? true : app.requestSingleInstanceLock();
|
||||||
|
|
||||||
@@ -1017,12 +1038,9 @@ if (!singleInstance) {
|
|||||||
|
|
||||||
app.whenReady()
|
app.whenReady()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
protocol.handle('feishin', async (request) => {
|
protocol.handle('feishin', async () => {
|
||||||
const filePath = `file:${request.url.slice('feishin:'.length)}`;
|
const filePath = store.get('local_font_path');
|
||||||
const response = await net.fetch(filePath);
|
if (typeof filePath !== 'string') {
|
||||||
const contentType = response.headers.get('content-type');
|
|
||||||
|
|
||||||
if (!contentType || !FONT_HEADERS.includes(contentType)) {
|
|
||||||
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
@@ -1031,7 +1049,38 @@ if (!singleInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
const response = await net.fetch('file:' + filePath);
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
|
||||||
|
// On Linux, the mime type is included in the response header
|
||||||
|
// In this case, we can forward the response with no further processing
|
||||||
|
if (contentType && FONT_HEADERS.has(contentType)) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, let's check the magic number to see if
|
||||||
|
// the file is a font type. This is either four or five bytes
|
||||||
|
const payload = await response.arrayBuffer();
|
||||||
|
const magicNumber = new Uint8Array(payload.slice(0, 5));
|
||||||
|
const fiveHex = bytesToInt(magicNumber, 5);
|
||||||
|
const fourHex = bytesToInt(magicNumber, 4);
|
||||||
|
|
||||||
|
if (
|
||||||
|
FONT_FIVE_BYTE_MAGIC_NUMBERS.has(fiveHex) ||
|
||||||
|
FONT_FOUR_BYTE_MAGIC_NUMBERS.has(fourHex)
|
||||||
|
) {
|
||||||
|
// We have to create a new response with the payload, since it has been read now
|
||||||
|
return new Response(payload, {
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMainWindow()?.webContents.send('custom-font-error', filePath);
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 403,
|
||||||
|
statusText: 'Forbidden',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ const set = (
|
|||||||
ipcRenderer.send('settings-set', { property, value });
|
ipcRenderer.send('settings-set', { property, value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSync = async (
|
||||||
|
property: string,
|
||||||
|
value: boolean | null | Record<string, unknown> | string | string[],
|
||||||
|
) => {
|
||||||
|
return ipcRenderer.invoke('settings-set-sync', { property, value });
|
||||||
|
};
|
||||||
|
|
||||||
const get = async (property: string) => {
|
const get = async (property: string) => {
|
||||||
return ipcRenderer.invoke('settings-get', { property });
|
return ipcRenderer.invoke('settings-get', { property });
|
||||||
};
|
};
|
||||||
@@ -99,6 +106,7 @@ export const localSettings = {
|
|||||||
passwordSet,
|
passwordSet,
|
||||||
restart,
|
restart,
|
||||||
set,
|
set,
|
||||||
|
setSync,
|
||||||
setZoomFactor,
|
setZoomFactor,
|
||||||
themeSet,
|
themeSet,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export const utils = {
|
|||||||
rendererToggleSidebar,
|
rendererToggleSidebar,
|
||||||
rendererUpdateAvailable,
|
rendererUpdateAvailable,
|
||||||
saveCustomCss,
|
saveCustomCss,
|
||||||
|
separator: isWindows() ? '\\' : '/',
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
startPowerSaveBlocker,
|
startPowerSaveBlocker,
|
||||||
stopPowerSaveBlocker,
|
stopPowerSaveBlocker,
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deleteArtistImage: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'Items/:id/Images/Primary',
|
||||||
|
responses: {
|
||||||
|
204: jfType._response.deleteArtistImage,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
deletePlaylist: {
|
deletePlaylist: {
|
||||||
body: null,
|
body: null,
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -63,6 +72,15 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
400: jfType._response.error,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
deletePlaylistImage: {
|
||||||
|
body: null,
|
||||||
|
method: 'DELETE',
|
||||||
|
path: 'Items/:id/Images/Primary',
|
||||||
|
responses: {
|
||||||
|
204: jfType._response.deletePlaylistImage,
|
||||||
|
400: jfType._response.error,
|
||||||
|
},
|
||||||
|
},
|
||||||
getAlbumArtistDetail: {
|
getAlbumArtistDetail: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: 'users/:userId/items/:id',
|
path: 'users/:userId/items/:id',
|
||||||
@@ -356,6 +374,24 @@ export const contract = c.router({
|
|||||||
400: jfType._response.error,
|
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({});
|
const axiosClient = axios.create({});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import axios from 'axios';
|
||||||
import { set } from 'idb-keyval';
|
import { set } from 'idb-keyval';
|
||||||
import chunk from 'lodash/chunk';
|
import chunk from 'lodash/chunk';
|
||||||
import filter from 'lodash/filter';
|
import filter from 'lodash/filter';
|
||||||
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
|
|||||||
import {
|
import {
|
||||||
albumArtistListSortMap,
|
albumArtistListSortMap,
|
||||||
albumListSortMap,
|
albumListSortMap,
|
||||||
|
DeleteArtistImageArgs,
|
||||||
|
DeleteArtistImageResponse,
|
||||||
|
DeletePlaylistImageArgs,
|
||||||
|
DeletePlaylistImageResponse,
|
||||||
Folder,
|
Folder,
|
||||||
genreListSortMap,
|
genreListSortMap,
|
||||||
ImageArgs,
|
ImageArgs,
|
||||||
@@ -29,6 +34,10 @@ import {
|
|||||||
SortOrder,
|
SortOrder,
|
||||||
sortOrderMap,
|
sortOrderMap,
|
||||||
Tag,
|
Tag,
|
||||||
|
UploadArtistImageArgs,
|
||||||
|
UploadArtistImageResponse,
|
||||||
|
UploadPlaylistImageArgs,
|
||||||
|
UploadPlaylistImageResponse,
|
||||||
} from '/@/shared/types/domain-types';
|
} from '/@/shared/types/domain-types';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
|
|
||||||
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
|
|||||||
return value.join(',');
|
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
|
// 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
|
// length of the full URL, since the ids are part of the query string and
|
||||||
// not the POST body
|
// not the POST body
|
||||||
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
|
|||||||
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
[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 = {
|
const JF_FIELDS = {
|
||||||
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
id: res.body.Id,
|
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) => {
|
deleteFavorite: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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) => {
|
getAlbumArtistDetail: async (args) => {
|
||||||
const { apiClientProps, query } = args;
|
const { apiClientProps, query } = args;
|
||||||
|
|
||||||
@@ -1847,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
|
|
||||||
return null;
|
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[]) {
|
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 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) {
|
if (Array.isArray(local) && local.length > 0) {
|
||||||
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
const item = local[Math.min(selectedStructuredIndex, local.length - 1)];
|
||||||
return { selected: item, selectedSynced: item.synced };
|
return { selected: item, selectedSynced: item.synced };
|
||||||
@@ -236,6 +232,13 @@ export function getDisplayOffset(
|
|||||||
return storedOffsetMs;
|
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 => ({
|
const emptyResult = (): LyricsQueryResult => ({
|
||||||
local: null,
|
local: null,
|
||||||
overrideData: null,
|
overrideData: null,
|
||||||
@@ -277,16 +280,11 @@ export const lyricsQueries = {
|
|||||||
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
const selectedOffsetMs = prev?.selectedOffsetMs ?? 0;
|
||||||
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
const preferLocalLyrics = useSettingsStore.getState().lyrics.preferLocalLyrics;
|
||||||
|
|
||||||
// Fetch local lyrics
|
|
||||||
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
|
||||||
|
|
||||||
// Fetch remote auto lyrics
|
|
||||||
const remoteAutoPromise =
|
const remoteAutoPromise =
|
||||||
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
suppressRemoteAuto || !useSettingsStore.getState().lyrics.fetch
|
||||||
? null
|
? null
|
||||||
: fetchRemoteLyricsAuto(song);
|
: fetchRemoteLyricsAuto(song);
|
||||||
|
|
||||||
// Fetch override data
|
|
||||||
const overrideDataPromise = overrideSelection
|
const overrideDataPromise = overrideSelection
|
||||||
? fetchRemoteLyricsById({
|
? fetchRemoteLyricsById({
|
||||||
remoteSongId: overrideSelection.id,
|
remoteSongId: overrideSelection.id,
|
||||||
@@ -295,11 +293,40 @@ export const lyricsQueries = {
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const [local, remoteAuto, overrideData] = await Promise.all([
|
const localPromise = fetchLocalLyrics({ serverId: args.serverId, signal, song });
|
||||||
localPromise,
|
|
||||||
remoteAutoPromise,
|
let local: FullLyricsMetadata | null | StructuredLyric[];
|
||||||
overrideDataPromise,
|
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<
|
const partial: Pick<
|
||||||
LyricsQueryResult,
|
LyricsQueryResult,
|
||||||
@@ -320,13 +347,12 @@ export const lyricsQueries = {
|
|||||||
preferLocalLyrics,
|
preferLocalLyrics,
|
||||||
selectedStructuredIndex,
|
selectedStructuredIndex,
|
||||||
);
|
);
|
||||||
const displayOffset = getDisplayOffset(
|
const resultSelectedOffsetMs = getDisplayOffset(
|
||||||
selected,
|
selected,
|
||||||
selectedOffsetMs,
|
selectedOffsetMs,
|
||||||
selectedStructuredIndex,
|
selectedStructuredIndex,
|
||||||
local,
|
local,
|
||||||
);
|
);
|
||||||
const resultSelectedOffsetMs = displayOffset;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...emptyResult(),
|
...emptyResult(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useIsFetching } from '@tanstack/react-query';
|
import { useIsFetching } from '@tanstack/react-query';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { RefObject } from 'react';
|
import { RefObject, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import styles from './play-queue-list-controls.module.css';
|
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 { Box } from '/@/shared/components/box/box';
|
||||||
import { Divider } from '/@/shared/components/divider/divider';
|
import { Divider } from '/@/shared/components/divider/divider';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { ServerFeature } from '/@/shared/types/features-types';
|
import { ServerFeature } from '/@/shared/types/features-types';
|
||||||
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
import { ItemListKey, ListDisplayType } from '/@/shared/types/types';
|
||||||
|
|
||||||
@@ -135,7 +136,17 @@ const QueueRestoreActions = () => {
|
|||||||
|
|
||||||
const isFetching = useIsFetching({ queryKey: queryKeys.player.fetch({ type: 'queue' }) });
|
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();
|
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 { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
|
||||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
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 {
|
import {
|
||||||
useSleepTimerActions,
|
useSleepTimerActions,
|
||||||
useSleepTimerActive,
|
useSleepTimerActive,
|
||||||
@@ -21,10 +25,11 @@ import { NumberInput } from '/@/shared/components/number-input/number-input';
|
|||||||
import { Popover } from '/@/shared/components/popover/popover';
|
import { Popover } from '/@/shared/components/popover/popover';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { PlayerStatus } from '/@/shared/types/types';
|
import { PlayerShuffle, PlayerStatus } from '/@/shared/types/types';
|
||||||
|
|
||||||
const PRESET_OPTIONS = [
|
const PRESET_OPTIONS = [
|
||||||
{ minutes: 0, mode: 'endOfSong' as const },
|
{ minutes: 0, mode: 'endOfSong' as const },
|
||||||
|
{ minutes: 0, mode: 'endOfAlbum' as const },
|
||||||
{ minutes: 5, mode: 'timed' as const },
|
{ minutes: 5, mode: 'timed' as const },
|
||||||
{ minutes: 10, mode: 'timed' as const },
|
{ minutes: 10, mode: 'timed' as const },
|
||||||
{ minutes: 15, mode: 'timed' as const },
|
{ minutes: 15, mode: 'timed' as const },
|
||||||
@@ -50,12 +55,38 @@ function formatRemaining(totalSeconds: number): string {
|
|||||||
const useSleepTimer = () => {
|
const useSleepTimer = () => {
|
||||||
const active = useSleepTimerActive();
|
const active = useSleepTimerActive();
|
||||||
const mode = useSleepTimerMode();
|
const mode = useSleepTimerMode();
|
||||||
const { cancelTimer, setRemaining } = useSleepTimerActions();
|
const { cancelTimer, setRemaining, setTargetAlbumId } = useSleepTimerActions();
|
||||||
const { mediaPause } = usePlayer();
|
const { mediaPause } = usePlayer();
|
||||||
|
|
||||||
const mediaPauseRef = useRef(mediaPause);
|
const mediaPauseRef = useRef(mediaPause);
|
||||||
mediaPauseRef.current = 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(() => {
|
const handleOnCurrentSongChange = useCallback(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
@@ -65,8 +96,14 @@ const useSleepTimer = () => {
|
|||||||
if (mode === 'endOfSong') {
|
if (mode === 'endOfSong') {
|
||||||
cancelTimer();
|
cancelTimer();
|
||||||
mediaPauseRef.current();
|
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();
|
const status = usePlayerStatus();
|
||||||
|
|
||||||
@@ -104,15 +141,32 @@ const useSleepTimer = () => {
|
|||||||
// mediaAutoNext returns PAUSED status when the current song ends.
|
// mediaAutoNext returns PAUSED status when the current song ends.
|
||||||
// This is a generic player mechanism — the web player handles it
|
// This is a generic player mechanism — the web player handles it
|
||||||
// without needing to know about the sleep timer.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!active || mode !== 'endOfSong') return;
|
if (!active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
if (mode === 'endOfSong') {
|
||||||
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(true);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||||
};
|
};
|
||||||
}, [active, mode]);
|
}
|
||||||
|
|
||||||
|
if (mode === 'endOfAlbum') {
|
||||||
|
evaluateEndOfAlbum();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
usePlayerStoreBase.getState().setPauseOnNextSongEnd(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}, [active, mode, evaluateEndOfAlbum]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SleepTimerHookInner = () => {
|
export const SleepTimerHookInner = () => {
|
||||||
@@ -135,8 +189,14 @@ export const SleepTimerButton = () => {
|
|||||||
const active = useSleepTimerActive();
|
const active = useSleepTimerActive();
|
||||||
const mode = useSleepTimerMode();
|
const mode = useSleepTimerMode();
|
||||||
const remaining = useSleepTimerRemaining();
|
const remaining = useSleepTimerRemaining();
|
||||||
const { cancelTimer, startEndOfSongTimer, startTimedTimer } = useSleepTimerActions();
|
const { cancelTimer, startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer } =
|
||||||
|
useSleepTimerActions();
|
||||||
const { mediaPause } = usePlayer();
|
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 [showCustom, setShowCustom] = useState(false);
|
||||||
const [customHours, setCustomHours] = useState<number>(0);
|
const [customHours, setCustomHours] = useState<number>(0);
|
||||||
@@ -151,13 +211,15 @@ export const SleepTimerButton = () => {
|
|||||||
(option: (typeof PRESET_OPTIONS)[number]) => {
|
(option: (typeof PRESET_OPTIONS)[number]) => {
|
||||||
if (option.mode === 'endOfSong') {
|
if (option.mode === 'endOfSong') {
|
||||||
startEndOfSongTimer();
|
startEndOfSongTimer();
|
||||||
|
} else if (option.mode === 'endOfAlbum') {
|
||||||
|
startEndOfAlbumTimer();
|
||||||
} else {
|
} else {
|
||||||
startTimedTimer(option.minutes * 60);
|
startTimedTimer(option.minutes * 60);
|
||||||
}
|
}
|
||||||
setShowCustom(false);
|
setShowCustom(false);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
},
|
},
|
||||||
[startEndOfSongTimer, startTimedTimer],
|
[startEndOfAlbumTimer, startEndOfSongTimer, startTimedTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCustomStart = useCallback(() => {
|
const handleCustomStart = useCallback(() => {
|
||||||
@@ -178,6 +240,9 @@ export const SleepTimerButton = () => {
|
|||||||
if (option.mode === 'endOfSong') {
|
if (option.mode === 'endOfSong') {
|
||||||
return t('player.sleepTimer_endOfSong');
|
return t('player.sleepTimer_endOfSong');
|
||||||
}
|
}
|
||||||
|
if (option.mode === 'endOfAlbum') {
|
||||||
|
return t('player.sleepTimer_endOfAlbum');
|
||||||
|
}
|
||||||
if (option.minutes >= 60) {
|
if (option.minutes >= 60) {
|
||||||
return t('player.sleepTimer_hours', {
|
return t('player.sleepTimer_hours', {
|
||||||
count: option.minutes / 60,
|
count: option.minutes / 60,
|
||||||
@@ -231,6 +296,10 @@ export const SleepTimerButton = () => {
|
|||||||
<Text c="primary" size="sm">
|
<Text c="primary" size="sm">
|
||||||
{t('player.sleepTimer_endOfSong')}
|
{t('player.sleepTimer_endOfSong')}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : mode === 'endOfAlbum' ? (
|
||||||
|
<Text c="primary" size="sm">
|
||||||
|
{t('player.sleepTimer_endOfAlbum')}
|
||||||
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text c="primary" fw="600" size="lg">
|
<Text c="primary" fw="600" size="lg">
|
||||||
{formatRemaining(remaining)}
|
{formatRemaining(remaining)}
|
||||||
@@ -249,12 +318,17 @@ export const SleepTimerButton = () => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{PRESET_OPTIONS.filter((option) => option.mode === 'endOfSong').map(
|
{PRESET_OPTIONS.filter(
|
||||||
(option, index) => (
|
(option) => option.mode === 'endOfSong' || option.mode === 'endOfAlbum',
|
||||||
|
).map((option) => {
|
||||||
|
const disabled = option.mode === 'endOfAlbum' && isTrackShuffle;
|
||||||
|
|
||||||
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
fullWidth
|
fullWidth
|
||||||
justify="flex-start"
|
justify="flex-start"
|
||||||
key={index}
|
key={option.mode}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handlePreset(option);
|
handlePreset(option);
|
||||||
@@ -264,8 +338,8 @@ export const SleepTimerButton = () => {
|
|||||||
>
|
>
|
||||||
{getPresetLabel(option)}
|
{getPresetLabel(option)}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
);
|
||||||
)}
|
})}
|
||||||
|
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|
||||||
|
|||||||
@@ -170,26 +170,20 @@ export const useSaveQueue = () => {
|
|||||||
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
throw new Error(`${t('error.multipleServerSaveQueueError')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return api.controller.savePlayQueue({
|
||||||
await api.controller.savePlayQueue({
|
apiClientProps: { serverId },
|
||||||
apiClientProps: { serverId },
|
query: {
|
||||||
query: {
|
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
||||||
currentIndex: queue.items.length > 0 ? state.player.index : undefined,
|
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
||||||
positionMs: useTimestampStoreBase.getState().timestamp * 1000,
|
songs: queue.items.map((item) => item.id),
|
||||||
songs: queue.items.map((item) => item.id),
|
},
|
||||||
},
|
});
|
||||||
});
|
},
|
||||||
|
onError: (error) => {
|
||||||
toast.success({
|
toast.error({
|
||||||
message: t('form.saveQueue.success'),
|
message: (error as Error).message,
|
||||||
});
|
title: t('error.saveQueueFailed'),
|
||||||
} catch (error) {
|
});
|
||||||
toast.error({
|
|
||||||
message: (error as Error).message,
|
|
||||||
title: t('error.saveQueueFailed'),
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import { FontType } from '/@/shared/types/types';
|
|||||||
|
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
const ipc = isElectron() ? window.api.ipc : null;
|
const ipc = isElectron() ? window.api.ipc : null;
|
||||||
|
const utils = isElectron() ? window.api.utils : null;
|
||||||
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
// Electron 32+ removed file.path, use this which is exposed in preload to get real path
|
||||||
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
const getPathForFile = isElectron() ? window.api.getPathForFile : null;
|
||||||
|
|
||||||
@@ -289,21 +290,29 @@ export const ApplicationSettings = memo(() => {
|
|||||||
control: (
|
control: (
|
||||||
<FileInput
|
<FileInput
|
||||||
accept=".ttc,.ttf,.otf,.woff,.woff2"
|
accept=".ttc,.ttf,.otf,.woff,.woff2"
|
||||||
onChange={(e) =>
|
clearable
|
||||||
|
defaultValue={
|
||||||
|
fontSettings.custom
|
||||||
|
? new File([], fontSettings.custom.split(utils?.separator || '').pop()!)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={async (e) => {
|
||||||
|
const custom = e ? getPathForFile?.(e) || null : null;
|
||||||
|
await localSettings?.setSync('local_font_path', custom);
|
||||||
setSettings({
|
setSettings({
|
||||||
font: {
|
font: {
|
||||||
...fontSettings,
|
...fontSettings,
|
||||||
custom: e ? getPathForFile?.(e) || null : null,
|
custom,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
w={300}
|
w={300}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
description: t('setting.customFontPath', {
|
description: t('setting.customFontPath', {
|
||||||
context: 'description',
|
context: 'description',
|
||||||
}),
|
}),
|
||||||
isHidden: fontSettings.type !== FontType.CUSTOM,
|
isHidden: !isElectron() || fontSettings.type !== FontType.CUSTOM,
|
||||||
title: t('setting.customFontPath'),
|
title: t('setting.customFontPath'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const useSyncSettingsToMain = () => {
|
|||||||
const settingsFromStore = useSettingsStore.getState();
|
const settingsFromStore = useSettingsStore.getState();
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
|
font: settingsFromStore.font,
|
||||||
general: settingsFromStore.general,
|
general: settingsFromStore.general,
|
||||||
hotkeys: settingsFromStore.hotkeys,
|
hotkeys: settingsFromStore.hotkeys,
|
||||||
lyrics: settingsFromStore.lyrics,
|
lyrics: settingsFromStore.lyrics,
|
||||||
@@ -101,6 +102,10 @@ export const useSyncSettingsToMain = () => {
|
|||||||
mainStoreKey: 'enableNeteaseTranslation',
|
mainStoreKey: 'enableNeteaseTranslation',
|
||||||
rendererValue: settings.lyrics.enableNeteaseTranslation,
|
rendererValue: settings.lyrics.enableNeteaseTranslation,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
mainStoreKey: 'local_font_path',
|
||||||
|
rendererValue: settings.font.custom,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Compare and sync each setting
|
// Compare and sync each setting
|
||||||
|
|||||||
@@ -1185,6 +1185,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
mediaSeekToTimestamp: (timestamp: number) => {
|
mediaSeekToTimestamp: (timestamp: number) => {
|
||||||
|
// See mediaSkipBackward: update the timestamp store right away to
|
||||||
|
// avoid the stale-read left by the ~500ms engine poll.
|
||||||
|
setTimestampStore(timestamp);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(timestamp);
|
||||||
});
|
});
|
||||||
@@ -1196,6 +1199,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
|
const newTimestamp = Math.max(0, currentTimestamp - timeToSkip);
|
||||||
|
|
||||||
|
// Update the timestamp store right away so the UI and any
|
||||||
|
// subsequent seek compute from the new position instead of the
|
||||||
|
// stale value left by the ~500ms engine poll (otherwise mashing
|
||||||
|
// the seek keys repeatedly lands on the same time).
|
||||||
|
setTimestampStore(newTimestamp);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||||
});
|
});
|
||||||
@@ -1217,6 +1225,9 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
|||||||
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
const currentTimestamp = useTimestampStoreBase.getState().timestamp;
|
||||||
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
|
const newTimestamp = Math.min(duration - 1, currentTimestamp + timeToSkip);
|
||||||
|
|
||||||
|
// See mediaSkipBackward: update the timestamp store right away to
|
||||||
|
// avoid the stale-read left by the ~500ms engine poll.
|
||||||
|
setTimestampStore(newTimestamp);
|
||||||
set((state) => {
|
set((state) => {
|
||||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { createWithEqualityFn } from 'zustand/traditional';
|
import { createWithEqualityFn } from 'zustand/traditional';
|
||||||
|
|
||||||
export type SleepTimerMode = 'endOfSong' | 'timed';
|
export type SleepTimerMode = 'endOfAlbum' | 'endOfSong' | 'timed';
|
||||||
|
|
||||||
interface SleepTimerActions {
|
interface SleepTimerActions {
|
||||||
cancelTimer: () => void;
|
cancelTimer: () => void;
|
||||||
setRemaining: (remaining: number) => void;
|
setRemaining: (remaining: number) => void;
|
||||||
|
setTargetAlbumId: (albumId: null | string) => void;
|
||||||
|
startEndOfAlbumTimer: () => void;
|
||||||
startEndOfSongTimer: () => void;
|
startEndOfSongTimer: () => void;
|
||||||
startTimedTimer: (durationSeconds: number) => void;
|
startTimedTimer: (durationSeconds: number) => void;
|
||||||
}
|
}
|
||||||
@@ -17,6 +19,8 @@ interface SleepTimerState {
|
|||||||
mode: SleepTimerMode;
|
mode: SleepTimerMode;
|
||||||
/** Remaining seconds (only ticks while playing) */
|
/** Remaining seconds (only ticks while playing) */
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
/** Album Id for song when mode activated */
|
||||||
|
targetAlbumId: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & SleepTimerState>()(
|
||||||
@@ -27,6 +31,7 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
active: false,
|
active: false,
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
@@ -36,11 +41,25 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
set({ remaining });
|
set({ remaining });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setTargetAlbumId: (albumId: null | string) => {
|
||||||
|
set({ targetAlbumId: albumId });
|
||||||
|
},
|
||||||
|
|
||||||
|
startEndOfAlbumTimer: () => {
|
||||||
|
set({
|
||||||
|
active: true,
|
||||||
|
mode: 'endOfAlbum',
|
||||||
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
startEndOfSongTimer: () => {
|
startEndOfSongTimer: () => {
|
||||||
set({
|
set({
|
||||||
active: true,
|
active: true,
|
||||||
mode: 'endOfSong',
|
mode: 'endOfSong',
|
||||||
remaining: 0,
|
remaining: 0,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -49,8 +68,11 @@ export const useSleepTimerStore = createWithEqualityFn<SleepTimerActions & Sleep
|
|||||||
active: true,
|
active: true,
|
||||||
mode: 'timed',
|
mode: 'timed',
|
||||||
remaining: durationSeconds,
|
remaining: durationSeconds,
|
||||||
|
targetAlbumId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
targetAlbumId: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -63,6 +85,8 @@ export const useSleepTimerActions = () =>
|
|||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
cancelTimer: s.cancelTimer,
|
cancelTimer: s.cancelTimer,
|
||||||
setRemaining: s.setRemaining,
|
setRemaining: s.setRemaining,
|
||||||
|
setTargetAlbumId: s.setTargetAlbumId,
|
||||||
|
startEndOfAlbumTimer: s.startEndOfAlbumTimer,
|
||||||
startEndOfSongTimer: s.startEndOfSongTimer,
|
startEndOfSongTimer: s.startEndOfSongTimer,
|
||||||
startTimedTimer: s.startTimedTimer,
|
startTimedTimer: s.startTimedTimer,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export const useAppTheme = (overrideTheme?: AppTheme) => {
|
|||||||
document.body.appendChild(textStyleRef.current);
|
document.body.appendChild(textStyleRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: we change the url to bust caches when changing the path
|
||||||
|
// The url provided here does NOT matter, validation is done
|
||||||
|
// on the main process. Any feishin:/ url will fetch the same
|
||||||
|
// item, which the renderer will check via magic number to be
|
||||||
|
// some font item
|
||||||
textStyleRef.current.textContent = `
|
textStyleRef.current.textContent = `
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "dynamic-font";
|
font-family: "dynamic-font";
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
|
|||||||
playCount: item.UserData?.PlayCount || 0,
|
playCount: item.UserData?.PlayCount || 0,
|
||||||
similarArtists,
|
similarArtists,
|
||||||
songCount: item.SongCount ?? null,
|
songCount: item.SongCount ?? null,
|
||||||
|
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||||
userFavorite: item.UserData?.IsFavorite || false,
|
userFavorite: item.UserData?.IsFavorite || false,
|
||||||
userRating: null,
|
userRating: null,
|
||||||
};
|
};
|
||||||
@@ -434,6 +435,7 @@ const normalizePlaylist = (
|
|||||||
size: null,
|
size: null,
|
||||||
songCount: item?.ChildCount || null,
|
songCount: item?.ChildCount || null,
|
||||||
sync: null,
|
sync: null,
|
||||||
|
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
|
|||||||
|
|
||||||
const deletePlaylist = z.null();
|
const deletePlaylist = z.null();
|
||||||
|
|
||||||
|
const deletePlaylistImage = z.null();
|
||||||
|
|
||||||
|
const deleteArtistImage = deletePlaylistImage;
|
||||||
|
|
||||||
|
const uploadPlaylistImage = z.null();
|
||||||
|
|
||||||
|
const uploadArtistImage = uploadPlaylistImage;
|
||||||
|
|
||||||
const deletePlaylistParameters = z.object({
|
const deletePlaylistParameters = z.object({
|
||||||
Id: z.string(),
|
Id: z.string(),
|
||||||
});
|
});
|
||||||
@@ -886,7 +894,9 @@ export const jfType = {
|
|||||||
albumList,
|
albumList,
|
||||||
authenticate,
|
authenticate,
|
||||||
createPlaylist,
|
createPlaylist,
|
||||||
|
deleteArtistImage,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
|
deletePlaylistImage,
|
||||||
error,
|
error,
|
||||||
favorite,
|
favorite,
|
||||||
filters,
|
filters,
|
||||||
@@ -912,6 +922,8 @@ export const jfType = {
|
|||||||
studioList,
|
studioList,
|
||||||
topSongsList,
|
topSongsList,
|
||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
|
uploadArtistImage,
|
||||||
|
uploadPlaylistImage,
|
||||||
user,
|
user,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user