mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 11:41:02 +00:00
Compare commits
8 Commits
25ce2d676e
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| a4f5bdd7a8 | |||
| 456f854863 | |||
| 3118d969c6 | |||
| 17b6f7958e | |||
| cba49ce411 | |||
| 369ea9df02 | |||
| ca5f79b23a | |||
| 8fb27b1acd |
82
CHANGELOG.md
Normal file
82
CHANGELOG.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0] - 2025-10-05
|
||||
|
||||
### 🎉 First Alpha Release
|
||||
|
||||
This is the very first usable build of Shark! - a retro Windows 98-styled music player and downloader. It's rough around the edges and likely contains uncaught bugs, but the core functionality is working.
|
||||
|
||||
### Features
|
||||
|
||||
**Local Library Management**
|
||||
- SQLite-backed library cache with automatic scanning
|
||||
- Album views with cover art
|
||||
- Track listing and playback
|
||||
|
||||
**Music Player**
|
||||
- Now playing panel with playback controls
|
||||
- Volume control with triangle slider
|
||||
- Queue tracks from context menus
|
||||
- Can show synced lyrics when the window is enlarged
|
||||
- Can be hidden or shown
|
||||
|
||||
**Deezer Integration**
|
||||
- Browse and download user favorites (tracks, albums, playlists)
|
||||
- Search for tracks, albums, and artists
|
||||
- Playlist caching with SQLite
|
||||
- Alternative format fallback for unavailable lossless content
|
||||
|
||||
**Download Queue**
|
||||
- Track and playlist downloads progress
|
||||
- Automatic audio decryption
|
||||
- Metadata tagging (ID3 for MP3, FLAC tags)
|
||||
- Cover art embedding
|
||||
- Existence checks to avoid re-downloading
|
||||
|
||||
**Lyrics**
|
||||
- Automatic lyrics fetching during downloads
|
||||
- LRCLIB integration for manual lyric fetching
|
||||
- Scan library for missing lyrics
|
||||
|
||||
**UI/UX**
|
||||
- Windows 98 (ish) dark theme throughout
|
||||
- Custom window decorations for OS parity
|
||||
- Bottom status bar for notifications
|
||||
- Onboarding prompts for folder selection
|
||||
- Double-click interactions
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- This is an early version - expect bugs and rough edges
|
||||
- Limited error handling in some edge cases
|
||||
- UI polish needed in various areas
|
||||
- Some features may not work as expected
|
||||
- Breaking changes likely in future releases
|
||||
- Lack of loading state means pages initially show no data
|
||||
- No dynamic routes for artists (yet)
|
||||
|
||||
### Known Bugs
|
||||
|
||||
- Context menus show inappropriate options sometimes
|
||||
- Now Playing section causes layout jitter on nav
|
||||
- Now Playing section causes title bar vertical shift
|
||||
- Batch lyrics downloading is less reliable than individual
|
||||
- Track scrubbing causes lyrics desync
|
||||
- Stopping (not pausing) a track and starting the same one resumes progress
|
||||
- Album year is not populated in Library table
|
||||
- Not all tables clip text overflow in certain columns
|
||||
- Album fetching edge cases (`Doja Cat` and `K/DA`)
|
||||
- Download interruptions don't always clean up temp files
|
||||
- LRCLIB service page indexes temp folder
|
||||
|
||||
### Technical Details
|
||||
|
||||
- Built with Tauri 2 + SvelteKit 2 + Svelte 5
|
||||
- Rust backend for audio processing and decryption
|
||||
- SQLite for caching and library management
|
||||
- File-based storage (FLAC/MP3 + metadata)
|
||||
- Playlists are stored as .m3u8 files with relative paths to tracks
|
||||
|
||||
### Notes
|
||||
|
||||
This release represents 5 days of development and ~80 commits. The app is functional enough for basic use but still has plenty of rough edges. Use at your own risk and expect possible breaking changes as development continues.
|
||||
3
bun.lock
3
bun.lock
@@ -10,6 +10,7 @@
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-process": "~2",
|
||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
@@ -187,6 +188,8 @@
|
||||
|
||||
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-B0LShOYae4CZjN8leiNDbnfjSrTwoZakqKaWpfoH6nXiJwt6Rgj6RnVIffG3DoJiKsffRhMkjmBV9VeilSb4TA=="],
|
||||
|
||||
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ty5V8XDUIFbSnrk3zsFoP3kzN+vAufYzalJSlmrVhQTImIZa1aL1a03bOaP2vuBvfR+WDRC6NgV2xBl8G07d+w=="],
|
||||
|
||||
"@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="],
|
||||
|
||||
"@tauri-apps/plugin-sql": ["@tauri-apps/plugin-sql@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-JYwIocfsLaDWa41LMiZWuzts7yCJR+EpZPRmgpO7Gd7XiAS9S67dKz306j/k/d9XntB0YopMRBol2OIWMschuA=="],
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@tauri-apps/plugin-fs": "^2.4.2",
|
||||
"@tauri-apps/plugin-http": "~2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-os": "~2",
|
||||
"@tauri-apps/plugin-process": "~2",
|
||||
"@tauri-apps/plugin-sql": "^2.3.0",
|
||||
"@tauri-apps/plugin-store": "~2",
|
||||
|
||||
50
src-tauri/Cargo.lock
generated
50
src-tauri/Cargo.lock
generated
@@ -21,6 +21,7 @@ dependencies = [
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-sql",
|
||||
"tauri-plugin-store",
|
||||
@@ -1471,6 +1472,16 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gethostname"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.1.16"
|
||||
@@ -2969,6 +2980,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_info"
|
||||
version = "3.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"plist",
|
||||
"serde",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
version = "0.18.3"
|
||||
@@ -4609,6 +4632,15 @@ dependencies = [
|
||||
"syn 2.0.106",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.6.1"
|
||||
@@ -4919,6 +4951,24 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-os"
|
||||
version = "2.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77a1c77ebf6f20417ab2a74e8c310820ba52151406d0c80fbcea7df232e3f6ba"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"log",
|
||||
"os_info",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serialize-to-javascript",
|
||||
"sys-locale",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.0"
|
||||
|
||||
@@ -36,4 +36,5 @@ byteorder = "1.5.0"
|
||||
reqwest = { version = "0.12.23", features = ["stream", "rustls-tls"] }
|
||||
tokio = { version = "1.47.1", features = ["fs", "io-util"] }
|
||||
futures-util = "0.3.31"
|
||||
tauri-plugin-os = "2"
|
||||
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-path",
|
||||
"allow": [
|
||||
{
|
||||
"path": "$APPDATA"
|
||||
},
|
||||
{
|
||||
"path": "$APPCONFIG"
|
||||
},
|
||||
{
|
||||
"path": "$APPLOCALDATA"
|
||||
}
|
||||
]
|
||||
},
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
@@ -63,6 +77,7 @@
|
||||
},
|
||||
"sql:default",
|
||||
"sql:allow-execute",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"os:default"
|
||||
]
|
||||
}
|
||||
@@ -23,8 +23,7 @@ pub fn generate_blowfish_key(track_id: &str) -> Vec<u8> {
|
||||
|
||||
/// Decrypt a single 2048-byte chunk using Blowfish CBC
|
||||
pub fn decrypt_chunk(chunk: &[u8], blowfish_key: &[u8]) -> Vec<u8> {
|
||||
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key)
|
||||
.expect("Invalid key length");
|
||||
let cipher = Blowfish::<BigEndian>::new_from_slice(blowfish_key).expect("Invalid key length");
|
||||
|
||||
let mut result = chunk.to_vec();
|
||||
let iv = [0u8, 1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use tauri_plugin_sql::{Migration, MigrationKind};
|
||||
|
||||
mod tagger;
|
||||
mod metadata;
|
||||
mod deezer_crypto;
|
||||
mod metadata;
|
||||
mod tagger;
|
||||
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
#[tauri::command]
|
||||
@@ -58,10 +58,10 @@ async fn download_and_decrypt_track(
|
||||
is_encrypted: bool,
|
||||
window: tauri::Window,
|
||||
) -> Result<(), String> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::fs::File;
|
||||
use deezer_crypto::StreamingDecryptor;
|
||||
use tauri::Emitter;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// Build HTTP client
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -117,11 +117,14 @@ async fn download_and_decrypt_track(
|
||||
|
||||
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
|
||||
}));
|
||||
let _ = window.emit(
|
||||
"download-progress",
|
||||
serde_json::json!({
|
||||
"downloaded": downloaded_bytes,
|
||||
"total": total_size as u64,
|
||||
"percentage": percentage
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,11 +157,14 @@ async fn download_and_decrypt_track(
|
||||
|
||||
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
|
||||
}));
|
||||
let _ = window.emit(
|
||||
"download-progress",
|
||||
serde_json::json!({
|
||||
"downloaded": downloaded_bytes,
|
||||
"total": total_size as u64,
|
||||
"percentage": percentage
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,10 +209,25 @@ pub fn run() {
|
||||
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
artist TEXT NOT NULL,
|
||||
album TEXT NOT NULL,
|
||||
duration INTEGER NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
has_lyrics INTEGER DEFAULT 0,
|
||||
last_scanned INTEGER,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
|
||||
CREATE INDEX IF NOT EXISTS idx_albums_artist_title ON albums(artist_name, title);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_has_lyrics ON tracks(has_lyrics);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_path ON tracks(path);
|
||||
",
|
||||
kind: MigrationKind::Up,
|
||||
}];
|
||||
@@ -278,6 +299,7 @@ pub fn run() {
|
||||
}];
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(
|
||||
tauri_plugin_sql::Builder::new()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use metaflac::Tag as FlacTag;
|
||||
use id3::{Tag as ID3Tag, TagLike};
|
||||
use metaflac::Tag as FlacTag;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -40,8 +40,8 @@ pub fn read_audio_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||
|
||||
/// Read metadata from MP3 file
|
||||
fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||
let tag = ID3Tag::read_from_path(path)
|
||||
.map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
||||
let tag =
|
||||
ID3Tag::read_from_path(path).map_err(|e| format!("Failed to read MP3 tags: {}", e))?;
|
||||
|
||||
Ok(AudioMetadata {
|
||||
title: tag.title().map(|s| s.to_string()),
|
||||
@@ -55,8 +55,8 @@ fn read_mp3_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||
|
||||
/// Read metadata from FLAC file
|
||||
fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||
let tag = FlacTag::read_from_path(path)
|
||||
.map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
||||
let tag =
|
||||
FlacTag::read_from_path(path).map_err(|e| format!("Failed to read FLAC tags: {}", e))?;
|
||||
|
||||
// Helper to get first value from vorbis comment
|
||||
let get_first = |key: &str| -> Option<String> {
|
||||
@@ -66,8 +66,7 @@ fn read_flac_metadata(path: &str) -> Result<AudioMetadata, String> {
|
||||
};
|
||||
|
||||
// Parse track number
|
||||
let track_number = get_first("TRACKNUMBER")
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
let track_number = get_first("TRACKNUMBER").and_then(|s| s.parse::<u32>().ok());
|
||||
|
||||
// Get duration from streaminfo block (in samples)
|
||||
let duration = tag.get_streaminfo().map(|info| {
|
||||
|
||||
@@ -24,6 +24,19 @@ export interface DbAlbum {
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface DbTrack {
|
||||
id: number;
|
||||
path: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
format: string;
|
||||
has_lyrics: number;
|
||||
last_scanned: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
/**
|
||||
@@ -231,3 +244,82 @@ export async function getLibraryStats(): Promise<{
|
||||
trackCount: trackResult[0]?.total || 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracks without lyrics (has_lyrics = 0)
|
||||
*/
|
||||
export async function getTracksWithoutLyrics(): Promise<DbTrack[]> {
|
||||
const database = await initDatabase();
|
||||
const tracks = await database.select<DbTrack[]>(
|
||||
'SELECT * FROM tracks WHERE has_lyrics = 0 ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE, title COLLATE NOCASE'
|
||||
);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a track (insert or update)
|
||||
*/
|
||||
export async function upsertTrack(track: {
|
||||
path: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
format: string;
|
||||
has_lyrics: boolean;
|
||||
}): Promise<void> {
|
||||
const database = await initDatabase();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
await database.execute(
|
||||
`INSERT INTO tracks (path, title, artist, album, duration, format, has_lyrics, last_scanned)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
title = $2,
|
||||
artist = $3,
|
||||
album = $4,
|
||||
duration = $5,
|
||||
format = $6,
|
||||
has_lyrics = $7,
|
||||
last_scanned = $8`,
|
||||
[
|
||||
track.path,
|
||||
track.title,
|
||||
track.artist,
|
||||
track.album,
|
||||
track.duration,
|
||||
track.format,
|
||||
track.has_lyrics ? 1 : 0,
|
||||
now
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last scan timestamp for lyrics
|
||||
*/
|
||||
export async function getLyricsScanTimestamp(): Promise<number | null> {
|
||||
const database = await initDatabase();
|
||||
const result = await database.select<{ last_scanned: number | null }[]>(
|
||||
'SELECT MAX(last_scanned) as last_scanned FROM tracks'
|
||||
);
|
||||
return result[0]?.last_scanned || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete tracks that are no longer in the provided paths
|
||||
*/
|
||||
export async function deleteTracksNotInPaths(paths: string[]): Promise<void> {
|
||||
if (paths.length === 0) {
|
||||
const database = await initDatabase();
|
||||
await database.execute('DELETE FROM tracks');
|
||||
return;
|
||||
}
|
||||
|
||||
const database = await initDatabase();
|
||||
const placeholders = paths.map((_, i) => `$${i + 1}`).join(',');
|
||||
await database.execute(
|
||||
`DELETE FROM tracks WHERE path NOT IN (${placeholders})`,
|
||||
paths
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { readDir, exists, readFile } from '@tauri-apps/plugin-fs';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import type { AudioFormat } from '$lib/types/track';
|
||||
import { upsertTrack, getTracksWithoutLyrics, type DbTrack } from '$lib/library/database';
|
||||
|
||||
export interface TrackWithoutLyrics {
|
||||
path: string;
|
||||
@@ -116,6 +117,7 @@ async function scanDirectoryForMissingLyrics(
|
||||
|
||||
/**
|
||||
* Scan the music library for tracks without .lrc files
|
||||
* Results are cached in the database
|
||||
*/
|
||||
export async function scanForTracksWithoutLyrics(
|
||||
musicFolderPath: string,
|
||||
@@ -129,9 +131,43 @@ export async function scanForTracksWithoutLyrics(
|
||||
|
||||
await scanDirectoryForMissingLyrics(musicFolderPath, results);
|
||||
|
||||
// Save results to database
|
||||
if (onProgress) {
|
||||
onProgress(results.length, results.length, 'Caching results...');
|
||||
}
|
||||
|
||||
for (const track of results) {
|
||||
await upsertTrack({
|
||||
path: track.path,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: Math.round(track.duration),
|
||||
format: track.format,
|
||||
has_lyrics: false
|
||||
});
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(results.length, results.length, `Found ${results.length} tracks without lyrics`);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cached tracks without lyrics from database
|
||||
*/
|
||||
export async function loadCachedTracksWithoutLyrics(): Promise<TrackWithoutLyrics[]> {
|
||||
const dbTracks = await getTracksWithoutLyrics();
|
||||
|
||||
return dbTracks.map((track: DbTrack) => ({
|
||||
path: track.path,
|
||||
filename: track.path.split('/').pop() || track.path,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: track.duration,
|
||||
format: track.format as AudioFormat
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||
import { encodeEmojis } from '$lib/utils/emoji';
|
||||
|
||||
export interface M3U8Track {
|
||||
duration: number; // in seconds
|
||||
@@ -22,14 +23,15 @@ export async function writeM3U8(
|
||||
tracks: M3U8Track[],
|
||||
playlistsFolder: string
|
||||
): Promise<string> {
|
||||
// Sanitize playlist name for filename
|
||||
const sanitizedName = sanitizeFilename(playlistName);
|
||||
// Encode emojis and sanitize playlist name for filename
|
||||
const encodedName = encodeEmojis(playlistName);
|
||||
const sanitizedName = sanitizeFilename(encodedName);
|
||||
const playlistPath = `${playlistsFolder}/${sanitizedName}.m3u8`;
|
||||
|
||||
// Build m3u8 content
|
||||
const lines: string[] = [
|
||||
'#EXTM3U',
|
||||
`#PLAYLIST:${playlistName}`,
|
||||
`#PLAYLIST:${encodedName}`,
|
||||
'#EXTENC:UTF-8',
|
||||
''
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core';
|
||||
import type { Track, AudioFormat, PlaylistWithTracks, TrackMetadata } from '$lib/types/track';
|
||||
import { findAlbumArt } from './album';
|
||||
import { sanitizeFilename } from '$lib/services/deezer/paths';
|
||||
import { decodeEmojis } from '$lib/utils/emoji';
|
||||
|
||||
/**
|
||||
* Get audio format from file extension
|
||||
@@ -36,6 +37,30 @@ export interface ParsedPlaylistTrack {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract playlist name from #PLAYLIST: metadata line in m3u8 file
|
||||
* Returns decoded emoji name, or undefined if not found
|
||||
*/
|
||||
export async function parsePlaylistName(playlistPath: string): Promise<string | undefined> {
|
||||
try {
|
||||
const content = await readTextFile(playlistPath);
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('#PLAYLIST:')) {
|
||||
const encodedName = trimmed.substring('#PLAYLIST:'.length);
|
||||
return decodeEmojis(encodedName);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.error('Error reading playlist name:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse M3U/M3U8 playlist file
|
||||
* Supports both basic M3U and extended M3U8 format
|
||||
@@ -52,7 +77,7 @@ export async function parsePlaylist(playlistPath: string): Promise<ParsedPlaylis
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip empty lines and non-EXTINF comments
|
||||
// Skip empty lines and comments (except EXTINF)
|
||||
if (!line || (line.startsWith('#') && !line.startsWith('#EXTINF'))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readDir, readFile } from '@tauri-apps/plugin-fs';
|
||||
import { parseBuffer } from 'music-metadata';
|
||||
import type { Album, ArtistWithAlbums, AudioFormat } from '$lib/types/track';
|
||||
import { parsePlaylistName } from './playlist';
|
||||
|
||||
export interface Artist {
|
||||
name: string;
|
||||
@@ -283,11 +284,18 @@ export async function scanPlaylists(playlistsFolderPath: string): Promise<Playli
|
||||
if (!entry.isDirectory) {
|
||||
const isPlaylist = entry.name.endsWith('.m3u') || entry.name.endsWith('.m3u8');
|
||||
if (isPlaylist) {
|
||||
// Remove extension for display name
|
||||
const playlistPath = `${playlistsFolderPath}/${entry.name}`;
|
||||
|
||||
// Try to read playlist name from #PLAYLIST: metadata (with emoji decoding)
|
||||
const metadataName = await parsePlaylistName(playlistPath);
|
||||
|
||||
// Fallback to filename without extension if no metadata found
|
||||
const nameWithoutExt = entry.name.replace(/\.(m3u8?|M3U8?)$/, '');
|
||||
const displayName = metadataName || nameWithoutExt;
|
||||
|
||||
playlists.push({
|
||||
name: nameWithoutExt,
|
||||
path: `${playlistsFolderPath}/${entry.name}`
|
||||
name: displayName,
|
||||
path: playlistPath
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,9 +122,6 @@ export class DeezerQueueManager {
|
||||
this.abortController = new AbortController();
|
||||
console.log('[DeezerQueueManager] Starting queue processor');
|
||||
|
||||
// Clear any stale currentJob from previous session
|
||||
await setCurrentJob(null);
|
||||
|
||||
try {
|
||||
await this.processQueue();
|
||||
} catch (error) {
|
||||
|
||||
@@ -50,11 +50,34 @@ export async function loadDownloadQueue(): Promise<void> {
|
||||
const queue = await store.get<Record<string, QueueItem>>('queue');
|
||||
const currentJob = await store.get<string>('currentJob');
|
||||
|
||||
downloadQueue.set({
|
||||
// Reset any items stuck in 'downloading' state from previous session
|
||||
const cleanedQueue = { ...(queue ?? {}) };
|
||||
let resetCount = 0;
|
||||
for (const id in cleanedQueue) {
|
||||
const item = cleanedQueue[id];
|
||||
if (item && item.status === 'downloading') {
|
||||
cleanedQueue[id] = {
|
||||
...item,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
currentTrack: undefined
|
||||
};
|
||||
resetCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (resetCount > 0) {
|
||||
console.log(`[DownloadQueue] Reset ${resetCount} interrupted download(s)`);
|
||||
}
|
||||
|
||||
const newState = {
|
||||
queueOrder: queueOrder ?? [],
|
||||
queue: queue ?? {},
|
||||
currentJob: currentJob ?? null
|
||||
});
|
||||
queue: cleanedQueue,
|
||||
currentJob: null // Always clear currentJob on load
|
||||
};
|
||||
|
||||
downloadQueue.set(newState);
|
||||
await saveQueue(newState);
|
||||
}
|
||||
|
||||
// Save queue to disk
|
||||
|
||||
82
src/lib/utils/emoji.ts
Normal file
82
src/lib/utils/emoji.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Emoji encoding/decoding utilities for filesystem-safe names
|
||||
* Converts emojis to [U+XXXXX] format for safe storage
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if a character is an emoji
|
||||
* Emojis are in Unicode ranges:
|
||||
* - Basic Emoticons: U+1F600 - U+1F64F
|
||||
* - Dingbats: U+2700 - U+27BF
|
||||
* - Miscellaneous Symbols: U+2600 - U+26FF
|
||||
* - Transport and Map: U+1F680 - U+1F6FF
|
||||
* - Supplemental Symbols: U+1F900 - U+1F9FF
|
||||
* - Flags: U+1F1E6 - U+1F1FF
|
||||
* - And many more...
|
||||
*/
|
||||
function isEmoji(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1F600 && codePoint <= 0x1F64F) || // Emoticons
|
||||
(codePoint >= 0x1F300 && codePoint <= 0x1F5FF) || // Misc Symbols and Pictographs
|
||||
(codePoint >= 0x1F680 && codePoint <= 0x1F6FF) || // Transport and Map
|
||||
(codePoint >= 0x1F900 && codePoint <= 0x1F9FF) || // Supplemental Symbols
|
||||
(codePoint >= 0x1F1E6 && codePoint <= 0x1F1FF) || // Flags
|
||||
(codePoint >= 0x2600 && codePoint <= 0x26FF) || // Misc symbols
|
||||
(codePoint >= 0x2700 && codePoint <= 0x27BF) || // Dingbats
|
||||
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) || // Variation Selectors
|
||||
(codePoint >= 0x1F000 && codePoint <= 0x1F02F) || // Mahjong Tiles
|
||||
(codePoint >= 0x1F0A0 && codePoint <= 0x1F0FF) || // Playing Cards
|
||||
(codePoint >= 0x1FA70 && codePoint <= 0x1FAFF) || // Symbols and Pictographs Extended-A
|
||||
(codePoint >= 0x200D) || // Zero Width Joiner (used in emoji sequences)
|
||||
(codePoint >= 0x231A && codePoint <= 0x231B) || // Watch, Hourglass
|
||||
(codePoint >= 0x23E9 && codePoint <= 0x23F3) || // Media controls
|
||||
(codePoint >= 0x25AA && codePoint <= 0x25AB) || // Geometric shapes
|
||||
(codePoint >= 0x25B6) || // Play button
|
||||
(codePoint >= 0x2934 && codePoint <= 0x2935) || // Arrows
|
||||
(codePoint >= 0x2B05 && codePoint <= 0x2B07) || // Arrows
|
||||
(codePoint >= 0x3030) || // Wavy dash
|
||||
(codePoint >= 0x303D) || // Part alternation mark
|
||||
(codePoint >= 0x3297) || // Japanese symbols
|
||||
(codePoint >= 0x3299) // Japanese symbols
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode emojis in text to [U+XXXXX] format
|
||||
* Example: "hello 👀" → "hello [U+1F440]"
|
||||
*/
|
||||
export function encodeEmojis(text: string): string {
|
||||
let result = '';
|
||||
|
||||
// Iterate through Unicode code points (not just chars, to handle surrogate pairs)
|
||||
for (const char of text) {
|
||||
const codePoint = char.codePointAt(0);
|
||||
|
||||
if (codePoint !== undefined && isEmoji(codePoint)) {
|
||||
// Convert to hex string with uppercase
|
||||
const hex = codePoint.toString(16).toUpperCase();
|
||||
result += `[U+${hex}]`;
|
||||
} else {
|
||||
result += char;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode [U+XXXXX] format back to emojis
|
||||
* Example: "hello [U+1F440]" → "hello 👀"
|
||||
*/
|
||||
export function decodeEmojis(text: string): string {
|
||||
// Match [U+XXXXX] patterns (hex can be 4-6 digits for Unicode)
|
||||
return text.replace(/\[U\+([0-9A-Fa-f]+)\]/g, (match, hex) => {
|
||||
try {
|
||||
const codePoint = parseInt(hex, 16);
|
||||
return String.fromCodePoint(codePoint);
|
||||
} catch {
|
||||
// If parsing fails, return the original match
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -42,24 +42,39 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="downloads-page">
|
||||
<div class="header">
|
||||
<h2>Downloads</h2>
|
||||
<div class="header-actions">
|
||||
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="downloads-wrapper">
|
||||
<h2 style="padding: 8px">Downloads</h2>
|
||||
|
||||
{#if queueItems.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No downloads in queue</p>
|
||||
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sunken-panel" style="overflow: auto; flex: 1;">
|
||||
<table class="interactive">
|
||||
<section class="downloads-content">
|
||||
<!--
|
||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||
-->
|
||||
<menu role="tablist">
|
||||
<li role="tab" aria-selected={true}>
|
||||
<button>Queue</button>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<div class="window tab-content" role="tabpanel">
|
||||
<div class="window-body">
|
||||
<div class="tab-header">
|
||||
<h4>{queueItems.length} item{queueItems.length !== 1 ? 's' : ''} in queue</h4>
|
||||
<div class="header-actions">
|
||||
<button onclick={handleClearCompleted} disabled={queueItems.every(i => i.status !== 'completed')}>
|
||||
Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if queueItems.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>No downloads in queue</p>
|
||||
<p class="help-text">Add tracks, albums, or playlists from services to start downloading</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="sunken-panel table-container">
|
||||
<table class="interactive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-title">Title</th>
|
||||
@@ -97,28 +112,62 @@
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.downloads-page {
|
||||
.downloads-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.downloads-content {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin-top: -2px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content .window-body {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--button-shadow, #808080);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-header h4 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
@@ -126,13 +175,21 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: light-dark(#666, #999);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
@@ -155,6 +212,9 @@
|
||||
|
||||
.col-title {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-artist {
|
||||
|
||||
@@ -124,8 +124,11 @@
|
||||
selectedArtistIndex = index;
|
||||
}
|
||||
|
||||
function handleAlbumClick(album: Album, index: number) {
|
||||
function handleAlbumClick(index: number) {
|
||||
selectedAlbumIndex = index;
|
||||
}
|
||||
|
||||
function handleAlbumDoubleClick(album: Album) {
|
||||
const artistEncoded = encodeURIComponent(album.artist);
|
||||
const albumEncoded = encodeURIComponent(album.title);
|
||||
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
||||
@@ -238,7 +241,8 @@
|
||||
{#each albums as album, i}
|
||||
<tr
|
||||
class:highlighted={selectedAlbumIndex === i}
|
||||
onclick={() => handleAlbumClick(album, i)}
|
||||
onclick={() => handleAlbumClick(i)}
|
||||
ondblclick={() => handleAlbumDoubleClick(album)}
|
||||
>
|
||||
<td class="cover-cell">
|
||||
{#if album.coverArtPath}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { settings } from '$lib/stores/settings';
|
||||
import { setSuccess, setWarning, setError, setInfo } from '$lib/stores/status';
|
||||
import { setSuccess, setWarning, setError, setInfo, removeStatus } from '$lib/stores/status';
|
||||
import { checkApiStatus, fetchAndSaveLyrics } from '$lib/services/lrclib';
|
||||
import { scanForTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
|
||||
import { scanForTracksWithoutLyrics, loadCachedTracksWithoutLyrics, type TrackWithoutLyrics } from '$lib/library/lyricScanner';
|
||||
import { getLyricsScanTimestamp, upsertTrack } from '$lib/library/database';
|
||||
import ContextMenu, { type MenuItem } from '$lib/components/ContextMenu.svelte';
|
||||
|
||||
type ViewMode = 'tracks' | 'info';
|
||||
@@ -16,11 +17,22 @@
|
||||
let tracks = $state<TrackWithoutLyrics[]>([]);
|
||||
let selectedTrackIndex = $state<number | null>(null);
|
||||
let contextMenu = $state<{ x: number; y: number; trackIndex: number } | null>(null);
|
||||
let lastScanned = $state<number | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
await checkApi();
|
||||
await loadCachedResults();
|
||||
});
|
||||
|
||||
async function loadCachedResults() {
|
||||
try {
|
||||
tracks = await loadCachedTracksWithoutLyrics();
|
||||
lastScanned = await getLyricsScanTimestamp();
|
||||
} catch (error) {
|
||||
console.error('[LRCLIB] Error loading cached results:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkApi() {
|
||||
checkingApi = true;
|
||||
apiAvailable = await checkApiStatus();
|
||||
@@ -45,6 +57,7 @@
|
||||
);
|
||||
|
||||
tracks = foundTracks;
|
||||
lastScanned = await getLyricsScanTimestamp();
|
||||
|
||||
if (tracks.length === 0) {
|
||||
setInfo('All tracks have lyrics!');
|
||||
@@ -72,6 +85,17 @@
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Update database to mark track as having lyrics
|
||||
await upsertTrack({
|
||||
path: track.path,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: Math.round(track.duration),
|
||||
format: track.format,
|
||||
has_lyrics: true
|
||||
});
|
||||
|
||||
if (result.instrumental) {
|
||||
setInfo(`Track marked as instrumental: ${track.title}`);
|
||||
} else if (result.hasLyrics) {
|
||||
@@ -92,13 +116,16 @@
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const totalTracks = tracks.length;
|
||||
|
||||
setInfo(`Fetching lyrics for ${tracks.length} tracks...`, 0);
|
||||
// Create a single status message that we'll update
|
||||
const statusId = setInfo(`Fetching lyrics... 0/${totalTracks}`, 0);
|
||||
|
||||
const tracksCopy = [...tracks];
|
||||
|
||||
for (let i = 0; i < tracksCopy.length; i++) {
|
||||
const track = tracksCopy[i];
|
||||
// Process tracks one by one, removing from array as we go
|
||||
let processedCount = 0;
|
||||
while (tracks.length > 0) {
|
||||
const track = tracks[0]; // Always process first track
|
||||
processedCount++;
|
||||
|
||||
try {
|
||||
const result = await fetchAndSaveLyrics(track.path, {
|
||||
@@ -109,23 +136,37 @@
|
||||
});
|
||||
|
||||
if (result.success && (result.hasLyrics || result.instrumental)) {
|
||||
// Update database to mark track as having lyrics
|
||||
await upsertTrack({
|
||||
path: track.path,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
duration: Math.round(track.duration),
|
||||
format: track.format,
|
||||
has_lyrics: true
|
||||
});
|
||||
|
||||
successCount++;
|
||||
// Remove from UI immediately on success
|
||||
tracks = tracks.slice(1);
|
||||
} else {
|
||||
failCount++;
|
||||
// Remove from list even if no lyrics found
|
||||
tracks = tracks.slice(1);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
// Remove from list on error
|
||||
tracks = tracks.slice(1);
|
||||
}
|
||||
|
||||
// Update progress
|
||||
if ((i + 1) % 10 === 0 || i === tracksCopy.length - 1) {
|
||||
setInfo(`Fetching lyrics... ${i + 1}/${tracksCopy.length}`, 0);
|
||||
}
|
||||
// Update progress message
|
||||
setInfo(`Fetching lyrics... ${processedCount}/${totalTracks}`, 0);
|
||||
}
|
||||
|
||||
// Rescan to update the list
|
||||
tracks = [];
|
||||
await handleScan();
|
||||
// Remove the progress message
|
||||
removeStatus(statusId);
|
||||
|
||||
// Show completion message
|
||||
if (successCount > 0 && failCount > 0) {
|
||||
@@ -194,7 +235,12 @@
|
||||
{#if viewMode === 'tracks'}
|
||||
<!-- Tracks View -->
|
||||
<div class="tab-header">
|
||||
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
|
||||
<div class="header-left">
|
||||
<span>{tracks.length} track{tracks.length !== 1 ? 's' : ''} found</span>
|
||||
{#if lastScanned}
|
||||
<span class="last-scanned">Last scanned: {new Date(lastScanned * 1000).toLocaleString()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<button onclick={handleScan} disabled={scanning || !$settings.musicFolder}>
|
||||
{scanning ? 'Scanning...' : 'Scan Library'}
|
||||
@@ -337,10 +383,15 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.last-scanned {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
import { clearDeezerCache } from '$lib/library/deezer-database';
|
||||
import { open, confirm, message } from '@tauri-apps/plugin-dialog';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { appDataDir } from '@tauri-apps/api/path';
|
||||
import { openPath } from '@tauri-apps/plugin-opener';
|
||||
|
||||
let currentMusicFolder = $state<string | null>(null);
|
||||
let currentPlaylistsFolder = $state<string | null>(null);
|
||||
@@ -122,34 +124,51 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openAppDataFolder() {
|
||||
try {
|
||||
const dataPath = await appDataDir();
|
||||
console.log('App data path:', dataPath);
|
||||
if (!dataPath) {
|
||||
throw new Error('Could not get app data directory path');
|
||||
}
|
||||
await openPath(dataPath);
|
||||
} catch (error) {
|
||||
console.error('Error opening app data folder:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await message('Error opening app data folder: ' + errorMessage, { title: 'Error', kind: 'error' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="padding: 8px;">
|
||||
<h2>Settings</h2>
|
||||
<!--
|
||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
||||
The <menu> element IS interactive (contains clickable <button> elements) and the
|
||||
role="tablist" properly describes the semantic purpose to assistive technology.
|
||||
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
||||
-->
|
||||
<menu role="tablist">
|
||||
<li role="tab" aria-selected={activeTab === 'library'}>
|
||||
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
||||
</li>
|
||||
<li role="tab" aria-selected={activeTab === 'deezer'}>
|
||||
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
|
||||
</li>
|
||||
<li role="tab" aria-selected={activeTab === 'advanced'}>
|
||||
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
|
||||
</li>
|
||||
</menu>
|
||||
<div class="settings-wrapper">
|
||||
<h2 style="padding: 8px">Settings</h2>
|
||||
|
||||
<div class="window" role="tabpanel">
|
||||
<div class="window-body">
|
||||
<section class="settings-content">
|
||||
<!--
|
||||
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
||||
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
||||
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
||||
The <menu> element IS interactive (contains clickable <button> elements) and the
|
||||
role="tablist" properly describes the semantic purpose to assistive technology.
|
||||
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
||||
-->
|
||||
<menu role="tablist">
|
||||
<li role="tab" aria-selected={activeTab === 'library'}>
|
||||
<a href="#library" onclick={(e) => { e.preventDefault(); activeTab = 'library'; }}>Library</a>
|
||||
</li>
|
||||
<li role="tab" aria-selected={activeTab === 'deezer'}>
|
||||
<a href="#deezer" onclick={(e) => { e.preventDefault(); activeTab = 'deezer'; }}>Deezer</a>
|
||||
</li>
|
||||
<li role="tab" aria-selected={activeTab === 'advanced'}>
|
||||
<a href="#advanced" onclick={(e) => { e.preventDefault(); activeTab = 'advanced'; }}>Advanced</a>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<div class="window tab-content" role="tabpanel">
|
||||
<div class="window-body">
|
||||
{#if activeTab === 'library'}
|
||||
<section class="tab-content">
|
||||
<section>
|
||||
<h3>Library Folders</h3>
|
||||
<div class="field-row-stacked">
|
||||
<label for="music-folder">Music Folder</label>
|
||||
@@ -197,7 +216,7 @@
|
||||
</div>
|
||||
</section>
|
||||
{:else if activeTab === 'deezer'}
|
||||
<section class="tab-content">
|
||||
<section>
|
||||
<h3>Deezer Download Settings</h3>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
@@ -319,7 +338,7 @@
|
||||
</fieldset>
|
||||
</section>
|
||||
{:else if activeTab === 'advanced'}
|
||||
<section class="tab-content">
|
||||
<section>
|
||||
<h3>Advanced Settings</h3>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
@@ -339,16 +358,28 @@
|
||||
<small class="help-text">This will delete all cached Deezer favorites data. The next time you visit the Deezer page, it will refetch from the API.</small>
|
||||
<button onclick={clearDeezerDatabase}>Clear Deezer Cache</button>
|
||||
</div>
|
||||
|
||||
<div class="field-row-stacked">
|
||||
<div class="setting-heading">Open App Data Folder</div>
|
||||
<small class="help-text">Opens the application data folder containing SQLite databases and other app data.</small>
|
||||
<button onclick={openAppDataFolder}>Open Folder</button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@@ -357,12 +388,27 @@
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
menu[role="tablist"] {
|
||||
margin-bottom: 0;
|
||||
.settings-content {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
margin: 0;
|
||||
margin-top: -2px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tab-content .window-body {
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.info-note {
|
||||
|
||||
Reference in New Issue
Block a user