feat(device-sync): USB/SD card sync page (WIP)

Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-14 00:02:13 +02:00
parent 58f99ae846
commit 17a5c92174
15 changed files with 1341 additions and 2 deletions
+2
View File
@@ -36,6 +36,7 @@ import Playlists from './pages/Playlists';
import PlaylistDetail from './pages/PlaylistDetail';
import InternetRadio from './pages/InternetRadio';
import FolderBrowser from './pages/FolderBrowser';
import DeviceSync from './pages/DeviceSync';
import NowPlayingPage from './pages/NowPlaying';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
@@ -370,6 +371,7 @@ function AppShell() {
<Route path="/playlists/:id" element={<PlaylistDetail />} />
<Route path="/radio" element={<InternetRadio />} />
<Route path="/folders" element={<FolderBrowser />} />
<Route path="/device-sync" element={<DeviceSync />} />
</Routes>
</div>
</main>
+3 -2
View File
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle, HardDriveUpload,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
@@ -30,7 +30,8 @@ export const ALL_NAV_ITEMS: Record<string, { icon: React.ElementType; labelKey:
playlists: { icon: ListMusic, labelKey: 'sidebar.playlists', to: '/playlists', section: 'library' },
mostPlayed: { icon: TrendingUp, labelKey: 'sidebar.mostPlayed', to: '/most-played', section: 'library' },
radio: { icon: Cast, labelKey: 'sidebar.radio', to: '/radio', section: 'library' },
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
folderBrowser: { icon: FolderOpen, labelKey: 'sidebar.folderBrowser', to: '/folders', section: 'library' },
deviceSync: { icon: HardDriveUpload, labelKey: 'sidebar.deviceSync', to: '/device-sync', section: 'library' },
statistics: { icon: BarChart3, labelKey: 'sidebar.statistics', to: '/statistics', section: 'system' },
help: { icon: HelpCircle, labelKey: 'sidebar.help', to: '/help', section: 'system' },
};
+32
View File
@@ -24,6 +24,7 @@ export const deTranslation = {
mostPlayed: 'Meistgehört',
radio: 'Internetradio',
folderBrowser: 'Ordner-Browser',
deviceSync: 'Gerätesync',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
expandPlaylists: 'Playlists ausklappen',
@@ -1021,4 +1022,35 @@ export const deTranslation = {
empty: 'Leerer Ordner',
error: 'Laden fehlgeschlagen',
},
deviceSync: {
title: 'Gerätesync',
targetFolder: 'Zielordner',
noFolderChosen: 'Kein Ordner gewählt',
chooseFolder: 'Auswählen…',
filenameTemplate: 'Dateinamen-Vorlage',
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'Auf dem Gerät',
addSources: 'Hinzufügen…',
colName: 'Name',
colType: 'Typ',
syncResult: '{{done}} übertragen, {{skipped}} bereits aktuell ({{total}} gesamt)',
deleteFromDevice: 'Vom Gerät löschen ({{count}})',
confirmDelete: '{{count}} Eintrag/Einträge vom Gerät löschen: {{names}}?',
deleteComplete: '{{count}} Eintrag/Einträge vom Gerät entfernt.',
selectedSources: 'Ausgewählte Quellen',
noSourcesSelected: 'Keine Quellen ausgewählt. Klicke auf Einträge im Browser um sie hinzuzufügen.',
clearAll: 'Alle entfernen',
tabPlaylists: 'Wiedergabelisten',
tabAlbums: 'Alben',
tabArtists: 'Künstler',
searchPlaceholder: 'Suche…',
syncButton: 'Auf Gerät übertragen',
cancel: 'Abbrechen',
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
noSources: 'Bitte mindestens eine Quelle auswählen.',
noTracks: 'Keine Tracks in den ausgewählten Quellen gefunden.',
fetchError: 'Fehler beim Laden der Tracks vom Server.',
syncComplete: 'Übertragung abgeschlossen.',
dismiss: 'Schließen',
},
};
+32
View File
@@ -25,6 +25,7 @@ export const enTranslation = {
mostPlayed: 'Most Played',
radio: 'Internet Radio',
folderBrowser: 'Folder Browser',
deviceSync: 'Device Sync',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
expandPlaylists: 'Expand playlists',
@@ -1023,4 +1024,35 @@ export const enTranslation = {
empty: 'Empty folder',
error: 'Failed to load',
},
deviceSync: {
title: 'Device Sync',
targetFolder: 'Target Folder',
noFolderChosen: 'No folder chosen',
chooseFolder: 'Choose…',
filenameTemplate: 'Filename Template',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'On Device',
addSources: 'Add…',
colName: 'Name',
colType: 'Type',
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
deleteFromDevice: 'Delete from device ({{count}})',
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
deleteComplete: '{{count}} item(s) removed from device.',
selectedSources: 'Selected Sources',
noSourcesSelected: 'No sources selected. Click items in the browser to add them.',
clearAll: 'Clear all',
tabPlaylists: 'Playlists',
tabAlbums: 'Albums',
tabArtists: 'Artists',
searchPlaceholder: 'Search…',
syncButton: 'Sync to Device',
cancel: 'Cancel',
noTargetDir: 'Please choose a target folder first.',
noSources: 'Please select at least one source.',
noTracks: 'No tracks found in the selected sources.',
fetchError: 'Failed to fetch tracks from server.',
syncComplete: 'Sync complete.',
dismiss: 'Dismiss',
},
};
+28
View File
@@ -25,6 +25,7 @@ export const esTranslation = {
mostPlayed: 'Más Reproducidos',
radio: 'Radio por Internet',
folderBrowser: 'Explorar Carpetas',
deviceSync: 'Sincronizar dispositivo',
libraryScope: 'Ámbito de biblioteca',
allLibraries: 'Todas las bibliotecas',
expandPlaylists: 'Expandir listas',
@@ -1024,4 +1025,31 @@ export const esTranslation = {
empty: 'Carpeta vacía',
error: 'Error al cargar',
},
deviceSync: {
title: 'Sincronizar dispositivo',
targetFolder: 'Carpeta de destino',
noFolderChosen: 'Ninguna carpeta seleccionada',
chooseFolder: 'Elegir…',
filenameTemplate: 'Plantilla de nombre de archivo',
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.',
confirmCleanup: '¿Eliminar {{count}} archivo(s) del dispositivo que no están en la selección actual? ¿Continuar?',
cleanupComplete: '{{count}} archivo(s) eliminado(s) del dispositivo.',
selectedSources: 'Fuentes seleccionadas',
noSourcesSelected: 'No hay fuentes seleccionadas. Haz clic en elementos del navegador para agregarlos.',
clearAll: 'Limpiar todo',
tabPlaylists: 'Listas de reproducción',
tabAlbums: 'Álbumes',
tabArtists: 'Artistas',
searchPlaceholder: 'Buscar…',
syncButton: 'Sincronizar al dispositivo',
cancel: 'Cancelar',
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
noSources: 'Por favor, selecciona al menos una fuente.',
noTracks: 'No se encontraron pistas en las fuentes seleccionadas.',
fetchError: 'Error al obtener pistas del servidor.',
syncComplete: 'Sincronización completada.',
dismiss: 'Cerrar',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const frTranslation = {
mostPlayed: 'Les plus joués',
radio: 'Radio Internet',
folderBrowser: 'Explorateur de dossiers',
deviceSync: 'Sync appareil',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
expandPlaylists: 'Développer les playlists',
@@ -1019,4 +1020,31 @@ export const frTranslation = {
empty: 'Dossier vide',
error: 'Échec du chargement',
},
deviceSync: {
title: 'Sync appareil',
targetFolder: 'Dossier cible',
noFolderChosen: 'Aucun dossier sélectionné',
chooseFolder: 'Choisir…',
filenameTemplate: 'Modèle de nom de fichier',
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Supprimer les fichiers absents',
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
confirmCleanup: 'Supprimer {{count}} fichier(s) de l\'appareil qui ne font pas partie de la sélection actuelle. Continuer ?',
cleanupComplete: '{{count}} fichier(s) supprimé(s) de l\'appareil.',
selectedSources: 'Sources sélectionnées',
noSourcesSelected: 'Aucune source sélectionnée. Cliquez sur des éléments dans le navigateur pour les ajouter.',
clearAll: 'Tout effacer',
tabPlaylists: 'Listes de lecture',
tabAlbums: 'Albums',
tabArtists: 'Artistes',
searchPlaceholder: 'Rechercher…',
syncButton: 'Synchroniser vers l\'appareil',
cancel: 'Annuler',
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
noSources: 'Veuillez sélectionner au moins une source.',
noTracks: 'Aucune piste trouvée dans les sources sélectionnées.',
fetchError: 'Échec du chargement des pistes depuis le serveur.',
syncComplete: 'Synchronisation terminée.',
dismiss: 'Fermer',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const nbTranslation = {
mostPlayed: 'Mest spilt',
radio: 'Internettradio',
folderBrowser: 'Mappeleser',
deviceSync: 'Enhetssynk',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
expandPlaylists: 'Utvid spillelister',
@@ -1018,4 +1019,31 @@ export const nbTranslation = {
empty: 'Tom mappe',
error: 'Kunne ikke laste',
},
deviceSync: {
title: 'Enhetssynk',
targetFolder: 'Målmappe',
noFolderChosen: 'Ingen mappe valgt',
chooseFolder: 'Velg…',
filenameTemplate: 'Filnavnmal',
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.',
confirmCleanup: 'Slette {{count}} fil(er) fra enheten som ikke er i det gjeldende utvalget. Fortsette?',
cleanupComplete: '{{count}} fil(er) fjernet fra enheten.',
selectedSources: 'Valgte kilder',
noSourcesSelected: 'Ingen kilder valgt. Klikk på elementer i nettleseren for å legge dem til.',
clearAll: 'Fjern alle',
tabPlaylists: 'Spillelister',
tabAlbums: 'Album',
tabArtists: 'Artister',
searchPlaceholder: 'Søk…',
syncButton: 'Synkroniser til enhet',
cancel: 'Avbryt',
noTargetDir: 'Velg en målmappe først.',
noSources: 'Velg minst én kilde.',
noTracks: 'Ingen spor funnet i de valgte kildene.',
fetchError: 'Kunne ikke hente spor fra serveren.',
syncComplete: 'Synkronisering fullført.',
dismiss: 'Lukk',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const nlTranslation = {
mostPlayed: 'Meest gespeeld',
radio: 'Internetradio',
folderBrowser: 'Mappenverkenner',
deviceSync: 'Apparaatsync',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
expandPlaylists: 'Afspeellijsten uitklappen',
@@ -1016,4 +1017,31 @@ export const nlTranslation = {
empty: 'Lege map',
error: 'Laden mislukt',
},
deviceSync: {
title: 'Apparaatsync',
targetFolder: 'Doelmap',
noFolderChosen: 'Geen map gekozen',
chooseFolder: 'Kiezen…',
filenameTemplate: 'Bestandsnaamsjabloon',
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
confirmCleanup: '{{count}} bestand(en) verwijderen van het apparaat die niet in de huidige selectie staan. Doorgaan?',
cleanupComplete: '{{count}} bestand(en) van het apparaat verwijderd.',
selectedSources: 'Geselecteerde bronnen',
noSourcesSelected: 'Geen bronnen geselecteerd. Klik op items in de browser om ze toe te voegen.',
clearAll: 'Alles wissen',
tabPlaylists: 'Afspeellijsten',
tabAlbums: 'Albums',
tabArtists: 'Artiesten',
searchPlaceholder: 'Zoeken…',
syncButton: 'Synchroniseren naar apparaat',
cancel: 'Annuleren',
noTargetDir: 'Kies eerst een doelmap.',
noSources: 'Selecteer minimaal één bron.',
noTracks: 'Geen nummers gevonden in de geselecteerde bronnen.',
fetchError: 'Ophalen van nummers van de server mislukt.',
syncComplete: 'Synchronisatie voltooid.',
dismiss: 'Sluiten',
},
};
+28
View File
@@ -25,6 +25,7 @@ export const ruTranslation = {
mostPlayed: 'Популярное',
radio: 'Онлайн-радио',
folderBrowser: 'Браузер папок',
deviceSync: 'Синхронизация устройства',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
expandPlaylists: 'Развернуть плейлисты',
@@ -1077,4 +1078,31 @@ export const ruTranslation = {
empty: 'Папка пуста',
error: 'Ошибка загрузки',
},
deviceSync: {
title: 'Синхронизация устройства',
targetFolder: 'Папка назначения',
noFolderChosen: 'Папка не выбрана',
chooseFolder: 'Выбрать…',
filenameTemplate: 'Шаблон имени файла',
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Удалить файлы вне выборки',
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
confirmCleanup: 'Удалить {{count}} файл(ов) с устройства, не входящих в текущую выборку. Продолжить?',
cleanupComplete: '{{count}} файл(ов) удалено с устройства.',
selectedSources: 'Выбранные источники',
noSourcesSelected: 'Источники не выбраны. Нажмите на элементы в браузере, чтобы добавить их.',
clearAll: 'Очистить всё',
tabPlaylists: 'Плейлисты',
tabAlbums: 'Альбомы',
tabArtists: 'Исполнители',
searchPlaceholder: 'Поиск…',
syncButton: 'Синхронизировать на устройство',
cancel: 'Отмена',
noTargetDir: 'Сначала выберите папку назначения.',
noSources: 'Выберите хотя бы один источник.',
noTracks: 'В выбранных источниках не найдено треков.',
fetchError: 'Ошибка загрузки треков с сервера.',
syncComplete: 'Синхронизация завершена.',
dismiss: 'Закрыть',
},
};
+28
View File
@@ -24,6 +24,7 @@ export const zhTranslation = {
mostPlayed: '最常播放',
radio: '网络电台',
folderBrowser: '文件夹浏览器',
deviceSync: '设备同步',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
expandPlaylists: '展开播放列表',
@@ -1012,4 +1013,31 @@ export const zhTranslation = {
empty: '空文件夹',
error: '加载失败',
},
deviceSync: {
title: '设备同步',
targetFolder: '目标文件夹',
noFolderChosen: '未选择文件夹',
chooseFolder: '选择…',
filenameTemplate: '文件名模板',
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
cleanupButton: '删除不在选择范围内的文件',
cleanupNothingToDelete: '无需删除 — 设备已同步。',
confirmCleanup: '将从设备中删除 {{count}} 个不在当前选择范围内的文件。继续?',
cleanupComplete: '已从设备中删除 {{count}} 个文件。',
selectedSources: '已选来源',
noSourcesSelected: '未选择来源。点击浏览器中的项目以添加。',
clearAll: '全部清除',
tabPlaylists: '播放列表',
tabAlbums: '专辑',
tabArtists: '艺术家',
searchPlaceholder: '搜索…',
syncButton: '同步到设备',
cancel: '取消',
noTargetDir: '请先选择目标文件夹。',
noSources: '请至少选择一个来源。',
noTracks: '所选来源中未找到曲目。',
fetchError: '从服务器加载曲目失败。',
syncComplete: '同步完成。',
dismiss: '关闭',
},
};
+464
View File
@@ -0,0 +1,464 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import {
HardDriveUpload, FolderOpen, Loader2,
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, SkipForward, Trash2,
ChevronRight, ChevronDown,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore';
import {
getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist,
buildDownloadUrl, SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
} from '../api/subsonic';
import { showToast } from '../utils/toast';
type SourceTab = 'playlists' | 'albums' | 'artists';
// ─── helpers ─────────────────────────────────────────────────────────────────
function uuid(): string { return crypto.randomUUID(); }
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
const { albums } = await getArtist(source.id);
const all: SubsonicSong[] = [];
for (const album of albums) { const { songs } = await getAlbum(album.id); all.push(...songs); }
return all;
}
function trackToSyncInfo(track: SubsonicSong, url: string) {
return {
id: track.id, url,
suffix: track.suffix ?? 'mp3',
artist: track.artist ?? '',
album: track.album ?? '',
title: track.title ?? '',
trackNumber: track.track,
discNumber: track.discNumber,
year: track.year,
};
}
// ─── component ───────────────────────────────────────────────────────────────
export default function DeviceSync() {
const { t } = useTranslation();
const targetDir = useDeviceSyncStore(s => s.targetDir);
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
const sources = useDeviceSyncStore(s => s.sources);
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
const activeJob = useDeviceSyncStore(s => s.activeJob);
const { setTargetDir, setFilenameTemplate, addSource, removeSource,
clearSources, toggleChecked, setCheckedIds, setActiveJob, updateJob } =
useDeviceSyncStore.getState();
const [activeTab, setActiveTab] = useState<SourceTab>('albums');
const [search, setSearch] = useState('');
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [loadingBrowser, setLoadingBrowser] = useState(false);
const [deleting, setDeleting] = useState(false);
const [expandedArtistIds, setExpandedArtistIds] = useState<Set<string>>(new Set());
const [artistAlbumsMap, setArtistAlbumsMap] = useState<Map<string, SubsonicAlbum[]>>(new Map());
const [loadingArtistIds, setLoadingArtistIds] = useState<Set<string>>(new Set());
const cancelRef = useRef(false);
// Load browser data when tab switches
useEffect(() => {
setSearch('');
if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists();
if (activeTab === 'albums' && albums.length === 0) loadAlbums();
if (activeTab === 'artists' && artists.length === 0) loadArtists();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const loadPlaylists = useCallback(async () => {
setLoadingBrowser(true);
try { setPlaylists(await getPlaylists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadAlbums = useCallback(async () => {
setLoadingBrowser(true);
try { setAlbums(await getAlbumList('alphabeticalByName', 500, 0)); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadArtists = useCallback(async () => {
setLoadingBrowser(true);
try { setArtists(await getArtists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const toggleArtistExpand = useCallback(async (artistId: string) => {
setExpandedArtistIds(prev => {
const next = new Set(prev);
if (next.has(artistId)) { next.delete(artistId); return next; }
next.add(artistId);
return next;
});
if (!artistAlbumsMap.has(artistId)) {
setLoadingArtistIds(prev => new Set(prev).add(artistId));
try {
const { albums } = await getArtist(artistId);
setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums));
} finally {
setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; });
}
}
}, [artistAlbumsMap]);
const q = search.toLowerCase();
const filteredPlaylists = playlists.filter(p => p.name.toLowerCase().includes(q));
const filteredAlbums = albums.filter(a =>
a.name.toLowerCase().includes(q) || (a.artist ?? '').toLowerCase().includes(q));
const filteredArtists = artists.filter(a => a.name.toLowerCase().includes(q));
const handleChooseFolder = async () => {
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (sel) setTargetDir(sel as string);
};
// ─── Sync ────────────────────────────────────────────────────────────────
const handleSync = async () => {
if (!targetDir) { showToast(t('deviceSync.noTargetDir'), 3000, 'error'); return; }
if (sources.length === 0){ showToast(t('deviceSync.noSources'), 3000, 'error'); return; }
cancelRef.current = false;
const jobId = uuid();
setActiveJob({ id: jobId, total: 0, done: 0, skipped: 0, failed: 0, status: 'running' });
let allTracks: SubsonicSong[] = [];
try {
for (const source of sources) {
if (cancelRef.current) break;
allTracks.push(...await fetchTracksForSource(source));
}
} catch {
showToast(t('deviceSync.fetchError'), 3000, 'error');
setActiveJob(null);
return;
}
const seen = new Set<string>();
allTracks = allTracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
if (allTracks.length === 0) {
showToast(t('deviceSync.noTracks'), 3000, 'error');
setActiveJob(null);
return;
}
updateJob({ total: allTracks.length });
const unlisten = await listen<{ jobId: string; status: string }>(
'device:sync:progress',
({ payload }) => {
if (payload.jobId !== jobId) return;
const st = useDeviceSyncStore.getState().activeJob!;
if (payload.status === 'done') updateJob({ done: st.done + 1 });
else if (payload.status === 'skipped') updateJob({ skipped: st.skipped + 1 });
else if (payload.status === 'error') updateJob({ failed: st.failed + 1 });
}
);
const CONCURRENCY = 4;
let idx = 0;
const worker = async () => {
while (idx < allTracks.length && !cancelRef.current) {
const track = allTracks[idx++];
try {
await invoke('sync_track_to_device', {
track: trackToSyncInfo(track, buildDownloadUrl(track.id)),
destDir: targetDir,
template: filenameTemplate,
jobId,
});
} catch { /* emitted via event */ }
}
};
try {
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
} finally {
unlisten();
}
updateJob({ status: cancelRef.current ? 'cancelled' : 'done' });
};
// ─── Delete checked items from device ────────────────────────────────────
const handleDeleteChecked = async () => {
if (!targetDir || checkedIds.length === 0) return;
const toDelete = sources.filter(s => checkedIds.includes(s.id));
const confirmed = window.confirm(
t('deviceSync.confirmDelete', { count: toDelete.length, names: toDelete.map(s => s.name).join(', ') })
);
if (!confirmed) return;
setDeleting(true);
try {
// Collect all tracks for the checked sources
let tracks: SubsonicSong[] = [];
for (const source of toDelete) {
tracks.push(...await fetchTracksForSource(source));
}
const seen = new Set<string>();
tracks = tracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
// Compute expected device paths via Rust (same sanitizer as sync)
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map(t => trackToSyncInfo(t, '')),
destDir: targetDir,
template: filenameTemplate,
});
for (const path of paths) {
await invoke('delete_device_file', { path }).catch(() => {});
}
// Remove from the list
for (const s of toDelete) removeSource(s.id);
showToast(t('deviceSync.deleteComplete', { count: toDelete.length }), 3000, 'info');
} catch {
showToast(t('deviceSync.fetchError'), 3000, 'error');
} finally {
setDeleting(false);
}
};
const handleCancel = () => { cancelRef.current = true; };
const isRunning = activeJob?.status === 'running';
const isDone = activeJob?.status === 'done';
const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id));
const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id));
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
{ key: 'artists', icon: <Users size={14} />, label: t('deviceSync.tabArtists') },
];
return (
<div className="device-sync-page">
{/* ── Header ── */}
<div className="device-sync-header">
<HardDriveUpload size={20} />
<h1>{t('deviceSync.title')}</h1>
<div className="device-sync-header-config">
<span className="device-sync-folder-path" data-tooltip={targetDir ?? ''}>
{targetDir ?? t('deviceSync.noFolderChosen')}
</span>
<button className="btn btn-surface" onClick={handleChooseFolder}>
<FolderOpen size={13} />{t('deviceSync.chooseFolder')}
</button>
</div>
</div>
{/* ── Template (collapsed row) ── */}
<div className="device-sync-template-row">
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
<input
className="input device-sync-template-input"
value={filenameTemplate}
onChange={e => setFilenameTemplate(e.target.value)}
spellCheck={false}
data-tooltip={t('deviceSync.templateHint')}
data-tooltip-pos="bottom"
/>
</div>
{/* ── Main ── */}
<div className="device-sync-main">
{/* ── Browser (left) ── */}
<div className="device-sync-browser">
<div className="device-sync-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon}{tab.label}
</button>
))}
</div>
<div className="device-sync-search-wrap">
<input
className="input"
placeholder={t('deviceSync.searchPlaceholder')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="device-sync-list">
{loadingBrowser && (
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
)}
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
selected={sources.some(s => s.id === pl.id)}
onToggle={() => sources.some(s => s.id === pl.id)
? removeSource(pl.id)
: addSource({ type: 'playlist', id: pl.id, name: pl.name })} />
))}
{activeTab === 'albums' && filteredAlbums.map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.artist}
selected={sources.some(s => s.id === al.id)}
onToggle={() => sources.some(s => s.id === al.id)
? removeSource(al.id)
: addSource({ type: 'album', id: al.id, name: al.name })} />
))}
{activeTab === 'artists' && filteredArtists.map(ar => (
<React.Fragment key={ar.id}>
<div className="device-sync-artist-row">
<button
className="device-sync-expand-btn"
onClick={() => toggleArtistExpand(ar.id)}
>
{loadingArtistIds.has(ar.id)
? <Loader2 size={13} className="spin" />
: expandedArtistIds.has(ar.id)
? <ChevronDown size={13} />
: <ChevronRight size={13} />}
</button>
<span className="device-sync-row-name">{ar.name}</span>
{ar.albumCount != null &&
<span className="device-sync-row-meta">{ar.albumCount} Alben</span>}
</div>
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
artistAlbumsMap.get(ar.id)!.map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
selected={sources.some(s => s.id === al.id)}
indent
onToggle={() => sources.some(s => s.id === al.id)
? removeSource(al.id)
: addSource({ type: 'album', id: al.id, name: al.name })} />
))
}
</React.Fragment>
))}
</div>
</div>
{/* ── Device list (right) ── */}
<div className="device-sync-device-panel">
<div className="device-sync-panel-header">
<span className="device-sync-panel-title">{t('deviceSync.onDevice')}</span>
<div className="device-sync-panel-actions">
{!activeJob && (
<button
className="btn btn-surface"
onClick={handleSync}
disabled={!targetDir || sources.length === 0}
>
<HardDriveUpload size={13} />
{t('deviceSync.syncButton')}
</button>
)}
{checkedIds.length > 0 && !isRunning && (
<button
className="btn btn-danger"
onClick={handleDeleteChecked}
disabled={deleting}
>
{deleting ? <Loader2 size={13} className="spin" /> : <Trash2 size={13} />}
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
</button>
)}
</div>
</div>
{sources.length === 0 ? (
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
) : (
<>
<div className="device-sync-list-header">
<label className="device-sync-check-label">
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
</label>
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
</div>
<div className="device-sync-device-list">
{sources.map(s => (
<label key={s.id} className={`device-sync-device-row${checkedIds.includes(s.id) ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checkedIds.includes(s.id)}
onChange={() => toggleChecked(s.id)}
/>
<span className="device-sync-row-name">{s.name}</span>
<span className="device-sync-source-type">{s.type}</span>
</label>
))}
</div>
</>
)}
{/* Progress / sync result */}
{activeJob && (
<div className="device-sync-progress">
<div className="device-sync-progress-bar-wrap">
<div
className="device-sync-progress-bar"
style={{ width: activeJob.total > 0
? `${((activeJob.done + activeJob.skipped + activeJob.failed) / activeJob.total) * 100}%`
: '0%' }}
/>
</div>
<div className="device-sync-progress-stats">
{isRunning && <Loader2 size={13} className="spin" />}
{isDone && <CheckCircle2 size={13} className="color-success" />}
<span>
{isDone
? t('deviceSync.syncResult', { done: activeJob.done, skipped: activeJob.skipped, total: activeJob.total })
: `${activeJob.done + activeJob.skipped + activeJob.failed} / ${activeJob.total}`}
</span>
{activeJob.failed > 0 && (
<span className="device-sync-stat-error"><AlertCircle size={12} /> {activeJob.failed}</span>
)}
{activeJob.skipped > 0 && (
<span className="device-sync-stat-muted"><SkipForward size={12} /> {activeJob.skipped}</span>
)}
{isRunning
? <button className="btn btn-ghost" onClick={handleCancel}>{t('deviceSync.cancel')}</button>
: <button className="btn btn-ghost" onClick={() => setActiveJob(null)}>{t('deviceSync.dismiss')}</button>
}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ─── BrowserRow ──────────────────────────────────────────────────────────────
function BrowserRow({ name, meta, selected, onToggle, indent }: {
name: string; meta?: string; selected: boolean; onToggle: () => void; indent?: boolean;
}) {
return (
<button className={`device-sync-browser-row${selected ? ' selected' : ''}${indent ? ' indent' : ''}`} onClick={onToggle}>
<span className="device-sync-row-check">
{selected ? <CheckCircle2 size={14} /> : <span className="device-sync-row-circle" />}
</span>
<span className="device-sync-row-name">{name}</span>
{meta && <span className="device-sync-row-meta">{meta}</span>}
</button>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface DeviceSyncSource {
type: 'album' | 'playlist' | 'artist';
id: string;
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;
setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void;
addSource: (source: DeviceSyncSource) => void;
removeSource: (id: string) => void;
clearSources: () => void;
toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void;
setActiveJob: (job: DeviceSyncJob | null) => void;
updateJob: (update: Partial<DeviceSyncJob>) => void;
}
export const useDeviceSyncStore = create<DeviceSyncState>()(
persist(
(set) => ({
targetDir: null,
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
activeJob: null,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
addSource: (source) =>
set((s) => ({
sources: s.sources.some((x) => x.id === source.id)
? s.sources
: [...s.sources, source],
})),
removeSource: (id) =>
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),
checkedIds: s.checkedIds.filter((x) => x !== id),
})),
clearSources: () => set({ sources: [], checkedIds: [] }),
toggleChecked: (id) =>
set((s) => ({
checkedIds: s.checkedIds.includes(id)
? s.checkedIds.filter((x) => x !== id)
: [...s.checkedIds, id],
})),
setCheckedIds: (ids) => set({ checkedIds: ids }),
setActiveJob: (job) => set({ activeJob: job }),
updateJob: (update) =>
set((s) => ({
activeJob: s.activeJob ? { ...s.activeJob, ...update } : null,
})),
}),
{
name: 'psysonic_device_sync',
partialize: (s) => ({
targetDir: s.targetDir,
filenameTemplate: s.filenameTemplate,
sources: s.sources,
}),
}
)
);
+1
View File
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mostPlayed', visible: true },
{ id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'deviceSync', visible: false },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];
+353
View File
@@ -7558,3 +7558,356 @@ html.no-compositing .fs-seekbar-played {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
/* ─── Device Sync ─────────────────────────────────────────────────────────── */
.device-sync-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 24px 28px;
overflow: hidden;
}
/* ── Header ── */
.device-sync-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
color: var(--text-primary);
flex-shrink: 0;
}
.device-sync-header h1 {
font-size: 1.3rem;
font-weight: 600;
margin: 0;
flex: 1;
}
.device-sync-header-config {
display: flex;
align-items: center;
gap: 8px;
}
.device-sync-folder-path {
max-width: 260px;
font-size: 0.8rem;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 5px 8px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
/* ── Template row ── */
.device-sync-template-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-shrink: 0;
}
.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 {
flex: 1;
font-family: monospace;
font-size: 0.82rem;
}
/* ── Main area (device panel + browser) ── */
.device-sync-main {
display: flex;
flex: 1;
min-height: 0;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
/* ── Device panel ── */
.device-sync-device-panel {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
background: var(--bg-card);
overflow: hidden;
}
.device-sync-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 52px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-panel-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
}
.device-sync-panel-actions {
display: flex;
align-items: center;
gap: 6px;
}
.device-sync-add-btn {
display: flex;
align-items: center;
gap: 5px;
}
.device-sync-empty {
font-size: 0.82rem;
color: var(--text-secondary);
padding: 20px 14px;
margin: 0;
}
/* ── Device list ── */
.device-sync-list-header {
display: flex;
align-items: center;
gap: 8px;
height: 52px;
padding: 0 14px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-check-label {
display: flex;
align-items: center;
flex-shrink: 0;
cursor: pointer;
}
.device-sync-list-col-name {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.device-sync-list-col-type {
width: 70px;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
flex-shrink: 0;
}
.device-sync-device-list {
flex: 1;
overflow-y: auto;
}
.device-sync-device-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
cursor: pointer;
transition: background 0.1s;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-primary);
}
.device-sync-device-row:last-child { border-bottom: none; }
.device-sync-device-row:hover { background: var(--bg-hover); }
.device-sync-device-row.checked { background: var(--accent-dim); }
.device-sync-source-type {
width: 70px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--accent);
flex-shrink: 0;
}
/* ── Progress ── */
.device-sync-progress {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-progress-bar-wrap {
height: 4px;
background: var(--bg-input);
border-radius: 2px;
overflow: hidden;
}
.device-sync-progress-bar {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.3s ease;
}
.device-sync-progress-stats {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.82rem;
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; }
.color-success { color: var(--success, #4ade80); }
/* ── Browser panel ── */
.device-sync-browser {
display: flex;
flex-direction: column;
flex: 1;
background: var(--bg-card);
overflow: hidden;
}
.device-sync-tabs {
display: flex;
height: 52px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.device-sync-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
.device-sync-tab.active { color: var(--accent); border-bottom: 2px solid var(--accent); }
.device-sync-search-wrap {
display: flex;
align-items: center;
height: 52px;
padding: 0 10px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.device-sync-search-wrap .input {
width: 100%;
}
.device-sync-list {
flex: 1;
overflow-y: auto;
}
.device-sync-loading {
display: flex;
justify-content: center;
padding: 24px;
color: var(--text-secondary);
}
.device-sync-browser-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: background 0.12s;
color: var(--text-primary);
}
.device-sync-browser-row:hover { background: var(--bg-hover); }
.device-sync-browser-row.selected { background: var(--accent-dim); }
.device-sync-browser-row.indent { padding-left: 36px; background: var(--bg-secondary); }
.device-sync-browser-row.indent:hover { background: var(--bg-hover); }
.device-sync-browser-row.indent.selected { background: var(--accent-dim); }
.device-sync-artist-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
font-size: 0.85rem;
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.device-sync-expand-btn {
display: flex;
align-items: center;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 2px;
flex-shrink: 0;
transition: color 0.12s;
}
.device-sync-expand-btn:hover { color: var(--text-primary); }
.device-sync-row-check { color: var(--accent); display: flex; flex-shrink: 0; }
.device-sync-row-circle {
width: 15px;
height: 15px;
border-radius: 50%;
border: 1.5px solid var(--border);
display: block;
}
.device-sync-row-name { flex: 1; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.device-sync-row-meta { font-size: 0.75rem; color: var(--text-secondary); flex-shrink: 0; }