- PlaylistDetailView: outer slide-down (200ms), spinner fadeout, content slide-up - PlaylistManagerView: outer slide-down (200ms), spinner fadeout, content slide-up - Matches the FavoritesView transition pattern
2447 lines
71 KiB
Svelte
2447 lines
71 KiB
Svelte
<script lang="ts">
|
|
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus, GripVertical } from 'lucide-svelte';
|
|
import AlbumMenu from '../AlbumMenu.svelte';
|
|
import PlaylistCollage from '../PlaylistCollage.svelte';
|
|
import PlaylistModal from '../PlaylistModal.svelte';
|
|
import ViewTransition from '../ViewTransition.svelte';
|
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { open } from '@tauri-apps/plugin-dialog';
|
|
import TrackRow from '../TrackRow.svelte';
|
|
import PlaylistSuggestions from '../PlaylistSuggestions.svelte';
|
|
import { extractAdaptiveArtists } from '$lib/services/playlistSuggestionsService';
|
|
import { type OfflineCacheStatus } from '$lib/stores/offlineCacheState';
|
|
import {
|
|
subscribe as subscribeOffline,
|
|
getStatus as getOfflineStatus,
|
|
type OfflineStatus
|
|
} from '$lib/stores/offlineStore';
|
|
import { consumeContextTrackFocus, setPlaybackContext } from '$lib/stores/playbackContextStore';
|
|
import { t } from '$lib/i18n';
|
|
import { onMount, tick } from 'svelte';
|
|
|
|
interface PlaylistTrack {
|
|
id: number;
|
|
title: string;
|
|
duration: number;
|
|
track_number: number;
|
|
performer?: { id?: number; name: string };
|
|
album?: {
|
|
id: string;
|
|
title: string;
|
|
image: { small?: string; thumbnail?: string; large?: string };
|
|
label?: { id: number; name: string };
|
|
};
|
|
hires: boolean;
|
|
maximum_bit_depth?: number;
|
|
maximum_sampling_rate?: number;
|
|
isrc?: string;
|
|
playlist_track_id?: number; // Qobuz playlist-specific ID for removal
|
|
}
|
|
|
|
interface Playlist {
|
|
id: number;
|
|
name: string;
|
|
description?: string;
|
|
owner: { id: number; name: string };
|
|
images?: string[];
|
|
tracks_count: number;
|
|
duration: number;
|
|
is_public: boolean;
|
|
tracks?: { items: PlaylistTrack[]; total: number };
|
|
}
|
|
|
|
interface DisplayTrack {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
artist?: string;
|
|
album?: string;
|
|
albumArt?: string;
|
|
albumId?: string;
|
|
artistId?: number;
|
|
duration: string;
|
|
durationSeconds: number;
|
|
hires?: boolean;
|
|
bitDepth?: number;
|
|
samplingRate?: number;
|
|
isrc?: string;
|
|
isLocal?: boolean;
|
|
localTrackId?: number;
|
|
artworkPath?: string;
|
|
playlistTrackId?: number; // Qobuz playlist-specific ID for removal
|
|
label?: string; // Record label name from Qobuz
|
|
addedIndex?: number; // Original position in playlist (proxy for date added)
|
|
customPosition?: number; // User-defined position for custom arrange mode
|
|
}
|
|
|
|
// Local library track from backend
|
|
interface LocalLibraryTrack {
|
|
id: number;
|
|
file_path: string;
|
|
title: string;
|
|
artist: string;
|
|
album: string;
|
|
duration_secs: number;
|
|
format: string;
|
|
bit_depth?: number;
|
|
sample_rate: number;
|
|
artwork_path?: string;
|
|
}
|
|
|
|
// Local track with playlist position (for mixed ordering)
|
|
interface PlaylistLocalTrack {
|
|
id: number;
|
|
file_path: string;
|
|
title: string;
|
|
artist: string;
|
|
album: string;
|
|
duration_secs: number;
|
|
format: string;
|
|
bit_depth?: number;
|
|
sample_rate: number;
|
|
artwork_path?: string;
|
|
playlist_position: number;
|
|
}
|
|
|
|
interface PlaylistSettings {
|
|
qobuz_playlist_id: number;
|
|
custom_artwork_path?: string;
|
|
sort_by: string;
|
|
sort_order: string;
|
|
last_search_query?: string;
|
|
notes?: string;
|
|
hidden?: boolean;
|
|
position?: number;
|
|
is_favorite?: boolean;
|
|
folder_id?: string | null;
|
|
}
|
|
|
|
interface PlaylistStats {
|
|
qobuz_playlist_id: number;
|
|
play_count: number;
|
|
last_played_at?: number;
|
|
}
|
|
|
|
type SortField = 'default' | 'title' | 'artist' | 'album' | 'duration' | 'added' | 'label' | 'custom';
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
interface Props {
|
|
playlistId: number;
|
|
onBack: () => void;
|
|
onTrackPlay?: (track: DisplayTrack) => void;
|
|
onTrackPlayNext?: (track: DisplayTrack) => void;
|
|
onTrackPlayLater?: (track: DisplayTrack) => void;
|
|
onTrackAddFavorite?: (trackId: number) => void;
|
|
onTrackAddToPlaylist?: (trackId: number) => void;
|
|
onTrackShareQobuz?: (trackId: number) => void;
|
|
onTrackShareSonglink?: (track: DisplayTrack) => void;
|
|
onTrackGoToAlbum?: (albumId: string) => void;
|
|
onTrackGoToArtist?: (artistId: number) => void;
|
|
onTrackShowInfo?: (trackId: number) => void;
|
|
onTrackDownload?: (track: DisplayTrack) => void;
|
|
onTrackRemoveDownload?: (trackId: number) => void;
|
|
onTrackReDownload?: (track: DisplayTrack) => void;
|
|
getTrackOfflineCacheStatus?: (trackId: number) => { status: OfflineCacheStatus; progress: number };
|
|
downloadStateVersion?: number;
|
|
onLocalTrackPlay?: (track: LocalLibraryTrack) => void;
|
|
onLocalTrackPlayNext?: (track: LocalLibraryTrack) => void;
|
|
onLocalTrackPlayLater?: (track: LocalLibraryTrack) => void;
|
|
onSetLocalQueue?: (trackIds: number[]) => void;
|
|
onPlaylistCountUpdate?: (playlistId: number, qobuzCount: number, localCount: number) => void;
|
|
onPlaylistUpdated?: () => void;
|
|
onPlaylistDeleted?: (playlistId: number) => void;
|
|
activeTrackId?: number | null;
|
|
isPlaybackActive?: boolean;
|
|
}
|
|
|
|
let {
|
|
playlistId,
|
|
onBack,
|
|
onTrackPlay,
|
|
onTrackPlayNext,
|
|
onTrackPlayLater,
|
|
onTrackAddFavorite,
|
|
onTrackAddToPlaylist,
|
|
onTrackShareQobuz,
|
|
onTrackShareSonglink,
|
|
onTrackGoToAlbum,
|
|
onTrackGoToArtist,
|
|
onTrackShowInfo,
|
|
onTrackDownload,
|
|
onTrackRemoveDownload,
|
|
onTrackReDownload,
|
|
getTrackOfflineCacheStatus,
|
|
downloadStateVersion,
|
|
onLocalTrackPlay,
|
|
onLocalTrackPlayNext,
|
|
onLocalTrackPlayLater,
|
|
onSetLocalQueue,
|
|
onPlaylistCountUpdate,
|
|
onPlaylistUpdated,
|
|
onPlaylistDeleted,
|
|
activeTrackId = null,
|
|
isPlaybackActive = false
|
|
}: Props = $props();
|
|
|
|
let playlist = $state<Playlist | null>(null);
|
|
let tracks = $state<DisplayTrack[]>([]);
|
|
let localTracks = $state<PlaylistLocalTrack[]>([]);
|
|
let localTracksMap = $state<Map<number, PlaylistLocalTrack>>(new Map());
|
|
let hasLocalTracks = $derived(localTracks.length > 0);
|
|
|
|
// Total counts including local tracks
|
|
let totalTrackCount = $derived((playlist?.tracks_count ?? 0) + localTracks.length);
|
|
let localTracksDuration = $derived(localTracks.reduce((sum, t) => sum + t.duration_secs, 0));
|
|
let totalDuration = $derived((playlist?.duration ?? 0) + localTracksDuration);
|
|
|
|
// Playlist suggestions: adaptive artist selection (quantity scales with playlist size,
|
|
// mix of top artists for coherence + random artists for discovery)
|
|
const playlistArtists = $derived(
|
|
extractAdaptiveArtists(tracks.filter(t => !t.isLocal))
|
|
);
|
|
// Track IDs to exclude from suggestions (already in playlist)
|
|
const excludeTrackIds = $derived(
|
|
tracks.filter(t => !t.isLocal).map(t => t.id)
|
|
);
|
|
|
|
let loading = $state(true);
|
|
let spinnerFading = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let scrollContainer: HTMLDivElement | null = $state(null);
|
|
|
|
// Offline mode state
|
|
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
|
let tracksWithLocalCopies = $state<Set<number>>(new Set());
|
|
|
|
// 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);
|
|
let playlistSettings = $state<PlaylistSettings | null>(null);
|
|
let playlistStats = $state<PlaylistStats | null>(null);
|
|
let editModalOpen = $state(false);
|
|
let isFavorite = $state(false);
|
|
|
|
// Custom order state
|
|
let customOrderMap = $state<Map<string, number>>(new Map()); // "trackId:isLocal" -> position
|
|
let customOrderLoading = $state(false);
|
|
let isCustomOrderMode = $derived(sortBy === 'custom');
|
|
|
|
// Drag and drop state
|
|
let draggedTrackIdx = $state<number | null>(null);
|
|
let dragOverIdx = $state<number | null>(null);
|
|
|
|
// Batch selection state (for custom order mode)
|
|
let selectedTrackKeys = $state<Set<string>>(new Set()); // Set of "trackId:isLocal" keys
|
|
let isSelectionMode = $derived(isCustomOrderMode && selectedTrackKeys.size > 0);
|
|
|
|
// User ownership state (to show "Copy to Library" button for non-owned playlists)
|
|
let currentUserId = $state<number | null>(null);
|
|
let isOwnPlaylist = $derived(playlist !== null && currentUserId !== null && playlist.owner.id === currentUserId);
|
|
let isCopying = $state(false);
|
|
let isCopied = $state(false);
|
|
|
|
// Track copied playlists in localStorage
|
|
const COPIED_PLAYLISTS_KEY = 'qbz_copied_playlists';
|
|
|
|
function getCopiedPlaylists(): Set<number> {
|
|
try {
|
|
const stored = localStorage.getItem(COPIED_PLAYLISTS_KEY);
|
|
return stored ? new Set(JSON.parse(stored)) : new Set();
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
function markPlaylistAsCopied(id: number) {
|
|
const copied = getCopiedPlaylists();
|
|
copied.add(id);
|
|
localStorage.setItem(COPIED_PLAYLISTS_KEY, JSON.stringify([...copied]));
|
|
isCopied = true;
|
|
}
|
|
|
|
function isPlaylistCopied(id: number): boolean {
|
|
return getCopiedPlaylists().has(id);
|
|
}
|
|
|
|
// Show copy button only if: not own playlist AND not already copied
|
|
let showCopyButton = $derived(!isOwnPlaylist && playlist !== null && !isCopied);
|
|
|
|
async function scrollToTrack(trackId: number) {
|
|
await tick();
|
|
const target = scrollContainer?.querySelector<HTMLElement>(`[data-track-id="${trackId}"]`);
|
|
target?.scrollIntoView({ block: 'center' });
|
|
}
|
|
|
|
// Subscribe to offline status changes and fetch current user ID
|
|
onMount(() => {
|
|
// Fetch current user ID for ownership check
|
|
invoke<number | null>('get_current_user_id').then(userId => {
|
|
currentUserId = userId;
|
|
}).catch(err => {
|
|
console.warn('Failed to get current user ID:', err);
|
|
});
|
|
|
|
const unsubscribe = subscribeOffline(() => {
|
|
offlineStatus = getOfflineStatus();
|
|
// Re-check local copies when offline status changes
|
|
if (offlineStatus.isOffline && tracks.length > 0) {
|
|
checkTracksLocalStatus();
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
});
|
|
|
|
// Check if this playlist was already copied when playlistId changes
|
|
$effect(() => {
|
|
isCopied = isPlaylistCopied(playlistId);
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!playlist || displayTracks.length === 0) return;
|
|
const targetId = consumeContextTrackFocus('playlist', playlist.id.toString());
|
|
if (targetId !== null) {
|
|
void scrollToTrack(targetId);
|
|
}
|
|
});
|
|
|
|
// Check if a track is available (has local copy when offline, always available when online)
|
|
function isTrackAvailable(track: DisplayTrack): boolean {
|
|
if (!offlineStatus.isOffline) return true;
|
|
if (track.isLocal) return true; // Local tracks are always available
|
|
return tracksWithLocalCopies.has(track.id);
|
|
}
|
|
|
|
// Check which tracks have local copies (for offline mode)
|
|
async function checkTracksLocalStatus() {
|
|
if (!offlineStatus.isOffline || tracks.length === 0) {
|
|
tracksWithLocalCopies = new Set();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const qobuzTrackIds = tracks.filter(t => !t.isLocal).map(t => t.id);
|
|
if (qobuzTrackIds.length === 0) {
|
|
tracksWithLocalCopies = new Set();
|
|
return;
|
|
}
|
|
|
|
const localIds = await invoke<number[]>('playlist_get_tracks_with_local_copies', {
|
|
trackIds: qobuzTrackIds
|
|
});
|
|
tracksWithLocalCopies = new Set(localIds);
|
|
} catch (err) {
|
|
console.error('Failed to check local track status:', err);
|
|
tracksWithLocalCopies = new Set();
|
|
}
|
|
}
|
|
|
|
// Helper to notify parent of track counts (called imperatively, not reactively)
|
|
function notifyParentOfCounts() {
|
|
if (playlist) {
|
|
const qobuzCount = playlist.tracks_count ?? 0;
|
|
const localCount = localTracks.length;
|
|
onPlaylistCountUpdate?.(playlistId, qobuzCount, localCount);
|
|
}
|
|
}
|
|
|
|
// Reload playlist when playlistId changes
|
|
$effect(() => {
|
|
// Access playlistId to create dependency
|
|
const id = playlistId;
|
|
// Load all data and notify parent when done
|
|
(async () => {
|
|
await Promise.all([loadPlaylist(), loadLocalTracks()]);
|
|
notifyParentOfCounts();
|
|
})();
|
|
loadSettings();
|
|
loadStats();
|
|
});
|
|
|
|
// Check local track status after loading tracks and when offline
|
|
$effect(() => {
|
|
if (offlineStatus.isOffline && tracks.length > 0) {
|
|
checkTracksLocalStatus();
|
|
}
|
|
});
|
|
|
|
|
|
async function loadLocalTracks() {
|
|
try {
|
|
// Check if this is a pending playlist (negative ID)
|
|
if (playlistId < 0) {
|
|
// For pending playlists, load local tracks from the pending playlist data
|
|
const pendingId = -playlistId;
|
|
const pendingPlaylists = await invoke<import('$lib/stores/offlineStore').PendingPlaylist[]>('get_pending_playlists');
|
|
const pending = pendingPlaylists.find(p => p.id === pendingId);
|
|
|
|
if (pending && pending.localTrackIds.length > 0) {
|
|
// Load the actual local track data
|
|
const localTrackData = await invoke<LocalLibraryTrack[]>('library_get_tracks_by_ids', {
|
|
trackIds: pending.localTrackIds
|
|
});
|
|
|
|
// Convert to PlaylistLocalTrack format with positions
|
|
localTracks = localTrackData.map((track, idx) => ({
|
|
...track,
|
|
playlist_position: pending.trackIds.length + idx // Local tracks come after Qobuz tracks
|
|
}));
|
|
localTracksMap = new Map(localTracks.map(t => [t.id, t]));
|
|
} else {
|
|
localTracks = [];
|
|
localTracksMap = new Map();
|
|
}
|
|
} else {
|
|
// Regular playlist - use existing command
|
|
const result = await invoke<PlaylistLocalTrack[]>('playlist_get_local_tracks_with_position', { playlistId });
|
|
localTracks = result;
|
|
localTracksMap = new Map(result.map(t => [t.id, t]));
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load local tracks:', err);
|
|
localTracks = [];
|
|
localTracksMap = new Map();
|
|
}
|
|
}
|
|
|
|
async function loadPlaylist() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
// Check if this is a pending playlist (negative ID)
|
|
if (playlistId < 0) {
|
|
// Load pending playlist data
|
|
const pendingId = -playlistId;
|
|
const pendingPlaylists = await invoke<import('$lib/stores/offlineStore').PendingPlaylist[]>('get_pending_playlists');
|
|
const pending = pendingPlaylists.find(p => p.id === pendingId);
|
|
|
|
if (!pending) {
|
|
throw new Error('Pending playlist not found');
|
|
}
|
|
|
|
// Build playlist object from pending data
|
|
playlist = {
|
|
id: playlistId, // Keep negative ID
|
|
name: pending.name,
|
|
description: pending.description || undefined,
|
|
owner: { id: 0, name: 'You (Offline)' },
|
|
images: [],
|
|
tracks_count: pending.trackIds.length,
|
|
duration: 0,
|
|
is_public: pending.isPublic,
|
|
tracks: { items: [], total: 0 }
|
|
};
|
|
|
|
// Load Qobuz tracks if any
|
|
if (pending.trackIds.length > 0) {
|
|
try {
|
|
const qobuzTracks = await invoke<PlaylistTrack[]>('get_tracks_by_ids', {
|
|
trackIds: pending.trackIds
|
|
});
|
|
|
|
tracks = qobuzTracks.map((t, idx) => ({
|
|
id: t.id,
|
|
number: idx + 1,
|
|
title: t.title,
|
|
artist: t.performer?.name,
|
|
album: t.album?.title,
|
|
albumArt: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small,
|
|
albumId: t.album?.id,
|
|
artistId: t.performer?.id,
|
|
duration: formatDuration(t.duration),
|
|
durationSeconds: t.duration,
|
|
hires: t.hires,
|
|
bitDepth: t.maximum_bit_depth,
|
|
samplingRate: t.maximum_sampling_rate,
|
|
isrc: t.isrc,
|
|
label: t.album?.label?.name,
|
|
addedIndex: idx,
|
|
}));
|
|
|
|
// Update duration
|
|
playlist.duration = qobuzTracks.reduce((sum, t) => sum + t.duration, 0);
|
|
} catch (err) {
|
|
console.error('Failed to load Qobuz tracks for pending playlist:', err);
|
|
tracks = [];
|
|
}
|
|
} else {
|
|
tracks = [];
|
|
}
|
|
} else {
|
|
// Regular playlist - use existing command
|
|
const result = await invoke<Playlist>('get_playlist', { playlistId });
|
|
playlist = result;
|
|
|
|
if (result.tracks?.items) {
|
|
tracks = result.tracks.items.map((t, idx) => ({
|
|
id: t.id,
|
|
number: idx + 1,
|
|
title: t.title,
|
|
artist: t.performer?.name,
|
|
album: t.album?.title,
|
|
albumArt: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small,
|
|
albumId: t.album?.id,
|
|
artistId: t.performer?.id,
|
|
duration: formatDuration(t.duration),
|
|
durationSeconds: t.duration,
|
|
hires: t.hires,
|
|
bitDepth: t.maximum_bit_depth,
|
|
samplingRate: t.maximum_sampling_rate,
|
|
isrc: t.isrc,
|
|
playlistTrackId: t.playlist_track_id,
|
|
label: t.album?.label?.name,
|
|
addedIndex: idx,
|
|
}));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load playlist:', err);
|
|
error = String(err);
|
|
} finally {
|
|
spinnerFading = true;
|
|
setTimeout(() => {
|
|
loading = false;
|
|
spinnerFading = false;
|
|
}, 200);
|
|
}
|
|
}
|
|
|
|
async function loadSettings() {
|
|
// Reset state before loading new playlist settings
|
|
sortBy = 'default';
|
|
sortOrder = 'asc';
|
|
customArtworkPath = null;
|
|
searchQuery = '';
|
|
playlistSettings = null;
|
|
isFavorite = false;
|
|
customOrderMap = new Map();
|
|
|
|
// Skip loading settings for pending playlists
|
|
if (playlistId < 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const settings = await invoke<PlaylistSettings | null>('playlist_get_settings', { playlistId });
|
|
playlistSettings = settings;
|
|
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 || '';
|
|
isFavorite = settings.is_favorite ?? false;
|
|
|
|
// Load custom order if in custom mode
|
|
if (sortBy === 'custom') {
|
|
await loadOrInitCustomOrder();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load playlist settings:', err);
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
// Skip loading stats for pending playlists
|
|
if (playlistId < 0) {
|
|
playlistStats = null;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stats = await invoke<PlaylistStats | null>('playlist_get_stats', { playlistId });
|
|
playlistStats = stats;
|
|
} catch (err) {
|
|
console.error('Failed to load playlist stats:', err);
|
|
}
|
|
}
|
|
|
|
async function toggleFavorite() {
|
|
const newValue = !isFavorite;
|
|
isFavorite = newValue; // Optimistic update
|
|
try {
|
|
await invoke('playlist_set_favorite', { playlistId, favorite: newValue });
|
|
} catch (err) {
|
|
console.error('Failed to toggle favorite:', err);
|
|
isFavorite = !newValue; // Revert on error
|
|
}
|
|
}
|
|
|
|
async function copyPlaylistToLibrary() {
|
|
if (isCopying || !playlist) return;
|
|
|
|
isCopying = true;
|
|
try {
|
|
const newPlaylist = await invoke<Playlist>('subscribe_playlist', { playlistId: playlist.id });
|
|
// Mark as copied so button disappears
|
|
markPlaylistAsCopied(playlist.id);
|
|
// Notify parent to refresh sidebar
|
|
onPlaylistUpdated?.();
|
|
console.log('Playlist copied successfully:', newPlaylist);
|
|
} catch (err) {
|
|
console.error('Failed to copy playlist:', err);
|
|
} finally {
|
|
isCopying = false;
|
|
}
|
|
}
|
|
|
|
async function saveSort(field: SortField, order: SortOrder) {
|
|
sortBy = field;
|
|
sortOrder = order;
|
|
showSortMenu = false;
|
|
|
|
// When switching to custom mode, load or initialize custom order
|
|
if (field === 'custom') {
|
|
await loadOrInitCustomOrder();
|
|
}
|
|
|
|
try {
|
|
await invoke('playlist_set_sort', { playlistId, sortBy: field, sortOrder: order });
|
|
} catch (err) {
|
|
console.error('Failed to save sort settings:', err);
|
|
}
|
|
}
|
|
|
|
// Load custom order from backend or initialize if not exists
|
|
async function loadOrInitCustomOrder() {
|
|
if (playlistId < 0) return; // Skip for pending playlists
|
|
|
|
customOrderLoading = true;
|
|
try {
|
|
// Check if custom order exists
|
|
const hasOrder = await invoke<boolean>('playlist_has_custom_order', { playlistId });
|
|
|
|
if (hasOrder) {
|
|
// Load existing custom order
|
|
const orders = await invoke<[number, boolean, number][]>('playlist_get_custom_order', { playlistId });
|
|
const newMap = new Map<string, number>();
|
|
for (const [trackId, isLocal, position] of orders) {
|
|
newMap.set(`${trackId}:${isLocal}`, position);
|
|
}
|
|
customOrderMap = newMap;
|
|
} else {
|
|
// Initialize from current track arrangement
|
|
await initCustomOrderFromCurrentTracks();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load custom order:', err);
|
|
} finally {
|
|
customOrderLoading = false;
|
|
}
|
|
}
|
|
|
|
// Initialize custom order from the current track list
|
|
async function initCustomOrderFromCurrentTracks() {
|
|
// Get all tracks in current display order (before custom sort applied)
|
|
const allTracks = [...tracks];
|
|
const localTracksInPlaylist = localTracks.map((t, idx) => ({
|
|
...t,
|
|
playlist_position: idx
|
|
}));
|
|
|
|
// Build track ID list: (trackId, isLocal)
|
|
const trackIds: [number, boolean][] = [];
|
|
|
|
// Add Qobuz tracks first (in original order)
|
|
for (const t of allTracks) {
|
|
if (!t.isLocal) {
|
|
trackIds.push([t.id, false]);
|
|
}
|
|
}
|
|
|
|
// Add local tracks (by their position)
|
|
for (const t of localTracksInPlaylist) {
|
|
trackIds.push([t.id, true]);
|
|
}
|
|
|
|
// Save to backend
|
|
try {
|
|
await invoke('playlist_init_custom_order', { playlistId, trackIds });
|
|
|
|
// Update local state
|
|
const newMap = new Map<string, number>();
|
|
for (let i = 0; i < trackIds.length; i++) {
|
|
const [trackId, isLocal] = trackIds[i];
|
|
newMap.set(`${trackId}:${isLocal}`, i);
|
|
}
|
|
customOrderMap = newMap;
|
|
} catch (err) {
|
|
console.error('Failed to initialize custom order:', err);
|
|
}
|
|
}
|
|
|
|
// Move a track to a new position
|
|
async function moveTrack(trackId: number, isLocal: boolean, fromIndex: number, toIndex: number) {
|
|
if (fromIndex === toIndex) return;
|
|
|
|
// Optimistic update: reorder the map
|
|
const key = `${trackId}:${isLocal}`;
|
|
const newMap = new Map(customOrderMap);
|
|
|
|
// Adjust positions
|
|
for (const [k, pos] of newMap) {
|
|
if (k === key) {
|
|
newMap.set(k, toIndex);
|
|
} else if (fromIndex < toIndex) {
|
|
// Moving down: shift tracks between from+1 and to up
|
|
if (pos > fromIndex && pos <= toIndex) {
|
|
newMap.set(k, pos - 1);
|
|
}
|
|
} else {
|
|
// Moving up: shift tracks between to and from-1 down
|
|
if (pos >= toIndex && pos < fromIndex) {
|
|
newMap.set(k, pos + 1);
|
|
}
|
|
}
|
|
}
|
|
customOrderMap = newMap;
|
|
|
|
// Persist to backend
|
|
try {
|
|
await invoke('playlist_move_track', {
|
|
playlistId,
|
|
trackId: Math.abs(trackId),
|
|
isLocal,
|
|
newPosition: toIndex
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to move track:', err);
|
|
// Reload to get consistent state
|
|
await loadOrInitCustomOrder();
|
|
}
|
|
}
|
|
|
|
// Helper to move track up one position
|
|
function moveTrackUp(track: DisplayTrack, currentIndex: number) {
|
|
if (currentIndex === 0) return;
|
|
const isLocal = track.isLocal ?? false;
|
|
const trackId = isLocal ? Math.abs(track.id) : track.id;
|
|
moveTrack(trackId, isLocal, currentIndex, currentIndex - 1);
|
|
}
|
|
|
|
// Helper to move track down one position
|
|
function moveTrackDown(track: DisplayTrack, currentIndex: number) {
|
|
if (currentIndex >= displayTracks.length - 1) return;
|
|
const isLocal = track.isLocal ?? false;
|
|
const trackId = isLocal ? Math.abs(track.id) : track.id;
|
|
moveTrack(trackId, isLocal, currentIndex, currentIndex + 1);
|
|
}
|
|
|
|
// Drag and drop handlers
|
|
function handleDragStart(e: DragEvent, idx: number) {
|
|
if (!isCustomOrderMode) return;
|
|
draggedTrackIdx = idx;
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', String(idx));
|
|
}
|
|
}
|
|
|
|
function handleDragOver(e: DragEvent, idx: number) {
|
|
if (!isCustomOrderMode || draggedTrackIdx === null) return;
|
|
e.preventDefault();
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.dropEffect = 'move';
|
|
}
|
|
dragOverIdx = idx;
|
|
}
|
|
|
|
function handleDragLeave() {
|
|
dragOverIdx = null;
|
|
}
|
|
|
|
function handleDragEnd() {
|
|
draggedTrackIdx = null;
|
|
dragOverIdx = null;
|
|
}
|
|
|
|
function handleDrop(e: DragEvent, toIdx: number) {
|
|
e.preventDefault();
|
|
if (!isCustomOrderMode || draggedTrackIdx === null) return;
|
|
|
|
const fromIdx = draggedTrackIdx;
|
|
if (fromIdx !== toIdx) {
|
|
const track = displayTracks[fromIdx];
|
|
const isLocal = track.isLocal ?? false;
|
|
const trackId = isLocal ? Math.abs(track.id) : track.id;
|
|
moveTrack(trackId, isLocal, fromIdx, toIdx);
|
|
}
|
|
|
|
draggedTrackIdx = null;
|
|
dragOverIdx = null;
|
|
}
|
|
|
|
// === Batch Selection Functions ===
|
|
|
|
function getTrackKey(track: DisplayTrack): string {
|
|
const isLocal = track.isLocal ?? false;
|
|
const trackId = isLocal ? Math.abs(track.id) : track.id;
|
|
return `${trackId}:${isLocal}`;
|
|
}
|
|
|
|
function toggleTrackSelection(track: DisplayTrack) {
|
|
const key = getTrackKey(track);
|
|
const newSet = new Set(selectedTrackKeys);
|
|
if (newSet.has(key)) {
|
|
newSet.delete(key);
|
|
} else {
|
|
newSet.add(key);
|
|
}
|
|
selectedTrackKeys = newSet;
|
|
}
|
|
|
|
function clearSelection() {
|
|
selectedTrackKeys = new Set();
|
|
}
|
|
|
|
function selectAllTracks() {
|
|
const newSet = new Set<string>();
|
|
for (const track of displayTracks) {
|
|
newSet.add(getTrackKey(track));
|
|
}
|
|
selectedTrackKeys = newSet;
|
|
}
|
|
|
|
// Move all selected tracks up one position (as a group)
|
|
async function moveSelectedUp() {
|
|
if (selectedTrackKeys.size === 0) return;
|
|
|
|
// Get indices of selected tracks (sorted)
|
|
const selectedIndices: number[] = [];
|
|
displayTracks.forEach((track, idx) => {
|
|
if (selectedTrackKeys.has(getTrackKey(track))) {
|
|
selectedIndices.push(idx);
|
|
}
|
|
});
|
|
selectedIndices.sort((a, b) => a - b);
|
|
|
|
// Can't move up if first selected is already at top
|
|
if (selectedIndices[0] === 0) return;
|
|
|
|
// Build new order: swap each selected with the one above
|
|
const currentOrder = displayTracks.map(t => ({
|
|
id: t.isLocal ? Math.abs(t.id) : t.id,
|
|
isLocal: t.isLocal ?? false
|
|
}));
|
|
|
|
// Move from top to bottom to avoid conflicts
|
|
for (const idx of selectedIndices) {
|
|
const newIdx = idx - 1;
|
|
[currentOrder[newIdx], currentOrder[idx]] = [currentOrder[idx], currentOrder[newIdx]];
|
|
}
|
|
|
|
// Save new order
|
|
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
|
|
try {
|
|
await invoke('playlist_set_custom_order', { playlistId, orders });
|
|
// Update local map
|
|
const newMap = new Map<string, number>();
|
|
orders.forEach(([id, isLocal, pos]) => {
|
|
newMap.set(`${id}:${isLocal}`, pos);
|
|
});
|
|
customOrderMap = newMap;
|
|
} catch (err) {
|
|
console.error('Failed to move selected tracks:', err);
|
|
}
|
|
}
|
|
|
|
// Move all selected tracks down one position (as a group)
|
|
async function moveSelectedDown() {
|
|
if (selectedTrackKeys.size === 0) return;
|
|
|
|
// Get indices of selected tracks (sorted descending for moving down)
|
|
const selectedIndices: number[] = [];
|
|
displayTracks.forEach((track, idx) => {
|
|
if (selectedTrackKeys.has(getTrackKey(track))) {
|
|
selectedIndices.push(idx);
|
|
}
|
|
});
|
|
selectedIndices.sort((a, b) => b - a); // Descending
|
|
|
|
// Can't move down if last selected is already at bottom
|
|
if (selectedIndices[0] === displayTracks.length - 1) return;
|
|
|
|
// Build new order: swap each selected with the one below
|
|
const currentOrder = displayTracks.map(t => ({
|
|
id: t.isLocal ? Math.abs(t.id) : t.id,
|
|
isLocal: t.isLocal ?? false
|
|
}));
|
|
|
|
// Move from bottom to top to avoid conflicts
|
|
for (const idx of selectedIndices) {
|
|
const newIdx = idx + 1;
|
|
[currentOrder[idx], currentOrder[newIdx]] = [currentOrder[newIdx], currentOrder[idx]];
|
|
}
|
|
|
|
// Save new order
|
|
const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]);
|
|
try {
|
|
await invoke('playlist_set_custom_order', { playlistId, orders });
|
|
// Update local map
|
|
const newMap = new Map<string, number>();
|
|
orders.forEach(([id, isLocal, pos]) => {
|
|
newMap.set(`${id}:${isLocal}`, pos);
|
|
});
|
|
customOrderMap = newMap;
|
|
} catch (err) {
|
|
console.error('Failed to move selected tracks:', 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);
|
|
}
|
|
}
|
|
|
|
// Convert local tracks to DisplayTrack format
|
|
function localTrackToDisplay(track: PlaylistLocalTrack, index: number): DisplayTrack {
|
|
return {
|
|
id: -track.id, // Negative ID to distinguish from Qobuz tracks
|
|
number: index + 1,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
albumArt: track.artwork_path ? `asset://localhost/${encodeURIComponent(track.artwork_path)}` : undefined,
|
|
duration: formatDuration(track.duration_secs),
|
|
durationSeconds: track.duration_secs,
|
|
hires: (track.bit_depth && track.bit_depth >= 24) || track.sample_rate > 48000,
|
|
bitDepth: track.bit_depth,
|
|
samplingRate: track.sample_rate / 1000, // Convert Hz to kHz for display
|
|
isLocal: true,
|
|
localTrackId: track.id,
|
|
artworkPath: track.artwork_path
|
|
};
|
|
}
|
|
|
|
// Filtered and sorted tracks (merged Qobuz + local by position)
|
|
let displayTracks = $derived.by(() => {
|
|
// Build merged list by interleaving based on position
|
|
// Local tracks have explicit playlist_position
|
|
// Qobuz tracks fill positions not occupied by local tracks
|
|
const result: DisplayTrack[] = [];
|
|
|
|
// Create a map of local track positions
|
|
const localByPosition = new Map<number, PlaylistLocalTrack>();
|
|
for (const lt of localTracks) {
|
|
localByPosition.set(lt.playlist_position, lt);
|
|
}
|
|
|
|
// Calculate total count: must reach the highest local position
|
|
const maxLocalPosition = localTracks.length > 0
|
|
? Math.max(...localTracks.map(t => t.playlist_position))
|
|
: -1;
|
|
const minTotalCount = tracks.length + localTracks.length;
|
|
const totalCount = Math.max(minTotalCount, maxLocalPosition + 1);
|
|
|
|
// Interleave: iterate through positions, use local if exists, else use next Qobuz track
|
|
let qobuzIdx = 0;
|
|
for (let pos = 0; pos < totalCount; pos++) {
|
|
const localTrack = localByPosition.get(pos);
|
|
if (localTrack) {
|
|
result.push(localTrackToDisplay(localTrack, result.length));
|
|
} else if (qobuzIdx < tracks.length) {
|
|
// Use Qobuz track
|
|
result.push({ ...tracks[qobuzIdx], number: result.length + 1 });
|
|
qobuzIdx++;
|
|
}
|
|
// Skip positions with no track (gaps)
|
|
}
|
|
|
|
// If any Qobuz tracks remain, append them
|
|
while (qobuzIdx < tracks.length) {
|
|
result.push({ ...tracks[qobuzIdx], number: result.length + 1 });
|
|
qobuzIdx++;
|
|
}
|
|
|
|
// Filter by search query
|
|
let filtered = result;
|
|
if (searchQuery.trim()) {
|
|
const query = searchQuery.toLowerCase();
|
|
filtered = result.filter(t =>
|
|
t.title.toLowerCase().includes(query) ||
|
|
(t.artist?.toLowerCase().includes(query)) ||
|
|
(t.album?.toLowerCase().includes(query))
|
|
);
|
|
}
|
|
|
|
// Sort (only if not default)
|
|
if (sortBy !== 'default') {
|
|
filtered.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;
|
|
case 'added':
|
|
// Use original index as proxy for date added
|
|
// ASC = newest first (higher index = more recent), DESC = oldest first
|
|
cmp = (b.addedIndex ?? 0) - (a.addedIndex ?? 0);
|
|
break;
|
|
case 'label':
|
|
const labelA = a.label || '';
|
|
const labelB = b.label || '';
|
|
// Tracks without label (local tracks) go to end
|
|
if (!labelA && labelB) return 1;
|
|
if (labelA && !labelB) return -1;
|
|
cmp = labelA.localeCompare(labelB);
|
|
break;
|
|
case 'custom':
|
|
// Get positions from customOrderMap
|
|
const aIsLocal = a.isLocal ?? false;
|
|
const bIsLocal = b.isLocal ?? false;
|
|
const aKey = `${aIsLocal ? Math.abs(a.id) : a.id}:${aIsLocal}`;
|
|
const bKey = `${bIsLocal ? Math.abs(b.id) : b.id}:${bIsLocal}`;
|
|
const aPos = customOrderMap.get(aKey) ?? a.addedIndex ?? 0;
|
|
const bPos = customOrderMap.get(bKey) ?? b.addedIndex ?? 0;
|
|
cmp = aPos - bPos;
|
|
break;
|
|
}
|
|
return sortOrder === 'desc' ? -cmp : cmp;
|
|
});
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
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' },
|
|
{ field: 'added', label: 'Added Recently' },
|
|
{ field: 'label', label: 'Label' },
|
|
{ field: 'custom', label: 'Custom Order' },
|
|
];
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
function formatTotalDuration(seconds: number): string {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const mins = Math.floor((seconds % 3600) / 60);
|
|
if (hours > 0) {
|
|
return `${hours} hr ${mins} min`;
|
|
}
|
|
return `${mins} min`;
|
|
}
|
|
|
|
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];
|
|
}
|
|
// Return first track's album art if available
|
|
if (tracks.length > 0 && tracks[0].albumArt) {
|
|
return tracks[0].albumArt;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function buildQueueTracks(tracks: DisplayTrack[]) {
|
|
const queueTracks = tracks.map(t => ({
|
|
id: t.isLocal ? Math.abs(t.id) : t.id,
|
|
title: t.title,
|
|
artist: t.artist || 'Unknown Artist',
|
|
album: t.album || playlist?.name || 'Playlist',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: t.albumArt || getPlaylistImage(),
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate != null ? (t.isLocal ? t.samplingRate * 1000 : t.samplingRate) : null,
|
|
is_local: t.isLocal ?? false,
|
|
album_id: t.isLocal ? null : (t.albumId || null),
|
|
artist_id: t.isLocal ? null : (t.artistId ?? null),
|
|
}));
|
|
|
|
const localIds = tracks
|
|
.filter(t => t.isLocal)
|
|
.map(t => Math.abs(t.id));
|
|
|
|
return { queueTracks, localIds };
|
|
}
|
|
|
|
async function setPlaylistQueue(startIndex: number) {
|
|
const allTracks = displayTracks;
|
|
if (allTracks.length === 0) return;
|
|
const { queueTracks, localIds } = buildQueueTracks(allTracks);
|
|
await invoke('set_queue', { tracks: queueTracks, startIndex });
|
|
if (localIds.length > 0) {
|
|
onSetLocalQueue?.(localIds);
|
|
}
|
|
}
|
|
|
|
async function handleTrackClick(track: DisplayTrack, trackIndex: number) {
|
|
// Create playlist context before playing
|
|
if (playlist) {
|
|
const trackIds = displayTracks
|
|
.filter(t => !t.isLocal) // Only Qobuz tracks in context
|
|
.map(t => t.id);
|
|
|
|
const contextIndex = trackIds.indexOf(track.id);
|
|
|
|
if (contextIndex >= 0 && trackIds.length > 0) {
|
|
await setPlaybackContext(
|
|
'playlist',
|
|
playlist.id.toString(),
|
|
playlist.name,
|
|
'qobuz',
|
|
trackIds,
|
|
contextIndex
|
|
);
|
|
console.log(`[Playlist] Context created: "${playlist.name}", ${trackIds.length} tracks, starting at ${contextIndex}`);
|
|
}
|
|
}
|
|
|
|
// Handle playback
|
|
try {
|
|
await setPlaylistQueue(trackIndex);
|
|
} catch (err) {
|
|
console.error('Failed to set queue:', err);
|
|
}
|
|
|
|
if (track.isLocal && track.localTrackId) {
|
|
// Handle local track play
|
|
const localTrack = localTracksMap.get(track.localTrackId);
|
|
if (localTrack && onLocalTrackPlay) {
|
|
onLocalTrackPlay(localTrack);
|
|
}
|
|
} else if (onTrackPlay) {
|
|
onTrackPlay(track);
|
|
}
|
|
}
|
|
|
|
function handleTrackPlayNext(track: DisplayTrack) {
|
|
if (track.isLocal && track.localTrackId) {
|
|
const localTrack = localTracksMap.get(track.localTrackId);
|
|
if (localTrack && onLocalTrackPlayNext) {
|
|
onLocalTrackPlayNext(localTrack);
|
|
}
|
|
} else if (onTrackPlayNext) {
|
|
onTrackPlayNext(track);
|
|
}
|
|
}
|
|
|
|
function handleTrackPlayLater(track: DisplayTrack) {
|
|
if (track.isLocal && track.localTrackId) {
|
|
const localTrack = localTracksMap.get(track.localTrackId);
|
|
if (localTrack && onLocalTrackPlayLater) {
|
|
onLocalTrackPlayLater(localTrack);
|
|
}
|
|
} else if (onTrackPlayLater) {
|
|
onTrackPlayLater(track);
|
|
}
|
|
}
|
|
|
|
async function removeTrackFromPlaylist(track: DisplayTrack) {
|
|
try {
|
|
if (track.isLocal && track.localTrackId) {
|
|
// Remove local track
|
|
await invoke('playlist_remove_local_track', { playlistId, localTrackId: track.localTrackId });
|
|
await loadLocalTracks();
|
|
notifyParentOfCounts();
|
|
} else if (track.playlistTrackId) {
|
|
// Remove Qobuz track using playlist_track_id
|
|
await invoke('remove_tracks_from_playlist', {
|
|
playlistId,
|
|
playlistTrackIds: [track.playlistTrackId]
|
|
});
|
|
await loadPlaylist();
|
|
notifyParentOfCounts();
|
|
}
|
|
// Notify parent to refresh sidebar counts
|
|
onPlaylistUpdated?.();
|
|
} catch (err) {
|
|
console.error('Failed to remove track from playlist:', err);
|
|
}
|
|
}
|
|
|
|
// Add a suggested track to the playlist
|
|
async function handleAddSuggestedTrack(suggestedTrack: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
|
|
try {
|
|
// Add to Qobuz playlist
|
|
await invoke('add_tracks_to_playlist', {
|
|
playlistId,
|
|
trackIds: [suggestedTrack.track_id]
|
|
});
|
|
|
|
// Add to local tracks array immediately (no reload needed)
|
|
const newTrack: DisplayTrack = {
|
|
id: suggestedTrack.track_id,
|
|
number: tracks.length + 1,
|
|
title: suggestedTrack.title,
|
|
artist: suggestedTrack.artist_name,
|
|
artistId: suggestedTrack.artist_id,
|
|
album: suggestedTrack.album_title,
|
|
albumId: suggestedTrack.album_id,
|
|
albumArt: suggestedTrack.album_image_url,
|
|
duration: formatDuration(suggestedTrack.duration),
|
|
durationSeconds: suggestedTrack.duration,
|
|
addedIndex: tracks.length, // Latest added
|
|
};
|
|
|
|
// Append to tracks array
|
|
tracks = [...tracks, newTrack];
|
|
|
|
// Update playlist count
|
|
if (playlist) {
|
|
playlist.tracks_count = (playlist.tracks_count || 0) + 1;
|
|
playlist.duration = (playlist.duration || 0) + suggestedTrack.duration;
|
|
}
|
|
|
|
// Notify parent (sidebar count update, etc.)
|
|
notifyParentOfCounts();
|
|
onPlaylistUpdated?.();
|
|
} catch (err) {
|
|
console.error('Failed to add suggested track:', err);
|
|
throw err; // Re-throw so the suggestions component knows it failed
|
|
}
|
|
}
|
|
|
|
// Preview a suggested track
|
|
function handlePreviewSuggestedTrack(track: import('$lib/services/playlistSuggestionsService').SuggestedTrack) {
|
|
if (!onTrackPlay) return;
|
|
|
|
// Convert SuggestedTrack to DisplayTrack format
|
|
const displayTrack: DisplayTrack = {
|
|
id: track.track_id,
|
|
number: 0,
|
|
title: track.title,
|
|
artist: track.artist_name,
|
|
album: track.album_title,
|
|
albumArt: track.album_image_url,
|
|
albumId: track.album_id,
|
|
artistId: track.artist_id,
|
|
duration: formatDuration(track.duration),
|
|
durationSeconds: track.duration
|
|
};
|
|
|
|
onTrackPlay(displayTrack);
|
|
}
|
|
|
|
async function handlePlayAll() {
|
|
// Get all display tracks (Qobuz + local, respecting search/sort)
|
|
const allTracks = displayTracks;
|
|
if (allTracks.length === 0) return;
|
|
|
|
// Set playback context for playlist
|
|
if (playlist) {
|
|
const trackIds = allTracks
|
|
.filter(t => !t.isLocal) // Only Qobuz tracks in context
|
|
.map(t => t.id);
|
|
|
|
if (trackIds.length > 0) {
|
|
await setPlaybackContext(
|
|
'playlist',
|
|
playlist.id.toString(),
|
|
playlist.name,
|
|
'qobuz',
|
|
trackIds,
|
|
0
|
|
);
|
|
console.log(`[Playlist] Context created via Play All: "${playlist.name}", ${trackIds.length} tracks`);
|
|
}
|
|
}
|
|
|
|
try {
|
|
await setPlaylistQueue(0);
|
|
|
|
// Play first track (handle local vs Qobuz)
|
|
const firstTrack = allTracks[0];
|
|
if (firstTrack.isLocal && onLocalTrackPlay) {
|
|
const localTrack = localTracks.find(t => t.id === Math.abs(firstTrack.id));
|
|
if (localTrack) onLocalTrackPlay(localTrack);
|
|
} else if (onTrackPlay) {
|
|
onTrackPlay(firstTrack);
|
|
}
|
|
|
|
// Increment play count
|
|
const stats = await invoke<PlaylistStats>('playlist_increment_play_count', { playlistId });
|
|
playlistStats = stats;
|
|
} catch (err) {
|
|
console.error('Failed to set queue:', err);
|
|
}
|
|
}
|
|
|
|
async function handleEditSuccess() {
|
|
editModalOpen = false;
|
|
await loadPlaylist(); // Reload playlist data
|
|
loadSettings(); // Reload settings (including hidden status)
|
|
notifyParentOfCounts();
|
|
onPlaylistUpdated?.();
|
|
}
|
|
|
|
function handleDelete(deletedPlaylistId: number) {
|
|
editModalOpen = false;
|
|
onPlaylistDeleted?.(deletedPlaylistId);
|
|
onBack();
|
|
}
|
|
|
|
async function handleShuffle() {
|
|
if (tracks.length > 0 && onTrackPlay) {
|
|
try {
|
|
await invoke('set_shuffle', { enabled: true });
|
|
await handlePlayAll();
|
|
} catch (err) {
|
|
console.error('Failed to shuffle:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handlePlayAllNext() {
|
|
const allTracks = displayTracks;
|
|
if (allTracks.length === 0) return;
|
|
|
|
// Collect local track IDs to add to set
|
|
const localIds = allTracks
|
|
.filter(t => t.isLocal)
|
|
.map(t => Math.abs(t.id));
|
|
|
|
// Add in reverse order so first track ends up right after current
|
|
for (let i = allTracks.length - 1; i >= 0; i--) {
|
|
const t = allTracks[i];
|
|
try {
|
|
await invoke('add_to_queue_next', {
|
|
track: {
|
|
id: t.isLocal ? Math.abs(t.id) : t.id,
|
|
title: t.title,
|
|
artist: t.artist || 'Unknown Artist',
|
|
album: t.album || playlist?.name || 'Playlist',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: t.albumArt || getPlaylistImage(),
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate != null ? (t.isLocal ? t.samplingRate * 1000 : t.samplingRate) : null,
|
|
is_local: t.isLocal ?? false,
|
|
album_id: t.isLocal ? null : (t.albumId || null),
|
|
artist_id: t.isLocal ? null : (t.artistId ?? null),
|
|
}
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to add track next:', err);
|
|
}
|
|
}
|
|
|
|
// Tell parent about local tracks added to queue
|
|
if (localIds.length > 0) {
|
|
onSetLocalQueue?.(localIds);
|
|
}
|
|
}
|
|
|
|
async function handlePlayAllLater() {
|
|
const allTracks = displayTracks;
|
|
if (allTracks.length === 0) return;
|
|
|
|
const queueTracks = allTracks.map(t => ({
|
|
id: t.isLocal ? Math.abs(t.id) : t.id,
|
|
title: t.title,
|
|
artist: t.artist || 'Unknown Artist',
|
|
album: t.album || playlist?.name || 'Playlist',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: t.albumArt || getPlaylistImage(),
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate != null ? (t.isLocal ? t.samplingRate * 1000 : t.samplingRate) : null,
|
|
is_local: t.isLocal ?? false,
|
|
album_id: t.isLocal ? null : (t.albumId || null),
|
|
artist_id: t.isLocal ? null : (t.artistId ?? null),
|
|
}));
|
|
|
|
// Collect local track IDs
|
|
const localIds = allTracks
|
|
.filter(t => t.isLocal)
|
|
.map(t => Math.abs(t.id));
|
|
|
|
try {
|
|
await invoke('add_tracks_to_queue', { tracks: queueTracks });
|
|
|
|
// Tell parent about local tracks added to queue
|
|
if (localIds.length > 0) {
|
|
onSetLocalQueue?.(localIds);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to add to queue:', err);
|
|
}
|
|
}
|
|
|
|
function sharePlaylistQobuz() {
|
|
if (!playlist?.id) return;
|
|
const url = `https://play.qobuz.com/playlist/${playlist.id}`;
|
|
writeText(url);
|
|
}
|
|
</script>
|
|
|
|
<ViewTransition duration={200} distance={12} direction="down">
|
|
<div class="playlist-detail" bind:this={scrollContainer}>
|
|
<!-- Navigation Row -->
|
|
<div class="nav-row">
|
|
<button class="back-btn" onclick={onBack}>
|
|
<ArrowLeft size={16} />
|
|
<span>Back</span>
|
|
</button>
|
|
{#if playlist}
|
|
<button class="edit-btn" onclick={() => editModalOpen = true} title="Edit playlist">
|
|
<Edit3 size={16} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if loading}
|
|
<div class="loading" class:fading={spinnerFading}>
|
|
<div class="spinner"></div>
|
|
<p>Loading playlist...</p>
|
|
</div>
|
|
{:else if error}
|
|
<div class="error">
|
|
<p>Failed to load playlist</p>
|
|
<p class="error-detail">{error}</p>
|
|
<button class="retry-btn" onclick={loadPlaylist}>Retry</button>
|
|
</div>
|
|
{:else if playlist}
|
|
<ViewTransition duration={200} distance={12} direction="up">
|
|
<!-- Playlist Header -->
|
|
<div class="playlist-header">
|
|
<!-- Playlist Artwork - Collage or Custom -->
|
|
<div class="artwork-container">
|
|
{#if customArtworkPath}
|
|
<div class="artwork custom-artwork">
|
|
<img src={`asset://localhost/${encodeURIComponent(customArtworkPath)}`} alt={playlist.name} />
|
|
<div class="artwork-overlay">
|
|
<button class="artwork-btn artwork-clear" onclick={clearCustomArtwork} title="Remove custom artwork">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="collage-wrapper">
|
|
<PlaylistCollage
|
|
artworks={tracks.slice(0, 4).map(t => t.albumArt).filter((a): a is string => !!a)}
|
|
size={200}
|
|
/>
|
|
<div class="artwork-overlay">
|
|
<button class="artwork-btn" onclick={selectCustomArtwork} title="Set custom artwork">
|
|
<ImagePlus size={24} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Playlist Metadata -->
|
|
<div class="metadata">
|
|
<span class="playlist-label">Playlist</span>
|
|
<h1 class="playlist-title">{playlist.name}</h1>
|
|
{#if playlist.description}
|
|
<p class="playlist-description">{playlist.description}</p>
|
|
{/if}
|
|
<div class="playlist-info">
|
|
<span class="owner">{playlist.owner.name}</span>
|
|
<span class="separator">•</span>
|
|
<span>{totalTrackCount} tracks{#if hasLocalTracks} <span class="local-count">({localTracks.length} local)</span>{/if}</span>
|
|
<span class="separator">•</span>
|
|
<span>{formatTotalDuration(totalDuration)}</span>
|
|
{#if playlistStats && playlistStats.play_count > 0}
|
|
<span class="separator">•</span>
|
|
<span class="play-count" title="Times played">
|
|
<BarChart2 size={12} />
|
|
{playlistStats.play_count}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="actions">
|
|
<button
|
|
class="action-btn-circle primary"
|
|
onclick={handlePlayAll}
|
|
title="Play"
|
|
>
|
|
<Play size={20} fill="currentColor" color="currentColor" />
|
|
</button>
|
|
<button
|
|
class="action-btn-circle"
|
|
onclick={handleShuffle}
|
|
title="Shuffle"
|
|
>
|
|
<Shuffle size={18} />
|
|
</button>
|
|
<button
|
|
class="action-btn-circle"
|
|
class:is-active={isFavorite}
|
|
onclick={toggleFavorite}
|
|
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
|
>
|
|
<Heart
|
|
size={18}
|
|
color={isFavorite ? 'var(--accent-primary)' : 'currentColor'}
|
|
fill={isFavorite ? 'var(--accent-primary)' : 'none'}
|
|
/>
|
|
</button>
|
|
{#if showCopyButton}
|
|
<button
|
|
class="action-btn-circle"
|
|
class:is-loading={isCopying}
|
|
onclick={copyPlaylistToLibrary}
|
|
disabled={isCopying}
|
|
title="Copy to My Library"
|
|
>
|
|
<ListPlus size={18} />
|
|
</button>
|
|
{/if}
|
|
<AlbumMenu
|
|
onPlayNext={handlePlayAllNext}
|
|
onPlayLater={handlePlayAllLater}
|
|
onShareQobuz={sharePlaylistQobuz}
|
|
/>
|
|
</div>
|
|
</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>
|
|
<span class="chevron" class:rotated={showSortMenu}><ChevronDown size={14} /></span>
|
|
</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>
|
|
|
|
</div>
|
|
|
|
<!-- Track List -->
|
|
<div class="track-list">
|
|
{#if isCustomOrderMode}
|
|
<div class="batch-controls">
|
|
<div class="batch-left">
|
|
{#if selectedTrackKeys.size > 0}
|
|
<span class="selection-count">{selectedTrackKeys.size} selected</span>
|
|
<button class="batch-btn" onclick={clearSelection}>Clear</button>
|
|
{:else}
|
|
<button class="batch-btn" onclick={selectAllTracks}>Select All</button>
|
|
{/if}
|
|
</div>
|
|
{#if selectedTrackKeys.size > 0}
|
|
<div class="batch-right">
|
|
<button class="batch-btn" onclick={moveSelectedUp} title="Move selected up">
|
|
<ChevronUp size={14} /> Move Up
|
|
</button>
|
|
<button class="batch-btn" onclick={moveSelectedDown} title="Move selected down">
|
|
<ChevronDown size={14} /> Move Down
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<div class="track-list-header">
|
|
{#if isCustomOrderMode}
|
|
<div class="col-checkbox"></div>
|
|
{/if}
|
|
<div class="col-number">#</div>
|
|
<div class="col-title">Title</div>
|
|
<div class="col-album">Album</div>
|
|
<div class="col-duration">Duration</div>
|
|
<div class="col-quality">Quality</div>
|
|
<div class="col-icon"><Heart size={14} /></div>
|
|
<div class="col-icon"><CloudDownload size={14} /></div>
|
|
<div class="col-spacer"></div>
|
|
</div>
|
|
|
|
{#each displayTracks as track, idx (`${idx}-${track.id}-${downloadStateVersion}`)}
|
|
{@const downloadInfo = track.isLocal ? { status: 'none' as const, progress: 0 } : (getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 })}
|
|
{@const isActiveTrack = (
|
|
track.isLocal
|
|
? (track.localTrackId !== undefined && activeTrackId === track.localTrackId)
|
|
: activeTrackId === track.id
|
|
)}
|
|
{@const isTrackPlaying = isActiveTrack && isPlaybackActive}
|
|
{@const available = isTrackAvailable(track)}
|
|
<div
|
|
class="track-row-wrapper"
|
|
class:unavailable={!available}
|
|
class:custom-order-mode={isCustomOrderMode}
|
|
class:dragging={draggedTrackIdx === idx}
|
|
class:drag-over={dragOverIdx === idx && draggedTrackIdx !== idx}
|
|
title={!available ? $t('offline.trackNotAvailable') : undefined}
|
|
draggable={isCustomOrderMode}
|
|
ondragstart={(e) => handleDragStart(e, idx)}
|
|
ondragover={(e) => handleDragOver(e, idx)}
|
|
ondragleave={handleDragLeave}
|
|
ondragend={handleDragEnd}
|
|
ondrop={(e) => handleDrop(e, idx)}
|
|
>
|
|
{#if isCustomOrderMode}
|
|
{@const trackKey = getTrackKey(track)}
|
|
<label class="track-checkbox" onclick={(e) => e.stopPropagation()}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedTrackKeys.has(trackKey)}
|
|
onchange={() => toggleTrackSelection(track)}
|
|
/>
|
|
</label>
|
|
<div class="reorder-controls">
|
|
<button
|
|
class="reorder-btn"
|
|
onclick={() => moveTrackUp(track, idx)}
|
|
disabled={idx === 0}
|
|
title="Move up"
|
|
>
|
|
<ChevronUp size={16} />
|
|
</button>
|
|
<div class="drag-handle" title="Drag to reorder">
|
|
<GripVertical size={16} />
|
|
</div>
|
|
<button
|
|
class="reorder-btn"
|
|
onclick={() => moveTrackDown(track, idx)}
|
|
disabled={idx === displayTracks.length - 1}
|
|
title="Move down"
|
|
>
|
|
<ChevronDown size={16} />
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
<TrackRow
|
|
trackId={track.isLocal ? undefined : track.id}
|
|
number={idx + 1}
|
|
title={track.title}
|
|
artist={track.artist}
|
|
album={track.album}
|
|
duration={track.duration}
|
|
quality={track.bitDepth && track.samplingRate
|
|
? `${track.bitDepth}bit/${track.samplingRate}kHz`
|
|
: track.hires
|
|
? 'Hi-Res'
|
|
: '-'}
|
|
isPlaying={isTrackPlaying}
|
|
isLocal={track.isLocal}
|
|
hideFavorite={track.isLocal}
|
|
hideDownload={track.isLocal}
|
|
downloadStatus={downloadInfo.status}
|
|
downloadProgress={downloadInfo.progress}
|
|
onPlay={available ? () => handleTrackClick(track, idx) : undefined}
|
|
onDownload={available && !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined}
|
|
onRemoveDownload={available && !track.isLocal && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined}
|
|
menuActions={available ? {
|
|
onPlayNow: () => handleTrackClick(track, idx),
|
|
onPlayNext: track.isLocal ? () => handleTrackPlayNext(track) : (onTrackPlayNext ? () => onTrackPlayNext(track) : undefined),
|
|
onPlayLater: track.isLocal ? () => handleTrackPlayLater(track) : (onTrackPlayLater ? () => onTrackPlayLater(track) : undefined),
|
|
onAddToPlaylist: !track.isLocal && onTrackAddToPlaylist ? () => onTrackAddToPlaylist(track.id) : undefined,
|
|
onRemoveFromPlaylist: () => removeTrackFromPlaylist(track),
|
|
onShareQobuz: !track.isLocal && onTrackShareQobuz ? () => onTrackShareQobuz(track.id) : undefined,
|
|
onShareSonglink: !track.isLocal && onTrackShareSonglink ? () => onTrackShareSonglink(track) : undefined,
|
|
onGoToAlbum: !track.isLocal && track.albumId && onTrackGoToAlbum ? () => onTrackGoToAlbum(track.albumId!) : undefined,
|
|
onGoToArtist: !track.isLocal && track.artistId && onTrackGoToArtist ? () => onTrackGoToArtist(track.artistId!) : undefined,
|
|
onShowInfo: !track.isLocal && onTrackShowInfo ? () => onTrackShowInfo(track.id) : undefined,
|
|
onDownload: !track.isLocal && onTrackDownload ? () => onTrackDownload(track) : undefined,
|
|
isTrackDownloaded: !track.isLocal ? downloadInfo.status === 'ready' : false,
|
|
onReDownload: !track.isLocal && downloadInfo.status === 'ready' && onTrackReDownload ? () => onTrackReDownload(track) : undefined,
|
|
onRemoveDownload: !track.isLocal && downloadInfo.status === 'ready' && onTrackRemoveDownload ? () => onTrackRemoveDownload(track.id) : undefined
|
|
} : {}}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
|
|
{#if displayTracks.length === 0 && searchQuery}
|
|
<div class="no-results">
|
|
<p>No tracks match "{searchQuery}"</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Playlist Suggestions (only for owned playlists) -->
|
|
{#if playlist && !searchQuery && playlistArtists.length > 0 && isOwnPlaylist}
|
|
<PlaylistSuggestions
|
|
playlistId={playlistId}
|
|
artists={playlistArtists}
|
|
excludeTrackIds={excludeTrackIds}
|
|
existingTracks={tracks.filter(t => !t.isLocal).map(t => ({ title: t.title, artist: t.artist }))}
|
|
onAddTrack={handleAddSuggestedTrack}
|
|
onGoToAlbum={onTrackGoToAlbum}
|
|
onGoToArtist={onTrackGoToArtist}
|
|
onPreviewTrack={handlePreviewSuggestedTrack}
|
|
showReasons={false}
|
|
/>
|
|
{/if}
|
|
</ViewTransition>
|
|
{/if}
|
|
</div>
|
|
</ViewTransition>
|
|
|
|
<!-- Edit Playlist Modal -->
|
|
{#if playlist}
|
|
<PlaylistModal
|
|
isOpen={editModalOpen}
|
|
mode="edit"
|
|
playlist={{ id: playlist.id, name: playlist.name, tracks_count: playlist.tracks_count }}
|
|
isHidden={playlistSettings?.hidden ?? false}
|
|
currentFolderId={playlistSettings?.folder_id ?? null}
|
|
onClose={() => editModalOpen = false}
|
|
onSuccess={handleEditSuccess}
|
|
onDelete={handleDelete}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.playlist-detail {
|
|
padding: 24px;
|
|
padding-left: 18px;
|
|
padding-right: 8px;
|
|
padding-bottom: 100px;
|
|
overflow-y: auto;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Custom scrollbar */
|
|
.playlist-detail::-webkit-scrollbar {
|
|
width: 6px;
|
|
}
|
|
|
|
.playlist-detail::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.playlist-detail::-webkit-scrollbar-thumb {
|
|
background: var(--bg-tertiary);
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.playlist-detail::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
.nav-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: color 150ms ease;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.edit-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
transition: all 150ms ease;
|
|
}
|
|
|
|
.edit-btn:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.loading,
|
|
.error {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 64px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.loading {
|
|
opacity: 1;
|
|
transition: opacity 200ms ease-out;
|
|
}
|
|
|
|
.loading.fading {
|
|
opacity: 0;
|
|
}
|
|
|
|
.spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid var(--bg-tertiary);
|
|
border-top-color: var(--accent-primary);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.error-detail {
|
|
font-size: 12px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.retry-btn {
|
|
margin-top: 16px;
|
|
padding: 8px 24px;
|
|
background-color: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.playlist-header {
|
|
display: flex;
|
|
gap: 32px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.artwork-container {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.collage-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.collage-wrapper .artwork-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 10;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
opacity: 0;
|
|
transition: opacity 150ms ease;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.collage-wrapper:hover .artwork-overlay {
|
|
opacity: 1;
|
|
}
|
|
|
|
.artwork {
|
|
width: 186px;
|
|
height: 186px;
|
|
position: relative;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
background-color: var(--bg-tertiary);
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
.custom-artwork {
|
|
width: 200px;
|
|
height: 200px;
|
|
}
|
|
|
|
.artwork img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.artwork-placeholder {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
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: var(--alpha-10);
|
|
border: 1px solid var(--alpha-30);
|
|
border-radius: 50%;
|
|
color: white;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
}
|
|
|
|
.artwork-btn:hover {
|
|
background: var(--alpha-20);
|
|
border-color: var(--alpha-50);
|
|
}
|
|
|
|
.artwork-btn.artwork-clear {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: var(--danger-bg);
|
|
border-color: var(--danger-border);
|
|
}
|
|
|
|
.artwork-btn.artwork-clear:hover {
|
|
background: var(--danger-hover);
|
|
}
|
|
|
|
.metadata {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-end;
|
|
min-width: 0;
|
|
}
|
|
|
|
.playlist-label {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.playlist-title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
margin: 0 0 8px 0;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.playlist-description {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
margin: 0 0 12px 0;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.playlist-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.owner {
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.separator {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.play-count {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
}
|
|
|
|
/* Style AlbumMenu trigger to match action buttons */
|
|
.actions :global(.album-menu .menu-trigger) {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
border: none;
|
|
box-shadow: inset 0 0 0 1px var(--border-strong);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.actions :global(.album-menu .menu-trigger:hover) {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
box-shadow: inset 0 0 0 1px var(--text-primary);
|
|
}
|
|
|
|
|
|
.icon-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
opacity: 0.7;
|
|
transition: opacity 150ms ease;
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
.track-list {
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.track-list-header {
|
|
width: 100%;
|
|
height: 40px;
|
|
padding: 0 16px;
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 16px;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
color: #666666;
|
|
font-weight: 400;
|
|
box-sizing: border-box;
|
|
border-bottom: 1px solid var(--bg-tertiary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.col-number {
|
|
width: 48px;
|
|
text-align: center;
|
|
}
|
|
|
|
.col-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.col-album {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.col-duration {
|
|
width: 80px;
|
|
text-align: center;
|
|
}
|
|
|
|
.col-quality {
|
|
width: 80px;
|
|
text-align: center;
|
|
}
|
|
|
|
.col-icon {
|
|
width: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-muted);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.col-spacer {
|
|
width: 28px;
|
|
}
|
|
|
|
/* 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;
|
|
justify-content: space-between;
|
|
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;
|
|
min-width: 200px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.sort-btn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.sort-btn .chevron {
|
|
display: flex;
|
|
transition: transform 150ms ease;
|
|
}
|
|
|
|
.sort-btn .chevron.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: 200px;
|
|
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;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.no-results {
|
|
padding: 48px;
|
|
text-align: center;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.no-results p {
|
|
margin: 0;
|
|
}
|
|
|
|
.local-count {
|
|
color: var(--text-muted);
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
|
|
.track-row-wrapper {
|
|
display: flex;
|
|
align-items: center;
|
|
position: relative;
|
|
}
|
|
|
|
.track-row-wrapper :global(.track-row) {
|
|
flex: 1;
|
|
}
|
|
|
|
/* Unavailable track styles (offline mode) */
|
|
.track-row-wrapper.unavailable {
|
|
opacity: 0.4;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
}
|
|
|
|
.track-row-wrapper.unavailable :global(.track-row) {
|
|
filter: grayscale(100%);
|
|
}
|
|
|
|
/* Custom order mode */
|
|
.track-row-wrapper.custom-order-mode {
|
|
padding-left: 0;
|
|
}
|
|
|
|
.reorder-controls {
|
|
display: flex;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-right: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.reorder-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary, #888);
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: background-color 0.15s, color 0.15s;
|
|
}
|
|
|
|
.reorder-btn:hover:not(:disabled) {
|
|
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.reorder-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.drag-handle {
|
|
color: var(--text-secondary, #888);
|
|
cursor: grab;
|
|
padding: 2px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Drag and drop styles */
|
|
.track-row-wrapper.dragging {
|
|
opacity: 0.5;
|
|
background: var(--drag-bg, rgba(99, 102, 241, 0.2));
|
|
}
|
|
|
|
.track-row-wrapper.drag-over {
|
|
border-top: 2px solid var(--accent-color, #6366f1);
|
|
margin-top: -2px;
|
|
}
|
|
|
|
.track-row-wrapper[draggable="true"] {
|
|
cursor: grab;
|
|
}
|
|
|
|
.track-row-wrapper[draggable="true"]:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
/* Batch selection controls */
|
|
.batch-controls {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 16px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.batch-left, .batch-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.selection-count {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.batch-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 6px 12px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.batch-btn:hover {
|
|
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.col-checkbox {
|
|
width: 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.track-checkbox {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
flex-shrink: 0;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.track-checkbox input[type="checkbox"] {
|
|
width: 16px;
|
|
height: 16px;
|
|
cursor: pointer;
|
|
accent-color: var(--accent-color, #6366f1);
|
|
}
|
|
|
|
</style>
|