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:
vicrodh committed 2026-01-11 20:37:23 -06:00
1 parent 7affd2bf13
commit 3fb9ee1381
8 files changed
+874 -33

No files matched your search

+8
View File
@@ -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,
+92 -1
View File
@@ -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
+267 -9
View File
@@ -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
+1 -1
View File
@@ -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::*;
+113
View File
@@ -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>
+377 -10
View File
@@ -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;
+12 -8
View File
@@ -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?.();
};
});
+4 -4
View File
@@ -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);
}
}}