Compare commits

...

26 Commits

Author SHA1 Message Date
a4f5bdd7a8 docs: changelog for first release 2025-10-05 16:52:03 -04:00
456f854863 refactor(ui): update albums interaction to double click for consistency 2025-10-05 01:40:50 -04:00
3118d969c6 refctor(ui): make tabs layout consistent with the rest of the app in settings and queue 2025-10-05 01:40:14 -04:00
17b6f7958e fix(queue): reset interrupted downloads and clear current job on load 2025-10-05 01:25:42 -04:00
cba49ce411 feat(library): add ipod-safe emoji encoding for playlist names 2025-10-05 01:07:22 -04:00
369ea9df02 feat(services): improve lyrics fetch progress and status updates 2025-10-05 00:49:51 -04:00
ca5f79b23a feat(settings): add button to open app data folder 2025-10-05 00:17:33 -04:00
8fb27b1acd feat(db): add tracks table and lyric scan caching 2025-10-05 00:17:19 -04:00
25ce2d676e feat(services): add LRCLIB service, scan utility, and context menus 2025-10-04 23:56:58 -04:00
38db835973 feat(ui): add reactive status bar with notifications 2025-10-04 23:36:09 -04:00
c30b205d9c feat(ui): add page decoration component for collection views 2025-10-04 22:25:07 -04:00
7b84bc32df fix(dl): add progress events for tracks from new downloader 2025-10-04 20:58:34 -04:00
96a01bdced refactor: move download/decryption to backend to fix UI freezing
Now implements streaming download+decryption entirely in Rust:
- Added reqwest/tokio/futures-util dependencies
- Created StreamingDecryptor for chunk-by-chunk decryption
- New download_and_decrypt_track command streams to disk directly
- Frontend simplified to single invoke() call
2025-10-04 20:53:59 -04:00
e4586f6497 fix: incorrect license in package 2025-10-04 20:46:01 -04:00
f4ef13ec0d fix(db): playlist username fallback handling 2025-10-04 20:38:59 -04:00
05acc3483c refactor(ui): remove disabled GitHub button from toolbar 2025-10-04 20:26:21 -04:00
efaa9f02b8 fix(tauri): update content security policy to include media sources 2025-10-04 16:23:05 -04:00
e535fdb4bc refactor(ui): housekeeping 2025-10-04 16:21:33 -04:00
8b3989e71f fix: lyrics not saved in new queue 2025-10-04 16:04:46 -04:00
9e75322a43 refactor(np): refactor layout 2025-10-04 15:49:24 -04:00
a602ee4bbd refactor(np): layout 2025-10-04 15:29:30 -04:00
9333e55095 refactor(np): now playing controls and icons 2025-10-04 15:19:24 -04:00
e5c8ce1c30 fix: volume slider 2025-10-04 15:05:06 -04:00
7c64818db1 refactor(np): add triangle volume slider to now playing panel 2025-10-04 15:01:19 -04:00
480aa5859b fix: path sanitization inconsistency in cover art lookup
Cover art lookup was constructing paths from raw metadata without
sanitization, causing "No such file or directory" errors for artists
with special characters (e.g. "Au/Ra" looked for "Au/Ra/" but files
were saved to "Au_Ra/"). Now uses sanitizeFilename() to match the
actual on-disk folder structure created during downloads.
2025-10-04 14:43:54 -04:00
26c465118b fix: missing cover art in playlist downloads
Playlist downloads were not fetching album cover URLs, causing both
embedded cover art and folder cover.jpg files to be skipped. Queue
manager now fetches album data on-demand (only when cover art is
enabled) to get cover URLs, reusing the same logic as individual track
downloads. Fetches track data first if albumId is missing.
2025-10-04 14:43:37 -04:00
49 changed files with 2853 additions and 341 deletions

82
CHANGELOG.md Normal file
View File

@@ -0,0 +1,82 @@
# Changelog
## [0.1.0] - 2025-10-05
### 🎉 First Alpha Release
This is the very first usable build of Shark! - a retro Windows 98-styled music player and downloader. It's rough around the edges and likely contains uncaught bugs, but the core functionality is working.
### Features
**Local Library Management**
- SQLite-backed library cache with automatic scanning
- Album views with cover art
- Track listing and playback
**Music Player**
- Now playing panel with playback controls
- Volume control with triangle slider
- Queue tracks from context menus
- Can show synced lyrics when the window is enlarged
- Can be hidden or shown
**Deezer Integration**
- Browse and download user favorites (tracks, albums, playlists)
- Search for tracks, albums, and artists
- Playlist caching with SQLite
- Alternative format fallback for unavailable lossless content
**Download Queue**
- Track and playlist downloads progress
- Automatic audio decryption
- Metadata tagging (ID3 for MP3, FLAC tags)
- Cover art embedding
- Existence checks to avoid re-downloading
**Lyrics**
- Automatic lyrics fetching during downloads
- LRCLIB integration for manual lyric fetching
- Scan library for missing lyrics
**UI/UX**
- Windows 98 (ish) dark theme throughout
- Custom window decorations for OS parity
- Bottom status bar for notifications
- Onboarding prompts for folder selection
- Double-click interactions
### Known Limitations
- This is an early version - expect bugs and rough edges
- Limited error handling in some edge cases
- UI polish needed in various areas
- Some features may not work as expected
- Breaking changes likely in future releases
- Lack of loading state means pages initially show no data
- No dynamic routes for artists (yet)
### Known Bugs
- Context menus show inappropriate options sometimes
- Now Playing section causes layout jitter on nav
- Now Playing section causes title bar vertical shift
- Batch lyrics downloading is less reliable than individual
- Track scrubbing causes lyrics desync
- Stopping (not pausing) a track and starting the same one resumes progress
- Album year is not populated in Library table
- Not all tables clip text overflow in certain columns
- Album fetching edge cases (`Doja Cat` and `K/DA`)
- Download interruptions don't always clean up temp files
- LRCLIB service page indexes temp folder
### Technical Details
- Built with Tauri 2 + SvelteKit 2 + Svelte 5
- Rust backend for audio processing and decryption
- SQLite for caching and library management
- File-based storage (FLAC/MP3 + metadata)
- Playlists are stored as .m3u8 files with relative paths to tracks
### Notes
This release represents 5 days of development and ~80 commits. The app is functional enough for basic use but still has plenty of rough edges. Use at your own risk and expect possible breaking changes as development continues.

View File

@@ -10,6 +10,7 @@
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-sql": "^2.3.0",
"@tauri-apps/plugin-store": "~2",
@@ -187,6 +188,8 @@
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="],
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],

View File

@@ -11,7 +11,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",
@@ -19,6 +20,7 @@
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-sql": "^2.3.0",
"@tauri-apps/plugin-store": "~2",

196
src-tauri/Cargo.lock generated
View File

@@ -8,9 +8,11 @@ version = "0.1.0"
dependencies = [
"blowfish",
"byteorder",
"futures-util",
"id3",
"md5",
"metaflac",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -19,9 +21,11 @@ dependencies = [
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
]
[[package]]
@@ -659,7 +663,7 @@ dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1192,6 +1196,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1199,7 +1212,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1213,6 +1226,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1453,6 +1472,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "gethostname"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
dependencies = [
"rustix",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@@ -1844,6 +1873,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2464,6 +2509,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2858,6 +2920,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -2874,6 +2980,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
dependencies = [
"log",
"plist",
"serde",
"windows-sys 0.52.0",
]
[[package]]
name = "pango"
version = "0.18.3"
@@ -3579,10 +3697,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3593,6 +3713,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3755,6 +3876,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3818,6 +3948,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4116,7 +4269,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -4479,6 +4632,15 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@@ -4789,6 +4951,24 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-os"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
dependencies = [
"gethostname",
"log",
"os_info",
"serde",
"serde_json",
"serialize-to-javascript",
"sys-locale",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.0"
@@ -5085,6 +5265,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"

View File

@@ -33,4 +33,8 @@ tauri-plugin-process = "2"
blowfish = "0.9"
md5 = "0.7"
byteorder = "1.5.0"
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"

View File

@@ -8,6 +8,20 @@
"permissions": [
"core:default",
"opener:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{
"path": "$APPDATA"
},
{
"path": "$APPCONFIG"
},
{
"path": "$APPLOCALDATA"
}
]
},
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
@@ -55,11 +69,15 @@
},
{
"url": "http://*.dzcdn.net/**"
},
{
"url": "https://lrclib.net/**"
}
]
},
"sql:default",
"sql:allow-execute",
"process:default"
"process:default",
"os:default"
]
}

View File

@@ -23,8 +23,7 @@ pub fn generate_blowfish_key(track_id: &str) -> Vec<u8> {
/// Decrypt a single 2048-byte chunk using Blowfish CBC
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key)
.expect("Invalid key length");
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key).expect("Invalid key length");
let mut result = chunk.to_vec();
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
@@ -97,6 +96,69 @@ pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec<u8> {
result
}
/// Streaming decryption state for processing data chunk-by-chunk
pub struct StreamingDecryptor {
blowfish_key: Vec<u8>,
buffer: Vec<u8>,
}
impl StreamingDecryptor {
const CHUNK_SIZE: usize = 2048;
const WINDOW_SIZE: usize = Self::CHUNK_SIZE * 3; // 6144
pub fn new(track_id: &str) -> Self {
Self {
blowfish_key: generate_blowfish_key(track_id),
buffer: Vec::new(),
}
}
/// Process incoming data and return decrypted output
/// May return less data than input as it buffers to maintain 6144-byte windows
pub fn process(&mut self, data: &[u8]) -> Vec<u8> {
self.buffer.extend_from_slice(data);
let mut output = Vec::new();
// Process complete windows
while self.buffer.len() >= Self::WINDOW_SIZE {
let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE];
let plain_part = &self.buffer[Self::CHUNK_SIZE..Self::WINDOW_SIZE];
let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key);
output.extend_from_slice(&decrypted);
output.extend_from_slice(plain_part);
self.buffer.drain(0..Self::WINDOW_SIZE);
}
output
}
/// Finalize decryption and return any remaining buffered data
pub fn finalize(self) -> Vec<u8> {
if self.buffer.is_empty() {
return Vec::new();
}
let remaining = self.buffer.len();
if remaining >= Self::CHUNK_SIZE {
// Partial window: decrypt first 2048 bytes, keep rest as-is
let encrypted_chunk = &self.buffer[0..Self::CHUNK_SIZE];
let plain_part = &self.buffer[Self::CHUNK_SIZE..];
let decrypted = decrypt_chunk(encrypted_chunk, &self.blowfish_key);
let mut output = Vec::with_capacity(remaining);
output.extend_from_slice(&decrypted);
output.extend_from_slice(plain_part);
output
} else {
// Less than 2048 bytes: keep as-is
self.buffer
}
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -1,8 +1,8 @@
use tauri_plugin_sql::{Migration, MigrationKind};
mod tagger;
mod metadata;
mod deezer_crypto;
mod metadata;
mod tagger;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@@ -36,7 +36,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
metadata::read_audio_metadata(&path)
}
/// Decrypt Deezer track data
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command]
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
// Run decryption on a background thread to avoid blocking the UI
@@ -49,6 +49,135 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
Ok(result)
}
/// Download and decrypt a Deezer track, streaming directly to disk
#[tauri::command]
async fn download_and_decrypt_track(
url: String,
track_id: String,
output_path: String,
is_encrypted: bool,
window: tauri::Window,
) -> Result<(), String> {
use deezer_crypto::StreamingDecryptor;
use tauri::Emitter;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
// Build HTTP client
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// Start download
let response = client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36")
.send()
.await
.map_err(|e| format!("Download failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("HTTP error: {}", response.status()));
}
let total_size = response.content_length().unwrap_or(0) as f64;
// Open output file
let mut file = File::create(&output_path)
.await
.map_err(|e| format!("Failed to create output file: {}", e))?;
let mut downloaded_bytes = 0u64;
let mut last_reported_percentage = 0u8;
// Stream download with optional decryption
if is_encrypted {
let mut decryptor = StreamingDecryptor::new(&track_id);
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?;
downloaded_bytes += chunk.len() as u64;
// Decrypt chunk and write to file
let decrypted = decryptor.process(&chunk);
if !decrypted.is_empty() {
file.write_all(&decrypted)
.await
.map_err(|e| format!("Failed to write to file: {}", e))?;
}
// Emit progress every 5%
if total_size > 0.0 {
let percentage = ((downloaded_bytes as f64 / total_size) * 100.0) as u8;
let rounded_percentage = (percentage / 5) * 5;
if rounded_percentage > last_reported_percentage || percentage == 100 {
last_reported_percentage = rounded_percentage;
let _ = window.emit(
"download-progress",
serde_json::json!({
"downloaded": downloaded_bytes,
"total": total_size as u64,
"percentage": percentage
}),
);
}
}
}
// Write any remaining buffered data
let final_data = decryptor.finalize();
if !final_data.is_empty() {
file.write_all(&final_data)
.await
.map_err(|e| format!("Failed to write final data: {}", e))?;
}
} else {
// No encryption - just stream directly
let mut stream = response.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| format!("Download stream error: {}", e))?;
downloaded_bytes += chunk.len() as u64;
file.write_all(&chunk)
.await
.map_err(|e| format!("Failed to write to file: {}", e))?;
// Emit progress every 5%
if total_size > 0.0 {
let percentage = ((downloaded_bytes as f64 / total_size) * 100.0) as u8;
let rounded_percentage = (percentage / 5) * 5;
if rounded_percentage > last_reported_percentage || percentage == 100 {
last_reported_percentage = rounded_percentage;
let _ = window.emit(
"download-progress",
serde_json::json!({
"downloaded": downloaded_bytes,
"total": total_size as u64,
"percentage": percentage
}),
);
}
}
}
}
// Ensure all data is flushed
file.flush()
.await
.map_err(|e| format!("Failed to flush file: {}", e))?;
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let library_migrations = vec![Migration {
@@ -80,10 +209,25 @@ pub fn run() {
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT NOT NULL,
duration INTEGER NOT NULL,
format TEXT NOT NULL,
has_lyrics INTEGER DEFAULT 0,
last_scanned INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now'))
);
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
CREATE INDEX IF NOT EXISTS idx_tracks_has_lyrics ON tracks(has_lyrics);
CREATE INDEX IF NOT EXISTS idx_tracks_path ON tracks(path);
",
kind: MigrationKind::Up,
}];
@@ -155,6 +299,7 @@ pub fn run() {
}];
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_process::init())
.plugin(
tauri_plugin_sql::Builder::new()
@@ -171,7 +316,8 @@ pub fn run() {
greet,
tag_audio_file,
read_audio_metadata,
decrypt_deezer_track
decrypt_deezer_track,
download_and_decrypt_track
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,5 +1,5 @@
use metaflac::Tag as FlacTag;
use id3::{Tag as ID3Tag, TagLike};
use metaflac::Tag as FlacTag;
use serde::{Deserialize, Serialize};
use std::path::Path;
@@ -40,8 +40,8 @@ pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
/// Read metadata from MP3 file
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = ID3Tag::read_from_path(path)
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
let tag =
ID3Tag::read_from_path(path).map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
Ok(AudioMetadata {
title: tag.title().map(|s| s.to_string()),
@@ -55,8 +55,8 @@ fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
/// Read metadata from FLAC file
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
let tag = FlacTag::read_from_path(path)
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
let tag =
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
// Helper to get first value from vorbis comment
let get_first = |key: &str| -> Option<String> {
@@ -66,8 +66,7 @@ fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
};
// Parse track number
let track_number = get_first("TRACKNUMBER")
.and_then(|s| s.parse::<u32>().ok());
let track_number = get_first("TRACKNUMBER").and_then(|s| s.parse::<u32>().ok());
// Get duration from streaminfo block (in samples)
let duration = tag.get_streaminfo().map(|info| {

View File

@@ -19,7 +19,7 @@
}
],
"security": {
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'",
"csp": "default-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; media-src 'self' asset: http://asset.localhost; style-src 'self' 'unsafe-inline'",
"assetProtocol": {
"enable": true,
"scope": ["**"]

View File

@@ -8,7 +8,6 @@
forward: '/icons/rightarrow.png',
play: '/icons/speaker.png',
search: '/icons/internet.png',
globe: '/icons/github-white.svg',
computer: '/icons/computer.png',
};
@@ -87,12 +86,6 @@
<span>Settings</span>
</a>
<div class="toolbar-separator"></div>
<button class="toolbar-button" disabled title="GitHub">
<img src={icons.globe} alt="Globe" />
<span>GitHub</span>
</button>
</div>
<style>

View File

@@ -3,6 +3,9 @@
import { convertFileSrc } from '@tauri-apps/api/core';
import { playback } from '$lib/stores/playback';
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
import PageDecoration from '$lib/components/PageDecoration.svelte';
import { fetchAndSaveLyrics } from '$lib/services/lrclib';
import { setSuccess, setWarning, setError } from '$lib/stores/status';
interface Props {
title: string;
@@ -14,6 +17,7 @@
onTrackClick?: (index: number) => void;
showAlbumColumn?: boolean;
useSequentialNumbers?: boolean;
decorationLabel?: string;
}
let {
@@ -25,7 +29,8 @@
selectedTrackIndex = null,
onTrackClick,
showAlbumColumn = false,
useSequentialNumbers = false
useSequentialNumbers = false,
decorationLabel = 'LOCAL PLAYLIST'
}: Props = $props();
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
@@ -57,6 +62,32 @@
};
}
async function handleFetchLyrics(trackIndex: number) {
const track = tracks[trackIndex];
if (!track) return;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.metadata.title || 'Unknown',
artist: track.metadata.artist || 'Unknown Artist',
album: track.metadata.album || 'Unknown Album',
duration: track.metadata.duration || 0
});
if (result.success) {
if (result.instrumental) {
setWarning(`${track.metadata.title || track.filename} is instrumental`);
} else if (result.hasLyrics) {
setSuccess(`Lyrics fetched for ${track.metadata.title || track.filename}`);
}
} else {
setWarning(`No lyrics found for ${track.metadata.title || track.filename}`);
}
} catch (error) {
setError(`Failed to fetch lyrics for ${track.metadata.title || track.filename}`);
}
}
function getContextMenuItems(trackIndex: number): MenuItem[] {
return [
{
@@ -70,11 +101,17 @@
{
label: 'Play Next',
action: () => playback.playNext([tracks[trackIndex]])
},
{
label: 'Fetch Lyrics via LRCLIB',
action: () => handleFetchLyrics(trackIndex)
}
];
}
</script>
<PageDecoration label={decorationLabel} />
<!-- Header -->
<div class="collection-header">
{#if coverArtPath}

View File

@@ -48,6 +48,13 @@
onClose();
}
}
function handleKeyDown(e: KeyboardEvent, item: MenuItem) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleItemClick(item);
}
}
</script>
<div
@@ -61,6 +68,8 @@
role="menuitem"
class:disabled={item.disabled}
onclick={() => handleItemClick(item)}
onkeydown={(e) => handleKeyDown(e, item)}
tabindex="0"
>
{item.label}
</li>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
import PageDecoration from '$lib/components/PageDecoration.svelte';
interface Props {
title: string;
@@ -64,6 +65,8 @@
}
</script>
<PageDecoration label="DEEZER PLAYLIST" />
<!-- Header -->
<div class="collection-header">
{#if coverImageUrl}

View File

@@ -2,6 +2,15 @@
import { playback } from '$lib/stores/playback';
import { audioPlayer } from '$lib/services/audioPlayer';
import LyricsDisplay from '$lib/components/LyricsDisplay.svelte';
import TriangleVolumeSlider from '$lib/components/TriangleVolumeSlider.svelte';
// Local volume state that syncs with the store
let volume = $state($playback.volume);
// Sync local volume with store volume
$effect(() => {
volume = $playback.volume;
});
function handlePlayPause() {
playback.togglePlayPause();
@@ -22,11 +31,8 @@
playback.setCurrentTime(time);
}
function handleVolumeChange(e: Event) {
const target = e.target as HTMLInputElement;
const volume = parseFloat(target.value);
playback.setVolume(volume);
audioPlayer.setVolume(volume);
function handleVolumeChange(newVolume: number) {
playback.setVolume(newVolume);
}
function formatTime(seconds: number): string {
@@ -43,82 +49,96 @@
</script>
<div class="now-playing">
<div class="controls">
<button
class="control-button"
onclick={handlePrevious}
disabled={!hasTrack}
title="Previous"
>
<img src="/icons/prev.svg" alt="Previous" />
</button>
<button
class="control-button play-pause"
onclick={handlePlayPause}
disabled={!hasTrack}
title={$playback.isPlaying ? 'Pause' : 'Play'}
>
{#if $playback.isPlaying}
<img src="/icons/pause.svg" alt="Pause" />
{:else}
<img src="/icons/play.svg" alt="Play" />
{/if}
</button>
<button
class="control-button"
onclick={handleNext}
disabled={!hasTrack}
title="Next"
>
<img src="/icons/next.svg" alt="Next" />
</button>
</div>
<div class="track-info">
{#if hasTrack && $playback.currentTrack}
<div class="track-title">{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}</div>
<div class="track-artist">{$playback.currentTrack.metadata.artist || 'Unknown Artist'}</div>
{:else}
<div class="track-title">No track playing</div>
{/if}
</div>
<div class="progress-section">
<span class="time-display">{formatTime($playback.currentTime)}</span>
<div class="progress-bar-container">
<div class="progress-indicator">
<span class="progress-indicator-bar" style="width: {progressPercent}%;"></span>
<div class="player-main">
<div class="track-info">
<div class="track-title-row">
<img src="/icons/play.svg" alt="" class="track-icon" />
<div class="track-text-content">
<div class="track-title">
{#if hasTrack && $playback.currentTrack}
{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}
{:else}
No track playing
{/if}
</div>
<div class="track-artist">
{#if hasTrack && $playback.currentTrack}
{$playback.currentTrack.metadata.artist || 'Unknown Artist'}
{/if}
</div>
</div>
</div>
<input
type="range"
min="0"
max={$playback.duration || 0}
step="0.1"
value={$playback.currentTime}
oninput={handleProgressChange}
disabled={!hasTrack}
class="progress-slider"
/>
</div>
<span class="time-display">{formatTime($playback.duration)}</span>
</div>
<div class="volume-section">
<img src="/icons/volume.svg" alt="Volume" class="volume-icon" />
<div class="is-vertical volume-slider-container">
<input
type="range"
min="0"
max="1"
step="0.01"
value={$playback.volume}
oninput={handleVolumeChange}
class="has-box-indicator"
/>
<div class="player-controls-container">
<div class="progress-section">
<span class="time-display">{formatTime($playback.currentTime)}</span>
<div class="progress-bar-container">
<div class="progress-indicator">
<span class="progress-indicator-bar" style="width: {progressPercent}%;"></span>
</div>
<input
type="range"
min="0"
max={$playback.duration || 0}
step="0.1"
value={$playback.currentTime}
oninput={handleProgressChange}
disabled={!hasTrack}
class="progress-slider"
/>
</div>
<span class="time-display">{formatTime($playback.duration)}</span>
</div>
<div class="controls-row">
<div class="controls">
<button
class="control-button"
onclick={handlePrevious}
disabled={!hasTrack}
title="Previous"
>
<img src="/icons/player-skip-back.svg" alt="Previous" />
</button>
<button
class="control-button play-pause"
onclick={handlePlayPause}
disabled={!hasTrack}
title={$playback.isPlaying ? 'Pause' : 'Play'}
>
{#if $playback.isPlaying}
<img src="/icons/player-pause.svg" alt="Pause" />
{:else}
<img src="/icons/player-play.svg" alt="Play" />
{/if}
</button>
<button
class="control-button"
onclick={() => playback.stop()}
disabled={!hasTrack}
title="Stop"
>
<img src="/icons/player-stop.svg" alt="Stop" />
</button>
<button
class="control-button"
onclick={handleNext}
disabled={!hasTrack}
title="Next"
>
<img src="/icons/player-skip-forward.svg" alt="Next" />
</button>
</div>
<div class="volume-section">
<TriangleVolumeSlider bind:value={volume} onchange={handleVolumeChange} />
</div>
</div>
</div>
<span class="volume-percent">{Math.round($playback.volume * 100)}%</span>
</div>
<LyricsDisplay lrcPath={$playback.lrcPath} currentTime={$playback.currentTime} />
@@ -127,7 +147,8 @@
<style>
.now-playing {
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
gap: 12px;
padding: 8px;
height: 100%;
@@ -135,66 +156,88 @@
font-size: 11px;
}
.player-main {
display: flex;
flex-direction: column;
gap: 8px;
}
.controls-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.controls {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.control-button {
background: transparent;
border: none;
box-shadow: none;
padding: 4px;
padding: 6px 20px;
min-width: auto;
min-height: auto;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0;
margin: 0 -1px 0 0;
}
.control-button:hover:not(:disabled) {
background: light-dark(silver, #2b2b2b);
box-shadow: inset -1px -1px light-dark(#0a0a0a, #000),
inset 1px 1px light-dark(#fff, #525252),
inset -2px -2px light-dark(grey, #232323),
inset 2px 2px light-dark(#dfdfdf, #363636);
}
.control-button:active:not(:disabled) {
box-shadow: inset -1px -1px light-dark(#fff, #525252),
inset 1px 1px light-dark(#0a0a0a, #000),
inset -2px -2px light-dark(#dfdfdf, #363636),
inset 2px 2px light-dark(grey, #232323);
}
.control-button:disabled {
opacity: 0.4;
cursor: not-allowed;
.control-button:first-child {
margin-left: 0;
}
.control-button img {
width: 24px;
height: 24px;
width: 12px;
height: 12px;
filter: light-dark(none, invert(1));
}
.play-pause {
padding: 6px;
padding: 6px 20px;
}
.play-pause img {
width: 12px;
height: 12px;
}
.track-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.track-title-row {
display: flex;
gap: 6px;
min-width: 0;
}
.track-icon {
width: 16px;
height: 16px;
filter: light-dark(none, invert(1));
flex-shrink: 0;
}
.track-text-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.track-title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
min-height: 16px;
}
.track-artist {
@@ -205,12 +248,19 @@
text-overflow: ellipsis;
}
.player-controls-container {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 400px;
width: 100%;
}
.progress-section {
display: flex;
align-items: center;
gap: 8px;
flex: 2;
min-width: 200px;
min-width: 0;
}
.time-display {
@@ -248,39 +298,22 @@
}
.volume-section {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.volume-icon {
width: 20px;
height: 20px;
filter: light-dark(none, invert(1));
}
.volume-slider-container {
width: 80px;
}
.volume-percent {
font-family: monospace;
font-size: 10px;
min-width: 35px;
}
/* Responsive: hide lyrics on medium widths */
@media (max-width: 1000px) {
/* Responsive: hide lyrics panel when not enough space */
@media (max-width: 800px) {
.now-playing :global(.lyrics-display) {
display: none;
}
}
/* Responsive: hide volume on small widths */
@media (max-width: 800px) {
.volume-section {
display: none;
.player-main {
width: 100%;
max-width: 600px;
}
.player-controls-container {
max-width: none;
}
}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
interface Props {
label: string;
}
let { label }: Props = $props();
// Calculate width based on text length (approximate monospace character width)
let labelWidth = $derived(label.length * 7 + 26); // ~7px per char + padding
</script>
<div class="page-decoration">
<div class="decoration-left" style="width: {labelWidth}px;"></div>
<!-- vector from /static/vectors/title-decoration.svg -->
<svg class="decoration-transition" viewBox="0 0 64 32" preserveAspectRatio="none">
<path d="M64,0H0v32h21.634c3.056-9.369,6.236-15.502,19.82-17.258,2.105-.272,4.23-.37,6.352-.37h16.193V0Z"/>
</svg>
<div class="decoration-right"></div>
<div class="decoration-label">//{label}//</div>
</div>
<style>
.page-decoration {
position: relative;
height: 20px;
display: flex;
align-items: stretch;
margin-bottom: 4px;
flex-shrink: 0;
}
.decoration-left {
flex-shrink: 0;
background: #373737;
}
.decoration-transition {
flex-shrink: 0;
width: 40px;
height: 20px;
fill: #373737;
}
.decoration-right {
flex: 1;
background: linear-gradient(to bottom, #373737 0%, #373737 9.184px, transparent 9.184px);
}
.decoration-label {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
color: #ffffff;
font-family: monospace;
font-size: 11px;
font-weight: bold;
letter-spacing: 0.5px;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,257 @@
<script lang="ts">
interface Props {
value: number; // 0-1
onchange?: (value: number) => void;
}
let { value = $bindable(0.75), onchange }: Props = $props();
let volume = $state(value * 100); // Internal state as 0-100
let isDragging = $state(false);
let dragOffset = $state(0);
let triangleContainer: HTMLDivElement;
let volumeThumb: HTMLDivElement;
const triangleWidth = 60;
// Sync external value (0-1) with internal volume (0-100)
$effect(() => {
volume = value * 100;
});
function updateVolume(percentage: number) {
volume = Math.max(0, Math.min(100, percentage));
const newValue = volume / 100;
value = newValue;
if (onchange) {
onchange(newValue);
}
}
function getVolumeFromPosition(clientX: number, useOffset: boolean = false): number {
const rect = triangleContainer.getBoundingClientRect();
let x = clientX - rect.left;
if (useOffset) {
x = x - dragOffset;
}
const percentage = (x / rect.width) * 100;
return percentage;
}
function handleThumbMouseDown(e: MouseEvent) {
isDragging = true;
const rect = triangleContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const currentThumbPosition = (volume / 100) * rect.width;
dragOffset = clickX - currentThumbPosition;
e.stopPropagation();
e.preventDefault();
}
function handleContainerMouseDown(e: MouseEvent) {
if (e.target !== volumeThumb) {
dragOffset = 0;
const newVolume = getVolumeFromPosition(e.clientX);
updateVolume(newVolume);
isDragging = true;
}
}
function handleMouseMove(e: MouseEvent) {
if (isDragging) {
const newVolume = getVolumeFromPosition(e.clientX, true);
updateVolume(newVolume);
}
}
function handleMouseUp() {
isDragging = false;
dragOffset = 0;
}
function handleThumbTouchStart(e: TouchEvent) {
isDragging = true;
const touch = e.touches[0];
const rect = triangleContainer.getBoundingClientRect();
const currentThumbPosition = (volume / 100) * rect.width;
dragOffset = (touch.clientX - rect.left) - currentThumbPosition;
e.preventDefault();
}
function handleContainerTouchStart(e: TouchEvent) {
if (e.target !== volumeThumb) {
const touch = e.touches[0];
dragOffset = 0;
const newVolume = getVolumeFromPosition(touch.clientX, false);
updateVolume(newVolume);
isDragging = true;
}
e.preventDefault();
}
function handleTouchMove(e: TouchEvent) {
if (isDragging) {
const touch = e.touches[0];
const newVolume = getVolumeFromPosition(touch.clientX, true);
updateVolume(newVolume);
}
}
function handleTouchEnd() {
isDragging = false;
}
function handleDecrease() {
updateVolume(volume - 5);
}
function handleIncrease() {
updateVolume(volume + 5);
}
const position = $derived((volume / 100) * triangleWidth);
const rightEdge = $derived(volume);
</script>
<svelte:window onmousemove={handleMouseMove} onmouseup={handleMouseUp} ontouchmove={handleTouchMove} ontouchend={handleTouchEnd} />
<div class="volume-container">
<button class="volume-icons" onclick={handleDecrease} aria-label="Decrease volume"></button>
<div class="triangle-wrapper">
<div
class="triangle-container"
bind:this={triangleContainer}
onmousedown={handleContainerMouseDown}
ontouchstart={handleContainerTouchStart}
role="slider"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(value * 100)}
tabindex="0"
>
<div class="triangle-bg"></div>
<div
class="triangle-fill"
style="clip-path: inset(0 {100 - rightEdge}% 0 0);"
></div>
<div
class="volume-thumb"
style="left: {position}px;"
bind:this={volumeThumb}
onmousedown={handleThumbMouseDown}
ontouchstart={handleThumbTouchStart}
role="button"
tabindex="-1"
aria-label="Volume slider thumb"
>
<div class="volume-thumb-inner"></div>
</div>
</div>
</div>
<button class="volume-icons" onclick={handleIncrease} aria-label="Increase volume">+</button>
</div>
<style>
.volume-container {
display: flex;
align-items: center;
gap: 8px;
}
.volume-icons {
font-size: 12px;
color: light-dark(#000, #fff);
user-select: none;
padding: 2px 6px;
min-width: auto;
min-height: auto;
font-weight: bold;
}
.triangle-wrapper {
position: relative;
width: 60px;
height: 16px;
flex: 1;
max-width: 60px;
}
.triangle-container {
position: relative;
width: 60px;
height: 100%;
cursor: pointer;
}
.triangle-bg {
position: absolute;
left: 0;
bottom: 0;
width: 0;
height: 0;
border-left: 60px solid transparent;
border-right: 0 solid transparent;
border-bottom: 16px solid light-dark(#808080, #525252);
}
.triangle-fill {
position: absolute;
left: 0;
bottom: 0;
width: 0;
height: 0;
border-left: 60px solid transparent;
border-right: 0 solid transparent;
border-bottom: 16px solid light-dark(#000080, #0066cc);
transition: clip-path 0.05s ease-out;
}
.volume-thumb {
position: absolute;
bottom: -2px;
width: 8px;
height: 16px;
background: light-dark(#c0c0c0, #2b2b2b);
border-top: 1px solid light-dark(#ffffff, #525252);
border-left: 1px solid light-dark(#ffffff, #525252);
border-right: 1px solid light-dark(#000000, #000);
border-bottom: 1px solid light-dark(#000000, #000);
box-shadow: inset 1px 1px 0 light-dark(#dfdfdf, #363636),
inset -1px -1px 0 light-dark(#808080, #232323);
cursor: grab;
transform: translateX(-4px);
z-index: 10;
}
.volume-thumb:active {
cursor: grabbing;
border-top: 1px solid light-dark(#000000, #000);
border-left: 1px solid light-dark(#000000, #000);
border-right: 1px solid light-dark(#ffffff, #525252);
border-bottom: 1px solid light-dark(#ffffff, #525252);
}
.volume-thumb-inner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 4px;
height: 10px;
background: linear-gradient(
to right,
light-dark(#dfdfdf, #363636) 0%,
light-dark(#c0c0c0, #2b2b2b) 50%,
light-dark(#808080, #232323) 100%
);
}
</style>

View File

@@ -24,6 +24,19 @@ export interface DbAlbum {
created_at: number;
}
export interface DbTrack {
id: number;
path: string;
title: string;
artist: string;
album: string;
duration: number;
format: string;
has_lyrics: number;
last_scanned: number | null;
created_at: number;
}
let db: Database | null = null;
/**
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
trackCount: trackResult[0]?.total || 0
};
}
/**
* Get all tracks without lyrics (has_lyrics = 0)
*/
export async function getTracksWithoutLyrics(): Promise<DbTrack[]> {
const database = await initDatabase();
const tracks = await database.select<DbTrack[]>(
'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE'
);
return tracks;
}
/**
* Upsert a track (insert or update)
*/
export async function upsertTrack(track: {
path: string;
title: string;
artist: string;
album: string;
duration: number;
format: string;
has_lyrics: boolean;
}): Promise<void> {
const database = await initDatabase();
const now = Math.floor(Date.now() / 1000);
await database.execute(
`INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT(path) DO UPDATE SET
title = $2,
artist = $3,
album = $4,
duration = $5,
format = $6,
has_lyrics = $7,
last_scanned = $8`,
[
track.path,
track.title,
track.artist,
track.album,
track.duration,
track.format,
track.has_lyrics ? 1 : 0,
now
]
);
}
/**
* Get the last scan timestamp for lyrics
*/
export async function getLyricsScanTimestamp(): Promise<number | null> {
const database = await initDatabase();
const result = await database.select<{ last_scanned: number | null }[]>(
'SELECT MAX(last_scanned) as last_scanned FROM tracks'
);
return result[0]?.last_scanned || null;
}
/**
* Delete tracks that are no longer in the provided paths
*/
export async function deleteTracksNotInPaths(paths: string[]): Promise<void> {
if (paths.length === 0) {
const database = await initDatabase();
await database.execute('DELETE FROM tracks');
return;
}
const database = await initDatabase();
const placeholders = paths.map((_, i) => `$${i + 1}`).join(',');
await database.execute(
`DELETE FROM tracks WHERE path NOT IN (${placeholders})`,
paths
);
}

View File

@@ -146,7 +146,7 @@ export async function upsertPlaylists(playlists: any[]): Promise<void> {
String(playlist.PLAYLIST_ID),
playlist.TITLE || '',
playlist.NB_SONG || 0,
playlist.PARENT_USERNAME || 'Unknown',
playlist.PARENT_USERNAME || playlist._USER_NAME_FALLBACK || 'Unknown',
playlist.PLAYLIST_PICTURE || null,
playlist.PICTURE_TYPE || null,
now

View File

@@ -0,0 +1,173 @@
/**
* Library scanner for tracks without lyrics files
*/
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { AudioFormat } from '$lib/types/track';
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
export interface TrackWithoutLyrics {
path: string;
filename: string;
title: string;
artist: string;
album: string;
duration: number; // in seconds
format: AudioFormat;
}
/**
* Check if a track has an accompanying .lrc file
*/
async function hasLyricsFile(audioFilePath: string): Promise<boolean> {
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
return await exists(lrcPath);
}
/**
* Get audio format from file extension
*/
function getAudioFormat(filename: string): AudioFormat {
const ext = filename.toLowerCase().split('.').pop();
switch (ext) {
case 'flac':
return 'flac';
case 'mp3':
return 'mp3';
case 'opus':
return 'opus';
case 'ogg':
return 'ogg';
case 'm4a':
return 'm4a';
case 'wav':
return 'wav';
default:
return 'unknown';
}
}
/**
* Scan a single directory for audio files without lyrics
*/
async function scanDirectoryForMissingLyrics(
dirPath: string,
results: TrackWithoutLyrics[]
): Promise<void> {
const audioExtensions = ['.flac', '.mp3', '.opus', '.ogg', '.m4a', '.wav'];
try {
const entries = await readDir(dirPath);
for (const entry of entries) {
const fullPath = `${dirPath}/${entry.name}`;
if (entry.isDirectory) {
// Recursively scan subdirectories
await scanDirectoryForMissingLyrics(fullPath, results);
} else {
// Check if it's an audio file
const hasAudioExt = audioExtensions.some(ext =>
entry.name.toLowerCase().endsWith(ext)
);
if (hasAudioExt) {
// Check if it has a .lrc file
const hasLyrics = await hasLyricsFile(fullPath);
if (!hasLyrics) {
// Read metadata
try {
const fileData = await readFile(fullPath);
const metadata = await parseBuffer(
fileData,
{ mimeType: `audio/${getAudioFormat(entry.name)}` },
{ duration: true, skipCovers: true }
);
const title = metadata.common.title || entry.name.replace(/\.[^.]+$/, '');
const artist = metadata.common.artist || metadata.common.albumartist || 'Unknown Artist';
const album = metadata.common.album || 'Unknown Album';
const duration = metadata.format.duration || 0;
// Only add if we have minimum required metadata
if (title && artist && album && duration > 0) {
results.push({
path: fullPath,
filename: entry.name,
title,
artist,
album,
duration,
format: getAudioFormat(entry.name)
});
}
} catch (error) {
console.warn(`[LyricScanner] Could not read metadata for ${fullPath}:`, error);
}
}
}
}
}
} catch (error) {
console.error(`[LyricScanner] Error scanning directory ${dirPath}:`, error);
}
}
/**
* Scan the music library for tracks without .lrc files
* Results are cached in the database
*/
export async function scanForTracksWithoutLyrics(
musicFolderPath: string,
onProgress?: (current: number, total: number, message: string) => void
): Promise<TrackWithoutLyrics[]> {
const results: TrackWithoutLyrics[] = [];
if (onProgress) {
onProgress(0, 0, 'Scanning for tracks without lyrics...');
}
await scanDirectoryForMissingLyrics(musicFolderPath, results);
// Save results to database
if (onProgress) {
onProgress(results.length, results.length, 'Caching results...');
}
for (const track of results) {
await upsertTrack({
path: track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: Math.round(track.duration),
format: track.format,
has_lyrics: false
});
}
if (onProgress) {
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
}
return results;
}
/**
* Load cached tracks without lyrics from database
*/
export async function loadCachedTracksWithoutLyrics(): Promise<TrackWithoutLyrics[]> {
const dbTracks = await getTracksWithoutLyrics();
return dbTracks.map((track: DbTrack) => ({
path: track.path,
filename: track.path.split('/').pop() || track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration,
format: track.format as AudioFormat
}));
}

View File

@@ -1,5 +1,6 @@
import { writeFile } from '@tauri-apps/plugin-fs';
import { sanitizeFilename } from '$lib/services/deezer/paths';
import { encodeEmojis } from '$lib/utils/emoji';
export interface M3U8Track {
duration: number; // in seconds
@@ -22,14 +23,15 @@ export async function writeM3U8(
tracks: M3U8Track[],
playlistsFolder: string
): Promise<string> {
// Sanitize playlist name for filename
const sanitizedName = sanitizeFilename(playlistName);
// Encode emojis and sanitize playlist name for filename
const encodedName = encodeEmojis(playlistName);
const sanitizedName = sanitizeFilename(encodedName);
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
// Build m3u8 content
const lines: string[] = [
'#EXTM3U',
`#PLAYLIST:${playlistName}`,
`#PLAYLIST:${encodedName}`,
'#EXTENC:UTF-8',
''
];

View File

@@ -2,6 +2,8 @@ import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
import { findAlbumArt } from './album';
import { sanitizeFilename } from '$lib/services/deezer/paths';
import { decodeEmojis } from '$lib/utils/emoji';
/**
* Get audio format from file extension
@@ -35,6 +37,30 @@ export interface ParsedPlaylistTrack {
};
}
/**
* Extract playlist name from #PLAYLIST: metadata line in m3u8 file
* Returns decoded emoji name, or undefined if not found
*/
export async function parsePlaylistName(playlistPath: string): Promise<string | undefined> {
try {
const content = await readTextFile(playlistPath);
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#PLAYLIST:')) {
const encodedName = trimmed.substring('#PLAYLIST:'.length);
return decodeEmojis(encodedName);
}
}
return undefined;
} catch (error) {
console.error('Error reading playlist name:', error);
return undefined;
}
}
/**
* Parse M3U/M3U8 playlist file
* Supports both basic M3U and extended M3U8 format
@@ -51,7 +77,7 @@ export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylis
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines and non-EXTINF comments
// Skip empty lines and comments (except EXTINF)
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
continue;
}
@@ -228,7 +254,8 @@ export async function findPlaylistCoverFallback(
}
// Construct album folder path following the same structure as downloader
const albumPath = `${musicFolder}/${albumArtist}/${album}`;
// Must use sanitized paths to match how files are actually saved on disk
const albumPath = `${musicFolder}/${sanitizeFilename(albumArtist)}/${sanitizeFilename(album)}`;
try {
// Check if album folder exists and has cover art

View File

@@ -1,6 +1,7 @@
import { readDir, readFile } from '@tauri-apps/plugin-fs';
import { parseBuffer } from 'music-metadata';
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
import { parsePlaylistName } from './playlist';
export interface Artist {
name: string;
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
if (!entry.isDirectory) {
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
if (isPlaylist) {
// Remove extension for display name
const playlistPath = `${playlistsFolderPath}/${entry.name}`;
// Try to read playlist name from #PLAYLIST: metadata (with emoji decoding)
const metadataName = await parsePlaylistName(playlistPath);
// Fallback to filename without extension if no metadata found
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
const displayName = metadataName || nameWithoutExt;
playlists.push({
name: nameWithoutExt,
path: `${playlistsFolderPath}/${entry.name}`
name: displayName,
path: playlistPath
});
}
}

View File

@@ -137,9 +137,10 @@ export const audioPlayer = new AudioPlayer();
if (typeof window !== 'undefined') {
let prevTrack: Track | null = null;
let prevIsPlaying = false;
let prevVolume = 1;
playback.subscribe(state => {
const { currentTrack, isPlaying } = state;
const { currentTrack, isPlaying, volume } = state;
// Track changed
if (currentTrack && currentTrack !== prevTrack) {
@@ -160,5 +161,11 @@ if (typeof window !== 'undefined') {
}
prevIsPlaying = isPlaying;
}
// Volume changed
if (volume !== prevVolume) {
audioPlayer.setVolume(volume);
prevVolume = volume;
}
});
}

View File

@@ -340,7 +340,15 @@ export class DeezerAPI {
nb: -1
});
return response.TAB?.playlists?.data || [];
const playlists = response.TAB?.playlists?.data || [];
const userName = response.DATA?.USER?.BLOG_NAME || 'Unknown';
// Attach userName to each playlist for use as fallback in database
playlists.forEach((playlist: any) => {
playlist._USER_NAME_FALLBACK = userName;
});
return playlists;
} catch (error) {
console.error('Error fetching playlists:', error);
return [];

View File

@@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue';
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { trackExists } from './downloader';
import { setInfo, setWarning } from '$lib/stores/status';
import { get } from 'svelte/store';
/**
@@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
if (exists) {
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
setWarning(`Skipped: ${track.title} (already exists)`);
return { added: false, reason: 'already_exists' };
}
}
@@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
downloadObject: track
});
setInfo(`Queued: ${track.title}`);
return { added: true };
}

View File

@@ -2,7 +2,6 @@
* Deezer track downloader with streaming and decryption
*/
import { fetch } from '@tauri-apps/plugin-http';
import { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import { generateTrackPath } from './paths';
@@ -32,6 +31,7 @@ export async function downloadTrack(
retryCount: number = 0,
decryptionTrackId?: string
): Promise<string> {
const { listen } = await import('@tauri-apps/api/event');
// Generate paths
const paths = generateTrackPath(track, musicFolder, format, false);
@@ -56,85 +56,32 @@ export async function downloadTrack(
console.log('Temp path:', paths.tempPath);
try {
// Fetch the track with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60 second timeout
const response = await fetch(downloadURL, {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const totalSize = parseInt(response.headers.get('content-length') || '0');
const isCrypted = downloadURL.includes('/mobile/') || downloadURL.includes('/media/');
// Stream the response with progress tracking
const reader = response.body!.getReader();
const chunks: Uint8Array[] = [];
let downloadedBytes = 0;
let lastReportedPercentage = 0;
// Use the provided decryption track ID (for fallback tracks) or the original track ID
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
downloadedBytes += value.length;
// Call progress callback every 5%
if (onProgress && totalSize > 0) {
const percentage = (downloadedBytes / totalSize) * 100;
const roundedPercentage = Math.floor(percentage / 5) * 5;
if (roundedPercentage > lastReportedPercentage || percentage === 100) {
lastReportedPercentage = roundedPercentage;
onProgress({
downloaded: downloadedBytes,
total: totalSize,
percentage
});
}
// Set up progress listener
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
if (onProgress) {
onProgress(event.payload);
}
}
});
// Combine chunks into single Uint8Array
const encryptedData = new Uint8Array(downloadedBytes);
let offset = 0;
for (const chunk of chunks) {
encryptedData.set(chunk, offset);
offset += chunk.length;
}
console.log(`Downloaded ${encryptedData.length} bytes, encrypted: ${isCrypted}`);
// Yield to the browser to keep UI responsive
await new Promise(resolve => setTimeout(resolve, 0));
// Decrypt if needed
let decryptedData: Uint8Array;
if (isCrypted) {
console.log('Decrypting track...');
// Use the provided decryption track ID (for fallback tracks) or the original track ID
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
console.log(`Decrypting with track ID: ${trackIdForDecryption}`);
// Call Rust decryption function - Tauri returns Vec<u8> as number[]
const decryptedArray = await invoke<number[]>('decrypt_deezer_track', {
data: encryptedData,
trackId: trackIdForDecryption
try {
// Download and decrypt in Rust backend (streaming, no memory accumulation)
console.log('Downloading and decrypting track in Rust backend...');
await invoke('download_and_decrypt_track', {
url: downloadURL,
trackId: trackIdForDecryption,
outputPath: paths.tempPath,
isEncrypted: isCrypted
});
decryptedData = new Uint8Array(decryptedArray);
} else {
decryptedData = encryptedData;
console.log('Download and decryption complete!');
} finally {
// Clean up event listener
unlisten();
}
// Get user settings
@@ -151,10 +98,7 @@ export async function downloadTrack(
}
}
// Write untagged file to temp first
console.log('Writing untagged file to temp...');
await writeFile(paths.tempPath, decryptedData);
// File is already written to temp by Rust backend
// Move to final location
const finalPath = `${paths.filepath}/${paths.filename}`;
console.log('Moving to final location:', finalPath);

View File

@@ -7,6 +7,7 @@ import { trackExists } from './downloader';
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
import { generateTrackPath } from './paths';
import { settings } from '$lib/stores/settings';
import { setInfo, setSuccess } from '$lib/stores/status';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer';
import { mkdir } from '@tauri-apps/plugin-fs';
@@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist(
console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`);
// Show queue status message
if (addedCount > 0) {
if (skippedCount > 0) {
setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''} (${skippedCount} skipped)`);
} else {
setInfo(`Queued ${addedCount} track${addedCount !== 1 ? 's' : ''}`);
}
}
// Generate m3u8 file
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
// Generate expected path for this track
@@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist(
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
// Show success message for playlist creation
setSuccess(`Playlist created: ${playlistName}`);
return m3u8Path;
}

View File

@@ -15,6 +15,7 @@ import {
import { settings } from '$lib/stores/settings';
import { deezerAuth } from '$lib/stores/deezer';
import { syncTrackPaths } from '$lib/library/incrementalSync';
import { setSuccess, setError } from '$lib/stores/status';
import { get } from 'svelte/store';
import type { DeezerTrack } from '$lib/types/deezer';
@@ -121,9 +122,6 @@ export class DeezerQueueManager {
this.abortController = new AbortController();
console.log('[DeezerQueueManager] Starting queue processor');
// Clear any stale currentJob from previous session
await setCurrentJob(null);
try {
await this.processQueue();
} catch (error) {
@@ -182,12 +180,29 @@ export class DeezerQueueManager {
status: 'completed',
progress: 100
});
// Show success message
if (nextItem.type === 'track') {
setSuccess(`Downloaded: ${nextItem.title}`);
} else {
const completed = nextItem.completedTracks;
const failed = nextItem.failedTracks;
if (failed > 0) {
setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''} (${failed} failed)`);
} else {
setSuccess(`Download complete: ${completed} track${completed !== 1 ? 's' : ''}`);
}
}
} catch (error) {
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
await updateQueueItem(nextItem.id, {
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error'
});
// Show error message
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
setError(`Download failed: ${errorMsg}`);
}
// Clear current job
@@ -197,6 +212,86 @@ export class DeezerQueueManager {
console.log('[DeezerQueueManager] Queue processor stopped');
}
/**
* Ensure track has cover art URL by fetching album data if needed
* Reuses the same logic as addToQueue for consistency
*/
private async ensureCoverUrl(track: DeezerTrack): Promise<void> {
// Skip if already has cover URL
if (track.albumCoverUrl) {
return;
}
// Skip if no album ID to fetch with
if (!track.albumId || track.albumId === 0) {
console.log(`[DeezerQueueManager] Track "${track.title}" has no albumId, fetching track data...`);
// Fetch track data to get album ID
try {
const trackData = await deezerAPI.getTrack(track.id.toString());
if (trackData && trackData.ALB_ID) {
track.albumId = parseInt(trackData.ALB_ID.toString(), 10);
} else {
console.warn(`[DeezerQueueManager] Could not get album ID for track "${track.title}"`);
return;
}
} catch (error) {
console.warn(`[DeezerQueueManager] Error fetching track data for "${track.title}":`, error);
return;
}
}
// Fetch album data for cover art URL
try {
const albumData = await deezerAPI.getAlbumData(track.albumId.toString());
if (albumData?.ALB_PICTURE) {
track.albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
track.albumCoverXlUrl = `https://e-cdns-images.dzcdn.net/images/cover/${albumData.ALB_PICTURE}/1000x1000-000000-80-0-0.jpg`;
console.log(`[DeezerQueueManager] Fetched cover URL for "${track.title}"`);
}
} catch (error) {
console.warn(`[DeezerQueueManager] Could not fetch album data for track "${track.title}":`, error);
}
}
/**
* Ensure track has lyrics by fetching if needed
* Reuses the same logic as addToQueue for consistency
*/
private async ensureLyrics(track: DeezerTrack): Promise<void> {
// Skip if already has lyrics
if (track.lyrics) {
return;
}
// Fetch lyrics from Deezer
try {
const lyricsData = await deezerAPI.getLyrics(track.id.toString());
if (lyricsData) {
// Parse LRC format (synced lyrics)
let syncLrc = '';
if (lyricsData.LYRICS_SYNC_JSON) {
for (const line of lyricsData.LYRICS_SYNC_JSON) {
const text = line.line || '';
const timestamp = line.lrc_timestamp || '[00:00.00]';
syncLrc += `${timestamp}${text}\n`;
}
}
track.lyrics = {
sync: syncLrc || undefined,
unsync: lyricsData.LYRICS_TEXT || undefined,
syncID3: undefined
};
console.log(`[DeezerQueueManager] Fetched lyrics for "${track.title}"`);
}
} catch (error) {
console.warn(`[DeezerQueueManager] Could not fetch lyrics for track "${track.title}":`, error);
}
}
/**
* Download a single track
*/
@@ -215,6 +310,16 @@ export class DeezerQueueManager {
}
deezerAPI.setArl(authState.arl);
// Ensure track has cover URL if cover art is enabled
if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) {
await this.ensureCoverUrl(track);
}
// Ensure track has lyrics if lyrics are enabled
if (appSettings.embedLyrics || appSettings.saveLrcFile) {
await this.ensureLyrics(track);
}
// Get user data for license token
const userData = await deezerAPI.getUserData();
const licenseToken = userData.USER?.OPTIONS?.license_token;
@@ -353,6 +458,16 @@ export class DeezerQueueManager {
}
});
// Ensure track has cover URL if cover art is enabled
if (appSettings.embedCoverArt || appSettings.saveCoverToFolder) {
await this.ensureCoverUrl(track);
}
// Ensure track has lyrics if lyrics are enabled
if (appSettings.embedLyrics || appSettings.saveLrcFile) {
await this.ensureLyrics(track);
}
const userData = await deezerAPI.getUserData();
const licenseToken = userData.USER?.OPTIONS?.license_token;

197
src/lib/services/lrclib.ts Normal file
View File

@@ -0,0 +1,197 @@
/**
* LRCLIB API client for fetching lyrics
* https://lrclib.net/
*/
import { fetch } from '@tauri-apps/plugin-http';
import { writeFile } from '@tauri-apps/plugin-fs';
const LRCLIB_API_BASE = 'https://lrclib.net/api';
const USER_AGENT = 'Shark Music Player v1.0.0 (https://github.com/soulshark)';
export interface LRCLIBLyrics {
id: number;
trackName: string;
artistName: string;
albumName: string;
duration: number;
instrumental: boolean;
plainLyrics: string | null;
syncedLyrics: string | null;
}
export interface LRCLIBSearchParams {
trackName: string;
artistName: string;
albumName: string;
duration: number; // in seconds
}
/**
* Check if LRCLIB API is available
*/
export async function checkApiStatus(): Promise<boolean> {
try {
const response = await fetch(`${LRCLIB_API_BASE}/get/1`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
return response.ok || response.status === 404; // 404 is fine, means API is up
} catch (error) {
console.error('[LRCLIB] API check failed:', error);
return false;
}
}
/**
* Get lyrics for a track by its signature
* Searches external sources if not in LRCLIB database
*/
export async function getLyrics(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
try {
const queryParams = new URLSearchParams({
track_name: params.trackName,
artist_name: params.artistName,
album_name: params.albumName,
duration: params.duration.toString()
});
const response = await fetch(`${LRCLIB_API_BASE}/get?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (response.status === 404) {
console.log('[LRCLIB] No lyrics found for:', params.trackName);
return null;
}
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics;
} catch (error) {
console.error('[LRCLIB] Error fetching lyrics:', error);
return null;
}
}
/**
* Get lyrics from cache only (no external search)
*/
export async function getLyricsCached(params: LRCLIBSearchParams): Promise<LRCLIBLyrics | null> {
try {
const queryParams = new URLSearchParams({
track_name: params.trackName,
artist_name: params.artistName,
album_name: params.albumName,
duration: params.duration.toString()
});
const response = await fetch(`${LRCLIB_API_BASE}/get-cached?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics;
} catch (error) {
console.error('[LRCLIB] Error fetching cached lyrics:', error);
return null;
}
}
/**
* Search for lyrics by keywords
*/
export async function searchLyrics(query: string): Promise<LRCLIBLyrics[]> {
try {
const queryParams = new URLSearchParams({ q: query });
const response = await fetch(`${LRCLIB_API_BASE}/search?${queryParams}`, {
method: 'GET',
headers: {
'User-Agent': USER_AGENT
}
});
if (!response.ok) {
throw new Error(`LRCLIB API error: ${response.status}`);
}
const data = await response.json();
return data as LRCLIBLyrics[];
} catch (error) {
console.error('[LRCLIB] Error searching lyrics:', error);
return [];
}
}
/**
* Save lyrics as .lrc file next to the audio file
*/
export async function saveLyricsFile(audioFilePath: string, lyrics: string): Promise<void> {
const lrcPath = audioFilePath.replace(/\.[^.]+$/, '.lrc');
await writeFile(lrcPath, new TextEncoder().encode(lyrics));
console.log('[LRCLIB] Saved lyrics to:', lrcPath);
}
/**
* Fetch and save lyrics for a track
* Returns true if successful, false otherwise
*/
export async function fetchAndSaveLyrics(
trackPath: string,
metadata: {
title: string;
artist: string;
album: string;
duration: number; // in seconds
}
): Promise<{ success: boolean; hasLyrics: boolean; instrumental: boolean }> {
try {
const lyrics = await getLyrics({
trackName: metadata.title,
artistName: metadata.artist,
albumName: metadata.album,
duration: Math.round(metadata.duration)
});
if (!lyrics) {
return { success: false, hasLyrics: false, instrumental: false };
}
if (lyrics.instrumental) {
return { success: true, hasLyrics: false, instrumental: true };
}
// Prefer synced lyrics, fall back to plain lyrics
const lyricsText = lyrics.syncedLyrics || lyrics.plainLyrics;
if (!lyricsText) {
return { success: false, hasLyrics: false, instrumental: false };
}
await saveLyricsFile(trackPath, lyricsText);
return { success: true, hasLyrics: true, instrumental: false };
} catch (error) {
console.error('[LRCLIB] Error fetching and saving lyrics:', error);
return { success: false, hasLyrics: false, instrumental: false };
}
}

View File

@@ -50,11 +50,34 @@ export async function loadDownloadQueue(): Promise<void> {
const queue = await store.get<Record<string, QueueItem>>('queue');
const currentJob = await store.get<string>('currentJob');
downloadQueue.set({
// Reset any items stuck in 'downloading' state from previous session
const cleanedQueue = { ...(queue ?? {}) };
let resetCount = 0;
for (const id in cleanedQueue) {
const item = cleanedQueue[id];
if (item && item.status === 'downloading') {
cleanedQueue[id] = {
...item,
status: 'queued',
progress: 0,
currentTrack: undefined
};
resetCount++;
}
}
if (resetCount > 0) {
console.log(`[DownloadQueue] Reset ${resetCount} interrupted download(s)`);
}
const newState = {
queueOrder: queueOrder ?? [],
queue: queue ?? {},
currentJob: currentJob ?? null
});
queue: cleanedQueue,
currentJob: null // Always clear currentJob on load
};
downloadQueue.set(newState);
await saveQueue(newState);
}
// Save queue to disk

View File

@@ -46,6 +46,8 @@ function createPlaybackStore(): PlaybackStore {
return {
subscribe,
set,
update,
// Queue management
playTrack(track: Track) {

203
src/lib/stores/status.ts Normal file
View File

@@ -0,0 +1,203 @@
/**
* Status notification service
* Provides a reactive status bar that shows download progress, notifications, and app state
*/
import { writable, derived, get } from 'svelte/store';
import { downloadQueue } from './downloadQueue';
export type StatusLevel = 'idle' | 'info' | 'success' | 'warning' | 'error';
export interface StatusMessage {
id: number;
message: string;
level: StatusLevel;
timestamp: number;
expiresAt: number;
}
interface StatusState {
messages: StatusMessage[];
nextId: number;
}
// Default expiration times (in ms)
const EXPIRATION_TIMES: Record<StatusLevel, number> = {
idle: 0,
info: 3000,
success: 4000,
warning: 6000,
error: 8000
};
// Priority order (higher = more important)
const PRIORITY: Record<StatusLevel, number> = {
error: 4,
warning: 3,
success: 2,
info: 1,
idle: 0
};
// Internal store
const statusState = writable<StatusState>({
messages: [],
nextId: 0
});
/**
* Push a new status message
*/
export function pushStatus(
message: string,
level: StatusLevel = 'info',
duration?: number
): number {
const state = get(statusState);
const id = state.nextId;
const timestamp = Date.now();
const expiresAt = timestamp + (duration ?? EXPIRATION_TIMES[level]);
const newMessage: StatusMessage = {
id,
message,
level,
timestamp,
expiresAt
};
statusState.update(s => ({
messages: [...s.messages, newMessage],
nextId: s.nextId + 1
}));
// Auto-remove after expiration
if (duration !== 0 && (duration ?? EXPIRATION_TIMES[level]) > 0) {
setTimeout(() => {
removeStatus(id);
}, duration ?? EXPIRATION_TIMES[level]);
}
return id;
}
/**
* Remove a status message by ID
*/
export function removeStatus(id: number): void {
statusState.update(s => ({
...s,
messages: s.messages.filter(m => m.id !== id)
}));
}
/**
* Clear all messages of a specific level (or all if no level specified)
*/
export function clearStatus(level?: StatusLevel): void {
statusState.update(s => ({
...s,
messages: level ? s.messages.filter(m => m.level !== level) : []
}));
}
/**
* Convenience methods
*/
export function setInfo(message: string, duration?: number): number {
return pushStatus(message, 'info', duration);
}
export function setSuccess(message: string, duration?: number): number {
return pushStatus(message, 'success', duration);
}
export function setWarning(message: string, duration?: number): number {
return pushStatus(message, 'warning', duration);
}
export function setError(message: string, duration?: number): number {
return pushStatus(message, 'error', duration);
}
/**
* Derive download status from queue state
*/
function deriveDownloadStatus(queueState: any): string | null {
const activeDownloads = queueState.queueOrder.filter((id: string) => {
const item = queueState.queue[id];
return item && (item.status === 'queued' || item.status === 'downloading');
});
const currentItem = queueState.currentJob
? queueState.queue[queueState.currentJob]
: null;
if (currentItem && currentItem.status === 'downloading') {
if (currentItem.type === 'track') {
return `Downloading: ${currentItem.title}`;
} else {
const total = currentItem.totalTracks;
const completed = currentItem.completedTracks;
const failed = currentItem.failedTracks;
const remaining = total - completed - failed;
if (remaining > 0) {
return `Downloading ${currentItem.title} (${completed}/${total} tracks)`;
} else {
return `Finishing ${currentItem.title}...`;
}
}
}
if (activeDownloads.length > 0) {
return `${activeDownloads.length} download${activeDownloads.length !== 1 ? 's' : ''} queued`;
}
return null;
}
/**
* Main status store - shows the highest priority active message or derived download status
*/
export const status = derived(
[statusState, downloadQueue],
([$statusState, $downloadQueue]) => {
const now = Date.now();
// Filter out expired messages
const activeMessages = $statusState.messages.filter(m => m.expiresAt > now);
// Get highest priority message
if (activeMessages.length > 0) {
const sorted = activeMessages.sort((a, b) => {
// Sort by priority first
const priorityDiff = PRIORITY[b.level] - PRIORITY[a.level];
if (priorityDiff !== 0) return priorityDiff;
// Then by timestamp (newer first)
return b.timestamp - a.timestamp;
});
return sorted[0].message;
}
// No explicit messages - check download queue
const downloadStatus = deriveDownloadStatus($downloadQueue);
if (downloadStatus) {
return downloadStatus;
}
// Default to idle
return 'Idle';
}
);
// Clean up expired messages periodically
setInterval(() => {
const now = Date.now();
statusState.update(s => ({
...s,
messages: s.messages.filter(m => m.expiresAt > now)
}));
}, 1000);

82
src/lib/utils/emoji.ts Normal file
View File

@@ -0,0 +1,82 @@
/**
* Emoji encoding/decoding utilities for filesystem-safe names
* Converts emojis to [U+XXXXX] format for safe storage
*/
/**
* Check if a character is an emoji
* Emojis are in Unicode ranges:
* - Basic Emoticons: U+1F600 - U+1F64F
* - Dingbats: U+2700 - U+27BF
* - Miscellaneous Symbols: U+2600 - U+26FF
* - Transport and Map: U+1F680 - U+1F6FF
* - Supplemental Symbols: U+1F900 - U+1F9FF
* - Flags: U+1F1E6 - U+1F1FF
* - And many more...
*/
function isEmoji(codePoint: number): boolean {
return (
(codePoint >= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons
(codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Misc Symbols and Pictographs
(codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map
(codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols
(codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) || // Flags
(codePoint >= 0x2600 && codePoint <= 0x26FF) || // Misc symbols
(codePoint >= 0x2700 && codePoint <= 0x27BF) || // Dingbats
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // Variation Selectors
(codePoint >= 0x1F000 && codePoint <= 0x1F02F) || // Mahjong Tiles
(codePoint >= 0x1F0A0 && codePoint <= 0x1F0FF) || // Playing Cards
(codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A
(codePoint >= 0x200D) || // Zero Width Joiner (used in emoji sequences)
(codePoint >= 0x231A && codePoint <= 0x231B) || // Watch, Hourglass
(codePoint >= 0x23E9 && codePoint <= 0x23F3) || // Media controls
(codePoint >= 0x25AA && codePoint <= 0x25AB) || // Geometric shapes
(codePoint >= 0x25B6) || // Play button
(codePoint >= 0x2934 && codePoint <= 0x2935) || // Arrows
(codePoint >= 0x2B05 && codePoint <= 0x2B07) || // Arrows
(codePoint >= 0x3030) || // Wavy dash
(codePoint >= 0x303D) || // Part alternation mark
(codePoint >= 0x3297) || // Japanese symbols
(codePoint >= 0x3299) // Japanese symbols
);
}
/**
* Encode emojis in text to [U+XXXXX] format
* Example: "hello 👀" → "hello [U+1F440]"
*/
export function encodeEmojis(text: string): string {
let result = '';
// Iterate through Unicode code points (not just chars, to handle surrogate pairs)
for (const char of text) {
const codePoint = char.codePointAt(0);
if (codePoint !== undefined && isEmoji(codePoint)) {
// Convert to hex string with uppercase
const hex = codePoint.toString(16).toUpperCase();
result += `[U+${hex}]`;
} else {
result += char;
}
}
return result;
}
/**
* Decode [U+XXXXX] format back to emojis
* Example: "hello [U+1F440]" → "hello 👀"
*/
export function decodeEmojis(text: string): string {
// Match [U+XXXXX] patterns (hex can be 4-6 digits for Unicode)
return text.replace(/\[U\+([0-9A-Fa-f]+)\]/g, (match, hex) => {
try {
const codePoint = parseInt(hex, 16);
return String.fromCodePoint(codePoint);
} catch {
// If parsing fails, return the original match
return match;
}
});
}

View File

@@ -9,6 +9,7 @@
import { downloadQueue } from '$lib/stores/downloadQueue';
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
import { playback } from '$lib/stores/playback';
import { status } from '$lib/stores/status';
let { children } = $props();
@@ -139,6 +140,10 @@
<img src="/icons/deezer.png" alt="" class="nav-icon" />
Deezer
</a>
<a href="/services/lrclib" class="nav-item nav-subitem">
<img src="/icons/lrclib-logo.svg" alt="" class="nav-icon" />
LRCLIB
</a>
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
Soulseek
@@ -185,7 +190,7 @@
</div>
</div>
<div class="status-text">Ready</div>
<div class="status-text">{$status}</div>
</div>
<style>

View File

@@ -79,6 +79,7 @@
{selectedTrackIndex}
onTrackClick={handleTrackClick}
showAlbumColumn={false}
decorationLabel="ALBUM OVERVIEW"
/>
{/if}
</div>

View File

@@ -42,24 +42,39 @@
}
</script>
<div class="downloads-page">
<div class="header">
<h2>Downloads</h2>
<div class="header-actions">
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
Clear Completed
</button>
</div>
</div>
<div class="downloads-wrapper">
<h2 style="padding: 8px">Downloads</h2>
{#if queueItems.length === 0}
<div class="empty-state">
<p>No downloads in queue</p>
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel" style="overflow: auto; flex: 1;">
<table class="interactive">
<section class="downloads-content">
<!--
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={true}>
<button>Queue</button>
</li>
</menu>
<div class="window tab-content" role="tabpanel">
<div class="window-body">
<div class="tab-header">
<h4>{queueItems.length} item{queueItems.length !== 1 ? 's' : ''} in queue</h4>
<div class="header-actions">
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
Clear Completed
</button>
</div>
</div>
{#if queueItems.length === 0}
<div class="empty-state">
<p>No downloads in queue</p>
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
</div>
{:else}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="col-title">Title</th>
@@ -97,28 +112,62 @@
</tr>
{/each}
</tbody>
</table>
</table>
</div>
{/if}
</div>
</div>
{/if}
</section>
</div>
<style>
.downloads-page {
.downloads-wrapper {
height: 100%;
display: flex;
flex-direction: column;
padding: 8px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
h2 {
margin: 0;
font-size: 1.4em;
}
.downloads-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;
}
.tab-content .window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
flex-shrink: 0;
}
.tab-header h4 {
margin: 0;
font-size: 1em;
font-weight: normal;
}
.header-actions {
@@ -126,13 +175,21 @@
gap: 8px;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
padding: 32px 16px;
text-align: center;
opacity: 0.6;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: light-dark(#666, #999);
}
.empty-state p {
@@ -155,6 +212,9 @@
.col-title {
width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.col-artist {

View File

@@ -124,8 +124,11 @@
selectedArtistIndex = index;
}
function handleAlbumClick(album: Album, index: number) {
function handleAlbumClick(index: number) {
selectedAlbumIndex = index;
}
function handleAlbumDoubleClick(album: Album) {
const artistEncoded = encodeURIComponent(album.artist);
const albumEncoded = encodeURIComponent(album.title);
goto(`/albums/${artistEncoded}/${albumEncoded}`);
@@ -238,7 +241,8 @@
{#each albums as album, i}
<tr
class:highlighted={selectedAlbumIndex === i}
onclick={() => handleAlbumClick(album, i)}
onclick={() => handleAlbumClick(i)}
ondblclick={() => handleAlbumDoubleClick(album)}
>
<td class="cover-cell">
{#if album.coverArtPath}

View File

@@ -98,24 +98,29 @@
// If we have cached album picture, use it
if (cachedTracks[0].album_picture) {
playlistPicture = cachedTracks[0].album_picture;
} else if ($deezerAuth.arl && cachedTracks[0].track_id) {
// Fetch album data from API to get cover
try {
deezerAPI.setArl($deezerAuth.arl);
const trackData = await deezerAPI.getTrackData(cachedTracks[0].track_id);
if (trackData && trackData.ALB_PICTURE) {
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
playlistPicture = albumCoverUrl;
} else if ($deezerAuth.arl) {
// Get the track ID - handle both DeezerTrack (has 'id') and DeezerPlaylistTrack (has 'track_id')
const trackId = 'track_id' in cachedTracks[0] ? cachedTracks[0].track_id : cachedTracks[0].id;
// Update cache with the album picture
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
await database.execute(
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
[albumCoverUrl, cachedTracks[0].track_id]
);
if (trackId) {
// Fetch album data from API to get cover
try {
deezerAPI.setArl($deezerAuth.arl);
const trackData = await deezerAPI.getTrack(trackId);
if (trackData && trackData.ALB_PICTURE) {
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
playlistPicture = albumCoverUrl;
// Update cache with the album picture
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
await database.execute(
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
[albumCoverUrl, trackId]
);
}
} catch (err) {
console.error('Failed to fetch album cover:', err);
}
} catch (err) {
console.error('Failed to fetch album cover:', err);
}
}
}

View File

@@ -0,0 +1,519 @@
<script lang="ts">
import { onMount } from 'svelte';
import { settings } from '$lib/stores/settings';
import { setSuccess, setWarning, setError, setInfo, removeStatus } from '$lib/stores/status';
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
import { scanForTracksWithoutLyrics, loadCachedTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
import { getLyricsScanTimestamp, upsertTrack } from '$lib/library/database';
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
type ViewMode = 'tracks' | 'info';
let viewMode = $state<ViewMode>('tracks');
let apiAvailable = $state<boolean | null>(null);
let checkingApi = $state(false);
let scanning = $state(false);
let scanProgress = $state<string | null>(null);
let tracks = $state<TrackWithoutLyrics[]>([]);
let selectedTrackIndex = $state<number | null>(null);
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
let lastScanned = $state<number | null>(null);
onMount(async () => {
await checkApi();
await loadCachedResults();
});
async function loadCachedResults() {
try {
tracks = await loadCachedTracksWithoutLyrics();
lastScanned = await getLyricsScanTimestamp();
} catch (error) {
console.error('[LRCLIB] Error loading cached results:', error);
}
}
async function checkApi() {
checkingApi = true;
apiAvailable = await checkApiStatus();
checkingApi = false;
}
async function handleScan() {
if (!$settings.musicFolder || scanning) {
return;
}
scanning = true;
scanProgress = 'Starting scan...';
tracks = [];
try {
const foundTracks = await scanForTracksWithoutLyrics(
$settings.musicFolder,
(current, total, message) => {
scanProgress = message;
}
);
tracks = foundTracks;
lastScanned = await getLyricsScanTimestamp();
if (tracks.length === 0) {
setInfo('All tracks have lyrics!');
} else {
setInfo(`Found ${tracks.length} track${tracks.length !== 1 ? 's' : ''} without lyrics`);
}
} catch (error) {
setError('Error scanning library: ' + (error instanceof Error ? error.message : String(error)));
} finally {
scanning = false;
scanProgress = null;
}
}
async function fetchLyricsForTrack(index: number) {
const track = tracks[index];
if (!track) return;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success) {
// Update database to mark track as having lyrics
await upsertTrack({
path: track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: Math.round(track.duration),
format: track.format,
has_lyrics: true
});
if (result.instrumental) {
setInfo(`Track marked as instrumental: ${track.title}`);
} else if (result.hasLyrics) {
setSuccess(`Lyrics fetched for ${track.title}`);
}
// Remove from list on success
tracks = tracks.filter((_, i) => i !== index);
} else {
setWarning(`No lyrics found for ${track.title}`);
}
} catch (error) {
setError(`Failed to fetch lyrics for ${track.title}`);
}
}
async function fetchLyricsForAllTracks() {
if (tracks.length === 0) return;
let successCount = 0;
let failCount = 0;
const totalTracks = tracks.length;
// Create a single status message that we'll update
const statusId = setInfo(`Fetching lyrics... 0/${totalTracks}`, 0);
// Process tracks one by one, removing from array as we go
let processedCount = 0;
while (tracks.length > 0) {
const track = tracks[0]; // Always process first track
processedCount++;
try {
const result = await fetchAndSaveLyrics(track.path, {
title: track.title,
artist: track.artist,
album: track.album,
duration: track.duration
});
if (result.success && (result.hasLyrics || result.instrumental)) {
// Update database to mark track as having lyrics
await upsertTrack({
path: track.path,
title: track.title,
artist: track.artist,
album: track.album,
duration: Math.round(track.duration),
format: track.format,
has_lyrics: true
});
successCount++;
// Remove from UI immediately on success
tracks = tracks.slice(1);
} else {
failCount++;
// Remove from list even if no lyrics found
tracks = tracks.slice(1);
}
} catch (error) {
failCount++;
// Remove from list on error
tracks = tracks.slice(1);
}
// Update progress message
setInfo(`Fetching lyrics... ${processedCount}/${totalTracks}`, 0);
}
// Remove the progress message
removeStatus(statusId);
// Show completion message
if (successCount > 0 && failCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''} (${failCount} failed)`);
} else if (successCount > 0) {
setSuccess(`Lyrics found for ${successCount} track${successCount !== 1 ? 's' : ''}`);
} else {
setWarning('No lyrics found for any tracks');
}
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
function handleTrackDoubleClick(index: number) {
fetchLyricsForTrack(index);
}
function handleContextMenu(e: MouseEvent, index: number) {
e.preventDefault();
contextMenu = {
x: e.clientX,
y: e.clientY,
trackIndex: index
};
}
function getContextMenuItems(trackIndex: number): MenuItem[] {
return [
{
label: 'Fetch Lyrics',
action: () => fetchLyricsForTrack(trackIndex)
}
];
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
}
</script>
<div class="lrclib-wrapper">
<h2 style="padding: 8px">LRCLIB</h2>
<section class="lrclib-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 === 'tracks'}>
<button onclick={() => viewMode = 'tracks'}>Missing Lyrics</button>
</li>
<li role="tab" aria-selected={viewMode === 'info'}>
<button onclick={() => viewMode = 'info'}>Info</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'tracks'}
<!-- Tracks View -->
<div class="tab-header">
<div class="header-left">
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
{#if lastScanned}
<span class="last-scanned">Last scanned: {new Date(lastScanned * 1000).toLocaleString()}</span>
{/if}
</div>
<div class="actions-row">
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
{scanning ? 'Scanning...' : 'Scan Library'}
</button>
{#if tracks.length > 0}
<button onclick={fetchLyricsForAllTracks} disabled={scanning}>
Fetch All ({tracks.length})
</button>
{/if}
</div>
</div>
{#if scanProgress}
<div class="progress-banner">
{scanProgress}
</div>
{/if}
{#if !$settings.musicFolder}
<div class="help-banner">
Please set a music folder in Settings first
</div>
{/if}
<!-- Results Table -->
{#if tracks.length > 0}
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
ondblclick={() => handleTrackDoubleClick(i)}
oncontextmenu={(e) => handleContextMenu(e, i)}
>
<td>{track.title}</td>
<td>{track.artist}</td>
<td>{track.album}</td>
<td class="duration">{formatDuration(track.duration)}</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if !scanning}
<div class="empty-state">
<p>No tracks without lyrics found. Click "Scan Library" to check your library.</p>
</div>
{/if}
{:else if viewMode === 'info'}
<!-- Info View -->
<div class="info-container">
<fieldset>
<legend>API Status</legend>
<div class="field-row">
<span class="field-label">Status:</span>
{#if checkingApi}
<span>Checking...</span>
{:else if apiAvailable === true}
<span class="status-indicator status-ok">✓ Available</span>
{:else if apiAvailable === false}
<span class="status-indicator status-error">✗ Unavailable</span>
{:else}
<span class="status-indicator">Unknown</span>
{/if}
</div>
<div class="field-row">
<button onclick={checkApi} disabled={checkingApi}>
{checkingApi ? 'Checking...' : 'Check API'}
</button>
</div>
</fieldset>
<fieldset>
<legend>About LRCLIB</legend>
<p>LRCLIB is a free, open API for fetching synchronized and plain lyrics for music tracks.</p>
<p>For more info, see <a href="https://lrclib.net/" target="_blank" rel="noopener noreferrer">lrclib.net</a></p>
</fieldset>
</div>
{/if}
</div>
</div>
</section>
</div>
{#if contextMenu}
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={getContextMenuItems(contextMenu.trackIndex)}
onClose={() => contextMenu = null}
/>
{/if}
<style>
.lrclib-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
flex-shrink: 0;
}
.lrclib-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;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--button-shadow, #808080);
flex-shrink: 0;
}
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.last-scanned {
font-size: 10px;
opacity: 0.6;
}
.status-indicator {
font-weight: bold;
font-size: 11px;
}
.status-ok {
color: #00aa00;
}
.status-error {
color: #ff6b6b;
}
.actions-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.progress-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
text-align: center;
flex-shrink: 0;
}
.help-banner {
padding: 8px;
background: var(--button-shadow, #2a2a2a);
border-bottom: 1px solid var(--button-shadow, #808080);
font-size: 11px;
color: #808080;
text-align: center;
flex-shrink: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
thead {
position: sticky;
top: 0;
z-index: 1;
background: #121212;
}
th {
text-align: left;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
.empty-state {
padding: 32px 16px;
text-align: center;
opacity: 0.6;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state p {
margin: 0;
}
.info-container {
padding: 16px;
overflow-y: auto;
flex: 1;
}
.info-container fieldset {
margin-bottom: 16px;
}
.info-container p {
margin: 8px 0;
line-height: 1.4;
}
.field-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 60px;
}
</style>

View File

@@ -19,6 +19,8 @@
import { clearDeezerCache } from '$lib/library/deezer-database';
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
import { relaunch } from '@tauri-apps/plugin-process';
import { appDataDir } from '@tauri-apps/api/path';
import { openPath } from '@tauri-apps/plugin-opener';
let currentMusicFolder = $state<string | null>(null);
let currentPlaylistsFolder = $state<string | null>(null);
@@ -122,34 +124,51 @@
}
}
}
async function openAppDataFolder() {
try {
const dataPath = await appDataDir();
console.log('App data path:', dataPath);
if (!dataPath) {
throw new Error('Could not get app data directory path');
}
await openPath(dataPath);
} catch (error) {
console.error('Error opening app data folder:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
await message('Error opening app data folder: ' + errorMessage, { title: 'Error', kind: 'error' });
}
}
</script>
<div style="padding: 8px;">
<h2>Settings</h2>
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={activeTab === 'library'}>
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
</li>
<li role="tab" aria-selected={activeTab === 'deezer'}>
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
</li>
<li role="tab" aria-selected={activeTab === 'advanced'}>
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
</li>
</menu>
<div class="settings-wrapper">
<h2 style="padding: 8px">Settings</h2>
<div class="window" role="tabpanel">
<div class="window-body">
<section class="settings-content">
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={activeTab === 'library'}>
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
</li>
<li role="tab" aria-selected={activeTab === 'deezer'}>
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
</li>
<li role="tab" aria-selected={activeTab === 'advanced'}>
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
</li>
</menu>
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if activeTab === 'library'}
<section class="tab-content">
<section>
<h3>Library Folders</h3>
<div class="field-row-stacked">
<label for="music-folder">Music Folder</label>
@@ -197,7 +216,7 @@
</div>
</section>
{:else if activeTab === 'deezer'}
<section class="tab-content">
<section>
<h3>Deezer Download Settings</h3>
<div class="field-row-stacked">
@@ -319,7 +338,7 @@
</fieldset>
</section>
{:else if activeTab === 'advanced'}
<section class="tab-content">
<section>
<h3>Advanced Settings</h3>
<div class="field-row-stacked">
@@ -339,16 +358,28 @@
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
</div>
<div class="field-row-stacked">
<div class="setting-heading">Open App Data Folder</div>
<small class="help-text">Opens the application data folder containing SQLite databases and other app data.</small>
<button onclick={openAppDataFolder}>Open Folder</button>
</div>
</section>
{/if}
</div>
</div>
</div>
</section>
</div>
<style>
.settings-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin-top: 0;
margin-bottom: 12px;
margin: 0;
}
h3 {
@@ -357,12 +388,27 @@
font-size: 1.1em;
}
menu[role="tablist"] {
margin-bottom: 0;
.settings-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin: 0;
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content .window-body {
padding: 12px;
flex: 1;
overflow-y: auto;
min-height: 0;
}
.info-note {

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #111041;
}
.cls-2 {
fill: #fdfdfd;
}
</style>
</defs>
<g id="QGdPvo.tif">
<g>
<path class="cls-1" d="M16.663,126.482C7.066,121.586,1.49,116.223,0,104.918V23.048C1.071,14.257,6.144,6.818,13.95,2.709c.515-.271,1.772-.711,3.089-1.26l1.247-.473C19.133.695,21.597.003,23.055,0h81.726c12.256,1.082,21.845,10.748,23.218,22.883l-.029,82.497c-1.337,10.446-8.245,18.709-18.356,21.676-.66.194-3.565.748-3.753.787-.191.04-.422.039-.627.073l-82.5.039c-1.539-.285-4.031-.886-4.833-1.085s-1.241-.389-1.241-.389ZM101.882,30.168c.536-.844-1.616-4.825-2.279-5.633-3.824-4.665-12.876-4.358-17.059-.33-5.699,5.488-5.939,20.919-.088,26.331,4.514,4.175,13.651,3.675,17.647-1.066.837-.993,3.255-4.753,1.639-5.583-.465-.239-5.132-1.468-5.462-1.368-.442.134-.908,1.926-1.294,2.49-1.213,1.769-3.804,2.347-5.808,1.761-4.917-1.438-4.464-13.841-2.336-17.285,1.674-2.708,5.696-3.17,7.586-.432.509.738.686,2.309,1.433,2.506.299.079,5.856-1.13,6.021-1.391ZM46.689,46.266h-11.65l-.246-.246v-24.114c0-.186-.672-.521-.913-.564-.655-.116-5.135-.084-5.608.114-.2.084-.435.269-.502.483l-.076,30.358.41.41,18.885.093c.342-.122.401-.565.441-.872.093-.711.076-4.809-.184-5.234-.078-.128-.426-.398-.557-.427ZM58.257,51.926v-10.991l.246-.246h3.446c.68,0,5.287,10.795,6.181,12.111.272.158,5.816.124,6.285.024.325-.069.597-.346.676-.65.287-1.101-5.527-10.991-6.005-12.888,1.046-.912,2.315-1.428,3.287-2.612,4.498-5.479.959-13.534-5.907-14.938-2.907-.594-11.097-.796-14.037-.408-.632.084-.93.259-1.053.916l.025,29.809c.117.379.295.683.707.77.543.115,5.498.041,5.754-.145.093-.067.373-.63.395-.753ZM97.498,85.496c.047-.09,1.235-.965,1.536-1.333,2.362-2.89,2.058-8.084-.66-10.663-3.188-3.025-11.839-2.811-16.085-2.633-.561.024-3.636.2-3.811.374l-.05,30.729.741.408c7.002-.546,20.72,2.585,22.058-7.492.348-2.626.038-5.033-1.701-7.117-.403-.483-2.268-1.812-2.028-2.272ZM46.896,96.011c-.662-.664-11.744.254-12.284-.433-.152-.194-.16-.429-.157-.663l-.027-23.267c-.083-.326-.176-.534-.538-.611-.571-.122-5.752-.039-6.081.153l-.148,30.845,18.961.262c.135-.04.247-.079.328-.203.269-.413.248-5.782-.053-6.084ZM58.703,71.073c-.327.095-.628.511-.619.858l.044,29.664c.117.481.41.675.878.763.698.131,4.515.126,5.229,0,.475-.083.929-.356.921-.884l-.013-29.694c-.079-.37-.368-.659-.738-.738-.47-.1-5.305-.084-5.701.031Z"/>
<g>
<g>
<path class="cls-2" d="M46.896,96.011c.301.302.322,5.671.053,6.084-.081.124-.193.163-.328.203l-18.961-.262.148-30.845c.329-.191,5.51-.274,6.081-.153.361.077.455.285.538.611l.027,23.267c-.003.234.005.469.157.663.54.687,11.622-.231,12.284.433Z"/>
<path class="cls-2" d="M58.703,71.073c.396-.115,5.232-.131,5.701-.031.37.079.66.368.738.738l.013,29.694c.008.528-.446.801-.921.884-.714.125-4.531.131-5.229,0-.468-.088-.761-.282-.878-.763l-.044-29.664c-.009-.347.292-.763.619-.858Z"/>
<g>
<path class="cls-1" d="M85.167,96.3v-7.382h5.825c.15,0,1.228.41,1.447.522,3.393,1.743,1.775,6.86-1.447,6.86h-5.825Z"/>
<path class="cls-1" d="M85.167,83.012v-5.906h5.989c.325,0,1.698.849,1.959,1.158,1.689,2.004-.298,4.747-2.615,4.747h-5.333Z"/>
<path class="cls-2" d="M97.498,85.496c-.24.46,1.625,1.789,2.028,2.272,1.738,2.084,2.049,4.492,1.701,7.117-1.338,10.078-15.056,6.947-22.058,7.492l-.741-.408.05-30.729c.175-.174,3.249-.35,3.811-.374,4.246-.178,12.896-.392,16.085,2.633,2.718,2.579,3.022,7.772.66,10.663-.301.368-1.489,1.243-1.536,1.333ZM85.167,83.012h5.333c2.317,0,4.304-2.743,2.615-4.747-.261-.31-1.634-1.158-1.959-1.158h-5.989v5.906ZM85.167,96.3h5.825c3.222,0,4.84-5.117,1.447-6.86-.219-.112-1.297-.522-1.447-.522h-5.825v7.382Z"/>
</g>
</g>
<g>
<path class="cls-2" d="M58.257,51.926c-.022.123-.302.686-.395.753-.256.185-5.211.259-5.754.145-.412-.087-.589-.391-.707-.77l-.025-29.809c.123-.657.421-.832,1.053-.916,2.94-.389,11.13-.187,14.037.408,6.866,1.404,10.405,9.459,5.907,14.938-.972,1.184-2.24,1.7-3.287,2.612.478,1.897,6.291,11.786,6.005,12.888-.079.304-.351.58-.676.65-.469.1-6.013.133-6.285-.024-.894-1.316-5.501-12.111-6.181-12.111h-3.446l-.246.246v10.991ZM58.257,34.619h5.005c1.232,0,3.441-1.02,3.682-2.388.12-.682.075-2.405-.251-3.011-.288-.536-1.879-1.655-2.447-1.655h-5.907c-.639,0,.119,6.472-.082,7.054Z"/>
<path class="cls-2" d="M101.882,30.168c-.166.261-5.723,1.47-6.021,1.391-.747-.197-.924-1.768-1.433-2.506-1.89-2.738-5.912-2.277-7.586.432-2.128,3.444-2.581,15.847,2.336,17.285,2.004.586,4.596.008,5.808-1.761.386-.563.852-2.356,1.294-2.49.331-.1,4.997,1.129,5.462,1.368,1.616.829-.802,4.59-1.639,5.583-3.996,4.741-13.133,5.241-17.647,1.066-5.851-5.412-5.611-20.843.088-26.331,4.183-4.028,13.235-4.335,17.059.33.663.808,2.815,4.789,2.279,5.633Z"/>
<path class="cls-2" d="M46.689,46.266c.131.029.479.299.557.427.259.426.277,4.523.184,5.234-.04.307-.099.75-.441.872l-18.885-.093-.41-.41.076-30.358c.067-.213.302-.399.502-.483.473-.198,4.953-.23,5.608-.114.241.042.913.378.913.564v24.114l.246.246h11.65Z"/>
</g>
</g>
<path class="cls-1" d="M58.257,34.619c.201-.582-.557-7.054.082-7.054h5.907c.568,0,2.159,1.118,2.447,1.655.325.606.371,2.329.251,3.011-.241,1.368-2.451,2.388-3.682,2.388h-5.005Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -1,3 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 20H8V4H10V6H12V9H14V11H16V13H14V15H12V18H10V20Z" fill="black"/>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="24" height="24" version="1.1" viewBox="0 0 24 24">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M10,20h-2V4h2v2h2v3h2v2h2v2h-2v2h-2v3h-2v2Z" fill="#fff" fill-rule="evenodd"/>
</svg>

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M224,432h-80V80h80v352ZM368,432h-80V80h80v352Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M96,448l320-192L96,64v384Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 311 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M143.47,64v163.52L416,64v384l-272.53-163.51999v163.51999h-47.47V64h47.47Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 358 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M368.53,64v163.52L96,64v384l272.53-163.52002v163.52002h47.47V64h-47.47Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" width="512" height="512" version="1.1" viewBox="0 0 512 512">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<path d="M80,80h352v352H80V80Z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32">
<defs>
<style>
.cls-1 {
fill: #373737;
}
</style>
</defs>
<path class="cls-1" d="M64,0H0v32h21.634c3.056-9.369,6.236-15.502,19.82-17.258,2.105-.272,4.23-.37,6.352-.37h16.193V0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 353 B