feat(playlists): add local playlist enhancements

- Add playlist settings table for storing local-only features per playlist
- Add playlist local tracks table for mixing local tracks into Qobuz playlists
- Implement search within playlist (filters by title, artist, album)
- Add custom sorting (by title, artist, album, duration, ascending/descending)
- Add custom artwork selection with file picker
- Create .env.example for Last.fm credentials setup
- Update README with environment configuration instructions
This commit is contained in:
vicrodh committed 2026-01-09 13:51:54 -06:00
1 parent 24f551b571
commit f7ca1a9557
7 files changed
+888 -6

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Last.fm API Configuration
# Create your own Last.fm API application at: https://www.last.fm/api/account/create
# These credentials are required for scrobbling functionality
LAST_FM_API_KEY=your_api_key_here
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
LAST_FM_APP_NAME=your_app_name
LAST_FM_APP_REGISTER=your_lastfm_username
+26
View File
@@ -91,6 +91,32 @@ npm run tauri dev
npm run tauri build
```
### Environment Variables
Copy the example environment file and configure it with your credentials:
```bash
cp .env.example .env
```
#### Last.fm Integration (Optional)
To enable Last.fm scrobbling, you need to create a Last.fm API application:
1. Go to [Last.fm API Account](https://www.last.fm/api/account/create)
2. Create a new application (any name works)
3. Copy your API Key and Shared Secret
4. Update your `.env` file:
```env
LAST_FM_API_KEY=your_api_key_here
LAST_FM_API_SHARED_SECRET=your_shared_secret_here
LAST_FM_APP_NAME=your_app_name
LAST_FM_APP_REGISTER=your_lastfm_username
```
The application will work without Last.fm credentials, but scrobbling will be disabled.
## Project Structure
```
+9
View File
@@ -183,6 +183,15 @@ pub fn run() {
library::commands::library_clear,
library::commands::library_get_track,
library::commands::library_play_track,
// Playlist local settings commands
library::commands::playlist_get_settings,
library::commands::playlist_save_settings,
library::commands::playlist_set_sort,
library::commands::playlist_set_artwork,
library::commands::playlist_add_local_track,
library::commands::playlist_remove_local_track,
library::commands::playlist_get_local_tracks,
library::commands::playlist_clear_local_tracks,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+114
View File
@@ -367,3 +367,117 @@ pub async fn library_play_track(
Ok(())
}
// === Playlist Local Settings ===
use crate::library::PlaylistSettings;
/// Get playlist settings by Qobuz playlist ID
#[tauri::command]
pub async fn playlist_get_settings(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<Option<PlaylistSettings>, String> {
log::info!("Command: playlist_get_settings {}", playlist_id);
let db = state.db.lock().await;
db.get_playlist_settings(playlist_id)
.map_err(|e| e.to_string())
}
/// Save or update playlist settings
#[tauri::command]
pub async fn playlist_save_settings(
settings: PlaylistSettings,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_save_settings {}", settings.qobuz_playlist_id);
let db = state.db.lock().await;
db.save_playlist_settings(&settings)
.map_err(|e| e.to_string())
}
/// Update playlist sort settings
#[tauri::command]
pub async fn playlist_set_sort(
playlist_id: u64,
sort_by: String,
sort_order: String,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_set_sort {} {} {}", playlist_id, sort_by, sort_order);
let db = state.db.lock().await;
db.update_playlist_sort(playlist_id, &sort_by, &sort_order)
.map_err(|e| e.to_string())
}
/// Update playlist custom artwork
#[tauri::command]
pub async fn playlist_set_artwork(
playlist_id: u64,
artwork_path: Option<String>,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_set_artwork {}", playlist_id);
let db = state.db.lock().await;
db.update_playlist_artwork(playlist_id, artwork_path.as_deref())
.map_err(|e| e.to_string())
}
/// Add a local track to a playlist
#[tauri::command]
pub async fn playlist_add_local_track(
playlist_id: u64,
local_track_id: i64,
position: i32,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_add_local_track {} track {}", playlist_id, local_track_id);
let db = state.db.lock().await;
db.add_local_track_to_playlist(playlist_id, local_track_id, position)
.map_err(|e| e.to_string())
}
/// Remove a local track from a playlist
#[tauri::command]
pub async fn playlist_remove_local_track(
playlist_id: u64,
local_track_id: i64,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_remove_local_track {} track {}", playlist_id, local_track_id);
let db = state.db.lock().await;
db.remove_local_track_from_playlist(playlist_id, local_track_id)
.map_err(|e| e.to_string())
}
/// Get all local tracks in a playlist
#[tauri::command]
pub async fn playlist_get_local_tracks(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<Vec<LocalTrack>, String> {
log::info!("Command: playlist_get_local_tracks {}", playlist_id);
let db = state.db.lock().await;
db.get_playlist_local_tracks(playlist_id)
.map_err(|e| e.to_string())
}
/// Clear all local tracks from a playlist
#[tauri::command]
pub async fn playlist_clear_local_tracks(
playlist_id: u64,
state: State<'_, LibraryState>,
) -> Result<(), String> {
log::info!("Command: playlist_clear_local_tracks {}", playlist_id);
let db = state.db.lock().await;
db.clear_playlist_local_tracks(playlist_id)
.map_err(|e| e.to_string())
}
+339
View File
@@ -70,6 +70,32 @@ impl LibraryDatabase {
CREATE INDEX IF NOT EXISTS idx_tracks_album_artist ON local_tracks(album_artist);
CREATE INDEX IF NOT EXISTS idx_tracks_file_path ON local_tracks(file_path);
CREATE INDEX IF NOT EXISTS idx_tracks_title ON local_tracks(title);
-- Playlist local settings (enhances remote Qobuz playlists)
CREATE TABLE IF NOT EXISTS playlist_settings (
qobuz_playlist_id INTEGER PRIMARY KEY,
custom_artwork_path TEXT,
sort_by TEXT DEFAULT 'default',
sort_order TEXT DEFAULT 'asc',
last_search_query TEXT,
notes TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Local tracks added to playlists (mixed with remote Qobuz tracks)
CREATE TABLE IF NOT EXISTS playlist_local_tracks (
id INTEGER PRIMARY KEY,
qobuz_playlist_id INTEGER NOT NULL,
local_track_id INTEGER NOT NULL,
position INTEGER NOT NULL,
added_at INTEGER NOT NULL,
FOREIGN KEY (local_track_id) REFERENCES local_tracks(id) ON DELETE CASCADE,
UNIQUE(qobuz_playlist_id, local_track_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_local_tracks_playlist
ON playlist_local_tracks(qobuz_playlist_id);
"#,
)
.map_err(|e| LibraryError::Database(format!("Failed to create schema: {}", e)))?;
@@ -435,3 +461,316 @@ pub struct LibraryStats {
pub total_duration_secs: u64,
pub total_size_bytes: u64,
}
/// Playlist local settings (enhances remote Qobuz playlists)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PlaylistSettings {
pub qobuz_playlist_id: u64,
pub custom_artwork_path: Option<String>,
pub sort_by: String,
pub sort_order: String,
pub last_search_query: Option<String>,
pub notes: Option<String>,
pub created_at: i64,
pub updated_at: i64,
}
impl Default for PlaylistSettings {
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,
custom_artwork_path: None,
sort_by: "default".to_string(),
sort_order: "asc".to_string(),
last_search_query: None,
notes: None,
created_at: now,
updated_at: now,
}
}
}
impl LibraryDatabase {
// === Playlist Settings ===
/// Get playlist settings by Qobuz playlist ID
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
FROM playlist_settings WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
|row| {
Ok(PlaylistSettings {
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
custom_artwork_path: row.get(1)?,
sort_by: row.get(2)?,
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)?,
})
},
).optional()
.map_err(|e| LibraryError::Database(format!("Failed to get playlist settings: {}", e)))?;
Ok(result)
}
/// Save or update playlist settings
pub fn save_playlist_settings(&self, settings: &PlaylistSettings) -> Result<(), LibraryError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
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)
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,
updated_at = excluded.updated_at",
params![
settings.qobuz_playlist_id as i64,
&settings.custom_artwork_path,
&settings.sort_by,
&settings.sort_order,
&settings.last_search_query,
&settings.notes,
settings.created_at,
now,
],
).map_err(|e| LibraryError::Database(format!("Failed to save playlist settings: {}", e)))?;
Ok(())
}
/// Update just the sort settings for a playlist
pub fn update_playlist_sort(&self, qobuz_playlist_id: u64, sort_by: &str, sort_order: &str) -> 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.sort_by = sort_by.to_string();
settings.sort_order = sort_order.to_string();
return self.save_playlist_settings(&settings);
}
self.conn.execute(
"UPDATE playlist_settings SET sort_by = ?1, sort_order = ?2, updated_at = ?3
WHERE qobuz_playlist_id = ?4",
params![sort_by, sort_order, now, qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to update playlist sort: {}", e)))?;
Ok(())
}
/// Update custom artwork path for a playlist
pub fn update_playlist_artwork(&self, qobuz_playlist_id: u64, artwork_path: Option<&str>) -> 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.custom_artwork_path = artwork_path.map(|s| s.to_string());
return self.save_playlist_settings(&settings);
}
self.conn.execute(
"UPDATE playlist_settings SET custom_artwork_path = ?1, updated_at = ?2
WHERE qobuz_playlist_id = ?3",
params![artwork_path, now, qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to update playlist artwork: {}", e)))?;
Ok(())
}
/// Update last search query for a playlist
pub fn update_playlist_search_query(&self, qobuz_playlist_id: u64, query: Option<&str>) -> 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.last_search_query = query.map(|s| s.to_string());
return self.save_playlist_settings(&settings);
}
self.conn.execute(
"UPDATE playlist_settings SET last_search_query = ?1, updated_at = ?2
WHERE qobuz_playlist_id = ?3",
params![query, now, qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to update playlist search query: {}", e)))?;
Ok(())
}
/// Delete playlist settings
pub fn delete_playlist_settings(&self, qobuz_playlist_id: u64) -> Result<(), LibraryError> {
self.conn.execute(
"DELETE FROM playlist_settings WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to delete playlist settings: {}", e)))?;
Ok(())
}
/// Get all playlist settings (for syncing/export)
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"
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
let settings = stmt.query_map([], |row| {
Ok(PlaylistSettings {
qobuz_playlist_id: row.get::<_, i64>(0)? as u64,
custom_artwork_path: row.get(1)?,
sort_by: row.get(2)?,
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)?,
})
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist settings: {}", e)))?;
settings.collect::<Result<Vec<_>, _>>()
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist settings: {}", e)))
}
// === Playlist Local Tracks ===
/// Add a local track to a playlist
pub fn add_local_track_to_playlist(&self, qobuz_playlist_id: u64, local_track_id: i64, 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);
self.conn.execute(
"INSERT OR REPLACE INTO playlist_local_tracks
(qobuz_playlist_id, local_track_id, position, added_at)
VALUES (?1, ?2, ?3, ?4)",
params![qobuz_playlist_id as i64, local_track_id, position, now],
).map_err(|e| LibraryError::Database(format!("Failed to add local track to playlist: {}", e)))?;
Ok(())
}
/// Remove a local track from a playlist
pub fn remove_local_track_from_playlist(&self, qobuz_playlist_id: u64, local_track_id: i64) -> Result<(), LibraryError> {
self.conn.execute(
"DELETE FROM playlist_local_tracks
WHERE qobuz_playlist_id = ?1 AND local_track_id = ?2",
params![qobuz_playlist_id as i64, local_track_id],
).map_err(|e| LibraryError::Database(format!("Failed to remove local track from playlist: {}", e)))?;
Ok(())
}
/// Get all local tracks in a playlist
pub fn get_playlist_local_tracks(&self, qobuz_playlist_id: u64) -> Result<Vec<LocalTrack>, LibraryError> {
let mut stmt = self.conn.prepare(
"SELECT t.id, t.file_path, t.title, t.artist, t.album, t.album_artist,
t.track_number, t.disc_number, t.year, t.genre, t.duration_secs,
t.format, t.bit_depth, t.sample_rate, t.channels, t.file_size_bytes,
t.cue_file_path, t.cue_start_secs, t.cue_end_secs, t.artwork_path,
t.last_modified, t.indexed_at, plt.position
FROM playlist_local_tracks plt
JOIN local_tracks t ON plt.local_track_id = t.id
WHERE plt.qobuz_playlist_id = ?1
ORDER BY plt.position ASC"
).map_err(|e| LibraryError::Database(format!("Failed to prepare statement: {}", e)))?;
let tracks = stmt.query_map(params![qobuz_playlist_id as i64], |row| {
Ok(LocalTrack {
id: row.get(0)?,
file_path: row.get(1)?,
title: row.get(2)?,
artist: row.get(3)?,
album: row.get(4)?,
album_artist: row.get(5)?,
track_number: row.get(6)?,
disc_number: row.get(7)?,
year: row.get(8)?,
genre: row.get(9)?,
duration_secs: row.get(10)?,
format: Self::parse_format(&row.get::<_, String>(11)?),
bit_depth: row.get(12)?,
sample_rate: row.get(13)?,
channels: row.get(14)?,
file_size_bytes: row.get(15)?,
cue_file_path: row.get(16)?,
cue_start_secs: row.get(17)?,
cue_end_secs: row.get(18)?,
artwork_path: row.get(19)?,
last_modified: row.get(20)?,
indexed_at: row.get(21)?,
})
}).map_err(|e| LibraryError::Database(format!("Failed to query playlist local tracks: {}", e)))?;
tracks.collect::<Result<Vec<_>, _>>()
.map_err(|e| LibraryError::Database(format!("Failed to collect playlist local tracks: {}", e)))
}
/// Get count of local tracks in a playlist
pub fn get_playlist_local_track_count(&self, qobuz_playlist_id: u64) -> Result<u32, LibraryError> {
let count: u32 = self.conn.query_row(
"SELECT COUNT(*) FROM playlist_local_tracks WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
|row| row.get(0),
).map_err(|e| LibraryError::Database(format!("Failed to count playlist local tracks: {}", e)))?;
Ok(count)
}
/// Update position of a local track in a playlist
pub fn update_local_track_position(&self, qobuz_playlist_id: u64, local_track_id: i64, new_position: i32) -> Result<(), LibraryError> {
self.conn.execute(
"UPDATE playlist_local_tracks SET position = ?1
WHERE qobuz_playlist_id = ?2 AND local_track_id = ?3",
params![new_position, qobuz_playlist_id as i64, local_track_id],
).map_err(|e| LibraryError::Database(format!("Failed to update local track position: {}", e)))?;
Ok(())
}
/// Clear all local tracks from a playlist
pub fn clear_playlist_local_tracks(&self, qobuz_playlist_id: u64) -> Result<(), LibraryError> {
self.conn.execute(
"DELETE FROM playlist_local_tracks WHERE qobuz_playlist_id = ?1",
params![qobuz_playlist_id as i64],
).map_err(|e| LibraryError::Database(format!("Failed to clear playlist local tracks: {}", e)))?;
Ok(())
}
}
+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};
pub use database::{LibraryDatabase, LibraryStats, PlaylistSettings};
pub use errors::LibraryError;
pub use metadata::MetadataExtractor;
pub use models::*;
@@ -1,6 +1,7 @@
<script lang="ts">
import { ArrowLeft, Play, Shuffle, Plus, ListMusic } from 'lucide-svelte';
import { ArrowLeft, Play, Shuffle, Plus, ListMusic, Search, X, ChevronDown, ImagePlus } from 'lucide-svelte';
import { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
import { onMount } from 'svelte';
import TrackRow from '../TrackRow.svelte';
@@ -42,6 +43,18 @@
samplingRate?: number;
}
interface PlaylistSettings {
qobuz_playlist_id: number;
custom_artwork_path?: string;
sort_by: string;
sort_order: string;
last_search_query?: string;
notes?: string;
}
type SortField = 'default' | 'title' | 'artist' | 'album' | 'duration';
type SortOrder = 'asc' | 'desc';
interface Props {
playlistId: number;
onBack: () => void;
@@ -56,8 +69,16 @@
let error = $state<string | null>(null);
let playBtnHovered = $state(false);
// Local settings state
let searchQuery = $state('');
let sortBy = $state<SortField>('default');
let sortOrder = $state<SortOrder>('asc');
let customArtworkPath = $state<string | null>(null);
let showSortMenu = $state(false);
onMount(() => {
loadPlaylist();
loadSettings();
});
async function loadPlaylist() {
@@ -90,6 +111,102 @@
}
}
async function loadSettings() {
try {
const settings = await invoke<PlaylistSettings | null>('playlist_get_settings', { playlistId });
if (settings) {
sortBy = (settings.sort_by as SortField) || 'default';
sortOrder = (settings.sort_order as SortOrder) || 'asc';
customArtworkPath = settings.custom_artwork_path || null;
searchQuery = settings.last_search_query || '';
}
} catch (err) {
console.error('Failed to load playlist settings:', err);
}
}
async function saveSort(field: SortField, order: SortOrder) {
sortBy = field;
sortOrder = order;
showSortMenu = false;
try {
await invoke('playlist_set_sort', { playlistId, sortBy: field, sortOrder: order });
} catch (err) {
console.error('Failed to save sort settings:', err);
}
}
async function selectCustomArtwork() {
try {
const selected = await open({
multiple: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif'] }]
});
if (selected && typeof selected === 'string') {
customArtworkPath = selected;
await invoke('playlist_set_artwork', { playlistId, artworkPath: selected });
}
} catch (err) {
console.error('Failed to select artwork:', err);
}
}
async function clearCustomArtwork() {
customArtworkPath = null;
try {
await invoke('playlist_set_artwork', { playlistId, artworkPath: null });
} catch (err) {
console.error('Failed to clear artwork:', err);
}
}
// Filtered and sorted tracks
let displayTracks = $derived.by(() => {
let result = [...tracks];
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(t =>
t.title.toLowerCase().includes(query) ||
(t.artist?.toLowerCase().includes(query)) ||
(t.album?.toLowerCase().includes(query))
);
}
// Sort
if (sortBy !== 'default') {
result.sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case 'title':
cmp = a.title.localeCompare(b.title);
break;
case 'artist':
cmp = (a.artist || '').localeCompare(b.artist || '');
break;
case 'album':
cmp = (a.album || '').localeCompare(b.album || '');
break;
case 'duration':
cmp = a.durationSeconds - b.durationSeconds;
break;
}
return sortOrder === 'desc' ? -cmp : cmp;
});
}
return result;
});
const sortOptions: { field: SortField; label: string }[] = [
{ field: 'default', label: 'Default' },
{ field: 'title', label: 'Title' },
{ field: 'artist', label: 'Artist' },
{ field: 'album', label: 'Album' },
{ field: 'duration', label: 'Duration' },
];
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -106,6 +223,10 @@
}
function getPlaylistImage(): string {
// Use custom artwork if set (convert to tauri asset URL)
if (customArtworkPath) {
return `asset://localhost/${encodeURIComponent(customArtworkPath)}`;
}
if (playlist?.images && playlist.images.length > 0) {
return playlist.images[0];
}
@@ -195,7 +316,8 @@
{:else if playlist}
<!-- Playlist Header -->
<div class="playlist-header">
<!-- Playlist Artwork -->
<!-- Playlist Artwork with customization -->
<div class="artwork-container">
<div class="artwork">
{#if getPlaylistImage()}
<img src={getPlaylistImage()} alt={playlist.name} />
@@ -204,6 +326,17 @@
<ListMusic size={64} />
</div>
{/if}
<div class="artwork-overlay">
<button class="artwork-btn" onclick={selectCustomArtwork} title="Set custom artwork">
<ImagePlus size={24} />
</button>
{#if customArtworkPath}
<button class="artwork-btn artwork-clear" onclick={clearCustomArtwork} title="Remove custom artwork">
<X size={20} />
</button>
{/if}
</div>
</div>
</div>
<!-- Playlist Metadata -->
@@ -244,6 +377,65 @@
</div>
</div>
<!-- Track List Controls -->
<div class="track-controls">
<!-- Search -->
<div class="search-container">
<Search size={16} class="search-icon" />
<input
type="text"
placeholder="Search in playlist..."
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => searchQuery = ''}>
<X size={14} />
</button>
{/if}
</div>
<!-- Sort dropdown -->
<div class="sort-container">
<button class="sort-btn" onclick={() => showSortMenu = !showSortMenu}>
<span>Sort: {sortOptions.find(o => o.field === sortBy)?.label}</span>
<ChevronDown size={14} class:rotated={showSortMenu} />
</button>
{#if showSortMenu}
<div class="sort-menu">
{#each sortOptions as option}
<button
class="sort-option"
class:active={sortBy === option.field}
onclick={() => saveSort(option.field, sortOrder)}
>
{option.label}
</button>
{/each}
<div class="sort-divider"></div>
<button
class="sort-option"
class:active={sortOrder === 'asc'}
onclick={() => saveSort(sortBy, 'asc')}
>
Ascending
</button>
<button
class="sort-option"
class:active={sortOrder === 'desc'}
onclick={() => saveSort(sortBy, 'desc')}
>
Descending
</button>
</div>
{/if}
</div>
<span class="track-count">
{displayTracks.length}{searchQuery ? ` / ${tracks.length}` : ''} tracks
</span>
</div>
<!-- Track List -->
<div class="track-list">
<div class="track-list-header">
@@ -253,9 +445,9 @@
<span class="col-duration">Duration</span>
</div>
{#each tracks as track (track.id)}
{#each displayTracks as track, idx (track.id)}
<TrackRow
number={track.number}
number={idx + 1}
title={track.title}
artist={track.artist}
duration={track.duration}
@@ -265,6 +457,12 @@
onclick={() => handleTrackClick(track)}
/>
{/each}
{#if displayTracks.length === 0 && searchQuery}
<div class="no-results">
<p>No tracks match "{searchQuery}"</p>
</div>
{/if}
</div>
{/if}
</div>
@@ -339,10 +537,14 @@
margin-bottom: 32px;
}
.artwork-container {
flex-shrink: 0;
}
.artwork {
width: 232px;
height: 232px;
flex-shrink: 0;
position: relative;
border-radius: 8px;
overflow: hidden;
background-color: var(--bg-tertiary);
@@ -364,6 +566,52 @@
color: var(--text-muted);
}
.artwork-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0;
transition: opacity 150ms ease;
}
.artwork:hover .artwork-overlay {
opacity: 1;
}
.artwork-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
color: white;
cursor: pointer;
transition: all 150ms ease;
}
.artwork-btn:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
}
.artwork-btn.artwork-clear {
width: 36px;
height: 36px;
background: rgba(255, 0, 0, 0.3);
border-color: rgba(255, 100, 100, 0.5);
}
.artwork-btn.artwork-clear:hover {
background: rgba(255, 0, 0, 0.5);
}
.metadata {
display: flex;
flex-direction: column;
@@ -495,4 +743,142 @@
.col-duration {
text-align: right;
}
/* Track Controls */
.track-controls {
display: flex;
align-items: center;
gap: 16px;
margin-top: 24px;
margin-bottom: 16px;
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 8px 12px;
flex: 1;
max-width: 300px;
}
.search-container :global(.search-icon) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.search-input::placeholder {
color: var(--text-muted);
}
.search-clear {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.search-clear:hover {
color: var(--text-primary);
}
.sort-container {
position: relative;
}
.sort-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background-color: var(--bg-tertiary);
border: none;
border-radius: 8px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: color 150ms ease;
}
.sort-btn:hover {
color: var(--text-primary);
}
.sort-btn :global(.rotated) {
transform: rotate(180deg);
}
.sort-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background-color: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
padding: 4px;
min-width: 140px;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.sort-option {
display: block;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--text-secondary);
font-size: 13px;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: all 150ms ease;
}
.sort-option:hover {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
.sort-option.active {
color: var(--accent-primary);
}
.sort-divider {
height: 1px;
background-color: var(--bg-tertiary);
margin: 4px 0;
}
.track-count {
margin-left: auto;
font-size: 13px;
color: var(--text-muted);
}
.no-results {
padding: 48px;
text-align: center;
color: var(--text-muted);
}
.no-results p {
margin: 0;
}
</style>