feat(playlists): Add playlist management - filter, sort, view modes, collage artwork
- Add PlaylistCollage component with Qobuz-style overlapping covers - Add playlist_settings columns (hidden, position) with migration - Add playlist_stats table for play count tracking - Add backend commands for playlist management: - playlist_get_all_settings, playlist_set_hidden - playlist_set_position, playlist_reorder - playlist_get_stats, playlist_get_all_stats - playlist_increment_play_count - Update Sidebar with filter dropdown (All/Visible/Hidden) - Add sort dropdown (Name/Recent/Custom) - Add view toggle between list and grid modes - Fix TypeScript errors in +page.svelte and TitleBar.svelte
This commit is contained in:
1 parent
7affd2bf13
commit
3fb9ee1381
8 files changed
+874
-33
No files matched your search
@@ -319,6 +319,14 @@ pub fn run() {
|
||||
library::commands::playlist_remove_local_track,
|
||||
library::commands::playlist_get_local_tracks,
|
||||
library::commands::playlist_clear_local_tracks,
|
||||
// Playlist management commands
|
||||
library::commands::playlist_get_all_settings,
|
||||
library::commands::playlist_set_hidden,
|
||||
library::commands::playlist_set_position,
|
||||
library::commands::playlist_reorder,
|
||||
library::commands::playlist_get_stats,
|
||||
library::commands::playlist_get_all_stats,
|
||||
library::commands::playlist_increment_play_count,
|
||||
// Discogs artwork commands
|
||||
library::commands::discogs_has_credentials,
|
||||
library::commands::library_fetch_missing_artwork,
|
||||
|
||||
@@ -379,7 +379,7 @@ pub async fn library_play_track(
|
||||
|
||||
// === Playlist Local Settings ===
|
||||
|
||||
use crate::library::PlaylistSettings;
|
||||
use crate::library::{PlaylistSettings, PlaylistStats};
|
||||
|
||||
/// Get playlist settings by Qobuz playlist ID
|
||||
#[tauri::command]
|
||||
@@ -491,6 +491,97 @@ pub async fn playlist_clear_local_tracks(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get all playlist settings (for sidebar filter/sort)
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_all_settings(
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Vec<PlaylistSettings>, String> {
|
||||
log::info!("Command: playlist_get_all_settings");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.get_all_playlist_settings()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set playlist hidden status
|
||||
#[tauri::command]
|
||||
pub async fn playlist_set_hidden(
|
||||
playlist_id: u64,
|
||||
hidden: bool,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_set_hidden {} {}", playlist_id, hidden);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.set_playlist_hidden(playlist_id, hidden)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Set playlist position (for custom ordering)
|
||||
#[tauri::command]
|
||||
pub async fn playlist_set_position(
|
||||
playlist_id: u64,
|
||||
position: i32,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_set_position {} {}", playlist_id, position);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.set_playlist_position(playlist_id, position)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Bulk reorder playlists
|
||||
#[tauri::command]
|
||||
pub async fn playlist_reorder(
|
||||
playlist_ids: Vec<u64>,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: playlist_reorder {:?}", playlist_ids);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.reorder_playlists(&playlist_ids)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get playlist statistics
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_stats(
|
||||
playlist_id: u64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Option<PlaylistStats>, String> {
|
||||
log::info!("Command: playlist_get_stats {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.get_playlist_stats(playlist_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Get all playlist statistics (for sorting by play count)
|
||||
#[tauri::command]
|
||||
pub async fn playlist_get_all_stats(
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<Vec<PlaylistStats>, String> {
|
||||
log::info!("Command: playlist_get_all_stats");
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.get_all_playlist_stats()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Increment playlist play count (called when "Play All" is clicked)
|
||||
#[tauri::command]
|
||||
pub async fn playlist_increment_play_count(
|
||||
playlist_id: u64,
|
||||
state: State<'_, LibraryState>,
|
||||
) -> Result<PlaylistStats, String> {
|
||||
log::info!("Command: playlist_increment_play_count {}", playlist_id);
|
||||
|
||||
let db = state.db.lock().await;
|
||||
db.increment_playlist_play_count(playlist_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// === Discogs Artwork ===
|
||||
|
||||
/// Check if Discogs credentials are configured
|
||||
|
||||
@@ -24,6 +24,7 @@ impl LibraryDatabase {
|
||||
|
||||
let db = Self { conn };
|
||||
db.init_schema()?;
|
||||
db.run_migrations()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -79,6 +80,17 @@ impl LibraryDatabase {
|
||||
sort_order TEXT DEFAULT 'asc',
|
||||
last_search_query TEXT,
|
||||
notes TEXT,
|
||||
hidden INTEGER DEFAULT 0,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Playlist statistics (play counts, etc.)
|
||||
CREATE TABLE IF NOT EXISTS playlist_stats (
|
||||
qobuz_playlist_id INTEGER PRIMARY KEY,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
last_played_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -103,6 +115,52 @@ impl LibraryDatabase {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run schema migrations for existing databases
|
||||
fn run_migrations(&self) -> Result<(), LibraryError> {
|
||||
// Check if playlist_settings has the 'hidden' column (added in v2)
|
||||
let has_hidden: bool = self.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('playlist_settings') WHERE name = 'hidden'",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_hidden {
|
||||
log::info!("Running migration: adding hidden and position columns to playlist_settings");
|
||||
self.conn.execute_batch(
|
||||
"ALTER TABLE playlist_settings ADD COLUMN hidden INTEGER DEFAULT 0;
|
||||
ALTER TABLE playlist_settings ADD COLUMN position INTEGER DEFAULT 0;"
|
||||
).map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
|
||||
}
|
||||
|
||||
// Check if playlist_stats table exists
|
||||
let has_stats_table: bool = self.conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='playlist_stats'",
|
||||
[],
|
||||
|row| row.get::<_, i32>(0),
|
||||
)
|
||||
.map(|count| count > 0)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_stats_table {
|
||||
log::info!("Running migration: creating playlist_stats table");
|
||||
self.conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS playlist_stats (
|
||||
qobuz_playlist_id INTEGER PRIMARY KEY,
|
||||
play_count INTEGER DEFAULT 0,
|
||||
last_played_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);"
|
||||
).map_err(|e| LibraryError::Database(format!("Migration failed: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Folder Management ===
|
||||
|
||||
/// Add a folder to the library
|
||||
@@ -524,6 +582,18 @@ pub struct PlaylistSettings {
|
||||
pub sort_order: String,
|
||||
pub last_search_query: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub hidden: bool,
|
||||
pub position: i32,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Playlist statistics
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct PlaylistStats {
|
||||
pub qobuz_playlist_id: u64,
|
||||
pub play_count: u32,
|
||||
pub last_played_at: Option<i64>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
@@ -541,6 +611,24 @@ impl Default for PlaylistSettings {
|
||||
sort_order: "asc".to_string(),
|
||||
last_search_query: None,
|
||||
notes: None,
|
||||
hidden: false,
|
||||
position: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlaylistStats {
|
||||
fn default() -> Self {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
Self {
|
||||
qobuz_playlist_id: 0,
|
||||
play_count: 0,
|
||||
last_played_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -554,7 +642,7 @@ impl LibraryDatabase {
|
||||
pub fn get_playlist_settings(&self, qobuz_playlist_id: u64) -> Result<Option<PlaylistSettings>, LibraryError> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at
|
||||
last_search_query, notes, hidden, position, created_at, updated_at
|
||||
FROM playlist_settings WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
|row| {
|
||||
@@ -565,8 +653,10 @@ impl LibraryDatabase {
|
||||
sort_order: row.get(3)?,
|
||||
last_search_query: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
hidden: row.get::<_, i32>(6)? != 0,
|
||||
position: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
},
|
||||
).optional()
|
||||
@@ -585,14 +675,16 @@ impl LibraryDatabase {
|
||||
self.conn.execute(
|
||||
"INSERT INTO playlist_settings
|
||||
(qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
last_search_query, notes, hidden, position, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
||||
ON CONFLICT(qobuz_playlist_id) DO UPDATE SET
|
||||
custom_artwork_path = excluded.custom_artwork_path,
|
||||
sort_by = excluded.sort_by,
|
||||
sort_order = excluded.sort_order,
|
||||
last_search_query = excluded.last_search_query,
|
||||
notes = excluded.notes,
|
||||
hidden = excluded.hidden,
|
||||
position = excluded.position,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
settings.qobuz_playlist_id as i64,
|
||||
@@ -601,6 +693,8 @@ impl LibraryDatabase {
|
||||
&settings.sort_order,
|
||||
&settings.last_search_query,
|
||||
&settings.notes,
|
||||
settings.hidden as i32,
|
||||
settings.position,
|
||||
settings.created_at,
|
||||
now,
|
||||
],
|
||||
@@ -699,8 +793,8 @@ impl LibraryDatabase {
|
||||
pub fn get_all_playlist_settings(&self) -> Result<Vec<PlaylistSettings>, LibraryError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT qobuz_playlist_id, custom_artwork_path, sort_by, sort_order,
|
||||
last_search_query, notes, created_at, updated_at
|
||||
FROM playlist_settings ORDER BY updated_at DESC"
|
||||
last_search_query, notes, hidden, position, created_at, updated_at
|
||||
FROM playlist_settings ORDER BY position ASC, updated_at DESC"
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
|
||||
|
||||
let settings = stmt.query_map([], |row| {
|
||||
@@ -711,8 +805,10 @@ impl LibraryDatabase {
|
||||
sort_order: row.get(3)?,
|
||||
last_search_query: row.get(4)?,
|
||||
notes: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
hidden: row.get::<_, i32>(6)? != 0,
|
||||
position: row.get(7)?,
|
||||
created_at: row.get(8)?,
|
||||
updated_at: row.get(9)?,
|
||||
})
|
||||
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist settings: {}", e)))?;
|
||||
|
||||
@@ -720,6 +816,168 @@ impl LibraryDatabase {
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist settings: {}", e)))
|
||||
}
|
||||
|
||||
/// Update hidden status for a playlist
|
||||
pub fn set_playlist_hidden(&self, qobuz_playlist_id: u64, hidden: bool) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// First check if settings exist, if not create default
|
||||
let existing = self.get_playlist_settings(qobuz_playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = qobuz_playlist_id;
|
||||
settings.hidden = hidden;
|
||||
return self.save_playlist_settings(&settings);
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET hidden = ?1, updated_at = ?2
|
||||
WHERE qobuz_playlist_id = ?3",
|
||||
params![hidden as i32, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update playlist hidden: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update position for a playlist
|
||||
pub fn set_playlist_position(&self, qobuz_playlist_id: u64, position: i32) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// First check if settings exist, if not create default
|
||||
let existing = self.get_playlist_settings(qobuz_playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = qobuz_playlist_id;
|
||||
settings.position = position;
|
||||
return self.save_playlist_settings(&settings);
|
||||
}
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET position = ?1, updated_at = ?2
|
||||
WHERE qobuz_playlist_id = ?3",
|
||||
params![position, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to update playlist position: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk reorder playlists by setting positions
|
||||
pub fn reorder_playlists(&self, playlist_ids: &[u64]) -> Result<(), LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
for (index, &playlist_id) in playlist_ids.iter().enumerate() {
|
||||
// Ensure settings exist first
|
||||
let existing = self.get_playlist_settings(playlist_id)?;
|
||||
if existing.is_none() {
|
||||
let mut settings = PlaylistSettings::default();
|
||||
settings.qobuz_playlist_id = playlist_id;
|
||||
settings.position = index as i32;
|
||||
self.save_playlist_settings(&settings)?;
|
||||
} else {
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_settings SET position = ?1, updated_at = ?2
|
||||
WHERE qobuz_playlist_id = ?3",
|
||||
params![index as i32, now, playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to reorder playlists: {}", e)))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// === Playlist Stats ===
|
||||
|
||||
/// Get playlist stats
|
||||
pub fn get_playlist_stats(&self, qobuz_playlist_id: u64) -> Result<Option<PlaylistStats>, LibraryError> {
|
||||
let result = self.conn.query_row(
|
||||
"SELECT qobuz_playlist_id, play_count, last_played_at, created_at, updated_at
|
||||
FROM playlist_stats WHERE qobuz_playlist_id = ?1",
|
||||
params![qobuz_playlist_id as i64],
|
||||
|row| {
|
||||
Ok(PlaylistStats {
|
||||
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
|
||||
play_count: row.get::<_, i32>(1)? as u32,
|
||||
last_played_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
},
|
||||
).optional()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to get playlist stats: {}", e)))?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Increment play count and update last_played_at for a playlist
|
||||
pub fn increment_playlist_play_count(&self, qobuz_playlist_id: u64) -> Result<PlaylistStats, LibraryError> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
// Try to update existing, if none exists, insert new
|
||||
let existing = self.get_playlist_stats(qobuz_playlist_id)?;
|
||||
|
||||
if let Some(mut stats) = existing {
|
||||
stats.play_count += 1;
|
||||
stats.last_played_at = Some(now);
|
||||
stats.updated_at = now;
|
||||
|
||||
self.conn.execute(
|
||||
"UPDATE playlist_stats SET play_count = ?1, last_played_at = ?2, updated_at = ?3
|
||||
WHERE qobuz_playlist_id = ?4",
|
||||
params![stats.play_count as i32, now, now, qobuz_playlist_id as i64],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to increment play count: {}", e)))?;
|
||||
|
||||
Ok(stats)
|
||||
} else {
|
||||
let stats = PlaylistStats {
|
||||
qobuz_playlist_id,
|
||||
play_count: 1,
|
||||
last_played_at: Some(now),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
self.conn.execute(
|
||||
"INSERT INTO playlist_stats (qobuz_playlist_id, play_count, last_played_at, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![qobuz_playlist_id as i64, 1, now, now, now],
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to create playlist stats: {}", e)))?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all playlist stats (for sorting by play count)
|
||||
pub fn get_all_playlist_stats(&self) -> Result<Vec<PlaylistStats>, LibraryError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT qobuz_playlist_id, play_count, last_played_at, created_at, updated_at
|
||||
FROM playlist_stats ORDER BY play_count DESC"
|
||||
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
|
||||
|
||||
let stats = stmt.query_map([], |row| {
|
||||
Ok(PlaylistStats {
|
||||
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
|
||||
play_count: row.get::<_, i32>(1)? as u32,
|
||||
last_played_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
})
|
||||
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist stats: {}", e)))?;
|
||||
|
||||
stats.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist stats: {}", e)))
|
||||
}
|
||||
|
||||
// === Playlist Local Tracks ===
|
||||
|
||||
/// Add a local track to a playlist
|
||||
|
||||
@@ -13,7 +13,7 @@ pub mod scanner;
|
||||
|
||||
pub use commands::LibraryState;
|
||||
pub use cue_parser::{cue_to_tracks, CueParser, CueSheet, CueTrack};
|
||||
pub use database::{LibraryDatabase, LibraryStats, PlaylistSettings};
|
||||
pub use database::{LibraryDatabase, LibraryStats, PlaylistSettings, PlaylistStats};
|
||||
pub use errors::LibraryError;
|
||||
pub use metadata::MetadataExtractor;
|
||||
pub use models::*;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { Music } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
artworks: string[]; // Album art URLs from tracks
|
||||
size?: number; // Total width in px
|
||||
class?: string; // Additional CSS classes
|
||||
}
|
||||
|
||||
let {
|
||||
artworks = [],
|
||||
size = 120,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
// Get unique artworks (dedupe same album covers)
|
||||
const uniqueArtworks = $derived(() => {
|
||||
const seen = new Set<string>();
|
||||
return artworks.filter(art => {
|
||||
if (!art || seen.has(art)) return false;
|
||||
seen.add(art);
|
||||
return true;
|
||||
}).slice(0, 4);
|
||||
});
|
||||
|
||||
const count = $derived(uniqueArtworks().length);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="collage {className}"
|
||||
class:single={count === 1}
|
||||
class:empty={count === 0}
|
||||
style="--size: {size}px; --count: {count}"
|
||||
>
|
||||
{#if count === 0}
|
||||
<!-- Empty placeholder -->
|
||||
<div class="placeholder">
|
||||
<Music size={size * 0.3} />
|
||||
</div>
|
||||
{:else if count === 1}
|
||||
<!-- Single cover -->
|
||||
<img src={uniqueArtworks()[0]} alt="" class="single-cover" />
|
||||
{:else}
|
||||
<!-- Collage with overlap -->
|
||||
{#each uniqueArtworks() as art, i}
|
||||
<img
|
||||
src={art}
|
||||
alt=""
|
||||
class="cover"
|
||||
style="--index: {i}"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.collage {
|
||||
position: relative;
|
||||
width: var(--size);
|
||||
height: calc(var(--size) * 0.65);
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collage.single,
|
||||
.collage.empty {
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.single-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
/* Overlap calculation: each cover shifts right by percentage of container width */
|
||||
left: calc(var(--index) * (var(--size) * 0.22));
|
||||
/* First cover on top (highest z-index), last on bottom */
|
||||
z-index: calc(4 - var(--index));
|
||||
/* Shadow on right edge for depth */
|
||||
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Adjust for 2 covers - more spacing */
|
||||
.collage[style*="--count: 2"] .cover {
|
||||
left: calc(var(--index) * (var(--size) * 0.35));
|
||||
}
|
||||
|
||||
/* Adjust for 3 covers */
|
||||
.collage[style*="--count: 3"] .cover {
|
||||
left: calc(var(--index) * (var(--size) * 0.28));
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Search, Home, HardDrive, Music, Disc3, Mic2, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Download } from 'lucide-svelte';
|
||||
import { Search, Home, HardDrive, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Download, Filter, ArrowUpDown, LayoutGrid, List, GripVertical, EyeOff, Eye } from 'lucide-svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { onMount } from 'svelte';
|
||||
import NavigationItem from './NavigationItem.svelte';
|
||||
import UserCard from './UserCard.svelte';
|
||||
import PlaylistCollage from './PlaylistCollage.svelte';
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
name: string;
|
||||
tracks_count: number;
|
||||
images?: string[];
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface PlaylistSettings {
|
||||
qobuz_playlist_id: number;
|
||||
hidden: boolean;
|
||||
position: number;
|
||||
play_count?: number;
|
||||
}
|
||||
|
||||
type PlaylistFilter = 'all' | 'visible' | 'hidden';
|
||||
type PlaylistSort = 'name' | 'recent' | 'custom';
|
||||
type PlaylistViewMode = 'list' | 'grid';
|
||||
|
||||
interface Props {
|
||||
activeView: string;
|
||||
selectedPlaylistId?: number | null;
|
||||
@@ -40,10 +54,67 @@
|
||||
}: Props = $props();
|
||||
|
||||
let userPlaylists = $state<Playlist[]>([]);
|
||||
let playlistSettings = $state<Map<number, PlaylistSettings>>(new Map());
|
||||
let playlistsLoading = $state(false);
|
||||
let playlistsCollapsed = $state(false);
|
||||
let localLibraryCollapsed = $state(false);
|
||||
|
||||
// Playlist management state
|
||||
let playlistFilter = $state<PlaylistFilter>(
|
||||
(localStorage.getItem('qbz-playlist-filter') as PlaylistFilter) || 'visible'
|
||||
);
|
||||
let playlistSort = $state<PlaylistSort>(
|
||||
(localStorage.getItem('qbz-playlist-sort') as PlaylistSort) || 'name'
|
||||
);
|
||||
let playlistViewMode = $state<PlaylistViewMode>(
|
||||
(localStorage.getItem('qbz-playlist-view') as PlaylistViewMode) || 'list'
|
||||
);
|
||||
let showFilterMenu = $state(false);
|
||||
let showSortMenu = $state(false);
|
||||
|
||||
// Persist preferences
|
||||
$effect(() => {
|
||||
localStorage.setItem('qbz-playlist-filter', playlistFilter);
|
||||
});
|
||||
$effect(() => {
|
||||
localStorage.setItem('qbz-playlist-sort', playlistSort);
|
||||
});
|
||||
$effect(() => {
|
||||
localStorage.setItem('qbz-playlist-view', playlistViewMode);
|
||||
});
|
||||
|
||||
// Filtered and sorted playlists
|
||||
const filteredPlaylists = $derived.by(() => {
|
||||
let result = [...userPlaylists];
|
||||
|
||||
// Apply filter
|
||||
if (playlistFilter === 'visible') {
|
||||
result = result.filter(p => {
|
||||
const settings = playlistSettings.get(p.id);
|
||||
return !settings?.hidden;
|
||||
});
|
||||
} else if (playlistFilter === 'hidden') {
|
||||
result = result.filter(p => {
|
||||
const settings = playlistSettings.get(p.id);
|
||||
return settings?.hidden;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
if (playlistSort === 'name') {
|
||||
result.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} else if (playlistSort === 'custom') {
|
||||
result.sort((a, b) => {
|
||||
const posA = playlistSettings.get(a.id)?.position ?? 999;
|
||||
const posB = playlistSettings.get(b.id)?.position ?? 999;
|
||||
return posA - posB;
|
||||
});
|
||||
}
|
||||
// 'recent' keeps original order from API (already sorted by recent)
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// Expose playlists to parent via binding
|
||||
export function getPlaylists(): Playlist[] {
|
||||
return userPlaylists;
|
||||
@@ -55,6 +126,7 @@
|
||||
|
||||
onMount(() => {
|
||||
loadUserPlaylists();
|
||||
loadPlaylistSettings();
|
||||
});
|
||||
|
||||
async function loadUserPlaylists() {
|
||||
@@ -69,6 +141,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlaylistSettings() {
|
||||
try {
|
||||
const settings = await invoke<PlaylistSettings[]>('playlist_get_all_settings');
|
||||
const map = new Map<number, PlaylistSettings>();
|
||||
for (const s of settings) {
|
||||
map.set(s.qobuz_playlist_id, s);
|
||||
}
|
||||
playlistSettings = map;
|
||||
} catch (err) {
|
||||
// Command might not exist yet, that's ok
|
||||
console.debug('Failed to load playlist settings:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlaylistHidden(playlistId: number) {
|
||||
const current = playlistSettings.get(playlistId);
|
||||
const newHidden = !current?.hidden;
|
||||
try {
|
||||
await invoke('playlist_set_hidden', { playlistId, hidden: newHidden });
|
||||
// Update local state
|
||||
const updated = new Map(playlistSettings);
|
||||
updated.set(playlistId, { ...current, qobuz_playlist_id: playlistId, hidden: newHidden, position: current?.position ?? 0 });
|
||||
playlistSettings = updated;
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle playlist hidden:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewChange(view: string) {
|
||||
onNavigate(view);
|
||||
}
|
||||
@@ -123,6 +223,68 @@
|
||||
<div class="playlists-header">
|
||||
<div class="section-header">Playlists</div>
|
||||
<div class="header-actions">
|
||||
<!-- Filter dropdown -->
|
||||
<div class="dropdown-container">
|
||||
<button
|
||||
class="icon-btn"
|
||||
class:active={playlistFilter !== 'visible'}
|
||||
onclick={() => showFilterMenu = !showFilterMenu}
|
||||
title="Filter playlists"
|
||||
>
|
||||
{#if playlistFilter === 'hidden'}
|
||||
<EyeOff size={14} />
|
||||
{:else}
|
||||
<Filter size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if showFilterMenu}
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<button class="dropdown-item" class:selected={playlistFilter === 'all'} onclick={() => { playlistFilter = 'all'; showFilterMenu = false; }}>
|
||||
All
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={playlistFilter === 'visible'} onclick={() => { playlistFilter = 'visible'; showFilterMenu = false; }}>
|
||||
Visible
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={playlistFilter === 'hidden'} onclick={() => { playlistFilter = 'hidden'; showFilterMenu = false; }}>
|
||||
Hidden
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="dropdown-container">
|
||||
<button class="icon-btn" onclick={() => showSortMenu = !showSortMenu} title="Sort playlists">
|
||||
<ArrowUpDown size={14} />
|
||||
</button>
|
||||
{#if showSortMenu}
|
||||
<div class="dropdown-menu" role="menu">
|
||||
<button class="dropdown-item" class:selected={playlistSort === 'name'} onclick={() => { playlistSort = 'name'; showSortMenu = false; }}>
|
||||
Name (A-Z)
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={playlistSort === 'recent'} onclick={() => { playlistSort = 'recent'; showSortMenu = false; }}>
|
||||
Recent
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={playlistSort === 'custom'} onclick={() => { playlistSort = 'custom'; showSortMenu = false; }}>
|
||||
Custom Order
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- View toggle -->
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={() => playlistViewMode = playlistViewMode === 'list' ? 'grid' : 'list'}
|
||||
title={playlistViewMode === 'list' ? 'Grid view' : 'List view'}
|
||||
>
|
||||
{#if playlistViewMode === 'list'}
|
||||
<LayoutGrid size={14} />
|
||||
{:else}
|
||||
<List size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" onclick={onCreatePlaylist} title="New playlist">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
@@ -145,16 +307,57 @@
|
||||
{#if !playlistsCollapsed}
|
||||
{#if playlistsLoading}
|
||||
<div class="playlists-loading">Loading...</div>
|
||||
{:else if filteredPlaylists.length > 0}
|
||||
{#if playlistViewMode === 'list'}
|
||||
<!-- List View -->
|
||||
<div class="playlists-list">
|
||||
{#each filteredPlaylists as playlist (playlist.id)}
|
||||
{@const isHidden = playlistSettings.get(playlist.id)?.hidden}
|
||||
<button
|
||||
class="playlist-list-item"
|
||||
class:active={activeView === 'playlist' && selectedPlaylistId === playlist.id}
|
||||
class:hidden-playlist={isHidden}
|
||||
onclick={() => handlePlaylistClick(playlist)}
|
||||
>
|
||||
<PlaylistCollage artworks={playlist.images ?? []} size={36} />
|
||||
<div class="playlist-info">
|
||||
<span class="playlist-name">{playlist.name}</span>
|
||||
<span class="playlist-count">{playlist.tracks_count} tracks</span>
|
||||
</div>
|
||||
{#if playlistFilter === 'all' && isHidden}
|
||||
<EyeOff size={12} class="hidden-indicator" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Grid View -->
|
||||
<div class="playlists-grid">
|
||||
{#each filteredPlaylists as playlist (playlist.id)}
|
||||
{@const isHidden = playlistSettings.get(playlist.id)?.hidden}
|
||||
<button
|
||||
class="playlist-grid-item"
|
||||
class:active={activeView === 'playlist' && selectedPlaylistId === playlist.id}
|
||||
class:hidden-playlist={isHidden}
|
||||
onclick={() => handlePlaylistClick(playlist)}
|
||||
>
|
||||
<div class="grid-artwork">
|
||||
<PlaylistCollage artworks={playlist.images ?? []} size={100} />
|
||||
{#if playlistFilter === 'all' && isHidden}
|
||||
<div class="hidden-badge">
|
||||
<EyeOff size={12} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="playlist-name">{playlist.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if userPlaylists.length > 0}
|
||||
{#each userPlaylists as playlist (playlist.id)}
|
||||
<NavigationItem
|
||||
label={playlist.name}
|
||||
active={activeView === 'playlist' && selectedPlaylistId === playlist.id}
|
||||
onclick={() => handlePlaylistClick(playlist)}
|
||||
>
|
||||
{#snippet icon()}<ListMusic size={14} />{/snippet}
|
||||
</NavigationItem>
|
||||
{/each}
|
||||
<div class="no-playlists">
|
||||
{playlistFilter === 'hidden' ? 'No hidden playlists' : 'No visible playlists'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-playlists">No playlists yet</div>
|
||||
{/if}
|
||||
@@ -324,12 +527,176 @@
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
transition: color 150ms ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Dropdown menus */
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
min-width: 120px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Playlist list view */
|
||||
.playlists-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.playlist-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.playlist-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-list-item.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.playlist-list-item.hidden-playlist {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.playlist-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.playlist-name {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.playlist-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-list-item :global(.hidden-indicator) {
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Playlist grid view */
|
||||
.playlists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.playlist-grid-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.playlist-grid-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.playlist-grid-item.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.playlist-grid-item.hidden-playlist {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.grid-artwork {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hidden-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.playlist-grid-item .playlist-name {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlists-loading,
|
||||
.no-playlists {
|
||||
font-size: 12px;
|
||||
|
||||
@@ -6,19 +6,23 @@
|
||||
let isMaximized = $state(false);
|
||||
let appWindow: ReturnType<typeof getCurrentWindow>;
|
||||
|
||||
onMount(async () => {
|
||||
appWindow = getCurrentWindow();
|
||||
onMount(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
// Check initial maximized state
|
||||
isMaximized = await appWindow.isMaximized();
|
||||
(async () => {
|
||||
appWindow = getCurrentWindow();
|
||||
|
||||
// Listen for window state changes
|
||||
const unlisten = await appWindow.onResized(async () => {
|
||||
// Check initial maximized state
|
||||
isMaximized = await appWindow.isMaximized();
|
||||
});
|
||||
|
||||
// Listen for window state changes
|
||||
unlisten = await appWindow.onResized(async () => {
|
||||
isMaximized = await appWindow.isMaximized();
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
unlisten();
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1417,16 +1417,16 @@
|
||||
onToggleLyrics={toggleLyricsSidebar}
|
||||
lyricsActive={lyricsSidebarVisible}
|
||||
onArtistClick={() => {
|
||||
if (currentTrack.isLocal) {
|
||||
if (currentTrack?.isLocal) {
|
||||
showToast('Local track - search for artist in Search', 'info');
|
||||
} else if (currentTrack.artistId) {
|
||||
} else if (currentTrack?.artistId) {
|
||||
handleArtistClick(currentTrack.artistId);
|
||||
}
|
||||
}}
|
||||
onAlbumClick={() => {
|
||||
if (currentTrack.isLocal) {
|
||||
if (currentTrack?.isLocal) {
|
||||
navigateTo('library');
|
||||
} else if (currentTrack.albumId) {
|
||||
} else if (currentTrack?.albumId) {
|
||||
handleAlbumClick(currentTrack.albumId);
|
||||
}
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user