mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
feat(device): add device sync button
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -26,6 +26,8 @@ dependencies = [
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"unicode-normalization",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -37,4 +37,6 @@ reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
||||
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
||||
futures-util = "0.3.31"
|
||||
tauri-plugin-os = "2"
|
||||
walkdir = "2.5.0"
|
||||
unicode-normalization = "0.1.24"
|
||||
|
||||
|
||||
241
src-tauri/src/device_sync.rs
Normal file
241
src-tauri/src/device_sync.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileInfo {
|
||||
relative_path: String,
|
||||
size: u64,
|
||||
status: String, // "new" | "updated"
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncDiff {
|
||||
files_to_copy: Vec<FileInfo>,
|
||||
stats: SyncStats,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStats {
|
||||
new_files: usize,
|
||||
updated_files: usize,
|
||||
unchanged_files: usize,
|
||||
total_size: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncProgress {
|
||||
current: usize,
|
||||
total: usize,
|
||||
current_file: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
struct FileMetadata {
|
||||
size: u64,
|
||||
}
|
||||
|
||||
fn should_skip_file(path: &Path) -> bool {
|
||||
let file_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip system files
|
||||
matches!(file_name, ".DS_Store" | "Thumbs.db" | "desktop.ini" | ".nomedia")
|
||||
}
|
||||
|
||||
fn should_skip_dir(path: &Path) -> bool {
|
||||
let dir_name = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Skip temp folders
|
||||
dir_name == "_temp"
|
||||
}
|
||||
|
||||
/// Normalize a path string to NFC form for consistent comparison
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
path.to_string_lossy().nfc().collect::<String>()
|
||||
}
|
||||
|
||||
/// Index device and compare with library
|
||||
#[tauri::command]
|
||||
pub async fn index_and_compare(
|
||||
library_path: String,
|
||||
device_path: String,
|
||||
overwrite_mode: String, // "skip" | "different" | "always"
|
||||
) -> Result<SyncDiff, String> {
|
||||
let library_path = PathBuf::from(library_path);
|
||||
let device_path = PathBuf::from(device_path);
|
||||
|
||||
// Validate paths exist
|
||||
if !library_path.exists() {
|
||||
return Err(format!("Library path does not exist: {}", library_path.display()));
|
||||
}
|
||||
if !device_path.exists() {
|
||||
return Err(format!("Device path does not exist: {}", device_path.display()));
|
||||
}
|
||||
|
||||
// Step 1: Index device - build HashMap of existing files with normalized paths
|
||||
let mut device_files: HashMap<String, FileMetadata> = HashMap::new();
|
||||
|
||||
for entry in WalkDir::new(&device_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !should_skip_dir(e.path()))
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Error reading device: {}", e))?;
|
||||
|
||||
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
|
||||
let relative_path = entry.path()
|
||||
.strip_prefix(&device_path)
|
||||
.map_err(|e| format!("Path error: {}", e))?
|
||||
.to_path_buf();
|
||||
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
let normalized = normalize_path(&relative_path);
|
||||
device_files.insert(normalized, FileMetadata {
|
||||
size: metadata.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Walk library and compare
|
||||
let mut files_to_copy = Vec::new();
|
||||
let mut new_count = 0;
|
||||
let mut updated_count = 0;
|
||||
let mut unchanged_count = 0;
|
||||
let mut total_size = 0u64;
|
||||
|
||||
for entry in WalkDir::new(&library_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !should_skip_dir(e.path()))
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Error reading library: {}", e))?;
|
||||
|
||||
if entry.file_type().is_file() && !should_skip_file(entry.path()) {
|
||||
let relative_path = entry.path()
|
||||
.strip_prefix(&library_path)
|
||||
.map_err(|e| format!("Path error: {}", e))?
|
||||
.to_path_buf();
|
||||
|
||||
let metadata = entry.metadata()
|
||||
.map_err(|e| format!("Cannot read file metadata: {}", e))?;
|
||||
|
||||
let file_size = metadata.len();
|
||||
let normalized_path = normalize_path(&relative_path);
|
||||
|
||||
// Check if file exists on device (using normalized path)
|
||||
if let Some(device_meta) = device_files.get(&normalized_path) {
|
||||
// File exists on device
|
||||
let size_different = device_meta.size != file_size;
|
||||
|
||||
let should_copy = match overwrite_mode.as_str() {
|
||||
"skip" => false, // Never overwrite
|
||||
"different" => size_different, // Only if different size
|
||||
"always" => true, // Always overwrite
|
||||
_ => size_different, // Default to "different"
|
||||
};
|
||||
|
||||
if should_copy {
|
||||
files_to_copy.push(FileInfo {
|
||||
relative_path: relative_path.to_string_lossy().to_string(),
|
||||
size: file_size,
|
||||
status: "updated".to_string(),
|
||||
});
|
||||
updated_count += 1;
|
||||
total_size += file_size;
|
||||
} else {
|
||||
unchanged_count += 1;
|
||||
}
|
||||
} else {
|
||||
// File doesn't exist on device - new file
|
||||
files_to_copy.push(FileInfo {
|
||||
relative_path: relative_path.to_string_lossy().to_string(),
|
||||
size: file_size,
|
||||
status: "new".to_string(),
|
||||
});
|
||||
new_count += 1;
|
||||
total_size += file_size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SyncDiff {
|
||||
files_to_copy,
|
||||
stats: SyncStats {
|
||||
new_files: new_count,
|
||||
updated_files: updated_count,
|
||||
unchanged_files: unchanged_count,
|
||||
total_size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Sync files to device
|
||||
#[tauri::command]
|
||||
pub async fn sync_to_device(
|
||||
app: AppHandle,
|
||||
library_path: String,
|
||||
device_path: String,
|
||||
files_to_copy: Vec<FileInfo>,
|
||||
) -> Result<String, String> {
|
||||
let library_path = PathBuf::from(library_path);
|
||||
let device_path = PathBuf::from(device_path);
|
||||
|
||||
let total = files_to_copy.len();
|
||||
|
||||
for (index, file_info) in files_to_copy.iter().enumerate() {
|
||||
let source = library_path.join(&file_info.relative_path);
|
||||
let dest = device_path.join(&file_info.relative_path);
|
||||
|
||||
// Emit progress
|
||||
app.emit("sync-progress", SyncProgress {
|
||||
current: index + 1,
|
||||
total,
|
||||
current_file: file_info.relative_path.clone(),
|
||||
status: format!("Copying {} ({} of {})", file_info.relative_path, index + 1, total),
|
||||
}).map_err(|e| format!("Failed to emit progress: {}", e))?;
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
|
||||
// Copy file
|
||||
fs::copy(&source, &dest)
|
||||
.map_err(|e| {
|
||||
// Check for common errors
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::PermissionDenied => {
|
||||
format!("Permission denied: {}", dest.display())
|
||||
}
|
||||
std::io::ErrorKind::NotFound => {
|
||||
format!("Device disconnected or path not found: {}", dest.display())
|
||||
}
|
||||
_ => format!("Failed to copy {}: {}", file_info.relative_path, e)
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
// Emit completion
|
||||
app.emit("sync-progress", SyncProgress {
|
||||
current: total,
|
||||
total,
|
||||
current_file: String::new(),
|
||||
status: format!("Sync complete! Copied {} files", total),
|
||||
}).map_err(|e| format!("Failed to emit completion: {}", e))?;
|
||||
|
||||
Ok(format!("Successfully synced {} files to device", total))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
|
||||
mod deezer_crypto;
|
||||
mod device_sync;
|
||||
mod metadata;
|
||||
mod tagger;
|
||||
|
||||
@@ -317,7 +318,9 @@ pub fn run() {
|
||||
tag_audio_file,
|
||||
read_audio_metadata,
|
||||
decrypt_deezer_track,
|
||||
download_and_decrypt_track
|
||||
download_and_decrypt_track,
|
||||
device_sync::index_and_compare,
|
||||
device_sync::sync_to_device
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
play: '/icons/speaker.png',
|
||||
search: '/icons/internet.png',
|
||||
computer: '/icons/computer.png',
|
||||
device: '/icons/ipod.svg',
|
||||
};
|
||||
|
||||
let history: string[] = $state([]);
|
||||
@@ -80,7 +81,12 @@
|
||||
<img src={icons.search} alt="Search" />
|
||||
<span>Search</span>
|
||||
</a>
|
||||
|
||||
|
||||
<a href="/sync" class="toolbar-button" title="Device Sync">
|
||||
<img src={icons.device} alt="Device Sync" />
|
||||
<span>Sync</span>
|
||||
</a>
|
||||
|
||||
<a href="/settings" class="toolbar-button" title="Settings">
|
||||
<img src={icons.computer} alt="Settings" />
|
||||
<span>Settings</span>
|
||||
|
||||
107
src/lib/services/deviceSync.ts
Normal file
107
src/lib/services/deviceSync.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Device sync service layer
|
||||
* Handles communication with Tauri backend for device synchronization
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
export interface FileInfo {
|
||||
relativePath: string;
|
||||
size: number;
|
||||
status: 'new' | 'updated';
|
||||
}
|
||||
|
||||
export interface SyncStats {
|
||||
newFiles: number;
|
||||
updatedFiles: number;
|
||||
unchangedFiles: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
export interface SyncDiff {
|
||||
filesToCopy: FileInfo[];
|
||||
stats: SyncStats;
|
||||
}
|
||||
|
||||
export interface SyncProgress {
|
||||
current: number;
|
||||
total: number;
|
||||
currentFile: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type ProgressCallback = (progress: SyncProgress) => void;
|
||||
|
||||
/**
|
||||
* Index device and compare with library
|
||||
* Returns a diff showing which files need to be synced
|
||||
*/
|
||||
export async function indexAndCompare(
|
||||
libraryPath: string,
|
||||
devicePath: string,
|
||||
overwriteMode: string
|
||||
): Promise<SyncDiff> {
|
||||
try {
|
||||
const result = await invoke<SyncDiff>('index_and_compare', {
|
||||
libraryPath,
|
||||
devicePath,
|
||||
overwriteMode
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error indexing and comparing:', error);
|
||||
throw new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync files to device with progress updates
|
||||
*/
|
||||
export async function syncToDevice(
|
||||
libraryPath: string,
|
||||
devicePath: string,
|
||||
filesToCopy: FileInfo[],
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<string> {
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
try {
|
||||
// Set up progress listener
|
||||
unlisten = await listen<SyncProgress>('sync-progress', (event) => {
|
||||
if (onProgress) {
|
||||
onProgress(event.payload);
|
||||
}
|
||||
});
|
||||
|
||||
// Start sync operation
|
||||
const result = await invoke<string>('sync_to_device', {
|
||||
libraryPath,
|
||||
devicePath,
|
||||
filesToCopy
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error syncing to device:', error);
|
||||
throw new Error(String(error));
|
||||
} finally {
|
||||
// Clean up event listener
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
81
src/lib/stores/deviceSync.ts
Normal file
81
src/lib/stores/deviceSync.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
// Device sync settings interface
|
||||
export type OverwriteMode = 'skip' | 'different' | 'always';
|
||||
|
||||
export interface DeviceSyncSettings {
|
||||
musicPath: string | null;
|
||||
playlistsPath: string | null;
|
||||
overwriteMode: OverwriteMode;
|
||||
}
|
||||
|
||||
// Initialize the store with device-sync.json
|
||||
const store = new LazyStore('device-sync.json');
|
||||
|
||||
// Default settings
|
||||
const defaultSettings: DeviceSyncSettings = {
|
||||
musicPath: null,
|
||||
playlistsPath: null,
|
||||
overwriteMode: 'different'
|
||||
};
|
||||
|
||||
// Create a writable store for reactive UI updates
|
||||
export const deviceSyncSettings: Writable<DeviceSyncSettings> = writable(defaultSettings);
|
||||
|
||||
// Load settings from store
|
||||
export async function loadDeviceSyncSettings(): Promise<void> {
|
||||
const musicPath = await store.get<string>('musicPath');
|
||||
const playlistsPath = await store.get<string>('playlistsPath');
|
||||
const overwriteMode = await store.get<OverwriteMode>('overwriteMode');
|
||||
|
||||
deviceSyncSettings.set({
|
||||
musicPath: musicPath ?? null,
|
||||
playlistsPath: playlistsPath ?? null,
|
||||
overwriteMode: overwriteMode ?? 'different'
|
||||
});
|
||||
}
|
||||
|
||||
// Save device music path setting
|
||||
export async function setMusicPath(path: string | null): Promise<void> {
|
||||
if (path) {
|
||||
await store.set('musicPath', path);
|
||||
} else {
|
||||
await store.delete('musicPath');
|
||||
}
|
||||
await store.save();
|
||||
|
||||
deviceSyncSettings.update(s => ({
|
||||
...s,
|
||||
musicPath: path
|
||||
}));
|
||||
}
|
||||
|
||||
// Save device playlists path setting
|
||||
export async function setPlaylistsPath(path: string | null): Promise<void> {
|
||||
if (path) {
|
||||
await store.set('playlistsPath', path);
|
||||
} else {
|
||||
await store.delete('playlistsPath');
|
||||
}
|
||||
await store.save();
|
||||
|
||||
deviceSyncSettings.update(s => ({
|
||||
...s,
|
||||
playlistsPath: path
|
||||
}));
|
||||
}
|
||||
|
||||
// Save overwrite mode setting
|
||||
export async function setOverwriteMode(mode: OverwriteMode): Promise<void> {
|
||||
await store.set('overwriteMode', mode);
|
||||
await store.save();
|
||||
|
||||
deviceSyncSettings.update(s => ({
|
||||
...s,
|
||||
overwriteMode: mode
|
||||
}));
|
||||
}
|
||||
|
||||
// Initialize settings on import
|
||||
loadDeviceSyncSettings();
|
||||
608
src/routes/sync/+page.svelte
Normal file
608
src/routes/sync/+page.svelte
Normal file
@@ -0,0 +1,608 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { exists } from '@tauri-apps/plugin-fs';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import {
|
||||
deviceSyncSettings,
|
||||
loadDeviceSyncSettings,
|
||||
setMusicPath,
|
||||
setPlaylistsPath,
|
||||
setOverwriteMode,
|
||||
type OverwriteMode
|
||||
} from '$lib/stores/deviceSync';
|
||||
import {
|
||||
indexAndCompare,
|
||||
syncToDevice,
|
||||
formatBytes,
|
||||
type SyncDiff,
|
||||
type SyncProgress
|
||||
} from '$lib/services/deviceSync';
|
||||
|
||||
type ViewMode = 'sync' | 'preferences';
|
||||
|
||||
let viewMode = $state<ViewMode>('sync');
|
||||
let configured = $state(false);
|
||||
let deviceConnected = $state(false);
|
||||
let checkingConnection = $state(false);
|
||||
|
||||
// Path inputs for initial setup
|
||||
let musicPathInput = $state('');
|
||||
let playlistsPathInput = $state('');
|
||||
|
||||
// Sync state
|
||||
let indexing = $state(false);
|
||||
let syncing = $state(false);
|
||||
let syncDiff = $state<SyncDiff | null>(null);
|
||||
let syncProgress = $state<SyncProgress | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
let selectedFileIndex = $state<number | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadDeviceSyncSettings();
|
||||
configured = !!$deviceSyncSettings.musicPath;
|
||||
|
||||
if (configured) {
|
||||
await checkDeviceConnection();
|
||||
}
|
||||
});
|
||||
|
||||
async function checkDeviceConnection() {
|
||||
if (!$deviceSyncSettings.musicPath) {
|
||||
deviceConnected = false;
|
||||
return;
|
||||
}
|
||||
|
||||
checkingConnection = true;
|
||||
try {
|
||||
deviceConnected = await exists($deviceSyncSettings.musicPath);
|
||||
} catch (e) {
|
||||
deviceConnected = false;
|
||||
} finally {
|
||||
checkingConnection = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseMusicPath() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Device Music Folder'
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
musicPathInput = selected;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowsePlaylistsPath() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Device Playlists Folder'
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
playlistsPathInput = selected;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfiguration() {
|
||||
if (!musicPathInput) {
|
||||
error = 'Please select a music folder path';
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
await setMusicPath(musicPathInput);
|
||||
await setPlaylistsPath(playlistsPathInput || null);
|
||||
|
||||
configured = true;
|
||||
await checkDeviceConnection();
|
||||
}
|
||||
|
||||
async function handleIndexAndCompare() {
|
||||
if (!$settings.musicFolder) {
|
||||
error = 'No library music folder configured. Please set one in Settings.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$deviceSyncSettings.musicPath) {
|
||||
error = 'No device music path configured.';
|
||||
return;
|
||||
}
|
||||
|
||||
indexing = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
syncDiff = null;
|
||||
|
||||
try {
|
||||
await checkDeviceConnection();
|
||||
if (!deviceConnected) {
|
||||
throw new Error('Device is not connected or path does not exist');
|
||||
}
|
||||
|
||||
const result = await indexAndCompare(
|
||||
$settings.musicFolder,
|
||||
$deviceSyncSettings.musicPath,
|
||||
$deviceSyncSettings.overwriteMode
|
||||
);
|
||||
|
||||
syncDiff = result;
|
||||
successMessage = `Found ${result.filesToCopy.length} files to sync`;
|
||||
} catch (e) {
|
||||
error = 'Error indexing device: ' + (e instanceof Error ? e.message : String(e));
|
||||
syncDiff = null;
|
||||
} finally {
|
||||
indexing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSync() {
|
||||
if (!syncDiff || syncDiff.filesToCopy.length === 0) {
|
||||
error = 'No files to sync. Run Index & Compare first.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$settings.musicFolder || !$deviceSyncSettings.musicPath) {
|
||||
error = 'Configuration error';
|
||||
return;
|
||||
}
|
||||
|
||||
syncing = true;
|
||||
error = null;
|
||||
successMessage = null;
|
||||
syncProgress = null;
|
||||
|
||||
try {
|
||||
const result = await syncToDevice(
|
||||
$settings.musicFolder,
|
||||
$deviceSyncSettings.musicPath,
|
||||
syncDiff.filesToCopy,
|
||||
(progress) => {
|
||||
syncProgress = progress;
|
||||
}
|
||||
);
|
||||
|
||||
successMessage = result;
|
||||
syncDiff = null;
|
||||
} catch (e) {
|
||||
error = 'Error syncing to device: ' + (e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
syncing = false;
|
||||
syncProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateMusicPath() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Device Music Folder'
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
await setMusicPath(selected);
|
||||
await checkDeviceConnection();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdatePlaylistsPath() {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: 'Select Device Playlists Folder'
|
||||
});
|
||||
|
||||
if (selected && typeof selected === 'string') {
|
||||
await setPlaylistsPath(selected);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileClick(index: number) {
|
||||
selectedFileIndex = index;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sync-wrapper">
|
||||
<h2 style="padding: 8px">Device Sync</h2>
|
||||
|
||||
{#if !configured}
|
||||
<!-- Initial Configuration -->
|
||||
<section class="window config-section" style="max-width: 600px; margin: 8px;">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">Configure Device Paths</div>
|
||||
</div>
|
||||
<div class="window-body" style="padding: 12px;">
|
||||
<p>Select the folders on your portable device to sync music and playlists:</p>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label for="music-path-input">Device Music Folder</label>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="music-path-input"
|
||||
type="text"
|
||||
bind:value={musicPathInput}
|
||||
placeholder="/Volumes/iPod/Music"
|
||||
readonly
|
||||
/>
|
||||
<button onclick={handleBrowseMusicPath}>Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label for="playlists-path-input">Device Playlists Folder</label>
|
||||
<div class="input-with-button">
|
||||
<input
|
||||
id="playlists-path-input"
|
||||
type="text"
|
||||
bind:value={playlistsPathInput}
|
||||
placeholder="/Volumes/iPod/Playlists"
|
||||
readonly
|
||||
/>
|
||||
<button onclick={handleBrowsePlaylistsPath}>Browse...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button onclick={handleSaveConfiguration}>Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if syncing}
|
||||
<div class="sync-status">
|
||||
{#if syncProgress && syncProgress.total > 0}
|
||||
<p class="progress-text">{syncProgress.current} / {syncProgress.total} files</p>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {(syncProgress.current / syncProgress.total) * 100}%"
|
||||
></div>
|
||||
</div>
|
||||
{#if syncProgress.currentFile}
|
||||
<p class="current-file">{syncProgress.currentFile}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>Syncing...</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if indexing}
|
||||
<p style="padding: 8px;">Indexing device...</p>
|
||||
{:else if error}
|
||||
<p class="error" style="padding: 8px;">{error}</p>
|
||||
{:else}
|
||||
<section class="sync-content">
|
||||
<!-- Tabs -->
|
||||
<!--
|
||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||
-->
|
||||
<menu role="tablist">
|
||||
<li role="tab" aria-selected={viewMode === 'sync'}>
|
||||
<button onclick={() => viewMode = 'sync'}>Sync</button>
|
||||
</li>
|
||||
<li role="tab" aria-selected={viewMode === 'preferences'}>
|
||||
<button onclick={() => viewMode = 'preferences'}>Preferences</button>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="window tab-content" role="tabpanel">
|
||||
<div class="window-body">
|
||||
{#if viewMode === 'sync'}
|
||||
<!-- Sync Tab -->
|
||||
<div class="sync-info">
|
||||
<p>
|
||||
<strong>Device:</strong>
|
||||
{#if checkingConnection}
|
||||
Checking...
|
||||
{:else if deviceConnected}
|
||||
Connected - {$deviceSyncSettings.musicPath}
|
||||
{:else}
|
||||
Not Connected - {$deviceSyncSettings.musicPath}
|
||||
{/if}
|
||||
</p>
|
||||
<div class="button-group">
|
||||
<button
|
||||
onclick={handleIndexAndCompare}
|
||||
disabled={indexing || syncing || !deviceConnected}
|
||||
>
|
||||
{indexing ? 'Indexing...' : 'Index & Compare'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleSync}
|
||||
disabled={!syncDiff || syncDiff.filesToCopy.length === 0 || syncing || !deviceConnected}
|
||||
>
|
||||
{syncing ? 'Syncing...' : 'Sync to Device'}
|
||||
</button>
|
||||
</div>
|
||||
{#if successMessage}
|
||||
<p class="success">{successMessage}</p>
|
||||
{/if}
|
||||
{#if syncDiff}
|
||||
<p>
|
||||
New: {syncDiff.stats.newFiles} | Updated: {syncDiff.stats.updatedFiles} |
|
||||
Unchanged: {syncDiff.stats.unchangedFiles} |
|
||||
Total: {formatBytes(syncDiff.stats.totalSize)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if syncDiff && syncDiff.filesToCopy.length > 0}
|
||||
<div class="sunken-panel table-container">
|
||||
<table class="interactive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>File</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each syncDiff.filesToCopy as file, i}
|
||||
<tr
|
||||
class:highlighted={selectedFileIndex === i}
|
||||
onclick={() => handleFileClick(i)}
|
||||
>
|
||||
<td>{file.relativePath}</td>
|
||||
<td>{file.status}</td>
|
||||
<td class="size-cell">{formatBytes(file.size)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if viewMode === 'preferences'}
|
||||
<!-- Preferences Tab -->
|
||||
<div class="prefs-container">
|
||||
<fieldset>
|
||||
<legend>Device Paths</legend>
|
||||
<div class="field-row-stacked">
|
||||
<label>Device Music Path</label>
|
||||
<div class="path-display">
|
||||
<code>{$deviceSyncSettings.musicPath || 'Not set'}</code>
|
||||
<button onclick={handleUpdateMusicPath}>Change...</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label>Device Playlists Path</label>
|
||||
<div class="path-display">
|
||||
<code>{$deviceSyncSettings.playlistsPath || 'Not set'}</code>
|
||||
<button onclick={handleUpdatePlaylistsPath}>Change...</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>When file exists on device</legend>
|
||||
<div class="field-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="mode-skip"
|
||||
name="overwrite-mode"
|
||||
value="skip"
|
||||
bind:group={$deviceSyncSettings.overwriteMode}
|
||||
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||
/>
|
||||
<label for="mode-skip">Skip (don't overwrite)</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="mode-different"
|
||||
name="overwrite-mode"
|
||||
value="different"
|
||||
bind:group={$deviceSyncSettings.overwriteMode}
|
||||
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||
/>
|
||||
<label for="mode-different">Overwrite if different size</label>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<input
|
||||
type="radio"
|
||||
id="mode-always"
|
||||
name="overwrite-mode"
|
||||
value="always"
|
||||
bind:group={$deviceSyncSettings.overwriteMode}
|
||||
onchange={() => setOverwriteMode($deviceSyncSettings.overwriteMode)}
|
||||
/>
|
||||
<label for="mode-always">Always overwrite</label>
|
||||
</div>
|
||||
|
||||
<p class="help-text">
|
||||
System files and _temp folders are always skipped.
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sync-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-status {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.sync-status p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: #c0c0c0;
|
||||
border: 2px inset #808080;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #000080, #0000ff);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.current-file {
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
color: #808080;
|
||||
margin: 4px 0 0 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.field-row-stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.sync-content {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin-top: -2px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.window-body {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sync-info {
|
||||
padding: 16px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-info p {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.size-cell {
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.prefs-container {
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.path-display {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.path-display code {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
background: var(--button-shadow, #2a2a2a);
|
||||
border: 1px solid var(--button-highlight, #606060);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 11px;
|
||||
color: #808080;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user