Compare commits

..

8 Commits

Author SHA1 Message Date
jeffvli ee33720fcd shamelessly copy transcoding config from NavidromeUI 2026-03-31 20:41:36 -07:00
jeffvli 7d34511039 add param to skipAutoTranscode for mpv 2026-03-31 11:47:55 -07:00
jeffvli 8b4bbc1ede remove serverFeatures and transcode from api context 2026-03-31 11:30:32 -07:00
jeffvli 833d4d3aac add transcode extension to player songUrl 2026-03-31 01:54:47 -07:00
jeffvli 7e353c4723 add getTranscodeStream to subsonic api 2026-03-31 01:54:47 -07:00
jeffvli ae2ce0866e include transcode settings and server features in api context 2026-03-31 01:54:47 -07:00
jeffvli 27c42dd9f4 add OS transcoding extension to ServerInfo output 2026-03-31 01:54:47 -07:00
jeffvli 52dea17d14 add getTranscodeDecision controller endpoint and types 2026-03-31 00:09:17 -07:00
97 changed files with 1190 additions and 2950 deletions
-3
View File
@@ -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": {
-3
View File
@@ -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",
+4 -51
View File
@@ -574,7 +574,7 @@
"hotkey_browserForward": "nabigatzailean aurreraka",
"imageAspectRatio": "erabili jatorrizko azaleko artearen aspektu-erlazioa",
"lyricFetchProvider": "letrak eskuratzeko hornitzaileak",
"lyricFetchProvider_description": "aukeratu letrak eskuratzeko hornitzaileak",
"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"
}
}
+8 -10
View File
@@ -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",
+2 -8
View File
@@ -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": {
+2 -5
View File
@@ -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": "重启服务器使新端口生效",
-3
View File
@@ -1124,9 +1124,6 @@
"export": "匯出歌詞",
"input_synced": "匯出同步歌詞",
"input_offset": "$t(setting.lyricOffset)"
},
"editRadioStation": {
"success": "電臺更新成功"
}
},
"releaseType": {
+2 -10
View File
@@ -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;
}
});
-56
View File
@@ -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
View File
@@ -7,7 +7,7 @@ import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import isElectron from 'is-electron';
import { lazy, 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 <>&nbsp;</>;
}
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) : <>&nbsp;</>;
@@ -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}`;
@@ -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,
});
@@ -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;
}
@@ -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(() => {
@@ -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,
};
+1 -1
View File
@@ -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={{
+3 -12
View File
@@ -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]);
};
+16 -25
View File
@@ -2,13 +2,13 @@ import { isAxiosError } from 'axios';
import isElectron from 'is-electron';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { useCallback, useEffect, 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;
};
+6 -20
View File
@@ -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;
}
+2 -2
View File
@@ -7,7 +7,7 @@ import styles from './default-layout.module.css';
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
import { MainContent } from '/@/renderer/layouts/default-layout/main-content';
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
import { useSettingsStore, 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 (
<>
+28 -61
View File
@@ -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 = () => {
+6 -16
View File
@@ -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;
}
+13 -15
View File
@@ -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>
);
};
+20 -28
View File
@@ -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} />;
+14 -14
View File
@@ -186,22 +186,22 @@ const VisualizerSettingsContextModal = (props: any) => (
</Suspense>
);
const appRouterModals = {
addToPlaylist: AddToPlaylistContextModal,
base: BaseContextModal,
lyricsSettings: LyricsSettingsContextModal,
saveAndReplace: SaveAndReplaceContextModal,
settings: SettingsContextModal,
shareItem: ShareItemContextModal,
shuffleAll: ShuffleAllContextModal,
updatePlaylist: UpdatePlaylistContextModal,
visualizerSettings: VisualizerSettingsContextModal,
};
export const AppRouter = () => {
const router = (
<HashRouter>
<ModalsProvider modals={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>;
};
+2 -2
View File
@@ -3,11 +3,11 @@ import { Outlet } from 'react-router';
import styles from './titlebar-outlet.module.css';
import { Titlebar } from '/@/renderer/features/titlebar/components/titlebar';
import { 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 (
<>
-12
View File
@@ -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,
);
+19 -37
View File
@@ -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);
}
},
{
-18
View File
@@ -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);
-48
View File
@@ -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() : '');
+6 -25
View File
@@ -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)),
+76 -67
View File
@@ -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,
+2 -82
View File
@@ -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,
},
-46
View File
@@ -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 };
};
+11 -36
View File
@@ -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;
+2 -4
View File
@@ -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,
+3 -11
View File
@@ -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>
+43
View File
@@ -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;
}
-4
View File
@@ -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 {
+8 -90
View File
@@ -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 = {
-2
View File
@@ -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',