mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-06 20:10:12 +02:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fe088174d | |||
| 515638c063 | |||
| 9b48671dcd | |||
| debec2e236 | |||
| 2a6e9b6ad3 | |||
| 167b42df2b | |||
| e6a2bc3acf | |||
| ca3c7015c6 | |||
| c7c15d917a | |||
| 6adb29bc38 | |||
| 2c3cd7af24 | |||
| 3c442a2d40 | |||
| d67c185c93 | |||
| ff96a5f121 | |||
| 6fc7b6b271 | |||
| 918f453066 | |||
| 4a986069f8 | |||
| 11d26af893 |
@@ -51,5 +51,4 @@ jobs:
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: |
|
||||
linux/amd64
|
||||
linux/arm/v7
|
||||
linux/arm64/v8
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
|
||||
- name: Build and Publish releases
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: nick-invision/retry@v3.0.2
|
||||
with:
|
||||
|
||||
@@ -43,10 +43,11 @@ mac:
|
||||
icon: assets/icons/icon.icns
|
||||
type: distribution
|
||||
hardenedRuntime: false
|
||||
identity: "-"
|
||||
identity: '-'
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
|
||||
extendInfo:
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
|
||||
@@ -61,7 +62,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: "1.0.2"
|
||||
appimage: '1.0.2'
|
||||
|
||||
npmRebuild: false
|
||||
|
||||
|
||||
@@ -43,9 +43,11 @@ mac:
|
||||
icon: assets/icons/icon.icns
|
||||
type: distribution
|
||||
hardenedRuntime: false
|
||||
identity: "-"
|
||||
identity: '-'
|
||||
gatekeeperAssess: false
|
||||
notarize: false
|
||||
extendInfo:
|
||||
NSLocalNetworkUsageDescription: 'Local network is necessary for accessing servers hosted on the same system as Feishin'
|
||||
|
||||
dmg:
|
||||
contents: [{ x: 130, y: 220 }, { x: 410, y: 220, type: link, path: /Applications }]
|
||||
@@ -60,7 +62,7 @@ linux:
|
||||
artifactName: ${productName}-${os}-${arch}.${ext}
|
||||
|
||||
toolsets:
|
||||
appimage: "1.0.2"
|
||||
appimage: '1.0.2'
|
||||
|
||||
npmRebuild: false
|
||||
publish:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "feishin",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "A modern self-hosted music player.",
|
||||
"keywords": [
|
||||
"subsonic",
|
||||
|
||||
@@ -1381,6 +1381,12 @@
|
||||
}
|
||||
},
|
||||
"pasteGradient": "Vložit přechod",
|
||||
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…"
|
||||
"pasteGradientPlaceholder": "Sem vložte JSON přechodu…",
|
||||
"systemAudioConsentAllow": "Povolit",
|
||||
"systemAudioConsentBody": "Vizualizér potřebuje pro svou činnost přístup k systémovému zvuku",
|
||||
"systemAudioConsentTitle": "Povolit přístup k systémovému zvuku?",
|
||||
"systemAudioCaptureFailed": "Nepodařilo se spustit zachytávání: {{message}}",
|
||||
"systemAudioNoAudioTrack": "Nebyla zachycena žádná zvuková stopa. Ujistěte se, že jste při výzvě povolili zachytávání zvuku.",
|
||||
"systemAudioConsentDecline": "Zamítnout"
|
||||
}
|
||||
}
|
||||
|
||||
+40
-27
@@ -242,7 +242,7 @@
|
||||
"criticRating": "Kritikerbewertung",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"trackNumber": "Track",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel,{\"count\":2})",
|
||||
"owner": "$t(common.owner)",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
@@ -273,13 +273,13 @@
|
||||
"input_name": "Servername",
|
||||
"success": "Server erfolgreich hinzugefügt",
|
||||
"input_savePassword": "Passwort speichern",
|
||||
"ignoreSsl": "SSL ignorieren $t(common.restartRequired)",
|
||||
"ignoreCors": "CORS ignorieren $t(common.restartRequired)",
|
||||
"ignoreSsl": "SSL ignorieren ($t(common.restartRequired))",
|
||||
"ignoreCors": "CORS ignorieren ($t(common.restartRequired))",
|
||||
"error_savePassword": "Beim Speichern des Passworts ist ein Fehler aufgetreten",
|
||||
"input_preferInstantMix": "Instant-Mix bevorzugen",
|
||||
"input_preferInstantMixDescription": "nur Instant-Mix verwenden, um ähnliche Songs zu erhalten. Nützlich bei Verwendung von Plugins, die in dieses Verhalten eingreifen",
|
||||
"input_preferRemoteUrl": "öffentliche URL bevorzugen",
|
||||
"input_remoteUrl": "Öffentliche URL",
|
||||
"input_remoteUrl": "öffentliche URL",
|
||||
"input_remoteUrlPlaceholder": "Optional: öffentliche URL für externe Funktionen"
|
||||
},
|
||||
"addToPlaylist": {
|
||||
@@ -357,6 +357,9 @@
|
||||
"input_offset": "$t(setting.lyricOffset)",
|
||||
"export": "Songtexte exportieren",
|
||||
"input_synced": "Synchronisierte Songtexte exportieren"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Radiosender erfolgreich aktualisiert"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -426,8 +429,8 @@
|
||||
"pagination": "Seitenzahlen",
|
||||
"pagination_itemsPerPage": "Elemente pro Seite",
|
||||
"pagination_infinite": "unendlich",
|
||||
"moveUp": "Nach oben bewegen",
|
||||
"moveDown": "Nach unten bewegen",
|
||||
"moveUp": "nach oben",
|
||||
"moveDown": "nach unten",
|
||||
"pinToLeft": "links anheften",
|
||||
"pinToRight": "rechts anheften",
|
||||
"itemGap": "Item Abstand (px)",
|
||||
@@ -450,13 +453,13 @@
|
||||
"albumArtist": "$t(entity.albumArtist, {\"count\": 1})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"favorite": "$t(common.favorite)",
|
||||
"actions": "$t(common.action_other)",
|
||||
"actions": "$t(common.action,{\"count\":2})",
|
||||
"genre": "$t(entity.genre, {\"count\": 1})",
|
||||
"album": "$t(entity.album, {\"count\": 1})",
|
||||
"size": "$t(common.size)",
|
||||
"bpm": "$t(common.bpm)",
|
||||
"titleCombined": "$t(common.title) (kombiniert)",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel,{\"count\":2})",
|
||||
"duration": "$t(common.duration)",
|
||||
"note": "$t(common.note)",
|
||||
"owner": "$t(common.owner)",
|
||||
@@ -493,7 +496,7 @@
|
||||
"rating": "Bewertung",
|
||||
"albumCount": "$t(entity.album, {\"count\": 2})",
|
||||
"artist": "$t(entity.artist, {\"count\": 1})",
|
||||
"channels": "$t(common.channel_other)",
|
||||
"channels": "$t(common.channel,{\"count\":2})",
|
||||
"comment": "Kommentar",
|
||||
"dateAdded": "hinzugefügt am",
|
||||
"playCount": "Abgespielt",
|
||||
@@ -716,7 +719,8 @@
|
||||
},
|
||||
"releasenotes": {
|
||||
"commitsSinceStable": "Commits seit {{stable}}",
|
||||
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar"
|
||||
"noStableReleaseToCompare": "Kein stable Relase zum vergleichen verfügbar",
|
||||
"noNewCommits": "keine neuen Beiträge in diesem Bereich"
|
||||
}
|
||||
},
|
||||
"player": {
|
||||
@@ -766,12 +770,12 @@
|
||||
"sleepTimer": "Sleep Timer",
|
||||
"sleepTimer_custom": "Benutzerdefiniert",
|
||||
"sleepTimer_hours": "{{count}} std",
|
||||
"sleepTimer_minutes": "{{count}} min",
|
||||
"sleepTimer_minutes": "{{count}} Min",
|
||||
"trackRadio": "Song Radio",
|
||||
"albumRadio": "Album Radio"
|
||||
},
|
||||
"setting": {
|
||||
"audioDevice_description": "wählen Sie das Audiogerät aus, das für die Wiedergabe verwendet werden soll (nur Webplayer)",
|
||||
"audioDevice_description": "das für die Wiedergabe zu verwendende Audiogerät auswählen",
|
||||
"audioExclusiveMode": "Audio-Exklusivmodus",
|
||||
"audioDevice": "Audiogerät",
|
||||
"accentColor": "Akzentfarbe",
|
||||
@@ -789,7 +793,7 @@
|
||||
"crossfadeDuration": "Dauer der Überblendung",
|
||||
"discordIdleStatus": "rich presence status im Leerlauf",
|
||||
"audioPlayer": "Audio-Player",
|
||||
"discordApplicationId": "{{discord}} Anwendungs ID",
|
||||
"discordApplicationId": "{{discord}} Anwendungs-ID",
|
||||
"customFontPath_description": "Legt den Pfad zur benutzerdefinierten Schriftart fest, welche für die Anwendung verwendet werden soll",
|
||||
"remotePort_description": "Legt den Port des Fernsteuerungsserver fest",
|
||||
"hotkey_skipBackward": "rückwärts springen",
|
||||
@@ -1009,7 +1013,7 @@
|
||||
"transcodeFormat": "Format für Umwandlung",
|
||||
"startMinimized_description": "Startet die Anwendung im Info-Bereich",
|
||||
"startMinimized": "Im Info-Bereich starten",
|
||||
"mediaSession_description": "Aktiviert die Windows Media Session-Integration, zeigt Mediensteuerelemente und Metadaten im Systemlautstärke-Overlay und auf dem Sperrbildschirm an (nur Windows)",
|
||||
"mediaSession_description": "aktiviert die Media Session Integration. Dies ermöglicht die Steuerung und Anzeige der Medien in der Systemlautstärkeoption und auf dem Sperrbildschirm",
|
||||
"mediaSession": "Media Session aktivieren",
|
||||
"artistBackgroundBlur": "Unschärfegrad für Künstlerhintergründe",
|
||||
"artistBackgroundBlur_description": "Legt den Grad der Unschärfe fest, der auf das Hintergrundbild des Künstlers angewendet wird",
|
||||
@@ -1017,11 +1021,11 @@
|
||||
"contextMenu_description": "Legt die Einträge fest, die im Rechtsklick-Menü angezeigt werden sollen. Abgewählte Einträge werden ausgeblendet",
|
||||
"crossfadeStyle": "Art der Überblende",
|
||||
"customCss_description": "Benutzerdefinierter CSS-Inhalt. Hinweis: Inhalte und Remote-URLs sind nicht zulässige Eigenschaften. Unten siehst du eine Vorschau deines Inhalts. Aufgrund von Bereinigung werden womöglich zusätzliche, nicht von dir definierte Felder angezeigt",
|
||||
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (url() und content: sind nicht zulässig), kann die Verwendung von benutzerdefiniertem CSS dennoch Risiken mit sich bringen, da dadurch die Benutzeroberfläche verändert wird",
|
||||
"customCssNotice": "Warnung: Obwohl eine gewisse Bereinigung erfolgt (nicht zulässig sind z. B. \"url()\" und \"content:\"), kann ein benutzerdefiniertes CSS Risiken mit sich bringen, da die Benutzeroberfläche dadurch verändert wird",
|
||||
"releaseChannel_optionBeta": "Beta",
|
||||
"releaseChannel_optionLatest": "Stabil",
|
||||
"releaseChannel": "Veröffentlichungskanal",
|
||||
"releaseChannel_description": "Zwischen stabilen und beta Veröffentlichungen für automatische Aktualisierungen wählen",
|
||||
"releaseChannel_description": "zwischen stabilen, Beta- oder Alpha-Versionen (Nightly) für automatische Updates wählen",
|
||||
"discordDisplayType_artistname": "Künstlername(n)",
|
||||
"discordDisplayType_description": "Ändert den aktuellen Titel im Zuhör-Status",
|
||||
"discordDisplayType_songname": "Songtitel",
|
||||
@@ -1121,7 +1125,13 @@
|
||||
"nativeSpotify": "Spotify App benutzen",
|
||||
"qobuz_description": "Zeige Links zu Qobuz auf den Interpreten/Alben Seiten",
|
||||
"spotify_description": "Zeige Links zu Spotify auf den Interpreten/Alben Seiten",
|
||||
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung"
|
||||
"artistReleaseTypeConfiguration": "Interpreten Release Typ Einstellung",
|
||||
"discordStateIcon": "Play Icon anzeigen",
|
||||
"homeFeatureStyle_optionSingle": "Einzeln",
|
||||
"nativeSpotify_description": "in der Spotify App statt im Browser öffnen",
|
||||
"imageResolution_optionFullScreenPlayer": "Wiedergabe im Vollbildmodus",
|
||||
"sidePlayQueueLayout_optionHorizontal": "horizontal",
|
||||
"sidePlayQueueLayout_optionVertical": "vertikal"
|
||||
},
|
||||
"dragDropZone": {
|
||||
"error_oneFileOnly": "Bitte wähle nur 1 Datei",
|
||||
@@ -1211,7 +1221,7 @@
|
||||
"dualVertical": "Dual-Vertikal"
|
||||
}
|
||||
},
|
||||
"minimumFrequency": "Mindestfrequenz",
|
||||
"minimumFrequency": "Minimale Frequenz",
|
||||
"minimumDecibels": "Minimale Dezibel",
|
||||
"visualizerType": "Visualizer Art",
|
||||
"cyclePresets": "Vorlagen durchrotieren",
|
||||
@@ -1246,10 +1256,10 @@
|
||||
"channelLayout": "Kanallayout",
|
||||
"maxFPS": "Max FPS",
|
||||
"opacity": "Deckkraft",
|
||||
"customGradients": "Benutzerdefinierte Gradienten",
|
||||
"customGradients": "Benutzerdefinierter Farbverlauf",
|
||||
"addCustomGradient": "Benutzerdefinierten Gradienten hinzufügen",
|
||||
"gradientName": "Gradientenname",
|
||||
"gradientNamePlaceholder": "Gradientenname",
|
||||
"gradientName": "Name Farbverlauf",
|
||||
"gradientNamePlaceholder": "Name Farbverlauf",
|
||||
"vertical": "Vertikal",
|
||||
"horizontal": "Horizontal",
|
||||
"addColor": "Farbe hinzufügen",
|
||||
@@ -1262,9 +1272,9 @@
|
||||
"builtIn": "Eingebaut",
|
||||
"colors": "Farben",
|
||||
"colorMode": "Farbmodus",
|
||||
"gradient": "Gradienten",
|
||||
"gradientLeft": "Gradienten links",
|
||||
"gradientRight": "Gradienten rechts",
|
||||
"gradient": "Farbverlauf",
|
||||
"gradientLeft": "Farberverlauf links",
|
||||
"gradientRight": "Farbverlauf rechts",
|
||||
"fft": "FFT",
|
||||
"fftSize": "FFT Größe",
|
||||
"smoothing": "Glätten",
|
||||
@@ -1273,17 +1283,20 @@
|
||||
"sensitivity": "Empfindlichkeit",
|
||||
"weightingFilter": "Gewichtungsfilter",
|
||||
"maximumDecibels": "Maximale Dezibel",
|
||||
"linearAmplitude": "Lineare Amplitude",
|
||||
"linearAmplitude": "Linearer Ausschlag",
|
||||
"linearBoost": "Linearer Boost",
|
||||
"radialSpectrum": "Radiales Spektrum",
|
||||
"radial": "Radial",
|
||||
"radialInvert": "Radial invertiert",
|
||||
"radius": "Radius",
|
||||
"miscellaneousSettings": "Verschiedenes Einstellungen",
|
||||
"miscellaneousSettings": "Verschiedene Einstellungen",
|
||||
"ansiBands": "ANSI Bänder",
|
||||
"lowResolution": "Niedrige Auflösung",
|
||||
"showFPS": "FPS anzeigen",
|
||||
"fadePeaks": "Spitzen abblenden",
|
||||
"showPeaks": "Spitzen anzeigen"
|
||||
"showPeaks": "Spitzen anzeigen",
|
||||
"systemAudioConsentAllow": "Erlauben",
|
||||
"systemAudioConsentDecline": "Ablehnen",
|
||||
"frequencyScale": "Frequenzskala"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1001,6 +1001,9 @@
|
||||
"export": "Exportar letras",
|
||||
"input_synced": "Exportar letras sincronizadas",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "Estación de radio actualizada con éxito"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
@@ -1378,6 +1381,12 @@
|
||||
"lowResolution": "Baja resolución",
|
||||
"splitGradient": "Dividir degradado",
|
||||
"noteLabels": "Etiquetas de notas",
|
||||
"lumiBars": "Barras luminiscentes"
|
||||
"lumiBars": "Barras luminiscentes",
|
||||
"systemAudioConsentAllow": "Permitir",
|
||||
"systemAudioConsentDecline": "Denegar",
|
||||
"systemAudioConsentTitle": "¿Permitir acceso al sistema de audio?",
|
||||
"systemAudioConsentBody": "El visualizador requiere acceso al sistema de audio para funcionar",
|
||||
"systemAudioCaptureFailed": "No se pudo iniciar la captura: {{message}}",
|
||||
"systemAudioNoAudioTrack": "Ninguna pista de audio devuelta. Asegúrate de que la captura de audio está activada cuando se solicite."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1002,6 +1002,9 @@
|
||||
"export": "exporter les paroles",
|
||||
"input_synced": "exporter les paroles synchronisées",
|
||||
"input_offset": "$t(setting.lyricOffset)"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "station de radio a été mise à jour avec succès"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -1379,6 +1382,12 @@
|
||||
},
|
||||
"lumiBars": "Lumi Bars",
|
||||
"outlineBars": "Outline Bars",
|
||||
"splitGradient": "Split Gradient"
|
||||
"splitGradient": "Split Gradient",
|
||||
"systemAudioNoAudioTrack": "Aucune piste audio n'a été renvoyée. Assurez-vous que la capture audio est activée lorsque vous y êtes invité.",
|
||||
"systemAudioConsentAllow": "Permettre",
|
||||
"systemAudioConsentBody": "Le visualiseur nécessite un accès au système audio pour fonctionner",
|
||||
"systemAudioConsentDecline": "Refuser",
|
||||
"systemAudioConsentTitle": "Permettre l'accès au système audio ?",
|
||||
"systemAudioCaptureFailed": "Impossible de démarrer la capture : {{message}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,7 +1037,7 @@
|
||||
},
|
||||
"updateServer": {
|
||||
"title": "サーバーをアップデート",
|
||||
"success": "サーバーがアップデートされました"
|
||||
"success": "サーバーの更新に成功しました"
|
||||
},
|
||||
"queryEditor": {
|
||||
"input_optionMatchAll": "すべて一致",
|
||||
@@ -1102,6 +1102,9 @@
|
||||
},
|
||||
"saveQueue": {
|
||||
"success": "プレイキューをサーバーに保存しました"
|
||||
},
|
||||
"editRadioStation": {
|
||||
"success": "ラジオ局の更新に成功しました"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -1332,6 +1335,12 @@
|
||||
"d": "D",
|
||||
"z": "Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"systemAudioConsentAllow": "許可",
|
||||
"systemAudioConsentBody": "ビジュアライザーを機能させるためには、システムオーディオへのアクセスが必要です",
|
||||
"systemAudioConsentDecline": "拒否",
|
||||
"systemAudioConsentTitle": "システムオーディオへのアクセスを許可しますか?",
|
||||
"systemAudioCaptureFailed": "キャプチャを開始できませんでした: {{message}}",
|
||||
"systemAudioNoAudioTrack": "音声トラックが返されませんでした。プロンプトが表示されたら、音声キャプチャが有効になっていることを確認してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1381,6 +1381,12 @@
|
||||
},
|
||||
"pasteGradient": "Wklej Gradient",
|
||||
"pasteGradientPlaceholder": "Wklej tutaj JSON gradientu...",
|
||||
"ansiBands": "Paski ANSI"
|
||||
"ansiBands": "Paski ANSI",
|
||||
"systemAudioConsentAllow": "Zezwól",
|
||||
"systemAudioConsentBody": "Wizualizer wymaga dostępu do audio systemu do działania",
|
||||
"systemAudioConsentDecline": "Odmów",
|
||||
"systemAudioConsentTitle": "Przyznać dostęp do audio systemu?",
|
||||
"systemAudioCaptureFailed": "Nie udało się rozpocząć przechwytywania: {{message}}",
|
||||
"systemAudioNoAudioTrack": "Nie została zwrócona żadna ścieżka audio. Sprawdź czy przechwytywanie audio będzie włączone, gdy będzie o to prośba."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1337,6 +1337,10 @@
|
||||
}
|
||||
},
|
||||
"systemAudioCaptureFailed": "無法開始擷取:{{message}}",
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。"
|
||||
"systemAudioNoAudioTrack": "沒有回傳任何音軌。確保在提示時啟用音訊擷取。",
|
||||
"systemAudioConsentAllow": "允許",
|
||||
"systemAudioConsentBody": "此視覺化器需要存取系統音訊才能運作",
|
||||
"systemAudioConsentDecline": "拒絕",
|
||||
"systemAudioConsentTitle": "允許存取系統音訊?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
import './core';
|
||||
import(`./${process.platform}`);
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
import('./linux');
|
||||
} else if (process.platform === 'darwin') {
|
||||
import('./darwin');
|
||||
} else if (process.platform === 'win32') {
|
||||
import('./win32');
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
|
||||
+1
-14
@@ -5,7 +5,6 @@ import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
desktopCapturer,
|
||||
globalShortcut,
|
||||
ipcMain,
|
||||
Menu,
|
||||
@@ -734,19 +733,7 @@ async function createWindow(first = true): Promise<void> {
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.setDisplayMediaRequestHandler((_request, callback) => {
|
||||
desktopCapturer
|
||||
.getSources({ types: ['screen'] })
|
||||
.then((sources) => {
|
||||
if (sources.length > 0) {
|
||||
callback({ audio: 'loopback', video: sources[0] });
|
||||
} else {
|
||||
callback({});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
log.warn('desktopCapturer.getSources failed', err);
|
||||
callback({});
|
||||
});
|
||||
callback({ audio: 'loopback' });
|
||||
});
|
||||
|
||||
if (!disableAutoUpdates() && store.get('disable_auto_updates') !== true) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SetActivity } from '@xhayper/discord-rpc';
|
||||
import type { SetActivity } from '@xhayper/discord-rpc';
|
||||
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
const initialize = (clientId: string) => {
|
||||
|
||||
@@ -147,6 +147,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
deleteArtistImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteArtistImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'deleteArtistImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
deleteFavorite(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
@@ -988,6 +1002,20 @@ export const controller: GeneralController = {
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
uploadArtistImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new Error(
|
||||
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: uploadArtistImage`,
|
||||
);
|
||||
}
|
||||
|
||||
return apiController(
|
||||
'uploadArtistImage',
|
||||
server.type,
|
||||
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
|
||||
},
|
||||
uploadInternetRadioStationImage(args) {
|
||||
const server = getServerById(args.apiClientProps.serverId);
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deleteArtistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'artist/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.deleteArtistImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
deleteInternetRadioStation: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
@@ -259,6 +268,15 @@ export const contract = c.router({
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
uploadArtistImage: {
|
||||
body: ndType._parameters.uploadArtistImage,
|
||||
method: 'POST',
|
||||
path: 'artist/:id/image',
|
||||
responses: {
|
||||
200: resultWithHeaders(ndType._response.uploadArtistImage),
|
||||
500: resultWithHeaders(ndType._response.error),
|
||||
},
|
||||
},
|
||||
uploadInternetRadioStationImage: {
|
||||
body: ndType._parameters.uploadInternetRadioStationImage,
|
||||
method: 'POST',
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
AuthenticationResponse,
|
||||
DeleteArtistImageArgs,
|
||||
DeleteArtistImageResponse,
|
||||
DeleteInternetRadioStationImageArgs,
|
||||
DeleteInternetRadioStationImageResponse,
|
||||
DeletePlaylistImageArgs,
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
tagListSortMap,
|
||||
UploadArtistImageArgs,
|
||||
UploadArtistImageResponse,
|
||||
UploadInternetRadioStationImageArgs,
|
||||
UploadInternetRadioStationImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
@@ -42,6 +46,7 @@ const VERSION_INFO: VersionInfo = [
|
||||
[
|
||||
'0.61.0',
|
||||
{
|
||||
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.INTERNET_RADIO_IMAGE_UPLOAD]: [1],
|
||||
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
|
||||
},
|
||||
@@ -186,6 +191,21 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
id: res.body.data.id,
|
||||
};
|
||||
},
|
||||
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
const res = await ndApiClient(apiClientProps as any).deleteArtistImage({
|
||||
params: {
|
||||
id: query.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new Error('Failed to delete artist image');
|
||||
}
|
||||
|
||||
return res.body.data.status === 'ok';
|
||||
},
|
||||
deleteFavorite: SubsonicController.deleteFavorite,
|
||||
deleteInternetRadioStation: async (args) => {
|
||||
const { apiClientProps, query } = args;
|
||||
@@ -1270,6 +1290,40 @@ export const NavidromeController: InternalControllerEndpoint = {
|
||||
|
||||
return null;
|
||||
},
|
||||
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
|
||||
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/artist/${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 artist image');
|
||||
}
|
||||
|
||||
return res.data?.status === 'ok';
|
||||
},
|
||||
uploadInternetRadioStationImage: async (
|
||||
args: UploadInternetRadioStationImageArgs,
|
||||
): Promise<UploadInternetRadioStationImageResponse> => {
|
||||
|
||||
@@ -2015,8 +2015,12 @@ export const SubsonicController: InternalControllerEndpoint = {
|
||||
},
|
||||
});
|
||||
|
||||
// If the server returns an error for transcodeDecision, fall back to direct stream so that we don't break the player
|
||||
if (transcodeDecision.status !== 200) {
|
||||
throw new Error('Failed to get transcode decision');
|
||||
logFn.error(
|
||||
`Failed to get transcode decision for song ${id}, falling back to direct stream`,
|
||||
);
|
||||
return streamUrl;
|
||||
}
|
||||
|
||||
const td = transcodeDecision.body.transcodeDecision;
|
||||
|
||||
@@ -22,12 +22,7 @@ import { WebAudio } from '/@/shared/types/types';
|
||||
import '/@/shared/styles/global.css';
|
||||
import { PlayerProvider } from '/@/renderer/features/player/context/player-context';
|
||||
import { AudioPlayers } from '/@/renderer/features/player/components/audio-players';
|
||||
|
||||
const ReleaseNotesModal = lazy(() =>
|
||||
import('./release-notes-modal').then((module) => ({
|
||||
default: module.ReleaseNotesModal,
|
||||
})),
|
||||
);
|
||||
import { ReleaseNotesModal } from '/@/renderer/release-notes-modal';
|
||||
|
||||
const UpdateAvailableDialog = lazy(() =>
|
||||
import('./update-available-dialog').then((module) => ({
|
||||
@@ -82,8 +77,8 @@ const AppShell = memo(function AppShell() {
|
||||
<AppRouter />
|
||||
</PlayerProvider>
|
||||
</WebAudioContext.Provider>
|
||||
<ReleaseNotesModal />
|
||||
<Suspense fallback={null}>
|
||||
<ReleaseNotesModal />
|
||||
<UpdateAvailableDialog />
|
||||
</Suspense>
|
||||
</>
|
||||
|
||||
@@ -169,6 +169,292 @@ export interface ItemCardDerivativeProps extends Omit<ItemCardProps, 'type'> {
|
||||
showRating: boolean;
|
||||
}
|
||||
|
||||
type ItemCardData = NonNullable<ItemCardProps['data']>;
|
||||
|
||||
const ItemCardStandardImageArea = memo(function ItemCardStandardImageArea({
|
||||
controls,
|
||||
data,
|
||||
enableExpansion,
|
||||
enableImageViewport = true,
|
||||
enableNavigation,
|
||||
handleContextMenu,
|
||||
handleImageClick,
|
||||
handleLinkDragStart,
|
||||
imageAsLink,
|
||||
imageFetchPriority,
|
||||
internalState,
|
||||
isRound,
|
||||
itemType,
|
||||
navigationPath,
|
||||
showRating,
|
||||
variant,
|
||||
withControls,
|
||||
}: {
|
||||
controls?: ItemControls;
|
||||
data: ItemCardData;
|
||||
enableExpansion?: boolean;
|
||||
enableImageViewport?: boolean;
|
||||
enableNavigation?: boolean;
|
||||
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
|
||||
imageAsLink?: boolean;
|
||||
imageFetchPriority?: 'auto' | 'high' | 'low';
|
||||
internalState?: ItemListStateActions;
|
||||
isRound?: boolean;
|
||||
itemType: LibraryItem;
|
||||
navigationPath: null | string;
|
||||
showRating: boolean;
|
||||
variant: 'default' | 'poster';
|
||||
withControls?: boolean;
|
||||
}) {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (withControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (withControls) {
|
||||
setShowControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
'userRating' in data &&
|
||||
typeof (data as { userRating: null | number }).userRating === 'number'
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
{itemType === LibraryItem.GENRE &&
|
||||
data &&
|
||||
'name' in data &&
|
||||
typeof (data as Genre).name === 'string' ? (
|
||||
<GenreImagePlaceholder
|
||||
className={clsx(styles.image, styles.genrePlaceholder, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
name={(data as Genre).name}
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
enableDebounce={false}
|
||||
{...(variant === 'poster' ? { enableViewport: enableImageViewport } : {})}
|
||||
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
|
||||
fetchPriority={imageFetchPriority}
|
||||
id={(data as { imageId?: string })?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as { imageUrl?: string })?.imageUrl}
|
||||
type="itemCard"
|
||||
/>
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
{...(variant === 'poster' ? { internalState } : {})}
|
||||
item={data}
|
||||
itemType={itemType}
|
||||
showRating={showRating}
|
||||
type={variant}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
|
||||
<Link
|
||||
className={imageContainerClassName}
|
||||
draggable={false}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
state={{ item: data }}
|
||||
to={navigationPath}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={imageContainerClassName}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ItemCardStandardImageArea.displayName = 'ItemCardStandardImageArea';
|
||||
|
||||
const CompactItemCardImageArea = memo(function CompactItemCardImageArea({
|
||||
controls,
|
||||
data,
|
||||
enableExpansion,
|
||||
enableNavigation,
|
||||
handleContextMenu,
|
||||
handleImageClick,
|
||||
handleLinkDragStart,
|
||||
imageAsLink,
|
||||
imageFetchPriority,
|
||||
internalState,
|
||||
isRound,
|
||||
itemType,
|
||||
navigationPath,
|
||||
rows,
|
||||
showRating,
|
||||
withControls,
|
||||
}: {
|
||||
controls?: ItemControls;
|
||||
data: ItemCardData;
|
||||
enableExpansion?: boolean;
|
||||
enableNavigation?: boolean;
|
||||
handleContextMenu: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
handleImageClick: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
handleLinkDragStart: (e: React.DragEvent<HTMLAnchorElement>) => void;
|
||||
imageAsLink?: boolean;
|
||||
imageFetchPriority?: 'auto' | 'high' | 'low';
|
||||
internalState?: ItemListStateActions;
|
||||
isRound?: boolean;
|
||||
itemType: LibraryItem;
|
||||
navigationPath: null | string;
|
||||
rows: DataRow[];
|
||||
showRating: boolean;
|
||||
withControls?: boolean;
|
||||
}) {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (withControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (withControls) {
|
||||
setShowControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite = 'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
'userRating' in data &&
|
||||
typeof (data as { userRating: null | number }).userRating === 'number'
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
{itemType === LibraryItem.GENRE &&
|
||||
data &&
|
||||
'name' in data &&
|
||||
typeof (data as Genre).name === 'string' ? (
|
||||
<GenreImagePlaceholder
|
||||
className={clsx(styles.image, styles.genrePlaceholder, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
name={(data as Genre).name}
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(styles.image, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
enableDebounce={false}
|
||||
explicitStatus={'explicitStatus' in data && data ? data.explicitStatus : null}
|
||||
fetchPriority={imageFetchPriority}
|
||||
id={data?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
type="itemCard"
|
||||
/>
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && data && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
internalState={internalState}
|
||||
item={data}
|
||||
itemType={itemType}
|
||||
showRating={showRating}
|
||||
type="compact"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className={clsx(styles.detailContainer, styles.compact)}>
|
||||
{rows
|
||||
.filter(
|
||||
(row): row is NonNullable<typeof row> => row !== null && row !== undefined,
|
||||
)
|
||||
.map((row, index) => (
|
||||
<ItemCardRow
|
||||
data={data!}
|
||||
index={index}
|
||||
key={row.id}
|
||||
row={row}
|
||||
type="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
|
||||
<Link
|
||||
className={imageContainerClassName}
|
||||
draggable={false}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
state={{ item: data }}
|
||||
to={navigationPath}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={imageContainerClassName}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CompactItemCardImageArea.displayName = 'CompactItemCardImageArea';
|
||||
|
||||
const CompactItemCard = ({
|
||||
controls,
|
||||
data,
|
||||
@@ -185,7 +471,6 @@ const CompactItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
? internalState.extractRowId(data)
|
||||
@@ -297,18 +582,6 @@ const CompactItemCard = ({
|
||||
if (data) {
|
||||
const navigationPath = getItemNavigationPath(data, itemType);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (withControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (withControls) {
|
||||
setShowControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!data || !controls) {
|
||||
return;
|
||||
@@ -338,81 +611,6 @@ const CompactItemCard = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const isFavorite =
|
||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
'userRating' in data &&
|
||||
typeof (data as { userRating: null | number }).userRating === 'number'
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
{itemType === LibraryItem.GENRE &&
|
||||
data &&
|
||||
'name' in data &&
|
||||
typeof (data as Genre).name === 'string' ? (
|
||||
<GenreImagePlaceholder
|
||||
className={clsx(styles.image, styles.genrePlaceholder, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
name={(data as Genre).name}
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(styles.image, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
fetchPriority={imageFetchPriority}
|
||||
id={data?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
type="itemCard"
|
||||
/>
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && data && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
internalState={internalState}
|
||||
item={data}
|
||||
itemType={itemType}
|
||||
showRating={showRating}
|
||||
type="compact"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className={clsx(styles.detailContainer, styles.compact)}>
|
||||
{rows
|
||||
.filter(
|
||||
(row): row is NonNullable<typeof row> =>
|
||||
row !== null && row !== undefined,
|
||||
)
|
||||
.map((row, index) => (
|
||||
<ItemCardRow
|
||||
data={data!}
|
||||
index={index}
|
||||
key={row.id}
|
||||
row={row}
|
||||
type="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, styles.compact, {
|
||||
@@ -421,31 +619,24 @@ const CompactItemCard = ({
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
|
||||
<Link
|
||||
className={imageContainerClassName}
|
||||
draggable={false}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
state={{ item: data }}
|
||||
to={navigationPath}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={imageContainerClassName}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</div>
|
||||
)}
|
||||
<CompactItemCardImageArea
|
||||
controls={controls}
|
||||
data={data}
|
||||
enableExpansion={enableExpansion}
|
||||
enableNavigation={enableNavigation}
|
||||
handleContextMenu={handleContextMenu}
|
||||
handleImageClick={handleImageClick}
|
||||
handleLinkDragStart={handleLinkDragStart}
|
||||
imageAsLink={imageAsLink}
|
||||
imageFetchPriority={imageFetchPriority}
|
||||
internalState={internalState}
|
||||
isRound={isRound}
|
||||
itemType={itemType}
|
||||
navigationPath={navigationPath}
|
||||
rows={rows}
|
||||
showRating={showRating}
|
||||
withControls={withControls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -491,7 +682,6 @@ const DefaultItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
? internalState.extractRowId(data)
|
||||
@@ -538,18 +728,6 @@ const DefaultItemCard = ({
|
||||
if (data) {
|
||||
const navigationPath = getItemNavigationPath(data, itemType);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (withControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (withControls) {
|
||||
setShowControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!data || !controls) {
|
||||
return;
|
||||
@@ -579,93 +757,30 @@ const DefaultItemCard = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite =
|
||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
'userRating' in data &&
|
||||
typeof (data as { userRating: null | number }).userRating === 'number'
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
{itemType === LibraryItem.GENRE &&
|
||||
data &&
|
||||
'name' in data &&
|
||||
typeof (data as Genre).name === 'string' ? (
|
||||
<GenreImagePlaceholder
|
||||
className={clsx(styles.image, styles.genrePlaceholder, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
name={(data as Genre).name}
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
fetchPriority={imageFetchPriority}
|
||||
id={data?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as Album | AlbumArtist | Playlist | Song)?.imageUrl}
|
||||
type="itemCard"
|
||||
/>
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
item={data}
|
||||
itemType={itemType}
|
||||
showRating={showRating}
|
||||
type="default"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, {
|
||||
[styles.selected]: isSelected,
|
||||
})}
|
||||
>
|
||||
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
|
||||
<Link
|
||||
className={imageContainerClassName}
|
||||
draggable={false}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
state={{ item: data }}
|
||||
to={navigationPath}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={imageContainerClassName}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</div>
|
||||
)}
|
||||
<ItemCardStandardImageArea
|
||||
controls={controls}
|
||||
data={data}
|
||||
enableExpansion={enableExpansion}
|
||||
enableNavigation={enableNavigation}
|
||||
handleContextMenu={handleContextMenu}
|
||||
handleImageClick={handleImageClick}
|
||||
handleLinkDragStart={handleLinkDragStart}
|
||||
imageAsLink={imageAsLink}
|
||||
imageFetchPriority={imageFetchPriority}
|
||||
internalState={internalState}
|
||||
isRound={isRound}
|
||||
itemType={itemType}
|
||||
navigationPath={navigationPath}
|
||||
showRating={showRating}
|
||||
variant="default"
|
||||
withControls={withControls}
|
||||
/>
|
||||
<div className={styles.detailContainer}>
|
||||
{rows
|
||||
.filter(
|
||||
@@ -728,7 +843,6 @@ const PosterItemCard = ({
|
||||
showRating,
|
||||
withControls,
|
||||
}: ItemCardDerivativeProps) => {
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const itemRowId =
|
||||
data && internalState && typeof data === 'object' && 'id' in data
|
||||
? internalState.extractRowId(data)
|
||||
@@ -840,18 +954,6 @@ const PosterItemCard = ({
|
||||
if (data) {
|
||||
const navigationPath = getItemNavigationPath(data, itemType);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (withControls) {
|
||||
setShowControls(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (withControls) {
|
||||
setShowControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent<HTMLElement>) => {
|
||||
if (!data || !controls) {
|
||||
return;
|
||||
@@ -881,63 +983,6 @@ const PosterItemCard = ({
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const imageContainerClassName = clsx(styles.imageContainer, {
|
||||
[styles.isRound]: isRound,
|
||||
});
|
||||
|
||||
const isFavorite =
|
||||
'userFavorite' in data && (data as { userFavorite: boolean }).userFavorite;
|
||||
const userRating =
|
||||
'userRating' in data &&
|
||||
typeof (data as { userRating: null | number }).userRating === 'number'
|
||||
? (data as { userRating: null | number }).userRating
|
||||
: null;
|
||||
const hasRating = showRating && userRating !== null && userRating > 0;
|
||||
|
||||
const imageContainerContent = (
|
||||
<>
|
||||
{itemType === LibraryItem.GENRE &&
|
||||
data &&
|
||||
'name' in data &&
|
||||
typeof (data as Genre).name === 'string' ? (
|
||||
<GenreImagePlaceholder
|
||||
className={clsx(styles.image, styles.genrePlaceholder, {
|
||||
[styles.isRound]: isRound,
|
||||
})}
|
||||
name={(data as Genre).name}
|
||||
/>
|
||||
) : (
|
||||
<ItemImage
|
||||
className={clsx(styles.image, { [styles.isRound]: isRound })}
|
||||
enableDebounce={false}
|
||||
explicitStatus={
|
||||
'explicitStatus' in data && data ? data.explicitStatus : null
|
||||
}
|
||||
fetchPriority={imageFetchPriority}
|
||||
id={(data as { imageId: string })?.imageId}
|
||||
itemType={itemType}
|
||||
src={(data as { imageUrl: string })?.imageUrl}
|
||||
type="itemCard"
|
||||
/>
|
||||
)}
|
||||
{isFavorite && <div className={styles.favoriteBadge} />}
|
||||
{hasRating && <div className={styles.ratingBadge}>{userRating}</div>}
|
||||
<AnimatePresence>
|
||||
{withControls && showControls && data && (
|
||||
<ItemCardControls
|
||||
controls={controls}
|
||||
enableExpansion={enableExpansion}
|
||||
internalState={internalState}
|
||||
item={data}
|
||||
itemType={itemType}
|
||||
showRating={showRating}
|
||||
type="poster"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.container, styles.poster, {
|
||||
@@ -946,31 +991,24 @@ const PosterItemCard = ({
|
||||
})}
|
||||
ref={ref}
|
||||
>
|
||||
{enableNavigation && navigationPath && (imageAsLink ?? !internalState) ? (
|
||||
<Link
|
||||
className={imageContainerClassName}
|
||||
draggable={false}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onDragStart={handleLinkDragStart}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
state={{ item: data }}
|
||||
to={navigationPath}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={imageContainerClassName}
|
||||
onClick={handleImageClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{imageContainerContent}
|
||||
</div>
|
||||
)}
|
||||
<ItemCardStandardImageArea
|
||||
controls={controls}
|
||||
data={data}
|
||||
enableExpansion={enableExpansion}
|
||||
enableNavigation={enableNavigation}
|
||||
handleContextMenu={handleContextMenu}
|
||||
handleImageClick={handleImageClick}
|
||||
handleLinkDragStart={handleLinkDragStart}
|
||||
imageAsLink={imageAsLink}
|
||||
imageFetchPriority={imageFetchPriority}
|
||||
internalState={internalState}
|
||||
isRound={isRound}
|
||||
itemType={itemType}
|
||||
navigationPath={navigationPath}
|
||||
showRating={showRating}
|
||||
variant="poster"
|
||||
withControls={withControls}
|
||||
/>
|
||||
{data && (
|
||||
<div className={styles.detailContainer}>
|
||||
{rows
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
|
||||
import { forwardRef, Fragment, useCallback, useMemo } from 'react';
|
||||
import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
|
||||
import { forwardRef, Fragment, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
@@ -8,6 +8,8 @@ import styles from './album-artist-detail-header.module.css';
|
||||
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
|
||||
import { artistsQueries } from '/@/renderer/features/artists/api/artists-api';
|
||||
import { getArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
|
||||
import { useDeleteArtistImage } from '/@/renderer/features/artists/mutations/delete-artist-image-mutation';
|
||||
import { useUploadArtistImage } from '/@/renderer/features/artists/mutations/upload-artist-image-mutation';
|
||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||
import { usePlayer } from '/@/renderer/features/player/context/player-context';
|
||||
import {
|
||||
@@ -20,17 +22,80 @@ import { AppRoute } from '/@/renderer/router/routes';
|
||||
import { useAppStore, useCurrentServer, useShowRatings } from '/@/renderer/store';
|
||||
import { useArtistReleaseTypeItems, usePlayButtonBehavior } from '/@/renderer/store/settings.store';
|
||||
import { formatDurationString } from '/@/renderer/utils';
|
||||
import { SEPARATOR_STRING, sortAlbumList } from '/@/shared/api/utils';
|
||||
import { hasFeature, SEPARATOR_STRING, sortAlbumList } 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 { Stack } from '/@/shared/components/stack/stack';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { AlbumListResponse, LibraryItem, ServerType } from '/@/shared/types/domain-types';
|
||||
import {
|
||||
AlbumArtistDetailResponse,
|
||||
AlbumListResponse,
|
||||
LibraryItem,
|
||||
ServerType,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
import { Play } from '/@/shared/types/types';
|
||||
|
||||
interface AlbumArtistDetailHeaderProps {
|
||||
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
|
||||
}
|
||||
|
||||
function ArtistImageUploadOverlay({
|
||||
data,
|
||||
onUploadFile,
|
||||
}: {
|
||||
data?: AlbumArtistDetailResponse;
|
||||
onUploadFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const deleteArtistImageMutation = useDeleteArtistImage({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
if (!data) return null;
|
||||
if (!hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD)) return null;
|
||||
|
||||
return (
|
||||
<Group gap="xs">
|
||||
<FileButton
|
||||
accept="image/*"
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
await onUploadFile(file);
|
||||
}}
|
||||
>
|
||||
{(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;
|
||||
deleteArtistImageMutation.mutate({
|
||||
apiClientProps: {
|
||||
serverId: data._serverId,
|
||||
},
|
||||
query: { id: data.id },
|
||||
});
|
||||
}}
|
||||
radius="xl"
|
||||
size="xs"
|
||||
variant="default"
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDetailHeaderProps>(
|
||||
({ albumsQuery }, ref) => {
|
||||
const { albumArtistId, artistId } = useParams() as {
|
||||
@@ -78,6 +143,7 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
||||
const playButtonBehavior = usePlayButtonBehavior();
|
||||
const setFavorite = useSetFavorite();
|
||||
const setRating = useSetRating();
|
||||
const uploadArtistImageMutation = useUploadArtistImage({});
|
||||
|
||||
const albumArtistDetailSort = useAppStore((state) => state.albumArtistDetailSort);
|
||||
const sortBy = albumArtistDetailSort.sortBy;
|
||||
@@ -167,40 +233,52 @@ export const AlbumArtistDetailHeader = forwardRef<HTMLDivElement, AlbumArtistDet
|
||||
[detailQuery.data],
|
||||
);
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
const headerImageUrl = useItemImageUrl({
|
||||
id: detailQuery.data?.imageId || undefined,
|
||||
imageUrl: detailQuery.data?.imageUrl,
|
||||
itemType: LibraryItem.ALBUM_ARTIST,
|
||||
type: 'itemCard',
|
||||
});
|
||||
|
||||
const artistInfoQuery = useQuery({
|
||||
...artistsQueries.albumArtistInfo({
|
||||
query: { id: routeId, limit: 10 },
|
||||
serverId: server?.id,
|
||||
}),
|
||||
enabled: Boolean(server?.id && routeId),
|
||||
type: 'header',
|
||||
});
|
||||
|
||||
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
|
||||
|
||||
const selectedImageUrl = useMemo(() => {
|
||||
return detailQuery.data?.imageUrl || imageUrl;
|
||||
}, [detailQuery.data?.imageUrl, imageUrl]);
|
||||
const canUploadArtistImage =
|
||||
hasFeature(server, ServerFeature.ARTIST_IMAGE_UPLOAD) &&
|
||||
Boolean(detailQuery.data?._serverId);
|
||||
|
||||
const alternateImageUrl = artistInfoQuery.data?.imageUrl;
|
||||
const hasImageId = Boolean(detailQuery.data?.imageId);
|
||||
const fallbackHeaderImageUrl = alternateImageUrl || selectedImageUrl;
|
||||
const handleArtistImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
const artist = detailQuery.data;
|
||||
if (!artist?._serverId) return;
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
uploadArtistImageMutation.mutate({
|
||||
apiClientProps: {
|
||||
serverId: artist._serverId,
|
||||
},
|
||||
body: { image: new Uint8Array(buffer) },
|
||||
query: { id: artist.id },
|
||||
});
|
||||
},
|
||||
[detailQuery.data, uploadArtistImageMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
<LibraryHeader
|
||||
imageUrl={hasImageId ? undefined : fallbackHeaderImageUrl}
|
||||
imageOverlay={
|
||||
<ArtistImageUploadOverlay
|
||||
data={detailQuery.data}
|
||||
onUploadFile={handleArtistImageUpload}
|
||||
/>
|
||||
}
|
||||
imageUrl={headerImageUrl}
|
||||
item={{
|
||||
imageId: detailQuery.data?.imageId,
|
||||
imageUrl: hasImageId ? undefined : fallbackHeaderImageUrl,
|
||||
imageUrl: detailQuery.data?.imageUrl,
|
||||
route: AppRoute.LIBRARY_ALBUM_ARTISTS,
|
||||
type: LibraryItem.ALBUM_ARTIST,
|
||||
}}
|
||||
onImageFileDrop={canUploadArtistImage ? handleArtistImageUpload : undefined}
|
||||
ref={ref}
|
||||
title={detailQuery.data?.name || ''}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { DeleteArtistImageArgs, DeleteArtistImageResponse } from '/@/shared/types/domain-types';
|
||||
|
||||
export const useDeleteArtistImage = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<DeleteArtistImageResponse, AxiosError, DeleteArtistImageArgs, null>({
|
||||
mutationFn: (args) => {
|
||||
return api.controller.deleteArtistImage({
|
||||
...args,
|
||||
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { apiClientProps, query } = variables;
|
||||
const serverId = apiClientProps.serverId;
|
||||
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.list(serverId),
|
||||
});
|
||||
|
||||
if (query?.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
|
||||
});
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
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 { UploadArtistImageArgs, UploadArtistImageResponse } from '/@/shared/types/domain-types';
|
||||
|
||||
export const useUploadArtistImage = (args: MutationHookArgs) => {
|
||||
const { options } = args || {};
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<UploadArtistImageResponse, AxiosError, UploadArtistImageArgs, null>({
|
||||
mutationFn: (args) => {
|
||||
return api.controller.uploadArtistImage({
|
||||
...args,
|
||||
apiClientProps: { serverId: args.apiClientProps.serverId },
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
const { apiClientProps, query } = variables;
|
||||
const serverId = apiClientProps.serverId;
|
||||
|
||||
if (!serverId) return;
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.list(serverId),
|
||||
});
|
||||
|
||||
if (query?.id) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.detail(serverId, { id: query.id }),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.albumArtists.info(serverId, { id: query.id }),
|
||||
});
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SetActivity, StatusDisplayType } from '@xhayper/discord-rpc';
|
||||
import type { SetActivity } from '@xhayper/discord-rpc';
|
||||
|
||||
import isElectron from 'is-electron';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -27,6 +28,13 @@ import { LibraryItem, QueueSong, ServerType } from '/@/shared/types/domain-types
|
||||
import { PlayerStatus } from '/@/shared/types/types';
|
||||
|
||||
const discordRpc = isElectron() ? window.api.discordRpc : null;
|
||||
|
||||
const DiscordStatusDisplayType = {
|
||||
DETAILS: 2,
|
||||
NAME: 0,
|
||||
STATE: 1,
|
||||
} as const;
|
||||
|
||||
type ActivityState = [QueueSong | undefined, number, PlayerStatus];
|
||||
|
||||
const MAX_FIELD_LENGTH = 127;
|
||||
@@ -122,7 +130,7 @@ export const useDiscordRpc = () => {
|
||||
: undefined
|
||||
: sentenceCase(current[2]),
|
||||
state: truncate(artist),
|
||||
statusDisplayType: StatusDisplayType.STATE,
|
||||
statusDisplayType: DiscordStatusDisplayType.STATE,
|
||||
type: discordSettings.showAsListening ? 2 : 0,
|
||||
};
|
||||
|
||||
@@ -196,9 +204,9 @@ export const useDiscordRpc = () => {
|
||||
const artists = song?.artists.map((artist) => artist.name).join(', ');
|
||||
|
||||
const statusDisplayMap = {
|
||||
[DiscordDisplayType.ARTIST_NAME]: StatusDisplayType.STATE,
|
||||
[DiscordDisplayType.FEISHIN]: StatusDisplayType.NAME,
|
||||
[DiscordDisplayType.SONG_NAME]: StatusDisplayType.DETAILS,
|
||||
[DiscordDisplayType.ARTIST_NAME]: DiscordStatusDisplayType.STATE,
|
||||
[DiscordDisplayType.FEISHIN]: DiscordStatusDisplayType.NAME,
|
||||
[DiscordDisplayType.SONG_NAME]: DiscordStatusDisplayType.DETAILS,
|
||||
};
|
||||
|
||||
const activity: SetActivity = {
|
||||
|
||||
@@ -131,7 +131,9 @@ export const LyricsActions = ({
|
||||
uppercase
|
||||
variant="subtle"
|
||||
>
|
||||
{t('common.clear', { postProcess: 'sentenceCase' })}
|
||||
{hasLyrics
|
||||
? t('common.clear', { postProcess: 'sentenceCase' })
|
||||
: t('common.refresh', { postProcess: 'sentenceCase' })}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
@@ -60,6 +60,7 @@ const CODEC_PROBES = [
|
||||
];
|
||||
|
||||
const DEFAULT_TRANSCODING_PROFILES = [
|
||||
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
|
||||
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
|
||||
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
|
||||
@@ -40,8 +41,13 @@ interface PlaylistDetailSongListHeaderProps {
|
||||
onToggleQueryBuilder?: () => void;
|
||||
}
|
||||
|
||||
function ImageUploadOverlay({ data }: { data?: Playlist }) {
|
||||
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
|
||||
function ImageUploadOverlay({
|
||||
data,
|
||||
onUploadFile,
|
||||
}: {
|
||||
data?: Playlist;
|
||||
onUploadFile: (file: File) => Promise<void>;
|
||||
}) {
|
||||
const deletePlaylistImageMutation = useDeletePlaylistImage({});
|
||||
const server = useCurrentServer();
|
||||
|
||||
@@ -53,16 +59,8 @@ function ImageUploadOverlay({ data }: { data?: Playlist }) {
|
||||
<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 },
|
||||
});
|
||||
if (!file) return;
|
||||
await onUploadFile(file);
|
||||
}}
|
||||
>
|
||||
{(props) => (
|
||||
@@ -121,11 +119,33 @@ export const PlaylistDetailSongListHeader = ({
|
||||
});
|
||||
|
||||
const player = usePlayer();
|
||||
const uploadPlaylistImageMutation = useUploadPlaylistImage({});
|
||||
|
||||
const handlePlay = (type?: Play) => {
|
||||
player.addToQueueByData(listData as Song[], type || Play.NOW);
|
||||
};
|
||||
|
||||
const canUploadPlaylistImage =
|
||||
hasFeature(server, ServerFeature.PLAYLIST_IMAGE_UPLOAD) &&
|
||||
Boolean(detailQuery?.data?._serverId);
|
||||
|
||||
const handlePlaylistImageUpload = useCallback(
|
||||
async (file: File) => {
|
||||
const playlist = detailQuery?.data;
|
||||
if (!playlist?._serverId) return;
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
uploadPlaylistImageMutation.mutate({
|
||||
apiClientProps: {
|
||||
serverId: playlist._serverId,
|
||||
},
|
||||
body: { image: new Uint8Array(buffer) },
|
||||
query: { id: playlist.id },
|
||||
});
|
||||
},
|
||||
[detailQuery?.data, uploadPlaylistImageMutation],
|
||||
);
|
||||
|
||||
const imageUrl = useItemImageUrl({
|
||||
id: detailQuery?.data?.imageId || undefined,
|
||||
itemType: LibraryItem.PLAYLIST,
|
||||
@@ -163,7 +183,12 @@ export const PlaylistDetailSongListHeader = ({
|
||||
) : (
|
||||
<LibraryHeader
|
||||
compact
|
||||
imageOverlay={<ImageUploadOverlay data={detailQuery?.data} />}
|
||||
imageOverlay={
|
||||
<ImageUploadOverlay
|
||||
data={detailQuery?.data}
|
||||
onUploadFile={handlePlaylistImageUpload}
|
||||
/>
|
||||
}
|
||||
imageUrl={imageUrl}
|
||||
item={{
|
||||
imageId: detailQuery?.data?.imageId,
|
||||
@@ -171,6 +196,7 @@ export const PlaylistDetailSongListHeader = ({
|
||||
route: AppRoute.PLAYLISTS,
|
||||
type: LibraryItem.PLAYLIST,
|
||||
}}
|
||||
onImageFileDrop={canUploadPlaylistImage ? handlePlaylistImageUpload : undefined}
|
||||
title={detailQuery?.data?.name || ''}
|
||||
topRight={<ListSearchInput />}
|
||||
>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useCurrentServer, useCurrentServerId, usePermissions } from '/@/rendere
|
||||
import { hasFeature } from '/@/shared/api/utils';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Box } from '/@/shared/components/box/box';
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { FileButton } from '/@/shared/components/file-button/file-button';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
@@ -270,16 +271,20 @@ function PlaylistCoverField({
|
||||
const iconControls = (
|
||||
<>
|
||||
<FileButton accept="image/*" onChange={onFileSelect}>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
icon="uploadImage"
|
||||
iconProps={{ size: 'lg' }}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{(props) => {
|
||||
const { ...triggerRest } = props;
|
||||
return (
|
||||
<ActionIcon
|
||||
icon="uploadImage"
|
||||
iconProps={{ size: 'lg' }}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
{...triggerRest}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FileButton>
|
||||
<ActionIcon
|
||||
disabled={secondaryDisabled}
|
||||
@@ -288,22 +293,12 @@ function PlaylistCoverField({
|
||||
onClick={secondaryAction}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
variant="default"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const coverArt = (
|
||||
<ItemImage
|
||||
enableViewport={false}
|
||||
id={previewId}
|
||||
itemType={LibraryItem.PLAYLIST}
|
||||
serverId={server?.id}
|
||||
src={previewSrc}
|
||||
type="header"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -315,21 +310,41 @@ function PlaylistCoverField({
|
||||
width: COVER_SIZE,
|
||||
}}
|
||||
>
|
||||
{coverArt}
|
||||
<Group
|
||||
gap={4}
|
||||
<DragDropZone
|
||||
accept="image/*"
|
||||
mode="file"
|
||||
onFileSelected={(file) => onFileSelect(file)}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
bottom: 6,
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{iconControls}
|
||||
</Group>
|
||||
<ItemImage
|
||||
enableViewport={false}
|
||||
id={previewId}
|
||||
itemType={LibraryItem.PLAYLIST}
|
||||
serverId={server?.id}
|
||||
src={previewSrc}
|
||||
type="header"
|
||||
/>
|
||||
<Group
|
||||
gap={4}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
bottom: 6,
|
||||
padding: 4,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
zIndex: 2,
|
||||
}}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{iconControls}
|
||||
</Group>
|
||||
</DragDropZone>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { FileButton } from '/@/shared/components/file-button/file-button';
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
@@ -241,16 +242,20 @@ function RadioStationCoverField({
|
||||
const iconControls = (
|
||||
<>
|
||||
<FileButton accept="image/*" onChange={onFileSelect}>
|
||||
{(props) => (
|
||||
<ActionIcon
|
||||
icon="uploadImage"
|
||||
iconProps={{ size: 'lg' }}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
{(props) => {
|
||||
const { ...triggerRest } = props;
|
||||
return (
|
||||
<ActionIcon
|
||||
icon="uploadImage"
|
||||
iconProps={{ size: 'lg' }}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
variant="default"
|
||||
{...triggerRest}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FileButton>
|
||||
<ActionIcon
|
||||
disabled={secondaryDisabled}
|
||||
@@ -259,22 +264,12 @@ function RadioStationCoverField({
|
||||
onClick={secondaryAction}
|
||||
radius="xl"
|
||||
size="sm"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
variant="default"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const coverArt = (
|
||||
<ItemImage
|
||||
enableViewport={false}
|
||||
id={previewId}
|
||||
itemType={LibraryItem.RADIO_STATION}
|
||||
serverId={server?.id}
|
||||
src={previewSrc}
|
||||
type="header"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
@@ -286,21 +281,41 @@ function RadioStationCoverField({
|
||||
width: COVER_SIZE,
|
||||
}}
|
||||
>
|
||||
{coverArt}
|
||||
<Group
|
||||
gap={4}
|
||||
<DragDropZone
|
||||
accept="image/*"
|
||||
mode="file"
|
||||
onFileSelected={(file) => onFileSelect(file)}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
borderRadius: 'var(--mantine-radius-md)',
|
||||
bottom: 6,
|
||||
padding: 4,
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{iconControls}
|
||||
</Group>
|
||||
<ItemImage
|
||||
enableViewport={false}
|
||||
id={previewId}
|
||||
itemType={LibraryItem.RADIO_STATION}
|
||||
serverId={server?.id}
|
||||
src={previewSrc}
|
||||
type="header"
|
||||
/>
|
||||
<Group
|
||||
gap={4}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.55)',
|
||||
bottom: 6,
|
||||
padding: 4,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
zIndex: 2,
|
||||
}}
|
||||
wrap="nowrap"
|
||||
>
|
||||
{iconControls}
|
||||
</Group>
|
||||
</DragDropZone>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
import { closeAllModals, openModal } from '@mantine/modals';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, ReactNode, Ref, useCallback } from 'react';
|
||||
@@ -22,6 +24,7 @@ import { useGeneralSettings } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Button } from '/@/shared/components/button/button';
|
||||
import { Center } from '/@/shared/components/center/center';
|
||||
import { DragDropZone } from '/@/shared/components/drag-drop-zone/drag-drop-zone';
|
||||
import { Group } from '/@/shared/components/group/group';
|
||||
import { Icon } from '/@/shared/components/icon/icon';
|
||||
import { BaseImage } from '/@/shared/components/image/image';
|
||||
@@ -47,6 +50,7 @@ interface LibraryHeaderProps {
|
||||
type?: LibraryItem;
|
||||
};
|
||||
loading?: boolean;
|
||||
onImageFileDrop?: (file: File) => Promise<void> | void;
|
||||
title: string;
|
||||
topRight?: ReactNode;
|
||||
}
|
||||
@@ -60,6 +64,7 @@ export const LibraryHeader = forwardRef(
|
||||
imageOverlay,
|
||||
imageUrl,
|
||||
item,
|
||||
onImageFileDrop,
|
||||
title,
|
||||
topRight,
|
||||
}: LibraryHeaderProps,
|
||||
@@ -136,6 +141,17 @@ export const LibraryHeader = forwardRef(
|
||||
});
|
||||
}, [blurExplicitImages, item.explicitStatus, item.imageId, item.type]);
|
||||
|
||||
const imageSectionSharedProps = {
|
||||
onClick: () => {
|
||||
openImage();
|
||||
},
|
||||
onKeyDown: (event: KeyboardEvent) =>
|
||||
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage(),
|
||||
role: 'button' as const,
|
||||
style: { cursor: 'pointer' as const },
|
||||
tabIndex: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -146,41 +162,63 @@ export const LibraryHeader = forwardRef(
|
||||
ref={ref}
|
||||
>
|
||||
{topRight && <div className={styles.topRight}>{topRight}</div>}
|
||||
<div
|
||||
className={styles.imageSection}
|
||||
onClick={() => {
|
||||
openImage();
|
||||
}}
|
||||
onKeyDown={(event) =>
|
||||
[' ', 'Enter', 'Spacebar'].includes(event.key) && openImage()
|
||||
}
|
||||
role="button"
|
||||
style={{ cursor: 'pointer' }}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ItemImage
|
||||
className={styles.image}
|
||||
containerClassName={styles.image}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={item.explicitStatus ?? null}
|
||||
fetchPriority="high"
|
||||
id={item.imageId}
|
||||
itemType={item.type as LibraryItem}
|
||||
src={imageUrl || ''}
|
||||
type="header"
|
||||
/>
|
||||
{imageOverlay && (
|
||||
<div
|
||||
className={styles.imageOverlay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
{imageOverlay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onImageFileDrop ? (
|
||||
<DragDropZone
|
||||
accept="image/*"
|
||||
className={styles.imageSection}
|
||||
mode="file"
|
||||
onFileSelected={(file) => void onImageFileDrop(file)}
|
||||
{...imageSectionSharedProps}
|
||||
>
|
||||
<ItemImage
|
||||
className={styles.image}
|
||||
containerClassName={styles.image}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={item.explicitStatus ?? null}
|
||||
fetchPriority="high"
|
||||
id={item.imageId}
|
||||
itemType={item.type as LibraryItem}
|
||||
src={imageUrl || ''}
|
||||
type="header"
|
||||
/>
|
||||
{imageOverlay && (
|
||||
<div
|
||||
className={styles.imageOverlay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
{imageOverlay}
|
||||
</div>
|
||||
)}
|
||||
</DragDropZone>
|
||||
) : (
|
||||
<div className={styles.imageSection} {...imageSectionSharedProps}>
|
||||
<ItemImage
|
||||
className={styles.image}
|
||||
containerClassName={styles.image}
|
||||
enableDebounce={false}
|
||||
enableViewport={false}
|
||||
explicitStatus={item.explicitStatus ?? null}
|
||||
fetchPriority="high"
|
||||
id={item.imageId}
|
||||
itemType={item.type as LibraryItem}
|
||||
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}>
|
||||
{item.children ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Suspense } from 'react';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
import styles from './mobile-layout.module.css';
|
||||
@@ -10,6 +10,7 @@ import { FullScreenVisualizer } from '/@/renderer/features/player/components/ful
|
||||
import { MobileFullscreenPlayer } from '/@/renderer/features/player/components/mobile-fullscreen-player';
|
||||
import { MobileSidebar } from '/@/renderer/features/sidebar/components/mobile-sidebar';
|
||||
import { PlayerBar } from '/@/renderer/layouts/default-layout/player-bar';
|
||||
import { WindowBar } from '/@/renderer/layouts/window-bar';
|
||||
import { useFullScreenPlayerOverlayState, useWindowBarStyle } from '/@/renderer/store';
|
||||
import { ActionIcon } from '/@/shared/components/action-icon/action-icon';
|
||||
import { Drawer } from '/@/shared/components/drawer/drawer';
|
||||
@@ -17,12 +18,6 @@ import { Spinner } from '/@/shared/components/spinner/spinner';
|
||||
import { useDisclosure } from '/@/shared/hooks/use-disclosure';
|
||||
import { Platform } from '/@/shared/types/types';
|
||||
|
||||
const WindowBar = lazy(() =>
|
||||
import('/@/renderer/layouts/window-bar').then((module) => ({
|
||||
default: module.WindowBar,
|
||||
})),
|
||||
);
|
||||
|
||||
interface MobileLayoutProps {
|
||||
shell?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { HashRouter, Route, Routes } from 'react-router';
|
||||
|
||||
import { ShuffleAllContextModal } from '/@/renderer/features/player/components/shuffle-all-modal';
|
||||
import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary';
|
||||
import { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet';
|
||||
import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout';
|
||||
@@ -96,18 +97,6 @@ const LyricsSettingsContextModal = (props: any) => (
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const LazyShuffleAllContextModal = lazy(() =>
|
||||
import('/@/renderer/features/player/components/shuffle-all-modal').then((module) => ({
|
||||
default: module.ShuffleAllContextModal,
|
||||
})),
|
||||
);
|
||||
|
||||
const ShuffleAllContextModal = (props: any) => (
|
||||
<Suspense fallback={<Spinner container />}>
|
||||
<LazyShuffleAllContextModal {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const LazyAddToPlaylistContextModal = lazy(() =>
|
||||
import('/@/renderer/features/playlists/components/add-to-playlist-context-modal').then(
|
||||
(module) => ({
|
||||
@@ -200,7 +189,7 @@ const appRouterModals = {
|
||||
|
||||
export const AppRouter = () => {
|
||||
const router = (
|
||||
<HashRouter unstable_useTransitions>
|
||||
<HashRouter unstable_useTransitions={false}>
|
||||
<ModalsProvider modals={appRouterModals}>
|
||||
<RouterErrorBoundary>
|
||||
<Routes>
|
||||
|
||||
@@ -18,14 +18,20 @@ import {
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerListItem, ServerType } from '/@/shared/types/types';
|
||||
|
||||
const getImageUrl = (args: { url: null | string }) => {
|
||||
const { url } = args;
|
||||
if (url === '/app/artist-placeholder.webp') {
|
||||
return null;
|
||||
}
|
||||
// const getImageUrl = (args: { url: null | string }) => {
|
||||
// const { url } = args;
|
||||
// if (url === '/app/artist-placeholder.webp') {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return url;
|
||||
};
|
||||
// return url;
|
||||
// };
|
||||
|
||||
const navidromeImageIdWithCacheBust = (
|
||||
id: string,
|
||||
uploadedImage: string | undefined,
|
||||
updatedAt: string | undefined,
|
||||
): string => (!uploadedImage ? id : `${id}&_=${updatedAt ?? ''}`);
|
||||
|
||||
interface WithDate {
|
||||
playDate?: string;
|
||||
@@ -397,7 +403,7 @@ const normalizeAlbumArtist = (
|
||||
},
|
||||
server?: null | ServerListItem,
|
||||
): AlbumArtist => {
|
||||
const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
|
||||
// const imageUrl = getImageUrl({ url: item?.largeImageUrl?.replace(/\?size=\d+/, '') || null });
|
||||
|
||||
let albumCount: number;
|
||||
let songCount: number;
|
||||
@@ -416,6 +422,12 @@ const normalizeAlbumArtist = (
|
||||
songCount = item.songCount;
|
||||
}
|
||||
|
||||
const imageId = navidromeImageIdWithCacheBust(
|
||||
item.id,
|
||||
item.uploadedImage,
|
||||
item.updatedAt ?? item.externalInfoUpdatedAt,
|
||||
);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.ALBUM_ARTIST,
|
||||
_serverId: server?.id || 'unknown',
|
||||
@@ -435,8 +447,8 @@ const normalizeAlbumArtist = (
|
||||
songCount: null,
|
||||
})),
|
||||
id: item.id,
|
||||
imageId: item.id,
|
||||
imageUrl: imageUrl || null,
|
||||
imageId,
|
||||
imageUrl: null,
|
||||
lastPlayedAt: normalizePlayDate(item),
|
||||
mbz: item.mbzArtistId || null,
|
||||
name: item.name,
|
||||
@@ -451,6 +463,7 @@ const normalizeAlbumArtist = (
|
||||
userRating: artist.userRating || null,
|
||||
})) || [],
|
||||
songCount,
|
||||
uploadedImage: item.uploadedImage,
|
||||
userFavorite: item.starred || false,
|
||||
userRating: item.rating || null,
|
||||
};
|
||||
@@ -460,7 +473,7 @@ const normalizePlaylist = (
|
||||
item: z.infer<typeof ndType._response.playlist>,
|
||||
server?: null | ServerListItem,
|
||||
): Playlist => {
|
||||
const imageId = !item.uploadedImage ? item.id : `${item.id}&_=${item.updatedAt}`;
|
||||
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
|
||||
|
||||
return {
|
||||
_itemType: LibraryItem.PLAYLIST,
|
||||
@@ -517,7 +530,7 @@ 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;
|
||||
const imageId = navidromeImageIdWithCacheBust(item.id, item.uploadedImage, item.updatedAt);
|
||||
|
||||
return {
|
||||
homepageUrl,
|
||||
|
||||
@@ -428,6 +428,7 @@ const albumArtist = z.object({
|
||||
starredAt: z.string(),
|
||||
stats: z.record(z.string(), stats).optional(),
|
||||
updatedAt: z.string().optional(),
|
||||
uploadedImage: z.string().optional(),
|
||||
});
|
||||
|
||||
const albumArtistList = z.array(albumArtist);
|
||||
@@ -683,6 +684,9 @@ const deletePlaylistImage = z.object({
|
||||
|
||||
const uploadInternetRadioStationImage = uploadPlaylistImage;
|
||||
const uploadInternetRadioStationImageParameters = uploadPlaylistImageParameters;
|
||||
const uploadArtistImage = uploadPlaylistImage;
|
||||
const uploadArtistImageParameters = uploadPlaylistImageParameters;
|
||||
const deleteArtistImage = deletePlaylistImage;
|
||||
const deleteInternetRadioStationImage = deletePlaylistImage;
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
@@ -813,6 +817,7 @@ export const ndType = {
|
||||
tagList: tagListParameters,
|
||||
updateInternetRadioStation: updateInternetRadioStationParameters,
|
||||
updatePlaylist: updatePlaylistParameters,
|
||||
uploadArtistImage: uploadArtistImageParameters,
|
||||
uploadInternetRadioStationImage: uploadInternetRadioStationImageParameters,
|
||||
uploadPlaylistImage: uploadPlaylistImageParameters,
|
||||
userList: userListParameters,
|
||||
@@ -825,6 +830,7 @@ export const ndType = {
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deleteArtistImage,
|
||||
deleteInternetRadioStation,
|
||||
deleteInternetRadioStationImage,
|
||||
deletePlaylist,
|
||||
@@ -848,6 +854,7 @@ export const ndType = {
|
||||
tagList,
|
||||
updateInternetRadioStation,
|
||||
updatePlaylist,
|
||||
uploadArtistImage,
|
||||
uploadInternetRadioStationImage,
|
||||
uploadPlaylistImage,
|
||||
user,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Inset outline on the root is hidden behind a full-bleed ItemImage; a ::after layer paints
|
||||
* above the image. Keep z-index below overlay controls (e.g. z-index: 2).
|
||||
* Avoid positive outline-offset so ancestors with overflow:hidden do not clip the indicator.
|
||||
*/
|
||||
.file-target-drag-over {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-target-drag-over::after {
|
||||
position: absolute;
|
||||
inset: calc(var(--theme-spacing-sm) * -1);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
border-radius: var(--theme-radius-md);
|
||||
box-shadow: inset 0 0 0 3px var(--theme-colors-primary);
|
||||
}
|
||||
@@ -1,17 +1,38 @@
|
||||
import type { ChangeEvent, DragEvent, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import clsx from 'clsx';
|
||||
import { t } from 'i18next';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import styles from './drag-drop-zone.module.css';
|
||||
|
||||
import { Flex } from '/@/shared/components/flex/flex';
|
||||
import { AppIcon, Icon } from '/@/shared/components/icon/icon';
|
||||
import { Text } from '/@/shared/components/text/text';
|
||||
import { isNativeFileDrag, pickFirstImageFile } from '/@/shared/utils/image-drop';
|
||||
|
||||
interface DragDropZoneProps {
|
||||
export interface DragDropZoneFileProps extends DivProps {
|
||||
accept?: string;
|
||||
children: ReactNode;
|
||||
mode: 'file';
|
||||
onFileSelected: (file: File) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type DragDropZoneProps = DragDropZoneFileProps | DragDropZoneTextProps;
|
||||
|
||||
type DivProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children' | 'onDragEnter' | 'onDragLeave' | 'onDragOver' | 'onDrop'
|
||||
>;
|
||||
|
||||
interface DragDropZoneTextProps {
|
||||
icon: keyof typeof AppIcon;
|
||||
mode?: 'text';
|
||||
onItemSelected: (contents: string) => void;
|
||||
validateItem?: (contents: string) => { error?: string; isValid: boolean };
|
||||
}
|
||||
|
||||
export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZoneProps) => {
|
||||
const DragDropZoneText = ({ icon, onItemSelected, validateItem }: DragDropZoneTextProps) => {
|
||||
const zoneFileInput = useRef<HTMLInputElement | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
@@ -32,7 +53,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
);
|
||||
|
||||
const onItemDropped = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const items = event.dataTransfer.items;
|
||||
@@ -62,7 +83,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
[processItem],
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}, []);
|
||||
@@ -72,7 +93,7 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
}, []);
|
||||
|
||||
const onZoneInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const { files } = event.target;
|
||||
|
||||
if (!files || files.length > 1) {
|
||||
@@ -131,3 +152,83 @@ export const DragDropZone = ({ icon, onItemSelected, validateItem }: DragDropZon
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const DragDropZoneFile = (props: DragDropZoneFileProps) => {
|
||||
const { accept = 'image/*', children, className, mode, onFileSelected, ...divProps } = props;
|
||||
void mode;
|
||||
const fileDragDepth = useRef(0);
|
||||
const [fileDragOver, setFileDragOver] = useState(false);
|
||||
|
||||
const resolveFile = useCallback(
|
||||
(dataTransfer: DataTransfer): File | null => {
|
||||
if (accept === 'image/*') {
|
||||
return pickFirstImageFile(dataTransfer.files);
|
||||
}
|
||||
const first = dataTransfer.files?.item(0);
|
||||
return first ?? null;
|
||||
},
|
||||
[accept],
|
||||
);
|
||||
|
||||
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current += 1;
|
||||
setFileDragOver(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current -= 1;
|
||||
if (fileDragDepth.current <= 0) {
|
||||
fileDragDepth.current = 0;
|
||||
setFileDragOver(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: DragEvent<HTMLDivElement>) => {
|
||||
if (!isNativeFileDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
fileDragDepth.current = 0;
|
||||
setFileDragOver(false);
|
||||
const file = resolveFile(e.dataTransfer);
|
||||
if (file) void onFileSelected(file);
|
||||
},
|
||||
[onFileSelected, resolveFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={clsx(className, {
|
||||
[styles.fileTargetDragOver]: fileDragOver,
|
||||
})}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DragDropZone = (props: DragDropZoneProps) => {
|
||||
if (props.mode === 'file') {
|
||||
return <DragDropZoneFile {...props} />;
|
||||
}
|
||||
|
||||
return <DragDropZoneText {...props} />;
|
||||
};
|
||||
|
||||
@@ -225,6 +225,7 @@ export type AlbumArtist = {
|
||||
playCount: null | number;
|
||||
similarArtists: null | RelatedArtist[];
|
||||
songCount: null | number;
|
||||
uploadedImage?: string;
|
||||
userFavorite: boolean;
|
||||
userRating: null | number;
|
||||
};
|
||||
@@ -957,6 +958,16 @@ export type CreatePlaylistBody = {
|
||||
// Create Playlist
|
||||
export type CreatePlaylistResponse = undefined | { id: string };
|
||||
|
||||
export type DeleteArtistImageArgs = BaseEndpointArgs & {
|
||||
query: DeleteArtistImageQuery;
|
||||
};
|
||||
|
||||
export type DeleteArtistImageQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type DeleteArtistImageResponse = boolean;
|
||||
|
||||
export type DeleteInternetRadioStationArgs = BaseEndpointArgs & {
|
||||
query: DeleteInternetRadioStationQuery;
|
||||
};
|
||||
@@ -1132,6 +1143,21 @@ export type UpdatePlaylistQuery = {
|
||||
// Update Playlist
|
||||
export type UpdatePlaylistResponse = null | undefined;
|
||||
|
||||
export type UploadArtistImageArgs = BaseEndpointArgs & {
|
||||
body: UploadArtistImageBody;
|
||||
query: UploadArtistImageQuery;
|
||||
};
|
||||
|
||||
export type UploadArtistImageBody = {
|
||||
image: Uint8Array;
|
||||
};
|
||||
|
||||
export type UploadArtistImageQuery = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type UploadArtistImageResponse = boolean;
|
||||
|
||||
export type UploadInternetRadioStationImageArgs = BaseEndpointArgs & {
|
||||
body: UploadInternetRadioStationImageBody;
|
||||
query: UploadInternetRadioStationImageQuery;
|
||||
@@ -1441,6 +1467,7 @@ export type ControllerEndpoint = {
|
||||
args: CreateInternetRadioStationArgs,
|
||||
) => Promise<CreateInternetRadioStationResponse>;
|
||||
createPlaylist: (args: CreatePlaylistArgs) => Promise<CreatePlaylistResponse>;
|
||||
deleteArtistImage?: (args: DeleteArtistImageArgs) => Promise<DeleteArtistImageResponse>;
|
||||
deleteFavorite: (args: FavoriteArgs) => Promise<FavoriteResponse>;
|
||||
deleteInternetRadioStation: (
|
||||
args: DeleteInternetRadioStationArgs,
|
||||
@@ -1503,6 +1530,7 @@ export type ControllerEndpoint = {
|
||||
args: UpdateInternetRadioStationArgs,
|
||||
) => Promise<UpdateInternetRadioStationResponse>;
|
||||
updatePlaylist: (args: UpdatePlaylistArgs) => Promise<UpdatePlaylistResponse>;
|
||||
uploadArtistImage?: (args: UploadArtistImageArgs) => Promise<UploadArtistImageResponse>;
|
||||
uploadInternetRadioStationImage?: (
|
||||
args: UploadInternetRadioStationImageArgs,
|
||||
) => Promise<UploadInternetRadioStationImageResponse>;
|
||||
@@ -1572,6 +1600,9 @@ export type InternalControllerEndpoint = {
|
||||
createPlaylist: (
|
||||
args: ReplaceApiClientProps<CreatePlaylistArgs>,
|
||||
) => Promise<CreatePlaylistResponse>;
|
||||
deleteArtistImage?: (
|
||||
args: ReplaceApiClientProps<DeleteArtistImageArgs>,
|
||||
) => Promise<DeleteArtistImageResponse>;
|
||||
deleteFavorite: (args: ReplaceApiClientProps<FavoriteArgs>) => Promise<FavoriteResponse>;
|
||||
deleteInternetRadioStation: (
|
||||
args: ReplaceApiClientProps<DeleteInternetRadioStationArgs>,
|
||||
@@ -1669,6 +1700,9 @@ export type InternalControllerEndpoint = {
|
||||
updatePlaylist: (
|
||||
args: ReplaceApiClientProps<UpdatePlaylistArgs>,
|
||||
) => Promise<UpdatePlaylistResponse>;
|
||||
uploadArtistImage?: (
|
||||
args: ReplaceApiClientProps<UploadArtistImageArgs>,
|
||||
) => Promise<UploadArtistImageResponse>;
|
||||
uploadInternetRadioStationImage?: (
|
||||
args: ReplaceApiClientProps<UploadInternetRadioStationImageArgs>,
|
||||
) => Promise<UploadInternetRadioStationImageResponse>;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// For example: <FEATURE GROUP>: "Playlists", <FEATURE NAME>: "Smart" = "PLAYLISTS_SMART"
|
||||
export enum ServerFeature {
|
||||
ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter',
|
||||
ARTIST_IMAGE_UPLOAD = 'artistImageUpload',
|
||||
BFR = 'bfr',
|
||||
INTERNET_RADIO_IMAGE_UPLOAD = 'internetRadioImageUpload',
|
||||
LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DragEvent } from 'react';
|
||||
|
||||
// OS / native file drag (vs in-app library drag).
|
||||
export function isNativeFileDrag(event: DragEvent): boolean {
|
||||
return event.dataTransfer.types.includes('Files');
|
||||
}
|
||||
|
||||
// First file in the list whose MIME type is an image.
|
||||
export function pickFirstImageFile(files: FileList | null): File | null {
|
||||
if (!files?.length) return null;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files.item(i);
|
||||
if (f?.type.startsWith('image/')) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user