feat(device-sync): overhaul Device Sync page

- New two-panel layout with live album search (search3, 300ms debounce)
  and 10 random albums when search is empty — replaces full paginated load
- Pre-sync summary modal: shows files to add/delete, net change, available
  space; Proceed button disabled when space is insufficient
- calculate_sync_payload now filters already-synced tracks (path exists on
  device) so add_bytes/add_count reflect the true delta
- Space check accounts for pending deletions: addBytes > availableBytes + delBytes
- Separate deviceSyncJobStore for ephemeral job progress state
- Removable drive detection + auto-poll every 5 s via sysinfo
- Desired-state diff: de-selecting a synced source stages it for deletion
  instead of silently removing it
- Status badges (Synced / Pending / Deletion) with matching row highlights
- Live-Search badge () and Zufallsalben section label (⇌) in album browser
- Status summary row height aligned with search field (52 px)
- i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-14 21:37:35 +02:00
parent 915f0143f7
commit d34de09673
18 changed files with 1912 additions and 279 deletions
+11 -4
View File
@@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) {
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
}
function getClient() {
export function getClient() {
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer();
const baseUrl = getBaseUrl();
@@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
}
export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', {
const data = await api<{ artists: { index: any } }>('getArtists.view', {
...libraryFilterParams(),
});
const indices = data.artists?.index ?? [];
return indices.flatMap(i => i.artist ?? []);
const rawIdx = data.artists?.index;
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
const artists: SubsonicArtist[] = [];
for (const idx of indices) {
const rawArt = idx.artist;
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
artists.push(...arr);
}
return artists;
}
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
+20
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom';
@@ -50,6 +51,12 @@ export default function Sidebar({
const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
const isSyncing = syncJobStatus === 'running';
const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
@@ -369,6 +376,19 @@ export default function Sidebar({
</button>
</div>
)}
{isSyncing && (
<div
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
data-tooltip-pos="right"
>
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
)}
</div>
)}
</nav>
</aside>
);
+32
View File
@@ -17,6 +17,7 @@ export const deTranslation = {
expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden',
downloadingTracks: '{{n}} Tracks werden gecacht…',
syncingTracks: 'Synchronisiere {{done}}/{{total}}…',
cancelDownload: 'Download abbrechen',
offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres',
@@ -1026,8 +1027,15 @@ export const deTranslation = {
title: 'Gerätesync',
targetFolder: 'Zielordner',
noFolderChosen: 'Kein Ordner gewählt',
selectDrive: 'Laufwerk auswählen…',
refreshDrives: 'Laufwerke aktualisieren',
noDrivesDetected: 'Keine Wechseldatenträger erkannt',
browseManual: 'Manuell durchsuchen…',
free: 'frei',
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
chooseFolder: 'Auswählen…',
filenameTemplate: 'Dateinamen-Vorlage',
targetDevice: 'Zielgerät',
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'Auf dem Gerät',
addSources: 'Hinzufügen…',
@@ -1045,6 +1053,10 @@ export const deTranslation = {
tabArtists: 'Künstler',
searchPlaceholder: 'Suche…',
syncButton: 'Auf Gerät übertragen',
actionTransfer: 'Auf Gerät übertragen',
actionDelete: 'Vom Gerät löschen',
actionApplyAll: 'Änderungen synchronisieren',
templatePreview: 'Vorschau',
cancel: 'Abbrechen',
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
noSources: 'Bitte mindestens eine Quelle auswählen.',
@@ -1052,5 +1064,25 @@ export const deTranslation = {
fetchError: 'Fehler beim Laden der Tracks vom Server.',
syncComplete: 'Übertragung abgeschlossen.',
dismiss: 'Schließen',
colStatus: 'Status',
statusSynced: 'Synchronisiert',
statusPending: 'Ausstehend',
statusDeletion: 'Löschung',
markForDeletion: 'Zur Löschung markieren',
removeSource: 'Entfernen',
undoDeletion: 'Löschung rückgängig',
syncInBackground: 'Sync gestartet — du kannst die Seite verlassen.',
syncInProgress: '{{done}} / {{total}} Tracks…',
syncSummary: 'Sync-Zusammenfassung',
calculating: 'Payload wird berechnet…',
filesToAdd: 'Hinzuzufügende Dateien:',
filesToDelete: 'Zu löschende Dateien:',
netChange: 'Nettoänderung:',
availableSpace: 'Verfügbarer Speicher:',
spaceWarning: 'Warnung: Das Zielgerät hat nicht genug gemeldeten Speicherplatz.',
proceed: 'Sync durchführen',
notEnoughSpace: 'Nicht genug physischer Speicherplatz erkannt!',
liveSearch: 'Live',
randomAlbumsLabel: 'Zufallsalben',
},
};
+35 -2
View File
@@ -18,6 +18,7 @@ export const enTranslation = {
collapse: 'Collapse Sidebar',
downloadingTracks: 'Caching {{n}} tracks…',
syncingTracks: 'Syncing {{done}}/{{total}}…',
cancelDownload: 'Cancel download',
offlineLibrary: 'Offline Library',
genres: 'Genres',
@@ -1028,15 +1029,23 @@ export const enTranslation = {
title: 'Device Sync',
targetFolder: 'Target Folder',
noFolderChosen: 'No folder chosen',
selectDrive: 'Select a drive…',
refreshDrives: 'Refresh drives',
noDrivesDetected: 'No removable drives detected',
browseManual: 'Browse manually…',
free: 'free',
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
chooseFolder: 'Choose…',
filenameTemplate: 'Filename Template',
targetDevice: 'Target Device',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'On Device',
onDevice: 'Device Manager',
addSources: 'Add…',
colName: 'Name',
colType: 'Type',
colStatus: 'Status',
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
deleteFromDevice: 'Delete from device ({{count}})',
deleteFromDevice: 'Mark for deletion ({{count}})',
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
deleteComplete: '{{count}} item(s) removed from device.',
selectedSources: 'Selected Sources',
@@ -1047,6 +1056,10 @@ export const enTranslation = {
tabArtists: 'Artists',
searchPlaceholder: 'Search…',
syncButton: 'Sync to Device',
actionTransfer: 'Transfer to Device',
actionDelete: 'Delete from Device',
actionApplyAll: 'Apply All Changes',
templatePreview: 'Preview',
cancel: 'Cancel',
noTargetDir: 'Please choose a target folder first.',
noSources: 'Please select at least one source.',
@@ -1054,5 +1067,25 @@ export const enTranslation = {
fetchError: 'Failed to fetch tracks from server.',
syncComplete: 'Sync complete.',
dismiss: 'Dismiss',
statusSynced: 'Synced',
statusPending: 'Pending',
statusDeletion: 'Deletion',
markForDeletion: 'Mark for deletion',
undoDeletion: 'Undo deletion',
removeSource: 'Remove',
syncInBackground: 'Sync started in background — you can navigate away.',
syncInProgress: '{{done}} / {{total}} tracks…',
scanningDevice: 'Scanning device…',
syncSummary: 'Sync Summary',
calculating: 'Calculating required payload…',
filesToAdd: 'Files to Add:',
filesToDelete: 'Files to Delete:',
netChange: 'Net Change:',
availableSpace: 'Available Disk Space:',
spaceWarning: 'Warning: Target device does not have enough reported space.',
proceed: 'Proceed with Sync',
notEnoughSpace: 'Not enough physical disk space detected!',
liveSearch: 'Live',
randomAlbumsLabel: 'Random Albums',
},
};
+40
View File
@@ -18,6 +18,7 @@ export const esTranslation = {
collapse: 'Colapsar Barra Lateral',
downloadingTracks: 'Descargando {{n}} pistas…',
syncingTracks: 'Sincronizando {{done}}/{{total}}…',
cancelDownload: 'Cancelar descarga',
offlineLibrary: 'Biblioteca Offline',
genres: 'Géneros',
@@ -1029,8 +1030,15 @@ export const esTranslation = {
title: 'Sincronizar dispositivo',
targetFolder: 'Carpeta de destino',
noFolderChosen: 'Ninguna carpeta seleccionada',
selectDrive: 'Seleccionar unidad…',
refreshDrives: 'Actualizar unidades',
noDrivesDetected: 'No se detectaron unidades extraíbles',
browseManual: 'Explorar manualmente…',
free: 'libre',
notMountedVolume: 'El destino no está en un volumen montado. La unidad puede haberse desconectado.',
chooseFolder: 'Elegir…',
filenameTemplate: 'Plantilla de nombre de archivo',
targetDevice: 'Dispositivo de destino',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Eliminar archivos fuera de la selección',
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
@@ -1044,6 +1052,10 @@ export const esTranslation = {
tabArtists: 'Artistas',
searchPlaceholder: 'Buscar…',
syncButton: 'Sincronizar al dispositivo',
actionTransfer: 'Transferir al dispositivo',
actionDelete: 'Eliminar del dispositivo',
actionApplyAll: 'Aplicar todos los cambios',
templatePreview: 'Vista previa',
cancel: 'Cancelar',
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
noSources: 'Por favor, selecciona al menos una fuente.',
@@ -1051,5 +1063,33 @@ export const esTranslation = {
fetchError: 'Error al obtener pistas del servidor.',
syncComplete: 'Sincronización completada.',
dismiss: 'Cerrar',
colName: 'Nombre',
colType: 'Tipo',
colStatus: 'Estado',
onDevice: 'Gestor de dispositivo',
addSources: 'Agregar…',
syncResult: '{{done}} transferido(s), {{skipped}} ya actualizado(s) ({{total}} total)',
deleteFromDevice: 'Marcar para eliminar ({{count}})',
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
statusSynced: 'Sincronizado',
statusPending: 'Pendiente',
statusDeletion: 'Eliminación',
markForDeletion: 'Marcar para eliminar',
removeSource: 'Eliminar',
undoDeletion: 'Deshacer eliminación',
syncInBackground: 'Sincronización iniciada en segundo plano — puedes navegar a otro lugar.',
syncInProgress: '{{done}} / {{total}} pistas…',
syncSummary: 'Resumen de sincronización',
calculating: 'Calculando la carga útil requerida…',
filesToAdd: 'Archivos a agregar:',
filesToDelete: 'Archivos a eliminar:',
netChange: 'Cambio neto:',
availableSpace: 'Espacio disponible en disco:',
spaceWarning: 'Advertencia: El dispositivo de destino no tiene suficiente espacio reportado.',
proceed: 'Proceder con la sincronización',
notEnoughSpace: '¡Espacio físico en disco insuficiente detectado!',
liveSearch: 'Live',
randomAlbumsLabel: 'Álbumes aleatorios',
},
};
+40
View File
@@ -17,6 +17,7 @@ export const frTranslation = {
expand: 'Développer la barre latérale',
collapse: 'Réduire la barre latérale',
downloadingTracks: '{{n}} pistes en cache…',
syncingTracks: 'Synchro {{done}}/{{total}}…',
cancelDownload: 'Annuler le téléchargement',
offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres',
@@ -1024,8 +1025,15 @@ export const frTranslation = {
title: 'Sync appareil',
targetFolder: 'Dossier cible',
noFolderChosen: 'Aucun dossier sélectionné',
selectDrive: 'Sélectionner un lecteur…',
refreshDrives: 'Actualiser les lecteurs',
noDrivesDetected: 'Aucun lecteur amovible détecté',
browseManual: 'Parcourir manuellement…',
free: 'libre',
notMountedVolume: 'La cible n\u0027est pas sur un volume monté. Le lecteur a peut-être été déconnecté.',
chooseFolder: 'Choisir…',
filenameTemplate: 'Modèle de nom de fichier',
targetDevice: 'Appareil cible',
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Supprimer les fichiers absents',
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
@@ -1039,6 +1047,10 @@ export const frTranslation = {
tabArtists: 'Artistes',
searchPlaceholder: 'Rechercher…',
syncButton: 'Synchroniser vers l\'appareil',
actionTransfer: 'Transférer vers l\'appareil',
actionDelete: 'Supprimer de l\'appareil',
actionApplyAll: 'Appliquer toutes les modifications',
templatePreview: 'Aperçu',
cancel: 'Annuler',
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
noSources: 'Veuillez sélectionner au moins une source.',
@@ -1046,5 +1058,33 @@ export const frTranslation = {
fetchError: 'Échec du chargement des pistes depuis le serveur.',
syncComplete: 'Synchronisation terminée.',
dismiss: 'Fermer',
colName: 'Nom',
colType: 'Type',
colStatus: 'Statut',
onDevice: 'Gestionnaire d\'appareil',
addSources: 'Ajouter…',
syncResult: '{{done}} transféré(s), {{skipped}} déjà à jour ({{total}} au total)',
deleteFromDevice: 'Marquer pour suppression ({{count}})',
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
statusSynced: 'Synchronisé',
statusPending: 'En attente',
statusDeletion: 'Suppression',
markForDeletion: 'Marquer pour suppression',
removeSource: 'Supprimer',
undoDeletion: 'Annuler la suppression',
syncInBackground: 'Sync démarré en arrière-plan — vous pouvez naviguer ailleurs.',
syncInProgress: '{{done}} / {{total}} pistes…',
syncSummary: 'Résumé de la synchronisation',
calculating: 'Calcul de la charge utile…',
filesToAdd: 'Fichiers à ajouter :',
filesToDelete: 'Fichiers à supprimer :',
netChange: 'Variation nette :',
availableSpace: 'Espace disque disponible :',
spaceWarning: 'Attention : L\'appareil cible ne dispose pas d\'assez d\'espace signalé.',
proceed: 'Procéder à la synchronisation',
notEnoughSpace: 'Espace disque physique insuffisant détecté !',
liveSearch: 'Live',
randomAlbumsLabel: 'Albums aléatoires',
},
};
+40
View File
@@ -17,6 +17,7 @@ export const nbTranslation = {
expand: 'Utvid sidefelt',
collapse: 'Skjul sidefelt',
downloadingTracks: 'Bufre {{n}} spor…',
syncingTracks: 'Synkroniserer {{done}}/{{total}}…',
cancelDownload: 'Avbryt nedlasting',
offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere',
@@ -1023,8 +1024,15 @@ export const nbTranslation = {
title: 'Enhetssynk',
targetFolder: 'Målmappe',
noFolderChosen: 'Ingen mappe valgt',
selectDrive: 'Velg en stasjon…',
refreshDrives: 'Oppdater stasjoner',
noDrivesDetected: 'Ingen flyttbare stasjoner oppdaget',
browseManual: 'Bla manuelt…',
free: 'ledig',
notMountedVolume: 'Målet er ikke på en montert stasjon. Enheten kan ha blitt koblet fra.',
chooseFolder: 'Velg…',
filenameTemplate: 'Filnavnmal',
targetDevice: 'Målenhet',
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Fjern filer som ikke er i utvalget',
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
@@ -1038,6 +1046,10 @@ export const nbTranslation = {
tabArtists: 'Artister',
searchPlaceholder: 'Søk…',
syncButton: 'Synkroniser til enhet',
actionTransfer: 'Overfør til enhet',
actionDelete: 'Slett fra enhet',
actionApplyAll: 'Bruk alle endringer',
templatePreview: 'Forhåndsvisning',
cancel: 'Avbryt',
noTargetDir: 'Velg en målmappe først.',
noSources: 'Velg minst én kilde.',
@@ -1045,5 +1057,33 @@ export const nbTranslation = {
fetchError: 'Kunne ikke hente spor fra serveren.',
syncComplete: 'Synkronisering fullført.',
dismiss: 'Lukk',
colName: 'Navn',
colType: 'Type',
colStatus: 'Status',
onDevice: 'Enhetsbehandling',
addSources: 'Legg til…',
syncResult: '{{done}} overført, {{skipped}} allerede oppdatert ({{total}} totalt)',
deleteFromDevice: 'Merk for sletting ({{count}})',
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
statusSynced: 'Synkronisert',
statusPending: 'Venter',
statusDeletion: 'Sletting',
markForDeletion: 'Merk for sletting',
removeSource: 'Fjern',
undoDeletion: 'Angre sletting',
syncInBackground: 'Synkronisering startet i bakgrunnen — du kan navigere bort.',
syncInProgress: '{{done}} / {{total}} spor…',
syncSummary: 'Synkroniseringssammendrag',
calculating: 'Beregner nødvendig nyttelast…',
filesToAdd: 'Filer som skal legges til:',
filesToDelete: 'Filer som skal slettes:',
netChange: 'Nettoendring:',
availableSpace: 'Tilgjengelig diskplass:',
spaceWarning: 'Advarsel: Målenheten har ikke nok rapportert plass.',
proceed: 'Fortsett med synkronisering',
notEnoughSpace: 'Ikke nok fysisk diskplass oppdaget!',
liveSearch: 'Live',
randomAlbumsLabel: 'Tilfeldige album',
},
};
+40
View File
@@ -17,6 +17,7 @@ export const nlTranslation = {
expand: 'Zijbalk uitklappen',
collapse: 'Zijbalk inklappen',
downloadingTracks: '{{n}} nummers worden gecached…',
syncingTracks: 'Synchroniseren {{done}}/{{total}}…',
cancelDownload: 'Download annuleren',
offlineLibrary: 'Offline bibliotheek',
genres: 'Genres',
@@ -1021,8 +1022,15 @@ export const nlTranslation = {
title: 'Apparaatsync',
targetFolder: 'Doelmap',
noFolderChosen: 'Geen map gekozen',
selectDrive: 'Selecteer een schijf…',
refreshDrives: 'Schijven vernieuwen',
noDrivesDetected: 'Geen verwijderbare schijven gedetecteerd',
browseManual: 'Handmatig bladeren…',
free: 'vrij',
notMountedVolume: 'Het doel bevindt zich niet op een gekoppeld volume. De schijf is mogelijk losgekoppeld.',
chooseFolder: 'Kiezen…',
filenameTemplate: 'Bestandsnaamsjabloon',
targetDevice: 'Doelapparaat',
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
@@ -1036,6 +1044,10 @@ export const nlTranslation = {
tabArtists: 'Artiesten',
searchPlaceholder: 'Zoeken…',
syncButton: 'Synchroniseren naar apparaat',
actionTransfer: 'Overdragen naar apparaat',
actionDelete: 'Verwijderen van apparaat',
actionApplyAll: 'Alle wijzigingen toepassen',
templatePreview: 'Voorbeeld',
cancel: 'Annuleren',
noTargetDir: 'Kies eerst een doelmap.',
noSources: 'Selecteer minimaal één bron.',
@@ -1043,5 +1055,33 @@ export const nlTranslation = {
fetchError: 'Ophalen van nummers van de server mislukt.',
syncComplete: 'Synchronisatie voltooid.',
dismiss: 'Sluiten',
colName: 'Naam',
colType: 'Type',
colStatus: 'Status',
onDevice: 'Apparaatbeheer',
addSources: 'Toevoegen…',
syncResult: '{{done}} overgedragen, {{skipped}} al up-to-date ({{total}} totaal)',
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
statusSynced: 'Gesynchroniseerd',
statusPending: 'In behandeling',
statusDeletion: 'Verwijdering',
markForDeletion: 'Markeren voor verwijdering',
removeSource: 'Verwijderen',
undoDeletion: 'Verwijdering ongedaan maken',
syncInBackground: 'Sync gestart op achtergrond — u kunt navigeren.',
syncInProgress: '{{done}} / {{total}} nummers…',
syncSummary: 'Synchronisatieoverzicht',
calculating: 'Vereiste payload berekenen…',
filesToAdd: 'Te toevoegen bestanden:',
filesToDelete: 'Te verwijderen bestanden:',
netChange: 'Nettoverandering:',
availableSpace: 'Beschikbare schijfruimte:',
spaceWarning: 'Waarschuwing: Het doelapparaat heeft niet genoeg gerapporteerde ruimte.',
proceed: 'Doorgaan met synchronisatie',
notEnoughSpace: 'Onvoldoende fysieke schijfruimte gedetecteerd!',
liveSearch: 'Live',
randomAlbumsLabel: 'Willekeurige albums',
},
};
+40
View File
@@ -18,6 +18,7 @@ export const ruTranslation = {
expand: 'Развернуть боковую панель',
collapse: 'Свернуть боковую панель',
downloadingTracks: 'Кэширование {{n}} треков…',
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
cancelDownload: 'Отменить загрузку',
offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры',
@@ -1082,8 +1083,15 @@ export const ruTranslation = {
title: 'Синхронизация устройства',
targetFolder: 'Папка назначения',
noFolderChosen: 'Папка не выбрана',
selectDrive: 'Выберите диск…',
refreshDrives: 'Обновить диски',
noDrivesDetected: 'Съёмные диски не обнаружены',
browseManual: 'Обзор вручную…',
free: 'свободно',
notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.',
chooseFolder: 'Выбрать…',
filenameTemplate: 'Шаблон имени файла',
targetDevice: 'Целевое устройство',
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Удалить файлы вне выборки',
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
@@ -1097,6 +1105,10 @@ export const ruTranslation = {
tabArtists: 'Исполнители',
searchPlaceholder: 'Поиск…',
syncButton: 'Синхронизировать на устройство',
actionTransfer: 'Перенести на устройство',
actionDelete: 'Удалить с устройства',
actionApplyAll: 'Применить все изменения',
templatePreview: 'Предпросмотр',
cancel: 'Отмена',
noTargetDir: 'Сначала выберите папку назначения.',
noSources: 'Выберите хотя бы один источник.',
@@ -1104,5 +1116,33 @@ export const ruTranslation = {
fetchError: 'Ошибка загрузки треков с сервера.',
syncComplete: 'Синхронизация завершена.',
dismiss: 'Закрыть',
colName: 'Название',
colType: 'Тип',
colStatus: 'Статус',
onDevice: 'Управление устройством',
addSources: 'Добавить…',
syncResult: '{{done}} перенесено, {{skipped}} уже актуально ({{total}} всего)',
deleteFromDevice: 'Пометить для удаления ({{count}})',
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
statusSynced: 'Синхронизировано',
statusPending: 'Ожидает',
statusDeletion: 'Удаление',
markForDeletion: 'Пометить для удаления',
removeSource: 'Удалить',
undoDeletion: 'Отменить удаление',
syncInBackground: 'Синхронизация запущена в фоне — можно перейти в другой раздел.',
syncInProgress: '{{done}} / {{total}} треков…',
syncSummary: 'Сводка синхронизации',
calculating: 'Вычисление необходимых данных…',
filesToAdd: 'Файлы для добавления:',
filesToDelete: 'Файлы для удаления:',
netChange: 'Чистое изменение:',
availableSpace: 'Доступное место на диске:',
spaceWarning: 'Предупреждение: на целевом устройстве недостаточно сообщаемого места.',
proceed: 'Продолжить синхронизацию',
notEnoughSpace: 'Обнаружено недостаточно физического места на диске!',
liveSearch: 'Live',
randomAlbumsLabel: 'Случайные альбомы',
},
};
+40
View File
@@ -17,6 +17,7 @@ export const zhTranslation = {
expand: '展开侧边栏',
collapse: '收起侧边栏',
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
syncingTracks: '同步中 {{done}}/{{total}}…',
cancelDownload: '取消下载',
offlineLibrary: '离线音乐库',
genres: '流派',
@@ -1017,8 +1018,15 @@ export const zhTranslation = {
title: '设备同步',
targetFolder: '目标文件夹',
noFolderChosen: '未选择文件夹',
selectDrive: '选择驱动器…',
refreshDrives: '刷新驱动器',
noDrivesDetected: '未检测到可移动驱动器',
browseManual: '手动浏览…',
free: '可用',
notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。',
chooseFolder: '选择…',
filenameTemplate: '文件名模板',
targetDevice: '目标设备',
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
cleanupButton: '删除不在选择范围内的文件',
cleanupNothingToDelete: '无需删除 — 设备已同步。',
@@ -1032,6 +1040,10 @@ export const zhTranslation = {
tabArtists: '艺术家',
searchPlaceholder: '搜索…',
syncButton: '同步到设备',
actionTransfer: '传输到设备',
actionDelete: '从设备删除',
actionApplyAll: '应用所有更改',
templatePreview: '预览',
cancel: '取消',
noTargetDir: '请先选择目标文件夹。',
noSources: '请至少选择一个来源。',
@@ -1039,5 +1051,33 @@ export const zhTranslation = {
fetchError: '从服务器加载曲目失败。',
syncComplete: '同步完成。',
dismiss: '关闭',
colName: '名称',
colType: '类型',
colStatus: '状态',
onDevice: '设备管理器',
addSources: '添加…',
syncResult: '已传输 {{done}}{{skipped}} 已是最新(共 {{total}}',
deleteFromDevice: '标记为删除({{count}}',
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}',
deleteComplete: '已从设备删除 {{count}} 个项目。',
statusSynced: '已同步',
statusPending: '待处理',
statusDeletion: '删除中',
markForDeletion: '标记为删除',
removeSource: '移除',
undoDeletion: '撤销删除',
syncInBackground: '同步已在后台启动 — 您可以离开此页面。',
syncInProgress: '{{done}} / {{total}} 首曲目…',
syncSummary: '同步摘要',
calculating: '正在计算所需数据…',
filesToAdd: '要添加的文件:',
filesToDelete: '要删除的文件:',
netChange: '净变化:',
availableSpace: '可用磁盘空间:',
spaceWarning: '警告:目标设备的可用空间不足。',
proceed: '继续同步',
notEnoughSpace: '检测到物理磁盘空间不足!',
liveSearch: '实时',
randomAlbumsLabel: '随机专辑',
},
};
+656 -209
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
import { create } from 'zustand';
export interface DeviceSyncJobState {
jobId: string | null;
total: number;
done: number;
skipped: number;
failed: number;
status: 'idle' | 'running' | 'done' | 'cancelled';
startSync: (jobId: string, total: number) => void;
updateProgress: (done: number, skipped: number, failed: number) => void;
complete: (done: number, skipped: number, failed: number) => void;
reset: () => void;
}
export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
jobId: null,
total: 0,
done: 0,
skipped: 0,
failed: 0,
status: 'idle',
startSync: (jobId, total) =>
set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }),
updateProgress: (done, skipped, failed) =>
set({ done, skipped, failed }),
complete: (done, skipped, failed) =>
set({ done, skipped, failed, status: 'done' }),
reset: () =>
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
}));
+35 -19
View File
@@ -7,21 +7,14 @@ export interface DeviceSyncSource {
name: string;
}
export interface DeviceSyncJob {
id: string;
total: number;
done: number;
skipped: number;
failed: number;
status: 'running' | 'done' | 'cancelled';
}
interface DeviceSyncState {
targetDir: string | null;
filenameTemplate: string;
sources: DeviceSyncSource[]; // persistent device content list
checkedIds: string[]; // currently checked for deletion (not persisted)
activeJob: DeviceSyncJob | null;
checkedIds: string[]; // currently checked for bulk actions (not persisted)
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
scanning: boolean; // true while scanning the device
setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void;
@@ -30,8 +23,12 @@ interface DeviceSyncState {
clearSources: () => void;
toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void;
setActiveJob: (job: DeviceSyncJob | null) => void;
updateJob: (update: Partial<DeviceSyncJob>) => void;
markForDeletion: (ids: string[]) => void;
unmarkDeletion: (id: string) => void;
clearPendingDeletion: () => void;
removeSources: (ids: string[]) => void;
setDeviceFilePaths: (paths: string[]) => void;
setScanning: (v: boolean) => void;
}
export const useDeviceSyncStore = create<DeviceSyncState>()(
@@ -41,7 +38,9 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
activeJob: null,
pendingDeletion: [],
deviceFilePaths: [],
scanning: false,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
@@ -57,9 +56,10 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),
checkedIds: s.checkedIds.filter((x) => x !== id),
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
})),
clearSources: () => set({ sources: [], checkedIds: [] }),
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
toggleChecked: (id) =>
set((s) => ({
@@ -70,12 +70,28 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
setCheckedIds: (ids) => set({ checkedIds: ids }),
setActiveJob: (job) => set({ activeJob: job }),
updateJob: (update) =>
markForDeletion: (ids) =>
set((s) => ({
activeJob: s.activeJob ? { ...s.activeJob, ...update } : null,
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
})),
unmarkDeletion: (id) =>
set((s) => ({
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
})),
clearPendingDeletion: () => set({ pendingDeletion: [] }),
removeSources: (ids) =>
set((s) => ({
sources: s.sources.filter((x) => !ids.includes(x.id)),
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
})),
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
setScanning: (v) => set({ scanning: v }),
}),
{
name: 'psysonic_device_sync',
+317 -31
View File
@@ -7573,24 +7573,51 @@ html.no-compositing .fs-seekbar-played {
.device-sync-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: var(--text-primary);
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
flex-shrink: 0;
}
.device-sync-header h1 {
.device-sync-header-title {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
}
.device-sync-header-title h1 {
font-size: 1.3rem;
font-weight: 600;
margin: 0;
flex: 1;
}
.device-sync-header-config {
/* ── Config Row (Template + Drive Layout) ── */
.device-sync-config-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 30px;
min-height: 0;
width: 100%;
}
.device-sync-template-section {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
max-width: 600px;
}
.device-sync-target-section {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-end; /* Flush right alignment */
flex: 1; /* allow block expansion */
}
.device-sync-folder-path {
@@ -7606,28 +7633,104 @@ html.no-compositing .fs-seekbar-played {
border-radius: var(--radius-sm);
}
/* ── Template row ── */
/* ── Drive layout ── */
.device-sync-template-row {
.device-sync-drive-layout {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.device-sync-drive-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
gap: 8px;
}
.device-sync-drive-meta {
font-size: 0.75rem;
color: var(--text-muted);
text-align: right;
padding-right: 2px;
}
.device-sync-drive-icon {
color: var(--accent);
flex-shrink: 0;
}
.device-sync-drive-select {
min-width: 200px;
max-width: 320px;
font-size: 0.95rem;
height: 40px;
padding: 0 10px;
line-height: normal;
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
cursor: pointer;
}
.device-sync-drive-controls .btn-ghost {
height: 40px;
width: 40px;
padding: 0 !important;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-sync-no-drives {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--warning, #f59e0b);
padding: 5px 10px;
background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent);
border-radius: var(--radius-sm);
}
.device-sync-label-inline {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.device-sync-template-input-wrap {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
.device-sync-template-input {
flex: 1;
width: 100%;
font-family: monospace;
font-size: 0.82rem;
font-size: 0.95rem;
height: 40px;
padding: 0 10px;
line-height: normal;
}
.device-sync-template-preview {
font-size: 0.72rem;
color: var(--text-muted);
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 2px;
opacity: 0.75;
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
}
/* ── Main area (device panel + browser) ── */
@@ -7746,7 +7849,19 @@ html.no-compositing .fs-seekbar-played {
}
.device-sync-device-row:last-child { border-bottom: none; }
.device-sync-device-row:hover { background: var(--bg-hover); }
.device-sync-source-row:hover {
background: var(--bg-hover);
}
.device-sync-source-row.deletion, .device-sync-row.deletion {
text-decoration: line-through;
opacity: 0.5;
}
.device-sync-row-name {
flex: 1;
}
.device-sync-device-row.checked { background: var(--accent-dim); }
.device-sync-source-type {
@@ -7759,41 +7874,184 @@ html.no-compositing .fs-seekbar-played {
flex-shrink: 0;
}
/* ── Progress ── */
/* ── Status summary badges ── */
.device-sync-progress {
.device-sync-status-summary {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border);
height: 52px;
padding: 0 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-progress-bar-wrap {
height: 4px;
.device-sync-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.device-sync-badge.synced {
background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent);
color: var(--success, #4ade80);
}
.device-sync-badge.pending {
background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent);
color: var(--warning, #f59e0b);
}
.device-sync-badge.deletion {
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
color: var(--danger, #f38ba8);
}
/* ── Status column ── */
.device-sync-list-col-status {
width: 50px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
text-align: center;
}
.device-sync-list-col-actions {
width: 32px;
flex-shrink: 0;
}
/* ── Status icons per row ── */
.device-sync-status-icon {
width: 50px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-sync-status-icon.synced { color: var(--success, #4ade80); }
.device-sync-status-icon.pending { color: var(--warning, #f59e0b); }
.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); }
/* ── Per-row action buttons ── */
.device-sync-row-actions {
width: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.device-sync-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s, color 0.15s, transform 0.1s;
opacity: 0;
}
.device-sync-device-row:hover .device-sync-action-btn {
opacity: 1;
}
.device-sync-action-btn.danger {
color: var(--danger, #f38ba8);
}
.device-sync-action-btn.danger:hover {
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
transform: scale(1.1);
}
.device-sync-action-btn.muted {
color: var(--text-muted);
}
.device-sync-action-btn.muted:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.1);
}
.device-sync-action-btn.undo {
color: var(--accent);
opacity: 1;
}
.device-sync-action-btn.undo:hover {
background: color-mix(in srgb, var(--accent) 15%, transparent);
transform: scale(1.1);
}
/* ── Row status variants ── */
.device-sync-device-row.synced {}
.device-sync-device-row.pending {}
.device-sync-device-row.deletion {
opacity: 0.55;
}
.device-sync-device-row.deletion .device-sync-row-name {
text-decoration: line-through;
text-decoration-color: var(--danger, #f38ba8);
}
/* ── Background sync progress (non-blocking) ── */
.device-sync-bg-progress {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid var(--border);
flex-shrink: 0;
background: color-mix(in srgb, var(--accent) 4%, transparent);
}
.device-sync-bg-progress.done {
flex-direction: row;
align-items: center;
justify-content: space-between;
background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent);
}
.device-sync-bg-progress-bar-wrap {
height: 3px;
background: var(--bg-input);
border-radius: 2px;
overflow: hidden;
}
.device-sync-progress-bar {
.device-sync-bg-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.device-sync-progress-stats {
.device-sync-bg-progress-text {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
gap: 6px;
font-size: 0.78rem;
color: var(--text-secondary);
}
.device-sync-stat-muted { color: var(--text-secondary); display: flex; align-items: center; gap: 3px; }
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
.color-success { color: var(--success, #4ade80); }
/* ── Browser panel ── */
@@ -7842,7 +8100,35 @@ html.no-compositing .fs-seekbar-played {
}
.device-sync-search-wrap .input {
width: 100%;
flex: 1;
}
.device-sync-live-badge {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-left: 8px;
padding: 2px 7px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--accent);
background: var(--accent-dim);
white-space: nowrap;
}
.device-sync-section-label {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px 4px;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-muted);
}
.device-sync-list {
+7
View File
@@ -912,6 +912,13 @@
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
/* ─── Sidebar device-sync queue ─── */
.sidebar-sync-queue {
background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar));
border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent);
color: var(--success, #4ade80);
}
@keyframes spin-slow {
to { transform: rotate(360deg); }
}