Compare commits

...

18 Commits

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

View File

@@ -11,7 +11,8 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"tauri": "tauri"
},
"license": "MIT",
"license": "UNLICENSED",
"private": true,
"dependencies": {
"@noble/ciphers": "^2.0.1",
"@tauri-apps/api": "^2",

146
src-tauri/Cargo.lock generated
View File

@@ -8,9 +8,11 @@ version = "0.1.0"
dependencies = [
"blowfish",
"byteorder",
"futures-util",
"id3",
"md5",
"metaflac",
"reqwest",
"serde",
"serde_json",
"tauri",
@@ -22,6 +24,7 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-sql",
"tauri-plugin-store",
"tokio",
]
[[package]]
@@ -659,7 +662,7 @@ dependencies = [
"bitflags 2.9.4",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -1192,6 +1195,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -1199,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -1213,6 +1225,12 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1844,6 +1862,22 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.17"
@@ -2464,6 +2498,23 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2858,6 +2909,50 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "openssl"
version = "0.10.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
dependencies = [
"bitflags 2.9.4",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -3579,10 +3674,12 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
"log",
"mime",
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
@@ -3593,6 +3690,7 @@ dependencies = [
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-util",
"tower",
@@ -3755,6 +3853,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3818,6 +3925,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.9.4",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -4116,7 +4246,7 @@ dependencies = [
"bytemuck",
"cfg_aliases",
"core-graphics",
"foreign-types",
"foreign-types 0.5.0",
"js-sys",
"log",
"objc2 0.5.2",
@@ -5085,6 +5215,16 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"

View File

@@ -33,4 +33,7 @@ tauri-plugin-process = "2"
blowfish = "0.9"
md5 = "0.7"
byteorder = "1.5.0"
reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
futures-util = "0.3.31"

View File

@@ -55,6 +55,9 @@
},
{
"url": "http://*.dzcdn.net/**"
},
{
"url": "https://lrclib.net/**"
}
]
},

View File

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

View File

@@ -36,7 +36,7 @@ fn read_audio_metadata(path: String) -> Result<metadata::AudioMetadata, String>
metadata::read_audio_metadata(&path)
}
/// Decrypt Deezer track data
/// Decrypt Deezer track data (legacy - kept for backwards compatibility)
#[tauri::command]
async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>, String> {
// Run decryption on a background thread to avoid blocking the UI
@@ -49,6 +49,129 @@ async fn decrypt_deezer_track(data: Vec<u8>, track_id: String) -> Result<Vec<u8>
Ok(result)
}
/// Download and decrypt a Deezer track, streaming directly to disk
#[tauri::command]
async fn download_and_decrypt_track(
url: String,
track_id: String,
output_path: String,
is_encrypted: bool,
window: tauri::Window,
) -> Result<(), String> {
use 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)]
pub fn run() {
let library_migrations = vec![Migration {
@@ -171,7 +294,8 @@ pub fn run() {
greet,
tag_audio_file,
read_audio_metadata,
decrypt_deezer_track
decrypt_deezer_track,
download_and_decrypt_track
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;
}

View File

@@ -2,6 +2,7 @@ import { readTextFile, exists, readDir } from '@tauri-apps/plugin-fs';
import { invoke } from '@tauri-apps/api/core';
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
import { findAlbumArt } from './album';
import { sanitizeFilename } from '$lib/services/deezer/paths';
/**
* Get audio format from file extension
@@ -228,7 +229,8 @@ export async function findPlaylistCoverFallback(
}
// Construct album folder path following the same structure as downloader
const albumPath = `${musicFolder}/${albumArtist}/${album}`;
// Must use sanitized paths to match how files are actually saved on disk
const albumPath = `${musicFolder}/${sanitizeFilename(albumArtist)}/${sanitizeFilename(album)}`;
try {
// Check if album folder exists and has cover art

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 220 B

After

Width:  |  Height:  |  Size: 344 B

View File

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

After

Width:  |  Height:  |  Size: 331 B

View File

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

After

Width:  |  Height:  |  Size: 311 B

View File

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

After

Width:  |  Height:  |  Size: 358 B

View File

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

After

Width:  |  Height:  |  Size: 356 B

View File

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

After

Width:  |  Height:  |  Size: 306 B

View File

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

After

Width:  |  Height:  |  Size: 353 B