Compare commits
18 Commits
fc2b987f63
...
25ce2d676e
| Author | SHA1 | Date | |
|---|---|---|---|
| 25ce2d676e | |||
| 38db835973 | |||
| c30b205d9c | |||
| 7b84bc32df | |||
| 96a01bdced | |||
| e4586f6497 | |||
| f4ef13ec0d | |||
| 05acc3483c | |||
| efaa9f02b8 | |||
| e535fdb4bc | |||
| 8b3989e71f | |||
| 9e75322a43 | |||
| a602ee4bbd | |||
| 9333e55095 | |||
| e5c8ce1c30 | |||
| 7c64818db1 | |||
| 480aa5859b | |||
| 26c465118b |
@@ -11,7 +11,8 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"tauri": "tauri"
|
"tauri": "tauri"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.0.1",
|
"@noble/ciphers": "^2.0.1",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
|||||||
146
src-tauri/Cargo.lock
generated
@@ -8,9 +8,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"blowfish",
|
"blowfish",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
"futures-util",
|
||||||
"id3",
|
"id3",
|
||||||
"md5",
|
"md5",
|
||||||
"metaflac",
|
"metaflac",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
@@ -22,6 +24,7 @@ dependencies = [
|
|||||||
"tauri-plugin-process",
|
"tauri-plugin-process",
|
||||||
"tauri-plugin-sql",
|
"tauri-plugin-sql",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -659,7 +662,7 @@ dependencies = [
|
|||||||
"bitflags 2.9.4",
|
"bitflags 2.9.4",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1192,6 +1195,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
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]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -1199,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1213,6 +1225,12 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1844,6 +1862,22 @@ dependencies = [
|
|||||||
"webpki-roots",
|
"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]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.17"
|
version = "0.1.17"
|
||||||
@@ -2464,6 +2498,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"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]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2858,6 +2909,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"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]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3579,10 +3674,12 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-rustls",
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -3593,6 +3690,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -3755,6 +3853,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3818,6 +3925,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4116,7 +4246,7 @@ dependencies = [
|
|||||||
"bytemuck",
|
"bytemuck",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"objc2 0.5.2",
|
"objc2 0.5.2",
|
||||||
@@ -5085,6 +5215,16 @@ dependencies = [
|
|||||||
"syn 2.0.106",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
|
|||||||
@@ -33,4 +33,7 @@ tauri-plugin-process = "2"
|
|||||||
blowfish = "0.9"
|
blowfish = "0.9"
|
||||||
md5 = "0.7"
|
md5 = "0.7"
|
||||||
byteorder = "1.5.0"
|
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"
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "http://*.dzcdn.net/**"
|
"url": "http://*.dzcdn.net/**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://lrclib.net/**"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,6 +97,69 @@ pub fn decrypt_track(data: &[u8], track_id: &str) -> Vec<u8> {
|
|||||||
result
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
|
|||||||
metadata::read_audio_metadata(&path)
|
metadata::read_audio_metadata(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt Deezer track data
|
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
|
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
|
// Run decryption on a background thread to avoid blocking the UI
|
||||||
@@ -49,6 +49,129 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
|
|||||||
Ok(result)
|
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 tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::fs::File;
|
||||||
|
use deezer_crypto::StreamingDecryptor;
|
||||||
|
use tauri::Emitter;
|
||||||
|
|
||||||
|
// 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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let library_migrations = vec![Migration {
|
let library_migrations = vec![Migration {
|
||||||
@@ -171,7 +294,8 @@ pub fn run() {
|
|||||||
greet,
|
greet,
|
||||||
tag_audio_file,
|
tag_audio_file,
|
||||||
read_audio_metadata,
|
read_audio_metadata,
|
||||||
decrypt_deezer_track
|
decrypt_deezer_track,
|
||||||
|
download_and_decrypt_track
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"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": {
|
"assetProtocol": {
|
||||||
"enable": true,
|
"enable": true,
|
||||||
"scope": ["**"]
|
"scope": ["**"]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
forward: '/icons/rightarrow.png',
|
forward: '/icons/rightarrow.png',
|
||||||
play: '/icons/speaker.png',
|
play: '/icons/speaker.png',
|
||||||
search: '/icons/internet.png',
|
search: '/icons/internet.png',
|
||||||
globe: '/icons/github-white.svg',
|
|
||||||
computer: '/icons/computer.png',
|
computer: '/icons/computer.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,12 +86,6 @@
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="toolbar-separator"></div>
|
|
||||||
|
|
||||||
<button class="toolbar-button" disabled title="GitHub">
|
|
||||||
<img src={icons.globe} alt="Globe" />
|
|
||||||
<span>GitHub</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||||
import { playback } from '$lib/stores/playback';
|
import { playback } from '$lib/stores/playback';
|
||||||
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
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 {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
onTrackClick?: (index: number) => void;
|
onTrackClick?: (index: number) => void;
|
||||||
showAlbumColumn?: boolean;
|
showAlbumColumn?: boolean;
|
||||||
useSequentialNumbers?: boolean;
|
useSequentialNumbers?: boolean;
|
||||||
|
decorationLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -25,7 +29,8 @@
|
|||||||
selectedTrackIndex = null,
|
selectedTrackIndex = null,
|
||||||
onTrackClick,
|
onTrackClick,
|
||||||
showAlbumColumn = false,
|
showAlbumColumn = false,
|
||||||
useSequentialNumbers = false
|
useSequentialNumbers = false,
|
||||||
|
decorationLabel = 'LOCAL PLAYLIST'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
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[] {
|
function getContextMenuItems(trackIndex: number): MenuItem[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -70,11 +101,17 @@
|
|||||||
{
|
{
|
||||||
label: 'Play Next',
|
label: 'Play Next',
|
||||||
action: () => playback.playNext([tracks[trackIndex]])
|
action: () => playback.playNext([tracks[trackIndex]])
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Fetch Lyrics via LRCLIB',
|
||||||
|
action: () => handleFetchLyrics(trackIndex)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PageDecoration label={decorationLabel} />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
{#if coverArtPath}
|
{#if coverArtPath}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent, item: MenuItem) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleItemClick(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -61,6 +68,8 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
class:disabled={item.disabled}
|
class:disabled={item.disabled}
|
||||||
onclick={() => handleItemClick(item)}
|
onclick={() => handleItemClick(item)}
|
||||||
|
onkeydown={(e) => handleKeyDown(e, item)}
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Track } from '$lib/types/track';
|
import type { Track } from '$lib/types/track';
|
||||||
|
import PageDecoration from '$lib/components/PageDecoration.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -64,6 +65,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<PageDecoration label="DEEZER PLAYLIST" />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="collection-header">
|
<div class="collection-header">
|
||||||
{#if coverImageUrl}
|
{#if coverImageUrl}
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
import { playback } from '$lib/stores/playback';
|
import { playback } from '$lib/stores/playback';
|
||||||
import { audioPlayer } from '$lib/services/audioPlayer';
|
import { audioPlayer } from '$lib/services/audioPlayer';
|
||||||
import LyricsDisplay from '$lib/components/LyricsDisplay.svelte';
|
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() {
|
function handlePlayPause() {
|
||||||
playback.togglePlayPause();
|
playback.togglePlayPause();
|
||||||
@@ -22,11 +31,8 @@
|
|||||||
playback.setCurrentTime(time);
|
playback.setCurrentTime(time);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVolumeChange(e: Event) {
|
function handleVolumeChange(newVolume: number) {
|
||||||
const target = e.target as HTMLInputElement;
|
playback.setVolume(newVolume);
|
||||||
const volume = parseFloat(target.value);
|
|
||||||
playback.setVolume(volume);
|
|
||||||
audioPlayer.setVolume(volume);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
@@ -43,48 +49,28 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
<div class="controls">
|
<div class="player-main">
|
||||||
<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">
|
<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}
|
{#if hasTrack && $playback.currentTrack}
|
||||||
<div class="track-title">{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}</div>
|
{$playback.currentTrack.metadata.title || $playback.currentTrack.filename}
|
||||||
<div class="track-artist">{$playback.currentTrack.metadata.artist || 'Unknown Artist'}</div>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="track-title">No track playing</div>
|
No track playing
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="track-artist">
|
||||||
|
{#if hasTrack && $playback.currentTrack}
|
||||||
|
{$playback.currentTrack.metadata.artist || 'Unknown Artist'}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-controls-container">
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
<span class="time-display">{formatTime($playback.currentTime)}</span>
|
<span class="time-display">{formatTime($playback.currentTime)}</span>
|
||||||
<div class="progress-bar-container">
|
<div class="progress-bar-container">
|
||||||
@@ -105,20 +91,54 @@
|
|||||||
<span class="time-display">{formatTime($playback.duration)}</span>
|
<span class="time-display">{formatTime($playback.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="volume-section">
|
<div class="controls-row">
|
||||||
<img src="/icons/volume.svg" alt="Volume" class="volume-icon" />
|
<div class="controls">
|
||||||
<div class="is-vertical volume-slider-container">
|
<button
|
||||||
<input
|
class="control-button"
|
||||||
type="range"
|
onclick={handlePrevious}
|
||||||
min="0"
|
disabled={!hasTrack}
|
||||||
max="1"
|
title="Previous"
|
||||||
step="0.01"
|
>
|
||||||
value={$playback.volume}
|
<img src="/icons/player-skip-back.svg" alt="Previous" />
|
||||||
oninput={handleVolumeChange}
|
</button>
|
||||||
class="has-box-indicator"
|
|
||||||
/>
|
<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>
|
</div>
|
||||||
<span class="volume-percent">{Math.round($playback.volume * 100)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LyricsDisplay lrcPath={$playback.lrcPath} currentTime={$playback.currentTime} />
|
<LyricsDisplay lrcPath={$playback.lrcPath} currentTime={$playback.currentTime} />
|
||||||
@@ -127,7 +147,8 @@
|
|||||||
<style>
|
<style>
|
||||||
.now-playing {
|
.now-playing {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -135,66 +156,88 @@
|
|||||||
font-size: 11px;
|
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 {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button {
|
.control-button {
|
||||||
background: transparent;
|
padding: 6px 20px;
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
padding: 4px;
|
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
min-height: auto;
|
min-height: auto;
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-radius: 0;
|
||||||
|
margin: 0 -1px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-button:hover:not(:disabled) {
|
.control-button:first-child {
|
||||||
background: light-dark(silver, #2b2b2b);
|
margin-left: 0;
|
||||||
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 img {
|
.control-button img {
|
||||||
width: 24px;
|
width: 12px;
|
||||||
height: 24px;
|
height: 12px;
|
||||||
filter: light-dark(none, invert(1));
|
filter: light-dark(none, invert(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-pause {
|
.play-pause {
|
||||||
padding: 6px;
|
padding: 6px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause img {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-info {
|
.track-info {
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
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 {
|
.track-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-artist {
|
.track-artist {
|
||||||
@@ -205,12 +248,19 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-controls-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.progress-section {
|
.progress-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex: 2;
|
min-width: 0;
|
||||||
min-width: 200px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-display {
|
.time-display {
|
||||||
@@ -248,39 +298,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-section {
|
.volume-section {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-icon {
|
/* Responsive: hide lyrics panel when not enough space */
|
||||||
width: 20px;
|
@media (max-width: 800px) {
|
||||||
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) {
|
|
||||||
.now-playing :global(.lyrics-display) {
|
.now-playing :global(.lyrics-display) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive: hide volume on small widths */
|
.player-controls-container {
|
||||||
@media (max-width: 800px) {
|
max-width: none;
|
||||||
.volume-section {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
src/lib/components/PageDecoration.svelte
Normal 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>
|
||||||
257
src/lib/components/TriangleVolumeSlider.svelte
Normal 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>
|
||||||
@@ -146,7 +146,7 @@ export async function upsertPlaylists(playlists: any[]): Promise<void> {
|
|||||||
String(playlist.PLAYLIST_ID),
|
String(playlist.PLAYLIST_ID),
|
||||||
playlist.TITLE || '',
|
playlist.TITLE || '',
|
||||||
playlist.NB_SONG || 0,
|
playlist.NB_SONG || 0,
|
||||||
playlist.PARENT_USERNAME || 'Unknown',
|
playlist.PARENT_USERNAME || playlist._USER_NAME_FALLBACK || 'Unknown',
|
||||||
playlist.PLAYLIST_PICTURE || null,
|
playlist.PLAYLIST_PICTURE || null,
|
||||||
playlist.PICTURE_TYPE || null,
|
playlist.PICTURE_TYPE || null,
|
||||||
now
|
now
|
||||||
|
|||||||
137
src/lib/library/lyricScanner.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
||||||
import { findAlbumArt } from './album';
|
import { findAlbumArt } from './album';
|
||||||
|
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get audio format from file extension
|
* Get audio format from file extension
|
||||||
@@ -228,7 +229,8 @@ export async function findPlaylistCoverFallback(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct album folder path following the same structure as downloader
|
// 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 {
|
try {
|
||||||
// Check if album folder exists and has cover art
|
// Check if album folder exists and has cover art
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ export const audioPlayer = new AudioPlayer();
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
let prevTrack: Track | null = null;
|
let prevTrack: Track | null = null;
|
||||||
let prevIsPlaying = false;
|
let prevIsPlaying = false;
|
||||||
|
let prevVolume = 1;
|
||||||
|
|
||||||
playback.subscribe(state => {
|
playback.subscribe(state => {
|
||||||
const { currentTrack, isPlaying } = state;
|
const { currentTrack, isPlaying, volume } = state;
|
||||||
|
|
||||||
// Track changed
|
// Track changed
|
||||||
if (currentTrack && currentTrack !== prevTrack) {
|
if (currentTrack && currentTrack !== prevTrack) {
|
||||||
@@ -160,5 +161,11 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
prevIsPlaying = isPlaying;
|
prevIsPlaying = isPlaying;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume changed
|
||||||
|
if (volume !== prevVolume) {
|
||||||
|
audioPlayer.setVolume(volume);
|
||||||
|
prevVolume = volume;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,7 +340,15 @@ export class DeezerAPI {
|
|||||||
nb: -1
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching playlists:', error);
|
console.error('Error fetching playlists:', error);
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { addToQueue } from '$lib/stores/downloadQueue';
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { trackExists } from './downloader';
|
import { trackExists } from './downloader';
|
||||||
|
import { setInfo, setWarning } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +104,7 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
|
|
||||||
if (exists) {
|
if (exists) {
|
||||||
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
console.log(`[AddToQueue] Skipping "${track.title}" - already exists`);
|
||||||
|
setWarning(`Skipped: ${track.title} (already exists)`);
|
||||||
return { added: false, reason: 'already_exists' };
|
return { added: false, reason: 'already_exists' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,5 +119,6 @@ export async function addDeezerTrackToQueue(trackId: string): Promise<{ added: b
|
|||||||
downloadObject: track
|
downloadObject: track
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setInfo(`Queued: ${track.title}`);
|
||||||
return { added: true };
|
return { added: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
* Deezer track downloader with streaming and decryption
|
* 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 { writeFile, mkdir, remove, rename, exists } from '@tauri-apps/plugin-fs';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { generateTrackPath } from './paths';
|
import { generateTrackPath } from './paths';
|
||||||
@@ -32,6 +31,7 @@ export async function downloadTrack(
|
|||||||
retryCount: number = 0,
|
retryCount: number = 0,
|
||||||
decryptionTrackId?: string
|
decryptionTrackId?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const { listen } = await import('@tauri-apps/api/event');
|
||||||
// Generate paths
|
// Generate paths
|
||||||
const paths = generateTrackPath(track, musicFolder, format, false);
|
const paths = generateTrackPath(track, musicFolder, format, false);
|
||||||
|
|
||||||
@@ -56,85 +56,32 @@ export async function downloadTrack(
|
|||||||
console.log('Temp path:', paths.tempPath);
|
console.log('Temp path:', paths.tempPath);
|
||||||
|
|
||||||
try {
|
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/');
|
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;
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// Use the provided decryption track ID (for fallback tracks) or the original track ID
|
||||||
const trackIdForDecryption = decryptionTrackId ? decryptionTrackId.toString() : track.id.toString();
|
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[]
|
// Set up progress listener
|
||||||
const decryptedArray = await invoke<number[]>('decrypt_deezer_track', {
|
const unlisten = await listen<DownloadProgress>('download-progress', (event) => {
|
||||||
data: encryptedData,
|
if (onProgress) {
|
||||||
trackId: trackIdForDecryption
|
onProgress(event.payload);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
decryptedData = new Uint8Array(decryptedArray);
|
|
||||||
} else {
|
try {
|
||||||
decryptedData = encryptedData;
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Download and decryption complete!');
|
||||||
|
} finally {
|
||||||
|
// Clean up event listener
|
||||||
|
unlisten();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user settings
|
// Get user settings
|
||||||
@@ -151,10 +98,7 @@ export async function downloadTrack(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write untagged file to temp first
|
// File is already written to temp by Rust backend
|
||||||
console.log('Writing untagged file to temp...');
|
|
||||||
await writeFile(paths.tempPath, decryptedData);
|
|
||||||
|
|
||||||
// Move to final location
|
// Move to final location
|
||||||
const finalPath = `${paths.filepath}/${paths.filename}`;
|
const finalPath = `${paths.filepath}/${paths.filename}`;
|
||||||
console.log('Moving to final location:', finalPath);
|
console.log('Moving to final location:', finalPath);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { trackExists } from './downloader';
|
|||||||
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
import { writeM3U8, makeRelativePath, type M3U8Track } from '$lib/library/m3u8';
|
||||||
import { generateTrackPath } from './paths';
|
import { generateTrackPath } from './paths';
|
||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { setInfo, setSuccess } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
import { mkdir } from '@tauri-apps/plugin-fs';
|
import { mkdir } from '@tauri-apps/plugin-fs';
|
||||||
@@ -76,6 +77,15 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Queued ${addedCount} tracks, skipped ${skippedCount}`);
|
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
|
// Generate m3u8 file
|
||||||
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
const m3u8Tracks: M3U8Track[] = tracks.map(track => {
|
||||||
// Generate expected path for this track
|
// Generate expected path for this track
|
||||||
@@ -98,5 +108,8 @@ export async function downloadDeezerPlaylist(
|
|||||||
|
|
||||||
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
console.log(`[PlaylistDownloader] Playlist saved to: ${m3u8Path}`);
|
||||||
|
|
||||||
|
// Show success message for playlist creation
|
||||||
|
setSuccess(`Playlist created: ${playlistName}`);
|
||||||
|
|
||||||
return m3u8Path;
|
return m3u8Path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { settings } from '$lib/stores/settings';
|
import { settings } from '$lib/stores/settings';
|
||||||
import { deezerAuth } from '$lib/stores/deezer';
|
import { deezerAuth } from '$lib/stores/deezer';
|
||||||
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
import { syncTrackPaths } from '$lib/library/incrementalSync';
|
||||||
|
import { setSuccess, setError } from '$lib/stores/status';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import type { DeezerTrack } from '$lib/types/deezer';
|
import type { DeezerTrack } from '$lib/types/deezer';
|
||||||
|
|
||||||
@@ -182,12 +183,29 @@ export class DeezerQueueManager {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
progress: 100
|
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) {
|
} catch (error) {
|
||||||
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
console.error(`[DeezerQueueManager] Error downloading ${nextItem.title}:`, error);
|
||||||
await updateQueueItem(nextItem.id, {
|
await updateQueueItem(nextItem.id, {
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
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
|
// Clear current job
|
||||||
@@ -197,6 +215,86 @@ export class DeezerQueueManager {
|
|||||||
console.log('[DeezerQueueManager] Queue processor stopped');
|
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
|
* Download a single track
|
||||||
*/
|
*/
|
||||||
@@ -215,6 +313,16 @@ export class DeezerQueueManager {
|
|||||||
}
|
}
|
||||||
deezerAPI.setArl(authState.arl);
|
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
|
// Get user data for license token
|
||||||
const userData = await deezerAPI.getUserData();
|
const userData = await deezerAPI.getUserData();
|
||||||
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
||||||
@@ -353,6 +461,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 userData = await deezerAPI.getUserData();
|
||||||
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
const licenseToken = userData.USER?.OPTIONS?.license_token;
|
||||||
|
|
||||||
|
|||||||
197
src/lib/services/lrclib.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,8 @@ function createPlaybackStore(): PlaybackStore {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
|
set,
|
||||||
|
update,
|
||||||
|
|
||||||
// Queue management
|
// Queue management
|
||||||
playTrack(track: Track) {
|
playTrack(track: Track) {
|
||||||
|
|||||||
203
src/lib/stores/status.ts
Normal 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);
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import { downloadQueue } from '$lib/stores/downloadQueue';
|
import { downloadQueue } from '$lib/stores/downloadQueue';
|
||||||
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
import { deezerQueueManager } from '$lib/services/deezer/queueManager';
|
||||||
import { playback } from '$lib/stores/playback';
|
import { playback } from '$lib/stores/playback';
|
||||||
|
import { status } from '$lib/stores/status';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -139,6 +140,10 @@
|
|||||||
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
<img src="/icons/deezer.png" alt="" class="nav-icon" />
|
||||||
Deezer
|
Deezer
|
||||||
</a>
|
</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">
|
<!-- <a href="/services/soulseek" class="nav-item nav-subitem">
|
||||||
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
<img src="/icons/soulseek.png" alt="" class="nav-icon" />
|
||||||
Soulseek
|
Soulseek
|
||||||
@@ -185,7 +190,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="status-text">Ready</div>
|
<div class="status-text">{$status}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
{selectedTrackIndex}
|
{selectedTrackIndex}
|
||||||
onTrackClick={handleTrackClick}
|
onTrackClick={handleTrackClick}
|
||||||
showAlbumColumn={false}
|
showAlbumColumn={false}
|
||||||
|
decorationLabel="ALBUM OVERVIEW"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -98,11 +98,15 @@
|
|||||||
// If we have cached album picture, use it
|
// If we have cached album picture, use it
|
||||||
if (cachedTracks[0].album_picture) {
|
if (cachedTracks[0].album_picture) {
|
||||||
playlistPicture = cachedTracks[0].album_picture;
|
playlistPicture = cachedTracks[0].album_picture;
|
||||||
} else if ($deezerAuth.arl && cachedTracks[0].track_id) {
|
} 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;
|
||||||
|
|
||||||
|
if (trackId) {
|
||||||
// Fetch album data from API to get cover
|
// Fetch album data from API to get cover
|
||||||
try {
|
try {
|
||||||
deezerAPI.setArl($deezerAuth.arl);
|
deezerAPI.setArl($deezerAuth.arl);
|
||||||
const trackData = await deezerAPI.getTrackData(cachedTracks[0].track_id);
|
const trackData = await deezerAPI.getTrack(trackId);
|
||||||
if (trackData && trackData.ALB_PICTURE) {
|
if (trackData && trackData.ALB_PICTURE) {
|
||||||
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
|
const albumCoverUrl = `https://e-cdns-images.dzcdn.net/images/cover/${trackData.ALB_PICTURE}/500x500-000000-80-0-0.jpg`;
|
||||||
playlistPicture = albumCoverUrl;
|
playlistPicture = albumCoverUrl;
|
||||||
@@ -111,7 +115,7 @@
|
|||||||
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
|
const database = await import('$lib/library/deezer-database').then(m => m.initDeezerDatabase());
|
||||||
await database.execute(
|
await database.execute(
|
||||||
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
|
'UPDATE deezer_playlist_tracks SET album_picture = $1 WHERE track_id = $2',
|
||||||
[albumCoverUrl, cachedTracks[0].track_id]
|
[albumCoverUrl, trackId]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -119,6 +123,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Final fallback to local files
|
// Final fallback to local files
|
||||||
if ((!playlistPicture || !playlistPicture.startsWith('http')) && $settings.musicFolder) {
|
if ((!playlistPicture || !playlistPicture.startsWith('http')) && $settings.musicFolder) {
|
||||||
|
|||||||
468
src/routes/services/lrclib/+page.svelte
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { settings } from '$lib/stores/settings';
|
||||||
|
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
|
||||||
|
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||||
|
import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
|
||||||
|
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);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await checkApi();
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
setInfo(`Fetching lyrics for ${tracks.length} tracks...`, 0);
|
||||||
|
|
||||||
|
const tracksCopy = [...tracks];
|
||||||
|
|
||||||
|
for (let i = 0; i < tracksCopy.length; i++) {
|
||||||
|
const track = tracksCopy[i];
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update progress
|
||||||
|
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
|
||||||
|
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rescan to update the list
|
||||||
|
tracks = [];
|
||||||
|
await handleScan();
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
36
static/icons/lrclib-logo.svg
Normal 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 |
@@ -1,3 +1,5 @@
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 20H8V4H10V6H12V9H14V11H16V13H14V15H12V18H10V20Z" fill="black"/>
|
<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>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 344 B |
5
static/icons/player-pause.svg
Normal 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 |
5
static/icons/player-play.svg
Normal 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 |
5
static/icons/player-skip-back.svg
Normal 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 |
5
static/icons/player-skip-forward.svg
Normal 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 |
5
static/icons/player-stop.svg
Normal 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 |
11
static/vectors/title-decoration.svg
Normal 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 |