mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 20:40:15 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee33720fcd | |||
| 7d34511039 | |||
| 8b4bbc1ede | |||
| 833d4d3aac | |||
| 7e353c4723 | |||
| ae2ce0866e | |||
| 27c42dd9f4 | |||
| 52dea17d14 |
@@ -1110,9 +1110,6 @@
|
||||
"export": "exportovat texty",
|
||||
"input_synced": "exportovat synchronizované texty",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "stanice rádia úspěšně upravena"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -364,9 +364,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak. hornitzaileen ordena kontsultatuko diren ordena da",
|
||||
"minimizeToTray": "minimizatu erretilura",
|
||||
"minimizeToTray_description": "minimizatu aplikazioa sistemaren erretilura",
|
||||
"minimumScrobblePercentage": "scrobble iraupen minimoa (ehunekoa)",
|
||||
@@ -688,33 +688,7 @@
|
||||
"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",
|
||||
"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"
|
||||
"remoteUsername": "urruneko kontrol zerbitzariaren erabiltzaile-izena"
|
||||
},
|
||||
"form": {
|
||||
"addServer": {
|
||||
@@ -969,8 +943,7 @@
|
||||
"nowPlaying": "orain erreproduzitzen",
|
||||
"shared": "partekatutako $t(entity.playlist, {\"count\": 2})",
|
||||
"favorites": "$t(entity.favorite, {\"count\": 2})",
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})",
|
||||
"collections": "bildumak"
|
||||
"radio": "$t(entity.radioStation, {\"count\": 2})"
|
||||
},
|
||||
"trackList": {
|
||||
"title": "$t(entity.track, {\"count\": 2})",
|
||||
@@ -1139,26 +1112,6 @@
|
||||
"saveAsPreset": "Aurrezarpen gisa gorde",
|
||||
"applyPreset": "Aurrezarpena Aplikatu",
|
||||
"selectPreset": "Aukeratu Aurrezarpena",
|
||||
"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"
|
||||
"presets": "Aurrezarpenak"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -891,9 +891,7 @@
|
||||
"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",
|
||||
"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."
|
||||
"sidePlayQueueLayout_optionVertical": "vertical"
|
||||
},
|
||||
"form": {
|
||||
"deletePlaylist": {
|
||||
@@ -1092,7 +1090,7 @@
|
||||
"pagination_itemsPerPage": "entrées par page",
|
||||
"pagination_infinite": "infini",
|
||||
"pagination_paginate": "paginé",
|
||||
"alternateRowColors": "alterner la couleur des lignes",
|
||||
"alternateRowColors": "alterner les couleurs des lignes",
|
||||
"horizontalBorders": "bordures de ligne",
|
||||
"rowHoverHighlight": "surligner les lignes au survol",
|
||||
"verticalBorders": "bordure de colonne",
|
||||
@@ -1234,12 +1232,12 @@
|
||||
},
|
||||
"visualizer": {
|
||||
"visualizerType": "type de visualisateur",
|
||||
"cyclePresets": "cycler les préréglages",
|
||||
"cycleTime": "durée d'un cycle (secondes)",
|
||||
"cyclePresets": "cycle les préréglages",
|
||||
"cycleTime": "temps de cycle (secondes)",
|
||||
"includeAllPresets": "inclure tous les préréglages",
|
||||
"ignoredPresets": "préréglages ignorés",
|
||||
"selectedPresets": "préréglages sélectionnés",
|
||||
"randomizeNextPreset": "préréglage suivant aléatoire",
|
||||
"selectedPresets": "préréglages sélectionné",
|
||||
"randomizeNextPreset": "randomiser le préréglage suivant",
|
||||
"blendTime": "temps de mélange",
|
||||
"presets": "préréglages",
|
||||
"selectPreset": "sélectionner un préréglage",
|
||||
@@ -1249,7 +1247,7 @@
|
||||
"copyConfiguration": "copier la configuration",
|
||||
"pasteConfiguration": "coller la configuration",
|
||||
"pasteConfigurationPlaceholder": "coller ici la configuration JSON...",
|
||||
"pasteFromClipboard": "coller depuis le presse-papiers",
|
||||
"pasteFromClipboard": "coller depuis le presse-papier",
|
||||
"applyConfiguration": "appliquer la configuration",
|
||||
"configCopied": "configuration copiée dans le presse-papiers",
|
||||
"configCopyFailed": "échec de la copie de la configuration",
|
||||
@@ -1274,7 +1272,7 @@
|
||||
"gradientNamePlaceholder": "nom du dégradé",
|
||||
"vertical": "verticale",
|
||||
"horizontal": "horizontale",
|
||||
"colorStops": "Points de Couleur",
|
||||
"colorStops": "couleur d'arrêts",
|
||||
"addColor": "ajouter un couleur",
|
||||
"position": "position",
|
||||
"level": "niveau",
|
||||
|
||||
@@ -169,8 +169,7 @@
|
||||
"filter_single": "single",
|
||||
"rename": "zmień nazwę",
|
||||
"newVersionAvailable": "nowa wersja jest dostępna",
|
||||
"numberOfResults": "{{numberOfResults}} wyników",
|
||||
"grouping": "grupowanie"
|
||||
"numberOfResults": "{{numberOfResults}} wyników"
|
||||
},
|
||||
"entity": {
|
||||
"genre_one": "gatunek",
|
||||
@@ -421,9 +420,6 @@
|
||||
"export": "eksportuj tekst",
|
||||
"input_synced": "eksportuj zsynchronizowany tekst",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "stacja radiowa zaktualizowana pomyślnie"
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -1062,9 +1058,7 @@
|
||||
"sidePlayQueueLayout": "układ boczny kolejki odtwarzania",
|
||||
"sidePlayQueueLayout_description": "ustawia układ przyczepionej z boku kolejki odtwarzania",
|
||||
"sidePlayQueueLayout_optionHorizontal": "poziomy",
|
||||
"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."
|
||||
"sidePlayQueueLayout_optionVertical": "pionowy"
|
||||
},
|
||||
"table": {
|
||||
"config": {
|
||||
|
||||
@@ -161,8 +161,7 @@
|
||||
"rename": "重命名",
|
||||
"filter_multiple": "多项",
|
||||
"newVersionAvailable": "新版本现已可用",
|
||||
"numberOfResults": "{{numberOfResults}} 结果",
|
||||
"grouping": "分组"
|
||||
"numberOfResults": "{{numberOfResults}} 结果"
|
||||
},
|
||||
"entity": {
|
||||
"albumArtist_other": "专辑艺术家",
|
||||
@@ -610,9 +609,7 @@
|
||||
"sidePlayQueueLayout": "侧边播放队列布局",
|
||||
"sidePlayQueueLayout_description": "设置附加侧边播放队列的布局",
|
||||
"sidePlayQueueLayout_optionHorizontal": "水平",
|
||||
"sidePlayQueueLayout_optionVertical": "垂直",
|
||||
"waveformLoadingDelay": "波形加载延迟",
|
||||
"waveformLoadingDelay_description": "加载波形前的延迟时间(秒)。如果在使用网页播放器时遇到卡顿现象,请增加此值。"
|
||||
"sidePlayQueueLayout_optionVertical": "垂直"
|
||||
},
|
||||
"error": {
|
||||
"remotePortWarning": "重启服务器使新端口生效",
|
||||
|
||||
@@ -1124,9 +1124,6 @@
|
||||
"export": "匯出歌詞",
|
||||
"input_synced": "匯出同步歌詞",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "電臺更新成功"
|
||||
}
|
||||
},
|
||||
"releaseType": {
|
||||
|
||||
@@ -437,18 +437,10 @@ ipcMain.on('player-mute', async (_event, mute: boolean) => {
|
||||
|
||||
ipcMain.handle('player-get-time', async (): Promise<number | undefined> => {
|
||||
try {
|
||||
const mpv = getMpvInstance();
|
||||
if (!mpv) {
|
||||
return undefined;
|
||||
}
|
||||
return await mpv.getTimePosition();
|
||||
return getMpvInstance()?.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 undefined;
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -175,20 +175,6 @@ 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);
|
||||
|
||||
@@ -203,20 +189,6 @@ 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);
|
||||
|
||||
@@ -988,32 +960,4 @@ 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 } }));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -46,24 +46,6 @@ 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',
|
||||
@@ -73,15 +55,6 @@ 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',
|
||||
@@ -159,15 +132,6 @@ 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',
|
||||
@@ -241,15 +205,6 @@ 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',
|
||||
@@ -259,24 +214,6 @@ 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,4 +1,3 @@
|
||||
import axios from 'axios';
|
||||
import { set } from 'idb-keyval';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
|
||||
@@ -6,17 +5,13 @@ 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 { NDRadioListSort, NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
|
||||
import { 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,
|
||||
@@ -28,10 +23,6 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
tagListSortMap,
|
||||
UploadInternetRadioStationImageArgs,
|
||||
UploadInternetRadioStationImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
UploadPlaylistImageResponse,
|
||||
userListSortMap,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
@@ -39,13 +30,6 @@ 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] }],
|
||||
@@ -187,38 +171,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
};
|
||||
},
|
||||
deleteFavorite: SubsonicController.deleteFavorite,
|
||||
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';
|
||||
},
|
||||
deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation,
|
||||
deletePlaylist: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -234,23 +187,6 @@ 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;
|
||||
|
||||
@@ -611,24 +547,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
},
|
||||
getImageRequest: SubsonicController.getImageRequest,
|
||||
getImageUrl: SubsonicController.getImageUrl,
|
||||
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));
|
||||
},
|
||||
getInternetRadioStations: SubsonicController.getInternetRadioStations,
|
||||
getLyrics: SubsonicController.getLyrics,
|
||||
getMusicFolderList: SubsonicController.getMusicFolderList,
|
||||
getPlaylistDetail: async (args) => {
|
||||
@@ -1226,26 +1145,7 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
},
|
||||
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;
|
||||
},
|
||||
updateInternetRadioStation: SubsonicController.updateInternetRadioStation,
|
||||
updatePlaylist: async (args) => {
|
||||
const { apiClientProps, body, query } = args;
|
||||
|
||||
@@ -1270,76 +1170,4 @@ 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';
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1185,7 +1185,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
return ssNormalize.playlist(res.body.playlist, apiClientProps.server);
|
||||
},
|
||||
getPlaylistList: async ({ apiClientProps, query }) => {
|
||||
const sortOrder = (query.sortOrder || SortOrder.ASC).toLowerCase() as 'asc' | 'desc';
|
||||
const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc';
|
||||
|
||||
const res = await ssApiClient(apiClientProps).getPlaylists({});
|
||||
|
||||
|
||||
+55
-113
@@ -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, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import i18n from '/@/i18n/i18n';
|
||||
import { openSettingsModal } from '/@/renderer/features/settings/utils/open-settings-modal';
|
||||
@@ -38,26 +38,67 @@ const UpdateAvailableDialog = lazy(() =>
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
|
||||
export const App = () => {
|
||||
return <ThemedApp />;
|
||||
};
|
||||
|
||||
const ThemedApp = () => {
|
||||
const { mode, theme } = useAppTheme();
|
||||
const language = useLanguage();
|
||||
|
||||
return (
|
||||
<MantineProvider forceColorScheme={mode} theme={theme}>
|
||||
<AppShell />
|
||||
</MantineProvider>
|
||||
);
|
||||
};
|
||||
const { content, enabled } = useCssSettings();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const cssRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useSyncSettingsToMain();
|
||||
useCheckForUpdates();
|
||||
|
||||
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: {
|
||||
@@ -68,8 +109,7 @@ const AppShell = memo(function AppShell() {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppEffects />
|
||||
<MantineProvider forceColorScheme={mode} theme={theme}>
|
||||
<Notifications
|
||||
containerWidth="300px"
|
||||
position="bottom-center"
|
||||
@@ -86,104 +126,6 @@ const AppShell = memo(function AppShell() {
|
||||
<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,16 +36,12 @@
|
||||
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,10 +1161,12 @@ 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 > 0 ? data.originalYear : null;
|
||||
'originalYear' in data && data.originalYear !== null
|
||||
? data.originalYear
|
||||
: null;
|
||||
|
||||
if (originalYear !== null && originalYear !== releaseYear) {
|
||||
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
|
||||
@@ -1184,10 +1186,10 @@ export const getDataRows = (type?: 'compact' | 'default' | 'poster'): DataRow[]
|
||||
data.originalDate &&
|
||||
data.originalDate !== data.releaseDate
|
||||
) {
|
||||
return `${formatPartialIsoDateUTC(data.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(data.releaseDate)}`;
|
||||
return `${formatDateAbsoluteUTC(data.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(data.releaseDate)}`;
|
||||
}
|
||||
|
||||
return `${formatPartialIsoDateUTC(data.releaseDate)}`;
|
||||
return `${formatDateAbsoluteUTC(data.releaseDate)}`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { ItemDetailListCellProps } from './types';
|
||||
|
||||
import { formatPartialIsoDateUTC } from '/@/renderer/utils/format';
|
||||
import { SEPARATOR_STRING } from '/@/shared/api/utils';
|
||||
import { formatDateAbsoluteUTC } from '/@/renderer/utils/format';
|
||||
|
||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) => {
|
||||
const row = song as typeof song & { originalDate?: null | string };
|
||||
const releaseDate = row.releaseDate;
|
||||
if (!releaseDate) {
|
||||
return <> </>;
|
||||
}
|
||||
|
||||
const originalDate =
|
||||
row.originalDate && row.originalDate !== releaseDate ? row.originalDate : null;
|
||||
|
||||
if (originalDate) {
|
||||
return `${formatPartialIsoDateUTC(originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(releaseDate)}`;
|
||||
}
|
||||
|
||||
return formatPartialIsoDateUTC(releaseDate);
|
||||
};
|
||||
export const ReleaseDateColumn = ({ song }: ItemDetailListCellProps) =>
|
||||
song.releaseDate ? formatDateAbsoluteUTC(song.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 { formatDurationString, formatPartialIsoDateUTC } from '/@/renderer/utils';
|
||||
import { formatDateAbsoluteUTC, formatDurationString } 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 = `${formatPartialIsoDateUTC(item.originalDate)}${SEPARATOR_STRING}${formatPartialIsoDateUTC(item.releaseDate)}`;
|
||||
releaseStr = `${formatDateAbsoluteUTC(item.originalDate)}${SEPARATOR_STRING}${formatDateAbsoluteUTC(item.releaseDate)}`;
|
||||
} else {
|
||||
releaseStr = formatPartialIsoDateUTC(item.releaseDate);
|
||||
releaseStr = formatDateAbsoluteUTC(item.releaseDate);
|
||||
}
|
||||
} else if (item.releaseYear != null) {
|
||||
releaseStr = String(item.releaseYear);
|
||||
|
||||
@@ -20,8 +20,7 @@ export const createColumnCellComponent = (
|
||||
prevProps.columnIndex === nextProps.columnIndex &&
|
||||
prevProps.data === nextProps.data &&
|
||||
prevProps.style === nextProps.style &&
|
||||
prevProps.columns === nextProps.columns &&
|
||||
prevProps.playlistId === nextProps.playlistId
|
||||
prevProps.columns === nextProps.columns
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -8,25 +8,49 @@ import {
|
||||
} from '/@/renderer/components/item-list/item-table-list/item-table-list-column';
|
||||
import {
|
||||
formatDateAbsolute,
|
||||
formatDateAbsoluteUTC,
|
||||
formatDateRelative,
|
||||
formatPartialIsoDateUTC,
|
||||
formatHrDateTime,
|
||||
} 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 formattedAbsolute = useMemo(
|
||||
() => (typeof row === 'string' && row ? formatDateAbsolute(row) : null),
|
||||
[row],
|
||||
);
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string' && row) {
|
||||
return {
|
||||
formattedDate: formatDateAbsolute(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
}, [row]);
|
||||
|
||||
if (formattedAbsolute) {
|
||||
if (typeof row === 'string' && row) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{formattedAbsolute}</span>
|
||||
<Tooltip label={tooltipLabel} multiline={false}>
|
||||
<span>{formattedDate}</span>
|
||||
</Tooltip>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
@@ -55,37 +79,44 @@ const AbsoluteDateColumnBase = (props: ItemTableListInnerColumn) => {
|
||||
: null;
|
||||
|
||||
if (originalDate) {
|
||||
const formattedOriginalDate = formatPartialIsoDateUTC(originalDate);
|
||||
const formattedReleaseDate = formatPartialIsoDateUTC(releaseDate);
|
||||
return `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
|
||||
const formattedOriginalDate = formatDateAbsoluteUTC(originalDate);
|
||||
const formattedReleaseDate = formatDateAbsoluteUTC(releaseDate);
|
||||
const displayText = `${formattedOriginalDate}${SEPARATOR_STRING}${formattedReleaseDate}`;
|
||||
|
||||
return {
|
||||
displayText,
|
||||
tooltipLabel: getDateTooltipLabel(releaseDate),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof releaseDate === 'string' && releaseDate) {
|
||||
return formatPartialIsoDateUTC(releaseDate);
|
||||
return {
|
||||
displayText: formatDateAbsoluteUTC(releaseDate),
|
||||
tooltipLabel: getDateTooltipLabel(releaseDate),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [props.type, rowItem]);
|
||||
|
||||
const formattedIsoFallback = useMemo(
|
||||
() => (typeof row === 'string' && row ? formatPartialIsoDateUTC(row) : null),
|
||||
[row],
|
||||
);
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string' && row) {
|
||||
return {
|
||||
formattedDate: formatDateAbsoluteUTC(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
}, [row]);
|
||||
|
||||
if (props.type === TableColumn.RELEASE_DATE) {
|
||||
if (releaseDateContent) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{releaseDateContent}</span>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (formattedIsoFallback) {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{formattedIsoFallback}</span>
|
||||
<Tooltip label={releaseDateContent.tooltipLabel} multiline={false}>
|
||||
<span>{releaseDateContent.displayText}</span>
|
||||
</Tooltip>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
@@ -97,6 +128,20 @@ 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} />;
|
||||
};
|
||||
|
||||
@@ -106,15 +151,22 @@ 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 formattedRelative = useMemo(() => {
|
||||
if (typeof row !== 'string') return null;
|
||||
return formatDateRelative(row);
|
||||
const { formattedDate, tooltipLabel } = useMemo(() => {
|
||||
if (typeof row === 'string') {
|
||||
return {
|
||||
formattedDate: formatDateRelative(row),
|
||||
tooltipLabel: getDateTooltipLabel(row),
|
||||
};
|
||||
}
|
||||
return { formattedDate: null, tooltipLabel: null };
|
||||
}, [row]);
|
||||
|
||||
if (formattedRelative !== null) {
|
||||
if (typeof row === 'string') {
|
||||
return (
|
||||
<TableColumnTextContainer {...props}>
|
||||
<span>{formattedRelative}</span>
|
||||
<Tooltip label={tooltipLabel} multiline={false}>
|
||||
<span>{formattedDate}</span>
|
||||
</Tooltip>
|
||||
</TableColumnTextContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import styles from './title-column.module.css';
|
||||
@@ -36,12 +35,8 @@ 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
|
||||
@@ -85,12 +80,8 @@ 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 > 0 ? item.originalYear : null;
|
||||
'originalYear' in item && item.originalYear !== null ? item.originalYear : null;
|
||||
|
||||
if (originalYear !== null && originalYear !== releaseYear) {
|
||||
return `${originalYear}${SEPARATOR_STRING}${releaseYear}`;
|
||||
|
||||
+225
-237
@@ -34,268 +34,256 @@ 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: shouldEnableDrag
|
||||
? {
|
||||
getId: () => {
|
||||
if (!item || !isDataRow) {
|
||||
return [];
|
||||
}
|
||||
drag: {
|
||||
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,
|
||||
}
|
||||
: undefined,
|
||||
drop: needsDropRegistration
|
||||
? {
|
||||
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,
|
||||
},
|
||||
drop: {
|
||||
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;
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
return;
|
||||
},
|
||||
},
|
||||
isEnabled: shouldEnableDrag,
|
||||
});
|
||||
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
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;
|
||||
}
|
||||
+12
-9
@@ -1,8 +1,9 @@
|
||||
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;
|
||||
@@ -17,7 +18,6 @@ export const useStickyTableGroupRows = ({
|
||||
mainGridRef,
|
||||
shouldShowStickyHeader,
|
||||
stickyHeaderTop,
|
||||
stickyLayout,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
enabled: boolean;
|
||||
@@ -27,14 +27,17 @@ export const useStickyTableGroupRows = ({
|
||||
mainGridRef: React.RefObject<HTMLDivElement | null>;
|
||||
shouldShowStickyHeader?: boolean;
|
||||
stickyHeaderTop?: number;
|
||||
stickyLayout: ItemTableStickyLayoutOffsets;
|
||||
}) => {
|
||||
const { inViewMarginTop, stickyTop: layoutStickyTop } = stickyLayout;
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const [stickyGroupIndex, setStickyGroupIndex] = useState<null | number>(null);
|
||||
|
||||
const groupRowsInViewMargin = `${inViewMarginTop}px 0px 0px 0px`;
|
||||
const topMargin =
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? '-130px'
|
||||
: '-100px';
|
||||
|
||||
const isTableInView = useInView(containerRef, {
|
||||
margin: groupRowsInViewMargin as NonNullable<Parameters<typeof useInView>[1]>['margin'],
|
||||
margin: `${topMargin} 0px 0px 0px`,
|
||||
});
|
||||
|
||||
const stickyTop = useMemo(() => {
|
||||
@@ -43,8 +46,8 @@ export const useStickyTableGroupRows = ({
|
||||
if (shouldShowStickyHeader && stickyHeaderTop !== undefined) {
|
||||
return stickyHeaderTop + headerHeight + 1;
|
||||
}
|
||||
return layoutStickyTop;
|
||||
}, [layoutStickyTop, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
|
||||
return windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS ? 95 : 65;
|
||||
}, [windowBarStyle, shouldShowStickyHeader, stickyHeaderTop, headerHeight]);
|
||||
|
||||
// Calculate group row indexes
|
||||
const groupRowIndexes = useMemo(() => {
|
||||
|
||||
+18
-12
@@ -1,8 +1,9 @@
|
||||
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,
|
||||
@@ -11,7 +12,6 @@ export const useStickyTableHeader = ({
|
||||
pinnedLeftColumnRef,
|
||||
pinnedRightColumnRef,
|
||||
stickyHeaderMainRef,
|
||||
stickyLayout,
|
||||
}: {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
enabled: boolean;
|
||||
@@ -20,9 +20,8 @@ export const useStickyTableHeader = ({
|
||||
pinnedLeftColumnRef?: RefObject<HTMLDivElement | null>;
|
||||
pinnedRightColumnRef?: RefObject<HTMLDivElement | null>;
|
||||
stickyHeaderMainRef?: RefObject<HTMLDivElement | null>;
|
||||
stickyLayout: ItemTableStickyLayoutOffsets;
|
||||
}) => {
|
||||
const { inViewMarginTop, stickyTop } = stickyLayout;
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
const isScrollingRef = useRef({
|
||||
main: false,
|
||||
pinnedLeft: false,
|
||||
@@ -30,20 +29,27 @@ export const useStickyTableHeader = ({
|
||||
stickyHeader: false,
|
||||
});
|
||||
|
||||
const inViewRootMargin = `${inViewMarginTop}px 0px 0px 0px`;
|
||||
const topMargin =
|
||||
windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS
|
||||
? '-130px'
|
||||
: '-100px';
|
||||
|
||||
const inViewOptions = { margin: inViewRootMargin } as {
|
||||
margin: NonNullable<Parameters<typeof useInView>[1]>['margin'];
|
||||
};
|
||||
const isTableHeaderInView = useInView(headerRef, {
|
||||
margin: `${topMargin} 0px 0px 0px`,
|
||||
});
|
||||
|
||||
const isTableHeaderInView = useInView(headerRef, inViewOptions);
|
||||
|
||||
const isTableInView = useInView(containerRef, inViewOptions);
|
||||
const isTableInView = useInView(containerRef, {
|
||||
margin: `${topMargin} 0px 0px 0px`,
|
||||
});
|
||||
|
||||
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,6 +19,7 @@ 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';
|
||||
@@ -81,6 +82,7 @@ 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;
|
||||
@@ -133,7 +135,7 @@ const ItemTableListColumnBase = (props: ItemTableListColumn) => {
|
||||
item,
|
||||
itemType: props.itemType,
|
||||
playerContext: props.playerContext,
|
||||
playlistId: props.playlistId,
|
||||
playlistId,
|
||||
});
|
||||
|
||||
const controls = props.controls;
|
||||
@@ -360,7 +362,6 @@ 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,51 +1,31 @@
|
||||
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: var(--item-table-sticky-top-default);
|
||||
top: 65px;
|
||||
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: var(--item-table-sticky-top-win-mac);
|
||||
top: 95px;
|
||||
}
|
||||
|
||||
.item-table-list-container.header-fixed-margin {
|
||||
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
@@ -31,7 +30,6 @@ 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';
|
||||
@@ -45,7 +43,6 @@ 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';
|
||||
@@ -107,11 +104,27 @@ 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>;
|
||||
@@ -121,10 +134,13 @@ interface VirtualizedTableGridProps {
|
||||
pinnedRightColumnRef: React.RefObject<HTMLDivElement | null>;
|
||||
pinnedRowCount: number;
|
||||
pinnedRowRef: React.RefObject<HTMLDivElement | null>;
|
||||
playerContext: PlayerContext;
|
||||
showLeftShadow: boolean;
|
||||
showRightShadow: boolean;
|
||||
showTopShadow: boolean;
|
||||
tableConfig: ItemTableListConfig;
|
||||
size: 'compact' | 'default' | 'large';
|
||||
startRowIndex?: number;
|
||||
tableId: string;
|
||||
totalColumnCount: number;
|
||||
totalRowCount: number;
|
||||
}
|
||||
@@ -132,11 +148,27 @@ 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,
|
||||
@@ -146,14 +178,16 @@ const VirtualizedTableGrid = ({
|
||||
pinnedRightColumnRef,
|
||||
pinnedRowCount,
|
||||
pinnedRowRef,
|
||||
playerContext,
|
||||
showLeftShadow,
|
||||
showRightShadow,
|
||||
showTopShadow,
|
||||
tableConfig,
|
||||
size,
|
||||
startRowIndex,
|
||||
tableId,
|
||||
totalColumnCount,
|
||||
totalRowCount,
|
||||
}: VirtualizedTableGridProps) => {
|
||||
const { enableHeader, enableRowHoverHighlight, getRowHeight, groups } = tableConfig;
|
||||
const hoverDelegateRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useRowInteractionDelegate({
|
||||
@@ -311,7 +345,35 @@ const VirtualizedTableGrid = ({
|
||||
],
|
||||
);
|
||||
|
||||
const gridOnlyProps = useMemo(
|
||||
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(
|
||||
() => ({
|
||||
calculatedColumnWidths,
|
||||
data: dataWithGroups,
|
||||
@@ -319,11 +381,11 @@ const VirtualizedTableGrid = ({
|
||||
getGroupRenderData,
|
||||
getRowItem,
|
||||
groupHeaderInfoByRowIndex,
|
||||
hasAlbumGroupColumn: parsedColumns.some((col) => col.id === TableColumn.ALBUM_GROUP),
|
||||
pinnedLeftColumnCount,
|
||||
pinnedLeftColumnWidths,
|
||||
pinnedRightColumnCount,
|
||||
pinnedRightColumnWidths,
|
||||
startRowIndex,
|
||||
}),
|
||||
[
|
||||
calculatedColumnWidths,
|
||||
@@ -332,68 +394,50 @@ 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(
|
||||
() => ({
|
||||
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],
|
||||
[stableConfigProps, dynamicDataProps, featureFlags],
|
||||
);
|
||||
|
||||
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 (
|
||||
@@ -403,14 +447,16 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pinnedLeftColumnCount, CellComponent],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pinnedLeftColumnCount, CellComponent, featureFlags, calculatedColumnWidths],
|
||||
);
|
||||
|
||||
const PinnedColumnCell = useCallback(
|
||||
(cellProps: CellComponentProps & TableItemProps) => {
|
||||
return <CellComponent {...cellProps} rowIndex={cellProps.rowIndex + pinnedRowCount} />;
|
||||
},
|
||||
[pinnedRowCount, CellComponent],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[pinnedRowCount, CellComponent, featureFlags, calculatedColumnWidths],
|
||||
);
|
||||
|
||||
const PinnedRightColumnCell = useCallback(
|
||||
@@ -423,7 +469,15 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pinnedLeftColumnCount, pinnedRowCount, totalColumnCount, CellComponent],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRowCount,
|
||||
totalColumnCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
);
|
||||
|
||||
const PinnedRightIntersectionCell = useCallback(
|
||||
@@ -435,7 +489,14 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pinnedLeftColumnCount, totalColumnCount, CellComponent],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
totalColumnCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
);
|
||||
|
||||
const RowCell = useCallback(
|
||||
@@ -448,7 +509,14 @@ const VirtualizedTableGrid = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[pinnedLeftColumnCount, pinnedRowCount, CellComponent],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRowCount,
|
||||
CellComponent,
|
||||
featureFlags,
|
||||
calculatedColumnWidths,
|
||||
],
|
||||
);
|
||||
|
||||
const handleOnCellsRendered = useCallback(
|
||||
@@ -473,7 +541,10 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minWidth: `${pinnedLeftGridMinWidthPx}px`,
|
||||
minWidth: `${Array.from({ length: pinnedLeftColumnCount }, () => 0).reduce(
|
||||
(a, _, i) => a + columnWidth(i),
|
||||
0,
|
||||
)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -483,7 +554,10 @@ const VirtualizedTableGrid = ({
|
||||
[styles.withHeader]: enableHeader,
|
||||
})}
|
||||
style={{
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
minHeight: `${Array.from({ length: pinnedRowCount }, () => 0).reduce(
|
||||
(a, _, i) => a + getRowHeight(i, itemProps),
|
||||
0,
|
||||
)}px`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
@@ -537,7 +611,10 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
minHeight: `${Array.from(
|
||||
{ length: pinnedRowCount },
|
||||
() => 0,
|
||||
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
|
||||
overflow: 'hidden',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
@@ -550,7 +627,7 @@ const VirtualizedTableGrid = ({
|
||||
columnWidth={(index) => {
|
||||
return columnWidth(index + pinnedLeftColumnCount);
|
||||
}}
|
||||
rowCount={pinnedRowCount}
|
||||
rowCount={Array.from({ length: pinnedRowCount }, () => 0).length}
|
||||
rowHeight={getRowHeight}
|
||||
/>
|
||||
</div>
|
||||
@@ -583,7 +660,14 @@ const VirtualizedTableGrid = ({
|
||||
style={
|
||||
{
|
||||
'--header-height': `${headerHeight}px`,
|
||||
minWidth: `${pinnedRightGridMinWidthPx}px`,
|
||||
minWidth: `${Array.from(
|
||||
{ length: pinnedRightColumnCount },
|
||||
() => 0,
|
||||
).reduce(
|
||||
(a, _, i) =>
|
||||
a + columnWidth(i + pinnedLeftColumnCount + totalColumnCount),
|
||||
0,
|
||||
)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
@@ -593,7 +677,10 @@ const VirtualizedTableGrid = ({
|
||||
[styles.withHeader]: enableHeader,
|
||||
})}
|
||||
style={{
|
||||
minHeight: `${pinnedRowsMinHeightPx}px`,
|
||||
minHeight: `${Array.from(
|
||||
{ length: pinnedRowCount },
|
||||
() => 0,
|
||||
).reduce((a, _, i) => a + getRowHeight(i, itemProps), 0)}px`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
@@ -652,12 +739,27 @@ const MemoizedVirtualizedTableGrid = memo(VirtualizedTableGrid, (prevProps, next
|
||||
prevProps.calculatedColumnWidths,
|
||||
nextProps.calculatedColumnWidths,
|
||||
) &&
|
||||
prevProps.tableConfig === nextProps.tableConfig &&
|
||||
prevProps.cellPadding === nextProps.cellPadding &&
|
||||
prevProps.controls === nextProps.controls &&
|
||||
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 &&
|
||||
@@ -667,9 +769,13 @@ 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
|
||||
@@ -722,7 +828,6 @@ export interface TableItemProps {
|
||||
pinnedRightColumnCount?: number;
|
||||
pinnedRightColumnWidths?: number[];
|
||||
playerContext: PlayerContext;
|
||||
playlistId?: string;
|
||||
size?: ItemTableListProps['size'];
|
||||
startRowIndex?: number;
|
||||
tableId: string;
|
||||
@@ -830,8 +935,6 @@ 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,
|
||||
@@ -840,7 +943,6 @@ const ItemTableListStickyUI = memo(
|
||||
pinnedLeftColumnRef,
|
||||
pinnedRightColumnRef,
|
||||
stickyHeaderMainRef,
|
||||
stickyLayout,
|
||||
});
|
||||
|
||||
useStickyHeaderPositioning({
|
||||
@@ -862,7 +964,6 @@ const ItemTableListStickyUI = memo(
|
||||
mainGridRef: rowRef,
|
||||
shouldShowStickyHeader,
|
||||
stickyHeaderTop: stickyTop,
|
||||
stickyLayout,
|
||||
});
|
||||
|
||||
const shouldRenderStickyGroupRow = shouldShowStickyGroupRow;
|
||||
@@ -1208,7 +1309,6 @@ 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;
|
||||
@@ -1474,7 +1574,6 @@ const BaseItemTableList = ({
|
||||
pinnedLeftColumnCount + totalColumnCount,
|
||||
),
|
||||
playerContext,
|
||||
playlistId: routePlaylistId,
|
||||
size,
|
||||
tableId,
|
||||
}),
|
||||
@@ -1500,7 +1599,6 @@ const BaseItemTableList = ({
|
||||
pinnedLeftColumnCount,
|
||||
pinnedRightColumnCount,
|
||||
playerContext,
|
||||
routePlaylistId,
|
||||
size,
|
||||
tableId,
|
||||
totalColumnCount,
|
||||
@@ -1514,27 +1612,17 @@ const BaseItemTableList = ({
|
||||
itemType,
|
||||
});
|
||||
|
||||
const tableConfigValue = useMemo<ItemTableListConfig>(
|
||||
const tableConfigValue = useMemo(
|
||||
() => ({
|
||||
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,
|
||||
@@ -1543,22 +1631,12 @@ const BaseItemTableList = ({
|
||||
cellPadding,
|
||||
parsedColumns,
|
||||
controls,
|
||||
enableAlternateRowColors,
|
||||
onColumnReordered,
|
||||
onColumnResized,
|
||||
enableDrag,
|
||||
enableExpansion,
|
||||
enableHeader,
|
||||
enableHorizontalBorders,
|
||||
enableRowHoverHighlight,
|
||||
enableSelection,
|
||||
enableVerticalBorders,
|
||||
getRowHeight,
|
||||
groups,
|
||||
internalState,
|
||||
itemType,
|
||||
playerContext,
|
||||
routePlaylistId,
|
||||
size,
|
||||
startRowIndex,
|
||||
tableId,
|
||||
@@ -1629,11 +1707,27 @@ 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}
|
||||
@@ -1643,10 +1737,13 @@ const BaseItemTableList = ({
|
||||
pinnedRightColumnRef={pinnedRightColumnRef}
|
||||
pinnedRowCount={pinnedRowCount}
|
||||
pinnedRowRef={pinnedRowRef}
|
||||
playerContext={playerContext}
|
||||
showLeftShadow={showLeftShadow}
|
||||
showRightShadow={showRightShadow}
|
||||
showTopShadow={showTopShadow}
|
||||
tableConfig={tableConfigValue}
|
||||
size={size}
|
||||
startRowIndex={startRowIndex}
|
||||
tableId={tableId}
|
||||
totalColumnCount={totalColumnCount}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import { CellComponentProps } from 'react-window-v2';
|
||||
|
||||
import { createColumnCellComponents } from './cell-component-factory';
|
||||
@@ -24,7 +24,24 @@ const MemoizedCellRouterBase = (props: MemoizedCellRouterProps) => {
|
||||
return <ItemTableListColumn {...props} />;
|
||||
};
|
||||
|
||||
export const MemoizedCellRouter = MemoizedCellRouterBase;
|
||||
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 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 { formatDurationString, formatPartialIsoDateUTC, formatSizeString } from '/@/renderer/utils';
|
||||
import { formatDateAbsoluteUTC, formatDurationString, 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,10 +131,7 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
const originalDifferentFromRelease =
|
||||
album?.originalDate && album?.originalDate !== album?.releaseDate;
|
||||
|
||||
const originalYearDifferentFromRelease =
|
||||
album.originalYear > 0 &&
|
||||
album.releaseYear != null &&
|
||||
album.originalYear !== album.releaseYear;
|
||||
const originalYearDifferentFromRelease = album?.originalYear !== album?.releaseYear;
|
||||
|
||||
const playCount = album?.playCount;
|
||||
|
||||
@@ -150,17 +147,17 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
if (originalDifferentFromRelease) {
|
||||
items.push({
|
||||
id: 'originalDate',
|
||||
value: `♫ ${formatPartialIsoDateUTC(album.originalDate)}`,
|
||||
value: `♫ ${formatDateAbsoluteUTC(album.originalDate)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (releaseDate) {
|
||||
items.push({
|
||||
id: 'releaseDate',
|
||||
value: `${releasePrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
|
||||
value: `${releasePrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
|
||||
});
|
||||
}
|
||||
} else if (album.originalYear > 0) {
|
||||
} else if (album.originalYear) {
|
||||
if (originalYearDifferentFromRelease) {
|
||||
items.push({
|
||||
id: 'originalYear',
|
||||
@@ -171,24 +168,14 @@ export const AlbumDetailHeader = forwardRef<HTMLDivElement>((_props, ref) => {
|
||||
if (releaseDate) {
|
||||
items.push({
|
||||
id: 'releaseDate',
|
||||
value: `${releaseYearPrefix} ${formatPartialIsoDateUTC(releaseDate)}`,
|
||||
value: `${releaseYearPrefix} ${formatDateAbsoluteUTC(releaseDate)}`,
|
||||
});
|
||||
} else if (releaseYear != null && releaseYear > 0) {
|
||||
} else if (releaseYear) {
|
||||
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,10 +17,9 @@ 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);
|
||||
@@ -43,21 +42,25 @@ const AlbumDetailRoute = () => {
|
||||
type: 'itemCard',
|
||||
}) || '';
|
||||
|
||||
const { background: backgroundColor } = useFastAverageColor({
|
||||
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({
|
||||
id: albumId,
|
||||
src: imageUrl,
|
||||
srcLoaded: true,
|
||||
});
|
||||
|
||||
const background = backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK;
|
||||
const background = backgroundColor;
|
||||
|
||||
const showBlurredImage = albumBackground;
|
||||
|
||||
if (isColorLoading) {
|
||||
return <Spinner container />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatedPage key={`album-detail-${albumId}`}>
|
||||
<NativeScrollArea
|
||||
pageHeaderProps={{
|
||||
backgroundColor: backgroundColor ?? ALBUM_DETAIL_BG_FALLBACK,
|
||||
backgroundColor: backgroundColor || undefined,
|
||||
children: (
|
||||
<LibraryHeaderBar>
|
||||
<LibraryHeaderBar.PlayButton
|
||||
|
||||
@@ -189,15 +189,13 @@ 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={hasImageId ? undefined : fallbackHeaderImageUrl}
|
||||
imageUrl={alternateImageUrl || selectedImageUrl}
|
||||
item={{
|
||||
imageId: detailQuery.data?.imageId,
|
||||
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl,
|
||||
imageUrl: alternateImageUrl || detailQuery.data?.imageUrl,
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
}}
|
||||
|
||||
@@ -216,10 +216,6 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (playerStatus !== PlayerStatus.PLAYING) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateProgress = async () => {
|
||||
if (!mpvPlayer || !isMountedRef.current) {
|
||||
return;
|
||||
@@ -249,7 +245,7 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => {
|
||||
progressIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasCurrentSong, isTransitioning, onProgress, playerStatus]);
|
||||
}, [hasCurrentSong, isTransitioning, onProgress]);
|
||||
|
||||
const { mediaAutoNext } = usePlayerActions();
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useMainPlayerListener = () => {
|
||||
|
||||
mpvPlayerListener.rendererStop(() => {
|
||||
if (!isRadioActive) {
|
||||
mediaStop({ reset: false });
|
||||
mediaStop();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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';
|
||||
@@ -81,8 +80,6 @@ function detectBrowserProfile() {
|
||||
}
|
||||
}
|
||||
|
||||
logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES });
|
||||
|
||||
return DIRECT_PLAY_PROFILES;
|
||||
}
|
||||
|
||||
@@ -99,6 +96,7 @@ export const AudioPlayers = () => {
|
||||
const { setWebAudio, webAudio: audioContext } = useWebAudio();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('getDirectPlayProfiles');
|
||||
detectBrowserProfile();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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,10 +11,7 @@ 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,
|
||||
useRadioPlayer,
|
||||
} from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useAppStore,
|
||||
@@ -53,11 +50,9 @@ 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;
|
||||
@@ -133,22 +128,7 @@ export const LeftControls = () => {
|
||||
})}
|
||||
openDelay={0}
|
||||
>
|
||||
{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 ? (
|
||||
{isRadioMode ? (
|
||||
<Center
|
||||
className={clsx(
|
||||
styles.playerbarImage,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
width: 100vw;
|
||||
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: (options?: { reset?: boolean }) => void;
|
||||
mediaStop: () => void;
|
||||
mediaToggleMute: () => void;
|
||||
mediaTogglePlayPause: () => void;
|
||||
moveSelectedTo: (items: QueueSong[], edge: 'bottom' | 'top', uniqueId: string) => void;
|
||||
@@ -596,17 +596,13 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
storeActions.mediaPrevious();
|
||||
}, [storeActions]);
|
||||
|
||||
const mediaStop = useCallback(
|
||||
(options?: { reset?: boolean }) => {
|
||||
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
|
||||
category: LogCategory.PLAYER,
|
||||
meta: { reset: options?.reset },
|
||||
});
|
||||
const mediaStop = useCallback(() => {
|
||||
logFn.debug(logMsg[LogCategory.PLAYER].mediaStop, {
|
||||
category: LogCategory.PLAYER,
|
||||
});
|
||||
|
||||
storeActions.mediaStop(options);
|
||||
},
|
||||
[storeActions],
|
||||
);
|
||||
storeActions.mediaStop();
|
||||
}, [storeActions]);
|
||||
|
||||
const mediaSeekToTimestamp = useCallback(
|
||||
(timestamp: number) => {
|
||||
|
||||
@@ -72,7 +72,6 @@ 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,8 +8,6 @@ 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,
|
||||
@@ -20,17 +18,9 @@ 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, Playlist, Song } from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { LibraryItem, Song } from '/@/shared/types/domain-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface PlaylistDetailSongListHeaderProps {
|
||||
@@ -40,64 +30,6 @@ 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) => {
|
||||
@@ -113,7 +45,6 @@ export const PlaylistDetailSongListHeader = ({
|
||||
});
|
||||
|
||||
const playlistDuration = detailQuery?.data?.duration;
|
||||
const playlistDescription = detailQuery?.data?.description?.trim();
|
||||
|
||||
const [collapsed] = useLocalStorage<boolean>({
|
||||
defaultValue: false,
|
||||
@@ -163,7 +94,6 @@ export const PlaylistDetailSongListHeader = ({
|
||||
) : (
|
||||
<LibraryHeader
|
||||
compact
|
||||
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
|
||||
imageUrl={imageUrl}
|
||||
item={{
|
||||
imageId: detailQuery?.data?.imageId,
|
||||
@@ -174,32 +104,10 @@ export const PlaylistDetailSongListHeader = ({
|
||||
title={detailQuery?.data?.name || ''}
|
||||
topRight={<ListSearchInput />}
|
||||
>
|
||||
<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>
|
||||
<LibraryHeaderMenu
|
||||
onPlay={(type) => handlePlay(type)}
|
||||
onShuffle={() => handlePlay(Play.SHUFFLE)}
|
||||
/>
|
||||
</LibraryHeader>
|
||||
)}
|
||||
<FilterBar>
|
||||
|
||||
@@ -32,7 +32,6 @@ 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';
|
||||
@@ -52,7 +51,6 @@ type DeleteArgs = {
|
||||
|
||||
interface PlaylistQueryBuilderProps {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
playlistId?: string;
|
||||
query: any;
|
||||
sortBy: SongListSort | SongListSort[];
|
||||
@@ -157,7 +155,6 @@ export type PlaylistQueryBuilderRef = {
|
||||
getFilters: () => {
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
};
|
||||
@@ -167,7 +164,7 @@ export type PlaylistQueryBuilderRef = {
|
||||
|
||||
export const PlaylistQueryBuilder = forwardRef(
|
||||
(
|
||||
{ limit, limitPercent, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
||||
{ limit, playlistId, query, sortBy, sortOrder }: PlaylistQueryBuilderProps,
|
||||
ref: Ref<PlaylistQueryBuilderRef>,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -216,8 +213,6 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
const extraFiltersForm = useForm({
|
||||
initialValues: {
|
||||
limit,
|
||||
limitMode: limitPercent != null ? 'limitPercent' : 'limit',
|
||||
limitPercent,
|
||||
sortEntries: initialSortEntries,
|
||||
},
|
||||
});
|
||||
@@ -229,26 +224,16 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
const sortString = convertSortEntriesToSortString(
|
||||
extraFiltersForm.values.sortEntries,
|
||||
);
|
||||
const isLimitPercent = extraFiltersForm.values.limitMode === 'limitPercent';
|
||||
return {
|
||||
extraFilters: {
|
||||
limit: isLimitPercent ? undefined : extraFiltersForm.values.limit,
|
||||
limitPercent: isLimitPercent
|
||||
? extraFiltersForm.values.limitPercent
|
||||
: undefined,
|
||||
limit: extraFiltersForm.values.limit,
|
||||
sortBy: sortString ? [sortString] : undefined,
|
||||
},
|
||||
filters,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[
|
||||
extraFiltersForm.values.sortEntries,
|
||||
extraFiltersForm.values.limit,
|
||||
extraFiltersForm.values.limitMode,
|
||||
extraFiltersForm.values.limitPercent,
|
||||
filters,
|
||||
],
|
||||
[extraFiltersForm.values.sortEntries, extraFiltersForm.values.limit, filters],
|
||||
);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
@@ -623,50 +608,10 @@ export const PlaylistQueryBuilder = forwardRef(
|
||||
))}
|
||||
</Stack>
|
||||
<NumberInput
|
||||
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
|
||||
}
|
||||
label={t('common.limit', { postProcess: 'titleCase' })}
|
||||
maxWidth="20%"
|
||||
width={75}
|
||||
{...extraFiltersForm.getInputProps('limit')}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -28,21 +28,11 @@ export interface PlaylistQueryEditorProps {
|
||||
detailQuery: ReturnType<typeof useQuery<any>>;
|
||||
handleSave: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
handleSaveAs: (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => void;
|
||||
isQueryBuilderExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
@@ -53,7 +43,6 @@ export interface PlaylistQueryEditorProps {
|
||||
|
||||
type AppliedJsonState = {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
query: Record<string, any>;
|
||||
sort?: string;
|
||||
};
|
||||
@@ -61,7 +50,7 @@ type AppliedJsonState = {
|
||||
type EditorMode = 'builder' | 'json';
|
||||
|
||||
const serializeFiltersToRulesJson = (filters: {
|
||||
extraFilters: { limit?: number; limitPercent?: number; sortBy?: string[] };
|
||||
extraFilters: { limit?: number; sortBy?: string[] };
|
||||
filters: any;
|
||||
}): Record<string, any> => {
|
||||
const queryValue = convertQueryGroupToNDQuery(filters.filters);
|
||||
@@ -69,25 +58,18 @@ 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; limitPercent?: number; sortBy?: string[] };
|
||||
filter: Record<string, any>;
|
||||
} => {
|
||||
): { extraFilters: { limit?: 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,
|
||||
@@ -111,12 +93,7 @@ export const PlaylistQueryEditor = ({
|
||||
const [appliedJsonState, setAppliedJsonState] = useState<AppliedJsonState | null>(null);
|
||||
|
||||
const getFiltersForSave = useCallback((): null | {
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
};
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string };
|
||||
filter: Record<string, any>;
|
||||
} => {
|
||||
if (editorMode === 'json') {
|
||||
@@ -147,9 +124,6 @@ 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({
|
||||
@@ -234,8 +208,6 @@ 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
|
||||
@@ -261,8 +233,6 @@ 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));
|
||||
@@ -278,7 +248,6 @@ export const PlaylistQueryEditor = ({
|
||||
}
|
||||
setAppliedJsonState({
|
||||
limit: parsed.limit,
|
||||
limitPercent: parsed.limitPercent,
|
||||
query: { [rootKey]: parsed[rootKey] },
|
||||
sort: parsed.sort,
|
||||
});
|
||||
@@ -294,16 +263,7 @@ export const PlaylistQueryEditor = ({
|
||||
setEditorMode('builder');
|
||||
}
|
||||
},
|
||||
[
|
||||
editorMode,
|
||||
effectiveLimit,
|
||||
effectiveLimitPercent,
|
||||
effectiveQuery,
|
||||
effectiveSortBy,
|
||||
jsonText,
|
||||
queryBuilderRef,
|
||||
t,
|
||||
],
|
||||
[editorMode, effectiveLimit, effectiveQuery, effectiveSortBy, jsonText, queryBuilderRef, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -407,7 +367,6 @@ export const PlaylistQueryEditor = ({
|
||||
<PlaylistQueryBuilder
|
||||
key={JSON.stringify(appliedJsonState ?? detailQuery?.data?.rules)}
|
||||
limit={effectiveLimit}
|
||||
limitPercent={effectiveLimitPercent}
|
||||
playlistId={playlistId}
|
||||
query={effectiveQuery}
|
||||
ref={queryBuilderRef}
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
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,
|
||||
@@ -34,41 +24,17 @@ 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 updateMutation = useUpdatePlaylist({});
|
||||
const uploadImageMutation = useUploadPlaylistImage({});
|
||||
const deleteImageMutation = useDeletePlaylistImage({});
|
||||
const mutation = useUpdatePlaylist({});
|
||||
const server = useCurrentServer();
|
||||
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 { body, query } = innerProps;
|
||||
|
||||
const form = useForm<UpdatePlaylistBody>({
|
||||
initialValues: {
|
||||
@@ -81,259 +47,91 @@ export const UpdatePlaylistContextModal = ({
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
if (!server?.id) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
apiClientProps: { serverId: server.id },
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
mutation.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: server?.id || '' },
|
||||
body: values,
|
||||
query,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
{
|
||||
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);
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const isPublicDisplayed = hasFeature(server, ServerFeature.PUBLIC_PLAYLIST);
|
||||
const isOwnerDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
const isCommentDisplayed = server?.type === ServerType.NAVIDROME;
|
||||
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>,
|
||||
);
|
||||
const isSubmitDisabled = !form.values.name || mutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{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}
|
||||
<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')}
|
||||
/>
|
||||
<Stack gap="md" style={{ flex: '1 1 220px', minWidth: 0 }}>
|
||||
{fieldNodes}
|
||||
</Stack>
|
||||
</Flex>
|
||||
) : (
|
||||
<Stack gap="md">{fieldNodes}</Stack>
|
||||
)}
|
||||
)}
|
||||
{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>
|
||||
</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,17 +1,11 @@
|
||||
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: {
|
||||
@@ -23,15 +17,9 @@ 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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
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,12 +85,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const handleSave = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
@@ -101,8 +96,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit ?? undefined,
|
||||
limitPercent: extraFilters.limitPercent ?? undefined,
|
||||
limit: extraFilters.limit || undefined,
|
||||
sort: sortValue,
|
||||
};
|
||||
|
||||
@@ -129,12 +123,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const handleSaveAs = (
|
||||
filter: Record<string, any>,
|
||||
extraFilters: {
|
||||
limit?: number;
|
||||
limitPercent?: number;
|
||||
sortBy?: string[];
|
||||
sortOrder?: string;
|
||||
},
|
||||
extraFilters: { limit?: number; sortBy?: string[]; sortOrder?: string },
|
||||
) => {
|
||||
if (!detailQuery?.data) return;
|
||||
|
||||
@@ -145,8 +134,7 @@ const PlaylistDetailSongListRoute = () => {
|
||||
|
||||
const rules = {
|
||||
...filter,
|
||||
limit: extraFilters.limit ?? undefined,
|
||||
limitPercent: extraFilters.limitPercent ?? undefined,
|
||||
limit: extraFilters.limit || undefined,
|
||||
sort: sortValue,
|
||||
};
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export function playlistSongsToAlbums(songs: Song[]): PlaylistAlbumRow[] {
|
||||
mbzReleaseGroupId: null,
|
||||
name: song.album ?? '',
|
||||
originalDate: null,
|
||||
originalYear: 0,
|
||||
originalYear: null,
|
||||
participants: song.participants,
|
||||
playCount: null,
|
||||
recordLabels: [],
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { t } from 'i18next';
|
||||
import { MouseEvent, type ReactNode, useEffect, useState } from 'react';
|
||||
import { MouseEvent } 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';
|
||||
@@ -23,51 +15,19 @@ 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 updateMutation = useUpdateRadioStation({});
|
||||
const uploadImageMutation = useUploadInternetRadioStationImage({});
|
||||
const deleteImageMutation = useDeleteInternetRadioStationImage({});
|
||||
const mutation = useUpdateRadioStation({});
|
||||
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: {
|
||||
@@ -77,234 +37,74 @@ export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationForm
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
if (!server?.id) return;
|
||||
const handleSubmit = form.onSubmit((values) => {
|
||||
if (!server) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
mutation.mutate(
|
||||
{
|
||||
apiClientProps: { serverId: server.id },
|
||||
body: values,
|
||||
query: { id: station.id },
|
||||
});
|
||||
},
|
||||
{
|
||||
onError: (error) => {
|
||||
logFn.error(logMsg.other.error, {
|
||||
meta: { error: error as Error },
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
toast.error({
|
||||
message: (error as Error).message,
|
||||
title: t('error.genericError', {
|
||||
postProcess: 'sentenceCase',
|
||||
}) as string,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
closeAllModals();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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}>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</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,
|
||||
@@ -319,11 +119,8 @@ 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,55 +20,11 @@
|
||||
|
||||
.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,7 +4,6 @@ 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,
|
||||
@@ -13,15 +12,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, LibraryItem } from '/@/shared/types/domain-types';
|
||||
import { InternetRadioStation } from '/@/shared/types/domain-types';
|
||||
|
||||
interface RadioListItemProps {
|
||||
station: InternetRadioStation;
|
||||
@@ -45,13 +44,8 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
|
||||
const handleClick = () => {
|
||||
if (stationIsPlaying) {
|
||||
stop();
|
||||
} else if (server?.id) {
|
||||
play(station.streamUrl, station.name, {
|
||||
id: station.id,
|
||||
imageId: station.imageId,
|
||||
imageUrl: station.imageUrl,
|
||||
serverId: server.id,
|
||||
});
|
||||
} else {
|
||||
play(station.streamUrl, station.name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,39 +107,27 @@ const RadioListItem = ({ station }: RadioListItemProps) => {
|
||||
})}
|
||||
p="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}>
|
||||
<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" />
|
||||
<Text fw={500} size="md">
|
||||
{station.name}
|
||||
</Text>
|
||||
<Text className={styles['meta-line']} isMuted size="sm">
|
||||
{station.streamUrl}
|
||||
</Group>
|
||||
<Text isMuted size="sm">
|
||||
{station.streamUrl}
|
||||
</Text>
|
||||
{station.homepageUrl && (
|
||||
<Text isMuted size="sm">
|
||||
{station.homepageUrl}
|
||||
</Text>
|
||||
{station.homepageUrl ? (
|
||||
<Text className={styles['meta-line']} isMuted size="sm">
|
||||
{station.homepageUrl}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</button>
|
||||
{(permissions.radio.edit || permissions.radio.delete) && (
|
||||
<Group className={styles['radio-item-actions']} gap="xs">
|
||||
<Group gap="xs">
|
||||
{permissions.radio.edit && (
|
||||
<ActionIcon
|
||||
icon="edit"
|
||||
|
||||
@@ -7,13 +7,6 @@ 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;
|
||||
@@ -22,18 +15,13 @@ export interface RadioMetadata {
|
||||
interface RadioStore {
|
||||
actions: {
|
||||
pause: () => void;
|
||||
play: (
|
||||
streamUrl?: string,
|
||||
stationName?: string,
|
||||
stationArt?: null | RadioCurrentStationArt,
|
||||
) => void;
|
||||
play: (streamUrl?: string, stationName?: string) => 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;
|
||||
@@ -46,11 +34,7 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
|
||||
set({ isPlaying: false });
|
||||
usePlayerStoreBase.getState().mediaPause();
|
||||
},
|
||||
play: (
|
||||
streamUrl?: string,
|
||||
stationName?: string,
|
||||
stationArt?: null | RadioCurrentStationArt,
|
||||
) => {
|
||||
play: (streamUrl?: string, stationName?: string) => {
|
||||
set((state) => {
|
||||
const newStreamUrl = streamUrl ?? state.currentStreamUrl;
|
||||
const newStationName = stationName ?? state.stationName;
|
||||
@@ -59,19 +43,12 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
|
||||
return state;
|
||||
}
|
||||
|
||||
const streamUrlExplicit = streamUrl !== undefined;
|
||||
const isSwitchingStation =
|
||||
streamUrlExplicit && streamUrl !== state.currentStreamUrl;
|
||||
|
||||
let nextStationArt = state.currentStationArt;
|
||||
if (isSwitchingStation) {
|
||||
nextStationArt = stationArt ?? null;
|
||||
}
|
||||
// Reset metadata when switching stations (streamUrl changes)
|
||||
const isSwitchingStation = newStreamUrl !== state.currentStreamUrl;
|
||||
|
||||
usePlayerStoreBase.getState().mediaPlay();
|
||||
|
||||
return {
|
||||
currentStationArt: nextStationArt,
|
||||
currentStreamUrl: newStreamUrl,
|
||||
isPlaying: true,
|
||||
metadata: isSwitchingStation ? null : state.metadata,
|
||||
@@ -87,7 +64,6 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
|
||||
const playbackType = useSettingsStore.getState().playback.type;
|
||||
|
||||
set({
|
||||
currentStationArt: null,
|
||||
currentStreamUrl: null,
|
||||
isPlaying: false,
|
||||
metadata: null,
|
||||
@@ -103,7 +79,6 @@ export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
|
||||
}
|
||||
},
|
||||
},
|
||||
currentStationArt: null,
|
||||
currentStreamUrl: null,
|
||||
isPlaying: false,
|
||||
metadata: null,
|
||||
@@ -115,14 +90,12 @@ 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,
|
||||
@@ -190,7 +163,6 @@ export const useRadioAudioInstance = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentStreamUrl(null);
|
||||
setStationName(null);
|
||||
useRadioStore.setState({ currentStationArt: null, metadata: null });
|
||||
};
|
||||
|
||||
mpvPlayerListener.rendererPlay(handleMpvPlay);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
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,7 +112,6 @@
|
||||
}
|
||||
|
||||
.image-section {
|
||||
position: relative;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
grid-area: image;
|
||||
@@ -125,21 +124,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.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,7 +35,6 @@ interface LibraryHeaderProps {
|
||||
children?: ReactNode;
|
||||
compact?: boolean;
|
||||
containerClassName?: string;
|
||||
imageOverlay?: ReactNode;
|
||||
imagePlaceholderUrl?: null | string;
|
||||
imageUrl?: null | string;
|
||||
item: {
|
||||
@@ -57,7 +56,6 @@ export const LibraryHeader = forwardRef(
|
||||
children,
|
||||
compact,
|
||||
containerClassName,
|
||||
imageOverlay,
|
||||
imageUrl,
|
||||
item,
|
||||
title,
|
||||
@@ -170,16 +168,6 @@ 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 { currentStationArt, isPlaying: isRadioPlaying } = useRadioPlayer();
|
||||
const { isPlaying: isRadioPlaying } = useRadioPlayer();
|
||||
const { blurExplicitImages } = useGeneralSettings();
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
@@ -177,14 +177,6 @@ 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);
|
||||
|
||||
@@ -232,9 +224,7 @@ const SidebarImage = () => {
|
||||
postProcess: 'sentenceCase',
|
||||
})}
|
||||
>
|
||||
{isRadioActive && radioImageUrl ? (
|
||||
<img className={styles.sidebarImage} loading="eager" src={radioImageUrl} />
|
||||
) : isRadioActive ? (
|
||||
{isPlayingRadio ? (
|
||||
<Center
|
||||
className={styles.sidebarImage}
|
||||
style={{
|
||||
|
||||
@@ -7,23 +7,15 @@ 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;
|
||||
}
|
||||
|
||||
startInterval();
|
||||
intervalIdRef.current = setInterval(() => {
|
||||
window.api?.utils?.forceGarbageCollection?.();
|
||||
}, GARBAGE_COLLECTION_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (intervalIdRef.current) {
|
||||
@@ -46,6 +38,5 @@ export const useGarbageCollection = () => {
|
||||
}
|
||||
|
||||
window.api?.utils?.forceGarbageCollection?.();
|
||||
startInterval();
|
||||
}, [location]);
|
||||
};
|
||||
|
||||
@@ -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, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, 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, useCurrentServerId } from '/@/renderer/store';
|
||||
import { getServerById, useAuthStoreActions, useCurrentServer } 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,18 +40,13 @@ const isNetworkError = (error: any): boolean => {
|
||||
|
||||
export const useServerAuthenticated = () => {
|
||||
const priorServerId = useRef<string | undefined>(undefined);
|
||||
const serverId = useCurrentServerId();
|
||||
const server = useCurrentServer();
|
||||
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();
|
||||
@@ -317,7 +312,7 @@ export const useServerAuthenticated = () => {
|
||||
|
||||
// Don't clear credentials on network failure - preserve them for when network returns
|
||||
setReady(AuthState.INVALID);
|
||||
navigateRef.current(AppRoute.NO_NETWORK, { replace: true });
|
||||
navigate(AppRoute.NO_NETWORK, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,19 +341,18 @@ export const useServerAuthenticated = () => {
|
||||
setReady(AuthState.INVALID);
|
||||
}
|
||||
},
|
||||
[updateServer, setCurrentServer],
|
||||
[updateServer, setCurrentServer, navigate],
|
||||
);
|
||||
|
||||
const debouncedAuth = useMemo(
|
||||
() =>
|
||||
debounce((serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
|
||||
authenticateServer(serverWithAuth).catch(console.error);
|
||||
}, 300),
|
||||
[authenticateServer],
|
||||
const debouncedAuth = debounce(
|
||||
(serverWithAuth: NonNullable<ReturnType<typeof getServerById>>) => {
|
||||
authenticateServer(serverWithAuth).catch(console.error);
|
||||
},
|
||||
300,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
if (!server) {
|
||||
logFn.debug(logMsg[LogCategory.SYSTEM].serverAuthenticationInvalid, {
|
||||
category: LogCategory.SYSTEM,
|
||||
meta: {
|
||||
@@ -369,9 +363,9 @@ export const useServerAuthenticated = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (priorServerId.current !== serverId) {
|
||||
const serverWithAuth = getServerById(serverId);
|
||||
priorServerId.current = serverId;
|
||||
if (priorServerId.current !== server.id) {
|
||||
const serverWithAuth = getServerById(server.id);
|
||||
priorServerId.current = server.id;
|
||||
retryCountRef.current = 0; // Reset retry count when server changes
|
||||
|
||||
if (!serverWithAuth) {
|
||||
@@ -379,7 +373,7 @@ export const useServerAuthenticated = () => {
|
||||
category: LogCategory.SYSTEM,
|
||||
meta: {
|
||||
reason: 'Server not found in store',
|
||||
serverId,
|
||||
serverId: server.id,
|
||||
},
|
||||
});
|
||||
setReady(AuthState.INVALID);
|
||||
@@ -389,10 +383,7 @@ export const useServerAuthenticated = () => {
|
||||
setReady(AuthState.LOADING);
|
||||
debouncedAuth(serverWithAuth);
|
||||
}
|
||||
return () => {
|
||||
debouncedAuth.cancel();
|
||||
};
|
||||
}, [debouncedAuth, serverId]);
|
||||
}, [debouncedAuth, server]);
|
||||
|
||||
return ready;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
'window-bar'
|
||||
'main-content'
|
||||
'player';
|
||||
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-rows: 0 calc(100vh - 90px) 90px;
|
||||
grid-template-rows: 0 calc(100dvh - 90px) 90px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
@@ -15,23 +13,11 @@
|
||||
}
|
||||
|
||||
.windows {
|
||||
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));
|
||||
grid-template-rows: 30px calc(100vh - 120px) 90px;
|
||||
grid-template-rows: 30px calc(100dvh - 120px) 90px;
|
||||
}
|
||||
|
||||
.macos {
|
||||
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));
|
||||
grid-template-rows: 30px calc(100vh - 120px) 90px;
|
||||
grid-template-rows: 30px calc(100dvh - 120px) 90px;
|
||||
}
|
||||
|
||||
@@ -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, useWindowBarStyle } from '/@/renderer/store/settings.store';
|
||||
import { useSettingsStore, useWindowSettings } 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 = useWindowBarStyle();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.container {
|
||||
position: relative;
|
||||
grid-area: sidebar;
|
||||
overflow: hidden;
|
||||
background: var(--theme-colors-background-alternate);
|
||||
border-radius: var(--theme-radius-lg);
|
||||
border-right: 1px solid alpha(var(--theme-colors-border), 0.5);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
.main-content-container {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-area: main-content;
|
||||
grid-template-areas: 'sidebar main';
|
||||
grid-template-areas: 'sidebar . right-sidebar';
|
||||
grid-template-rows: 1fr;
|
||||
gap: var(--theme-spacing-sm);
|
||||
padding: var(--theme-spacing-md);
|
||||
gap: 0;
|
||||
background: var(--theme-colors-background);
|
||||
}
|
||||
|
||||
@@ -23,7 +21,6 @@
|
||||
}
|
||||
|
||||
.main-content-container.right-expanded {
|
||||
grid-template-areas: 'sidebar main right-sidebar';
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--right-sidebar-width);
|
||||
}
|
||||
|
||||
@@ -33,7 +30,7 @@
|
||||
|
||||
.main-content-container.vertical-layout {
|
||||
grid-template-areas:
|
||||
'sidebar main'
|
||||
'sidebar .'
|
||||
'sidebar right-sidebar';
|
||||
grid-template-rows: minmax(0, 1fr) var(--right-sidebar-height);
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
@@ -43,15 +40,17 @@
|
||||
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,18 +175,13 @@ 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);
|
||||
};
|
||||
}, [isResizing, isResizingRight, resize, stopResizing]);
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
.wrapper {
|
||||
.container {
|
||||
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%);
|
||||
@@ -24,8 +9,12 @@
|
||||
@mixin dark {
|
||||
background: darken(var(--theme-colors-background), 10%);
|
||||
}
|
||||
|
||||
transition: background 0.5s;
|
||||
}
|
||||
|
||||
.open-drawer .bar:hover {
|
||||
background: darken(var(--theme-colors-background), 20%);
|
||||
.open-drawer {
|
||||
&:hover {
|
||||
background: darken(var(--theme-colors-background), 20%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,13 @@ export const PlayerBar = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.wrapper, {
|
||||
className={clsx({
|
||||
[styles.container]: true,
|
||||
[styles.openDrawer]: playerbarOpenDrawer,
|
||||
})}
|
||||
id="player-bar"
|
||||
>
|
||||
<div className={styles.bar}>
|
||||
<Playerbar />
|
||||
</div>
|
||||
<Playerbar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,13 +7,18 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--theme-colors-background-alternate);
|
||||
border-radius: var(--theme-radius-lg);
|
||||
border-left: 1px solid alpha(var(--theme-colors-border), 0.5);
|
||||
|
||||
.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,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, Ref } from 'react';
|
||||
|
||||
import styles from './right-sidebar.module.css';
|
||||
@@ -63,7 +64,9 @@ export const RightSidebar = forwardRef(
|
||||
<>
|
||||
{rightExpanded && sideQueueType === 'sideQueue' && (
|
||||
<aside
|
||||
className={styles.rightSidebarContainer}
|
||||
className={clsx(styles.rightSidebarContainer, {
|
||||
[styles.verticalLayout]: isVerticalLayout,
|
||||
})}
|
||||
id="sidebar-queue"
|
||||
key="queue-sidebar"
|
||||
>
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
'window-bar'
|
||||
'main-content'
|
||||
'player';
|
||||
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-rows: 0 calc(100vh - 90px) 90px;
|
||||
grid-template-rows: 0 calc(100dvh - 90px) 90px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0;
|
||||
width: 100vw;
|
||||
@@ -20,20 +18,14 @@
|
||||
|
||||
.windows,
|
||||
.macos {
|
||||
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));
|
||||
grid-template-rows: 30px calc(100vh - 120px) 90px;
|
||||
grid-template-rows: 30px calc(100dvh - 120px) 90px;
|
||||
}
|
||||
|
||||
.drawer-button {
|
||||
position: absolute;
|
||||
bottom: calc(90px + var(--theme-spacing-md) + 0.75rem);
|
||||
left: var(--theme-spacing-md);
|
||||
bottom: calc(90px + 0.75rem);
|
||||
left: 0.75rem;
|
||||
z-index: 100;
|
||||
background: color-mix(in srgb, var(--theme-colors-background) 90%, transparent);
|
||||
border: 1px solid var(--theme-colors-border);
|
||||
|
||||
@@ -10,7 +10,8 @@ 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 { useFullScreenPlayerOverlayState, useWindowBarStyle } from '/@/renderer/store';
|
||||
import { useFullScreenPlayerStore } from '/@/renderer/store';
|
||||
import { useWindowSettings } 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';
|
||||
@@ -31,8 +32,8 @@ export const MobileLayout = ({ shell }: MobileLayoutProps) => {
|
||||
const {
|
||||
expanded: isFullScreenPlayerExpanded,
|
||||
visualizerExpanded: isFullScreenVisualizerExpanded,
|
||||
} = useFullScreenPlayerOverlayState();
|
||||
const windowBarStyle = useWindowBarStyle();
|
||||
} = useFullScreenPlayerStore();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
@@ -10,8 +9,8 @@ import { DefaultLayout } from '/@/renderer/layouts/default-layout';
|
||||
import { MobileLayout } from '/@/renderer/layouts/mobile-layout/mobile-layout';
|
||||
import { AppRoute } from '/@/renderer/router/routes';
|
||||
import {
|
||||
useCommandPaletteState,
|
||||
useLayoutHotkeyBindings,
|
||||
useCommandPalette,
|
||||
useHotkeySettings,
|
||||
useSettingsStoreActions,
|
||||
useZoomFactor,
|
||||
} from '/@/renderer/store';
|
||||
@@ -43,72 +42,40 @@ 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 = useLayoutHotkeyBindings();
|
||||
const { close, open, opened, toggle } = useCommandPaletteState();
|
||||
const { bindings } = useHotkeySettings();
|
||||
const { opened, ...handlers } = useCommandPalette();
|
||||
|
||||
const handlers = useMemo(
|
||||
() => ({
|
||||
close,
|
||||
open,
|
||||
toggle,
|
||||
}),
|
||||
[close, open, toggle],
|
||||
);
|
||||
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 updateZoom = useCallback(
|
||||
(increase: number) => {
|
||||
const newVal = zoomFactor + increase;
|
||||
if (newVal > 300 || newVal < 50 || !localSettings) return;
|
||||
const zoomHotkeys: HotkeyItem[] = [
|
||||
[bindings.zoomIn.hotkey, () => updateZoom(5)],
|
||||
[bindings.zoomOut.hotkey, () => updateZoom(-5)],
|
||||
];
|
||||
|
||||
setSettings({
|
||||
general: {
|
||||
zoomFactor: newVal,
|
||||
},
|
||||
});
|
||||
localSettings?.setZoomFactor(newVal);
|
||||
},
|
||||
[setSettings, zoomFactor],
|
||||
);
|
||||
useHotkeys([
|
||||
[bindings.globalSearch.hotkey, () => handlers.open()],
|
||||
[bindings.browserBack.hotkey, () => navigate(-1)],
|
||||
[bindings.browserForward.hotkey, () => navigate(1)],
|
||||
[bindings.navigateHome.hotkey, () => navigate(AppRoute.HOME)],
|
||||
...(isElectron() ? zoomHotkeys : []),
|
||||
]);
|
||||
|
||||
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} />;
|
||||
return <CommandPalette modalProps={{ handlers, opened }} />;
|
||||
};
|
||||
|
||||
const GarbageCollection = () => {
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
.window-bar {
|
||||
grid-area: window-bar;
|
||||
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);
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.windows-container {
|
||||
@@ -23,6 +9,8 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -107,6 +95,8 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -205,21 +205,19 @@ export const WindowBar = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<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 className={styles.windowBar}>
|
||||
{windowBarStyle === Platform.WINDOWS && (
|
||||
<WindowsControls
|
||||
controls={{ handleClose, handleMaximize, handleMinimize }}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
{windowBarStyle === Platform.MACOS && (
|
||||
<MacOsControls
|
||||
controls={{ handleClose, handleMaximize, handleMinimize }}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { 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 { useAuthStore, useAuthStoreActions } from '/@/renderer/store';
|
||||
import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store';
|
||||
|
||||
const normalizeUrl = (url: string) => url.replace(/\/$/, '');
|
||||
|
||||
export const AppOutlet = () => {
|
||||
const currentServer = useAuthStore(
|
||||
(state) =>
|
||||
state.currentServer
|
||||
? {
|
||||
id: state.currentServer.id,
|
||||
url: state.currentServer.url,
|
||||
}
|
||||
: null,
|
||||
shallow,
|
||||
);
|
||||
const currentServer = useCurrentServer();
|
||||
const { deleteServer, setCurrentServer } = useAuthStoreActions();
|
||||
|
||||
const hasServerLockMismatch = useMemo(() => {
|
||||
if (!isServerLock() || !currentServer || !window.SERVER_URL) {
|
||||
return false;
|
||||
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 configuredUrl = normalizeUrl(window.SERVER_URL);
|
||||
const persistedUrl = normalizeUrl(currentServer.url);
|
||||
const isServerRequired = !currentServer;
|
||||
|
||||
return configuredUrl !== persistedUrl;
|
||||
}, [currentServer]);
|
||||
const actions = [isServerRequired];
|
||||
const isActionRequired = actions.some((c) => c);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasServerLockMismatch && currentServer) {
|
||||
deleteServer(currentServer.id);
|
||||
setCurrentServer(null);
|
||||
}
|
||||
}, [currentServer, deleteServer, hasServerLockMismatch, setCurrentServer]);
|
||||
|
||||
const isActionsRequired = !currentServer || hasServerLockMismatch;
|
||||
return isActionRequired;
|
||||
}, [currentServer, deleteServer, setCurrentServer]);
|
||||
|
||||
if (isActionsRequired) {
|
||||
return <Navigate replace to={AppRoute.ACTION_REQUIRED} />;
|
||||
|
||||
@@ -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={appRouterModals}>
|
||||
<ModalsProvider
|
||||
modals={{
|
||||
addToPlaylist: AddToPlaylistContextModal,
|
||||
base: BaseContextModal,
|
||||
lyricsSettings: LyricsSettingsContextModal,
|
||||
saveAndReplace: SaveAndReplaceContextModal,
|
||||
settings: SettingsContextModal,
|
||||
shareItem: ShareItemContextModal,
|
||||
shuffleAll: ShuffleAllContextModal,
|
||||
updatePlaylist: UpdatePlaylistContextModal,
|
||||
visualizerSettings: VisualizerSettingsContextModal,
|
||||
}}
|
||||
>
|
||||
<RouterErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<AuthenticationOutlet />}>
|
||||
@@ -341,5 +341,5 @@ export const AppRouter = () => {
|
||||
</HashRouter>
|
||||
);
|
||||
|
||||
return <Suspense fallback={<Spinner container />}>{router}</Suspense>;
|
||||
return <Suspense fallback={<></>}>{router}</Suspense>;
|
||||
};
|
||||
|
||||
@@ -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 { useWindowBarStyle } from '/@/renderer/store/settings.store';
|
||||
import { useWindowSettings } from '/@/renderer/store/settings.store';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
export const TitlebarOutlet = () => {
|
||||
const windowBarStyle = useWindowBarStyle();
|
||||
const { windowBarStyle } = useWindowSettings();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -281,17 +280,6 @@ 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,7 +1,6 @@
|
||||
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 {
|
||||
@@ -63,12 +62,3 @@ export const useFullScreenPlayerStoreActions = () =>
|
||||
|
||||
export const useSetFullScreenPlayerStore = () =>
|
||||
useFullScreenPlayerStore((state) => state.actions.setStore);
|
||||
|
||||
export const useFullScreenPlayerOverlayState = () =>
|
||||
useFullScreenPlayerStore(
|
||||
(state) => ({
|
||||
expanded: state.expanded,
|
||||
visualizerExpanded: state.visualizerExpanded,
|
||||
}),
|
||||
shallow,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -59,11 +58,7 @@ interface Actions {
|
||||
mediaSeekToTimestamp: (timestamp: number) => void;
|
||||
mediaSkipBackward: (offset?: number) => void;
|
||||
mediaSkipForward: (offset?: number) => 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;
|
||||
mediaStop: () => void;
|
||||
mediaToggleMute: () => void;
|
||||
mediaTogglePlayPause: () => void;
|
||||
moveSelectedTo: (items: QueueSong[], uniqueId: string, edge: 'bottom' | 'top') => void;
|
||||
@@ -1168,14 +1163,11 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(newTimestamp);
|
||||
});
|
||||
},
|
||||
mediaStop: (options?: { reset?: boolean }) => {
|
||||
const reset = options?.reset !== false;
|
||||
mediaStop: () => {
|
||||
set((state) => {
|
||||
state.player.status = PlayerStatus.PAUSED;
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
|
||||
setTimestampStore(0);
|
||||
if (reset) {
|
||||
state.player.seekToTimestamp = uniqueSeekToTimestamp(0);
|
||||
}
|
||||
});
|
||||
},
|
||||
mediaToggleMute: () => {
|
||||
@@ -1644,13 +1636,10 @@ export const usePlayerActions = () => {
|
||||
})),
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
...actions,
|
||||
setTimestamp: setTimestampStore,
|
||||
}),
|
||||
[actions],
|
||||
);
|
||||
return {
|
||||
...actions,
|
||||
setTimestamp: setTimestampStore,
|
||||
};
|
||||
};
|
||||
|
||||
export type AddToQueueByPlayType = Play;
|
||||
@@ -1724,8 +1713,6 @@ 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)) {
|
||||
@@ -1743,25 +1730,20 @@ export const subscribeNextSongInsertion = (onChange: (song: QueueSong | undefine
|
||||
nextSong = calculateNextSong(queueIndex, queue.items, repeat);
|
||||
}
|
||||
|
||||
return {
|
||||
currentUniqueId: currentSong?._uniqueId,
|
||||
nextSong,
|
||||
};
|
||||
return { index: queueIndex, song: nextSong };
|
||||
},
|
||||
(current, prev) => {
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2421,26 +2421,8 @@ 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);
|
||||
|
||||
|
||||
@@ -76,16 +76,6 @@ 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);
|
||||
@@ -102,44 +92,6 @@ 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() : '');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,
|
||||
@@ -139,20 +138,6 @@ 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,
|
||||
@@ -196,8 +181,6 @@ const normalizeSong = (
|
||||
|
||||
const artists = getArtists(item, participants);
|
||||
|
||||
const { releaseDate, releaseYear } = jellyfinPremiereFields(item);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.SONG,
|
||||
_serverId: server?.id || '',
|
||||
@@ -261,8 +244,8 @@ const normalizeSong = (
|
||||
peak: null,
|
||||
playCount: (item.UserData && item.UserData.PlayCount) || 0,
|
||||
playlistItemId: item.PlaylistItemId,
|
||||
releaseDate,
|
||||
releaseYear,
|
||||
releaseDate: item.PremiereDate || null,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
sampleRate,
|
||||
size,
|
||||
sortName: item.SortName || item.Name,
|
||||
@@ -279,8 +262,6 @@ 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 || '',
|
||||
@@ -329,15 +310,15 @@ const normalizeAlbum = (
|
||||
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
|
||||
mbzReleaseGroupId: item.ProviderIds?.MusicBrainzReleaseGroup || null,
|
||||
name: item.Name,
|
||||
originalDate: releaseDate,
|
||||
originalYear,
|
||||
originalDate: item.PremiereDate || null,
|
||||
originalYear: item.ProductionYear || null,
|
||||
participants: getPeople(item),
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
recordLabels: item.Studios?.map((entry) => entry.Name) || [],
|
||||
releaseDate,
|
||||
releaseDate: item.PremiereDate || null,
|
||||
releaseType: null,
|
||||
releaseTypes: [],
|
||||
releaseYear,
|
||||
releaseYear: item.ProductionYear || null,
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
songs: item.Songs?.map((song) => normalizeSong(song, server)),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
AlbumArtist,
|
||||
ExplicitStatus,
|
||||
Genre,
|
||||
InternetRadioStation,
|
||||
LibraryItem,
|
||||
Playlist,
|
||||
RelatedArtist,
|
||||
@@ -35,57 +33,95 @@ const normalizePlayDate = (item: WithDate): null | string => {
|
||||
return !item.playDate || item.playDate.includes('0001-') ? null : item.playDate;
|
||||
};
|
||||
|
||||
const normalizeNavidromeReleaseDate = (item: {
|
||||
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: {
|
||||
date?: string;
|
||||
minYear?: number;
|
||||
releaseDate?: string;
|
||||
}): { date: null | string; year: number } => {
|
||||
const fromRelease = parsePartialIsoDate(item.releaseDate);
|
||||
if (fromRelease.date) {
|
||||
return fromRelease;
|
||||
}): { 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),
|
||||
};
|
||||
}
|
||||
|
||||
const fromDateField = parsePartialIsoDate(item.date);
|
||||
if (fromDateField.date) {
|
||||
return fromDateField;
|
||||
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 y = coerceYear(item.minYear);
|
||||
if (y > 0) {
|
||||
return { date: String(y), year: y };
|
||||
}
|
||||
|
||||
return { date: null, year: 0 };
|
||||
return {
|
||||
date: null,
|
||||
year: item.minYear ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNavidromeOriginalDate = (item: {
|
||||
const normalizeOriginalDate = (item: {
|
||||
date?: string;
|
||||
minOriginalYear?: number;
|
||||
minYear?: number;
|
||||
originalDate?: string;
|
||||
releaseDate?: string;
|
||||
}): { date: null | string; year: number } => {
|
||||
const fromOriginal = parsePartialIsoDate(item.originalDate);
|
||||
if (fromOriginal.date) {
|
||||
return fromOriginal;
|
||||
}): { 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),
|
||||
};
|
||||
}
|
||||
|
||||
const fromRelease = parsePartialIsoDate(item.releaseDate);
|
||||
if (fromRelease.date) {
|
||||
return fromRelease;
|
||||
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 fromDateField = parsePartialIsoDate(item.date);
|
||||
if (fromDateField.date) {
|
||||
return fromDateField;
|
||||
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 y = coerceYear(item.minOriginalYear ?? item.minYear);
|
||||
if (y > 0) {
|
||||
return { date: String(y), year: y };
|
||||
}
|
||||
|
||||
return { date: null, year: 0 };
|
||||
return {
|
||||
date: null,
|
||||
year: item.minYear ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const getArtists = (
|
||||
@@ -207,12 +243,6 @@ 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,
|
||||
@@ -271,8 +301,8 @@ const normalizeSong = (
|
||||
: null,
|
||||
playCount: item.playCount || 0,
|
||||
playlistItemId,
|
||||
releaseDate,
|
||||
releaseYear,
|
||||
releaseDate: normalizeReleaseDate(item).date,
|
||||
releaseYear: item.year || null,
|
||||
sampleRate: item.sampleRate || null,
|
||||
size: item.size,
|
||||
sortName: item.orderTitle,
|
||||
@@ -334,8 +364,8 @@ const normalizeAlbum = (
|
||||
pathReplace?: string,
|
||||
pathReplaceWith?: string,
|
||||
): Album => {
|
||||
const releaseDate = normalizeNavidromeReleaseDate(item);
|
||||
const originalDate = normalizeNavidromeOriginalDate(item);
|
||||
const releaseDate = normalizeReleaseDate(item);
|
||||
const originalDate = normalizeOriginalDate(item);
|
||||
|
||||
return {
|
||||
...parseAlbumTags(item),
|
||||
@@ -377,7 +407,7 @@ const normalizeAlbum = (
|
||||
playCount: item.playCount || 0,
|
||||
releaseDate: releaseDate.date,
|
||||
releaseType: item.mbzAlbumType || null,
|
||||
releaseYear: releaseDate.year > 0 ? releaseDate.year : null,
|
||||
releaseYear: releaseDate.year,
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
songs: item.songs
|
||||
@@ -460,8 +490,6 @@ 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',
|
||||
@@ -470,7 +498,7 @@ const normalizePlaylist = (
|
||||
duration: item.duration * 1000,
|
||||
genres: [],
|
||||
id: item.id,
|
||||
imageId,
|
||||
imageId: item.id,
|
||||
imageUrl: null,
|
||||
name: item.name,
|
||||
owner: item.ownerName,
|
||||
@@ -480,7 +508,6 @@ const normalizePlaylist = (
|
||||
size: item.size,
|
||||
songCount: item.songCount,
|
||||
sync: item.sync,
|
||||
uploadedImage: item.uploadedImage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -513,28 +540,10 @@ 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,
|
||||
|
||||
@@ -72,24 +72,12 @@ 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' },
|
||||
@@ -600,14 +588,6 @@ 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(),
|
||||
@@ -619,12 +599,11 @@ const playlist = z.object({
|
||||
ownerName: z.string(),
|
||||
path: z.string(),
|
||||
public: z.boolean(),
|
||||
rules: playlistRules,
|
||||
rules: z.record(z.string(), z.any()),
|
||||
size: z.number(),
|
||||
songCount: z.number(),
|
||||
sync: z.boolean(),
|
||||
updatedAt: z.string(),
|
||||
uploadedImage: z.string().optional(),
|
||||
});
|
||||
|
||||
const playlistList = z.array(playlist);
|
||||
@@ -652,7 +631,7 @@ const createPlaylistParameters = z.object({
|
||||
name: z.string(),
|
||||
ownerId: z.string().optional(),
|
||||
public: z.boolean().optional(),
|
||||
rules: playlistRules.optional(),
|
||||
rules: z.record(z.any()).optional(),
|
||||
sync: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -660,32 +639,8 @@ 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(),
|
||||
});
|
||||
@@ -760,35 +715,12 @@ 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,
|
||||
@@ -802,16 +734,12 @@ 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: {
|
||||
@@ -822,10 +750,7 @@ export const ndType = {
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deleteInternetRadioStation,
|
||||
deleteInternetRadioStationImage,
|
||||
deletePlaylist,
|
||||
deletePlaylistImage,
|
||||
error,
|
||||
genre,
|
||||
genreList,
|
||||
@@ -835,18 +760,13 @@ export const ndType = {
|
||||
playlistSong,
|
||||
playlistSongList,
|
||||
queue,
|
||||
radioList,
|
||||
radioStation,
|
||||
removeFromPlaylist,
|
||||
saveQueue,
|
||||
shareItem,
|
||||
song,
|
||||
songList,
|
||||
tagList,
|
||||
updateInternetRadioStation,
|
||||
updatePlaylist,
|
||||
uploadInternetRadioStationImage,
|
||||
uploadPlaylistImage,
|
||||
user,
|
||||
userList,
|
||||
},
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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 };
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
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 {
|
||||
@@ -134,32 +133,6 @@ 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,
|
||||
@@ -175,8 +148,6 @@ const normalizeSong = (
|
||||
? item.albumArtists.map((a) => a.name).join(', ')
|
||||
: item.artist || '';
|
||||
|
||||
const { releaseDate, releaseYear } = subsonicReleaseFields(item);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.SONG,
|
||||
_serverId: server?.id || 'unknown',
|
||||
@@ -231,8 +202,8 @@ const normalizeSong = (
|
||||
: null,
|
||||
playCount: item?.playCount || 0,
|
||||
playlistItemId: playlistIndex !== undefined ? playlistIndex.toString() : undefined,
|
||||
releaseDate,
|
||||
releaseYear,
|
||||
releaseDate: null,
|
||||
releaseYear: item.year || null,
|
||||
sampleRate: item.samplingRate || null,
|
||||
size: item.size,
|
||||
sortName: item.title,
|
||||
@@ -314,7 +285,13 @@ const normalizeAlbum = (
|
||||
discTitleMap.set(discTitle.disc, discTitle.title);
|
||||
});
|
||||
|
||||
const { releaseDate, releaseYear } = subsonicReleaseFields(item);
|
||||
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;
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.ALBUM,
|
||||
@@ -342,14 +319,14 @@ const normalizeAlbum = (
|
||||
mbzReleaseGroupId: null,
|
||||
name: item.name,
|
||||
originalDate: releaseDate,
|
||||
originalYear: releaseYear ?? 0,
|
||||
originalYear: item.year || null,
|
||||
participants: getParticipants(item),
|
||||
playCount: null,
|
||||
recordLabels: item.recordLabels?.map((item) => item.name) || [],
|
||||
releaseDate,
|
||||
releaseType: getReleaseType(item),
|
||||
releaseTypes: item.releaseTypes || [],
|
||||
releaseYear,
|
||||
releaseYear: item.year || null,
|
||||
size: null,
|
||||
songCount: item.songCount,
|
||||
songs:
|
||||
@@ -455,8 +432,6 @@ const normalizeInternetRadioStation = (
|
||||
return {
|
||||
homepageUrl: item.homepageUrl || null,
|
||||
id: item.id,
|
||||
imageId: item.coverArt?.toString() || null,
|
||||
imageUrl: null,
|
||||
name: item.name,
|
||||
streamUrl: item.streamUrl,
|
||||
};
|
||||
|
||||
@@ -755,7 +755,6 @@ const playQueueByIndex = z.object({
|
||||
});
|
||||
|
||||
const internetRadioStation = z.object({
|
||||
coverArt: z.string().optional(),
|
||||
homepageUrl: z.string().optional(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
LuArrowUpToLine,
|
||||
LuBookOpen,
|
||||
LuBraces,
|
||||
LuCamera,
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronLast,
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
LuCloudDownload,
|
||||
LuCornerDownRight,
|
||||
LuCornerUpRight,
|
||||
LuDelete,
|
||||
LuDisc,
|
||||
LuDisc3,
|
||||
LuDownload,
|
||||
@@ -117,7 +117,6 @@ import {
|
||||
LuTable,
|
||||
LuTimer,
|
||||
LuTimerOff,
|
||||
LuTrash,
|
||||
LuTriangleAlert,
|
||||
LuUpload,
|
||||
LuUser,
|
||||
@@ -249,7 +248,7 @@ export const AppIcon = {
|
||||
check: LuCheck,
|
||||
clipboardCopy: LuClipboardCopy,
|
||||
collection: LuPackage2,
|
||||
delete: LuTrash,
|
||||
delete: LuDelete,
|
||||
disc: LuDisc,
|
||||
download: LuDownload,
|
||||
dragHorizontal: LuGripHorizontal,
|
||||
@@ -352,7 +351,6 @@ export const AppIcon = {
|
||||
unfavorite: LuHeartCrack,
|
||||
unpin: LuPinOff,
|
||||
upload: LuUpload,
|
||||
uploadImage: LuCamera,
|
||||
user: LuUser,
|
||||
userManage: LuUserRoundCog,
|
||||
visibility: MdOutlineVisibility,
|
||||
|
||||
@@ -7,17 +7,9 @@ import { Icon } from '/@/shared/components/icon/icon';
|
||||
|
||||
interface SpoilerProps extends Omit<MantineSpoilerProps, 'hideLabel' | 'showLabel'> {
|
||||
children?: ReactNode;
|
||||
hideLabel?: ReactNode;
|
||||
showLabel?: ReactNode;
|
||||
}
|
||||
|
||||
export const Spoiler = ({
|
||||
children,
|
||||
hideLabel,
|
||||
maxHeight = 56,
|
||||
showLabel,
|
||||
...props
|
||||
}: SpoilerProps) => {
|
||||
export const Spoiler = ({ children, maxHeight = 56, ...props }: SpoilerProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -26,9 +18,9 @@ export const Spoiler = ({
|
||||
expanded={expanded}
|
||||
maxHeight={maxHeight}
|
||||
{...props}
|
||||
hideLabel={hideLabel ?? <Icon icon="arrowUpS" size="lg" />}
|
||||
hideLabel={<Icon icon="arrowUpS" size="lg" />}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
showLabel={showLabel ?? <Icon icon="arrowDownS" size="lg" />}
|
||||
showLabel={<Icon icon="arrowDownS" size="lg" />}
|
||||
>
|
||||
{children}
|
||||
</MantineSpoiler>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
.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;
|
||||
}
|
||||
@@ -283,10 +283,6 @@ 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-bar {
|
||||
.fs-player-bar-module-container {
|
||||
background: rgb(0 0 0 / 40%) !important;
|
||||
backdrop-filter: blur(2rem);
|
||||
}
|
||||
@@ -125,8 +125,8 @@ table {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
:has(.fs-window-bar-module-wrapper) .fs-main-content-module-main-content-container {
|
||||
height: calc(100vh - 30px - var(--theme-spacing-md));
|
||||
:has(.fs-window-bar-module-window-bar) .fs-main-content-module-main-content-container {
|
||||
height: calc(100vh - 30px);
|
||||
}
|
||||
|
||||
.mantine-Tabs-root {
|
||||
|
||||
@@ -188,12 +188,12 @@ export type Album = {
|
||||
mbzId: null | string;
|
||||
mbzReleaseGroupId: null | string;
|
||||
name: string;
|
||||
originalDate: null | PartialIsoDateString;
|
||||
originalYear: number;
|
||||
originalDate: null | string;
|
||||
originalYear: null | number;
|
||||
participants: null | Record<string, RelatedArtist[]>;
|
||||
playCount: null | number;
|
||||
recordLabels: string[];
|
||||
releaseDate: null | PartialIsoDateString;
|
||||
releaseDate: null | string;
|
||||
releaseType: null | string;
|
||||
releaseTypes: string[];
|
||||
releaseYear: null | number;
|
||||
@@ -326,8 +326,6 @@ export type MusicFolder = {
|
||||
|
||||
export type MusicFoldersResponse = MusicFolder[];
|
||||
|
||||
export type PartialIsoDateString = string;
|
||||
|
||||
export type Playlist = {
|
||||
_itemType: LibraryItem.PLAYLIST;
|
||||
_serverId: string;
|
||||
@@ -342,11 +340,10 @@ export type Playlist = {
|
||||
owner: null | string;
|
||||
ownerId: null | string;
|
||||
public: boolean | null;
|
||||
rules?: null | PlaylistRules;
|
||||
rules?: null | Record<string, any>;
|
||||
size: null | number;
|
||||
songCount: null | number;
|
||||
sync?: boolean | null;
|
||||
uploadedImage?: string;
|
||||
};
|
||||
|
||||
export type RelatedAlbumArtist = {
|
||||
@@ -400,7 +397,7 @@ export type Song = {
|
||||
peak: GainInfo | null;
|
||||
playCount: number;
|
||||
playlistItemId?: string;
|
||||
releaseDate: null | PartialIsoDateString;
|
||||
releaseDate: null | string;
|
||||
releaseYear: null | number;
|
||||
sampleRate: null | number;
|
||||
size: number;
|
||||
@@ -950,7 +947,7 @@ export type CreatePlaylistBody = {
|
||||
name: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
queryBuilderRules?: PlaylistRules;
|
||||
queryBuilderRules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
|
||||
@@ -961,16 +958,6 @@ 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;
|
||||
};
|
||||
@@ -981,16 +968,6 @@ 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
|
||||
@@ -1011,13 +988,10 @@ 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 };
|
||||
@@ -1035,12 +1009,6 @@ 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;
|
||||
@@ -1121,7 +1089,7 @@ export type UpdatePlaylistBody = {
|
||||
name: string;
|
||||
ownerId?: string;
|
||||
public?: boolean;
|
||||
queryBuilderRules?: PlaylistRules;
|
||||
queryBuilderRules?: Record<string, any>;
|
||||
sync?: boolean;
|
||||
};
|
||||
|
||||
@@ -1132,36 +1100,6 @@ 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>;
|
||||
@@ -1445,11 +1383,7 @@ 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>;
|
||||
@@ -1503,10 +1437,6 @@ 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 & {
|
||||
@@ -1576,15 +1506,9 @@ 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>;
|
||||
@@ -1669,12 +1593,6 @@ export type InternalControllerEndpoint = {
|
||||
updatePlaylist: (
|
||||
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
|
||||
) => Promise<UpdatePlaylistResponse>;
|
||||
uploadInternetRadioStationImage?: (
|
||||
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
|
||||
) => Promise<UploadInternetRadioStationImageResponse>;
|
||||
uploadPlaylistImage?: (
|
||||
args: ReplaceApiClientProps<UploadPlaylistImageArgs>,
|
||||
) => Promise<UploadPlaylistImageResponse>;
|
||||
};
|
||||
|
||||
export type LyricGetQuery = {
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user