- Add margin-bottom 2px to local-library-section in sidebar - Make dividers transparent (keep spacing, remove visible line) - Add independent sort dropdowns to: EPs & Singles, Live Albums, Compilations, Tributes & Covers, Others - Each section maintains its own sort state
1152 lines
32 KiB
Svelte
1152 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { tick } from 'svelte';
|
|
import { Search, Home, HardDrive, Plus, RefreshCw, ChevronDown, ChevronUp, Heart, ListMusic, Import, Settings, MoreHorizontal, ArrowUpDown, ChevronRight, ChevronLeft } from 'lucide-svelte';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { onMount } from 'svelte';
|
|
import NavigationItem from './NavigationItem.svelte';
|
|
import UserCard from './UserCard.svelte';
|
|
import { t } from '$lib/i18n';
|
|
import {
|
|
subscribe as subscribeOffline,
|
|
getStatus as getOfflineStatus,
|
|
getSettings as getOfflineSettings,
|
|
type OfflineStatus,
|
|
type OfflineSettings
|
|
} from '$lib/stores/offlineStore';
|
|
|
|
interface Playlist {
|
|
id: number;
|
|
name: string;
|
|
tracks_count: number;
|
|
images?: string[];
|
|
duration?: number;
|
|
}
|
|
|
|
type LocalContentStatus = 'unknown' | 'no' | 'some_local' | 'all_local';
|
|
|
|
interface PlaylistSettings {
|
|
qobuz_playlist_id: number;
|
|
hidden: boolean;
|
|
position: number;
|
|
play_count?: number;
|
|
hasLocalContent?: LocalContentStatus;
|
|
}
|
|
|
|
type SortOption = 'name' | 'recent' | 'tracks' | 'playcount' | 'custom';
|
|
|
|
interface Props {
|
|
activeView: string;
|
|
selectedPlaylistId?: number | null;
|
|
onNavigate: (view: string) => void;
|
|
onPlaylistSelect?: (playlistId: number) => void;
|
|
onCreatePlaylist?: () => void;
|
|
onImportPlaylist?: () => void;
|
|
onPlaylistManagerClick?: () => void;
|
|
onSettingsClick?: () => void;
|
|
onAboutClick?: () => void;
|
|
onLogout?: () => void;
|
|
userName?: string;
|
|
subscription?: string;
|
|
isExpanded?: boolean;
|
|
onToggle?: () => void;
|
|
showTitleBar?: boolean;
|
|
}
|
|
|
|
let {
|
|
activeView,
|
|
selectedPlaylistId = null,
|
|
onNavigate,
|
|
onPlaylistSelect,
|
|
onCreatePlaylist,
|
|
onImportPlaylist,
|
|
onPlaylistManagerClick,
|
|
onSettingsClick,
|
|
onAboutClick,
|
|
onLogout,
|
|
userName = 'User',
|
|
subscription = 'Qobuz™',
|
|
isExpanded = true,
|
|
onToggle,
|
|
showTitleBar = true
|
|
}: Props = $props();
|
|
|
|
let userPlaylists = $state<Playlist[]>([]);
|
|
let playlistSettings = $state<Map<number, PlaylistSettings>>(new Map());
|
|
let localTrackCounts = $state<Map<number, number>>(new Map());
|
|
let pendingPlaylistsMap = $state<Map<number, import('$lib/stores/offlineStore').PendingPlaylist>>(new Map());
|
|
let playlistsLoading = $state(false);
|
|
let playlistsCollapsed = $state(false);
|
|
let localLibraryCollapsed = $state(false);
|
|
|
|
// Offline state
|
|
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
|
let offlineSettings = $state<OfflineSettings>(getOfflineSettings());
|
|
let isOffline = $derived(offlineStatus.isOffline);
|
|
|
|
// Dropdown menu state
|
|
let menuOpen = $state(false);
|
|
let sortSubmenuOpen = $state(false);
|
|
let submenuCloseTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
let menuRef: HTMLDivElement | null = null;
|
|
let menuEl: HTMLDivElement | null = null;
|
|
let triggerRef: HTMLButtonElement | null = null;
|
|
let sortTriggerRef: HTMLDivElement | null = null;
|
|
let submenuEl: HTMLDivElement | null = null;
|
|
let menuStyle = $state('');
|
|
let submenuStyle = $state('');
|
|
|
|
function openSubmenu() {
|
|
if (submenuCloseTimeout) {
|
|
clearTimeout(submenuCloseTimeout);
|
|
submenuCloseTimeout = null;
|
|
}
|
|
sortSubmenuOpen = true;
|
|
}
|
|
|
|
function closeSubmenuDelayed() {
|
|
if (submenuCloseTimeout) {
|
|
clearTimeout(submenuCloseTimeout);
|
|
}
|
|
submenuCloseTimeout = setTimeout(() => {
|
|
sortSubmenuOpen = false;
|
|
}, 150); // Small delay to allow mouse to move to submenu
|
|
}
|
|
|
|
// Sort state with localStorage persistence
|
|
let sortOption = $state<SortOption>('name');
|
|
|
|
// Tooltip cache for playlist artists (non-reactive for reads during render)
|
|
const playlistTooltipCache = new Map<number, string>();
|
|
const tooltipLoadingIds = new Set<number>();
|
|
|
|
// Get total track count including local tracks
|
|
function getTotalTrackCount(playlist: Playlist): number {
|
|
const localCount = localTrackCounts.get(playlist.id) ?? 0;
|
|
return playlist.tracks_count + localCount;
|
|
}
|
|
|
|
// Fetch playlist artists for tooltip
|
|
async function fetchPlaylistArtists(playlistId: number, trackCount: number): Promise<string> {
|
|
interface PlaylistDetails {
|
|
tracks?: {
|
|
items: Array<{
|
|
performer?: { name: string };
|
|
}>;
|
|
};
|
|
}
|
|
|
|
try {
|
|
const details = await invoke<PlaylistDetails>('get_playlist', { playlistId });
|
|
if (details.tracks?.items) {
|
|
// Extract unique artist names
|
|
const artistNames = new Set<string>();
|
|
for (const track of details.tracks.items) {
|
|
if (track.performer?.name) {
|
|
artistNames.add(track.performer.name);
|
|
if (artistNames.size >= 5) break;
|
|
}
|
|
}
|
|
|
|
const artists = Array.from(artistNames).slice(0, 5);
|
|
const trackText = `${trackCount} ${trackCount === 1 ? 'Track' : 'Tracks'}`;
|
|
|
|
if (artists.length > 0) {
|
|
return `${artists.join('\n')}\n${trackText}`;
|
|
}
|
|
return trackText;
|
|
}
|
|
} catch (err) {
|
|
console.debug('Failed to fetch playlist artists:', err);
|
|
}
|
|
|
|
return `${trackCount} ${trackCount === 1 ? 'Track' : 'Tracks'}`;
|
|
}
|
|
|
|
// Format track count text with proper plural
|
|
function formatTrackCount(total: number, localCount: number): string {
|
|
const plural = total === 1 ? 'Track' : 'Tracks';
|
|
if (localCount > 0) {
|
|
return `${total} ${plural} (${localCount} local)`;
|
|
}
|
|
return `${total} ${plural}`;
|
|
}
|
|
|
|
// Get basic tooltip (no state mutation during render)
|
|
function getPlaylistTooltip(playlist: Playlist, sidebarExpanded: boolean): string {
|
|
// When sidebar is collapsed, show playlist name + track count
|
|
if (!sidebarExpanded) {
|
|
const totalCount = getTotalTrackCount(playlist);
|
|
const trackText = totalCount === 1 ? 'track' : 'tracks';
|
|
return `${playlist.name}\n${totalCount} ${trackText}`;
|
|
}
|
|
|
|
// When sidebar is expanded, use the rich tooltip with artists
|
|
const cached = playlistTooltipCache.get(playlist.id);
|
|
if (cached) return cached;
|
|
|
|
// Return basic tooltip with combined count
|
|
const totalCount = getTotalTrackCount(playlist);
|
|
const localCount = localTrackCounts.get(playlist.id) ?? 0;
|
|
return formatTrackCount(totalCount, localCount);
|
|
}
|
|
|
|
// Load artist info for tooltip (called on hover, not during render)
|
|
function loadPlaylistTooltip(playlist: Playlist) {
|
|
if (playlistTooltipCache.has(playlist.id) || tooltipLoadingIds.has(playlist.id)) return;
|
|
|
|
tooltipLoadingIds.add(playlist.id);
|
|
|
|
const totalCount = getTotalTrackCount(playlist);
|
|
const localCount = localTrackCounts.get(playlist.id) ?? 0;
|
|
fetchPlaylistArtists(playlist.id, totalCount).then(baseTooltip => {
|
|
// Replace the song count line with properly formatted one including local count
|
|
const countText = formatTrackCount(totalCount, localCount);
|
|
const finalTooltip = baseTooltip.replace(/\d+ (Track|Tracks)/, countText);
|
|
playlistTooltipCache.set(playlist.id, finalTooltip);
|
|
tooltipLoadingIds.delete(playlist.id);
|
|
});
|
|
}
|
|
|
|
// Invalidate tooltip cache for a specific playlist (call when tracks change)
|
|
function invalidatePlaylistTooltip(playlistId: number) {
|
|
playlistTooltipCache.delete(playlistId);
|
|
tooltipLoadingIds.delete(playlistId);
|
|
}
|
|
|
|
// Load sort preference from localStorage
|
|
function loadSortPreference() {
|
|
try {
|
|
const saved = localStorage.getItem('sidebar-playlist-sort');
|
|
if (saved && ['name', 'recent', 'tracks', 'playcount', 'custom'].includes(saved)) {
|
|
sortOption = saved as SortOption;
|
|
}
|
|
} catch (e) {
|
|
// localStorage not available
|
|
}
|
|
}
|
|
|
|
function saveSortPreference(option: SortOption) {
|
|
try {
|
|
localStorage.setItem('sidebar-playlist-sort', option);
|
|
} catch (e) {
|
|
// localStorage not available
|
|
}
|
|
}
|
|
|
|
// Visible and sorted playlists
|
|
const visiblePlaylists = $derived.by(() => {
|
|
let visible = userPlaylists.filter(p => {
|
|
const settings = playlistSettings.get(p.id);
|
|
return !settings?.hidden;
|
|
});
|
|
|
|
// Filter by local content when offline
|
|
if (offlineStatus.isOffline) {
|
|
visible = visible.filter(p => {
|
|
// Calculate local content status from actual data
|
|
const localCount = localTrackCounts.get(p.id) ?? 0;
|
|
const qobuzCount = p.tracks_count ?? 0;
|
|
|
|
// Determine availability status
|
|
let localStatus: LocalContentStatus;
|
|
if (localCount === 0) {
|
|
localStatus = 'no';
|
|
} else if (qobuzCount === 0) {
|
|
// Only local tracks - fully available
|
|
localStatus = 'all_local';
|
|
} else {
|
|
// Mixed: has both local and Qobuz tracks - partially available
|
|
localStatus = 'some_local';
|
|
}
|
|
|
|
if (offlineSettings.showPartialPlaylists) {
|
|
return localStatus === 'all_local' || localStatus === 'some_local';
|
|
}
|
|
return localStatus === 'all_local';
|
|
});
|
|
}
|
|
|
|
// Sort based on selected option
|
|
return [...visible].sort((a, b) => {
|
|
switch (sortOption) {
|
|
case 'name':
|
|
return a.name.localeCompare(b.name);
|
|
case 'recent':
|
|
// For now, use reverse order (most recently added first)
|
|
return userPlaylists.indexOf(b) - userPlaylists.indexOf(a);
|
|
case 'tracks':
|
|
return b.tracks_count - a.tracks_count;
|
|
case 'playcount': {
|
|
const aCount = playlistSettings.get(a.id)?.play_count ?? 0;
|
|
const bCount = playlistSettings.get(b.id)?.play_count ?? 0;
|
|
return bCount - aCount;
|
|
}
|
|
case 'custom': {
|
|
const aPos = playlistSettings.get(a.id)?.position ?? 9999;
|
|
const bPos = playlistSettings.get(b.id)?.position ?? 9999;
|
|
return aPos - bPos;
|
|
}
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Expose playlists to parent via binding
|
|
export function getPlaylists(): Playlist[] {
|
|
return userPlaylists;
|
|
}
|
|
|
|
export function refreshPlaylists() {
|
|
playlistTooltipCache.clear();
|
|
loadUserPlaylists();
|
|
}
|
|
|
|
export function refreshPlaylistSettings() {
|
|
loadPlaylistSettings();
|
|
}
|
|
|
|
export function refreshLocalTrackCounts() {
|
|
loadLocalTrackCounts();
|
|
}
|
|
|
|
// Call this when tracks are added/removed from a playlist
|
|
export function onPlaylistTracksChanged(playlistId: number) {
|
|
invalidatePlaylistTooltip(playlistId);
|
|
loadUserPlaylists();
|
|
loadLocalTrackCounts();
|
|
}
|
|
|
|
// Update counts for a specific playlist (single source of truth from detail view)
|
|
export function updatePlaylistCounts(playlistId: number, qobuzCount: number, localCount: number) {
|
|
// Update Qobuz count in userPlaylists
|
|
userPlaylists = userPlaylists.map(p =>
|
|
p.id === playlistId ? { ...p, tracks_count: qobuzCount } : p
|
|
);
|
|
// Update local count
|
|
localTrackCounts.set(playlistId, localCount);
|
|
localTrackCounts = new Map(localTrackCounts); // Trigger reactivity
|
|
// Invalidate tooltip cache for this playlist
|
|
invalidatePlaylistTooltip(playlistId);
|
|
}
|
|
|
|
// Menu handling functions
|
|
function closeMenu() {
|
|
menuOpen = false;
|
|
sortSubmenuOpen = false;
|
|
if (submenuCloseTimeout) {
|
|
clearTimeout(submenuCloseTimeout);
|
|
submenuCloseTimeout = null;
|
|
}
|
|
}
|
|
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (menuRef && !menuRef.contains(event.target as Node)) {
|
|
closeMenu();
|
|
}
|
|
}
|
|
|
|
async function setMenuPosition() {
|
|
await tick();
|
|
if (!triggerRef || !menuEl) return;
|
|
|
|
const triggerRect = triggerRef.getBoundingClientRect();
|
|
const menuRect = menuEl.getBoundingClientRect();
|
|
const padding = 8;
|
|
|
|
let left = triggerRect.left;
|
|
let top = triggerRect.bottom + 6;
|
|
|
|
// Keep menu within bounds
|
|
if (left + menuRect.width > window.innerWidth - padding) {
|
|
left = Math.max(padding, window.innerWidth - menuRect.width - padding);
|
|
}
|
|
|
|
if (top + menuRect.height > window.innerHeight - padding) {
|
|
top = triggerRect.top - menuRect.height - 6;
|
|
if (top < padding) top = padding;
|
|
}
|
|
|
|
menuStyle = `left: ${left}px; top: ${top}px;`;
|
|
}
|
|
|
|
async function setSubmenuPosition() {
|
|
await tick();
|
|
if (!sortTriggerRef || !submenuEl) return;
|
|
|
|
const triggerRect = sortTriggerRef.getBoundingClientRect();
|
|
const submenuRect = submenuEl.getBoundingClientRect();
|
|
const padding = 8;
|
|
|
|
// Try to position to the right of the trigger
|
|
let left = triggerRect.right + 4;
|
|
let top = triggerRect.top;
|
|
|
|
// If not enough space on right, position to left
|
|
if (left + submenuRect.width > window.innerWidth - padding) {
|
|
left = triggerRect.left - submenuRect.width - 4;
|
|
}
|
|
|
|
// Keep within vertical bounds
|
|
if (top + submenuRect.height > window.innerHeight - padding) {
|
|
top = Math.max(padding, window.innerHeight - submenuRect.height - padding);
|
|
}
|
|
|
|
submenuStyle = `left: ${left}px; top: ${top}px;`;
|
|
}
|
|
|
|
async function toggleMenu() {
|
|
if (menuOpen) {
|
|
closeMenu();
|
|
} else {
|
|
menuOpen = true;
|
|
await setMenuPosition();
|
|
document.addEventListener('click', handleClickOutside);
|
|
}
|
|
}
|
|
|
|
function handleSortChange(option: SortOption) {
|
|
sortOption = option;
|
|
saveSortPreference(option);
|
|
closeMenu();
|
|
}
|
|
|
|
function handleMenuAction(action: () => void) {
|
|
action();
|
|
closeMenu();
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!menuOpen) {
|
|
document.removeEventListener('click', handleClickOutside);
|
|
sortSubmenuOpen = false;
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (sortSubmenuOpen) {
|
|
setSubmenuPosition();
|
|
}
|
|
});
|
|
|
|
// Reload playlists when offline status changes
|
|
$effect(() => {
|
|
// Track isOffline to trigger reload when it changes
|
|
if (isOffline !== undefined) {
|
|
console.log('[Sidebar] Offline status changed, reloading playlists:', isOffline);
|
|
loadUserPlaylists();
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
loadSortPreference();
|
|
loadUserPlaylists();
|
|
loadPlaylistSettings();
|
|
loadLocalTrackCounts();
|
|
|
|
// Subscribe to offline state changes
|
|
const unsubscribeOffline = subscribeOffline(() => {
|
|
offlineStatus = getOfflineStatus();
|
|
offlineSettings = getOfflineSettings();
|
|
});
|
|
|
|
return () => {
|
|
unsubscribeOffline();
|
|
};
|
|
});
|
|
|
|
async function loadUserPlaylists() {
|
|
playlistsLoading = true;
|
|
try {
|
|
if (isOffline) {
|
|
// In offline mode, show only pending playlists (created offline)
|
|
console.log('[Sidebar] Loading pending playlists in offline mode');
|
|
const pendingPlaylists = await invoke<import('$lib/stores/offlineStore').PendingPlaylist[]>('get_pending_playlists');
|
|
|
|
// Store pending playlists metadata and populate localTrackCounts
|
|
const newPendingMap = new Map<number, import('$lib/stores/offlineStore').PendingPlaylist>();
|
|
const newLocalTrackCounts = new Map<number, number>();
|
|
|
|
// Convert pending playlists to Playlist format for UI compatibility
|
|
userPlaylists = pendingPlaylists.map(p => {
|
|
const negativeId = -p.id;
|
|
newPendingMap.set(negativeId, p);
|
|
newLocalTrackCounts.set(negativeId, p.localTrackIds.length); // Populate local track count
|
|
|
|
return {
|
|
id: negativeId, // Negative ID to distinguish from real playlists
|
|
name: p.name,
|
|
description: p.description || undefined,
|
|
is_public: p.isPublic,
|
|
tracks_count: p.trackIds.length, // Only Qobuz tracks for correct filtering
|
|
duration: 0,
|
|
users_count: 0,
|
|
is_collaborative: false,
|
|
timestamp_creation: p.createdAt,
|
|
timestamp_update: p.createdAt,
|
|
owner: {
|
|
id: 0,
|
|
name: 'You (Offline)',
|
|
display_name: undefined
|
|
}
|
|
};
|
|
});
|
|
|
|
pendingPlaylistsMap = newPendingMap;
|
|
localTrackCounts = newLocalTrackCounts;
|
|
console.log(`[Sidebar] Loaded ${userPlaylists.length} pending playlists`);
|
|
} else {
|
|
// Online mode - load from Qobuz
|
|
const playlists = await invoke<Playlist[]>('get_user_playlists');
|
|
userPlaylists = playlists;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load playlists:', err);
|
|
} finally {
|
|
playlistsLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadPlaylistSettings() {
|
|
try {
|
|
const settings = await invoke<PlaylistSettings[]>('playlist_get_all_settings');
|
|
const map = new Map<number, PlaylistSettings>();
|
|
for (const s of settings) {
|
|
map.set(s.qobuz_playlist_id, s);
|
|
}
|
|
playlistSettings = map;
|
|
} catch (err) {
|
|
// Command might not exist yet, that's ok
|
|
console.debug('Failed to load playlist settings:', err);
|
|
}
|
|
}
|
|
|
|
async function loadLocalTrackCounts() {
|
|
try {
|
|
const counts = await invoke<Record<string, number>>('playlist_get_all_local_track_counts');
|
|
const map = new Map<number, number>();
|
|
for (const [id, count] of Object.entries(counts)) {
|
|
map.set(Number(id), count);
|
|
}
|
|
localTrackCounts = map;
|
|
} catch (err) {
|
|
console.debug('Failed to load local track counts:', err);
|
|
}
|
|
}
|
|
|
|
function handleViewChange(view: string) {
|
|
onNavigate(view);
|
|
}
|
|
|
|
function handlePlaylistClick(playlist: Playlist) {
|
|
if (onPlaylistSelect) {
|
|
onPlaylistSelect(playlist.id);
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<aside class="sidebar" class:collapsed={!isExpanded} class:no-titlebar={!showTitleBar}>
|
|
<!-- Scrollable Content Area -->
|
|
<div class="content">
|
|
<!-- Search Bar -->
|
|
<button
|
|
type="button"
|
|
class="search-container"
|
|
class:collapsed={!isExpanded}
|
|
onclick={() => {
|
|
console.log('Search button clicked!');
|
|
handleViewChange('search');
|
|
}}
|
|
title={isExpanded ? undefined : $t('nav.search')}
|
|
>
|
|
<Search class="search-icon" size={16} />
|
|
{#if isExpanded}
|
|
<span class="search-placeholder">{$t('nav.search')}</span>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Navigation Section -->
|
|
<nav class="nav-section">
|
|
<NavigationItem
|
|
label={$t('nav.home')}
|
|
active={activeView === 'home'}
|
|
onclick={() => handleViewChange('home')}
|
|
showLabel={isExpanded}
|
|
>
|
|
{#snippet icon()}<Home size={14} />{/snippet}
|
|
</NavigationItem>
|
|
</nav>
|
|
|
|
<!-- Favorites Section (standalone) -->
|
|
<nav class="nav-section">
|
|
<NavigationItem
|
|
label={$t('nav.favorites')}
|
|
active={activeView.startsWith('favorites-')}
|
|
onclick={() => handleViewChange('favorites')}
|
|
showLabel={isExpanded}
|
|
>
|
|
{#snippet icon()}<Heart size={14} />{/snippet}
|
|
</NavigationItem>
|
|
</nav>
|
|
|
|
<!-- Playlists Section (hidden in offline mode) -->
|
|
{#if !isOffline}
|
|
<div class="section playlists-section">
|
|
{#if isExpanded}
|
|
<div class="playlists-header">
|
|
<div class="section-header">{$t('nav.playlists')}</div>
|
|
<div class="header-actions" bind:this={menuRef}>
|
|
<button class="icon-btn" onclick={onCreatePlaylist} title={$t('playlist.createNew')}>
|
|
<Plus size={14} />
|
|
</button>
|
|
<button
|
|
class="icon-btn"
|
|
bind:this={triggerRef}
|
|
onclick={(e) => { e.stopPropagation(); toggleMenu(); }}
|
|
title={$t('actions.more')}
|
|
>
|
|
<MoreHorizontal size={14} />
|
|
</button>
|
|
<button class="icon-btn" onclick={() => playlistsCollapsed = !playlistsCollapsed} title={playlistsCollapsed ? $t('actions.open') : $t('actions.close')}>
|
|
{#if playlistsCollapsed}
|
|
<ChevronDown size={14} />
|
|
{:else}
|
|
<ChevronUp size={14} />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Dropdown Menu -->
|
|
{#if menuOpen}
|
|
<div class="dropdown-menu" bind:this={menuEl} style={menuStyle}>
|
|
<!-- Sort by submenu trigger -->
|
|
<div
|
|
class="menu-item has-submenu"
|
|
bind:this={sortTriggerRef}
|
|
role="button"
|
|
tabindex="0"
|
|
onmouseenter={openSubmenu}
|
|
onmouseleave={closeSubmenuDelayed}
|
|
>
|
|
<ArrowUpDown size={14} />
|
|
<span class="menu-label">{$t('library.sortBy')}</span>
|
|
<ChevronRight size={14} class="submenu-arrow" />
|
|
</div>
|
|
|
|
<!-- Sort submenu (positioned outside trigger for better hover handling) -->
|
|
{#if sortSubmenuOpen}
|
|
<div
|
|
class="submenu"
|
|
bind:this={submenuEl}
|
|
style={submenuStyle}
|
|
onmouseenter={openSubmenu}
|
|
onmouseleave={closeSubmenuDelayed}
|
|
>
|
|
<button class="menu-item" class:selected={sortOption === 'name'} onclick={() => handleSortChange('name')}>
|
|
{$t('sort.nameAZ')}
|
|
</button>
|
|
<button class="menu-item" class:selected={sortOption === 'recent'} onclick={() => handleSortChange('recent')}>
|
|
{$t('sort.recent')}
|
|
</button>
|
|
<button class="menu-item" class:selected={sortOption === 'tracks'} onclick={() => handleSortChange('tracks')}>
|
|
{$t('sort.trackCount')}
|
|
</button>
|
|
<button class="menu-item" class:selected={sortOption === 'playcount'} onclick={() => handleSortChange('playcount')}>
|
|
{$t('sort.playCount')}
|
|
</button>
|
|
<button class="menu-item" class:selected={sortOption === 'custom'} onclick={() => handleSortChange('custom')}>
|
|
{$t('sort.custom')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
<button class="menu-item" onclick={() => handleMenuAction(loadUserPlaylists)}>
|
|
<RefreshCw size={14} />
|
|
<span>{$t('actions.refresh')}</span>
|
|
</button>
|
|
|
|
<button
|
|
class="menu-item"
|
|
class:disabled={offlineStatus.isOffline}
|
|
onclick={() => !offlineStatus.isOffline && handleMenuAction(onImportPlaylist ?? (() => {}))}
|
|
title={offlineStatus.isOffline ? $t('offline.featureDisabled') : undefined}
|
|
>
|
|
<Import size={14} />
|
|
<span>{$t('playlist.import')}</span>
|
|
</button>
|
|
|
|
<div class="menu-divider"></div>
|
|
|
|
<button class="menu-item" onclick={() => handleMenuAction(onPlaylistManagerClick ?? (() => {}))}>
|
|
<Settings size={14} />
|
|
<span>{$t('playlist.manage')}</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if !playlistsCollapsed || !isExpanded}
|
|
<div class="playlists-scroll">
|
|
{#if playlistsLoading}
|
|
{#if isExpanded}
|
|
<div class="playlists-loading">{$t('actions.loading')}</div>
|
|
{/if}
|
|
{:else if visiblePlaylists.length > 0}
|
|
<nav class="playlists-nav">
|
|
{#each visiblePlaylists as playlist (playlist.id)}
|
|
<NavigationItem
|
|
label={playlist.name}
|
|
tooltip={getPlaylistTooltip(playlist, isExpanded)}
|
|
active={activeView === 'playlist' && selectedPlaylistId === playlist.id}
|
|
onclick={() => handlePlaylistClick(playlist)}
|
|
onHover={() => loadPlaylistTooltip(playlist)}
|
|
showLabel={isExpanded}
|
|
>
|
|
{#snippet icon()}<ListMusic size={14} />{/snippet}
|
|
</NavigationItem>
|
|
{/each}
|
|
</nav>
|
|
{:else if userPlaylists.length > 0}
|
|
{#if isExpanded}
|
|
<div class="no-playlists">{$t('playlist.allHidden')}</div>
|
|
{/if}
|
|
{:else}
|
|
{#if isExpanded}
|
|
<div class="no-playlists">{$t('empty.noPlaylists')}</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Local Library Section -->
|
|
<div class="section local-library-section">
|
|
{#if isExpanded}
|
|
<button class="section-header-btn" onclick={() => localLibraryCollapsed = !localLibraryCollapsed}>
|
|
<span class="section-header">{$t('library.title')}</span>
|
|
{#if localLibraryCollapsed}
|
|
<ChevronDown size={12} />
|
|
{:else}
|
|
<ChevronUp size={12} />
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
{#if !localLibraryCollapsed || !isExpanded}
|
|
<NavigationItem
|
|
label={$t('library.browse')}
|
|
active={activeView === 'library'}
|
|
onclick={() => handleViewChange('library')}
|
|
showLabel={isExpanded}
|
|
>
|
|
{#snippet icon()}<HardDrive size={14} />{/snippet}
|
|
</NavigationItem>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toggle Button (Edge position) -->
|
|
<button
|
|
class="toggle-btn"
|
|
onclick={onToggle}
|
|
title={isExpanded ? $t('actions.collapse') : $t('actions.expand')}
|
|
>
|
|
{#if isExpanded}
|
|
<ChevronLeft size={16} />
|
|
{:else}
|
|
<ChevronRight size={16} />
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Fixed User Profile at Bottom -->
|
|
<div class="user-section" class:collapsed={!isExpanded}>
|
|
{#if isExpanded}
|
|
<UserCard
|
|
username={userName}
|
|
{subscription}
|
|
onSettingsClick={onSettingsClick ?? (() => handleViewChange('settings'))}
|
|
{onAboutClick}
|
|
/>
|
|
{:else}
|
|
<button
|
|
class="collapsed-settings-btn"
|
|
onclick={onSettingsClick ?? (() => handleViewChange('settings'))}
|
|
title={$t('nav.settings')}
|
|
>
|
|
<Settings size={16} />
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</aside>
|
|
|
|
<style>
|
|
.sidebar {
|
|
width: 280px;
|
|
min-width: 280px;
|
|
flex-shrink: 0;
|
|
background-color: var(--bg-secondary, #1a1a1a);
|
|
position: relative;
|
|
z-index: 3;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
|
|
transition: width 200ms ease, min-width 200ms ease;
|
|
}
|
|
|
|
.sidebar.collapsed {
|
|
width: 64px;
|
|
min-width: 64px;
|
|
}
|
|
|
|
.sidebar.no-titlebar {
|
|
height: calc(100vh - 104px); /* Only 104px NowPlayingBar, no title bar */
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
padding: 12px;
|
|
padding-bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.search-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
height: 32px;
|
|
min-height: 32px;
|
|
max-height: 32px;
|
|
background-color: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
padding: 0 10px;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.search-container:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
.search-container.collapsed {
|
|
width: 40px;
|
|
height: 40px;
|
|
padding: 0;
|
|
justify-content: center;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.search-container :global(.search-icon) {
|
|
color: var(--text-muted);
|
|
flex-shrink: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.search-placeholder {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.nav-section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.local-library-section {
|
|
flex-shrink: 0;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.section-header {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
color: var(--text-muted);
|
|
font-weight: 600;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 6px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.section-header-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
padding: 0 8px;
|
|
margin-bottom: 6px;
|
|
color: var(--text-muted);
|
|
transition: color 150ms ease;
|
|
}
|
|
|
|
.section-header-btn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.section-header-btn .section-header {
|
|
margin-bottom: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.playlists-section {
|
|
flex: 1;
|
|
padding-bottom: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.playlists-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 8px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.playlists-header .section-header {
|
|
margin-bottom: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.icon-btn {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
transition: color 150ms ease;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Playlist list view */
|
|
.playlists-nav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.playlists-scroll {
|
|
overflow-y: overlay;
|
|
margin-right: 1px;
|
|
min-height: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
/* Thin subtle scrollbar - always visible */
|
|
.playlists-scroll::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
.playlists-scroll::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.playlists-scroll::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.playlists-scroll::-webkit-scrollbar-thumb:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
|
|
/* Compensate margin-right in collapsed mode by shifting icons slightly right */
|
|
.sidebar.collapsed .playlists-scroll .nav-item {
|
|
padding-left: 1px;
|
|
}
|
|
|
|
.playlists-loading,
|
|
.no-playlists {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
padding: 6px 8px;
|
|
}
|
|
|
|
.toggle-btn {
|
|
position: absolute;
|
|
right: -10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 20px;
|
|
height: 20px;
|
|
padding: 0;
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 50%;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.75);
|
|
transition: transform 150ms ease, background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
|
|
z-index: 10;
|
|
}
|
|
|
|
.toggle-btn:hover {
|
|
color: var(--text-primary);
|
|
background: var(--bg-hover);
|
|
transform: translateY(-50%) scale(1.1);
|
|
box-shadow: -6px 0 16px rgba(0, 0, 0, 0.85);
|
|
}
|
|
|
|
.user-section {
|
|
border-top: 1px solid var(--bg-tertiary);
|
|
padding: 8px;
|
|
}
|
|
|
|
.user-section.collapsed {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 8px;
|
|
}
|
|
|
|
.collapsed-settings-btn {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: color 150ms ease, background-color 150ms ease;
|
|
}
|
|
|
|
.collapsed-settings-btn:hover {
|
|
color: var(--text-primary);
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
/* Dropdown menu styles */
|
|
.dropdown-menu {
|
|
position: fixed;
|
|
min-width: 180px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--bg-tertiary);
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
z-index: 10000;
|
|
max-height: 260px;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--text-muted) transparent;
|
|
}
|
|
|
|
.dropdown-menu::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.dropdown-menu::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.dropdown-menu::-webkit-scrollbar-thumb {
|
|
background: var(--text-muted);
|
|
border-radius: 9999px;
|
|
}
|
|
|
|
.dropdown-menu::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-secondary);
|
|
}
|
|
|
|
.menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 8px 10px;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 4px;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease, color 150ms ease;
|
|
text-align: left;
|
|
}
|
|
|
|
.menu-item:hover {
|
|
background-color: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.menu-item.selected {
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.menu-item.disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.menu-item.disabled:hover {
|
|
background: none;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.menu-item.has-submenu {
|
|
position: relative;
|
|
}
|
|
|
|
.menu-item .menu-label {
|
|
flex: 1;
|
|
}
|
|
|
|
.menu-item .submenu-arrow {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.menu-divider {
|
|
height: 1px;
|
|
background: var(--bg-tertiary);
|
|
margin: 6px 0;
|
|
}
|
|
|
|
.submenu {
|
|
position: fixed;
|
|
min-width: 140px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--bg-tertiary);
|
|
border-radius: 8px;
|
|
padding: 6px;
|
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
z-index: 10001;
|
|
}
|
|
|
|
.submenu .menu-item {
|
|
gap: 8px;
|
|
}
|
|
</style>
|