mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
Compare commits
4 Commits
a4f5bdd7a8
...
e19c25e94b
| Author | SHA1 | Date | |
|---|---|---|---|
| e19c25e94b | |||
| 7f719bec11 | |||
| 8d773f8188 | |||
| af4f8ce77f |
3
bun.lock
3
bun.lock
@@ -4,6 +4,7 @@
|
||||
"": {
|
||||
"name": "shark",
|
||||
"dependencies": {
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@noble/ciphers": "^2.0.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
@@ -84,6 +85,8 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="],
|
||||
|
||||
"@fabianlars/tauri-plugin-oauth": ["@fabianlars/tauri-plugin-oauth@2.0.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.3" } }, "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fabianlars/tauri-plugin-oauth": "2",
|
||||
"@noble/ciphers": "^2.0.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "~2",
|
||||
|
||||
18
src-tauri/Cargo.lock
generated
18
src-tauri/Cargo.lock
generated
@@ -20,12 +20,15 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-oauth",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-store",
|
||||
"tokio",
|
||||
"unicode-normalization",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4929,6 +4932,21 @@ dependencies = [
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-oauth"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3"
|
||||
dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 1.0.69",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.0"
|
||||
|
||||
@@ -37,4 +37,7 @@ 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"
|
||||
tauri-plugin-oauth = "2.0.0"
|
||||
|
||||
|
||||
@@ -72,12 +72,28 @@
|
||||
},
|
||||
{
|
||||
"url": "https://lrclib.net/**"
|
||||
},
|
||||
{
|
||||
"url": "https://accounts.spotify.com/**"
|
||||
},
|
||||
{
|
||||
"url": "https://api.spotify.com/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"process:default",
|
||||
"os:default"
|
||||
"os:default",
|
||||
"oauth:allow-start",
|
||||
"oauth:allow-cancel",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://accounts.spotify.com/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -299,6 +300,7 @@ pub fn run() {
|
||||
}];
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_oauth::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(
|
||||
@@ -317,7 +319,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];
|
||||
}
|
||||
260
src/lib/services/spotify.ts
Normal file
260
src/lib/services/spotify.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
import type { SpotifyUser } from '$lib/stores/spotify';
|
||||
import { isTokenExpired } from '$lib/stores/spotify';
|
||||
|
||||
const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize';
|
||||
const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token';
|
||||
const SPOTIFY_API_BASE = 'https://api.spotify.com/v1';
|
||||
|
||||
// Required scopes for the app
|
||||
const REQUIRED_SCOPES = [
|
||||
'user-read-private',
|
||||
'user-read-email',
|
||||
'user-library-read',
|
||||
'playlist-read-private',
|
||||
'playlist-read-collaborative',
|
||||
'user-follow-read'
|
||||
];
|
||||
|
||||
/**
|
||||
* Spotify API client with OAuth 2.0 PKCE flow
|
||||
*/
|
||||
export class SpotifyAPI {
|
||||
private clientId: string | null = null;
|
||||
private clientSecret: string | null = null;
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private expiresAt: number | null = null;
|
||||
|
||||
/**
|
||||
* Set client credentials (developer app credentials)
|
||||
*/
|
||||
setClientCredentials(clientId: string, clientSecret: string): void {
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set OAuth tokens
|
||||
*/
|
||||
setTokens(accessToken: string, refreshToken: string, expiresAt: number): void {
|
||||
this.accessToken = accessToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random code verifier for PKCE
|
||||
*/
|
||||
generateCodeVerifier(): string {
|
||||
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const values = crypto.getRandomValues(new Uint8Array(64));
|
||||
return Array.from(values)
|
||||
.map(x => possible[x % possible.length])
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate code challenge from verifier using SHA256
|
||||
*/
|
||||
async generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(verifier);
|
||||
const hashed = await crypto.subtle.digest('SHA-256', data);
|
||||
|
||||
// Base64 URL encode
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(hashed)))
|
||||
.replace(/=/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_');
|
||||
|
||||
return base64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authorization URL for user to authenticate
|
||||
* Returns the URL and the code verifier (must be stored for later)
|
||||
*/
|
||||
async getAuthorizationUrl(clientId: string, redirectUri: string): Promise<{ url: string; codeVerifier: string }> {
|
||||
const codeVerifier = this.generateCodeVerifier();
|
||||
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: redirectUri,
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: codeChallenge,
|
||||
scope: REQUIRED_SCOPES.join(' ')
|
||||
});
|
||||
|
||||
const url = `${SPOTIFY_AUTH_URL}?${params.toString()}`;
|
||||
|
||||
return { url, codeVerifier };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for access token
|
||||
*/
|
||||
async exchangeCodeForToken(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
clientId: string,
|
||||
redirectUri: string
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier
|
||||
});
|
||||
|
||||
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Token exchange error:', errorText);
|
||||
throw new Error(`Token exchange failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Store tokens
|
||||
this.accessToken = data.access_token;
|
||||
this.refreshToken = data.refresh_token;
|
||||
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_in: data.expires_in
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
*/
|
||||
async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> {
|
||||
if (!this.refreshToken || !this.clientId || !this.clientSecret) {
|
||||
throw new Error('Missing refresh token or client credentials');
|
||||
}
|
||||
|
||||
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: this.refreshToken
|
||||
});
|
||||
|
||||
const response = await fetch(SPOTIFY_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${credentials}`
|
||||
},
|
||||
body: params.toString()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Token refresh error:', errorText);
|
||||
throw new Error(`Token refresh failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Update tokens
|
||||
this.accessToken = data.access_token;
|
||||
this.expiresAt = Date.now() + (data.expires_in * 1000);
|
||||
|
||||
// Note: Spotify may or may not return a new refresh token
|
||||
if (data.refresh_token) {
|
||||
this.refreshToken = data.refresh_token;
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
expires_in: data.expires_in
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated API call to Spotify
|
||||
* Automatically refreshes token if expired
|
||||
*/
|
||||
private async apiCall<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
// Check if token needs refresh
|
||||
if (isTokenExpired(this.expiresAt)) {
|
||||
console.log('[Spotify] Token expired, refreshing...');
|
||||
await this.refreshAccessToken();
|
||||
}
|
||||
|
||||
if (!this.accessToken) {
|
||||
throw new Error('No access token available');
|
||||
}
|
||||
|
||||
const url = `${SPOTIFY_API_BASE}${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`Spotify API error for ${endpoint}:`, errorText);
|
||||
throw new Error(`API call failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's profile
|
||||
*/
|
||||
async getCurrentUser(): Promise<SpotifyUser> {
|
||||
return this.apiCall<SpotifyUser>('/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's playlists
|
||||
*/
|
||||
async getUserPlaylists(limit: number = 50, offset: number = 0): Promise<any> {
|
||||
return this.apiCall(`/me/playlists?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's saved tracks
|
||||
*/
|
||||
async getUserTracks(limit: number = 50, offset: number = 0): Promise<any> {
|
||||
return this.apiCall(`/me/tracks?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's saved albums
|
||||
*/
|
||||
async getUserAlbums(limit: number = 50, offset: number = 0): Promise<any> {
|
||||
return this.apiCall(`/me/albums?limit=${limit}&offset=${offset}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's followed artists
|
||||
*/
|
||||
async getUserArtists(limit: number = 50, after?: string): Promise<any> {
|
||||
const afterParam = after ? `&after=${after}` : '';
|
||||
return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const spotifyAPI = new SpotifyAPI();
|
||||
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();
|
||||
133
src/lib/stores/spotify.ts
Normal file
133
src/lib/stores/spotify.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { LazyStore } from '@tauri-apps/plugin-store';
|
||||
import { writable, type Writable } from 'svelte/store';
|
||||
|
||||
// Spotify User interface
|
||||
export interface SpotifyUser {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email?: string;
|
||||
country?: string;
|
||||
product?: string; // premium, free, etc.
|
||||
images?: Array<{ url: string }>;
|
||||
}
|
||||
|
||||
// Spotify auth state
|
||||
export interface SpotifyAuthState {
|
||||
// Developer credentials
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
// OAuth tokens
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
expiresAt: number | null; // Unix timestamp in milliseconds
|
||||
// User data
|
||||
user: SpotifyUser | null;
|
||||
loggedIn: boolean;
|
||||
}
|
||||
|
||||
// Initialize the store with spotify.json
|
||||
const store = new LazyStore('spotify.json');
|
||||
|
||||
// Default state
|
||||
const defaultState: SpotifyAuthState = {
|
||||
clientId: null,
|
||||
clientSecret: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
loggedIn: false
|
||||
};
|
||||
|
||||
// Create a writable store for reactive UI updates
|
||||
export const spotifyAuth: Writable<SpotifyAuthState> = writable(defaultState);
|
||||
|
||||
// Load Spotify auth state from store
|
||||
export async function loadSpotifyAuth(): Promise<void> {
|
||||
const clientId = await store.get<string>('clientId');
|
||||
const clientSecret = await store.get<string>('clientSecret');
|
||||
const accessToken = await store.get<string>('accessToken');
|
||||
const refreshToken = await store.get<string>('refreshToken');
|
||||
const expiresAt = await store.get<number>('expiresAt');
|
||||
const user = await store.get<SpotifyUser>('user');
|
||||
|
||||
spotifyAuth.set({
|
||||
clientId: clientId ?? null,
|
||||
clientSecret: clientSecret ?? null,
|
||||
accessToken: accessToken ?? null,
|
||||
refreshToken: refreshToken ?? null,
|
||||
expiresAt: expiresAt ?? null,
|
||||
user: user ?? null,
|
||||
loggedIn: !!(accessToken && user)
|
||||
});
|
||||
}
|
||||
|
||||
// Save client credentials (developer app credentials)
|
||||
export async function saveClientCredentials(clientId: string, clientSecret: string): Promise<void> {
|
||||
await store.set('clientId', clientId);
|
||||
await store.set('clientSecret', clientSecret);
|
||||
await store.save();
|
||||
|
||||
spotifyAuth.update(s => ({
|
||||
...s,
|
||||
clientId,
|
||||
clientSecret
|
||||
}));
|
||||
}
|
||||
|
||||
// Save OAuth tokens
|
||||
export async function saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise<void> {
|
||||
const expiresAt = Date.now() + (expiresIn * 1000);
|
||||
|
||||
await store.set('accessToken', accessToken);
|
||||
await store.set('refreshToken', refreshToken);
|
||||
await store.set('expiresAt', expiresAt);
|
||||
await store.save();
|
||||
|
||||
spotifyAuth.update(s => ({
|
||||
...s,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresAt
|
||||
}));
|
||||
}
|
||||
|
||||
// Save user data
|
||||
export async function saveUser(user: SpotifyUser): Promise<void> {
|
||||
await store.set('user', user);
|
||||
await store.save();
|
||||
|
||||
spotifyAuth.update(s => ({
|
||||
...s,
|
||||
user,
|
||||
loggedIn: true
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear auth (logout)
|
||||
export async function clearSpotifyAuth(): Promise<void> {
|
||||
await store.delete('accessToken');
|
||||
await store.delete('refreshToken');
|
||||
await store.delete('expiresAt');
|
||||
await store.delete('user');
|
||||
await store.save();
|
||||
|
||||
spotifyAuth.update(s => ({
|
||||
...s,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
user: null,
|
||||
loggedIn: false
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if token is expired or about to expire (within 5 minutes)
|
||||
export function isTokenExpired(expiresAt: number | null): boolean {
|
||||
if (!expiresAt) return true;
|
||||
const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
return Date.now() >= (expiresAt - bufferTime);
|
||||
}
|
||||
|
||||
// Initialize on module load
|
||||
loadSpotifyAuth();
|
||||
@@ -132,10 +132,10 @@
|
||||
Services
|
||||
</summary>
|
||||
<div class="nav-submenu">
|
||||
<!-- <a href="/services/spotify" class="nav-item nav-subitem">
|
||||
<a href="/services/spotify" class="nav-item nav-subitem">
|
||||
<img src="/icons/spotify.png" alt="" class="nav-icon" />
|
||||
Spotify
|
||||
</a> -->
|
||||
</a>
|
||||
<a href="/services/deezer" class="nav-item nav-subitem">
|
||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||
Deezer
|
||||
|
||||
514
src/routes/services/spotify/+page.svelte
Normal file
514
src/routes/services/spotify/+page.svelte
Normal file
@@ -0,0 +1,514 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth } from '$lib/stores/spotify';
|
||||
import { spotifyAPI } from '$lib/services/spotify';
|
||||
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
|
||||
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
|
||||
const OAUTH_PORT = 8228;
|
||||
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
|
||||
|
||||
// Login form state
|
||||
let clientIdInput = $state('');
|
||||
let clientSecretInput = $state('');
|
||||
let isAuthenticating = $state(false);
|
||||
let loginError = $state('');
|
||||
let loginSuccess = $state('');
|
||||
|
||||
// OAuth state
|
||||
let isWaitingForCallback = $state(false);
|
||||
let oauthUnlisten: (() => void) | null = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
await loadSpotifyAuth();
|
||||
|
||||
// Check if we have client credentials stored
|
||||
if ($spotifyAuth.clientId) {
|
||||
clientIdInput = $spotifyAuth.clientId;
|
||||
}
|
||||
if ($spotifyAuth.clientSecret) {
|
||||
clientSecretInput = $spotifyAuth.clientSecret;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleAuthorize() {
|
||||
if (!clientIdInput || !clientSecretInput) {
|
||||
loginError = 'Please enter both Client ID and Client Secret';
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
|
||||
loginError = 'Client ID and Client Secret cannot be empty';
|
||||
return;
|
||||
}
|
||||
|
||||
isAuthenticating = true;
|
||||
loginError = '';
|
||||
loginSuccess = '';
|
||||
|
||||
try {
|
||||
// Save credentials
|
||||
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
|
||||
|
||||
// Clean up any existing OAuth listener
|
||||
if (oauthUnlisten) {
|
||||
oauthUnlisten();
|
||||
}
|
||||
|
||||
// Set up OAuth callback listener and store unlisten function
|
||||
oauthUnlisten = await onUrl((callbackUrl) => {
|
||||
handleOAuthCallback(callbackUrl);
|
||||
});
|
||||
|
||||
// Start OAuth server on fixed port with custom styled response
|
||||
const port = await start({
|
||||
ports: [OAUTH_PORT],
|
||||
response: `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spotify Authorization Complete</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||
background: #008080;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.window {
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
padding: 3px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
.title-bar {
|
||||
background: linear-gradient(90deg, navy, #1084d0);
|
||||
padding: 3px 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
}
|
||||
.window-body {
|
||||
background: silver;
|
||||
padding: 16px;
|
||||
margin: 3px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
p {
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="window">
|
||||
<div class="title-bar">
|
||||
<span>Spotify Authorization</span>
|
||||
</div>
|
||||
<div class="window-body">
|
||||
<h1>Authorization Complete</h1>
|
||||
<p>You have successfully authorized Shark with your Spotify account.</p>
|
||||
<p>You can close this window and return to the app.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
});
|
||||
console.log(`[Spotify] OAuth server started on port ${port}`);
|
||||
|
||||
// Generate authorization URL
|
||||
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
|
||||
clientIdInput.trim(),
|
||||
REDIRECT_URI
|
||||
);
|
||||
|
||||
// Store code verifier for callback
|
||||
localStorage.setItem('spotify_code_verifier', codeVerifier);
|
||||
|
||||
isWaitingForCallback = true;
|
||||
|
||||
// Open Spotify authorization in default browser
|
||||
await openUrl(url);
|
||||
} catch (error) {
|
||||
console.error('[Spotify] Authorization error:', error);
|
||||
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
|
||||
isAuthenticating = false;
|
||||
isWaitingForCallback = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthCallback(callbackUrl: string) {
|
||||
// Immediately remove the listener to prevent duplicate calls from hot reload
|
||||
if (oauthUnlisten) {
|
||||
oauthUnlisten();
|
||||
oauthUnlisten = null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the callback URL
|
||||
const url = new URL(callbackUrl);
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Authorization failed: ${error}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
}
|
||||
|
||||
// Retrieve code verifier from localStorage
|
||||
const codeVerifier = localStorage.getItem('spotify_code_verifier');
|
||||
|
||||
if (!codeVerifier) {
|
||||
throw new Error('OAuth state lost. Please try logging in again.');
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenData = await spotifyAPI.exchangeCodeForToken(
|
||||
code,
|
||||
codeVerifier,
|
||||
$spotifyAuth.clientId!,
|
||||
REDIRECT_URI
|
||||
);
|
||||
|
||||
// Save tokens
|
||||
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
|
||||
|
||||
// Set tokens in API client
|
||||
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
|
||||
spotifyAPI.setTokens(
|
||||
tokenData.access_token,
|
||||
tokenData.refresh_token,
|
||||
Date.now() + (tokenData.expires_in * 1000)
|
||||
);
|
||||
|
||||
// Fetch user info
|
||||
const user = await spotifyAPI.getCurrentUser();
|
||||
await saveUser(user);
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem('spotify_code_verifier');
|
||||
} catch (error) {
|
||||
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
localStorage.removeItem('spotify_code_verifier');
|
||||
} finally {
|
||||
isAuthenticating = false;
|
||||
isWaitingForCallback = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await clearSpotifyAuth();
|
||||
clientIdInput = '';
|
||||
clientSecretInput = '';
|
||||
loginSuccess = '';
|
||||
loginError = '';
|
||||
}
|
||||
|
||||
async function handleRefreshUser() {
|
||||
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set credentials in API client
|
||||
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
|
||||
spotifyAPI.setTokens(
|
||||
$spotifyAuth.accessToken,
|
||||
$spotifyAuth.refreshToken!,
|
||||
$spotifyAuth.expiresAt!
|
||||
);
|
||||
|
||||
// Fetch updated user info
|
||||
const user = await spotifyAPI.getCurrentUser();
|
||||
await saveUser(user);
|
||||
|
||||
loginSuccess = 'User info refreshed successfully!';
|
||||
setTimeout(() => {
|
||||
loginSuccess = '';
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
loginError = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="spotify-wrapper">
|
||||
<h2 style="padding: 8px">Spotify</h2>
|
||||
|
||||
{#if !$spotifyAuth.loggedIn}
|
||||
<!-- Login Form -->
|
||||
<section class="window login-section" style="max-width: 600px; margin: 8px;">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">Login to Spotify</div>
|
||||
</div>
|
||||
<div class="window-body">
|
||||
<p>Enter your Spotify Developer credentials and authorize access:</p>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label for="client-id-input">Client ID</label>
|
||||
<input
|
||||
id="client-id-input"
|
||||
type="text"
|
||||
bind:value={clientIdInput}
|
||||
placeholder="Your Spotify App Client ID"
|
||||
disabled={isAuthenticating || isWaitingForCallback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<label for="client-secret-input">Client Secret</label>
|
||||
<input
|
||||
id="client-secret-input"
|
||||
type="password"
|
||||
bind:value={clientSecretInput}
|
||||
placeholder="Your Spotify App Client Secret"
|
||||
disabled={isAuthenticating || isWaitingForCallback}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loginError}
|
||||
<div class="error-message">
|
||||
⚠ {loginError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isWaitingForCallback}
|
||||
<div class="info-message">
|
||||
Waiting for authorization in your browser... Please complete the login process.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="button-row">
|
||||
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
|
||||
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
|
||||
This will open Spotify's login page in your default browser.
|
||||
</p>
|
||||
|
||||
<details class="instructions">
|
||||
<summary>How to get your Spotify Developer credentials</summary>
|
||||
<div class="instructions-content">
|
||||
<ol>
|
||||
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
|
||||
<li>Log in with your Spotify account</li>
|
||||
<li>Click <strong>"Create app"</strong></li>
|
||||
<li>Fill in the app details:
|
||||
<ul>
|
||||
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
|
||||
<li>App description: (any description)</li>
|
||||
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
|
||||
<li>Check the Web API box</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Click <strong>"Save"</strong></li>
|
||||
<li>Click <strong>"Settings"</strong> on your new app</li>
|
||||
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
|
||||
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
|
||||
<li>Paste both values into the fields above</li>
|
||||
</ol>
|
||||
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
|
||||
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
|
||||
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
{:else}
|
||||
<!-- Authenticated View -->
|
||||
<section class="authenticated-content">
|
||||
<div class="window" style="max-width: 600px; margin: 8px;">
|
||||
<div class="title-bar">
|
||||
<div class="title-bar-text">Connected to Spotify</div>
|
||||
</div>
|
||||
<div class="window-body">
|
||||
{#if loginError}
|
||||
<div class="error-message">
|
||||
{loginError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<fieldset>
|
||||
<legend>User Information</legend>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Name:</span>
|
||||
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Email:</span>
|
||||
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Country:</span>
|
||||
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span class="field-label">Subscription:</span>
|
||||
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="margin-top: 16px;">
|
||||
<legend>Actions</legend>
|
||||
<div class="button-row">
|
||||
<button onclick={handleRefreshUser}>Refresh User Info</button>
|
||||
<button onclick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="info-box">
|
||||
<p><strong>Note:</strong> Spotify integration is for library sync only. This app does not support playback or downloads from Spotify.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spotify-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-section,
|
||||
.authenticated-content {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.window-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.field-row-stacked {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: bold;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #ffcccc;
|
||||
color: #cc0000;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #cc0000;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background-color: #cce5ff;
|
||||
color: #004085;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #004085;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background-color: var(--button-shadow, #2a2a2a);
|
||||
}
|
||||
|
||||
.instructions summary {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.instructions-content {
|
||||
margin-top: 8px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.instructions-content ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.instructions-content ul {
|
||||
margin: 4px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.instructions-content li {
|
||||
margin: 6px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.instructions-content strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.instructions-content code {
|
||||
background-color: var(--button-highlight, #505050);
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.instructions-content p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 16px;
|
||||
padding: 8px;
|
||||
background-color: var(--button-shadow, #2a2a2a);
|
||||
border: 1px solid var(--button-highlight, #606060);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
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>
|
||||
44
static/icons/ipod.svg
Normal file
44
static/icons/ipod.svg
Normal file
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #231f20;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #bfbebd;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #c0c1c4;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #fefefe;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="309.936" y1="731.362" x2="309.936" y2="731.454" gradientTransform="translate(0 1024) scale(1 -1)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#595858"/>
|
||||
<stop offset="1" stop-color="#aeadad"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="512" y1="920.713" x2="512" y2="103.287" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a5a7aa"/>
|
||||
<stop offset="1" stop-color="#e9e9ea"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path class="cls-3" d="M309.99,292.52l-.155.073.2.046c-.018-.036-.027-.073-.045-.118Z"/>
|
||||
<path class="cls-1" d="M766.12,806.626l-.209-95.041-.436-302.141-.055-103.741-.045-30.874c-.009-4.009-.245-12.701.145-16.473.7-16.128-.164-33.338.018-49.566.191-16.819.609-34.02-.1-50.82-.673-16.401-6.591-30.229-19.028-41.12-17.492-15.31-39.965-13.573-61.621-13.437l-40.729.227-135.77.273-133.67-.291-42.711-.318c-24.474-.064-43.62-.5-61.157,20.028-13.946,16.328-13.028,36.947-13.019,57.103.009,13.301-.264,26.601.491,39.902.355,5.982-.282,12.064.209,18.146-.745,10.573-.1,28.701-.064,39.938l.091,74.158.027,240.411-.518,190.581-.136,52.684c-.045,10.273-.518,24.583.491,34.538,1.227,11.319,5.964,21.974,13.546,30.465,21.201,24.138,47.902,19.037,76.667,18.946l52.521-.009,146.498-.109,99.577.136,41.375.045c21.537.018,41.029,1.364,58.621-14.182,14.119-12.473,18.192-27.01,18.855-45.375.609-17.855.209-36.183.136-54.084ZM309.836,292.593l.155-.073c.018.045.027.082.045.118l-.2-.046ZM712.382,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
|
||||
<path class="cls-4" d="M310.036,292.639l-.2-.046.155-.073c.018.045.027.082.045.118Z"/>
|
||||
<path class="cls-6" d="M509.217,539.412c80.027-1.511,146.156,62.085,147.769,142.111,1.613,80.026-61.899,146.235-141.923,147.95-80.168,1.717-146.522-61.933-148.139-142.104-1.616-80.17,62.119-146.443,142.292-147.958Z"/>
|
||||
<path class="cls-5" d="M509.43,630.294c29.875-1.416,55.245,21.646,56.675,51.521,1.429,29.874-21.622,55.254-51.495,56.698-29.893,1.445-55.293-21.625-56.724-51.518-1.431-29.894,21.65-55.283,51.544-56.701Z"/>
|
||||
<path class="cls-2" d="M712.705,288.911l-.118,101.296.018,33.492c.009,7.564.136,14.964-.527,22.365-.836,9.182-5.873,19.865-16.31,20.301-18.219.764-36.829.627-55.066.7l-105.705.127-131.906-.2-40.647-.036c-45.584.027-50.83,4.391-50.993-44.775l-.073-31.32-.064-108.896.082-37.874c-.009-6.018.182-13.992-.227-19.846l-.036-.582c.209-12.601.655-25.183.473-37.911-.164-11.546,1.764-27.483,16.51-28.592,4.946-.373,10.055-.318,15.046-.373l27.738-.2,84.149-.064c58.994-.2,117.978-.091,176.972.318,17.946.136,35.911-.082,53.866.155,3.273.045,7.219.091,10.419.518,17.028,2.236,16.073,21.292,16.137,33.811l.091,28.565c.018,4.173-.145,12.437.336,16.346-.773,15.701-.155,36.684-.164,52.675Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
Reference in New Issue
Block a user