From 17a5c9217461b9e31c4f279edbb185653c2ecee9 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 14 Apr 2026 00:02:13 +0200 Subject: [PATCH] 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 --- src-tauri/src/lib.rs | 197 +++++++++++++++ src/App.tsx | 2 + src/components/Sidebar.tsx | 5 +- src/locales/de.ts | 32 +++ src/locales/en.ts | 32 +++ src/locales/es.ts | 28 +++ src/locales/fr.ts | 28 +++ src/locales/nb.ts | 28 +++ src/locales/nl.ts | 28 +++ src/locales/ru.ts | 28 +++ src/locales/zh.ts | 28 +++ src/pages/DeviceSync.tsx | 464 +++++++++++++++++++++++++++++++++++ src/store/deviceSyncStore.ts | 89 +++++++ src/store/sidebarStore.ts | 1 + src/styles/components.css | 353 ++++++++++++++++++++++++++ 15 files changed, 1341 insertions(+), 2 deletions(-) create mode 100644 src/pages/DeviceSync.tsx create mode 100644 src/store/deviceSyncStore.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c58649fb..a0bd57cd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1349,6 +1349,199 @@ async fn purge_hot_cache(custom_dir: Option, 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, + #[serde(rename = "discNumber")] + disc_number: Option, + year: Option, +} + +#[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 { + 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, + dest_dir: String, + template: String, +) -> Vec { + 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, 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 { @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 419ce0b0..5ff3f4cf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> } /> + } /> diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index a367a971..598e9540 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 { + 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('albums'); + const [search, setSearch] = useState(''); + const [playlists, setPlaylists] = useState([]); + const [albums, setAlbums] = useState([]); + const [artists, setArtists] = useState([]); + const [loadingBrowser, setLoadingBrowser] = useState(false); + const [deleting, setDeleting] = useState(false); + const [expandedArtistIds, setExpandedArtistIds] = useState>(new Set()); + const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); + const [loadingArtistIds, setLoadingArtistIds] = useState>(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(); + 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(); + 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('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: , label: t('deviceSync.tabPlaylists') }, + { key: 'albums', icon: , label: t('deviceSync.tabAlbums') }, + { key: 'artists', icon: , label: t('deviceSync.tabArtists') }, + ]; + + return ( +
+ + {/* ── Header ── */} +
+ +

{t('deviceSync.title')}

+
+ + {targetDir ?? t('deviceSync.noFolderChosen')} + + +
+
+ + {/* ── Template (collapsed row) ── */} +
+ {t('deviceSync.filenameTemplate')} + setFilenameTemplate(e.target.value)} + spellCheck={false} + data-tooltip={t('deviceSync.templateHint')} + data-tooltip-pos="bottom" + /> +
+ + {/* ── Main ── */} +
+ + {/* ── Browser (left) ── */} +
+
+ {tabs.map(tab => ( + + ))} +
+
+ setSearch(e.target.value)} + /> +
+
+ {loadingBrowser && ( +
+ )} + {activeTab === 'playlists' && filteredPlaylists.map(pl => ( + 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 => ( + 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 => ( + +
+ + {ar.name} + {ar.albumCount != null && + {ar.albumCount} Alben} +
+ {expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) && + artistAlbumsMap.get(ar.id)!.map(al => ( + 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 })} /> + )) + } +
+ ))} +
+
+ + {/* ── Device list (right) ── */} +
+
+ {t('deviceSync.onDevice')} +
+ {!activeJob && ( + + )} + {checkedIds.length > 0 && !isRunning && ( + + )} +
+
+ + {sources.length === 0 ? ( +

{t('deviceSync.noSourcesSelected')}

+ ) : ( + <> +
+ + {t('deviceSync.colName')} + {t('deviceSync.colType')} +
+
+ {sources.map(s => ( + + ))} +
+ + )} + + {/* Progress / sync result */} + {activeJob && ( +
+
+
0 + ? `${((activeJob.done + activeJob.skipped + activeJob.failed) / activeJob.total) * 100}%` + : '0%' }} + /> +
+
+ {isRunning && } + {isDone && } + + {isDone + ? t('deviceSync.syncResult', { done: activeJob.done, skipped: activeJob.skipped, total: activeJob.total }) + : `${activeJob.done + activeJob.skipped + activeJob.failed} / ${activeJob.total}`} + + {activeJob.failed > 0 && ( + {activeJob.failed} + )} + {activeJob.skipped > 0 && ( + {activeJob.skipped} + )} + {isRunning + ? + : + } +
+
+ )} + +
+ +
+
+ ); +} + +// ─── BrowserRow ────────────────────────────────────────────────────────────── + +function BrowserRow({ name, meta, selected, onToggle, indent }: { + name: string; meta?: string; selected: boolean; onToggle: () => void; indent?: boolean; +}) { + return ( + + ); +} diff --git a/src/store/deviceSyncStore.ts b/src/store/deviceSyncStore.ts new file mode 100644 index 00000000..ec3924c2 --- /dev/null +++ b/src/store/deviceSyncStore.ts @@ -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) => void; +} + +export const useDeviceSyncStore = create()( + 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, + }), + } + ) +); diff --git a/src/store/sidebarStore.ts b/src/store/sidebarStore.ts index 0177e956..e0e60d3d 100644 --- a/src/store/sidebarStore.ts +++ b/src/store/sidebarStore.ts @@ -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 }, ]; diff --git a/src/styles/components.css b/src/styles/components.css index 63de6451..04863167 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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; }