Compare commits

...

27 Commits

Author SHA1 Message Date
jeffvli 5900d41e0a handle sticky elements on new layout 2026-04-04 13:42:50 -07:00
jeffvli efe94b3a3b inset the windowbar 2026-04-04 13:25:35 -07:00
jeffvli 231b6f3865 inset the playerbar 2026-04-04 13:21:22 -07:00
jeffvli 2fbd3ab02d inset the main content / sidebars 2026-04-04 13:21:01 -07:00
jeffvli 141a20f042 refactor item table props 2026-04-04 12:34:27 -07:00
jeffvli 1592204515 add fallback sort order for subsonic playlist list 2026-04-04 12:03:41 -07:00
jeffvli b9f5459725 fix layout shift on grid carousel page change 2026-04-03 20:25:12 -07:00
jeffvli d4e9b9b7a6 adjust bg loading on album detail page 2026-04-03 20:11:10 -07:00
jeffvli ec9e4b1339 fix type error due to new param on mediaStop 2026-04-03 19:09:42 -07:00
Hosted Weblate f09109b887 Translated using Weblate
Currently translated at 100.0% (1194 of 1194 strings) (Czech)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/cs/

Translated using Weblate

Currently translated at 100.0% (1194 of 1194 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Translated using Weblate

Currently translated at 100.0% (1194 of 1194 strings) (Chinese (Traditional Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hant/

Translated using Weblate

Currently translated at 100.0% (1193 of 1193 strings) (French)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/fr/

Translated using Weblate

Currently translated at 100.0% (1193 of 1193 strings) (Polish)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/pl/

Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: KosmoMoustache <kosmomoustache@users.noreply.hosted.weblate.org>
Co-authored-by: York <goog10216922@gmail.com>
Co-authored-by: skajmer <skajmer@protonmail.com>
2026-04-04 04:05:50 +02:00
jeffvli 1494c8e044 fix mpv seek error on queue end 2026-04-03 19:05:34 -07:00
jeffvli f3a6027e6d fix mpv progress interval still running after queue ends 2026-04-03 18:58:58 -07:00
jeffvli 3c42355c1e attempt to fix mpv playback sync on song insertion (#1855) 2026-04-03 18:54:49 -07:00
jeffvli feda1bb06f remove square image param, default item id for image 2026-04-03 11:24:39 -07:00
jeffvli 72f1d2f9f9 improve date parsing for partial dates (#1683) 2026-04-02 19:39:08 -07:00
jeffvli ad11a9303c add playlist description to expanded header 2026-04-02 18:36:42 -07:00
jeffvli db06e7f601 add native nd radio endpoints, support radio station images 2026-04-02 18:26:26 -07:00
jeffvli fbf82c1ef0 add playlist image upload to edit playlist modal 2026-04-02 17:41:25 -07:00
jeffvli 92cea5dfda add log for direct play profiles 2026-04-02 01:27:14 -07:00
jeffvli 7442f9d3ca support navidrome playlist image upload 2026-04-02 01:23:09 -07:00
jeffvli 68dacea228 use resized images in artist header 2026-04-01 21:57:32 -07:00
jeffvli 51425b5e86 various performance refactors 2026-04-01 21:57:26 -07:00
jeffvli c60610cb42 lint files 2026-03-31 21:12:48 -07:00
jeffvli d3881ee3be support limitPercent for smart playlists 2026-03-31 21:09:13 -07:00
jeffvli de403ea6ac add new nd smart playlist fields
- averagerating

- albumdateloved
- albumlastplayed
- albumdaterated
- albumloved
- albumrating

- artistdateloved
 -artistlastplayed
- artistdaterated
- artistloved
- artistplaycount
2026-03-31 20:55:36 -07:00
jeffvli a30b1ec90b add OS transcoding extension 2026-03-31 20:45:22 -07:00
Hosted Weblate 7982c0e1bd Translated using Weblate
Currently translated at 100.0% (1193 of 1193 strings) (Chinese (Simplified Han script))
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/zh_Hans/

Translated using Weblate

Currently translated at 83.4% (996 of 1193 strings) (Basque)
Translation: feishin/Translation
Translate-URL: https://hosted.weblate.org/projects/feishin/translation/eu/

Co-authored-by: Aitor Astorga <a.astorga.sdv@protonmail.com>
Co-authored-by: 無情天 <kofzhanganguo@126.com>
2026-03-31 16:09:57 +02:00
100 changed files with 3478 additions and 1260 deletions
+3
View File
@@ -1110,6 +1110,9 @@
"export": "exportovat texty",
"input_synced": "exportovat synchronizované texty",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stanice rádia úspěšně upravena"
}
},
"entity": {
+3
View File
@@ -364,6 +364,9 @@
"input_name": "name",
"input_streamUrl": "stream url"
},
"editRadioStation": {
"success": "radio station updated successfully"
},
"deletePlaylist": {
"input_confirm": "type the name of the $t(entity.playlist, {\"count\": 1}) to confirm",
"success": "$t(entity.playlist, {\"count\": 1}) deleted successfully",
+51 -4
View File
@@ -574,7 +574,7 @@
"hotkey_browserForward": "nabigatzailean aurreraka",
"imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa",
"lyricFetchProvider": "letrak eskuratzeko hornitzaileak",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak",
"minimizeToTray": "minimizatu erretilura",
"minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura",
"minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)",
@@ -688,7 +688,33 @@
"remotePort_description": "urruneko kontrol zerbitzariaren portua ezartzen du",
"remotePort": "urruneko kontrol zerbitzariaren ataka",
"remoteUsername_description": "urruneko kontrol zerbitzariaren erabiltzaile-izena ezartzen du. Erabiltzaile-izena eta pasahitza hutsik badaude, autentifikazioa desgaituta egongo da",
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena"
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena",
"logLevel_optionWarn": "abisua",
"qobuz_description": "erakutsi Qobuz-erako estekak artista/album orrialdeetan",
"qobuz": "erakutsi Qobuz-erako estekak",
"spotify_description": "erakutsi Spotify-rako estekak artista/album orrialdeetan",
"spotify": "erakutsi Spotify-rako estekak",
"nativeSpotify_description": "ireki Spotify aplikazioan, arakatzailearen ordez",
"nativeSpotify": "erabili Spotify aplikazioa",
"playerbarSlider_description": "uhin-forma ez da gomendagarria interneteko konexio motela edo neurtua baduzu",
"playerbarSliderType_optionWaveform": "uhin-forma",
"playerbarWaveformAlign": "uhin-formaren lerrokatzea",
"playerbarWaveformAlign_optionTop": "nagusia",
"playerbarWaveformBarWidth": "uhin-formako barraren zabalera",
"playerbarWaveformGap": "uhin-formaren tartea",
"playerbarWaveformRadius": "uhin-formaren erradioa",
"showLyricsInSidebar_description": "letrak erakusten dituen panel bat gehituko da erantsitako erreprodukzio-ilaran",
"showLyricsInSidebar": "erakutsi letra erreproduzitzailearen alboko barran",
"blurExplicitImages": "irudi esplizituak lausotu",
"blurExplicitImages_description": "esplizitu gisa etiketatutako albumaren eta abestiaren azalak lausotuta agertuko dira",
"enableGridMultiSelect": "gaitu sareta anitzeko hautaketa",
"enableGridMultiSelect_description": "gaituta dagoenean, sareta-ikuspegietan hainbat elementu hautatzea ahalbidetzen du. desgaituta dagoenean, sareta-elementuen irudietan klik egitean elementuaren orrialdera nabigatuko da",
"showVisualizerInSidebar_description": "bistaratzailea erakusten duen panel bat gehituko da erreproduzitzailearen alboko barran",
"preservePitch_description": "erreprodukzio-abiadura aldatzean tonua mantentzen du",
"preservePitch": "mantendu tonua",
"preventSleepOnPlayback": "erreprodukzioan loa saihestu",
"replayGainClipping_description": "Saihestu {{ReplayGain}}-k eragindako mozketa irabazpena automatikoki jaitsiz",
"replayGainMode_description": "doitu bolumenaren irabazia fitxategiaren metadatuetan gordetako {{ReplayGain}} balioen arabera"
},
"form": {
"addServer": {
@@ -943,7 +969,8 @@
"nowPlaying": "orain erreproduzitzen",
"shared": "partekatutako $t(entity.playlist, {\"count\": 2})",
"favorites": "$t(entity.favorite, {\"count\": 2})",
"radio": "$t(entity.radioStation, {\"count\": 2})"
"radio": "$t(entity.radioStation, {\"count\": 2})",
"collections": "bildumak"
},
"trackList": {
"title": "$t(entity.track, {\"count\": 2})",
@@ -1112,6 +1139,26 @@
"saveAsPreset": "Aurrezarpen gisa gorde",
"applyPreset": "Aurrezarpena Aplikatu",
"selectPreset": "Aukeratu Aurrezarpena",
"presets": "Aurrezarpenak"
"presets": "Aurrezarpenak",
"visualizerType": "Bistaratzaile Mota",
"cycleTime": "Zikloaren denbora (segundoak)",
"includeAllPresets": "Aurrezarpen guztiak sartu",
"ignoredPresets": "Aurrezarpen baztertuak",
"selectedPresets": "Hautatutako aurrezarpenak",
"mode1To8": "1 - 8 modua",
"mode10": "10 modua",
"gradientLeft": "Gradientearen ezkerra",
"gradientRight": "Gradientearen eskuina",
"peakBehavior": "Gailurraren Portaera",
"peakLine": "Gailurraren lerroa",
"miscellaneousSettings": "Hainbat ezarpen",
"alphaBars": "Alfa barrak",
"ansiBands": "ANSI bandak",
"ledBars": "LED barrak",
"trueLeds": "True LED-ak",
"roundBars": "Barra biribilduak",
"lowResolution": "Erresoluzio baxua",
"showFPS": "Erakutsi FPS",
"showScaleX": "Erakutsi X eskala"
}
}
+10 -8
View File
@@ -891,7 +891,9 @@
"sidePlayQueueLayout": "disposition de la file d'attente",
"sidePlayQueueLayout_description": "définit la disposition de la file d'attente attaché",
"sidePlayQueueLayout_optionHorizontal": "horizontal",
"sidePlayQueueLayout_optionVertical": "vertical"
"sidePlayQueueLayout_optionVertical": "vertical",
"waveformLoadingDelay": "délai de chargement de la forme d'onde",
"waveformLoadingDelay_description": "délai en secondes avant le chargement de l'onde. augmentez cette valeur si vous rencontrez des saccades lors de l'utilisation du lecteur web."
},
"form": {
"deletePlaylist": {
@@ -1090,7 +1092,7 @@
"pagination_itemsPerPage": "entrées par page",
"pagination_infinite": "infini",
"pagination_paginate": "paginé",
"alternateRowColors": "alterner les couleurs des lignes",
"alternateRowColors": "alterner la couleur des lignes",
"horizontalBorders": "bordures de ligne",
"rowHoverHighlight": "surligner les lignes au survol",
"verticalBorders": "bordure de colonne",
@@ -1232,12 +1234,12 @@
},
"visualizer": {
"visualizerType": "type de visualisateur",
"cyclePresets": "cycle les préréglages",
"cycleTime": "temps de cycle (secondes)",
"cyclePresets": "cycler les préréglages",
"cycleTime": "durée d'un cycle (secondes)",
"includeAllPresets": "inclure tous les préréglages",
"ignoredPresets": "préréglages ignorés",
"selectedPresets": "préréglages sélectionné",
"randomizeNextPreset": "randomiser le préréglage suivant",
"selectedPresets": "préréglages sélectionnés",
"randomizeNextPreset": "préréglage suivant aléatoire",
"blendTime": "temps de mélange",
"presets": "préréglages",
"selectPreset": "sélectionner un préréglage",
@@ -1247,7 +1249,7 @@
"copyConfiguration": "copier la configuration",
"pasteConfiguration": "coller la configuration",
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
"pasteFromClipboard": "coller depuis le presse-papier",
"pasteFromClipboard": "coller depuis le presse-papiers",
"applyConfiguration": "appliquer la configuration",
"configCopied": "configuration copiée dans le presse-papiers",
"configCopyFailed": "échec de la copie de la configuration",
@@ -1272,7 +1274,7 @@
"gradientNamePlaceholder": "nom du dégradé",
"vertical": "verticale",
"horizontal": "horizontale",
"colorStops": "couleur d'arrêts",
"colorStops": "Points de Couleur",
"addColor": "ajouter un couleur",
"position": "position",
"level": "niveau",
+8 -2
View File
@@ -169,7 +169,8 @@
"filter_single": "single",
"rename": "zmień nazwę",
"newVersionAvailable": "nowa wersja jest dostępna",
"numberOfResults": "{{numberOfResults}} wyników"
"numberOfResults": "{{numberOfResults}} wyników",
"grouping": "grupowanie"
},
"entity": {
"genre_one": "gatunek",
@@ -420,6 +421,9 @@
"export": "eksportuj tekst",
"input_synced": "eksportuj zsynchronizowany tekst",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "stacja radiowa zaktualizowana pomyślnie"
}
},
"page": {
@@ -1058,7 +1062,9 @@
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
"sidePlayQueueLayout_optionHorizontal": "poziomy",
"sidePlayQueueLayout_optionVertical": "pionowy"
"sidePlayQueueLayout_optionVertical": "pionowy",
"waveformLoadingDelay": "opóźnienie załadowania fali",
"waveformLoadingDelay_description": "opóźnienie w sekundach przed załadowaniem fali. zwiększ tą wartość jeżeli doświadczasz zawieszania się odtwarzacza przeglądarkowego."
},
"table": {
"config": {
+5 -2
View File
@@ -161,7 +161,8 @@
"rename": "重命名",
"filter_multiple": "多项",
"newVersionAvailable": "新版本现已可用",
"numberOfResults": "{{numberOfResults}} 结果"
"numberOfResults": "{{numberOfResults}} 结果",
"grouping": "分组"
},
"entity": {
"albumArtist_other": "专辑艺术家",
@@ -609,7 +610,9 @@
"sidePlayQueueLayout": "侧边播放队列布局",
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
"sidePlayQueueLayout_optionHorizontal": "水平",
"sidePlayQueueLayout_optionVertical": "垂直"
"sidePlayQueueLayout_optionVertical": "垂直",
"waveformLoadingDelay": "波形加载延迟",
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
},
"error": {
"remotePortWarning": "重启服务器使新端口生效",
+3
View File
@@ -1124,6 +1124,9 @@
"export": "匯出歌詞",
"input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電臺更新成功"
}
},
"releaseType": {
+10 -2
View File
@@ -437,10 +437,18 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
try {
return getMpvInstance()?.getTimePosition();
const mpv = getMpvInstance();
if (!mpv) {
return undefined;
}
return await mpv.getTimePosition();
} catch (err: any | NodeMpvError) {
// Err 3: IPC command invalid — e.g. time-pos unavailable when idle / between tracks
if (err?.errcode === 3) {
return undefined;
}
mpvLog({ action: `Failed to get current time` }, err);
return 0;
return undefined;
}
});
+60 -1
View File
@@ -69,6 +69,7 @@ const getPathReplaceSettings = () => {
const addContext = <T extends { apiClientProps: any; context?: any }>(args: T): T => {
const pathSettings = getPathReplaceSettings();
return {
...args,
context: {
@@ -174,6 +175,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deleteInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStationImage`,
);
}
return apiController(
'deleteInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -188,6 +203,20 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
deletePlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deletePlaylistImage`,
);
}
return apiController(
'deletePlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
getAlbumArtistDetail(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -719,7 +748,9 @@ export const controller: GeneralController = {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
return '';
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getStreamUrl`,
);
}
return apiController(
@@ -957,4 +988,32 @@ export const controller: GeneralController = {
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadInternetRadioStationImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadInternetRadioStationImage`,
);
}
return apiController(
'uploadInternetRadioStationImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
uploadPlaylistImage(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadPlaylistImage`,
);
}
return apiController(
'uploadPlaylistImage',
server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
},
};
@@ -1283,7 +1283,7 @@ export const JellyfinController: InternalControllerEndpoint = {
apiClientProps,
query: { ...query, limit: 1, startIndex: 0 },
}).then((result) => result!.totalRecordCount!),
getStreamUrl: ({ apiClientProps: { server }, query }) => {
getStreamUrl: async ({ apiClientProps: { server }, query }) => {
const { bitrate, format, id, transcode } = query;
const deviceId = '';
@@ -46,6 +46,24 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStation: {
body: null,
method: 'DELETE',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
deleteInternetRadioStationImage: {
body: null,
method: 'DELETE',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deleteInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylist: {
body: null,
method: 'DELETE',
@@ -55,6 +73,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.deletePlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
getAlbumArtistDetail: {
method: 'GET',
path: 'artist/:id',
@@ -132,6 +159,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
getRadioList: {
method: 'GET',
path: 'radio',
query: ndType._parameters.radioList,
responses: {
200: resultWithHeaders(ndType._response.radioList),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
@@ -205,6 +241,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
updateInternetRadioStation: {
body: ndType._parameters.updateInternetRadioStation,
method: 'PUT',
path: 'radio/:id',
responses: {
200: resultWithHeaders(ndType._response.updateInternetRadioStation),
500: resultWithHeaders(ndType._response.error),
},
},
updatePlaylist: {
body: ndType._parameters.updatePlaylist,
method: 'PUT',
@@ -214,6 +259,24 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
uploadInternetRadioStationImage: {
body: ndType._parameters.uploadInternetRadioStationImage,
method: 'POST',
path: 'radio/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadInternetRadioStationImage),
500: resultWithHeaders(ndType._response.error),
},
},
uploadPlaylistImage: {
body: ndType._parameters.uploadPlaylistImage,
method: 'POST',
path: 'playlist/:id/image',
responses: {
200: resultWithHeaders(ndType._response.uploadPlaylistImage),
500: resultWithHeaders(ndType._response.error),
},
},
});
const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval';
import orderBy from 'lodash/orderBy';
@@ -5,13 +6,17 @@ import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
AuthenticationResponse,
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
genreListSortMap,
InternalControllerEndpoint,
playlistListSortMap,
@@ -23,6 +28,10 @@ import {
SortOrder,
sortOrderMap,
tagListSortMap,
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
userListSortMap,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
@@ -30,6 +39,13 @@ import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [
// Why 2? Subsonic controller will return 1 for its own implementation
// Use 2 to denote that Navidrome's own API has a different endpoint
[
'0.61.0',
{
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
},
],
['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }],
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
@@ -171,7 +187,38 @@ export const NavidromeController: InternalControllerEndpoint = {
};
},
deleteFavorite: SubsonicController.deleteFavorite,
deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation,
deleteInternetRadioStation: async (args) => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps).deleteInternetRadioStation({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station');
}
return null;
},
deleteInternetRadioStationImage: async (
args: DeleteInternetRadioStationImageArgs,
): Promise<DeleteInternetRadioStationImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deleteInternetRadioStationImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete internet radio station image');
}
return res.body.data.status === 'ok';
},
deletePlaylist: async (args) => {
const { apiClientProps, query } = args;
@@ -187,6 +234,23 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
const res = await ndApiClient(apiClientProps as any).deletePlaylistImage({
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to delete playlist image');
}
return res.body.data.status === 'ok';
},
getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args;
@@ -547,7 +611,24 @@ export const NavidromeController: InternalControllerEndpoint = {
},
getImageRequest: SubsonicController.getImageRequest,
getImageUrl: SubsonicController.getImageUrl,
getInternetRadioStations: SubsonicController.getInternetRadioStations,
getInternetRadioStations: async (args) => {
const { apiClientProps } = args;
const res = await ndApiClient(apiClientProps).getRadioList({
query: {
_end: -1,
_order: 'ASC',
_sort: NDRadioListSort.NAME,
_start: 0,
},
});
if (res.status !== 200) {
throw new Error('Failed to get internet radio stations');
}
return res.body.data.map((station) => ndNormalize.internetRadioStation(station));
},
getLyrics: SubsonicController.getLyrics,
getMusicFolderList: SubsonicController.getMusicFolderList,
getPlaylistDetail: async (args) => {
@@ -1145,7 +1226,26 @@ export const NavidromeController: InternalControllerEndpoint = {
id: res.body.data.id,
};
},
updateInternetRadioStation: SubsonicController.updateInternetRadioStation,
updateInternetRadioStation: async (args) => {
const { apiClientProps, body, query } = args;
const res = await ndApiClient(apiClientProps).updateInternetRadioStation({
body: {
homePageUrl: body.homepageUrl ?? '',
name: body.name,
streamUrl: body.streamUrl,
},
params: {
id: query.id,
},
});
if (res.status !== 200) {
throw new Error('Failed to update internet radio station');
}
return null;
},
updatePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
@@ -1170,4 +1270,76 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
uploadInternetRadioStationImage: async (
args: UploadInternetRadioStationImageArgs,
): Promise<UploadInternetRadioStationImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/radio/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload internet radio station image');
}
return res.data?.status === 'ok';
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
const server = apiClientProps.server;
const serverUrl = server?.url?.replace(/\/$/, '');
if (!serverUrl) {
throw new Error('Server is required');
}
const form = new FormData();
const bytes = body.image as Uint8Array<ArrayBuffer>;
const fileLike =
typeof File !== 'undefined'
? new File([bytes], 'image', { type: 'application/octet-stream' })
: new Blob([bytes], { type: 'application/octet-stream' });
form.append('image', fileLike as any);
const res = await axios.post(`${serverUrl}/api/playlist/${query.id}/image`, form, {
headers: {
'Content-Type': 'multipart/form-data',
...(server?.ndCredential && {
'x-nd-authorization': `Bearer ${server.ndCredential}`,
}),
},
signal: apiClientProps.signal,
});
if (res.status !== 200) {
throw new Error('Failed to upload playlist image');
}
return res.data?.status === 'ok';
},
};
+51 -9
View File
@@ -250,6 +250,23 @@ export const contract = c.router({
200: ssType._response.topSongsList,
},
},
getTranscodeDecision: {
body: ssType._body.getTranscodeDecision,
method: 'POST',
path: 'getTranscodeDecision.view',
query: ssType._parameters.getTranscodeDecision,
responses: {
200: ssType._response.getTranscodeDecision,
},
},
getTranscodeStream: {
method: 'GET',
path: 'getTranscodeStream.view',
query: ssType._parameters.getTranscodeStream,
responses: {
200: z.string(),
},
},
getUser: {
method: 'GET',
path: 'getUser.view',
@@ -392,7 +409,7 @@ export const ssApiClient = (args: {
const { server, signal, silent, url } = args;
return initClient(contract, {
api: async ({ headers, method, path }) => {
api: async ({ body, headers, method, path, rawQuery }) => {
let baseUrl: string | undefined;
const authParams: Record<string, any> = {};
@@ -423,19 +440,44 @@ export const ssApiClient = (args: {
url: `${baseUrl}/${api}`,
};
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
const isGetTranscodeDecisionPost =
method === 'POST' && api === 'getTranscodeDecision.view';
if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
if (isGetTranscodeDecisionPost && body != null) {
request.method = 'POST';
request.headers = {
...headers,
'Content-Type': 'application/json',
};
request.data = body;
request.params = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...(typeof rawQuery === 'object' && rawQuery !== null
? (rawQuery as Record<string, unknown>)
: {}),
};
} else if (hasFeature(server, ServerFeature.OS_FORM_POST)) {
headers['Content-Type'] = 'application/x-www-form-urlencoded';
request.method = 'POST';
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
request.data = qs.stringify(data, { arrayFormat: 'repeat' });
} else {
const data = {
c: 'Feishin',
f: 'json',
v: '1.13.0',
...authParams,
...params,
};
request.method = method;
request.params = data;
}
+226 -11
View File
@@ -8,7 +8,12 @@ import md5 from 'md5';
import { z } from 'zod';
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import {
getDefaultTranscodingProfiles,
getDirectPlayProfiles,
} from '/@/renderer/features/player/components/audio-players';
import { randomString } from '/@/renderer/utils';
import { logFn } from '/@/renderer/utils/logger';
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import {
@@ -87,6 +92,151 @@ const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefin
const MAX_SUBSONIC_ITEMS = 500;
const SUBSONIC_FAST_BATCH_SIZE = MAX_SUBSONIC_ITEMS * 10;
// const TRANSCODE_DIRECT_PLAY_PROFILES = [
// {
// audioCodecs: ['mp3'],
// containers: ['mp3'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['aac'],
// containers: ['m4a', 'mp4'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['vorbis'],
// containers: ['ogg'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['opus'],
// containers: ['ogg', 'webm'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['pcm'],
// containers: ['wav'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// {
// audioCodecs: ['flac'],
// containers: ['flac'],
// maxAudioChannels: 2,
// protocols: ['http'],
// },
// ];
// const TRANSCODE_UNSUPPORTED_DIRECT_PLAY_PROFILES = [
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["alac"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["m4a", "mp4"],
// audioCodecs: ["ac3", "eac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["ogg"],
// audioCodecs: ["flac", "speex"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wav"],
// audioCodecs: ["adpcm", "gsm", "aac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["mkv"],
// audioCodecs: ["aac", "mp3", "flac", "opus", "vorbis", "ac3", "eac3", "dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["avi"],
// audioCodecs: ["mp3", "ac3", "pcm", "aac"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["asf", "wma"],
// audioCodecs: ["wma", "pcm", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["caf"],
// audioCodecs: ["pcm", "aac", "alac", "mp3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["3gp"],
// audioCodecs: ["aac", "amr"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["amr"],
// audioCodecs: ["amr"],
// protocols: ["http"],
// maxAudioChannels: 1
// },
// {
// containers: ["ape"],
// audioCodecs: ["ape"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["wv"],
// audioCodecs: ["wavpack"],
// protocols: ["http"],
// maxAudioChannels: 2
// },
// {
// containers: ["ac3"],
// audioCodecs: ["ac3"],
// protocols: ["http"],
// maxAudioChannels: 6
// },
// {
// containers: ["eac3"],
// audioCodecs: ["eac3"],
// protocols: ["http"],
// maxAudioChannels: 8
// },
// {
// containers: ["dts"],
// audioCodecs: ["dts"],
// protocols: ["http"],
// maxAudioChannels: 8
// }
// ];
function appendTranscodeParams(url: string, format?: string, bitrate?: number) {
let streamUrl = url;
if (format) {
streamUrl += `&format=${format}`;
}
if (bitrate !== undefined) {
streamUrl += `&maxBitRate=${bitrate}`;
}
return streamUrl;
}
function sortAndPaginate<T>(
items: T[],
options: {
@@ -1035,7 +1185,7 @@ export const SubsonicController: InternalControllerEndpoint = {
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
},
getPlaylistList: async ({ apiClientProps, query }) => {
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
const sortOrder = (query.sortOrder || SortOrder.ASC).toLowerCase() as 'asc' | 'desc';
const res = await ssApiClient(apiClientProps).getPlaylists({});
@@ -1273,6 +1423,10 @@ export const SubsonicController: InternalControllerEndpoint = {
}
}
if (subsonicFeatures[SubsonicExtensions.TRANSCODING]) {
features.osTranscodeDecision = [1];
}
if (subsonicFeatures[SubsonicExtensions.SONG_LYRICS]) {
features.lyricsMultipleStructured = [1];
}
@@ -1801,20 +1955,81 @@ export const SubsonicController: InternalControllerEndpoint = {
return totalRecordCount;
},
getStreamUrl: ({ apiClientProps: { server }, query }) => {
const { bitrate, format, id, transcode } = query;
let url = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
getStreamUrl: async ({ apiClientProps, query }) => {
const { server } = apiClientProps;
const { bitrate, format, id, mediaType = 'song', skipAutoTranscode, transcode } = query;
const streamUrl = `${server?.url}/rest/stream.view?id=${id}&v=1.13.0&c=Feishin&${server?.credential}`;
// If transcoding is explicitly enabled, just return the direct transcoded stream URL
if (transcode) {
if (format) {
url += `&format=${format}`;
}
if (bitrate !== undefined) {
url += `&maxBitRate=${bitrate}`;
}
return appendTranscodeParams(streamUrl, format, bitrate);
}
return url;
// Used in cases where MPV is the default player, since mpv handles basically every audio format
if (skipAutoTranscode) {
return streamUrl;
}
// If the server supports transcoding decision, always use it to determine if we need to transcode
if (hasFeature(server, ServerFeature.OS_TRANSCODE_DECISION)) {
const maxTranscodingAudioBitrate = 0;
const directPlayProfiles = getDirectPlayProfiles();
const transcodingProfiles = getDefaultTranscodingProfiles();
const transcodeDecision = await ssApiClient(apiClientProps).getTranscodeDecision({
body: {
codecProfiles: [],
directPlayProfiles,
maxAudioBitrate: 0,
maxTranscodingAudioBitrate,
name: 'Feishin',
platform: navigator.userAgent,
transcodingProfiles,
},
query: {
mediaId: id,
mediaType,
},
});
if (transcodeDecision.status !== 200) {
throw new Error('Failed to get transcode decision');
}
const td = transcodeDecision.body.transcodeDecision;
const requiresTranscoding = !td?.canDirectPlay;
// If the server does not require transcoding, just return the direct stream URL
if (!requiresTranscoding) {
return streamUrl;
}
logFn.info(`Song ${id} requires transcoding: ${[td.transcodeReason].join(', ')}`);
// If the server does not return transcode params, manually create the transcode params
if (!td.transcodeParams) {
return appendTranscodeParams(streamUrl, format, bitrate);
}
const transcodeStreamUrl = await ssApiClient(apiClientProps).getTranscodeStream({
query: {
mediaId: id,
mediaType,
offset: 0,
transcodeParams: td.transcodeParams,
},
});
if (transcodeStreamUrl.status !== 200) {
throw new Error('Failed to get transcode stream');
}
return transcodeStreamUrl.body;
}
return streamUrl;
},
getStructuredLyrics: async (args) => {
const { apiClientProps, query } = args;
+113 -55
View File
@@ -7,7 +7,7 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import isElectron from 'is-electron';
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
import i18n from '/@/i18n/i18n';
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
@@ -38,67 +38,26 @@ const UpdateAvailableDialog = lazy(() =>
const ipc = isElectron() ? window.api.ipc : null;
export const App = () => {
return <ThemedApp />;
};
const ThemedApp = () => {
const { mode, theme } = useAppTheme();
const language = useLanguage();
const { content, enabled } = useCssSettings();
const { bindings } = useHotkeySettings();
const cssRef = useRef<HTMLStyleElement | null>(null);
useSyncSettingsToMain();
useCheckForUpdates();
return (
<MantineProvider forceColorScheme={mode} theme={theme}>
<AppShell />
</MantineProvider>
);
};
const AppShell = memo(function AppShell() {
const [webAudio, setWebAudio] = useState<WebAudio>();
useEffect(() => {
if (enabled && content) {
// Yes, CSS is sanitized here as well. Prevent a suer from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
cssRef.current!.textContent = '';
};
}
return () => {};
}, [content, enabled]);
const webAudioProvider = useMemo(() => {
return { setWebAudio, webAudio };
}, [webAudio]);
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
const notificationStyles = useMemo(
() => ({
root: {
@@ -109,7 +68,8 @@ export const App = () => {
);
return (
<MantineProvider forceColorScheme={mode} theme={theme}>
<>
<AppEffects />
<Notifications
containerWidth="300px"
position="bottom-center"
@@ -126,6 +86,104 @@ export const App = () => {
<ReleaseNotesModal />
<UpdateAvailableDialog />
</Suspense>
</MantineProvider>
</>
);
});
const AppEffects = () => (
<>
<SyncSettingsEffect />
<UpdateCheckEffect />
<CssSettingsEffect />
<GlobalShortcutsEffect />
<LanguageEffect />
<OpenSettingsEffect />
</>
);
const SyncSettingsEffect = () => {
useSyncSettingsToMain();
return null;
};
const UpdateCheckEffect = () => {
useCheckForUpdates();
return null;
};
const CssSettingsEffect = () => {
const { content, enabled } = useCssSettings();
const cssRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
if (!enabled || !content) {
if (cssRef.current) {
cssRef.current.textContent = '';
}
return;
}
// Yes, CSS is sanitized here as well. Prevent a user from changing the
// localStorage to bypass sanitizing.
const sanitized = sanitizeCss(content);
if (!cssRef.current) {
cssRef.current = document.createElement('style');
document.body.appendChild(cssRef.current);
}
cssRef.current.textContent = sanitized;
return () => {
if (cssRef.current) {
cssRef.current.textContent = '';
}
};
}, [content, enabled]);
return null;
};
const GlobalShortcutsEffect = () => {
const { bindings } = useHotkeySettings();
useEffect(() => {
if (isElectron()) {
ipc?.send('set-global-shortcuts', bindings);
}
}, [bindings]);
return null;
};
const LanguageEffect = () => {
const language = useLanguage();
useEffect(() => {
if (language) {
i18n.changeLanguage(language);
}
}, [language]);
return null;
};
const OpenSettingsEffect = () => {
useEffect(() => {
if (isElectron()) {
window.api.utils.rendererOpenSettings(() => {
openSettingsModal();
});
return () => {
ipc?.removeAllListeners('renderer-open-settings');
};
}
return undefined;
}, []);
return null;
};
@@ -36,12 +36,16 @@
min-width: 0;
}
.grid-carousel-viewport {
width: 100%;
min-height: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--cards-to-show, 2), minmax(0, 1fr));
gap: var(--theme-spacing-md);
contain: layout paint;
content-visibility: auto;
overflow: hidden;
will-change: transform;
}
@@ -22,9 +22,9 @@ import { AppRoute } from '/@/renderer/router/routes';
import { useShowRatings } from '/@/renderer/store';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatDurationString,
formatPartialIsoDateUTC,
formatRating,
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
@@ -1161,12 +1161,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
},
{
format: (data) => {
if ('releaseYear' in data && data.releaseYear !== null) {
if ('releaseYear' in data && data.releaseYear != null) {
const releaseYear = data.releaseYear;
const originalYear =
'originalYear' in data && data.originalYear !== null
? data.originalYear
: null;
'originalYear' in data && data.originalYear > 0 ? data.originalYear : null;
if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -1186,10 +1184,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
data.originalDate &&
data.originalDate !== data.releaseDate
) {
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
}
return `${formatDateAbsoluteUTC(data.releaseDate)}`;
return `${formatPartialIsoDateUTC(data.releaseDate)}`;
}
return '';
},
@@ -1,6 +1,21 @@
import { ItemDetailListCellProps } from './types';
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
import { formatPartialIsoDateUTC } from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
song.releaseDate ? formatDateAbsoluteUTC(song.releaseDate) : <>&nbsp;</>;
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
const row = song as typeof song & { originalDate?: null | string };
const releaseDate = row.releaseDate;
if (!releaseDate) {
return <>&nbsp;</>;
}
const originalDate =
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
if (originalDate) {
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
}
return formatPartialIsoDateUTC(releaseDate);
};
@@ -67,7 +67,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { AppRoute } from '/@/renderer/router/routes';
import { useSettingsStore, useShowRatings } from '/@/renderer/store';
import { formatDateAbsoluteUTC, formatDurationString } from '/@/renderer/utils';
import { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { ExplicitIndicator } from '/@/shared/components/explicit-indicator/explicit-indicator';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
@@ -489,9 +489,9 @@ const MetadataSection = memo(
let releaseStr = '';
if (item.releaseDate) {
if (item.originalDate && item.originalDate !== item.releaseDate) {
releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`;
releaseStr = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`;
} else {
releaseStr = formatDateAbsoluteUTC(item.releaseDate);
releaseStr = formatPartialIsoDateUTC(item.releaseDate);
}
} else if (item.releaseYear != null) {
releaseStr = String(item.releaseYear);
@@ -20,7 +20,8 @@ export const createColumnCellComponent = (
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data &&
prevProps.style === nextProps.style &&
prevProps.columns === nextProps.columns
prevProps.columns === nextProps.columns &&
prevProps.playlistId === nextProps.playlistId
);
},
);
@@ -8,49 +8,25 @@ import {
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
formatDateAbsolute,
formatDateAbsoluteUTC,
formatDateRelative,
formatHrDateTime,
formatPartialIsoDateUTC,
} from '/@/renderer/utils/format';
import { SEPARATOR_STRING } from '/@/shared/api/utils';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { Tooltip } from '/@/shared/components/tooltip/tooltip';
import { TableColumn } from '/@/shared/types/types';
const getDateTooltipLabel = (utcString: string) => {
return (
<Stack gap="xs" justify="center">
<Text size="md" ta="center">
{formatHrDateTime(utcString)}
</Text>
<Text isMuted size="sm" ta="center">
{utcString}
</Text>
</Stack>
);
};
const DateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string' && row) {
return {
formattedDate: formatDateAbsolute(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
const formattedAbsolute = useMemo(
() => (typeof row === 'string' && row ? formatDateAbsolute(row) : null),
[row],
);
if (typeof row === 'string' && row) {
if (formattedAbsolute) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
<span>{formattedAbsolute}</span>
</TableColumnTextContainer>
);
}
@@ -79,44 +55,37 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
: null;
if (originalDate) {
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
return {
displayText,
tooltipLabel: getDateTooltipLabel(releaseDate),
};
const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
}
if (typeof releaseDate === 'string' && releaseDate) {
return {
displayText: formatDateAbsoluteUTC(releaseDate),
tooltipLabel: getDateTooltipLabel(releaseDate),
};
return formatPartialIsoDateUTC(releaseDate);
}
}
}
return null;
}, [props.type, rowItem]);
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string' && row) {
return {
formattedDate: formatDateAbsoluteUTC(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
}, [row]);
const formattedIsoFallback = useMemo(
() => (typeof row === 'string' && row ? formatPartialIsoDateUTC(row) : null),
[row],
);
if (props.type === TableColumn.RELEASE_DATE) {
if (releaseDateContent) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}>
<span>{releaseDateContent.displayText}</span>
</Tooltip>
<span>{releaseDateContent}</span>
</TableColumnTextContainer>
);
}
if (formattedIsoFallback) {
return (
<TableColumnTextContainer {...props}>
<span>{formattedIsoFallback}</span>
</TableColumnTextContainer>
);
}
@@ -128,20 +97,6 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
return <ColumnSkeletonFixed {...props} />;
}
if (typeof row === 'string' && row) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
</TableColumnTextContainer>
);
}
if (row === null) {
return <ColumnNullFallback {...props} />;
}
return <ColumnSkeletonFixed {...props} />;
};
@@ -151,22 +106,15 @@ const RelativeDateColumnBase = (props: ItemTableListInnerColumn) => {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = (rowItem as any)?.[props.columns[props.columnIndex].id];
const { formattedDate, tooltipLabel } = useMemo(() => {
if (typeof row === 'string') {
return {
formattedDate: formatDateRelative(row),
tooltipLabel: getDateTooltipLabel(row),
};
}
return { formattedDate: null, tooltipLabel: null };
const formattedRelative = useMemo(() => {
if (typeof row !== 'string') return null;
return formatDateRelative(row);
}, [row]);
if (typeof row === 'string') {
if (formattedRelative !== null) {
return (
<TableColumnTextContainer {...props}>
<Tooltip label={tooltipLabel} multiline={false}>
<span>{formattedDate}</span>
</Tooltip>
<span>{formattedRelative}</span>
</TableColumnTextContainer>
);
}
@@ -1,4 +1,5 @@
import clsx from 'clsx';
import { useMemo } from 'react';
import { Link } from 'react-router';
import styles from './title-column.module.css';
@@ -35,8 +36,12 @@ function DefaultTitleColumn(props: ItemTableListInnerColumn) {
const rowItem = props.getRowItem?.(props.rowIndex) ?? (props.data as any[])[props.rowIndex];
const row: string | undefined = rowItem?.[props.columns[props.columnIndex].id];
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
@@ -80,8 +85,12 @@ function QueueSongTitleColumn(props: ItemTableListInnerColumn) {
const song = rowItem as QueueSong;
const isActive = useIsActiveRow(song?.id, song?._uniqueId);
const path = useMemo(() => {
if (typeof row !== 'string' || !rowItem || !(rowItem as any).id) return undefined;
return getTitlePath(props.itemType, (rowItem as any).id as string);
}, [props.itemType, row, rowItem]);
if (typeof row === 'string') {
const path = getTitlePath(props.itemType, (rowItem as any).id as string);
const item = rowItem as any;
const titleLinkProps = path
@@ -13,10 +13,10 @@ const YearColumnBase = (props: ItemTableListInnerColumn) => {
const item = rowItem as any;
const yearDisplay = useMemo(() => {
if (item && 'releaseYear' in item && item.releaseYear !== null) {
if (item && 'releaseYear' in item && item.releaseYear != null) {
const releaseYear = item.releaseYear;
const originalYear =
'originalYear' in item && item.originalYear !== null ? item.originalYear : null;
'originalYear' in item && item.originalYear > 0 ? item.originalYear : null;
if (originalYear !== null && originalYear !== releaseYear) {
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
@@ -34,256 +34,268 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
}: UseItemDragDropStateProps): DragDropState<TElement> => {
const shouldEnableDrag = enableDrag && isDataRow && !!item;
const needsDropRegistration =
shouldEnableDrag &&
(itemType === LibraryItem.QUEUE_SONG || itemType === LibraryItem.PLAYLIST_SONG);
const {
isDraggedOver,
isDragging: isDraggingLocal,
ref: dragRef,
} = useDragDrop<TElement>({
drag: {
getId: () => {
if (!item || !isDataRow) {
return [];
}
drag: shouldEnableDrag
? {
getId: () => {
if (!item || !isDataRow) {
return [];
}
const draggedItems = getDraggedItems(item as any, internalState);
const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems.map((draggedItem) => draggedItem.id);
},
getItem: () => {
if (!item || !isDataRow) {
return [];
}
return draggedItems.map((draggedItem) => draggedItem.id);
},
getItem: () => {
if (!item || !isDataRow) {
return [];
}
const draggedItems = getDraggedItems(item as any, internalState);
const draggedItems = getDraggedItems(item as any, internalState);
return draggedItems;
},
itemType,
onDragStart: () => {
if (!item || !isDataRow) {
return;
}
return draggedItems;
},
itemType,
onDragStart: () => {
if (!item || !isDataRow) {
return;
}
const draggedItems = getDraggedItems(item as any, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTargetMap[itemType] || DragTarget.GENERIC,
},
drop: {
canDrop: (args) => {
if (args.source.type === DragTarget.TABLE_COLUMN) {
return false;
}
const draggedItems = getDraggedItems(item as any, internalState);
if (internalState) {
internalState.setDragging(draggedItems);
}
},
onDrop: () => {
if (internalState) {
internalState.setDragging([]);
}
},
operation:
itemType === LibraryItem.QUEUE_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: itemType === LibraryItem.PLAYLIST_SONG
? [DragOperation.REORDER, DragOperation.ADD]
: [DragOperation.ADD],
target: DragTargetMap[itemType] || DragTarget.GENERIC,
}
: undefined,
drop: needsDropRegistration
? {
canDrop: (args) => {
if (args.source.type === DragTarget.TABLE_COLUMN) {
return false;
}
// Allow drops for QUEUE_SONG (queue reordering)
if (itemType === LibraryItem.QUEUE_SONG) {
return true;
}
// Allow drops for QUEUE_SONG (queue reordering)
if (itemType === LibraryItem.QUEUE_SONG) {
return true;
}
// Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle
if (
itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true
) {
return true;
}
// Allow drops for PLAYLIST_SONG (playlist reordering)
// Only allow drops when drag is started from the reorder handle
if (
itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true
) {
return true;
}
return false;
},
getData: () => {
return {
id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]],
itemType,
type: DragTargetMap[itemType] || DragTarget.GENERIC,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
if (args.self.type === DragTarget.QUEUE_SONG) {
const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string }
)._serverId;
return false;
},
getData: () => {
return {
id: [(item as unknown as { id: string }).id],
item: [item as unknown as unknown[]],
itemType,
type: DragTargetMap[itemType] || DragTarget.GENERIC,
};
},
onDrag: () => {
return;
},
onDragLeave: () => {
return;
},
onDrop: (args) => {
if (args.self.type === DragTarget.QUEUE_SONG) {
const sourceServerId = (
args.source.item?.[0] as unknown as { _serverId: string }
)._serverId;
const sourceItemType = args.source.itemType as LibraryItem;
const sourceItemType = args.source.itemType as LibraryItem;
const droppedOnUniqueId = (
args.self.item?.[0] as unknown as { _uniqueId: string }
)._uniqueId;
const droppedOnUniqueId = (
args.self.item?.[0] as unknown as { _uniqueId: string }
)._uniqueId;
switch (args.source.type) {
case DragTarget.ALBUM: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ALBUM_ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
switch (args.source.type) {
case DragTarget.ALBUM: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ALBUM_ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.ARTIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.FOLDER: {
const items = args.source.item;
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if ((item as unknown as Song)._itemType === LibraryItem.SONG) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType === LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const { folders, songs } = (items || []).reduce<{
folders: Folder[];
songs: Song[];
}>(
(acc, item) => {
if (
(item as unknown as Song)._itemType ===
LibraryItem.SONG
) {
acc.songs.push(item as unknown as Song);
} else if (
(item as unknown as Folder)._itemType ===
LibraryItem.FOLDER
) {
acc.folders.push(item as unknown as Folder);
}
return acc;
},
{ folders: [], songs: [] },
);
const folderIds = folders.map((folder) => folder.id);
const folderIds = folders.map((folder) => folder.id);
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle folders: fetch and add to queue
if (folderIds.length > 0) {
playerContext.addToQueueByFetch(
sourceServerId,
folderIds,
LibraryItem.FOLDER,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
}
// Handle songs: add directly to queue
if (songs.length > 0) {
playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
// Handle songs: add directly to queue
if (songs.length > 0) {
playerContext.addToQueueByData(songs, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
case DragTarget.GENRE: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.PLAYLIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.QUEUE_SONG: {
const sourceItems = (args.source.item || []) as QueueSong[];
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom')
) {
playerContext.moveSelectedTo(
sourceItems,
args.edge,
droppedOnUniqueId,
);
}
break;
}
case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
default: {
break;
}
}
}
break;
}
case DragTarget.GENRE: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.PLAYLIST: {
playerContext.addToQueueByFetch(
sourceServerId,
args.source.id,
sourceItemType,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
);
break;
}
case DragTarget.QUEUE_SONG: {
const sourceItems = (args.source.item || []) as QueueSong[];
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom')
) {
playerContext.moveSelectedTo(
sourceItems,
args.edge,
droppedOnUniqueId,
);
}
break;
}
case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, {
edge: args.edge,
uniqueId: droppedOnUniqueId,
});
}
break;
}
default: {
break;
}
}
}
// Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle
if (
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true &&
playlistId
) {
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
// Handle PLAYLIST_SONG reordering
// Only allow drops when drag is started from the reorder handle
if (
args.self.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.itemType === LibraryItem.PLAYLIST_SONG &&
args.source.metadata?.fromReorderHandle === true &&
playlistId
) {
const sourceItems = (args.source.item || []) as any[];
const targetItem = item as any;
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
targetItem
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
}
if (
sourceItems.length > 0 &&
args.edge &&
(args.edge === 'top' || args.edge === 'bottom') &&
targetItem
) {
// Emit event to reorder playlist songs
eventEmitter.emit('PLAYLIST_REORDER', {
edge: args.edge,
playlistId,
sourceIds: args.source.id,
targetId: targetItem.id,
});
}
}
if (internalState) {
internalState.setDragging([]);
}
if (internalState) {
internalState.setDragging([]);
}
return;
},
},
return;
},
}
: undefined,
isEnabled: shouldEnableDrag,
});
@@ -0,0 +1,72 @@
import { useLayoutEffect, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface ItemTableStickyLayoutOffsets {
inViewMarginTop: number;
stickyTop: number;
}
export function useItemTableStickyLayoutOffsets(): ItemTableStickyLayoutOffsets {
const { windowBarStyle } = useWindowSettings();
const isWinMac = windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS;
const [offsets, setOffsets] = useState(() => ({
inViewMarginTop: getFallbackInViewMargin(windowBarStyle),
stickyTop: getFallbackStickyTop(windowBarStyle),
}));
useLayoutEffect(() => {
const read = () => {
const topVar = isWinMac
? '--item-table-sticky-top-win-mac'
: '--item-table-sticky-top-default';
const marginVar = isWinMac
? '--item-table-sticky-inview-margin-win-mac'
: '--item-table-sticky-inview-margin-default';
setOffsets({
inViewMarginTop: resolveRootCssMarginLeftVar(
marginVar,
getFallbackInViewMargin(windowBarStyle),
),
stickyTop: resolveRootCssWidthVar(topVar, getFallbackStickyTop(windowBarStyle)),
});
};
read();
window.addEventListener('resize', read);
return () => window.removeEventListener('resize', read);
}, [isWinMac, windowBarStyle]);
return offsets;
}
function getFallbackInViewMargin(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? -130 : -100;
}
function getFallbackStickyTop(windowBarStyle: Platform): number {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}
function resolveRootCssMarginLeftVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:0;top:0;margin-left:var(${varName});width:1px;height:0;margin-top:0;margin-right:0;margin-bottom:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const raw = getComputedStyle(el).marginLeft;
el.remove();
const v = parseFloat(raw);
return Number.isFinite(v) ? v : fallback;
}
function resolveRootCssWidthVar(varName: string, fallback: number): number {
if (typeof document === 'undefined') return fallback;
const el = document.createElement('div');
el.style.cssText = `position:fixed;left:-99999px;top:0;width:var(${varName});height:0;margin:0;padding:0;border:none;visibility:hidden;pointer-events:none;`;
document.body.appendChild(el);
const w = el.getBoundingClientRect().width;
el.remove();
return Number.isFinite(w) && w > 0 ? w : fallback;
}
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export interface GroupRowInfo {
groupIndex: number;
rowIndex: number;
@@ -18,6 +17,7 @@ export const useStickyTableGroupRows = ({
mainGridRef,
shouldShowStickyHeader,
stickyHeaderTop,
stickyLayout,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
enabled: boolean;
@@ -27,17 +27,14 @@ export const useStickyTableGroupRows = ({
mainGridRef: React.RefObject<HTMLDivElement | null>;
shouldShowStickyHeader?: boolean;
stickyHeaderTop?: number;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => {
const { windowBarStyle } = useWindowSettings();
const { inViewMarginTop, stickyTop: layoutStickyTop } = stickyLayout;
const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null);
const topMargin =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const groupRowsInViewMargin = `${inViewMarginTop}px 0px 0px 0px`;
const isTableInView = useInView(containerRef, {
margin: `${topMargin} 0px 0px 0px`,
margin: groupRowsInViewMargin as NonNullable<Parameters<typeof useInView>[1]>['margin'],
});
const stickyTop = useMemo(() => {
@@ -46,8 +43,8 @@ export const useStickyTableGroupRows = ({
if (shouldShowStickyHeader && stickyHeaderTop !== undefined) {
return stickyHeaderTop + headerHeight + 1;
}
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
return layoutStickyTop;
}, [layoutStickyTop, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
// Calculate group row indexes
const groupRowIndexes = useMemo(() => {
@@ -1,9 +1,8 @@
import type { ItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useInView } from 'motion/react';
import { RefObject, useEffect, useMemo, useRef } from 'react';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export const useStickyTableHeader = ({
containerRef,
enabled,
@@ -12,6 +11,7 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
stickyLayout,
}: {
containerRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
@@ -20,8 +20,9 @@ export const useStickyTableHeader = ({
pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;
pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;
stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;
stickyLayout: ItemTableStickyLayoutOffsets;
}) => {
const { windowBarStyle } = useWindowSettings();
const { inViewMarginTop, stickyTop } = stickyLayout;
const isScrollingRef = useRef({
main: false,
pinnedLeft: false,
@@ -29,27 +30,20 @@ export const useStickyTableHeader = ({
stickyHeader: false,
});
const topMargin =
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
? '-130px'
: '-100px';
const inViewRootMargin = `${inViewMarginTop}px 0px 0px 0px`;
const isTableHeaderInView = useInView(headerRef, {
margin: `${topMargin} 0px 0px 0px`,
});
const inViewOptions = { margin: inViewRootMargin } as {
margin: NonNullable<Parameters<typeof useInView>[1]>['margin'];
};
const isTableInView = useInView(containerRef, {
margin: `${topMargin} 0px 0px 0px`,
});
const isTableHeaderInView = useInView(headerRef, inViewOptions);
const isTableInView = useInView(containerRef, inViewOptions);
const shouldShowStickyHeader = useMemo(() => {
return enabled && !isTableHeaderInView && isTableInView;
}, [enabled, isTableHeaderInView, isTableInView]);
const stickyTop = useMemo(() => {
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
}, [windowBarStyle]);
// Sync scroll between sticky header and main grid/pinned columns
useEffect(() => {
if (!shouldShowStickyHeader || !stickyHeaderMainRef?.current || !mainGridRef?.current) {
@@ -19,7 +19,6 @@ import React, {
useRef,
useState,
} from 'react';
import { useParams } from 'react-router';
import { CellComponentProps } from 'react-window-v2';
import styles from './item-table-list-column.module.css';
@@ -82,7 +81,6 @@ export interface ItemTableListInnerColumn extends ItemTableListColumn {
}
const ItemTableListColumnBase = (props: ItemTableListColumn) => {
const { playlistId } = useParams() as { playlistId?: string };
const type = props.columnType ?? (props.columns[props.columnIndex].id as TableColumn);
const isHeaderEnabled = !!props.enableHeader;
@@ -135,7 +133,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
item,
itemType: props.itemType,
playerContext: props.playerContext,
playlistId,
playlistId: props.playlistId,
});
const controls = props.controls;
@@ -362,6 +360,7 @@ export const ItemTableListColumn = memo(ItemTableListColumnBase, (prevProps, nex
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding &&
prevProps.playlistId === nextProps.playlistId &&
prevItem === nextItem
);
});
@@ -1,31 +1,51 @@
import type { ReactElement } from 'react';
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
import { useSyncExternalStore } from 'react';
import type { TableItemProps } from './item-table-list';
import { ItemListStateActions } from '/@/renderer/components/item-list/helpers/item-list-state';
import { ItemControls, ItemTableListColumnConfig } from '/@/renderer/components/item-list/types';
import { PlayerContext } from '/@/renderer/features/player/context/player-context';
import { LibraryItem } from '/@/shared/types/domain-types';
/**
* Stage A/B: Provide table-scoped config + external stores so churny values can update
* without forcing `cellProps` identity changes (and therefore without rerendering every visible cell).
*/
export type ItemTableListConfig = {
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
columns: ItemTableListColumnConfig[];
controls: ItemControls;
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: ItemTableListGroupHeader[];
internalState: ItemListStateActions;
itemType: LibraryItem;
playerContext: PlayerContext;
playlistId?: string;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
};
export type ItemTableListGroupHeader = {
itemCount: number;
render: (props: {
data: unknown[];
groupIndex: number;
index: number;
internalState: ItemListStateActions;
startDataIndex: number;
}) => ReactElement;
};
const ItemTableListConfigContext = createContext<ItemTableListConfig | null>(null);
export const ItemTableListConfigProvider = ({
@@ -52,7 +52,7 @@
.item-table-pinned-rows-grid-container.header-fixed {
position: fixed !important;
top: 65px;
top: var(--item-table-sticky-top-default);
z-index: 15;
background-color: var(--theme-bg-primary);
box-shadow: 0 -1px 0 0 var(--theme-colors-border);
@@ -60,7 +60,7 @@
}
.item-table-pinned-rows-grid-container.header-window-bar {
top: 95px;
top: var(--item-table-sticky-top-win-mac);
}
.item-table-list-container.header-fixed-margin {
@@ -15,6 +15,7 @@ import React, {
useRef,
useState,
} from 'react';
import { useParams } from 'react-router';
import { type CellComponentProps, Grid } from 'react-window-v2';
import styles from './item-table-list.module.css';
@@ -30,6 +31,7 @@ import { parseTableColumns } from '/@/renderer/components/item-list/helpers/pars
import { useListHotkeys } from '/@/renderer/components/item-list/helpers/use-list-hotkeys';
import { useContainerWidthTracking } from '/@/renderer/components/item-list/item-table-list/hooks/use-container-width-tracking';
import { useRowInteractionDelegate } from '/@/renderer/components/item-list/item-table-list/hooks/use-row-interaction-delegate';
import { useItemTableStickyLayoutOffsets } from '/@/renderer/components/item-list/item-table-list/hooks/use-item-table-sticky-layout-offsets';
import { useStickyGroupRowPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-group-row-positioning';
import { useStickyHeaderPositioning } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-header-positioning';
import { useStickyTableGroupRows } from '/@/renderer/components/item-list/item-table-list/hooks/use-sticky-table-group-rows';
@@ -43,6 +45,7 @@ import { useTableRowModel } from '/@/renderer/components/item-list/item-table-li
import { useTableScrollToIndex } from '/@/renderer/components/item-list/item-table-list/hooks/use-table-scroll-to-index';
import { ItemTableListColumn } from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
import {
type ItemTableListConfig,
ItemTableListConfigProvider,
ItemTableListStoreProvider,
} from '/@/renderer/components/item-list/item-table-list/item-table-list-context';
@@ -104,27 +107,11 @@ export enum TableItemSize {
interface VirtualizedTableGridProps {
calculatedColumnWidths: number[];
CellComponent: JSXElementConstructor<CellComponentProps<TableItemProps>>;
cellPadding: 'lg' | 'md' | 'sm' | 'xl' | 'xs';
controls: ItemControls;
data: unknown[];
dataWithGroups: (null | unknown)[];
enableAlternateRowColors: boolean;
enableColumnReorder: boolean;
enableColumnResize: boolean;
enableDrag?: boolean;
enableExpansion: boolean;
enableHeader: boolean;
enableHorizontalBorders: boolean;
enableRowHoverHighlight: boolean;
enableScrollShadow: boolean;
enableSelection: boolean;
enableVerticalBorders: boolean;
getItem?: (index: number) => undefined | unknown;
getRowHeight: (index: number, cellProps: TableItemProps) => number;
groups?: TableGroupHeader[];
headerHeight: number;
internalState: ItemListStateActions;
itemType: LibraryItem;
mergedRowRef: React.Ref<HTMLDivElement>;
onRangeChanged?: ItemTableListProps['onRangeChanged'];
parsedColumns: ReturnType<typeof parseTableColumns>;
@@ -134,13 +121,10 @@ interface VirtualizedTableGridProps {
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
pinnedRowCount: number;
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
playerContext: PlayerContext;
showLeftShadow: boolean;
showRightShadow: boolean;
showTopShadow: boolean;
size: 'compact' | 'default' | 'large';
startRowIndex?: number;
tableId: string;
tableConfig: ItemTableListConfig;
totalColumnCount: number;
totalRowCount: number;
}
@@ -148,27 +132,11 @@ interface VirtualizedTableGridProps {
const VirtualizedTableGrid = ({
calculatedColumnWidths,
CellComponent,
cellPadding,
controls,
data,
dataWithGroups,
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableScrollShadow,
enableSelection,
enableVerticalBorders,
getItem,
getRowHeight,
groups,
headerHeight,
internalState,
itemType,
mergedRowRef,
onRangeChanged,
parsedColumns,
@@ -178,16 +146,14 @@ const VirtualizedTableGrid = ({
pinnedRightColumnRef,
pinnedRowCount,
pinnedRowRef,
playerContext,
showLeftShadow,
showRightShadow,
showTopShadow,
size,
startRowIndex,
tableId,
tableConfig,
totalColumnCount,
totalRowCount,
}: VirtualizedTableGridProps) => {
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
useRowInteractionDelegate({
@@ -345,35 +311,7 @@ const VirtualizedTableGrid = ({
],
);
const stableConfigProps = useMemo(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableHeader,
getRowHeight,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
internalState,
itemType,
playerContext,
size,
tableId,
}),
[
cellPadding,
parsedColumns,
controls,
enableHeader,
getRowHeight,
internalState,
itemType,
playerContext,
size,
tableId,
],
);
const dynamicDataProps = useMemo(
const gridOnlyProps = useMemo(
() => ({
calculatedColumnWidths,
data: dataWithGroups,
@@ -381,11 +319,11 @@ const VirtualizedTableGrid = ({
getGroupRenderData,
getRowItem,
groupHeaderInfoByRowIndex,
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
startRowIndex,
}),
[
calculatedColumnWidths,
@@ -394,50 +332,68 @@ const VirtualizedTableGrid = ({
getAdjustedRowIndex,
getGroupRenderData,
groupHeaderInfoByRowIndex,
parsedColumns,
pinnedLeftColumnCount,
pinnedLeftColumnWidths,
pinnedRightColumnCount,
pinnedRightColumnWidths,
startRowIndex,
],
);
const featureFlags = useMemo(
() => ({
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
}),
[
enableAlternateRowColors,
enableColumnReorder,
enableColumnResize,
enableDrag,
enableExpansion,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
groups,
],
);
const itemProps: TableItemProps = useMemo(
() => ({
...stableConfigProps,
...dynamicDataProps,
...featureFlags,
cellPadding: tableConfig.cellPadding,
columns: tableConfig.columns,
controls: tableConfig.controls,
enableAlternateRowColors: tableConfig.enableAlternateRowColors,
enableColumnReorder: tableConfig.enableColumnReorder,
enableColumnResize: tableConfig.enableColumnResize,
enableDrag: tableConfig.enableDrag,
enableExpansion: tableConfig.enableExpansion,
enableHeader: tableConfig.enableHeader,
enableHorizontalBorders: tableConfig.enableHorizontalBorders,
enableRowHoverHighlight: tableConfig.enableRowHoverHighlight,
enableSelection: tableConfig.enableSelection,
enableVerticalBorders: tableConfig.enableVerticalBorders,
getRowHeight: tableConfig.getRowHeight,
groups: tableConfig.groups,
internalState: tableConfig.internalState,
itemType: tableConfig.itemType,
playerContext: tableConfig.playerContext,
playlistId: tableConfig.playlistId,
size: tableConfig.size,
startRowIndex: tableConfig.startRowIndex,
tableId: tableConfig.tableId,
...gridOnlyProps,
}),
[stableConfigProps, dynamicDataProps, featureFlags],
[gridOnlyProps, tableConfig],
);
const pinnedLeftGridMinWidthPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedLeftColumnCount; i++) {
sum += calculatedColumnWidths[i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount]);
const pinnedRightGridMinWidthPx = useMemo(() => {
let sum = 0;
const start = pinnedLeftColumnCount + totalColumnCount;
for (let i = 0; i < pinnedRightColumnCount; i++) {
sum += calculatedColumnWidths[start + i] ?? 0;
}
return sum;
}, [calculatedColumnWidths, pinnedLeftColumnCount, pinnedRightColumnCount, totalColumnCount]);
const pinnedRowsMinHeightPx = useMemo(() => {
let sum = 0;
for (let i = 0; i < pinnedRowCount; i++) {
sum += getRowHeight(i, itemProps);
}
return sum;
}, [getRowHeight, itemProps, pinnedRowCount]);
const PinnedRowCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return (
@@ -447,16 +403,14 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],
[pinnedLeftColumnCount, CellComponent],
);
const PinnedColumnCell = useCallback(
(cellProps: CellComponentProps & TableItemProps) => {
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],
[pinnedRowCount, CellComponent],
);
const PinnedRightColumnCell = useCallback(
@@ -469,15 +423,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
pinnedRowCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
);
const PinnedRightIntersectionCell = useCallback(
@@ -489,14 +435,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
totalColumnCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, totalColumnCount, CellComponent],
);
const RowCell = useCallback(
@@ -509,14 +448,7 @@ const VirtualizedTableGrid = ({
/>
);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
pinnedLeftColumnCount,
pinnedRowCount,
CellComponent,
featureFlags,
calculatedColumnWidths,
],
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
);
const handleOnCellsRendered = useCallback(
@@ -541,10 +473,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
(a, _, i) => a + columnWidth(i),
0,
)}px`,
minWidth: `${pinnedLeftGridMinWidthPx}px`,
} as React.CSSProperties
}
>
@@ -554,10 +483,7 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(
(a, _, i) => a + getRowHeight(i, itemProps),
0,
)}px`,
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
}}
>
@@ -611,10 +537,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
} as React.CSSProperties
}
@@ -627,7 +550,7 @@ const VirtualizedTableGrid = ({
columnWidth={(index) => {
return columnWidth(index + pinnedLeftColumnCount);
}}
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}
rowCount={pinnedRowCount}
rowHeight={getRowHeight}
/>
</div>
@@ -660,14 +583,7 @@ const VirtualizedTableGrid = ({
style={
{
'--header-height': `${headerHeight}px`,
minWidth: `${Array.from(
{ length: pinnedRightColumnCount },
() => 0,
).reduce(
(a, _, i) =>
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
0,
)}px`,
minWidth: `${pinnedRightGridMinWidthPx}px`,
} as React.CSSProperties
}
>
@@ -677,10 +593,7 @@ const VirtualizedTableGrid = ({
[styles.withHeader]: enableHeader,
})}
style={{
minHeight: `${Array.from(
{ length: pinnedRowCount },
() => 0,
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
minHeight: `${pinnedRowsMinHeightPx}px`,
overflow: 'hidden',
}}
>
@@ -739,27 +652,12 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.calculatedColumnWidths,
nextProps.calculatedColumnWidths,
) &&
prevProps.cellPadding === nextProps.cellPadding &&
prevProps.controls === nextProps.controls &&
prevProps.tableConfig === nextProps.tableConfig &&
prevProps.data === nextProps.data &&
prevProps.dataWithGroups === nextProps.dataWithGroups &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableDrag === nextProps.enableDrag &&
prevProps.enableExpansion === nextProps.enableExpansion &&
prevProps.enableHeader === nextProps.enableHeader &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableScrollShadow === nextProps.enableScrollShadow &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.getItem === nextProps.getItem &&
prevProps.getRowHeight === nextProps.getRowHeight &&
prevProps.groups === nextProps.groups &&
prevProps.headerHeight === nextProps.headerHeight &&
prevProps.internalState === nextProps.internalState &&
prevProps.itemType === nextProps.itemType &&
prevProps.mergedRowRef === nextProps.mergedRowRef &&
prevProps.onRangeChanged === nextProps.onRangeChanged &&
prevProps.parsedColumns === nextProps.parsedColumns &&
@@ -769,13 +667,9 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
prevProps.pinnedRightColumnRef === nextProps.pinnedRightColumnRef &&
prevProps.pinnedRowCount === nextProps.pinnedRowCount &&
prevProps.pinnedRowRef === nextProps.pinnedRowRef &&
prevProps.playerContext === nextProps.playerContext &&
prevProps.showLeftShadow === nextProps.showLeftShadow &&
prevProps.showRightShadow === nextProps.showRightShadow &&
prevProps.showTopShadow === nextProps.showTopShadow &&
prevProps.size === nextProps.size &&
prevProps.startRowIndex === nextProps.startRowIndex &&
prevProps.tableId === nextProps.tableId &&
prevProps.totalColumnCount === nextProps.totalColumnCount &&
prevProps.totalRowCount === nextProps.totalRowCount &&
prevProps.CellComponent === nextProps.CellComponent
@@ -828,6 +722,7 @@ export interface TableItemProps {
pinnedRightColumnCount?: number;
pinnedRightColumnWidths?: number[];
playerContext: PlayerContext;
playlistId?: string;
size?: ItemTableListProps['size'];
startRowIndex?: number;
tableId: string;
@@ -935,6 +830,8 @@ const ItemTableListStickyUI = memo(
const stickyHeaderMainRef = useRef<HTMLDivElement | null>(null);
const stickyHeaderRightRef = useRef<HTMLDivElement | null>(null);
const stickyLayout = useItemTableStickyLayoutOffsets();
const { shouldShowStickyHeader, stickyTop } = useStickyTableHeader({
containerRef,
enabled: enableHeader && enableStickyHeader,
@@ -943,6 +840,7 @@ const ItemTableListStickyUI = memo(
pinnedLeftColumnRef,
pinnedRightColumnRef,
stickyHeaderMainRef,
stickyLayout,
});
useStickyHeaderPositioning({
@@ -964,6 +862,7 @@ const ItemTableListStickyUI = memo(
mainGridRef: rowRef,
shouldShowStickyHeader,
stickyHeaderTop: stickyTop,
stickyLayout,
});
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
@@ -1309,6 +1208,7 @@ const BaseItemTableList = ({
size = 'default',
startRowIndex,
}: ItemTableListProps) => {
const { playlistId: routePlaylistId } = useParams() as { playlistId?: string };
const tableId = useId();
const baseItemCount = itemCount ?? data.length;
const totalItemCount = enableHeader ? baseItemCount + 1 : baseItemCount;
@@ -1574,6 +1474,7 @@ const BaseItemTableList = ({
pinnedLeftColumnCount + totalColumnCount,
),
playerContext,
playlistId: routePlaylistId,
size,
tableId,
}),
@@ -1599,6 +1500,7 @@ const BaseItemTableList = ({
pinnedLeftColumnCount,
pinnedRightColumnCount,
playerContext,
routePlaylistId,
size,
tableId,
totalColumnCount,
@@ -1612,17 +1514,27 @@ const BaseItemTableList = ({
itemType,
});
const tableConfigValue = useMemo(
const tableConfigValue = useMemo<ItemTableListConfig>(
() => ({
cellPadding,
columns: parsedColumns,
controls,
enableAlternateRowColors,
enableColumnReorder: !!onColumnReordered,
enableColumnResize: !!onColumnResized,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
playerContext,
playlistId: routePlaylistId,
size,
startRowIndex,
tableId,
@@ -1631,12 +1543,22 @@ const BaseItemTableList = ({
cellPadding,
parsedColumns,
controls,
enableAlternateRowColors,
onColumnReordered,
onColumnResized,
enableDrag,
enableExpansion,
enableHeader,
enableHorizontalBorders,
enableRowHoverHighlight,
enableSelection,
enableVerticalBorders,
getRowHeight,
groups,
internalState,
itemType,
playerContext,
routePlaylistId,
size,
startRowIndex,
tableId,
@@ -1707,27 +1629,11 @@ const BaseItemTableList = ({
<MemoizedVirtualizedTableGrid
calculatedColumnWidths={calculatedColumnWidths}
CellComponent={optimizedCellComponent}
cellPadding={cellPadding}
controls={controls}
data={data}
dataWithGroups={dataWithGroups}
enableAlternateRowColors={enableAlternateRowColors}
enableColumnReorder={!!onColumnReordered}
enableColumnResize={!!onColumnResized}
enableDrag={enableDrag}
enableExpansion={enableExpansion}
enableHeader={enableHeader}
enableHorizontalBorders={enableHorizontalBorders}
enableRowHoverHighlight={enableRowHoverHighlight}
enableScrollShadow={enableScrollShadow}
enableSelection={enableSelection}
enableVerticalBorders={enableVerticalBorders}
getItem={getItem}
getRowHeight={getRowHeight}
groups={groups}
headerHeight={headerHeight}
internalState={internalState}
itemType={itemType}
mergedRowRef={mergedRowRef}
onRangeChanged={onRangeChanged}
parsedColumns={parsedColumns}
@@ -1737,13 +1643,10 @@ const BaseItemTableList = ({
pinnedRightColumnRef={pinnedRightColumnRef}
pinnedRowCount={pinnedRowCount}
pinnedRowRef={pinnedRowRef}
playerContext={playerContext}
showLeftShadow={showLeftShadow}
showRightShadow={showRightShadow}
showTopShadow={showTopShadow}
size={size}
startRowIndex={startRowIndex}
tableId={tableId}
tableConfig={tableConfigValue}
totalColumnCount={totalColumnCount}
totalRowCount={totalRowCount}
/>
@@ -1,4 +1,4 @@
import React, { memo, useMemo } from 'react';
import React, { useMemo } from 'react';
import { CellComponentProps } from 'react-window-v2';
import { createColumnCellComponents } from './cell-component-factory';
@@ -24,24 +24,7 @@ const MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {
return <ItemTableListColumn {...props} />;
};
export const MemoizedCellRouter = memo(MemoizedCellRouterBase, (prevProps, nextProps) => {
return (
prevProps.rowIndex === nextProps.rowIndex &&
prevProps.columnIndex === nextProps.columnIndex &&
prevProps.data === nextProps.data &&
prevProps.columns === nextProps.columns &&
prevProps.columnCellComponents === nextProps.columnCellComponents &&
prevProps.size === nextProps.size &&
prevProps.enableAlternateRowColors === nextProps.enableAlternateRowColors &&
prevProps.enableHorizontalBorders === nextProps.enableHorizontalBorders &&
prevProps.enableVerticalBorders === nextProps.enableVerticalBorders &&
prevProps.enableRowHoverHighlight === nextProps.enableRowHoverHighlight &&
prevProps.enableSelection === nextProps.enableSelection &&
prevProps.enableColumnResize === nextProps.enableColumnResize &&
prevProps.enableColumnReorder === nextProps.enableColumnReorder &&
prevProps.cellPadding === nextProps.cellPadding
);
});
export const MemoizedCellRouter = MemoizedCellRouterBase;
export const useColumnCellComponents = (
columns: TableColumn[],
@@ -20,7 +20,7 @@ import { songsQueries } from '/@/renderer/features/songs/api/songs-api';
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer, useShowRatings } from '/@/renderer/store';
import { useArtistRadioCount, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
import { formatDateAbsoluteUTC, formatDurationString, formatSizeString } from '/@/renderer/utils';
import { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils';
import { normalizeReleaseTypes } from '/@/renderer/utils/normalize-release-types';
import { Group } from '/@/shared/components/group/group';
import { Separator } from '/@/shared/components/separator/separator';
@@ -131,7 +131,10 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
const originalDifferentFromRelease =
album?.originalDate && album?.originalDate !== album?.releaseDate;
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;
const originalYearDifferentFromRelease =
album.originalYear > 0 &&
album.releaseYear != null &&
album.originalYear !== album.releaseYear;
const playCount = album?.playCount;
@@ -147,17 +150,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (originalDifferentFromRelease) {
items.push({
id: 'originalDate',
value: `${formatDateAbsoluteUTC(album.originalDate)}`,
value: `${formatPartialIsoDateUTC(album.originalDate)}`,
});
}
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
});
}
} else if (album.originalYear) {
} else if (album.originalYear > 0) {
if (originalYearDifferentFromRelease) {
items.push({
id: 'originalYear',
@@ -168,14 +171,24 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
});
} else if (releaseYear) {
} else if (releaseYear != null && releaseYear > 0) {
items.push({
id: 'releaseYear',
value: `${releaseYearPrefix} ${releaseYear}`,
});
}
} else if (releaseDate) {
items.push({
id: 'releaseDate',
value: `${formatPartialIsoDateUTC(releaseDate)}`,
});
} else if (releaseYear != null && releaseYear > 0) {
items.push({
id: 'releaseYear',
value: `${releaseYear}`,
});
}
items.push(
@@ -17,9 +17,10 @@ import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library
import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary';
import { useFastAverageColor } from '/@/renderer/hooks';
import { useAlbumBackground, useCurrentServer } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner';
import { LibraryItem } from '/@/shared/types/domain-types';
const ALBUM_DETAIL_BG_FALLBACK = 'var(--theme-colors-foreground-muted)';
const AlbumDetailRoute = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
@@ -42,25 +43,21 @@ const AlbumDetailRoute = () => {
type: 'itemCard',
}) || '';
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
const { background: backgroundColor } = useFastAverageColor({
id: albumId,
src: imageUrl,
srcLoaded: true,
});
const background = backgroundColor;
const background = backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK;
const showBlurredImage = albumBackground;
if (isColorLoading) {
return <Spinner container />;
}
return (
<AnimatedPage key={`album-detail-${albumId}`}>
<NativeScrollArea
pageHeaderProps={{
backgroundColor: backgroundColor || undefined,
backgroundColor: backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK,
children: (
<LibraryHeaderBar>
<LibraryHeaderBar.PlayButton
@@ -189,13 +189,15 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
}, [detailQuery.data?.imageUrl, imageUrl]);
const alternateImageUrl = artistInfoQuery.data?.imageUrl;
const hasImageId = Boolean(detailQuery.data?.imageId);
const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl;
return (
<LibraryHeader
imageUrl={alternateImageUrl || selectedImageUrl}
imageUrl={hasImageId ? undefined : fallbackHeaderImageUrl}
item={{
imageId: detailQuery.data?.imageId,
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl,
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
type: LibraryItem.ALBUM_ARTIST,
}}
@@ -124,10 +124,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
if (!radioState.currentStreamUrl) {
const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode)
? await getSongUrl(playerData.currentSong, transcode, true)
: undefined;
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
? await getSongUrl(playerData.nextSong, transcode, true)
: undefined;
if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) {
@@ -216,6 +216,10 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
return;
}
if (playerStatus !== PlayerStatus.PLAYING) {
return;
}
const updateProgress = async () => {
if (!mpvPlayer || !isMountedRef.current) {
return;
@@ -245,7 +249,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
progressIntervalRef.current = null;
}
};
}, [hasCurrentSong, isTransitioning, onProgress]);
}, [hasCurrentSong, isTransitioning, onProgress, playerStatus]);
const { mediaAutoNext } = usePlayerActions();
@@ -274,14 +278,14 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
onMediaPrev: () => {
replaceMpvQueue(transcode);
},
onNextSongInsertion: (song) => {
onNextSongInsertion: async (song) => {
const radioState = useRadioStore.getState();
if (radioState.currentStreamUrl) {
return;
}
const nextSongUrl = song ? getSongUrl(song, transcode) : undefined;
const nextSongUrl = song ? await getSongUrl(song, transcode, true) : undefined;
mpvPlayer?.setQueueNext(nextSongUrl);
},
onPlayerPlay: () => {
@@ -339,19 +343,19 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
MpvPlayerEngine.displayName = 'MpvPlayerEngine';
function handleMpvAutoNext(transcode: {
async function handleMpvAutoNext(transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
}) {
const playerData = usePlayerStore.getState().getPlayerData();
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
? await getSongUrl(playerData.nextSong, transcode, true)
: undefined;
mpvPlayer?.autoNext(nextSongUrl);
}
function replaceMpvQueue(transcode: {
async function replaceMpvQueue(transcode: {
bitrate?: number | undefined;
enabled: boolean;
format?: string | undefined;
@@ -365,10 +369,10 @@ function replaceMpvQueue(transcode: {
const playerData = usePlayerStore.getState().getPlayerData();
const currentSongUrl = playerData.currentSong
? getSongUrl(playerData.currentSong, transcode)
? await getSongUrl(playerData.currentSong, transcode, true)
: undefined;
const nextSongUrl = playerData.nextSong
? getSongUrl(playerData.nextSong, transcode)
? await getSongUrl(playerData.nextSong, transcode, true)
: undefined;
mpvPlayer?.setQueue(currentSongUrl, nextSongUrl, false);
}
@@ -80,7 +80,7 @@ export const useMainPlayerListener = () => {
mpvPlayerListener.rendererStop(() => {
if (!isRadioActive) {
mediaStop();
mediaStop({ reset: false });
}
});
@@ -1,4 +1,5 @@
import { useMemo, useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { api } from '/@/renderer/api';
import { TranscodingConfig } from '/@/renderer/store';
@@ -10,52 +11,71 @@ export function useSongUrl(
transcode: TranscodingConfig,
): string | undefined {
const prior = useRef(['', '']);
const shouldReusePrior = Boolean(
song?._serverId && current && prior.current[0] === song._uniqueId && prior.current[1],
);
return useMemo(() => {
if (song?._serverId) {
// If we are the current track, we do not want a transcoding
// reconfiguration to force a restart.
if (current && prior.current[0] === song._uniqueId) {
return prior.current[1];
}
const url = api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId },
const { data: queryStreamUrl } = useQuery({
enabled: Boolean(song?._serverId) && !shouldReusePrior,
queryFn: () =>
api.controller.getStreamUrl({
apiClientProps: { serverId: song!._serverId },
query: {
bitrate: transcode.bitrate,
format: transcode.format,
id: song.id,
id: song!.id,
transcode: transcode.enabled,
},
});
}),
queryKey: [
song?._serverId,
'stream-url',
song?.id,
shouldReusePrior ? 'reuse-prior' : transcode.bitrate,
shouldReusePrior ? 'reuse-prior' : transcode.format,
shouldReusePrior ? 'reuse-prior' : transcode.enabled,
] as const,
staleTime: 60 * 1000,
});
// transcoding enabled; save the updated result
prior.current = [song._uniqueId, url];
return url;
useEffect(() => {
if (!song?._serverId) {
prior.current = ['', ''];
return;
}
// no track; clear result
prior.current = ['', ''];
return undefined;
}, [
song?._serverId,
song?._uniqueId,
song?.id,
current,
transcode.bitrate,
transcode.format,
transcode.enabled,
]);
if (!queryStreamUrl) {
return;
}
// Save resolved URL to avoid restarting current track on transcode setting changes.
prior.current = [song._uniqueId, queryStreamUrl];
}, [song?._serverId, song?._uniqueId, queryStreamUrl]);
useEffect(() => {
if (!song?._serverId) {
prior.current = ['', ''];
}
}, [song?._serverId]);
return shouldReusePrior ? prior.current[1] : queryStreamUrl;
}
export const getSongUrl = (song: QueueSong, transcode: TranscodingConfig) => {
return api.controller.getStreamUrl({
export const getSongUrl = async (
song: QueueSong,
transcode: TranscodingConfig,
skipAutoTranscode?: boolean,
) => {
const url = await api.controller.getStreamUrl({
apiClientProps: { serverId: song._serverId },
query: {
bitrate: transcode.bitrate,
format: transcode.format,
id: song.id,
skipAutoTranscode,
transcode: transcode.enabled,
},
});
return url;
};
@@ -33,10 +33,59 @@ import {
usePlaybackType,
useSettingsStoreActions,
} from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerType } from '/@/shared/types/types';
const CODEC_PROBES = [
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
{ codec: 'wav', container: 'wav', mime: 'audio/wav' },
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
];
const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
];
const DIRECT_PLAY_PROFILES: {
audioCodecs: string[];
containers: string[];
protocols: string[];
}[] = [];
export function getDefaultTranscodingProfiles() {
return DEFAULT_TRANSCODING_PROFILES;
}
export function getDirectPlayProfiles() {
return DIRECT_PLAY_PROFILES;
}
// Shamelessly taken from NavidromeUI
function detectBrowserProfile() {
const audio = new Audio();
for (const { codec, container, mime } of CODEC_PROBES) {
if (audio.canPlayType(mime) === 'probably') {
DIRECT_PLAY_PROFILES.push({
audioCodecs: [codec],
containers: [container],
protocols: ['http'],
});
}
}
logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES });
return DIRECT_PLAY_PROFILES;
}
export const AudioPlayers = () => {
const playbackType = usePlaybackType();
const serverId = useCurrentServerId();
@@ -49,6 +98,10 @@ export const AudioPlayers = () => {
} = usePlaybackSettings();
const { setWebAudio, webAudio: audioContext } = useWebAudio();
useEffect(() => {
detectBrowserProfile();
}, []);
return (
<>
<SleepTimerHook />
@@ -112,7 +112,7 @@ const StopButton = ({ disabled }: { disabled?: boolean }) => {
<PlayerButton
disabled={disabled}
icon={<Icon fill="default" icon="mediaStop" size={buttonSize - 2} />}
onClick={mediaStop}
onClick={() => mediaStop()}
tooltip={{
label: t('player.stop', { postProcess: 'sentenceCase' }),
openDelay: 0,
@@ -11,7 +11,10 @@ import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display';
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
import {
useIsRadioActive,
useRadioPlayer,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { AppRoute } from '/@/renderer/router/routes';
import {
useAppStore,
@@ -50,9 +53,11 @@ export const LeftControls = () => {
const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive();
const { currentStationArt } = useRadioPlayer();
const { bindings } = useHotkeySettings();
const isRadioMode = isRadioActive;
const hasRadioStationImage = Boolean(currentStationArt?.imageId || currentStationArt?.imageUrl);
const hideImage = image && !collapsed;
const isSongDefined = Boolean(currentSong?.id) && !isRadioMode;
const title = currentSong?.name;
@@ -128,7 +133,22 @@ export const LeftControls = () => {
})}
openDelay={0}
>
{isRadioMode ? (
{isRadioMode && hasRadioStationImage ? (
<ItemImage
className={clsx(
styles.playerbarImage,
PlaybackSelectors.playerCoverArt,
)}
enableDebounce={false}
enableViewport={false}
fetchPriority="high"
id={currentStationArt?.imageId ?? undefined}
itemType={LibraryItem.RADIO_STATION}
serverId={currentStationArt?.serverId}
src={currentStationArt?.imageUrl ?? ''}
type="table"
/>
) : isRadioMode ? (
<Center
className={clsx(
styles.playerbarImage,
@@ -1,7 +1,6 @@
.container {
width: 100vw;
width: 100%;
height: 100%;
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
}
.controls-grid {
@@ -64,7 +64,7 @@ export interface PlayerContext {
mediaSeekToTimestamp: (timestamp: number) => void;
mediaSkipBackward: () => void;
mediaSkipForward: () => void;
mediaStop: () => void;
mediaStop: (options?: { reset?: boolean }) => void;
mediaToggleMute: () => void;
mediaTogglePlayPause: () => void;
moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;
@@ -596,13 +596,17 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
storeActions.mediaPrevious();
}, [storeActions]);
const mediaStop = useCallback(() => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
category: LogCategory.PLAYER,
});
const mediaStop = useCallback(
(options?: { reset?: boolean }) => {
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
category: LogCategory.PLAYER,
meta: { reset: options?.reset },
});
storeActions.mediaStop();
}, [storeActions]);
storeActions.mediaStop(options);
},
[storeActions],
);
const mediaSeekToTimestamp = useCallback(
(timestamp: number) => {
@@ -72,6 +72,7 @@ export const CreatePlaylistForm = ({ onCancel }: CreatePlaylistFormProps) => {
? {
...convertQueryGroupToNDQuery(smartPlaylist.filters),
limit: smartPlaylist.extraFilters.limit,
limitPercent: smartPlaylist.extraFilters.limitPercent,
// order field is now optional - sort direction is embedded in sort field
sort: sortValue || '+dateAdded',
}
@@ -8,6 +8,8 @@ import { useListContext } from '/@/renderer/context/list-context';
import { usePlayer } from '/@/renderer/features/player/context/player-context';
import { playlistsQueries } from '/@/renderer/features/playlists/api/playlists-api';
import { PlaylistDetailSongListHeaderFilters } from '/@/renderer/features/playlists/components/playlist-detail-song-list-header-filters';
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
import { FilterBar } from '/@/renderer/features/shared/components/filter-bar';
import {
LibraryHeader,
@@ -18,9 +20,17 @@ import { ListSearchInput } from '/@/renderer/features/shared/components/list-sea
import { AppRoute } from '/@/renderer/router/routes';
import { useCurrentServer } from '/@/renderer/store';
import { formatDurationString } from '/@/renderer/utils';
import { replaceURLWithHTMLLinks } from '/@/renderer/utils/linkify';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Group } from '/@/shared/components/group/group';
import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { LibraryItem, Playlist, Song } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
interface PlaylistDetailSongListHeaderProps {
@@ -30,6 +40,64 @@ interface PlaylistDetailSongListHeaderProps {
onToggleQueryBuilder?: () => void;
}
function ImageUploadOverlay({ data }: { data?: Playlist }) {
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
const deletePlaylistImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
if (!data) return null;
if (!hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD)) return null;
return (
<Group gap="xs">
<FileButton
accept="image/*"
onChange={async (file) => {
if (!file || !data?._serverId) return;
const buffer = await file.arrayBuffer();
uploadPlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
body: { image: new Uint8Array(buffer) },
query: { id: data.id },
});
}}
>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="xs"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={!data?.uploadedImage}
icon="delete"
iconProps={{ size: 'lg' }}
onClick={(e) => {
e.stopPropagation();
if (!data?._serverId) return;
deletePlaylistImageMutation.mutate({
apiClientProps: {
serverId: data._serverId,
},
query: { id: data.id },
});
}}
radius="xl"
size="xs"
variant="default"
/>
</Group>
);
}
export const PlaylistDetailSongListHeader = ({
isSmartPlaylist,
}: PlaylistDetailSongListHeaderProps) => {
@@ -45,6 +113,7 @@ export const PlaylistDetailSongListHeader = ({
});
const playlistDuration = detailQuery?.data?.duration;
const playlistDescription = detailQuery?.data?.description?.trim();
const [collapsed] = useLocalStorage<boolean>({
defaultValue: false,
@@ -94,6 +163,7 @@ export const PlaylistDetailSongListHeader = ({
) : (
<LibraryHeader
compact
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
imageUrl={imageUrl}
item={{
imageId: detailQuery?.data?.imageId,
@@ -104,10 +174,32 @@ export const PlaylistDetailSongListHeader = ({
title={detailQuery?.data?.name || ''}
topRight={<ListSearchInput />}
>
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
onShuffle={() => handlePlay(Play.SHUFFLE)}
/>
<Stack gap="md" w="100%">
{playlistDescription ? (
<Spoiler
hideLabel={<></>}
maxHeight={16}
showLabel={<></>}
style={{ marginBottom: 0 }}
>
<Text
isMuted
size="sm"
style={{
maxWidth: '100%',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{replaceURLWithHTMLLinks(playlistDescription)}
</Text>
</Spoiler>
) : null}
<LibraryHeaderMenu
onPlay={(type) => handlePlay(type)}
onShuffle={() => handlePlay(Play.SHUFFLE)}
/>
</Stack>
</LibraryHeader>
)}
<FilterBar>
@@ -32,6 +32,7 @@ import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { NumberInput } from '/@/shared/components/number-input/number-input';
import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { useForm } from '/@/shared/hooks/use-form';
@@ -51,6 +52,7 @@ type DeleteArgs = {
interface PlaylistQueryBuilderProps {
limit?: number;
limitPercent?: number;
playlistId?: string;
query: any;
sortBy: SongListSort | SongListSort[];
@@ -155,6 +157,7 @@ export type PlaylistQueryBuilderRef = {
getFilters: () => {
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
};
@@ -164,7 +167,7 @@ export type PlaylistQueryBuilderRef = {
export const PlaylistQueryBuilder = forwardRef(
(
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
{ limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
ref: Ref<PlaylistQueryBuilderRef>,
) => {
const { t } = useTranslation();
@@ -213,6 +216,8 @@ export const PlaylistQueryBuilder = forwardRef(
const extraFiltersForm = useForm({
initialValues: {
limit,
limitMode: limitPercent != null ? 'limitPercent' : 'limit',
limitPercent,
sortEntries: initialSortEntries,
},
});
@@ -224,16 +229,26 @@ export const PlaylistQueryBuilder = forwardRef(
const sortString = convertSortEntriesToSortString(
extraFiltersForm.values.sortEntries,
);
const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent';
return {
extraFilters: {
limit: extraFiltersForm.values.limit,
limit: isLimitPercent ? undefined : extraFiltersForm.values.limit,
limitPercent: isLimitPercent
? extraFiltersForm.values.limitPercent
: undefined,
sortBy: sortString ? [sortString] : undefined,
},
filters,
};
},
}),
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
[
extraFiltersForm.values.sortEntries,
extraFiltersForm.values.limit,
extraFiltersForm.values.limitMode,
extraFiltersForm.values.limitPercent,
filters,
],
);
const handleResetFilters = useCallback(() => {
@@ -608,10 +623,50 @@ export const PlaylistQueryBuilder = forwardRef(
))}
</Stack>
<NumberInput
label={t('common.limit', { postProcess: 'titleCase' })}
maxWidth="20%"
label={
<Group align="center" gap="xs" wrap="nowrap">
{t('common.limit', { postProcess: 'titleCase' })}
<SegmentedControl
data={[
{ label: '#', value: 'limit' },
{ label: '%', value: 'limitPercent' },
]}
onChange={(value) =>
extraFiltersForm.setFieldValue(
'limitMode',
value as 'limit' | 'limitPercent',
)
}
size="xs"
value={extraFiltersForm.values.limitMode}
/>
</Group>
}
max={
extraFiltersForm.values.limitMode === 'limitPercent'
? 100
: undefined
}
min={
extraFiltersForm.values.limitMode === 'limitPercent'
? 0
: undefined
}
onChange={(value) => {
const nextValue =
value === '' || value == null ? undefined : Number(value);
if (extraFiltersForm.values.limitMode === 'limitPercent') {
extraFiltersForm.setFieldValue('limitPercent', nextValue);
} else {
extraFiltersForm.setFieldValue('limit', nextValue);
}
}}
value={
extraFiltersForm.values.limitMode === 'limitPercent'
? extraFiltersForm.values.limitPercent
: extraFiltersForm.values.limit
}
width={75}
{...extraFiltersForm.getInputProps('limit')}
/>
</Group>
</Stack>
@@ -28,11 +28,21 @@ export interface PlaylistQueryEditorProps {
detailQuery: ReturnType<typeof useQuery<any>>;
handleSave: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void;
handleSaveAs: (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => void;
isQueryBuilderExpanded: boolean;
onToggleExpand: () => void;
@@ -43,6 +53,7 @@ export interface PlaylistQueryEditorProps {
type AppliedJsonState = {
limit?: number;
limitPercent?: number;
query: Record<string, any>;
sort?: string;
};
@@ -50,7 +61,7 @@ type AppliedJsonState = {
type EditorMode = 'builder' | 'json';
const serializeFiltersToRulesJson = (filters: {
extraFilters: { limit?: number; sortBy?: string[] };
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filters: any;
}): Record<string, any> => {
const queryValue = convertQueryGroupToNDQuery(filters.filters);
@@ -58,18 +69,25 @@ const serializeFiltersToRulesJson = (filters: {
return {
...queryValue,
...(filters.extraFilters.limit != null && { limit: filters.extraFilters.limit }),
...(filters.extraFilters.limitPercent != null && {
limitPercent: filters.extraFilters.limitPercent,
}),
...(sortString && { sort: sortString }),
};
};
const parseRulesJsonToSaveArgs = (
parsed: Record<string, any>,
): { extraFilters: { limit?: number; sortBy?: string[] }; filter: Record<string, any> } => {
): {
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
filter: Record<string, any>;
} => {
const rootKey = parsed.all ? 'all' : 'any';
const filter = rootKey in parsed ? { [rootKey]: parsed[rootKey] } : { all: [] };
return {
extraFilters: {
...(parsed.limit != null && { limit: parsed.limit }),
...(parsed.limitPercent != null && { limitPercent: parsed.limitPercent }),
...(parsed.sort != null && { sortBy: [parsed.sort] }),
},
filter,
@@ -93,7 +111,12 @@ export const PlaylistQueryEditor = ({
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
const getFiltersForSave = useCallback((): null | {
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
};
filter: Record<string, any>;
} => {
if (editorMode === 'json') {
@@ -124,6 +147,9 @@ export const PlaylistQueryEditor = ({
const previewValue = {
...payload.filter,
...(payload.extraFilters.limit != null && { limit: payload.extraFilters.limit }),
...(payload.extraFilters.limitPercent != null && {
limitPercent: payload.extraFilters.limitPercent,
}),
...(payload.extraFilters.sortBy?.[0] && { sort: payload.extraFilters.sortBy[0] }),
};
openModal({
@@ -208,6 +234,8 @@ export const PlaylistQueryEditor = ({
[appliedJsonState?.query, detailQuery?.data?.rules],
);
const effectiveLimit = appliedJsonState?.limit ?? detailQuery?.data?.rules?.limit;
const effectiveLimitPercent =
appliedJsonState?.limitPercent ?? detailQuery?.data?.rules?.limitPercent;
const effectiveSortBy = useMemo(
() =>
(appliedJsonState?.sort ? [appliedJsonState.sort] : parseSortBy()) as
@@ -233,6 +261,8 @@ export const PlaylistQueryEditor = ({
? { ...effectiveQuery }
: { all: [] };
if (effectiveLimit != null) fallback.limit = effectiveLimit;
if (effectiveLimitPercent != null)
fallback.limitPercent = effectiveLimitPercent;
if (effectiveSortBy?.[0]) fallback.sort = effectiveSortBy[0];
if (!fallback.sort) fallback.sort = '+dateAdded';
setJsonText(JSON.stringify(fallback, null, 2));
@@ -248,6 +278,7 @@ export const PlaylistQueryEditor = ({
}
setAppliedJsonState({
limit: parsed.limit,
limitPercent: parsed.limitPercent,
query: { [rootKey]: parsed[rootKey] },
sort: parsed.sort,
});
@@ -263,7 +294,16 @@ export const PlaylistQueryEditor = ({
setEditorMode('builder');
}
},
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
[
editorMode,
effectiveLimit,
effectiveLimitPercent,
effectiveQuery,
effectiveSortBy,
jsonText,
queryBuilderRef,
t,
],
);
return (
@@ -367,6 +407,7 @@ export const PlaylistQueryEditor = ({
<PlaylistQueryBuilder
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
limit={effectiveLimit}
limitPercent={effectiveLimitPercent}
playlistId={playlistId}
query={effectiveQuery}
ref={queryBuilderRef}
@@ -1,21 +1,31 @@
import { closeModal, ContextModalProps } from '@mantine/modals';
import { useQuery } from '@tanstack/react-query';
import { t } from 'i18next';
import { type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeletePlaylistImage } from '/@/renderer/features/playlists/mutations/delete-playlist-image-mutation';
import { useUpdatePlaylist } from '/@/renderer/features/playlists/mutations/update-playlist-mutation';
import { useUploadPlaylistImage } from '/@/renderer/features/playlists/mutations/upload-playlist-image-mutation';
import { sharedQueries } from '/@/renderer/features/shared/api/shared-api';
import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { ModalButton } from '/@/shared/components/modal/model-shared';
import { Select } from '/@/shared/components/select/select';
import { Stack } from '/@/shared/components/stack/stack';
import { Switch } from '/@/shared/components/switch/switch';
import { TextInput } from '/@/shared/components/text-input/text-input';
import { Textarea } from '/@/shared/components/textarea/textarea';
import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import {
LibraryItem,
ServerType,
SortOrder,
UpdatePlaylistBody,
@@ -24,17 +34,41 @@ import {
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
type PlaylistImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const UpdatePlaylistContextModal = ({
id,
innerProps,
}: ContextModalProps<{
body: Partial<UpdatePlaylistBody>;
playlistImage?: PlaylistImageProps;
query: UpdatePlaylistQuery;
}>) => {
const { t } = useTranslation();
const mutation = useUpdatePlaylist({});
const updateMutation = useUpdatePlaylist({});
const uploadImageMutation = useUploadPlaylistImage({});
const deleteImageMutation = useDeletePlaylistImage({});
const server = useCurrentServer();
const { body, query } = innerProps;
const { body, playlistImage, query } = innerProps;
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
const [removeCustomCover, setRemoveCustomCover] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!pendingFile) {
setPendingPreviewUrl(null);
return;
}
const url = URL.createObjectURL(pendingFile);
setPendingPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [pendingFile]);
const form = useForm<UpdatePlaylistBody>({
initialValues: {
@@ -47,91 +81,259 @@ export const UpdatePlaylistContextModal = ({
},
});
const handleSubmit = form.onSubmit((values) => {
mutation.mutate(
{
apiClientProps: { serverId: server?.id || '' },
const handleSubmit = form.onSubmit(async (values) => {
if (!server?.id) return;
setIsSaving(true);
try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: values,
query,
},
{
onError: (err) => {
toast.error({
message: err.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
},
onSuccess: () => {
toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
});
closeModal(id);
},
},
);
});
if (pendingFile) {
const buffer = await pendingFile.arrayBuffer();
await uploadImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: { image: new Uint8Array(buffer) },
query: { id: query.id },
});
} else if (removeCustomCover && playlistImage?.uploadedImage) {
await deleteImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
query: { id: query.id },
});
}
toast.success({
message: t('form.editPlaylist.success', { postProcess: 'sentenceCase' }),
});
closeModal(id);
} catch (err: any) {
toast.error({
message: err?.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }),
});
} finally {
setIsSaving(false);
}
});
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
const isCommentDisplayed = server?.type === ServerType.NAVIDROME;
const isSubmitDisabled = !form.values.name || mutation.isPending;
const isCoverImageDisplayed = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
const isSubmitDisabled = !form.values.name || isSaving;
const hadUploadedCover = !!playlistImage?.uploadedImage;
const fieldNodes: ReactNode[] = [
<TextInput
data-autofocus
key="name"
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>,
];
if (isCommentDisplayed) {
fieldNodes.push(
<Textarea
autosize
key="comment"
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
minRows={5}
{...form.getInputProps('comment')}
/>,
);
}
if (isOwnerDisplayed) {
fieldNodes.push(<OwnerSelect form={form} key="owner" />);
}
if (isPublicDisplayed) {
if (server?.type === ServerType.JELLYFIN) {
fieldNodes.push(
<div key="jellyfin-public-note">
{t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase',
})}
</div>,
);
}
fieldNodes.push(
<Switch
key="public"
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>,
);
}
fieldNodes.push(
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={() => closeModal(id)}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
);
return (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
data-autofocus
label={t('form.createPlaylist.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>
{isCommentDisplayed && (
<TextInput
label={t('form.createPlaylist.input', {
context: 'description',
postProcess: 'titleCase',
})}
{...form.getInputProps('comment')}
{isCoverImageDisplayed ? (
<Flex align="flex-start" gap="lg" wrap="wrap">
<PlaylistCoverField
hadUploadedCover={hadUploadedCover}
onClearPending={() => setPendingFile(null)}
onFileSelect={(file) => {
if (!file) return;
setRemoveCustomCover(false);
setPendingFile(file);
}}
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
pendingFile={pendingFile}
pendingPreviewUrl={pendingPreviewUrl}
playlistImage={playlistImage}
removeCustomCover={removeCustomCover}
/>
)}
{isOwnerDisplayed && <OwnerSelect form={form} />}
{isPublicDisplayed && (
<>
{server?.type === ServerType.JELLYFIN && (
<div>
{t('form.editPlaylist.publicJellyfinNote', {
postProcess: 'sentenceCase',
})}
</div>
)}
<Switch
label={t('form.createPlaylist.input', {
context: 'public',
postProcess: 'titleCase',
})}
{...form.getInputProps('public', { type: 'checkbox' })}
/>
</>
)}
<Group justify="flex-end">
<ModalButton onClick={() => closeModal(id)}>{t('common.cancel')}</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={mutation.isPending}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>
</Stack>
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{fieldNodes}
</Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form>
);
};
const COVER_SIZE = 240;
function PlaylistCoverField({
hadUploadedCover,
onClearPending,
onFileSelect,
onToggleRemoveCover,
pendingFile,
pendingPreviewUrl,
playlistImage,
removeCustomCover,
}: {
hadUploadedCover: boolean;
onClearPending: () => void;
onFileSelect: (file: File | null) => void;
onToggleRemoveCover: () => void;
pendingFile: File | null;
pendingPreviewUrl: null | string;
playlistImage?: PlaylistImageProps;
removeCustomCover: boolean;
}) {
const server = useCurrentServer();
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
const previewId = showServerCover ? playlistImage?.imageId || undefined : undefined;
const previewSrc = pendingPreviewUrl || (showServerCover ? playlistImage?.imageUrl || '' : '');
const secondaryAction = () => {
if (pendingFile) {
onClearPending();
return;
}
if (hadUploadedCover) {
onToggleRemoveCover();
}
};
const secondaryDisabled = !pendingFile && !hadUploadedCover;
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.PLAYLIST}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</Box>
);
}
const OwnerSelect = ({ form }: { form: ReturnType<typeof useForm<UpdatePlaylistBody>> }) => {
const serverId = useCurrentServerId();
const permissions = usePermissions();
@@ -1,11 +1,17 @@
import { openContextModal } from '@mantine/modals';
import i18n from '/@/i18n/i18n';
import { useAuthStore } from '/@/renderer/store';
import { hasFeature } from '/@/shared/api/utils';
import { Playlist } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
const { playlist } = args;
const server = useAuthStore.getState().currentServer;
const hasImageUpload = hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD);
openContextModal({
innerProps: {
body: {
@@ -17,9 +23,15 @@ export const openUpdatePlaylistModal = async (args: { playlist: Playlist }) => {
queryBuilderRules: playlist?.rules || undefined,
sync: playlist?.sync || undefined,
},
playlistImage: {
imageId: playlist.imageId,
imageUrl: playlist.imageUrl,
uploadedImage: playlist.uploadedImage,
},
query: { id: playlist?.id },
},
modalKey: 'updatePlaylist',
size: hasImageUpload ? 'lg' : 'md',
title: i18n.t('form.editPlaylist.title', { postProcess: 'titleCase' }) as string,
});
};
@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { DeletePlaylistImageArgs, DeletePlaylistImageResponse } from '/@/shared/types/domain-types';
export const useDeletePlaylistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<DeletePlaylistImageResponse, AxiosError, DeletePlaylistImageArgs, null>({
mutationFn: (args) => {
return api.controller.deletePlaylistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, query.id),
});
}
},
...options,
});
};
@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import { UploadPlaylistImageArgs, UploadPlaylistImageResponse } from '/@/shared/types/domain-types';
export const useUploadPlaylistImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<UploadPlaylistImageResponse, AxiosError, UploadPlaylistImageArgs, null>({
mutationFn: (args) => {
return api.controller.uploadPlaylistImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps, query } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.list(serverId),
});
if (query?.id) {
queryClient.invalidateQueries({
queryKey: queryKeys.playlists.detail(serverId, query.id),
});
}
},
...options,
});
};
@@ -85,7 +85,12 @@ const PlaylistDetailSongListRoute = () => {
const handleSave = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => {
if (!detailQuery?.data) return;
@@ -96,7 +101,8 @@ const PlaylistDetailSongListRoute = () => {
const rules = {
...filter,
limit: extraFilters.limit || undefined,
limit: extraFilters.limit ?? undefined,
limitPercent: extraFilters.limitPercent ?? undefined,
sort: sortValue,
};
@@ -123,7 +129,12 @@ const PlaylistDetailSongListRoute = () => {
const handleSaveAs = (
filter: Record<string, any>,
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
extraFilters: {
limit?: number;
limitPercent?: number;
sortBy?: string[];
sortOrder?: string;
},
) => {
if (!detailQuery?.data) return;
@@ -134,7 +145,8 @@ const PlaylistDetailSongListRoute = () => {
const rules = {
...filter,
limit: extraFilters.limit || undefined,
limit: extraFilters.limit ?? undefined,
limitPercent: extraFilters.limitPercent ?? undefined,
sort: sortValue,
};
+1 -1
View File
@@ -36,7 +36,7 @@ export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
mbzReleaseGroupId: null,
name: song.album ?? '',
originalDate: null,
originalYear: null,
originalYear: 0,
participants: song.participants,
playCount: null,
recordLabels: [],
@@ -1,11 +1,19 @@
import { t } from 'i18next';
import { MouseEvent } from 'react';
import { MouseEvent, type ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { useDeleteInternetRadioStationImage } from '/@/renderer/features/radio/mutations/delete-internet-radio-station-image-mutation';
import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation';
import { useUploadInternetRadioStationImage } from '/@/renderer/features/radio/mutations/upload-internet-radio-station-image-mutation';
import { useCurrentServer } from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { hasFeature } from '/@/shared/api/utils';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { FileButton } from '/@/shared/components/file-button/file-button';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { closeAllModals, openModal } from '/@/shared/components/modal/modal';
import { ModalButton } from '/@/shared/components/modal/model-shared';
@@ -15,19 +23,51 @@ import { toast } from '/@/shared/components/toast/toast';
import { useForm } from '/@/shared/hooks/use-form';
import {
InternetRadioStation,
LibraryItem,
ServerListItem,
UpdateInternetRadioStationBody,
} from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types';
interface EditRadioStationFormProps {
onCancel: () => void;
station: InternetRadioStation;
}
type RadioStationImageProps = {
imageId: null | string;
imageUrl: null | string;
uploadedImage?: string;
};
export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => {
const { t } = useTranslation();
const mutation = useUpdateRadioStation({});
const updateMutation = useUpdateRadioStation({});
const uploadImageMutation = useUploadInternetRadioStationImage({});
const deleteImageMutation = useDeleteInternetRadioStationImage({});
const server = useCurrentServer();
const isCoverImageDisplayed = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD);
const stationImage: RadioStationImageProps = {
imageId: station.imageId ?? null,
imageUrl: station.imageUrl ?? null,
uploadedImage: station.uploadedImage ?? undefined,
};
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<null | string>(null);
const [removeCustomCover, setRemoveCustomCover] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
if (!pendingFile) {
setPendingPreviewUrl(null);
return;
}
const url = URL.createObjectURL(pendingFile);
setPendingPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [pendingFile]);
const form = useForm<UpdateInternetRadioStationBody>({
initialValues: {
@@ -37,74 +77,234 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm
},
});
const handleSubmit = form.onSubmit((values) => {
if (!server) return;
const handleSubmit = form.onSubmit(async (values) => {
if (!server?.id) return;
mutation.mutate(
{
setIsSaving(true);
try {
await updateMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: values,
query: { id: station.id },
},
{
onError: (error) => {
logFn.error(logMsg.other.error, {
meta: { error: error as Error },
});
});
toast.error({
message: (error as Error).message,
title: t('error.genericError', {
postProcess: 'sentenceCase',
}) as string,
});
},
onSuccess: () => {
closeAllModals();
},
},
);
if (pendingFile) {
const buffer = await pendingFile.arrayBuffer();
await uploadImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
body: { image: new Uint8Array(buffer) },
query: { id: station.id },
});
} else if (removeCustomCover && stationImage.uploadedImage) {
await deleteImageMutation.mutateAsync({
apiClientProps: { serverId: server.id },
query: { id: station.id },
});
}
toast.success({
message: t('form.editRadioStation.success', {
postProcess: 'sentenceCase',
}) as string,
});
closeAllModals();
} catch (err: unknown) {
logFn.error(logMsg.other.error, {
meta: { error: err as Error },
});
toast.error({
message: (err as Error)?.message,
title: t('error.genericError', { postProcess: 'sentenceCase' }) as string,
});
} finally {
setIsSaving(false);
}
});
const isSubmitDisabled = !form.values.name || !form.values.streamUrl || isSaving;
const hadUploadedCover = !!stationImage.uploadedImage;
const fieldNodes: ReactNode[] = [
<TextInput
data-autofocus
key="name"
label={t('form.createRadioStation.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>,
<TextInput
key="streamUrl"
label={t('form.createRadioStation.input', {
context: 'streamUrl',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('streamUrl')}
/>,
<TextInput
key="homepageUrl"
label={t('form.createRadioStation.input', {
context: 'homepageUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('homepageUrl')}
/>,
<Group justify="flex-end" key="actions">
<ModalButton disabled={isSaving} onClick={onCancel}>
{t('common.cancel')}
</ModalButton>
<ModalButton
disabled={isSubmitDisabled}
loading={isSaving}
type="submit"
variant="filled"
>
{t('common.save')}
</ModalButton>
</Group>,
];
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={t('form.createRadioStation.input', {
context: 'name',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('name')}
/>
<TextInput
label={t('form.createRadioStation.input', {
context: 'streamUrl',
postProcess: 'titleCase',
})}
required
{...form.getInputProps('streamUrl')}
/>
<TextInput
label={t('form.createRadioStation.input', {
context: 'homepageUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('homepageUrl')}
/>
<Group justify="flex-end">
<ModalButton onClick={onCancel} variant="subtle">
{t('common.cancel', { postProcess: 'sentenceCase' })}
</ModalButton>
<ModalButton loading={mutation.isPending} type="submit" variant="filled">
{t('common.save', { postProcess: 'sentenceCase' })}
</ModalButton>
</Group>
</Stack>
{isCoverImageDisplayed && server?.id ? (
<Flex align="flex-start" gap="lg" wrap="wrap">
<RadioStationCoverField
hadUploadedCover={hadUploadedCover}
onClearPending={() => setPendingFile(null)}
onFileSelect={(file) => {
if (!file) return;
setRemoveCustomCover(false);
setPendingFile(file);
}}
onToggleRemoveCover={() => setRemoveCustomCover((v) => !v)}
pendingFile={pendingFile}
pendingPreviewUrl={pendingPreviewUrl}
removeCustomCover={removeCustomCover}
stationImage={stationImage}
/>
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
{fieldNodes}
</Stack>
</Flex>
) : (
<Stack gap="md">{fieldNodes}</Stack>
)}
</form>
);
};
const COVER_SIZE = 240;
function RadioStationCoverField({
hadUploadedCover,
onClearPending,
onFileSelect,
onToggleRemoveCover,
pendingFile,
pendingPreviewUrl,
removeCustomCover,
stationImage,
}: {
hadUploadedCover: boolean;
onClearPending: () => void;
onFileSelect: (file: File | null) => void;
onToggleRemoveCover: () => void;
pendingFile: File | null;
pendingPreviewUrl: null | string;
removeCustomCover: boolean;
stationImage: RadioStationImageProps;
}) {
const server = useCurrentServer();
const showServerCover = !pendingPreviewUrl && !removeCustomCover;
const previewId = showServerCover ? stationImage.imageId || undefined : undefined;
const previewSrc = pendingPreviewUrl || (showServerCover ? stationImage.imageUrl || '' : '');
const secondaryAction = () => {
if (pendingFile) {
onClearPending();
return;
}
if (hadUploadedCover) {
onToggleRemoveCover();
}
};
const secondaryDisabled = !pendingFile && !hadUploadedCover;
const secondaryIcon = pendingFile ? 'x' : removeCustomCover ? 'arrowLeft' : 'delete';
const iconControls = (
<>
<FileButton accept="image/*" onChange={onFileSelect}>
{(props) => (
<ActionIcon
icon="uploadImage"
iconProps={{ size: 'lg' }}
radius="xl"
size="sm"
variant="default"
{...props}
/>
)}
</FileButton>
<ActionIcon
disabled={secondaryDisabled}
icon={secondaryIcon}
iconProps={{ size: 'lg' }}
onClick={secondaryAction}
radius="xl"
size="sm"
variant="default"
/>
</>
);
const coverArt = (
<ItemImage
enableViewport={false}
id={previewId}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={previewSrc}
type="header"
/>
);
return (
<Box
style={{
borderRadius: 'var(--mantine-radius-md)',
flexShrink: 0,
height: COVER_SIZE,
overflow: 'hidden',
position: 'relative',
width: COVER_SIZE,
}}
>
{coverArt}
<Group
gap={4}
style={{
background: 'rgba(0, 0, 0, 0.55)',
borderRadius: 'var(--mantine-radius-md)',
bottom: 6,
padding: 4,
position: 'absolute',
right: 6,
}}
wrap="nowrap"
>
{iconControls}
</Group>
</Box>
);
}
export const openEditRadioStationModal = (
station: InternetRadioStation,
server: null | ServerListItem,
@@ -119,8 +319,11 @@ export const openEditRadioStationModal = (
return;
}
const hasImageUpload = hasFeature(server, ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD);
openModal({
children: <EditRadioStationForm onCancel={closeAllModals} station={station} />,
size: hasImageUpload ? 'lg' : 'md',
title: t('common.edit', { postProcess: 'titleCase' }) as string,
});
};
@@ -20,11 +20,55 @@
.radio-item-button {
all: unset;
box-sizing: border-box;
display: block;
flex: 1;
width: 100%;
min-width: 0;
text-align: left;
cursor: pointer;
}
.thumbnail {
flex-shrink: 0;
width: 3rem;
height: 3rem;
overflow: hidden;
border-radius: var(--mantine-radius-md);
}
.image-container {
width: 3rem;
height: 3rem;
}
.meta {
flex: 1;
min-width: 0;
}
.meta-line {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.radio-item-link {
color: inherit;
text-decoration: underline;
}
.radio-item-actions {
flex-shrink: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
}
.radio-item:hover .radio-item-actions,
.radio-item:focus-within .radio-item-actions {
pointer-events: auto;
opacity: 1;
}
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import styles from './radio-list-items.module.css';
import { ItemImage } from '/@/renderer/components/item-image/item-image';
import { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form';
import {
useRadioControls,
@@ -12,15 +13,15 @@ import {
import { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation';
import { useCurrentServer, usePermissions } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Box } from '/@/shared/components/box/box';
import { Flex } from '/@/shared/components/flex/flex';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
import { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal';
import { Paper } from '/@/shared/components/paper/paper';
import { Stack } from '/@/shared/components/stack/stack';
import { Text } from '/@/shared/components/text/text';
import { toast } from '/@/shared/components/toast/toast';
import { InternetRadioStation } from '/@/shared/types/domain-types';
import { InternetRadioStation, LibraryItem } from '/@/shared/types/domain-types';
interface RadioListItemProps {
station: InternetRadioStation;
@@ -44,8 +45,13 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
const handleClick = () => {
if (stationIsPlaying) {
stop();
} else {
play(station.streamUrl, station.name);
} else if (server?.id) {
play(station.streamUrl, station.name, {
id: station.id,
imageId: station.imageId,
imageUrl: station.imageUrl,
serverId: server.id,
});
}
};
@@ -107,27 +113,39 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
})}
p="md"
>
<Flex align="flex-start" gap="md" justify="space-between">
<button className={styles['radio-item-button']} onClick={handleClick} role="button">
<Stack gap="xs">
<Group gap="xs">
<Icon color="muted" icon="radio" size="md" />
<Flex align="center" gap="md" justify="space-between" wrap="nowrap">
<button className={styles['radio-item-button']} onClick={handleClick} type="button">
<Group align="center" gap="md" wrap="nowrap">
<Box className={styles.thumbnail}>
<ItemImage
enableViewport={false}
id={station.imageId ?? undefined}
imageContainerProps={{
className: styles['image-container'],
}}
itemType={LibraryItem.RADIO_STATION}
serverId={server?.id}
src={station.imageUrl ?? ''}
type="table"
/>
</Box>
<Stack className={styles.meta} gap={4}>
<Text fw={500} size="md">
{station.name}
</Text>
</Group>
<Text isMuted size="sm">
{station.streamUrl}
</Text>
{station.homepageUrl && (
<Text isMuted size="sm">
{station.homepageUrl}
<Text className={styles['meta-line']} isMuted size="sm">
{station.streamUrl}
</Text>
)}
</Stack>
{station.homepageUrl ? (
<Text className={styles['meta-line']} isMuted size="sm">
{station.homepageUrl}
</Text>
) : null}
</Stack>
</Group>
</button>
{(permissions.radio.edit || permissions.radio.delete) && (
<Group gap="xs">
<Group className={styles['radio-item-actions']} gap="xs">
{permissions.radio.edit && (
<ActionIcon
icon="edit"
@@ -7,6 +7,13 @@ import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/
import { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
export type RadioCurrentStationArt = {
id: string;
imageId?: null | string;
imageUrl?: null | string;
serverId: string;
};
export interface RadioMetadata {
artist: null | string;
title: null | string;
@@ -15,13 +22,18 @@ export interface RadioMetadata {
interface RadioStore {
actions: {
pause: () => void;
play: (streamUrl?: string, stationName?: string) => void;
play: (
streamUrl?: string,
stationName?: string,
stationArt?: null | RadioCurrentStationArt,
) => void;
setCurrentStreamUrl: (currentStreamUrl: null | string) => void;
setIsPlaying: (isPlaying: boolean) => void;
setMetadata: (metadata: null | RadioMetadata) => void;
setStationName: (stationName: null | string) => void;
stop: () => void;
};
currentStationArt: null | RadioCurrentStationArt;
currentStreamUrl: null | string;
isPlaying: boolean;
metadata: null | RadioMetadata;
@@ -34,7 +46,11 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
set({ isPlaying: false });
usePlayerStoreBase.getState().mediaPause();
},
play: (streamUrl?: string, stationName?: string) => {
play: (
streamUrl?: string,
stationName?: string,
stationArt?: null | RadioCurrentStationArt,
) => {
set((state) => {
const newStreamUrl = streamUrl ?? state.currentStreamUrl;
const newStationName = stationName ?? state.stationName;
@@ -43,12 +59,19 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
return state;
}
// Reset metadata when switching stations (streamUrl changes)
const isSwitchingStation = newStreamUrl !== state.currentStreamUrl;
const streamUrlExplicit = streamUrl !== undefined;
const isSwitchingStation =
streamUrlExplicit && streamUrl !== state.currentStreamUrl;
let nextStationArt = state.currentStationArt;
if (isSwitchingStation) {
nextStationArt = stationArt ?? null;
}
usePlayerStoreBase.getState().mediaPlay();
return {
currentStationArt: nextStationArt,
currentStreamUrl: newStreamUrl,
isPlaying: true,
metadata: isSwitchingStation ? null : state.metadata,
@@ -64,6 +87,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
const playbackType = useSettingsStore.getState().playback.type;
set({
currentStationArt: null,
currentStreamUrl: null,
isPlaying: false,
metadata: null,
@@ -79,6 +103,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
}
},
},
currentStationArt: null,
currentStreamUrl: null,
isPlaying: false,
metadata: null,
@@ -90,12 +115,14 @@ export const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying)
export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl));
export const useRadioPlayer = () => {
const currentStationArt = useRadioStore((state) => state.currentStationArt);
const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);
const isPlaying = useRadioStore((state) => state.isPlaying);
const metadata = useRadioStore((state) => state.metadata);
const stationName = useRadioStore((state) => state.stationName);
return {
currentStationArt,
currentStreamUrl,
isPlaying,
metadata,
@@ -163,6 +190,7 @@ export const useRadioAudioInstance = () => {
setIsPlaying(false);
setCurrentStreamUrl(null);
setStationName(null);
useRadioStore.setState({ currentStationArt: null, metadata: null });
};
mpvPlayerListener.rendererPlay(handleMpvPlay);
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import {
DeleteInternetRadioStationImageArgs,
DeleteInternetRadioStationImageResponse,
} from '/@/shared/types/domain-types';
export const useDeleteInternetRadioStationImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
DeleteInternetRadioStationImageResponse,
AxiosError,
DeleteInternetRadioStationImageArgs,
null
>({
mutationFn: (args) => {
return api.controller.deleteInternetRadioStationImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.radio.list(serverId),
});
},
...options,
});
};
@@ -0,0 +1,40 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { api } from '/@/renderer/api';
import { queryKeys } from '/@/renderer/api/query-keys';
import { MutationHookArgs } from '/@/renderer/lib/react-query';
import {
UploadInternetRadioStationImageArgs,
UploadInternetRadioStationImageResponse,
} from '/@/shared/types/domain-types';
export const useUploadInternetRadioStationImage = (args: MutationHookArgs) => {
const { options } = args || {};
const queryClient = useQueryClient();
return useMutation<
UploadInternetRadioStationImageResponse,
AxiosError,
UploadInternetRadioStationImageArgs,
null
>({
mutationFn: (args) => {
return api.controller.uploadInternetRadioStationImage({
...args,
apiClientProps: { serverId: args.apiClientProps.serverId },
});
},
onSuccess: (_data, variables) => {
const { apiClientProps } = variables;
const serverId = apiClientProps.serverId;
if (!serverId) return;
queryClient.invalidateQueries({
queryKey: queryKeys.radio.list(serverId),
});
},
...options,
});
};
@@ -112,6 +112,7 @@
}
.image-section {
position: relative;
z-index: 15;
display: flex;
grid-area: image;
@@ -124,6 +125,21 @@
}
}
.image-overlay {
position: absolute;
right: var(--theme-spacing-xs);
bottom: var(--theme-spacing-xs);
z-index: 2;
pointer-events: none;
opacity: 0;
transition: opacity 120ms ease;
}
.image-section:hover .image-overlay {
pointer-events: auto;
opacity: 1;
}
.metadata-section {
z-index: 15;
display: flex;
@@ -35,6 +35,7 @@ interface LibraryHeaderProps {
children?: ReactNode;
compact?: boolean;
containerClassName?: string;
imageOverlay?: ReactNode;
imagePlaceholderUrl?: null | string;
imageUrl?: null | string;
item: {
@@ -56,6 +57,7 @@ export const LibraryHeader = forwardRef(
children,
compact,
containerClassName,
imageOverlay,
imageUrl,
item,
title,
@@ -168,6 +170,16 @@ export const LibraryHeader = forwardRef(
src={imageUrl || ''}
type="header"
/>
{imageOverlay && (
<div
className={styles.imageOverlay}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
{imageOverlay}
</div>
)}
</div>
{title && (
<div className={styles.metadataSection}>
@@ -167,7 +167,7 @@ const SidebarImage = () => {
const { setSideBar } = useAppStoreActions();
const currentSong = usePlayerSong();
const isRadioActive = useIsRadioActive();
const { isPlaying: isRadioPlaying } = useRadioPlayer();
const { currentStationArt, isPlaying: isRadioPlaying } = useRadioPlayer();
const { blurExplicitImages } = useGeneralSettings();
const imageUrl = useItemImageUrl({
@@ -177,6 +177,14 @@ const SidebarImage = () => {
type: 'sidebar',
});
const radioImageUrl = useItemImageUrl({
id: isRadioActive ? currentStationArt?.imageId || undefined : undefined,
imageUrl: isRadioActive ? currentStationArt?.imageUrl || undefined : undefined,
itemType: LibraryItem.RADIO_STATION,
serverId: isRadioActive ? currentStationArt?.serverId : undefined,
type: 'sidebar',
});
const isPlayingRadio = isRadioActive && isRadioPlaying;
const isSongDefined = Boolean(currentSong?.id);
@@ -224,7 +232,9 @@ const SidebarImage = () => {
postProcess: 'sentenceCase',
})}
>
{isPlayingRadio ? (
{isRadioActive && radioImageUrl ? (
<img className={styles.sidebarImage} loading="eager" src={radioImageUrl} />
) : isRadioActive ? (
<Center
className={styles.sidebarImage}
style={{
+12 -3
View File
@@ -7,15 +7,23 @@ const GARBAGE_COLLECTION_INTERVAL = 1000 * 60 * 5;
export const useGarbageCollection = () => {
const intervalIdRef = useRef<NodeJS.Timeout | null>(null);
const startInterval = () => {
if (intervalIdRef.current) {
clearInterval(intervalIdRef.current);
}
intervalIdRef.current = setInterval(() => {
window.api?.utils?.forceGarbageCollection?.();
}, GARBAGE_COLLECTION_INTERVAL);
};
// Clear the cache on an interval
useEffect(() => {
if (!isElectron()) {
return;
}
intervalIdRef.current = setInterval(() => {
window.api?.utils?.forceGarbageCollection?.();
}, GARBAGE_COLLECTION_INTERVAL);
startInterval();
return () => {
if (intervalIdRef.current) {
@@ -38,5 +46,6 @@ export const useGarbageCollection = () => {
}
window.api?.utils?.forceGarbageCollection?.();
startInterval();
}, [location]);
};
+25 -16
View File
@@ -2,13 +2,13 @@ import { isAxiosError } from 'axios';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import { api } from '/@/renderer/api';
import { controller } from '/@/renderer/api/controller';
import { AppRoute } from '/@/renderer/router/routes';
import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { getServerById, useAuthStoreActions, useCurrentServerId } from '/@/renderer/store';
import { LogCategory, logFn } from '/@/renderer/utils/logger';
import { logMsg } from '/@/renderer/utils/logger-message';
import { toast } from '/@/shared/components/toast/toast';
@@ -40,13 +40,18 @@ const isNetworkError = (error: any): boolean => {
export const useServerAuthenticated = () => {
const priorServerId = useRef<string | undefined>(undefined);
const server = useCurrentServer();
const serverId = useCurrentServerId();
const [ready, setReady] = useState(AuthState.LOADING);
const navigate = useNavigate();
const navigateRef = useRef(navigate);
const retryCountRef = useRef<number>(0);
const { setCurrentServer, updateServer } = useAuthStoreActions();
useEffect(() => {
navigateRef.current = navigate;
}, [navigate]);
const authenticateServer = useCallback(
async (serverWithAuth: NonNullable<ReturnType<typeof getServerById>>, retryAttempt = 0) => {
const authStartTime = Date.now();
@@ -312,7 +317,7 @@ export const useServerAuthenticated = () => {
// Don't clear credentials on network failure - preserve them for when network returns
setReady(AuthState.INVALID);
navigate(AppRoute.NO_NETWORK, { replace: true });
navigateRef.current(AppRoute.NO_NETWORK, { replace: true });
return;
}
@@ -341,18 +346,19 @@ export const useServerAuthenticated = () => {
setReady(AuthState.INVALID);
}
},
[updateServer, setCurrentServer, navigate],
[updateServer, setCurrentServer],
);
const debouncedAuth = debounce(
(serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
authenticateServer(serverWithAuth).catch(console.error);
},
300,
const debouncedAuth = useMemo(
() =>
debounce((serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
authenticateServer(serverWithAuth).catch(console.error);
}, 300),
[authenticateServer],
);
useEffect(() => {
if (!server) {
if (!serverId) {
logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, {
category: LogCategory.SYSTEM,
meta: {
@@ -363,9 +369,9 @@ export const useServerAuthenticated = () => {
return;
}
if (priorServerId.current !== server.id) {
const serverWithAuth = getServerById(server.id);
priorServerId.current = server.id;
if (priorServerId.current !== serverId) {
const serverWithAuth = getServerById(serverId);
priorServerId.current = serverId;
retryCountRef.current = 0; // Reset retry count when server changes
if (!serverWithAuth) {
@@ -373,7 +379,7 @@ export const useServerAuthenticated = () => {
category: LogCategory.SYSTEM,
meta: {
reason: 'Server not found in store',
serverId: server.id,
serverId,
},
});
setReady(AuthState.INVALID);
@@ -383,7 +389,10 @@ export const useServerAuthenticated = () => {
setReady(AuthState.LOADING);
debouncedAuth(serverWithAuth);
}
}, [debouncedAuth, server]);
return () => {
debouncedAuth.cancel();
};
}, [debouncedAuth, serverId]);
return ready;
};
+20 -6
View File
@@ -4,8 +4,10 @@
'window-bar'
'main-content'
'player';
grid-template-rows: 0 calc(100vh - 90px) 90px;
grid-template-rows: 0 calc(100dvh - 90px) 90px;
grid-template-rows:
0 calc(100vh - 90px - var(--theme-spacing-md)) calc(90px + var(--theme-spacing-md));
grid-template-rows:
0 calc(100dvh - 90px - var(--theme-spacing-md)) calc(90px + var(--theme-spacing-md));
grid-template-columns: 1fr;
gap: 0;
height: 100%;
@@ -13,11 +15,23 @@
}
.windows {
grid-template-rows: 30px calc(100vh - 120px) 90px;
grid-template-rows: 30px calc(100dvh - 120px) 90px;
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100vh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100dvh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
}
.macos {
grid-template-rows: 30px calc(100vh - 120px) 90px;
grid-template-rows: 30px calc(100dvh - 120px) 90px;
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100vh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100dvh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
}
+2 -2
View File
@@ -7,7 +7,7 @@ import styles from './default-layout.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { MainContent } from '/@/renderer/layouts/default-layout/main-content';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { useSettingsStore, useWindowSettings } from '/@/renderer/store/settings.store';
import { useSettingsStore, useWindowBarStyle } from '/@/renderer/store/settings.store';
import { Platform, PlayerType } from '/@/shared/types/types';
if (!isElectron()) {
@@ -29,7 +29,7 @@ interface DefaultLayoutProps {
}
export const DefaultLayout = ({ shell }: DefaultLayoutProps) => {
const { windowBarStyle } = useWindowSettings();
const windowBarStyle = useWindowBarStyle();
return (
<>
@@ -1,6 +1,7 @@
.container {
position: relative;
grid-area: sidebar;
overflow: hidden;
background: var(--theme-colors-background-alternate);
border-right: 1px solid alpha(var(--theme-colors-border), 0.5);
border-radius: var(--theme-radius-lg);
}
@@ -1,10 +1,12 @@
.main-content-container {
position: relative;
box-sizing: border-box;
display: grid;
grid-area: main-content;
grid-template-areas: 'sidebar . right-sidebar';
grid-template-areas: 'sidebar main';
grid-template-rows: 1fr;
gap: 0;
gap: var(--theme-spacing-sm);
padding: var(--theme-spacing-md);
background: var(--theme-colors-background);
}
@@ -21,6 +23,7 @@
}
.main-content-container.right-expanded {
grid-template-areas: 'sidebar main right-sidebar';
grid-template-columns: var(--sidebar-width) 1fr var(--right-sidebar-width);
}
@@ -30,7 +33,7 @@
.main-content-container.vertical-layout {
grid-template-areas:
'sidebar .'
'sidebar main'
'sidebar right-sidebar';
grid-template-rows: minmax(0, 1fr) var(--right-sidebar-height);
grid-template-columns: var(--sidebar-width) 1fr;
@@ -40,17 +43,15 @@
grid-template-columns: 80px 1fr;
}
.main-content-container.vertical-layout #sidebar-queue {
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
border-left: 0;
}
.main-content-body {
display: flex;
flex: 1;
flex-direction: column;
grid-area: main;
min-height: 0;
overflow: hidden;
background: var(--theme-colors-background);
border-radius: var(--theme-radius-lg);
}
.main-content-body-scroll {
@@ -175,13 +175,18 @@ export const MainContent = ({ shell }: { shell?: boolean }) => {
);
useEffect(() => {
if (!isResizing && !isResizingRight) {
return;
}
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resize, stopResizing]);
}, [isResizing, isResizingRight, resize, stopResizing]);
return (
<motion.div
@@ -1,6 +1,21 @@
.container {
.wrapper {
z-index: 200;
box-sizing: border-box;
display: flex;
grid-area: player;
height: 100%;
padding: 0 var(--theme-spacing-md) var(--theme-spacing-md);
background: var(--theme-colors-background);
}
.bar {
display: flex;
flex: 1;
width: 100%;
min-height: 0;
overflow: hidden;
border-radius: var(--theme-radius-lg);
transition: background 0.5s;
@mixin light {
background: darken(var(--theme-colors-background), 5%);
@@ -9,12 +24,8 @@
@mixin dark {
background: darken(var(--theme-colors-background), 10%);
}
transition: background 0.5s;
}
.open-drawer {
&:hover {
background: darken(var(--theme-colors-background), 20%);
}
.open-drawer .bar:hover {
background: darken(var(--theme-colors-background), 20%);
}
@@ -10,13 +10,14 @@ export const PlayerBar = () => {
return (
<div
className={clsx({
[styles.container]: true,
className={clsx(styles.wrapper, {
[styles.openDrawer]: playerbarOpenDrawer,
})}
id="player-bar"
>
<Playerbar />
<div className={styles.bar}>
<Playerbar />
</div>
</div>
);
};
@@ -7,18 +7,13 @@
min-height: 0;
overflow: hidden;
background: var(--theme-colors-background-alternate);
border-left: 1px solid alpha(var(--theme-colors-border), 0.5);
border-radius: var(--theme-radius-lg);
.current-song-cell:not(.current-playlist-song-cell) svg {
display: none;
}
}
.right-sidebar-container.vertical-layout {
border-top: 1px solid alpha(var(--theme-colors-border), 0.5);
border-left: 0;
}
.queue-drawer {
border-radius: var(--theme-radius-lg);
}
@@ -1,4 +1,3 @@
import clsx from 'clsx';
import { forwardRef, Ref } from 'react';
import styles from './right-sidebar.module.css';
@@ -64,9 +63,7 @@ export const RightSidebar = forwardRef(
<>
{rightExpanded && sideQueueType === 'sideQueue' && (
<aside
className={clsx(styles.rightSidebarContainer, {
[styles.verticalLayout]: isVerticalLayout,
})}
className={styles.rightSidebarContainer}
id="sidebar-queue"
key="queue-sidebar"
>
@@ -5,8 +5,10 @@
'window-bar'
'main-content'
'player';
grid-template-rows: 0 calc(100vh - 90px) 90px;
grid-template-rows: 0 calc(100dvh - 90px) 90px;
grid-template-rows:
0 calc(100vh - 90px - var(--theme-spacing-md)) calc(90px + var(--theme-spacing-md));
grid-template-rows:
0 calc(100dvh - 90px - var(--theme-spacing-md)) calc(90px + var(--theme-spacing-md));
grid-template-columns: 1fr;
gap: 0;
width: 100vw;
@@ -18,14 +20,20 @@
.windows,
.macos {
grid-template-rows: 30px calc(100vh - 120px) 90px;
grid-template-rows: 30px calc(100dvh - 120px) 90px;
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100vh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
grid-template-rows:
calc(30px + var(--theme-spacing-md))
calc(100dvh - 120px - 2 * var(--theme-spacing-md))
calc(90px + var(--theme-spacing-md));
}
.drawer-button {
position: absolute;
bottom: calc(90px + 0.75rem);
left: 0.75rem;
bottom: calc(90px + var(--theme-spacing-md) + 0.75rem);
left: var(--theme-spacing-md);
z-index: 100;
background: color-mix(in srgb, var(--theme-colors-background) 90%, transparent);
border: 1px solid var(--theme-colors-border);
@@ -10,8 +10,7 @@ import { FullScreenVisualizer } from '/@/renderer/features/player/components/ful
import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { useFullScreenPlayerStore } from '/@/renderer/store';
import { useWindowSettings } from '/@/renderer/store';
import { useFullScreenPlayerOverlayState, useWindowBarStyle } from '/@/renderer/store';
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
import { Drawer } from '/@/shared/components/drawer/drawer';
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
@@ -32,8 +31,8 @@ export const MobileLayout = ({ shell }: MobileLayoutProps) => {
const {
expanded: isFullScreenPlayerExpanded,
visualizerExpanded: isFullScreenVisualizerExpanded,
} = useFullScreenPlayerStore();
const { windowBarStyle } = useWindowSettings();
} = useFullScreenPlayerOverlayState();
const windowBarStyle = useWindowBarStyle();
return (
<>
+61 -28
View File
@@ -1,4 +1,5 @@
import isElectron from 'is-electron';
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useAppTracker } from '/@/renderer/features/analytics/hooks/use-app-tracker';
@@ -9,8 +10,8 @@ import { DefaultLayout } from '/@/renderer/layouts/default-layout';
import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout';
import { AppRoute } from '/@/renderer/router/routes';
import {
useCommandPalette,
useHotkeySettings,
useCommandPaletteState,
useLayoutHotkeyBindings,
useSettingsStoreActions,
useZoomFactor,
} from '/@/renderer/store';
@@ -42,40 +43,72 @@ export const ResponsiveLayout = ({ shell }: ResponsiveLayoutProps) => {
);
};
const localSettings = isElectron() ? window.api.localSettings : null;
const LayoutHotkeys = () => {
const navigate = useNavigate();
const localSettings = isElectron() ? window.api.localSettings : null;
const zoomFactor = useZoomFactor();
const { setSettings } = useSettingsStoreActions();
const { bindings } = useHotkeySettings();
const { opened, ...handlers } = useCommandPalette();
const bindings = useLayoutHotkeyBindings();
const { close, open, opened, toggle } = useCommandPaletteState();
const updateZoom = (increase: number) => {
const newVal = zoomFactor + increase;
if (newVal > 300 || newVal < 50 || !isElectron()) return;
setSettings({
general: {
zoomFactor: newVal,
},
});
localSettings?.setZoomFactor(zoomFactor);
};
localSettings?.setZoomFactor(zoomFactor);
const handlers = useMemo(
() => ({
close,
open,
toggle,
}),
[close, open, toggle],
);
const zoomHotkeys: HotkeyItem[] = [
[bindings.zoomIn.hotkey, () => updateZoom(5)],
[bindings.zoomOut.hotkey, () => updateZoom(-5)],
];
const updateZoom = useCallback(
(increase: number) => {
const newVal = zoomFactor + increase;
if (newVal > 300 || newVal < 50 || !localSettings) return;
useHotkeys([
[bindings.globalSearch.hotkey, () => handlers.open()],
[bindings.browserBack.hotkey, () => navigate(-1)],
[bindings.browserForward.hotkey, () => navigate(1)],
[bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)],
...(isElectron() ? zoomHotkeys : []),
]);
setSettings({
general: {
zoomFactor: newVal,
},
});
localSettings?.setZoomFactor(newVal);
},
[setSettings, zoomFactor],
);
return <CommandPalette modalProps={{ handlers, opened }} />;
useEffect(() => {
if (localSettings) {
localSettings?.setZoomFactor(zoomFactor);
}
}, [zoomFactor]);
const hotkeys = useMemo<HotkeyItem[]>(
() => [
[bindings.globalSearch.hotkey, open],
[bindings.browserBack.hotkey, () => navigate(-1)],
[bindings.browserForward.hotkey, () => navigate(1)],
[bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)],
...(localSettings
? ([
[bindings.zoomIn.hotkey, () => updateZoom(5)],
[bindings.zoomOut.hotkey, () => updateZoom(-5)],
] as HotkeyItem[])
: []),
],
[bindings, navigate, open, updateZoom],
);
const modalProps = useMemo(
() => ({
handlers,
opened,
}),
[handlers, opened],
);
useHotkeys(hotkeys);
return <CommandPalette modalProps={modalProps} />;
};
const GarbageCollection = () => {
+16 -6
View File
@@ -1,6 +1,20 @@
.window-bar {
.wrapper {
box-sizing: border-box;
display: flex;
grid-area: window-bar;
height: 30px;
height: 100%;
padding: var(--theme-spacing-md) var(--theme-spacing-md) 0;
background: var(--theme-colors-background);
}
.bar {
display: flex;
flex: 1;
width: 100%;
min-height: 0;
overflow: hidden;
background: var(--theme-colors-background);
border-radius: var(--theme-radius-lg);
}
.windows-container {
@@ -9,8 +23,6 @@
align-items: center;
width: 100%;
height: 100%;
background: var(--theme-colors-background);
border-bottom: 1px solid alpha(var(--theme-colors-border), 0.5);
-webkit-app-region: drag;
}
@@ -95,8 +107,6 @@
justify-content: center;
width: 100%;
height: 100%;
background: var(--theme-colors-background);
border-bottom: 1px solid alpha(var(--theme-colors-border), 0.5);
-webkit-app-region: drag;
}
+15 -13
View File
@@ -205,19 +205,21 @@ export const WindowBar = () => {
}
return (
<div className={styles.windowBar}>
{windowBarStyle === Platform.WINDOWS && (
<WindowsControls
controls={{ handleClose, handleMaximize, handleMinimize }}
title={title}
/>
)}
{windowBarStyle === Platform.MACOS && (
<MacOsControls
controls={{ handleClose, handleMaximize, handleMinimize }}
title={title}
/>
)}
<div className={styles.wrapper}>
<div className={styles.bar}>
{windowBarStyle === Platform.WINDOWS && (
<WindowsControls
controls={{ handleClose, handleMaximize, handleMinimize }}
title={title}
/>
)}
{windowBarStyle === Platform.MACOS && (
<MacOsControls
controls={{ handleClose, handleMaximize, handleMinimize }}
title={title}
/>
)}
</div>
</div>
);
};
+28 -20
View File
@@ -1,37 +1,45 @@
import { useMemo } from 'react';
import { useEffect, useMemo } from 'react';
import { Navigate, Outlet } from 'react-router';
import { shallow } from 'zustand/shallow';
import { isServerLock } from '/@/renderer/features/action-required/utils/window-properties';
import { AppRoute } from '/@/renderer/router/routes';
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
import { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
export const AppOutlet = () => {
const currentServer = useCurrentServer();
const currentServer = useAuthStore(
(state) =>
state.currentServer
? {
id: state.currentServer.id,
url: state.currentServer.url,
}
: null,
shallow,
);
const { deleteServer, setCurrentServer } = useAuthStoreActions();
const isActionsRequired = useMemo(() => {
// When SERVER_LOCK is enabled and the configured URL has changed,
// clear the stale session so the user re-authenticates against the new server.
if (isServerLock() && currentServer && window.SERVER_URL) {
const configuredUrl = normalizeUrl(window.SERVER_URL);
const persistedUrl = normalizeUrl(currentServer.url);
if (configuredUrl !== persistedUrl) {
deleteServer(currentServer.id);
setCurrentServer(null);
return true;
}
const hasServerLockMismatch = useMemo(() => {
if (!isServerLock() || !currentServer || !window.SERVER_URL) {
return false;
}
const isServerRequired = !currentServer;
const configuredUrl = normalizeUrl(window.SERVER_URL);
const persistedUrl = normalizeUrl(currentServer.url);
const actions = [isServerRequired];
const isActionRequired = actions.some((c) => c);
return configuredUrl !== persistedUrl;
}, [currentServer]);
return isActionRequired;
}, [currentServer, deleteServer, setCurrentServer]);
useEffect(() => {
if (hasServerLockMismatch && currentServer) {
deleteServer(currentServer.id);
setCurrentServer(null);
}
}, [currentServer, deleteServer, hasServerLockMismatch, setCurrentServer]);
const isActionsRequired = !currentServer || hasServerLockMismatch;
if (isActionsRequired) {
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;
+14 -14
View File
@@ -186,22 +186,22 @@ const VisualizerSettingsContextModal = (props: any) => (
</Suspense>
);
const appRouterModals = {
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
lyricsSettings: LyricsSettingsContextModal,
saveAndReplace: SaveAndReplaceContextModal,
settings: SettingsContextModal,
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
updatePlaylist: UpdatePlaylistContextModal,
visualizerSettings: VisualizerSettingsContextModal,
};
export const AppRouter = () => {
const router = (
<HashRouter>
<ModalsProvider
modals={{
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
lyricsSettings: LyricsSettingsContextModal,
saveAndReplace: SaveAndReplaceContextModal,
settings: SettingsContextModal,
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
updatePlaylist: UpdatePlaylistContextModal,
visualizerSettings: VisualizerSettingsContextModal,
}}
>
<ModalsProvider modals={appRouterModals}>
<RouterErrorBoundary>
<Routes>
<Route element={<AuthenticationOutlet />}>
@@ -341,5 +341,5 @@ export const AppRouter = () => {
</HashRouter>
);
return <Suspense fallback={<></>}>{router}</Suspense>;
return <Suspense fallback={<Spinner container />}>{router}</Suspense>;
};
+2 -2
View File
@@ -3,11 +3,11 @@ import { Outlet } from 'react-router';
import styles from './titlebar-outlet.module.css';
import { Titlebar } from '/@/renderer/features/titlebar/components/titlebar';
import { useWindowSettings } from '/@/renderer/store/settings.store';
import { useWindowBarStyle } from '/@/renderer/store/settings.store';
import { Platform } from '/@/shared/types/types';
export const TitlebarOutlet = () => {
const { windowBarStyle } = useWindowSettings();
const windowBarStyle = useWindowBarStyle();
return (
<>
+12
View File
@@ -4,6 +4,7 @@ import type { LibraryItem } from '/@/shared/types/domain-types';
import merge from 'lodash/merge';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { AlbumListSort, SongListSort, SortOrder } from '/@/shared/types/domain-types';
@@ -280,6 +281,17 @@ export const useTitlebarStore = () => useAppStore((state) => state.titlebar);
export const useCommandPalette = () => useAppStore((state) => state.commandPalette);
export const useCommandPaletteState = () =>
useAppStore(
(state) => ({
close: state.commandPalette.close,
open: state.commandPalette.open,
opened: state.commandPalette.opened,
toggle: state.commandPalette.toggle,
}),
shallow,
);
export const usePageSidebar = (key: string): [boolean, (value: boolean) => void] => {
const isOpen = useAppStore((state) => state.pageSidebar[key] ?? false);
const setPageSidebar = useAppStore((state) => state.actions.setPageSidebar);
@@ -1,6 +1,7 @@
import merge from 'lodash/merge';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
export interface FullScreenPlayerSlice extends FullScreenPlayerState {
@@ -62,3 +63,12 @@ export const useFullScreenPlayerStoreActions = () =>
export const useSetFullScreenPlayerStore = () =>
useFullScreenPlayerStore((state) => state.actions.setStore);
export const useFullScreenPlayerOverlayState = () =>
useFullScreenPlayerStore(
(state) => ({
expanded: state.expanded,
visualizerExpanded: state.visualizerExpanded,
}),
shallow,
);
+37 -19
View File
@@ -1,5 +1,6 @@
import merge from 'lodash/merge';
import { nanoid } from 'nanoid';
import { useMemo } from 'react';
import { persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useShallow } from 'zustand/react/shallow';
@@ -58,7 +59,11 @@ interface Actions {
mediaSeekToTimestamp: (timestamp: number) => void;
mediaSkipBackward: (offset?: number) => void;
mediaSkipForward: (offset?: number) => void;
mediaStop: () => void;
/**
* @param options.reset - When true (default), sets seekToTimestamp(0) so the engine seeks to start.
* Timestamp display is always cleared to 0. Use false when the engine is already idle (e.g. mpv `stopped`) to skip that seek.
*/
mediaStop: (options?: { reset?: boolean }) => void;
mediaToggleMute: () => void;
mediaTogglePlayPause: () => void;
moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => void;
@@ -1163,11 +1168,14 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
});
},
mediaStop: () => {
mediaStop: (options?: { reset?: boolean }) => {
const reset = options?.reset !== false;
set((state) => {
state.player.status = PlayerStatus.PAUSED;
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
setTimestampStore(0);
if (reset) {
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
}
});
},
mediaToggleMute: () => {
@@ -1636,10 +1644,13 @@ export const usePlayerActions = () => {
})),
);
return {
...actions,
setTimestamp: setTimestampStore,
};
return useMemo(
() => ({
...actions,
setTimestamp: setTimestampStore,
}),
[actions],
);
};
export type AddToQueueByPlayType = Play;
@@ -1713,6 +1724,8 @@ export const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefine
queueIndex = mapShuffledToQueueIndex(queueIndex, state.queue.shuffled);
}
const currentSong = queue.items[queueIndex];
// Calculate next song based on shuffle and repeat settings
let nextSong: QueueSong | undefined;
if (isShuffleEnabled(state)) {
@@ -1730,20 +1743,25 @@ export const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefine
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
}
return { index: queueIndex, song: nextSong };
return {
currentUniqueId: currentSong?._uniqueId,
nextSong,
};
},
(current, prev) => {
// Only trigger if:
// 1. We have a previous value (not the first call)
// 2. Index hasn't changed (not a natural advance)
// 3. Next song has changed (song was inserted)
if (
prev &&
current.index === prev.index &&
current.song?._uniqueId !== prev.song?._uniqueId
) {
// Index stayed the same but next song changed = insertion at next position
onChange(current.song);
if (!prev) {
return;
}
// Still on the same track, but the upcoming song changed (queue edit: insert, reorder, etc.).
// Do not require the current track's queue index to stay fixed — e.g. inserting *before* the
// current item shifts its index in `queue.default`, and the old check missed that case.
const sameTrackStillPlaying =
current.currentUniqueId !== undefined &&
current.currentUniqueId === prev.currentUniqueId;
if (sameTrackStillPlaying && current.nextSong?._uniqueId !== prev.nextSong?._uniqueId) {
onChange(current.nextSong);
}
},
{
+18
View File
@@ -2421,8 +2421,26 @@ export const usePlayButtonBehavior = () =>
export const useWindowSettings = () => useSettingsStore((state) => state.window, shallow);
export const useWindowBarStyle = () =>
useSettingsStore((state) => state.window.windowBarStyle, shallow);
export const useHotkeySettings = () => useSettingsStore((state) => state.hotkeys, shallow);
export const useHotkeyBindings = () => useSettingsStore((state) => state.hotkeys.bindings, shallow);
export const useLayoutHotkeyBindings = () =>
useSettingsStore(
(state) => ({
browserBack: state.hotkeys.bindings.browserBack,
browserForward: state.hotkeys.bindings.browserForward,
globalSearch: state.hotkeys.bindings.globalSearch,
navigateHome: state.hotkeys.bindings.navigateHome,
zoomIn: state.hotkeys.bindings.zoomIn,
zoomOut: state.hotkeys.bindings.zoomOut,
}),
shallow,
);
export const useMpvSettings = () =>
useSettingsStore((state) => state.playback.mpvProperties, shallow);
+48
View File
@@ -76,6 +76,16 @@ const getDayjsLocale = (i18nLang: string): string => {
return localeMap[i18nLang] || 'en';
};
// BCP 47 tags for Intl (differs from dayjs locale ids for some languages).
const getIntlLocale = (i18nLang: string): string => {
const localeMap: Record<string, string> = {
'zh-Hans': 'zh-CN',
'zh-Hant': 'zh-TW',
};
return localeMap[i18nLang] ?? i18nLang;
};
const updateDayjsLocale = () => {
const dayjsLocale = getDayjsLocale(i18n.language);
dayjs.locale(dayjsLocale);
@@ -92,6 +102,44 @@ export const formatDateAbsolute = (key: null | string) => (key ? dayjs(key).form
export const formatDateAbsoluteUTC = (key: null | string) =>
key ? dayjs.utc(key).format('ll') : '';
const PARTIAL_ISO_YEAR = /^\d{4}$/;
const PARTIAL_ISO_YEAR_MONTH = /^\d{4}-\d{2}$/;
export const formatPartialIsoDateUTC = (key: null | string): string => {
if (!key) {
return '';
}
const trimmedKey = key.trim();
const intlLocale = getIntlLocale(i18n.language);
if (PARTIAL_ISO_YEAR.test(trimmedKey)) {
const year = Number.parseInt(trimmedKey, 10);
if (!Number.isFinite(year)) {
return trimmedKey;
}
return new Intl.DateTimeFormat(intlLocale, { timeZone: 'UTC', year: 'numeric' }).format(
new Date(Date.UTC(year, 0, 1)),
);
}
if (PARTIAL_ISO_YEAR_MONTH.test(trimmedKey)) {
const d = dayjs.utc(`${trimmedKey}-01`);
if (!d.isValid()) {
return trimmedKey;
}
return new Intl.DateTimeFormat(intlLocale, {
month: 'long',
timeZone: 'UTC',
year: 'numeric',
}).format(d.toDate());
}
return dayjs.utc(trimmedKey).format('ll');
};
export const formatHrDateTime = (key: null | string) => (key ? dayjs(key).format('lll') : '');
export const formatDateRelative = (key: null | string) => (key ? dayjs(key).fromNow() : '');
+25 -6
View File
@@ -1,6 +1,7 @@
import { z } from 'zod';
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
import { coerceYear, parsePartialIsoDateFromApi } from '/@/shared/api/partial-iso-date';
import { replacePathPrefix } from '/@/shared/api/utils';
import {
Album,
@@ -138,6 +139,20 @@ const getArtists = (
return result;
};
const jellyfinPremiereFields = (item: {
PremiereDate?: string;
ProductionYear?: number;
}): { originalYear: number; releaseDate: null | string; releaseYear: null | number } => {
const premiere = parsePartialIsoDateFromApi(item.PremiereDate ?? null);
const prodYear = coerceYear(item.ProductionYear);
const releaseYear: null | number =
premiere.year > 0 ? premiere.year : prodYear > 0 ? prodYear : null;
const releaseDate = premiere.date ?? (prodYear > 0 ? String(prodYear) : null);
const originalYear = premiere.year > 0 ? premiere.year : prodYear;
return { originalYear, releaseDate, releaseYear };
};
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: null | ServerListItem,
@@ -181,6 +196,8 @@ const normalizeSong = (
const artists = getArtists(item, participants);
const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
return {
_itemType: LibraryItem.SONG,
_serverId: server?.id || '',
@@ -244,8 +261,8 @@ const normalizeSong = (
peak: null,
playCount: (item.UserData && item.UserData.PlayCount) || 0,
playlistItemId: item.PlaylistItemId,
releaseDate: item.PremiereDate || null,
releaseYear: item.ProductionYear || null,
releaseDate,
releaseYear,
sampleRate,
size,
sortName: item.SortName || item.Name,
@@ -262,6 +279,8 @@ const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem,
): Album => {
const { originalYear, releaseDate, releaseYear } = jellyfinPremiereFields(item);
return {
_itemType: LibraryItem.ALBUM,
_serverId: server?.id || '',
@@ -310,15 +329,15 @@ const normalizeAlbum = (
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null,
name: item.Name,
originalDate: item.PremiereDate || null,
originalYear: item.ProductionYear || null,
originalDate: releaseDate,
originalYear,
participants: getPeople(item),
playCount: item.UserData?.PlayCount || 0,
recordLabels: item.Studios?.map((entry) => entry.Name) || [],
releaseDate: item.PremiereDate || null,
releaseDate,
releaseType: null,
releaseTypes: [],
releaseYear: item.ProductionYear || null,
releaseYear,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server)),
+67 -76
View File
@@ -1,6 +1,7 @@
import z from 'zod';
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import { replacePathPrefix } from '/@/shared/api/utils';
import {
@@ -8,6 +9,7 @@ import {
AlbumArtist,
ExplicitStatus,
Genre,
InternetRadioStation,
LibraryItem,
Playlist,
RelatedArtist,
@@ -33,95 +35,57 @@ const normalizePlayDate = (item: WithDate): null | string => {
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
};
const matchesFullDate = (date: string) => {
return Boolean(date.match(/^\d{4}-\d{2}-\d{2}$/));
};
const matchesYearOnly = (date: string) => {
return Boolean(date.match(/^\d{4}$/));
};
const normalizeReleaseDate = (item: {
const normalizeNavidromeReleaseDate = (item: {
date?: string;
minYear?: number;
releaseDate?: string;
}): { date: null | string; year: null | number } => {
if (item.releaseDate && matchesFullDate(item.releaseDate)) {
return {
date: item.releaseDate,
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
}): { date: null | string; year: number } => {
const fromRelease = parsePartialIsoDate(item.releaseDate);
if (fromRelease.date) {
return fromRelease;
}
if (item.date && matchesFullDate(item.date)) {
return {
date: item.date,
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
const fromDateField = parsePartialIsoDate(item.date);
if (fromDateField.date) {
return fromDateField;
}
return {
date: null,
year: item.minYear ?? null,
};
const y = coerceYear(item.minYear);
if (y > 0) {
return { date: String(y), year: y };
}
return { date: null, year: 0 };
};
const normalizeOriginalDate = (item: {
const normalizeNavidromeOriginalDate = (item: {
date?: string;
minOriginalYear?: number;
minYear?: number;
originalDate?: string;
releaseDate?: string;
}): { date: null | string; year: null | number } => {
if (item.originalDate && matchesFullDate(item.originalDate)) {
return {
date: item.originalDate,
year: parseInt(item.originalDate.split('-')[0]),
};
} else if (item.originalDate && matchesYearOnly(item.originalDate)) {
return {
date: null,
year: parseInt(item.originalDate),
};
}): { date: null | string; year: number } => {
const fromOriginal = parsePartialIsoDate(item.originalDate);
if (fromOriginal.date) {
return fromOriginal;
}
if (item.releaseDate && matchesFullDate(item.releaseDate)) {
return {
date: item.releaseDate,
year: parseInt(item.releaseDate.split('-')[0]),
};
} else if (item.releaseDate && matchesYearOnly(item.releaseDate)) {
return {
date: null,
year: parseInt(item.releaseDate),
};
const fromRelease = parsePartialIsoDate(item.releaseDate);
if (fromRelease.date) {
return fromRelease;
}
if (item.date && matchesFullDate(item.date)) {
return {
date: item.date,
year: parseInt(item.date.split('-')[0]),
};
} else if (item.date && matchesYearOnly(item.date)) {
return {
date: null,
year: parseInt(item.date),
};
const fromDateField = parsePartialIsoDate(item.date);
if (fromDateField.date) {
return fromDateField;
}
return {
date: null,
year: item.minYear ?? null,
};
const y = coerceYear(item.minOriginalYear ?? item.minYear);
if (y > 0) {
return { date: String(y), year: y };
}
return { date: null, year: 0 };
};
const getArtists = (
@@ -243,6 +207,12 @@ const normalizeSong = (
id = item.id;
}
const fromSongRelease = parsePartialIsoDate(item.releaseDate);
const songApiYear = coerceYear(item.year);
const releaseYear: null | number =
fromSongRelease.year > 0 ? fromSongRelease.year : songApiYear > 0 ? songApiYear : null;
const releaseDate = fromSongRelease.date ?? (songApiYear > 0 ? String(songApiYear) : null);
return {
album: item.album,
albumId: item.albumId,
@@ -301,8 +271,8 @@ const normalizeSong = (
: null,
playCount: item.playCount || 0,
playlistItemId,
releaseDate: normalizeReleaseDate(item).date,
releaseYear: item.year || null,
releaseDate,
releaseYear,
sampleRate: item.sampleRate || null,
size: item.size,
sortName: item.orderTitle,
@@ -364,8 +334,8 @@ const normalizeAlbum = (
pathReplace?: string,
pathReplaceWith?: string,
): Album => {
const releaseDate = normalizeReleaseDate(item);
const originalDate = normalizeOriginalDate(item);
const releaseDate = normalizeNavidromeReleaseDate(item);
const originalDate = normalizeNavidromeOriginalDate(item);
return {
...parseAlbumTags(item),
@@ -407,7 +377,7 @@ const normalizeAlbum = (
playCount: item.playCount || 0,
releaseDate: releaseDate.date,
releaseType: item.mbzAlbumType || null,
releaseYear: releaseDate.year,
releaseYear: releaseDate.year > 0 ? releaseDate.year : null,
size: item.size,
songCount: item.songCount,
songs: item.songs
@@ -490,6 +460,8 @@ const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server?: null | ServerListItem,
): Playlist => {
const imageId = !item.uploadedImage ? item.id : `${item.id}&_=${item.updatedAt}`;
return {
_itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || 'unknown',
@@ -498,7 +470,7 @@ const normalizePlaylist = (
duration: item.duration * 1000,
genres: [],
id: item.id,
imageId: item.id,
imageId,
imageUrl: null,
name: item.name,
owner: item.ownerName,
@@ -508,6 +480,7 @@ const normalizePlaylist = (
size: item.size,
songCount: item.songCount,
sync: item.sync,
uploadedImage: item.uploadedImage,
};
};
@@ -540,10 +513,28 @@ const normalizeUser = (item: z.infer<typeof ndType._response.user>): User => {
};
};
const normalizeInternetRadioStation = (
item: z.infer<typeof ndType._response.radioStation>,
): InternetRadioStation => {
const homepageUrl = item.homePageUrl?.trim() ? item.homePageUrl : null;
const imageId = item.uploadedImage ? `${item.id}&_=${item.updatedAt}` : item.id;
return {
homepageUrl,
id: item.id,
imageId,
imageUrl: null,
name: item.name,
streamUrl: item.streamUrl,
uploadedImage: item.uploadedImage || null,
};
};
export const ndNormalize = {
album: normalizeAlbum,
albumArtist: normalizeAlbumArtist,
genre: normalizeGenre,
internetRadioStation: normalizeInternetRadioStation,
playlist: normalizePlaylist,
song: normalizeSong,
user: normalizeUser,
+82 -2
View File
@@ -72,12 +72,24 @@ export const NDSongQueryFields = [
{ label: 'Album Artist', type: 'string', value: 'albumartist' },
{ label: 'Album Artists', type: 'string', value: 'albumartists' },
{ label: 'Album Comment', type: 'string', value: 'albumcomment' },
{ label: 'Album Date Favorited', type: 'date', value: 'albumdateloved' },
{ label: 'Album Date Last Played', type: 'date', value: 'albumlastplayed' },
{ label: 'Album Date Rated', type: 'date', value: 'albumdaterated' },
{ label: 'Album Is Favorite', type: 'boolean', value: 'albumloved' },
{ label: 'Album Play Count', type: 'number', value: 'albumplaycount' },
{ label: 'Album Rating', type: 'number', value: 'albumrating' },
{ label: 'Album Type', type: 'string', value: 'albumtype' },
{ label: 'Album Version', type: 'string', value: 'albumversion' },
{ label: 'Arranger', type: 'string', value: 'arranger' },
{ label: 'Artist', type: 'string', value: 'artist' },
{ label: 'Artist Date Favorited', type: 'date', value: 'artistdateloved' },
{ label: 'Artist Date Last Played', type: 'date', value: 'artistlastplayed' },
{ label: 'Artist Date Rated', type: 'date', value: 'artistdaterated' },
{ label: 'Artist Is Favorite', type: 'boolean', value: 'artistloved' },
{ label: 'Artist Play Count', type: 'number', value: 'artistplaycount' },
{ label: 'Artists', type: 'string', value: 'artists' },
{ label: 'ASIN', type: 'string', value: 'asin' },
{ label: 'Average Rating', type: 'number', value: 'averagerating' },
{ label: 'Barcode', type: 'string', value: 'barcode' },
{ label: 'Bit Depth', type: 'number', value: 'bitdepth' },
{ label: 'Bitrate', type: 'number', value: 'bitrate' },
@@ -588,6 +600,14 @@ const songListParameters = paginationParameters.extend({
year: z.number().optional(),
});
const playlistRules = z
.object({
limit: z.number().optional(),
limitPercent: z.number().optional(),
sort: z.string().optional(),
})
.catchall(z.any());
const playlist = z.object({
comment: z.string(),
createdAt: z.string(),
@@ -599,11 +619,12 @@ const playlist = z.object({
ownerName: z.string(),
path: z.string(),
public: z.boolean(),
rules: z.record(z.string(), z.any()),
rules: playlistRules,
size: z.number(),
songCount: z.number(),
sync: z.boolean(),
updatedAt: z.string(),
uploadedImage: z.string().optional(),
});
const playlistList = z.array(playlist);
@@ -631,7 +652,7 @@ const createPlaylistParameters = z.object({
name: z.string(),
ownerId: z.string().optional(),
public: z.boolean().optional(),
rules: z.record(z.any()).optional(),
rules: playlistRules.optional(),
sync: z.boolean().optional(),
});
@@ -639,8 +660,32 @@ const updatePlaylist = playlist;
const updatePlaylistParameters = createPlaylistParameters.partial();
const updateInternetRadioStationParameters = z.object({
homePageUrl: z.string().optional(),
name: z.string(),
streamUrl: z.string(),
});
const uploadPlaylistImage = z.object({
status: z.string(),
});
const uploadPlaylistImageParameters = z.object({
image: z.instanceof(Uint8Array),
});
const deletePlaylistImage = z.object({
status: z.string(),
});
const uploadInternetRadioStationImage = uploadPlaylistImage;
const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters;
const deleteInternetRadioStationImage = deletePlaylistImage;
const deletePlaylist = z.null();
const deleteInternetRadioStation = deletePlaylist;
const addToPlaylist = z.object({
added: z.number(),
});
@@ -715,12 +760,35 @@ const queue = z.object({
userId: z.string(),
});
export enum NDRadioListSort {
NAME = 'name',
}
const radioStation = z.object({
createdAt: z.string(),
homePageUrl: z.string().optional(),
id: z.string(),
name: z.string(),
streamUrl: z.string(),
updatedAt: z.string(),
uploadedImage: z.string().optional(),
});
const radioList = z.array(radioStation);
const updateInternetRadioStation = radioStation;
const radioListParameters = optionalPaginationParameters.extend({
_sort: z.nativeEnum(NDRadioListSort).optional(),
});
export const ndType = {
_enum: {
albumArtistList: NDAlbumArtistListSort,
albumList: NDAlbumListSort,
genreList: genreListSort,
playlistList: NDPlaylistListSort,
radioList: NDRadioListSort,
songList: NDSongListSort,
tagList: NDTagListSort,
userList: ndUserListSort,
@@ -734,12 +802,16 @@ export const ndType = {
genreList: genreListParameters,
moveItem: moveItemParameters,
playlistList: playlistListParameters,
radioList: radioListParameters,
removeFromPlaylist: removeFromPlaylistParameters,
saveQueue: saveQueueParameters,
shareItem: shareItemParameters,
songList: songListParameters,
tagList: tagListParameters,
updateInternetRadioStation: updateInternetRadioStationParameters,
updatePlaylist: updatePlaylistParameters,
uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters,
uploadPlaylistImage: uploadPlaylistImageParameters,
userList: userListParameters,
},
_response: {
@@ -750,7 +822,10 @@ export const ndType = {
albumList,
authenticate,
createPlaylist,
deleteInternetRadioStation,
deleteInternetRadioStationImage,
deletePlaylist,
deletePlaylistImage,
error,
genre,
genreList,
@@ -760,13 +835,18 @@ export const ndType = {
playlistSong,
playlistSongList,
queue,
radioList,
radioStation,
removeFromPlaylist,
saveQueue,
shareItem,
song,
songList,
tagList,
updateInternetRadioStation,
updatePlaylist,
uploadInternetRadioStationImage,
uploadPlaylistImage,
user,
userList,
},
+46
View File
@@ -0,0 +1,46 @@
const PARTIAL_ISO = /^\d{4}(-\d{2}(-\d{2})?)?$/;
export const coerceYear = (value: null | number | undefined): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return 0;
}
return value;
};
// Parses `YYYY`, `YYYY-MM`, or `YYYY-MM-DD`. Returns the trimmed string as `date` when valid.
export const parsePartialIsoDate = (
input: null | string | undefined,
): { date: null | string; year: number } => {
if (input == null || typeof input !== 'string') {
return { date: null, year: 0 };
}
const s = input.trim();
if (!s || !PARTIAL_ISO.test(s)) {
return { date: null, year: 0 };
}
const year = Number.parseInt(s.slice(0, 4), 10);
if (!Number.isFinite(year)) {
return { date: null, year: 0 };
}
return { date: s, year };
};
// Like `parsePartialIsoDate`, but if the value is a full ISO datetime, uses the `YYYY-MM-DD` prefix.
export const parsePartialIsoDateFromApi = (
input: null | string | undefined,
): { date: null | string; year: number } => {
const direct = parsePartialIsoDate(input);
if (direct.date) {
return direct;
}
if (input != null && typeof input === 'string' && input.length >= 10) {
return parsePartialIsoDate(input.slice(0, 10));
}
return { date: null, year: 0 };
};
+36 -11
View File
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { coerceYear, parsePartialIsoDate } from '/@/shared/api/partial-iso-date';
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
import { replacePathPrefix } from '/@/shared/api/utils';
import {
@@ -133,6 +134,32 @@ const getGenres = (
: [];
};
const pad2 = (n: number) => String(n).padStart(2, '0');
const subsonicReleaseFields = (item: {
releaseDate?: { day?: number; month?: number; year?: number };
year?: number;
}): { releaseDate: null | string; releaseYear: null | number } => {
const rd = item.releaseDate;
if (
rd &&
typeof rd.year === 'number' &&
typeof rd.month === 'number' &&
typeof rd.day === 'number'
) {
const iso = `${rd.year}-${pad2(rd.month)}-${pad2(rd.day)}`;
const parsed = parsePartialIsoDate(iso);
return { releaseDate: parsed.date, releaseYear: parsed.year };
}
const y = coerceYear(item.year);
if (y > 0) {
return { releaseDate: String(y), releaseYear: y };
}
return { releaseDate: null, releaseYear: null };
};
const normalizeSong = (
item: z.infer<typeof ssType._response.song>,
server?: null | ServerListItemWithCredential,
@@ -148,6 +175,8 @@ const normalizeSong = (
? item.albumArtists.map((a) => a.name).join(', ')
: item.artist || '';
const { releaseDate, releaseYear } = subsonicReleaseFields(item);
return {
_itemType: LibraryItem.SONG,
_serverId: server?.id || 'unknown',
@@ -202,8 +231,8 @@ const normalizeSong = (
: null,
playCount: item?.playCount || 0,
playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined,
releaseDate: null,
releaseYear: item.year || null,
releaseDate,
releaseYear,
sampleRate: item.samplingRate || null,
size: item.size,
sortName: item.title,
@@ -285,13 +314,7 @@ const normalizeAlbum = (
discTitleMap.set(discTitle.disc, discTitle.title);
});
const releaseDate =
item.releaseDate &&
typeof item.releaseDate.year === 'number' &&
typeof item.releaseDate.month === 'number' &&
typeof item.releaseDate.day === 'number'
? `${item.releaseDate.year}-${item.releaseDate.month}-${item.releaseDate.day}`
: null;
const { releaseDate, releaseYear } = subsonicReleaseFields(item);
return {
_itemType: LibraryItem.ALBUM,
@@ -319,14 +342,14 @@ const normalizeAlbum = (
mbzReleaseGroupId: null,
name: item.name,
originalDate: releaseDate,
originalYear: item.year || null,
originalYear: releaseYear ?? 0,
participants: getParticipants(item),
playCount: null,
recordLabels: item.recordLabels?.map((item) => item.name) || [],
releaseDate,
releaseType: getReleaseType(item),
releaseTypes: item.releaseTypes || [],
releaseYear: item.year || null,
releaseYear,
size: null,
songCount: item.songCount,
songs:
@@ -432,6 +455,8 @@ const normalizeInternetRadioStation = (
return {
homepageUrl: item.homepageUrl || null,
id: item.id,
imageId: item.coverArt?.toString() || null,
imageUrl: null,
name: item.name,
streamUrl: item.streamUrl,
};
+82
View File
@@ -11,6 +11,80 @@ const userParameters = z.object({
username: z.string(),
});
const transcodeDecisionParameters = z.object({
mediaId: z.string(),
mediaType: z.enum(['song', 'podcast']),
});
const getTranscodeStreamParameters = z.object({
mediaId: z.string(),
mediaType: z.enum(['song', 'podcast']),
offset: z.number().optional(),
transcodeParams: z.string(),
});
const codecProfileLimitation = z.object({
comparison: z.string(),
name: z.string(),
required: z.boolean().optional(),
values: z.array(z.string()),
});
const directPlayProfile = z.object({
audioCodecs: z.array(z.string()),
containers: z.array(z.string()),
maxAudioChannels: z.number().optional(),
protocols: z.array(z.string()),
});
const transcodingProfile = z.object({
audioCodec: z.string(),
container: z.string(),
maxAudioChannels: z.number().optional(),
protocol: z.string(),
});
const codecProfile = z.object({
limitations: z.array(codecProfileLimitation).optional(),
name: z.string(),
type: z.string(),
});
const transcodeDecisionRequestBody = z.object({
codecProfiles: z.array(codecProfile).optional(),
directPlayProfiles: z.array(directPlayProfile).optional(),
maxAudioBitrate: z.number().optional(),
maxTranscodingAudioBitrate: z.number().optional(),
name: z.string(),
platform: z.string(),
transcodingProfiles: z.array(transcodingProfile).optional(),
});
const streamDetails = z.object({
audioBitdepth: z.number().optional(),
audioBitrate: z.number().optional(),
audioChannels: z.number().optional(),
audioProfile: z.string().optional(),
audioSamplerate: z.number().optional(),
codec: z.string().optional(),
container: z.string().optional(),
protocol: z.string().optional(),
});
const transcodeDecision = z.object({
canDirectPlay: z.boolean(),
canTranscode: z.boolean(),
errorReason: z.string().optional(),
sourceStream: streamDetails.optional(),
transcodeParams: z.string().optional(),
transcodeReason: z.array(z.string()).optional(),
transcodeStream: streamDetails.optional(),
});
const getTranscodeDecision = z.object({
transcodeDecision,
});
const user = z.object({
user: z.object({
adminRole: z.boolean(),
@@ -382,6 +456,7 @@ export enum SubsonicExtensions {
INDEX_BASED_QUEUE = 'indexBasedQueue',
SONG_LYRICS = 'songLyrics',
TRANSCODE_OFFSET = 'transcodeOffset',
TRANSCODING = 'transcoding',
}
const updatePlaylistParameters = z.object({
@@ -680,6 +755,7 @@ const playQueueByIndex = z.object({
});
const internetRadioStation = z.object({
coverArt: z.string().optional(),
homepageUrl: z.string().optional(),
id: z.string(),
name: z.string(),
@@ -718,6 +794,9 @@ const getInternetRadioStations = z.object({
});
export const ssType = {
_body: {
getTranscodeDecision: transcodeDecisionRequestBody,
},
_parameters: {
albumInfo: albumInfoParameters,
albumList: albumListParameters,
@@ -741,6 +820,8 @@ export const ssType = {
getSong: getSongParameters,
getSongsByGenre: getSongsByGenreParameters,
getStarred: getStarredParameters,
getTranscodeDecision: transcodeDecisionParameters,
getTranscodeStream: getTranscodeStreamParameters,
randomSongList: randomSongListParameters,
removeFavorite: removeFavoriteParameters,
savePlayQueueByIndex: savePlayQueueByIndexParameters,
@@ -786,6 +867,7 @@ export const ssType = {
getSong,
getSongsByGenre,
getStarred,
getTranscodeDecision,
internetRadioStation,
musicFolderList,
ping,
@@ -0,0 +1,12 @@
import {
FileButton as MantineFileButton,
FileButtonProps as MantineFileButtonProps,
} from '@mantine/core';
import { CSSProperties } from 'react';
export interface FileButtonProps extends MantineFileButtonProps {
maxWidth?: CSSProperties['maxWidth'];
width?: CSSProperties['width'];
}
export const FileButton = MantineFileButton;
+4 -2
View File
@@ -28,6 +28,7 @@ import {
LuArrowUpToLine,
LuBookOpen,
LuBraces,
LuCamera,
LuCheck,
LuChevronDown,
LuChevronLast,
@@ -41,7 +42,6 @@ import {
LuCloudDownload,
LuCornerDownRight,
LuCornerUpRight,
LuDelete,
LuDisc,
LuDisc3,
LuDownload,
@@ -117,6 +117,7 @@ import {
LuTable,
LuTimer,
LuTimerOff,
LuTrash,
LuTriangleAlert,
LuUpload,
LuUser,
@@ -248,7 +249,7 @@ export const AppIcon = {
check: LuCheck,
clipboardCopy: LuClipboardCopy,
collection: LuPackage2,
delete: LuDelete,
delete: LuTrash,
disc: LuDisc,
download: LuDownload,
dragHorizontal: LuGripHorizontal,
@@ -351,6 +352,7 @@ export const AppIcon = {
unfavorite: LuHeartCrack,
unpin: LuPinOff,
upload: LuUpload,
uploadImage: LuCamera,
user: LuUser,
userManage: LuUserRoundCog,
visibility: MdOutlineVisibility,
+11 -3
View File
@@ -7,9 +7,17 @@ import { Icon } from '/@/shared/components/icon/icon';
interface SpoilerProps extends Omit<MantineSpoilerProps, 'hideLabel' | 'showLabel'> {
children?: ReactNode;
hideLabel?: ReactNode;
showLabel?: ReactNode;
}
export const Spoiler = ({ children, maxHeight = 56, ...props }: SpoilerProps) => {
export const Spoiler = ({
children,
hideLabel,
maxHeight = 56,
showLabel,
...props
}: SpoilerProps) => {
const [expanded, setExpanded] = useState(false);
return (
@@ -18,9 +26,9 @@ export const Spoiler = ({ children, maxHeight = 56, ...props }: SpoilerProps) =>
expanded={expanded}
maxHeight={maxHeight}
{...props}
hideLabel={<Icon icon="arrowUpS" size="lg" />}
hideLabel={hideLabel ?? <Icon icon="arrowUpS" size="lg" />}
onClick={() => setExpanded(!expanded)}
showLabel={<Icon icon="arrowDownS" size="lg" />}
showLabel={showLabel ?? <Icon icon="arrowDownS" size="lg" />}
>
{children}
</MantineSpoiler>
-43
View File
@@ -1,43 +0,0 @@
.ag-header-fixed {
position: fixed !important;
top: 65px;
z-index: 15;
padding: 0 2rem;
margin: 0 -2rem;
box-shadow: 0 -1px 0 0 #181818;
transition: position 0.2s ease-in-out;
}
.ag-header-window-bar {
top: 95px;
}
.ag-header {
z-index: 5;
}
.window-frame {
top: 95px;
}
.ag-header-transparent {
--ag-header-background-color: rgb(0 0 0 / 0%) !important;
}
.ag-header-fixed-margin {
margin-top: 36px !important;
}
.ag-header-cell-comp-wrapper {
margin: 0 0.5rem;
}
.ag-header-cell,
.ag-header-group-cell {
padding-right: 0.5rem;
padding-left: 0.5rem;
}
.ag-header-cell-resize {
background-color: transparent;
}
+4
View File
@@ -283,6 +283,10 @@ button {
--theme-spacing-xs: var(--mantine-spacing-xs);
--theme-spacing-sm: var(--mantine-spacing-sm);
--theme-spacing-md: var(--mantine-spacing-md);
--item-table-sticky-top-win-mac: calc(95px + 2 * var(--theme-spacing-md));
--item-table-sticky-top-default: calc(65px + var(--theme-spacing-md));
--item-table-sticky-inview-margin-win-mac: calc(-130px - 2 * var(--theme-spacing-md));
--item-table-sticky-inview-margin-default: calc(-100px - var(--theme-spacing-md));
--theme-spacing-lg: var(--mantine-spacing-lg);
--theme-spacing-xl: var(--mantine-spacing-xl);
--theme-spacing-2xl: var(--mantine-spacing-2xl);
@@ -1,5 +1,5 @@
/* stylelint-disable selector-class-pattern */
.fs-player-bar-module-container {
.fs-player-bar-module-bar {
background: rgb(0 0 0 / 40%) !important;
backdrop-filter: blur(2rem);
}
@@ -125,8 +125,8 @@ table {
height: 100vh;
}
:has(.fs-window-bar-module-window-bar) .fs-main-content-module-main-content-container {
height: calc(100vh - 30px);
:has(.fs-window-bar-module-wrapper) .fs-main-content-module-main-content-container {
height: calc(100vh - 30px - var(--theme-spacing-md));
}
.mantine-Tabs-root {
+146 -19
View File
@@ -188,12 +188,12 @@ export type Album = {
mbzId: null | string;
mbzReleaseGroupId: null | string;
name: string;
originalDate: null | string;
originalYear: null | number;
originalDate: null | PartialIsoDateString;
originalYear: number;
participants: null | Record<string, RelatedArtist[]>;
playCount: null | number;
recordLabels: string[];
releaseDate: null | string;
releaseDate: null | PartialIsoDateString;
releaseType: null | string;
releaseTypes: string[];
releaseYear: null | number;
@@ -326,6 +326,8 @@ export type MusicFolder = {
export type MusicFoldersResponse = MusicFolder[];
export type PartialIsoDateString = string;
export type Playlist = {
_itemType: LibraryItem.PLAYLIST;
_serverId: string;
@@ -340,10 +342,11 @@ export type Playlist = {
owner: null | string;
ownerId: null | string;
public: boolean | null;
rules?: null | Record<string, any>;
rules?: null | PlaylistRules;
size: null | number;
songCount: null | number;
sync?: boolean | null;
uploadedImage?: string;
};
export type RelatedAlbumArtist = {
@@ -397,7 +400,7 @@ export type Song = {
peak: GainInfo | null;
playCount: number;
playlistItemId?: string;
releaseDate: null | string;
releaseDate: null | PartialIsoDateString;
releaseYear: null | number;
sampleRate: null | number;
size: number;
@@ -410,16 +413,18 @@ export type Song = {
userRating: null | number;
};
type ApiContext = {
pathReplace?: string;
pathReplaceWith?: string;
};
type BaseEndpointArgs = {
apiClientProps: {
server?: null | ServerListItemWithCredential;
serverId: string;
signal?: AbortSignal;
};
context?: {
pathReplace?: string;
pathReplaceWith?: string;
};
context?: ApiContext;
};
type GenreListSortMap = {
@@ -945,7 +950,7 @@ export type CreatePlaylistBody = {
name: string;
ownerId?: string;
public?: boolean;
queryBuilderRules?: Record<string, any>;
queryBuilderRules?: PlaylistRules;
sync?: boolean;
};
@@ -956,6 +961,16 @@ export type DeleteInternetRadioStationArgs = BaseEndpointArgs & {
query: DeleteInternetRadioStationQuery;
};
export type DeleteInternetRadioStationImageArgs = BaseEndpointArgs & {
query: DeleteInternetRadioStationImageQuery;
};
export type DeleteInternetRadioStationImageQuery = {
id: string;
};
export type DeleteInternetRadioStationImageResponse = boolean;
export type DeleteInternetRadioStationQuery = {
id: string;
};
@@ -966,6 +981,16 @@ export type DeletePlaylistArgs = BaseEndpointArgs & {
query: DeletePlaylistQuery;
};
export type DeletePlaylistImageArgs = BaseEndpointArgs & {
query: DeletePlaylistImageQuery;
};
export type DeletePlaylistImageQuery = {
id: string;
};
export type DeletePlaylistImageResponse = boolean;
export type DeletePlaylistQuery = { id: string };
// Delete Playlist
@@ -986,10 +1011,13 @@ export type GetInternetRadioStationsArgs = BaseEndpointArgs;
export type GetInternetRadioStationsResponse = InternetRadioStation[];
export type InternetRadioStation = {
homepageUrl?: null | string;
homepageUrl: null | string;
id: string;
imageId?: null | string;
imageUrl?: null | string;
name: string;
streamUrl: string;
uploadedImage?: null | string;
};
export type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery };
@@ -1007,6 +1035,12 @@ export interface PlaylistListQuery extends BaseQuery<PlaylistListSort> {
// Playlist List
export type PlaylistListResponse = BasePaginatedResponse<Playlist[]>;
export type PlaylistRules = Record<string, any> & {
limit?: number;
limitPercent?: number;
sort?: string;
};
export type RatingQuery = {
id: string[];
rating: number;
@@ -1087,7 +1121,7 @@ export type UpdatePlaylistBody = {
name: string;
ownerId?: string;
public?: boolean;
queryBuilderRules?: Record<string, any>;
queryBuilderRules?: PlaylistRules;
sync?: boolean;
};
@@ -1098,6 +1132,36 @@ export type UpdatePlaylistQuery = {
// Update Playlist
export type UpdatePlaylistResponse = null | undefined;
export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & {
body: UploadInternetRadioStationImageBody;
query: UploadInternetRadioStationImageQuery;
};
export type UploadInternetRadioStationImageBody = {
image: Uint8Array;
};
export type UploadInternetRadioStationImageQuery = {
id: string;
};
export type UploadInternetRadioStationImageResponse = boolean;
export type UploadPlaylistImageArgs = BaseEndpointArgs & {
body: UploadPlaylistImageBody;
query: UploadPlaylistImageQuery;
};
export type UploadPlaylistImageBody = {
image: Uint8Array;
};
export type UploadPlaylistImageQuery = {
id: string;
};
export type UploadPlaylistImageResponse = boolean;
type PlaylistListSortMap = {
jellyfin: Record<PlaylistListSort, JFPlaylistListSort | undefined>;
navidrome: Record<PlaylistListSort, NDPlaylistListSort | undefined>;
@@ -1381,7 +1445,11 @@ export type ControllerEndpoint = {
deleteInternetRadioStation: (
args: DeleteInternetRadioStationArgs,
) => Promise<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: DeleteInternetRadioStationImageArgs,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (args: DeletePlaylistImageArgs) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
@@ -1416,11 +1484,10 @@ export type ControllerEndpoint = {
getSongDetail: (args: SongDetailArgs) => Promise<SongDetailResponse>;
getSongList: (args: SongListArgs) => Promise<SongListResponse>;
getSongListCount: (args: SongListCountArgs) => Promise<number>;
getStreamUrl: (args: StreamArgs) => string;
getStreamUrl: (args: StreamArgs) => Promise<string>;
getStructuredLyrics?: (args: StructuredLyricsArgs) => Promise<StructuredLyric[]>;
getTagList?: (args: TagListArgs) => Promise<TagListResponse>;
getTopSongs: (args: TopSongListArgs) => Promise<TopSongListResponse>;
// getArtistInfo?: (args: any) => void;
getUserInfo: (args: UserInfoArgs) => Promise<UserInfoResponse>;
getUserList?: (args: UserListArgs) => Promise<UserListResponse>;
movePlaylistItem?: (args: MoveItemArgs) => Promise<void>;
@@ -1436,6 +1503,10 @@ export type ControllerEndpoint = {
args: UpdateInternetRadioStationArgs,
) => Promise<UpdateInternetRadioStationResponse>;
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: UploadInternetRadioStationImageArgs,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: (args: UploadPlaylistImageArgs) => Promise<UploadPlaylistImageResponse>;
};
export type DownloadArgs = BaseEndpointArgs & {
@@ -1505,9 +1576,15 @@ export type InternalControllerEndpoint = {
deleteInternetRadioStation: (
args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,
) => Promise<DeleteInternetRadioStationResponse>;
deleteInternetRadioStationImage?: (
args: ReplaceApiClientProps<DeleteInternetRadioStationImageArgs>,
) => Promise<DeleteInternetRadioStationImageResponse>;
deletePlaylist: (
args: ReplaceApiClientProps<DeletePlaylistArgs>,
) => Promise<DeletePlaylistResponse>;
deletePlaylistImage?: (
args: ReplaceApiClientProps<DeletePlaylistImageArgs>,
) => Promise<DeletePlaylistImageResponse>;
getAlbumArtistDetail: (
args: ReplaceApiClientProps<AlbumArtistDetailArgs>,
) => Promise<AlbumArtistDetailResponse>;
@@ -1563,7 +1640,7 @@ export type InternalControllerEndpoint = {
getSongDetail: (args: ReplaceApiClientProps<SongDetailArgs>) => Promise<SongDetailResponse>;
getSongList: (args: ReplaceApiClientProps<SongListArgs>) => Promise<SongListResponse>;
getSongListCount: (args: ReplaceApiClientProps<SongListCountArgs>) => Promise<number>;
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => string;
getStreamUrl: (args: ReplaceApiClientProps<StreamArgs>) => Promise<string>;
getStructuredLyrics?: (
args: ReplaceApiClientProps<StructuredLyricsArgs>,
) => Promise<StructuredLyric[]>;
@@ -1592,6 +1669,12 @@ export type InternalControllerEndpoint = {
updatePlaylist: (
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
) => Promise<UpdatePlaylistResponse>;
uploadInternetRadioStationImage?: (
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
) => Promise<UploadInternetRadioStationImageResponse>;
uploadPlaylistImage?: (
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
) => Promise<UploadPlaylistImageResponse>;
};
export type LyricGetQuery = {
@@ -1667,6 +1750,9 @@ export type StreamQuery = {
bitrate?: number;
format?: string;
id: string;
mediaType?: 'podcast' | 'song';
offset?: number;
skipAutoTranscode?: boolean;
transcode: boolean;
};
@@ -1711,6 +1797,50 @@ export type TagListResponse = {
tags?: Tag[];
};
export type TranscodeDecisionArgs = BaseEndpointArgs & {
body?: TranscodeDecisionRequestBody;
query: TranscodeDecisionQuery;
};
export type TranscodeDecisionQuery = {
id: string;
type: 'song';
};
export type TranscodeDecisionRequestBody = {
codecProfiles?: Array<{
limitations?: Array<{
comparison: string;
name: string;
required?: boolean;
values: string[];
}>;
name: string;
type: string;
}>;
directPlayProfiles?: Array<{
audioCodecs: string[];
containers: string[];
maxAudioChannels?: number;
protocols: string[];
}>;
maxAudioBitrate?: number;
maxTranscodingAudioBitrate?: number;
name: string;
platform: string;
transcodingProfiles?: Array<{
audioCodec: string;
container: string;
maxAudioChannels?: number;
protocol: string;
}>;
};
export type TranscodeDecisionResponse = {
decision: 'direct' | 'transcode';
transcodeParams?: string;
};
export type UserInfoArgs = BaseEndpointArgs & { query: UserInfoQuery };
export type UserInfoQuery = {
@@ -1730,8 +1860,5 @@ type BaseEndpointArgsWithServer = {
serverId: string;
signal?: AbortSignal;
};
context?: {
pathReplace?: string;
pathReplaceWith?: string;
};
context?: ApiContext;
};
+3
View File
@@ -3,10 +3,13 @@
export enum ServerFeature {
ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter',
BFR = 'bfr',
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured',
MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',
OS_FORM_POST = 'osFormPost',
OS_TRANSCODE_DECISION = 'osTranscodeDecision',
PLAYLIST_IMAGE_UPLOAD = 'playlistImageUpload',
PLAYLISTS_SMART = 'playlistsSmart',
PUBLIC_PLAYLIST = 'publicPlaylist',
SERVER_PLAY_QUEUE = 'serverPlayQueue',