feat(device): add device sync button

This commit is contained in:
2025-10-15 11:45:52 -04:00
parent af4f8ce77f
commit 8d773f8188
8 changed files with 1052 additions and 2 deletions

View File

@@ -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>

View 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];
}

View 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();

View 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>