mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -1349,6 +1349,199 @@ async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TrackSyncInfo {
|
||||
id: String,
|
||||
url: String,
|
||||
suffix: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
title: String,
|
||||
#[serde(rename = "trackNumber")]
|
||||
track_number: Option<u32>,
|
||||
#[serde(rename = "discNumber")]
|
||||
disc_number: Option<u32>,
|
||||
year: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct SyncTrackResult {
|
||||
path: String,
|
||||
skipped: bool,
|
||||
}
|
||||
|
||||
/// Replaces characters that are invalid in file/directory names on Windows and
|
||||
/// most Unix filesystems with an underscore. Also trims leading/trailing dots
|
||||
/// and spaces which cause issues on Windows.
|
||||
fn sanitize_path_component(s: &str) -> String {
|
||||
const INVALID: &[char] = &['/', '\\', ':', '*', '?', '"', '<', '>', '|'];
|
||||
let sanitized: String = s
|
||||
.chars()
|
||||
.map(|c| if INVALID.contains(&c) || c.is_control() { '_' } else { c })
|
||||
.collect();
|
||||
sanitized.trim_matches(|c| c == '.' || c == ' ').to_string()
|
||||
}
|
||||
|
||||
/// Evaluates `template` by substituting `{artist}`, `{album}`, `{title}`,
|
||||
/// `{track_number}`, `{disc_number}`, `{year}` with sanitized values from `track`.
|
||||
fn apply_device_sync_template(template: &str, track: &TrackSyncInfo) -> String {
|
||||
let track_number = track.track_number
|
||||
.map(|n| format!("{:02}", n))
|
||||
.unwrap_or_default();
|
||||
let disc_number = track.disc_number
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let year = track.year
|
||||
.map(|y| y.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
template
|
||||
.replace("{artist}", &sanitize_path_component(&track.artist))
|
||||
.replace("{album}", &sanitize_path_component(&track.album))
|
||||
.replace("{title}", &sanitize_path_component(&track.title))
|
||||
.replace("{track_number}", &track_number)
|
||||
.replace("{disc_number}", &disc_number)
|
||||
.replace("{year}", &year)
|
||||
}
|
||||
|
||||
/// Downloads a single track to a USB/SD device using the configured filename template.
|
||||
/// Emits `device:sync:progress` events with `{ jobId, trackId, status, path? }`.
|
||||
#[tauri::command]
|
||||
async fn sync_track_to_device(
|
||||
track: TrackSyncInfo,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
job_id: String,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<SyncTrackResult, String> {
|
||||
let relative = apply_device_sync_template(&template, &track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
let dest_path = std::path::Path::new(&dest_dir).join(&file_name);
|
||||
let path_str = dest_path.to_string_lossy().to_string();
|
||||
|
||||
if dest_path.exists() {
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "skipped", "path": path_str,
|
||||
}));
|
||||
return Ok(SyncTrackResult { path: path_str, skipped: true });
|
||||
}
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client.get(&track.url).send().await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
let msg = format!("HTTP {}", response.status().as_u16());
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "error", "error": msg,
|
||||
}));
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
let part_path = dest_path.with_extension(format!("{}.part", track.suffix));
|
||||
if let Err(e) = stream_to_file(response, &part_path).await {
|
||||
let _ = tokio::fs::remove_file(&part_path).await;
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "error", "error": e,
|
||||
}));
|
||||
return Err(e);
|
||||
}
|
||||
tokio::fs::rename(&part_path, &dest_path)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = app.emit("device:sync:progress", serde_json::json!({
|
||||
"jobId": job_id, "trackId": track.id, "status": "done", "path": path_str,
|
||||
}));
|
||||
Ok(SyncTrackResult { path: path_str, skipped: false })
|
||||
}
|
||||
|
||||
/// Computes the expected file paths for a batch of tracks using the given template,
|
||||
/// without downloading anything. Used by the cleanup flow to find orphans.
|
||||
#[tauri::command]
|
||||
fn compute_sync_paths(
|
||||
tracks: Vec<TrackSyncInfo>,
|
||||
dest_dir: String,
|
||||
template: String,
|
||||
) -> Vec<String> {
|
||||
tracks.iter().map(|track| {
|
||||
let relative = apply_device_sync_template(&template, track);
|
||||
let file_name = format!("{}.{}", relative, track.suffix);
|
||||
std::path::Path::new(&dest_dir)
|
||||
.join(&file_name)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Lists all files (recursively) under `dir`. Returns `"VOLUME_NOT_FOUND"` if
|
||||
/// the directory does not exist (e.g. USB was unplugged).
|
||||
#[tauri::command]
|
||||
async fn list_device_dir_files(dir: String) -> Result<Vec<String>, String> {
|
||||
let root = std::path::PathBuf::from(&dir);
|
||||
if !root.exists() {
|
||||
return Err("VOLUME_NOT_FOUND".to_string());
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut stack = vec![root];
|
||||
while let Some(current) = stack.pop() {
|
||||
let mut rd = match tokio::fs::read_dir(¤t).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let path = entry.path();
|
||||
// Skip hidden dirs (e.g. .Trash-1000, .Ventoy, .fseventsd)
|
||||
let is_hidden = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false);
|
||||
if is_hidden { continue; }
|
||||
if path.is_dir() {
|
||||
stack.push(path);
|
||||
} else {
|
||||
files.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Deletes a file from the device and prunes empty parent directories
|
||||
/// (up to 2 levels: album folder, then artist folder).
|
||||
#[tauri::command]
|
||||
async fn delete_device_file(path: String) -> Result<(), String> {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
if p.exists() {
|
||||
tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?;
|
||||
// Prune empty parent dirs (album → artist)
|
||||
let mut current = p.parent().map(|d| d.to_path_buf());
|
||||
for _ in 0..2 {
|
||||
let Some(dir) = current else { break };
|
||||
let is_empty = std::fs::read_dir(&dir)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = tokio::fs::remove_dir(&dir).await;
|
||||
current = dir.parent().map(|d| d.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds and returns a new system-tray icon with all menu items and event handlers.
|
||||
/// Called from `setup()` (initial creation) and from `toggle_tray_icon` (re-creation).
|
||||
fn build_tray_icon(app: &tauri::AppHandle) -> tauri::Result<TrayIcon> {
|
||||
@@ -1738,6 +1931,10 @@ pub fn run() {
|
||||
get_hot_cache_size,
|
||||
delete_hot_cache_track,
|
||||
purge_hot_cache,
|
||||
sync_track_to_device,
|
||||
compute_sync_paths,
|
||||
list_device_dir_files,
|
||||
delete_device_file,
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: 'Закрыть',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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: '关闭',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user