Files
qbz/src/lib/components/views/LocalLibraryView.svelte
T
vicrodh 37db48ea3d fix(v2): complete command migration sweep and musician/blacklist flows
- migrate remaining frontend invokes to V2 names
- add missing V2 command registrations and implementations
- enforce commands_v2 backend purity (no legacy delegation)
- restore blacklist/relationships/musician flows in V2
- align playlist/local-library/settings invoke paths with V2

cx
2026-02-16 18:40:07 -06:00

6165 lines
186 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { getThumbnailUrl, getCachedThumbnailUrl } from '$lib/services/thumbnailService';
import { open, ask } from '@tauri-apps/plugin-dialog';
import { onMount, onDestroy, tick } from 'svelte';
import {
HardDrive, Music, Disc3, Mic2, FolderPlus, Trash2, RefreshCw,
Settings, ArrowLeft, X, Play, AlertCircle, ImageDown, Upload, Search, LayoutGrid, List, Edit3,
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown, Check
} from 'lucide-svelte';
import FolderSettingsModal from '../FolderSettingsModal.svelte';
import LocalLibraryTagEditorModal from '../LocalLibraryTagEditorModal.svelte';
import ViewTransition from '../ViewTransition.svelte';
import { t } from '$lib/i18n';
import { getUserItem } from '$lib/utils/userStorage';
import { downloadSettingsVersion } from '$lib/stores/downloadSettingsStore';
import { showToast } from '$lib/stores/toastStore';
import AlbumCard from '../AlbumCard.svelte';
import VirtualizedAlbumList from '../VirtualizedAlbumList.svelte';
import VirtualizedArtistGrid from '../VirtualizedArtistGrid.svelte';
import VirtualizedArtistList from '../VirtualizedArtistList.svelte';
import VirtualizedTrackList from '../VirtualizedTrackList.svelte';
import {
isVirtualizationEnabled,
shouldUsePerformanceMode,
subscribe as subscribePerformance
} from '$lib/stores/libraryPerformanceStore';
import TrackRow from '../TrackRow.svelte';
import {
subscribe as subscribeNav,
selectLocalAlbum,
clearLocalAlbum,
getSelectedLocalAlbumId,
goBack as navGoBack,
navigateTo,
getNavigationState
} from '$lib/stores/navigationStore';
import {
subscribe as subscribeOffline,
isOffline as checkIsOffline,
getOfflineReason,
getSettings as getOfflineSettings
} from '$lib/stores/offlineStore';
import {
setPlaybackContext
} from '$lib/stores/playbackContextStore';
// Backend types matching Rust models
interface LocalTrack {
id: number;
file_path: string;
title: string;
artist: string;
album: string;
album_artist?: string;
album_group_key?: string;
album_group_title?: string;
track_number?: number;
disc_number?: number;
year?: number;
genre?: string;
catalog_number?: string;
duration_secs: number;
format: string;
bit_depth?: number;
sample_rate: number;
channels: number;
file_size_bytes: number;
cue_file_path?: string;
cue_start_secs?: number;
cue_end_secs?: number;
artwork_path?: string;
last_modified: number;
indexed_at: number;
source?: string; // 'user' | 'qobuz_download' | 'plex'
}
interface LocalAlbum {
id: string;
title: string;
artist: string;
all_artists?: string; // Comma-separated list of all contributing artists
year?: number;
catalog_number?: string;
artwork_path?: string;
track_count: number;
total_duration_secs: number;
format: string;
bit_depth?: number;
sample_rate: number;
directory_path: string;
source?: string; // 'user' | 'qobuz_download' | 'plex'
likely_single_file_album?: boolean;
}
interface PlexCachedAlbum {
id: string;
title: string;
artist: string;
artworkPath?: string;
trackCount: number;
totalDurationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
source: string;
likelySingleFileAlbum?: boolean;
}
interface PlexCachedTrack {
id: number;
ratingKey: string;
title: string;
artist: string;
album: string;
durationSecs: number;
format: string;
bitDepth?: number;
sampleRate: number;
artworkPath?: string;
source: string;
albumKey: string;
}
interface PlexMusicSection {
key: string;
title: string;
}
interface PlexTrack {
ratingKey: string;
title: string;
artist?: string;
album?: string;
durationMs?: number;
artworkPath?: string;
partKey?: string;
container?: string;
codec?: string;
channels?: number;
bitrateKbps?: number;
samplingRateHz?: number;
bitDepth?: number;
}
interface PlexTrackMetadata {
ratingKey: string;
container?: string;
codec?: string;
samplingRateHz?: number;
bitDepth?: number;
}
interface PlexTrackQualityUpdate {
ratingKey: string;
container?: string;
samplingRateHz?: number;
bitDepth?: number;
}
interface LocalArtist {
name: string;
album_count: number;
track_count: number;
}
interface ArtistSearchResult {
id: number;
name: string;
image?: { small?: string; thumbnail?: string; large?: string };
}
interface SearchResults<T> {
items: T[];
total: number;
offset: number;
limit: number;
}
interface LibraryStats {
track_count: number;
album_count: number;
artist_count: number;
total_duration_secs: number;
total_size_bytes: number;
}
interface ScanProgress {
status: 'Idle' | 'Scanning' | 'Complete' | 'Cancelled' | 'Error';
total_files: number;
processed_files: number;
current_file?: string;
errors: { file_path: string; error: string }[];
}
interface LibraryFolder {
id: number;
path: string;
alias: string | null;
enabled: boolean;
isNetwork: boolean;
networkFsType: string | null;
userOverrideNetwork: boolean;
lastScan: number | null;
}
interface DiscogsImageOption {
url: string;
width: number;
height: number;
image_type: string;
release_title?: string;
release_year?: number;
}
interface Props {
onAlbumClick?: (album: LocalAlbum) => void;
onQobuzArtistClick?: (artistId: number) => void;
onTrackPlay?: (track: LocalTrack) => void;
onTrackPlayNext?: (track: LocalTrack) => void;
onTrackPlayLater?: (track: LocalTrack) => void;
onTrackAddToPlaylist?: (trackId: number) => void;
onSetLocalQueue?: (trackIds: number[]) => void;
activeTrackId?: number | null;
isPlaybackActive?: boolean;
}
const PLEX_METADATA_WRITE_KEY = 'qbz-plex-poc-metadata-write-enabled';
let {
onAlbumClick,
onQobuzArtistClick,
onTrackPlay,
onTrackPlayNext,
onTrackPlayLater,
onTrackAddToPlaylist,
onSetLocalQueue,
activeTrackId = null,
isPlaybackActive = false
}: Props = $props();
// View state
type TabType = 'albums' | 'artists' | 'tracks';
let activeTab = $state<TabType>('albums');
let showSettings = $state(false);
let showHiddenAlbums = $state(false);
let albumSearch = $state('');
let folderSearch = $state('');
let albumViewMode = $state<'grid' | 'list'>('grid');
type AlbumGroupMode = 'alpha' | 'artist';
let albumGroupMode = $state<AlbumGroupMode>('alpha');
let albumGroupingEnabled = $state(false);
let showGroupMenu = $state(false);
// Quality/Format filter with checkboxes (AND between sections, OR within section)
let showFilterPanel = $state(false);
let filterPanelRef: HTMLDivElement | null = $state(null);
let filterPanelTimeout: ReturnType<typeof setTimeout> | null = null;
function startFilterPanelTimer() {
clearFilterPanelTimer();
filterPanelTimeout = setTimeout(() => {
showFilterPanel = false;
}, 3000);
}
function clearFilterPanelTimer() {
if (filterPanelTimeout) {
clearTimeout(filterPanelTimeout);
filterPanelTimeout = null;
}
}
function handleFilterPanelActivity() {
if (showFilterPanel) {
startFilterPanelTimer();
}
}
function handleClickOutsideFilterPanel(event: MouseEvent) {
if (showFilterPanel && filterPanelRef && !filterPanelRef.contains(event.target as Node)) {
showFilterPanel = false;
clearFilterPanelTimer();
}
}
// Effect to manage filter panel auto-close
$effect(() => {
if (showFilterPanel) {
startFilterPanelTimer();
document.addEventListener('click', handleClickOutsideFilterPanel, true);
} else {
clearFilterPanelTimer();
document.removeEventListener('click', handleClickOutsideFilterPanel, true);
}
return () => {
clearFilterPanelTimer();
document.removeEventListener('click', handleClickOutsideFilterPanel, true);
};
});
// Effect to close sort menu on click outside
$effect(() => {
if (showSortMenu) {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.sort-btn') && !target.closest('.sort-menu')) {
showSortMenu = false;
}
};
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
}
});
// Quality tier filters (OR within this group)
let filterHiRes = $state(false);
let filterCdQuality = $state(false);
let filterLossy = $state(false);
// Format filters (OR within this group)
let filterFlac = $state(false);
let filterAlac = $state(false);
let filterApe = $state(false);
let filterWav = $state(false);
let filterMp3 = $state(false);
let filterAac = $state(false);
let filterOther = $state(false);
// Source filters (OR within this group)
let filterLocalFiles = $state(false);
let filterOfflineCache = $state(false);
const LOSSLESS_FORMATS = ['flac', 'wav', 'aiff', 'alac', 'ape', 'dsd', 'dsf', 'dff'];
const LOSSY_FORMATS = ['mp3', 'aac', 'm4a', 'ogg', 'opus', 'wma'];
// Derived: check if any filter is active
let hasActiveFilters = $derived(
filterHiRes || filterCdQuality || filterLossy ||
filterFlac || filterAlac || filterApe || filterWav || filterMp3 || filterAac || filterOther ||
filterLocalFiles || filterOfflineCache
);
// Count active filters for badge
let activeFilterCount = $derived(
[filterHiRes, filterCdQuality, filterLossy, filterFlac, filterAlac, filterApe, filterWav, filterMp3, filterAac, filterOther, filterLocalFiles, filterOfflineCache]
.filter(Boolean).length
);
function matchesQualityFilters(album: LocalAlbum): boolean {
const format = album.format.toLowerCase();
const isLossless = LOSSLESS_FORMATS.includes(format);
const bitDepth = album.bit_depth ?? 16;
// Check quality tier (OR logic - pass if any selected matches, or none selected)
const qualityFiltersActive = filterHiRes || filterCdQuality || filterLossy;
let passesQuality = !qualityFiltersActive; // Pass if no quality filters
if (qualityFiltersActive) {
if (filterHiRes && isLossless && (bitDepth >= 24 || album.sample_rate > 48000)) {
passesQuality = true;
}
if (filterCdQuality && isLossless && bitDepth <= 16 && album.sample_rate <= 48000) {
passesQuality = true;
}
if (filterLossy && LOSSY_FORMATS.includes(format)) {
passesQuality = true;
}
}
// Check format (OR logic - pass if any selected matches, or none selected)
const formatFiltersActive = filterFlac || filterAlac || filterApe || filterWav || filterMp3 || filterAac || filterOther;
let passesFormat = !formatFiltersActive; // Pass if no format filters
if (formatFiltersActive) {
if (filterFlac && format === 'flac') passesFormat = true;
if (filterAlac && (format === 'alac' || format === 'm4a')) passesFormat = true;
if (filterApe && format === 'ape') passesFormat = true;
if (filterWav && (format === 'wav' || format === 'wave')) passesFormat = true;
if (filterMp3 && format === 'mp3') passesFormat = true;
if (filterAac && (format === 'aac' || format === 'm4a')) passesFormat = true;
if (filterOther && !['flac', 'alac', 'ape', 'wav', 'wave', 'mp3', 'aac', 'm4a'].includes(format)) passesFormat = true;
}
// Check source (OR logic - pass if any selected matches, or none selected)
const sourceFiltersActive = filterLocalFiles || filterOfflineCache;
let passesSource = !sourceFiltersActive; // Pass if no source filters
if (sourceFiltersActive) {
const source = album.source ?? 'user';
if (filterLocalFiles && (source === 'user' || source === 'plex')) passesSource = true;
if (filterOfflineCache && source === 'qobuz_download') passesSource = true;
}
// AND between sections
return passesQuality && passesFormat && passesSource;
}
function clearAllFilters() {
filterHiRes = false;
filterCdQuality = false;
filterLossy = false;
filterFlac = false;
filterAlac = false;
filterApe = false;
filterWav = false;
filterMp3 = false;
filterAac = false;
filterOther = false;
filterLocalFiles = false;
filterOfflineCache = false;
}
// Album sorting state
type SortBy = 'title' | 'year' | 'artist';
type SortDirection = 'asc' | 'desc';
let sortBy = $state<SortBy>('title');
let sortDirection = $state<SortDirection>('asc');
let showSortMenu = $state(false);
const sortOptions: { value: SortBy; label: string }[] = [
{ value: 'title', label: 'Album Name' },
{ value: 'year', label: 'Release Year' },
{ value: 'artist', label: 'Artist Name' }
];
function getSortLabel(): string {
const option = sortOptions.find(o => o.value === sortBy);
return option?.label || 'Album Name';
}
function toggleSortDirection() {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
}
function selectSort(value: SortBy) {
if (sortBy === value) {
toggleSortDirection();
} else {
sortBy = value;
sortDirection = 'asc';
}
showSortMenu = false;
}
function sortAlbums(items: LocalAlbum[]): LocalAlbum[] {
const sorted = [...items];
const dir = sortDirection === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
switch (sortBy) {
case 'title':
return a.title.localeCompare(b.title) * dir;
case 'year': {
// Albums without year go to the end
const yearA = a.year ?? (sortDirection === 'asc' ? 9999 : 0);
const yearB = b.year ?? (sortDirection === 'asc' ? 9999 : 0);
if (yearA !== yearB) return (yearA - yearB) * dir;
// Secondary sort by title
return a.title.localeCompare(b.title);
}
case 'artist':
const artistCompare = a.artist.localeCompare(b.artist) * dir;
if (artistCompare !== 0) return artistCompare;
// Secondary sort by title
return a.title.localeCompare(b.title);
default:
return 0;
}
});
return sorted;
}
// Performance mode state
let useVirtualization = $state(isVirtualizationEnabled());
let virtualizedScrollTarget = $state<string | undefined>(undefined);
// Artist view state
let artistSearch = $state('');
let artistViewMode = $state<'grid' | 'list'>('grid');
let artistGroupingEnabled = $state(true); // Enable alpha grouping by default
let showArtistGroupMenu = $state(false);
// Selected artist for the two-column layout
let selectedArtistName = $state<string | null>(null);
// Reference to the artist list scroll container
let artistListScrollRef: HTMLDivElement | null = $state(null);
let artistImageFetchInProgress = false; // Guard against concurrent fetches
let artistImageFetchAborted = false; // Flag to abort fetching
let trackSearch = $state('');
let tracksHydrationRequestId = 0;
let searchOpen = $state(false);
let searchInputEl = $state<HTMLInputElement | undefined>(undefined);
type TrackGroupMode = 'album' | 'artist' | 'name';
let trackGroupMode = $state<TrackGroupMode>('album');
let trackGroupingEnabled = $state(false);
let showTrackGroupMenu = $state(false);
let trackSearchTimer: ReturnType<typeof setTimeout> | null = null;
// Reference to virtualized track list for programmatic scrolling
let virtualizedTrackListRef = $state<{ scrollToGroup: (groupId: string) => void } | undefined>(undefined);
let albumSearchTimer: ReturnType<typeof setTimeout> | null = null;
let artistSearchTimer: ReturnType<typeof setTimeout> | null = null;
let debouncedAlbumSearch = $state('');
let debouncedArtistSearch = $state('');
// Data state
let albums = $state<LocalAlbum[]>([]);
let hiddenAlbums = $state<LocalAlbum[]>([]);
let artists = $state<LocalArtist[]>([]);
let tracks = $state<LocalTrack[]>([]);
let stats = $state<LibraryStats | null>(null);
let folders = $state<LibraryFolder[]>([]);
let scanProgress = $state<ScanProgress | null>(null);
// Reactive counters based on filtered data
// Note: filteredArtistCount is defined after mergedArtists below
let filteredAlbumCount = $derived(albums.length);
let filteredTrackCount = $derived.by(() => {
// When in tracks view with search results, use actual filtered count
if (activeTab === 'tracks' && tracks.length > 0) {
return tracks.length;
}
// When in albums view, calculate from filtered albums
if (activeTab === 'albums') {
return albums.reduce((sum, album) => sum + album.track_count, 0);
}
// When in artists view, calculate from filtered artists
if (activeTab === 'artists') {
return artists.reduce((sum, artist) => sum + artist.track_count, 0);
}
// Fallback for tracks view when no search results - calculate from albums
// This ensures the counter respects filters even when tracks aren't loaded
return albums.reduce((sum, album) => sum + album.track_count, 0);
});
// Merge artists with same normalized name (e.g., "Alice in Chains" and "Alice In Chains")
// Priority goes to the variant with most albums, then tracks
// Returns both merged list and canonical name mappings
let artistMergeResult = $derived.by(() => {
const byNormalized = new Map<string, LocalArtist[]>();
// Group by normalized name (skip empty names)
for (const artist of artists) {
if (!artist.name || !artist.name.trim()) continue;
const normalized = normalizeArtistName(artist.name);
if (!normalized) continue; // Skip if normalized name is empty
if (!byNormalized.has(normalized)) {
byNormalized.set(normalized, []);
}
byNormalized.get(normalized)!.push(artist);
}
// Build a map of normalized artist -> unique album IDs
// This gives us accurate album counts even when same album has tracks with different artist spellings
const artistAlbumIds = new Map<string, Set<string>>();
for (const album of albums) {
const normalizedAlbumArtist = normalizeArtistName(album.artist);
const isVariousArtists = normalizedAlbumArtist === 'various artists';
// For all albums with all_artists, credit each artist
if (album.all_artists) {
const allArtistsList = album.all_artists.split(',').map(a => normalizeArtistName(a.trim()));
for (const normalizedArtist of allArtistsList) {
if (!normalizedArtist || normalizedArtist === 'various artists') continue;
if (!artistAlbumIds.has(normalizedArtist)) {
artistAlbumIds.set(normalizedArtist, new Set());
}
artistAlbumIds.get(normalizedArtist)!.add(album.id);
}
} else if (!isVariousArtists) {
// Fallback to album.artist (only if not Various Artists)
if (!artistAlbumIds.has(normalizedAlbumArtist)) {
artistAlbumIds.set(normalizedAlbumArtist, new Set());
}
artistAlbumIds.get(normalizedAlbumArtist)!.add(album.id);
}
}
// Helper to get album count for an artist (handles multi-artist names)
function getAlbumCountForArtist(artistName: string, normalized: string): number {
// First try direct lookup
const direct = artistAlbumIds.get(normalized);
if (direct && direct.size > 0) return direct.size;
// For multi-artist names like "A, B, C", find albums where ANY part appears
const parts = artistName.split(/,|&|feat\.|ft\.|featuring|with/i)
.map(p => normalizeArtistName(p.trim()))
.filter(p => p.length > 0);
if (parts.length > 1) {
// Union of all albums where ANY artist appears
const unionAlbums = new Set<string>();
for (const part of parts) {
const partAlbums = artistAlbumIds.get(part);
if (partAlbums) {
for (const albumId of partAlbums) {
unionAlbums.add(albumId);
}
}
}
return unionAlbums.size;
}
return 0;
}
// Merge groups and build canonical mappings
const merged: LocalArtist[] = [];
const newCanonicalMappings = new Map<string, string>();
for (const [normalized, variants] of byNormalized) {
if (variants.length === 1) {
// Single variant - use actual album count
const actualAlbumCount = getAlbumCountForArtist(variants[0].name, normalized);
merged.push({
...variants[0],
album_count: actualAlbumCount || variants[0].album_count
});
continue;
}
// Find the canonical variant (most albums, then most tracks)
const sorted = [...variants].sort((a, b) => {
if (b.album_count !== a.album_count) return b.album_count - a.album_count;
return b.track_count - a.track_count;
});
const canonical = sorted[0];
// Get actual unique album count using helper
const actualAlbumCount = getAlbumCountForArtist(canonical.name, normalized);
// Sum track counts (tracks are unique per artist spelling in DB)
const totalTracks = variants.reduce((sum, v) => sum + v.track_count, 0);
// Create merged artist with canonical name and accurate album count
merged.push({
name: canonical.name,
album_count: actualAlbumCount || canonical.album_count,
track_count: totalTracks
});
// Map all variants to canonical name
for (const variant of variants) {
if (variant.name !== canonical.name) {
newCanonicalMappings.set(variant.name, canonical.name);
}
}
}
return { merged, canonicalMappings: newCanonicalMappings };
});
// Extract merged artists list
let mergedArtists = $derived(artistMergeResult.merged);
// Artist count uses merged artists (after deduplication)
let filteredArtistCount = $derived(mergedArtists.length);
// Combined canonical names: merge mappings + Qobuz mappings
// This is a derived that combines both sources without causing infinite loops
let allCanonicalNames = $derived.by(() => {
const combined = new Map(canonicalNames); // Start with Qobuz mappings
// Add merge mappings (these take precedence for case normalization)
for (const [variant, canonical] of artistMergeResult.canonicalMappings) {
combined.set(variant, canonical);
}
return combined;
});
// Memoized filtered artists (uses merged artists)
let filteredArtistsMemo = $derived.by(() => {
if (!debouncedArtistSearch) return mergedArtists;
const needle = debouncedArtistSearch.toLowerCase();
return mergedArtists.filter(artist => artist.name.toLowerCase().includes(needle));
});
// Memoized grouped artists with alpha index and display names
let groupedArtistsMemo = $derived.by(() => {
const filtered = filteredArtistsMemo;
// Add display names from canonical names mapping
const withDisplayNames = filtered.map(artist => ({
...artist,
displayName: allCanonicalNames.get(artist.name) || artist.name
}));
if (!artistGroupingEnabled) {
return {
grouped: [{ key: '', id: 'ungrouped', artists: withDisplayNames }],
alphaGroups: new Set<string>()
};
}
// Group by first letter of DISPLAY name (canonical name if available)
const groups = new Map<string, (LocalArtist & { displayName: string })[]>();
for (const artist of withDisplayNames) {
const key = alphaGroupKey(artist.displayName);
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(artist);
}
// Sort keys (# at end)
const keys = [...groups.keys()].sort((a, b) => {
if (a === '#') return 1;
if (b === '#') return -1;
return a.localeCompare(b);
});
const grouped = keys.map(key => ({
key,
id: `artist-alpha-${key}`,
// Sort artists within group by display name
artists: (groups.get(key) ?? []).sort((a, b) =>
a.displayName.localeCompare(b.displayName)
)
}));
return {
grouped,
alphaGroups: new Set(keys)
};
});
// Memoized filtered and grouped albums
let filteredAndGroupedAlbums = $derived.by(() => {
// Filter albums by search and quality
let filtered = albums;
// Apply search filter
if (debouncedAlbumSearch) {
filtered = filtered.filter(album => matchesAlbumSearchFast(album, debouncedAlbumSearch));
}
// Apply quality/format filters (checkboxes)
if (hasActiveFilters) {
filtered = filtered.filter(album => matchesQualityFilters(album));
}
// Apply sorting
filtered = sortAlbums(filtered);
// Group if enabled
if (!albumGroupingEnabled) {
return {
filtered,
grouped: [{ key: '', id: 'ungrouped', albums: filtered }],
alphaGroups: new Set<string>()
};
}
const grouped = groupAlbumsOptimized(filtered, albumGroupMode);
const alphaGroups = albumGroupMode === 'alpha'
? new Set(grouped.map(g => g.key))
: new Set<string>();
return { filtered, grouped, alphaGroups };
});
// Albums for the selected artist (used in artist view two-column layout)
// Includes multi-artist albums where the selected artist appears
let selectedArtistAlbums = $derived.by(() => {
if (!selectedArtistName) return [];
const normalizedSelected = normalizeArtistName(selectedArtistName);
// Special case: when user selects "Various Artists" from the list,
// show all albums where the album artist is "Various Artists"
if (normalizedSelected === 'various artists') {
return albums.filter(album =>
normalizeArtistName(album.artist) === 'various artists'
);
}
// Split selected artist into parts for multi-artist matching
// e.g., "Katsutoshi Kitagawa, Mina Kubota" -> ["katsutoshi kitagawa", "mina kubota"]
const selectedParts = selectedArtistName.split(/,|&|feat\.|ft\.|featuring|with/i)
.map(p => normalizeArtistName(p.trim()))
.filter(p => p.length > 0);
return albums.filter(album => {
const normalizedArtist = normalizeArtistName(album.artist);
const allArtistsList = album.all_artists
? album.all_artists.split(',').map(a => normalizeArtistName(a.trim()))
: [];
// For "Various Artists" albums: include if ANY part of the artist name is in all_artists
if (normalizedArtist === 'various artists') {
if (allArtistsList.length === 0) return false;
// Check exact match first
if (allArtistsList.includes(normalizedSelected)) return true;
// Check if ANY part of a multi-artist name is present
if (selectedParts.length > 1 && selectedParts.some(part => allArtistsList.includes(part))) {
return true;
}
return false;
}
// Exact match on display artist
if (normalizedArtist === normalizedSelected) return true;
// Check all_artists field for EXACT matches
if (allArtistsList.includes(normalizedSelected)) return true;
// Check if ANY part of a multi-artist name is present in all_artists
if (selectedParts.length > 1 && allArtistsList.length > 0) {
if (selectedParts.some(part => allArtistsList.includes(part))) return true;
}
// Also check individual parts split by common separators in album.artist
const artistParts = album.artist.split(/,|&|feat\.|ft\.|featuring|with/i)
.map(p => normalizeArtistName(p.trim()))
.filter(p => p.length > 0);
return artistParts.includes(normalizedSelected);
});
});
// Fast album search without function call overhead
function matchesAlbumSearchFast(album: LocalAlbum, needle: string): boolean {
const lowerNeedle = needle.toLowerCase();
return (
album.title.toLowerCase().includes(lowerNeedle) ||
album.artist.toLowerCase().includes(lowerNeedle)
);
}
// Optimized grouping that avoids unnecessary allocations
function groupAlbumsOptimized(items: LocalAlbum[], mode: AlbumGroupMode) {
const prefix = `album-${mode}`;
// Build groups without sorting first (sort within groups)
const groups = new Map<string, LocalAlbum[]>();
for (const album of items) {
// Use canonical name for artist grouping to merge "Alice in Chains" and "Alice In Chains"
const key = mode === 'artist'
? (allCanonicalNames.get(album.artist) || album.artist)
: alphaGroupKey(album.title);
let group = groups.get(key);
if (!group) {
group = [];
groups.set(key, group);
}
group.push(album);
}
// Sort keys
const keys = [...groups.keys()].sort((a, b) => {
if (mode === 'alpha') {
if (a === '#') return 1;
if (b === '#') return -1;
}
return a.localeCompare(b);
});
// Sort albums within each group and build result
return keys.map(key => {
const albumsInGroup = groups.get(key) ?? [];
albumsInGroup.sort((a, b) => a.title.localeCompare(b.title));
return {
key,
id: groupIdForKey(prefix, key),
albums: albumsInGroup
};
});
}
// Memoized filtered and grouped tracks
let groupedTracksMemo = $derived.by(() => {
if (!trackGroupingEnabled) {
return {
grouped: [{ id: 'ungrouped', title: '', subtitle: '', tracks, key: '' }],
alphaGroups: new Set<string>(),
indexTargets: new Map<string, string>()
};
}
const grouped = groupTracks(tracks, trackGroupMode);
// Build alpha index targets for artist mode
let indexTargets = new Map<string, string>();
if (trackGroupMode === 'artist') {
for (const group of grouped) {
const letter = alphaGroupKey(group.title);
if (!indexTargets.has(letter)) {
indexTargets.set(letter, group.id);
}
}
}
// Build alpha groups set
const alphaGroups = trackGroupMode === 'name'
? new Set(grouped.map(group => group.key))
: trackGroupMode === 'artist'
? new Set(indexTargets.keys())
: new Set<string>();
return { grouped, alphaGroups, indexTargets };
});
// Loading state
let loading = $state(false);
let scanning = $state(false);
let error = $state<string | null>(null);
let plexRepairAttempted = $state(false);
let plexRepairInProgress = $state(false);
let plexRepairQueued = $state(false);
let fetchingArtwork = $state(false);
let updatingArtwork = $state(false);
let hasDiscogsCredentials = $state(false);
let isOffline = $state(checkIsOffline());
// Album detail state (for viewing album tracks)
let selectedAlbum = $state<LocalAlbum | null>(null);
let albumTracks = $state<LocalTrack[]>([]);
// Qobuz artist images cache (artist name -> image URL)
let artistImages = $state<Map<string, string>>(new Map());
// Canonical artist names mapping (local name -> Qobuz/Discogs canonical name)
let canonicalNames = $state<Map<string, string>>(new Map());
// Album edit modal state
let showAlbumEditModal = $state(false);
let showTagEditorModal = $state(false);
let refreshingAlbumMetadata = $state(false);
let albumMetadataRefreshed = $state(false);
let editingAlbumHidden = $state(false);
let discogsImageOptions = $state<DiscogsImageOption[]>([]);
let selectedDiscogsImage = $state<string | null>(null);
let fetchingDiscogsImages = $state(false);
let discogsFetchSuccessful = $state(false);
let discogsImagePage = $state(0);
const IMAGES_PER_PAGE = 3;
// Folder selection state (by folder ID)
let selectedFolders = $state<Set<number>>(new Set());
// Folder settings modal state
let showFolderSettingsModal = $state(false);
let editingFolder = $state<LibraryFolder | null>(null);
// Folder accessibility cache
let folderAccessibility = $state<Map<number, boolean>>(new Map());
let unsubscribeNav: (() => void) | null = null;
let unsubscribeOffline: (() => void) | null = null;
let unsubscribePerformance: (() => void) | null = null;
// Reactive effect: reload library when download settings change
$effect(() => {
// Access the store value to create a reactive dependency
const version = $downloadSettingsVersion;
// Skip the initial mount (version 0)
if (version > 0) {
console.log('Download settings changed, reloading library data');
loadLibraryData();
}
});
// Reactive effect: reload library when offline state changes
let previousOfflineState = $state<boolean | undefined>(undefined);
$effect(() => {
// Skip the initial mount
if (previousOfflineState !== undefined && previousOfflineState !== isOffline) {
console.log('Offline state changed to:', isOffline, '- reloading library data');
loadLibraryData();
}
previousOfflineState = isOffline;
});
// Reactive effect: update virtualization state when album or track count changes
// Use max of both to ensure large track libraries also trigger virtualization
$effect(() => {
const itemCount = Math.max(albums.length, tracks.length);
useVirtualization = isVirtualizationEnabled() && shouldUsePerformanceMode(itemCount);
});
onMount(async () => {
await loadLibraryData();
// Load folders (now safe in offline mode - uses library_get_folders instead)
loadFolders(); // Load in background - doesn't block UI
checkDiscogsCredentials();
// Subscribe to offline state changes
unsubscribeOffline = subscribeOffline(() => {
isOffline = checkIsOffline();
});
// Subscribe to performance settings changes
unsubscribePerformance = subscribePerformance(() => {
const itemCount = Math.max(albums.length, tracks.length);
useVirtualization = isVirtualizationEnabled() && shouldUsePerformanceMode(itemCount);
});
// Subscribe to navigation changes for back/forward support
unsubscribeNav = subscribeNav(() => {
const navState = getNavigationState();
// When navigating to library-album, load the album if we have an ID
if (navState.activeView === 'library-album' && navState.selectedLocalAlbumId) {
const albumId = navState.selectedLocalAlbumId;
// Find album in current list or load it
const album = albums.find(a => a.id === albumId);
if (album && (!selectedAlbum || selectedAlbum.id !== albumId)) {
loadAlbumById(albumId);
}
}
// When navigating back to library (from library-album), clear album selection
if (navState.activeView === 'library' && selectedAlbum) {
selectedAlbum = null;
albumTracks = [];
}
});
// Check if we should show an album on initial load (forward navigation)
const initialNavState = getNavigationState();
if (initialNavState.activeView === 'library-album' && initialNavState.selectedLocalAlbumId) {
loadAlbumById(initialNavState.selectedLocalAlbumId);
}
});
onDestroy(() => {
// Abort any ongoing artist image fetch
artistImageFetchAborted = true;
if (unsubscribeNav) {
unsubscribeNav();
}
if (unsubscribeOffline) {
unsubscribeOffline();
}
if (unsubscribePerformance) {
unsubscribePerformance();
}
});
async function loadAlbumById(albumId: string) {
try {
// Find album in current list
let album = albums.find(a => a.id === albumId);
// If not found in loaded albums, we need to fetch album list first
if (!album) {
const includePlex = isPlexLibraryEnabled();
const [localAlbums, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('v2_library_get_albums', {
includeHidden: false,
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedAlbum[]>('v2_plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const allAlbums = [...localAlbums, ...plexAlbumsRaw.map(mapPlexAlbum)];
albums = allAlbums;
album = allAlbums.find(a => a.id === albumId);
}
if (album) {
selectedAlbum = album;
albumTracks = await fetchAlbumTracks(album);
}
} catch (err) {
console.error('Failed to load album:', err);
}
}
async function checkDiscogsCredentials() {
try {
hasDiscogsCredentials = await invoke<boolean>('discogs_has_credentials');
} catch {
hasDiscogsCredentials = false;
}
}
/**
* Determine if we should hide network folder content based on offline state
* - Not offline: Show everything
* - Offline real (no_network): Always hide network content
* - Offline manual: Hide network content ONLY if user disabled the setting
*/
function shouldExcludeNetworkFolders(): boolean {
try {
console.log('[LocalLibrary] shouldExcludeNetworkFolders called, isOffline:', isOffline);
if (!isOffline) return false;
const reason = getOfflineReason();
const offlineSettings = getOfflineSettings();
console.log('[LocalLibrary] Offline reason:', reason, 'Settings:', offlineSettings);
if (reason === 'no_network') {
// No internet connection - always hide network folders
console.log('[LocalLibrary] Excluding network folders (no_network)');
return true;
}
if (reason === 'manual_override') {
// Manual offline mode - respect user preference
const exclude = !offlineSettings.showNetworkFoldersInManualOffline;
console.log('[LocalLibrary] Excluding network folders (manual):', exclude);
return exclude;
}
// Default: hide network content when offline
console.log('[LocalLibrary] Excluding network folders (default)');
return true;
} catch (err) {
console.error('[LocalLibrary] Error in shouldExcludeNetworkFolders:', err);
return false; // On error, don't filter
}
}
function isPlexLibraryEnabled(): boolean {
if (getUserItem('qbz-plex-enabled') !== 'true') return false;
// Disable Plex when no network is detected — Plex streams from a server
// manual_override keeps Plex active: local Plex server may still be reachable
if (isOffline && getOfflineReason() === 'no_network') return false;
return true;
}
function buildPlexArtworkUrl(path: string): string {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return path;
const base = baseUrl.replace(/\/+$/, '');
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
function normalizePlexAlbumTitle(artist: string, album: string): string {
const artistName = artist.trim();
const albumTitle = album.trim();
if (!artistName || !albumTitle) return albumTitle;
for (const sep of [' - ', ' — ', ' – ', ': ']) {
const prefix = `${artistName}${sep}`;
if (albumTitle.startsWith(prefix)) {
return albumTitle.slice(prefix.length).trim();
}
}
return albumTitle;
}
function isLikelyLegacyPlexCache(plexAlbums: PlexCachedAlbum[]): boolean {
if (plexAlbums.length < 20) return false;
const suspicious = plexAlbums.filter(album =>
album.title.trim().toLowerCase() === album.artist.trim().toLowerCase()
).length;
return suspicious >= 20 || suspicious / plexAlbums.length >= 0.2;
}
async function repairLegacyPlexCache(): Promise<boolean> {
if (plexRepairInProgress) return false;
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return false;
plexRepairInProgress = true;
try {
const startedAt = performance.now();
const sections = await invoke<PlexMusicSection[]>('v2_plex_get_music_sections', {
baseUrl,
token
});
await invoke('v2_plex_cache_save_sections', { serverId: null, sections });
const sectionMetrics = await Promise.all(
sections.map(async (section) => {
const sectionStart = performance.now();
const sectionTracks = await invoke<PlexTrack[]>('v2_plex_get_section_tracks', {
baseUrl,
token,
sectionKey: section.key
});
await invoke('v2_plex_cache_save_tracks', {
serverId: null,
sectionKey: section.key,
tracks: sectionTracks
});
return {
key: section.key,
tracks: sectionTracks.length,
ms: performance.now() - sectionStart
};
})
);
const totalTracks = sectionMetrics.reduce((sum, item) => sum + item.tracks, 0);
const elapsedMs = performance.now() - startedAt;
console.log('[PlexBench] cache repair completed', {
sections: sections.length,
totalTracks,
elapsedMs: Number(elapsedMs.toFixed(1)),
slowestSections: sectionMetrics
.slice()
.sort((a, b) => b.ms - a.ms)
.slice(0, 3)
.map((item) => ({ key: item.key, tracks: item.tracks, ms: Number(item.ms.toFixed(1)) }))
});
return true;
} catch (err) {
console.error('[LocalLibrary] Failed to auto-repair Plex cache:', err);
return false;
} finally {
plexRepairInProgress = false;
}
}
async function queuePlexRepairInBackground() {
if (plexRepairQueued || plexRepairInProgress) return;
plexRepairQueued = true;
try {
const repaired = await repairLegacyPlexCache();
if (repaired) {
await loadLibraryData({ background: true });
}
} finally {
plexRepairQueued = false;
}
}
function mapPlexAlbum(plexAlbum: PlexCachedAlbum): LocalAlbum {
const artist = plexAlbum.artist?.trim() || 'Unknown Artist';
const title = normalizePlexAlbumTitle(artist, plexAlbum.title || 'Unknown Album');
return {
id: plexAlbum.id,
title: title || 'Unknown Album',
artist,
artwork_path: plexAlbum.artworkPath,
track_count: plexAlbum.trackCount,
total_duration_secs: plexAlbum.totalDurationSecs,
format: plexAlbum.format,
bit_depth: plexAlbum.bitDepth,
sample_rate: plexAlbum.sampleRate,
directory_path: '',
source: 'plex',
likely_single_file_album: plexAlbum.likelySingleFileAlbum
};
}
function mapPlexTrack(plexTrack: PlexCachedTrack): LocalTrack {
const artist = plexTrack.artist?.trim() || 'Unknown Artist';
const album = normalizePlexAlbumTitle(artist, plexTrack.album || 'Unknown Album');
return {
id: plexTrack.id,
file_path: plexTrack.ratingKey,
title: plexTrack.title,
artist,
album: album || 'Unknown Album',
album_artist: artist,
album_group_key: plexTrack.albumKey,
album_group_title: album || 'Unknown Album',
track_number: undefined,
disc_number: undefined,
duration_secs: plexTrack.durationSecs,
format: plexTrack.format,
bit_depth: plexTrack.bitDepth,
sample_rate: plexTrack.sampleRate,
channels: 2,
file_size_bytes: 0,
artwork_path: plexTrack.artworkPath,
last_modified: 0,
indexed_at: 0,
source: 'plex'
};
}
async function loadLibraryData(options: { background?: boolean } = {}) {
const background = options.background === true;
const startedAt = performance.now();
console.log('[LocalLibrary] loadLibraryData START, isOffline:', isOffline, 'background:', background);
if (!background) {
loading = true;
error = null;
}
try {
const excludeNetwork = shouldExcludeNetworkFolders();
console.log('[LocalLibrary] Calling library_get_albums with excludeNetwork:', excludeNetwork);
const fetchStart = performance.now();
const includePlex = isPlexLibraryEnabled();
const [albumsResult, statsResult, plexAlbumsRaw] = await Promise.all([
invoke<LocalAlbum[]>('v2_library_get_albums', {
includeHidden: false,
excludeNetworkFolders: excludeNetwork
}),
invoke<LibraryStats>('v2_library_get_stats'),
includePlex
? invoke<PlexCachedAlbum[]>('v2_plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const fetchMs = performance.now() - fetchStart;
let workingPlexAlbumsRaw = plexAlbumsRaw;
const shouldAttemptRepair =
includePlex &&
!plexRepairAttempted &&
(isLikelyLegacyPlexCache(workingPlexAlbumsRaw) || workingPlexAlbumsRaw.length <= 1);
if (shouldAttemptRepair) {
plexRepairAttempted = true;
void queuePlexRepairInBackground();
}
const mapStart = performance.now();
const plexAlbums = workingPlexAlbumsRaw.map(mapPlexAlbum);
const mapMs = performance.now() - mapStart;
console.log('[LocalLibrary] Received albums:', albumsResult.length, 'plex albums:', plexAlbums.length);
console.log('[LocalLibrary] Received stats:', statsResult);
const plexTrackCount = plexAlbums.reduce((sum, album) => sum + album.track_count, 0);
const plexDurationSecs = plexAlbums.reduce((sum, album) => sum + album.total_duration_secs, 0);
albums = [...albumsResult, ...plexAlbums];
stats = {
...statsResult,
track_count: statsResult.track_count + plexTrackCount,
album_count: statsResult.album_count + plexAlbums.length,
total_duration_secs: statsResult.total_duration_secs + plexDurationSecs
};
console.log('[PlexBench] loadLibraryData', {
background,
localAlbums: albumsResult.length,
plexAlbums: plexAlbums.length,
fetchMs: Number(fetchMs.toFixed(1)),
mapMs: Number(mapMs.toFixed(1)),
totalMs: Number((performance.now() - startedAt).toFixed(1)),
repairQueued: shouldAttemptRepair
});
// Background hydration: fetch quality for Plex tracks with missing data
if (includePlex && plexAlbums.length > 0) {
void hydrateUnhydratedPlexTracks();
}
} catch (err) {
console.error('[LocalLibrary] Failed to load library:', err);
if (!background) {
error = String(err);
}
} finally {
if (!background) {
console.log('[LocalLibrary] Setting loading = false');
loading = false;
}
}
}
async function loadFolders() {
try {
console.log('[LocalLibrary] loadFolders START, isOffline:', isOffline);
if (isOffline) {
// When offline, get folders without calling is_network_path (blocks offline)
// Use basic folder list command instead
folders = await invoke<LibraryFolder[]>('v2_library_get_folders_with_metadata');
console.log('[LocalLibrary] Received folders (offline mode):', folders.length);
// Mark all network folders as inaccessible, local folders as accessible
for (const folder of folders) {
folderAccessibility.set(folder.id, !folder.isNetwork);
}
folderAccessibility = new Map(folderAccessibility);
} else {
// When online, use the full metadata call with network detection
const timeoutPromise = new Promise<LibraryFolder[]>((_, reject) =>
setTimeout(() => reject(new Error('Folder loading timeout')), 5000)
);
const foldersPromise = invoke<LibraryFolder[]>('v2_library_get_folders_with_metadata');
folders = await Promise.race([foldersPromise, timeoutPromise]);
console.log('[LocalLibrary] Received folders (online mode):', folders.length);
// Check accessibility for network folders
for (const folder of folders) {
if (folder.isNetwork) {
checkFolderAccessibility(folder);
} else {
folderAccessibility.set(folder.id, true);
}
}
folderAccessibility = new Map(folderAccessibility);
}
} catch (err) {
console.error('[LocalLibrary] Failed to load folders (timeout or error):', err);
// Continue anyway - folders are not critical for basic library functionality
}
}
async function checkFolderAccessibility(folder: LibraryFolder) {
try {
const accessible = await invoke<boolean>('v2_library_check_folder_accessible', { path: folder.path });
folderAccessibility.set(folder.id, accessible);
folderAccessibility = new Map(folderAccessibility);
} catch (err) {
console.error('Failed to check folder accessibility:', err);
folderAccessibility.set(folder.id, false);
folderAccessibility = new Map(folderAccessibility);
}
}
async function loadArtists() {
console.log('[LocalLibrary] loadArtists START');
loading = true;
try {
console.log('[LocalLibrary] Calling library_get_artists + plex_cache_get_albums');
const includePlex = isPlexLibraryEnabled();
const [localArtists, plexAlbumsRaw] = await Promise.all([
invoke<LocalArtist[]>('v2_library_get_artists', {
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedAlbum[]>('v2_plex_cache_get_albums').catch(() => [])
: Promise.resolve([])
]);
const plexArtistMap = new Map<string, { albumCount: number; trackCount: number }>();
for (const rawAlbum of plexAlbumsRaw) {
const name = (rawAlbum.artist || '').trim();
if (!name) continue;
const current = plexArtistMap.get(name) ?? { albumCount: 0, trackCount: 0 };
current.albumCount += 1;
current.trackCount += rawAlbum.trackCount ?? 0;
plexArtistMap.set(name, current);
}
const mergedArtistMap = new Map<string, LocalArtist>();
for (const localArtist of localArtists) {
mergedArtistMap.set(localArtist.name, { ...localArtist });
}
for (const [name, metrics] of plexArtistMap) {
const existing = mergedArtistMap.get(name);
if (existing) {
existing.album_count += metrics.albumCount;
existing.track_count += metrics.trackCount;
} else {
mergedArtistMap.set(name, {
name,
album_count: metrics.albumCount,
track_count: metrics.trackCount
});
}
}
artists = Array.from(mergedArtistMap.values()).sort((a, b) => a.name.localeCompare(b.name));
console.log('[LocalLibrary] Received artists:', artists.length, 'local:', localArtists.length, 'plex:', plexArtistMap.size);
// Load cached artist images from database
await loadCachedArtistImages();
// Fetch missing images in background if enabled
const fetchEnabled = getUserItem('qbz-fetch-artist-images') !== 'false';
if (fetchEnabled) {
fetchMissingArtistImages();
}
} catch (err) {
console.error('[LocalLibrary] Failed to load artists:', err);
error = String(err);
} finally {
console.log('[LocalLibrary] loadArtists COMPLETE');
loading = false;
}
}
async function loadTracks(query = '') {
console.log('[LocalLibrary] loadTracks START, query:', query);
const requestId = ++tracksHydrationRequestId;
loading = true;
try {
console.log('[LocalLibrary] Calling library_search + plex_cache_search_tracks');
const includePlex = isPlexLibraryEnabled();
const [localTracks, plexTracksRaw] = await Promise.all([
invoke<LocalTrack[]>('v2_library_search', {
query,
limit: 0, // 0 = no limit, virtualization handles any list size
excludeNetworkFolders: shouldExcludeNetworkFolders()
}),
includePlex
? invoke<PlexCachedTrack[]>('v2_plex_cache_search_tracks', {
query
}).catch(() => [])
: Promise.resolve([])
]);
const mappedPlexTracks = plexTracksRaw.map(mapPlexTrack);
tracks = [...localTracks, ...mappedPlexTracks];
console.log('[LocalLibrary] Received tracks:', tracks.length, 'local:', localTracks.length, 'plex:', plexTracksRaw.length);
// Hydrate Plex quality in the background; don't block rendering track lists.
// Guard with requestId to avoid stale updates after a newer search.
void hydratePlexTrackQuality(mappedPlexTracks)
.then((hydratedPlexTracks) => {
if (requestId !== tracksHydrationRequestId) return;
tracks = [...localTracks, ...hydratedPlexTracks];
})
.catch((error) => {
console.warn('[LocalLibrary] Background Plex quality hydration failed:', error);
});
} catch (err) {
console.error('[LocalLibrary] Failed to load tracks:', err);
error = String(err);
} finally {
console.log('[LocalLibrary] loadTracks COMPLETE');
loading = false;
}
}
function handleTabChange(tab: TabType) {
activeTab = tab;
// If we're viewing an album, navigate back to library
const navState = getNavigationState();
if (navState.activeView === 'library-album') {
clearLocalAlbum();
navigateTo('library');
}
// Clear local state
selectedAlbum = null;
albumTracks = [];
if (tab === 'artists' && artists.length === 0) {
loadArtists();
} else if (tab === 'tracks' && tracks.length === 0) {
loadTracks(trackSearch.trim());
}
}
async function handleAddFolder() {
try {
const selected = await open({
directory: true,
multiple: false,
title: 'Select Music Folder'
});
if (!selected || typeof selected !== 'string') return;
const newFolder = await invoke<LibraryFolder>('v2_library_add_folder', { path: selected });
await loadFolders();
// Show warning if network folder detected
if (newFolder.isNetwork) {
alert($t('library.networkFolderDetected'));
}
} catch (err) {
console.error('Failed to add folder:', err);
}
}
async function handleRemoveFolder(folder: LibraryFolder) {
const displayName = folder.alias || folder.path;
if (!confirm(`Remove "${displayName}" from library? This will remove all indexed tracks from this folder.`)) {
return;
}
try {
await invoke('v2_library_remove_folder', { path: folder.path });
selectedFolders.delete(folder.id);
selectedFolders = new Set(selectedFolders);
await loadFolders();
await loadLibraryData();
} catch (err) {
console.error('Failed to remove folder:', err);
alert(`Failed to remove folder: ${err}`);
}
}
function toggleFolderSelection(folderId: number) {
if (selectedFolders.has(folderId)) {
selectedFolders.delete(folderId);
} else {
selectedFolders.add(folderId);
}
selectedFolders = new Set(selectedFolders);
}
function handleEditFolder() {
if (selectedFolders.size !== 1) return;
const folderId = Array.from(selectedFolders)[0];
editingFolder = folders.find(f => f.id === folderId) || null;
if (editingFolder) {
showFolderSettingsModal = true;
}
}
function handleFolderSettingsSave(updatedFolder: LibraryFolder) {
// Update folder in list
const index = folders.findIndex(f => f.id === updatedFolder.id);
if (index !== -1) {
folders[index] = updatedFolder;
folders = [...folders];
}
editingFolder = null;
}
async function handleScanSingleFolder(folderId: number) {
try {
scanning = true;
await invoke('v2_library_scan_folder', { folderId });
// Start polling for progress
const progressInterval = setInterval(async () => {
scanProgress = await invoke<ScanProgress>('v2_library_get_scan_progress');
if (scanProgress.status === 'Complete' || scanProgress.status === 'Cancelled' || scanProgress.status === 'Error') {
clearInterval(progressInterval);
scanning = false;
await loadLibraryData();
await loadFolders();
}
}, 500);
} catch (err) {
console.error('Failed to scan folder:', err);
scanning = false;
alert(`Failed to scan folder: ${err}`);
}
}
async function handleRemoveSelectedFolders() {
if (selectedFolders.size === 0) return;
const count = selectedFolders.size;
if (!confirm(`Remove ${count} selected folder${count > 1 ? 's' : ''}? This will remove all indexed tracks from these folders.`)) return;
try {
for (const folderId of selectedFolders) {
const folder = folders.find(f => f.id === folderId);
if (folder) {
await invoke('v2_library_remove_folder', { path: folder.path });
}
}
selectedFolders.clear();
selectedFolders = new Set(selectedFolders);
await loadFolders();
await loadLibraryData();
} catch (err) {
console.error('Failed to remove folders:', err);
alert(`Failed to remove folders: ${err}`);
}
}
async function handleScan() {
if (folders.length === 0) {
alert('Please add at least one folder to scan.');
return;
}
scanning = true;
scanProgress = {
status: 'Scanning',
total_files: 0,
processed_files: 0,
current_file: undefined,
errors: []
};
// Start polling for progress
const pollInterval = setInterval(async () => {
try {
scanProgress = await invoke<ScanProgress>('v2_library_get_scan_progress');
if (scanProgress.status === 'Complete' || scanProgress.status === 'Cancelled' || scanProgress.status === 'Error') {
clearInterval(pollInterval);
scanning = false;
await loadLibraryData();
if (activeTab === 'artists') await loadArtists();
if (activeTab === 'tracks') await loadTracks();
}
} catch (err) {
console.error('Failed to get scan progress:', err);
}
}, 500);
try {
await invoke('v2_library_scan');
} catch (err) {
console.error('Scan failed:', err);
scanning = false;
clearInterval(pollInterval);
}
}
async function handleStopScan() {
try {
await invoke('v2_library_stop_scan');
} catch (err) {
console.error('Failed to stop scan:', err);
}
}
let clearingLibrary = $state(false);
let cleaningUpMissingFiles = $state(false);
let cleanupStatus = $state('');
async function handleCleanupMissingFiles() {
if (cleaningUpMissingFiles) return;
cleaningUpMissingFiles = true;
cleanupStatus = 'Scanning track paths...';
try {
const result = await invoke<{ checked: number; removed: number }>('v2_library_cleanup_missing_files');
if (result.removed > 0) {
cleanupStatus = `Removed ${result.removed} of ${result.checked} tracks`;
showToast($t('toast.removedMissingFiles', { values: { count: result.removed } }), 'success');
// Reload library data
cleanupStatus = 'Refreshing library...';
await loadLibraryData();
await loadArtists();
} else {
cleanupStatus = `Checked ${result.checked} tracks - all OK`;
showToast($t('toast.noMissingFilesFound'), 'info');
}
} catch (err) {
console.error('Failed to cleanup missing files:', err);
cleanupStatus = 'Error during cleanup';
showToast($t('toast.failedCleanupMissing'), 'error');
} finally {
cleaningUpMissingFiles = false;
// Clear status after a delay
setTimeout(() => {
cleanupStatus = '';
}, 3000);
}
}
async function handleClearLibrary(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
if (clearingLibrary) return;
// First confirmation using Tauri dialog (async, properly sequential)
const firstConfirm = await ask(
'This will remove ALL indexed tracks from the database.\n' +
'Your audio files will NOT be deleted.\n\n' +
'You will need to re-scan your folders after this.',
{
title: 'Clear entire library?',
kind: 'warning',
okLabel: 'Continue',
cancelLabel: 'Cancel'
}
);
if (!firstConfirm) {
return;
}
// Second confirmation - only shown after first is confirmed
const secondConfirm = await ask(
'This action cannot be undone.',
{
title: 'Are you absolutely sure?',
kind: 'warning',
okLabel: 'Clear Library',
cancelLabel: 'Cancel'
}
);
if (!secondConfirm) {
return;
}
// Only proceed if both confirmations passed
clearingLibrary = true;
try {
await invoke('v2_library_clear');
await loadLibraryData();
albums = [];
artists = [];
tracks = [];
} catch (err) {
console.error('Failed to clear library:', err);
alert(`Failed to clear library: ${err}`);
} finally {
clearingLibrary = false;
}
}
async function handleFetchMissingArtwork() {
if (!hasDiscogsCredentials) {
alert('Discogs credentials not configured. Please set up DISCOGS_API_CLIENT_KEY and DISCOGS_API_CLIENT_SECRET.');
return;
}
fetchingArtwork = true;
try {
const count = await invoke<number>('v2_library_fetch_missing_artwork');
if (count > 0) {
alert(`Fetched artwork for ${count} albums from Discogs.`);
await loadLibraryData();
} else {
alert('No albums needed artwork updates.');
}
} catch (err) {
console.error('Failed to fetch artwork:', err);
alert(`Failed to fetch artwork: ${err}`);
} finally {
fetchingArtwork = false;
}
}
function applyAlbumArtworkUpdate(groupKey: string, artworkPath: string) {
albums = albums.map(album =>
album.id === groupKey ? { ...album, artwork_path: artworkPath } : album
);
if (selectedAlbum?.id === groupKey) {
selectedAlbum = { ...selectedAlbum, artwork_path: artworkPath };
}
albumTracks = albumTracks.map(track =>
track.album_group_key === groupKey ? { ...track, artwork_path: artworkPath } : track
);
tracks = tracks.map(track =>
track.album_group_key === groupKey ? { ...track, artwork_path: artworkPath } : track
);
}
async function handleSetAlbumArtwork() {
if (!selectedAlbum || updatingArtwork) return;
try {
updatingArtwork = true;
const selected = await open({
title: 'Select Album Artwork',
multiple: false,
directory: false,
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }]
});
if (!selected || typeof selected !== 'string') return;
const cachedPath = await invoke<string>('v2_library_set_album_artwork', {
albumGroupKey: selectedAlbum.id,
artworkPath: selected
});
applyAlbumArtworkUpdate(selectedAlbum.id, cachedPath);
} catch (err) {
console.error('Failed to set album artwork:', err);
alert(`Failed to set artwork: ${err}`);
} finally {
updatingArtwork = false;
}
}
async function handleAlbumClick(album: LocalAlbum) {
// Use navigation store for proper back/forward support
selectLocalAlbum(album.id);
// Also load album data immediately for responsive UI
selectedAlbum = album;
try {
albumTracks = await fetchAlbumTracks(album);
} catch (err) {
console.error('Failed to load album tracks:', err);
}
}
async function handleAlbumPlayFromGrid(album: LocalAlbum) {
const tracks = await fetchAlbumTracks(album);
if (!tracks.length) return;
await setQueueForAlbumTracks(tracks);
await handleTrackPlay(tracks[0]);
}
async function handleAlbumQueueNextFromGrid(album: LocalAlbum) {
if (!onTrackPlayNext) return;
const tracks = await fetchAlbumTracks(album);
if (!tracks.length) return;
for (let i = tracks.length - 1; i >= 0; i--) {
onTrackPlayNext(tracks[i]);
}
}
async function handleAlbumQueueLaterFromGrid(album: LocalAlbum) {
if (!onTrackPlayLater) return;
const tracks = await fetchAlbumTracks(album);
if (!tracks.length) return;
for (const track of tracks) {
onTrackPlayLater(track);
}
}
async function handleTrackPlay(track: LocalTrack) {
try {
const trackSource = track.source === 'plex' ? 'plex' : 'local';
if (selectedAlbum && albumTracks.length > 0) {
const trackIndex = albumTracks.findIndex((albumTrack) => albumTrack.id === track.id);
const trackIds = albumTracks.map(track => track.id);
// Set playback context for Local Library album
await setPlaybackContext(
'local_library',
selectedAlbum.id,
selectedAlbum.title,
selectedAlbum.source === 'plex' ? 'plex' : 'local',
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
await setQueueForAlbumTracks(albumTracks, trackIndex >= 0 ? trackIndex : 0);
} else if (activeTab === 'tracks' && tracks.length > 0) {
const orderedTracks = getDisplayedTrackOrder();
const sameSourceTracks = orderedTracks.filter(item => (item.source === 'plex') === (trackSource === 'plex'));
const trackIndex = sameSourceTracks.findIndex(item => item.id === track.id);
const trackIds = sameSourceTracks.map(item => item.id);
// Set playback context for Local Library tracks view
await setPlaybackContext(
'local_library',
trackSource === 'plex' ? 'plex-tracks' : 'local-tracks',
'Local Tracks',
trackSource,
trackIds,
trackIndex >= 0 ? trackIndex : 0
);
await setQueueForLocalTracks(sameSourceTracks, trackIndex >= 0 ? trackIndex : 0);
}
if (onTrackPlay) {
await Promise.resolve(onTrackPlay(track));
} else if (trackSource === 'local') {
await invoke('v2_library_play_track', { trackId: track.id });
} else {
throw new Error('Plex playback handler not available');
}
} catch (err) {
console.error('Failed to play track:', err);
alert(`Failed to play: ${err}`);
}
}
async function handlePlayAllAlbum() {
if (!selectedAlbum || albumTracks.length === 0) return;
try {
await handleTrackPlay(albumTracks[0]);
} catch (err) {
console.error('Failed to play album:', err);
}
}
async function handleShuffleAllAlbum() {
if (!selectedAlbum || albumTracks.length === 0) return;
try {
console.log('[LocalLibrary Shuffle] Starting shuffle with', albumTracks.length, 'tracks');
// Enable shuffle mode first (V2)
await invoke('v2_set_shuffle', { enabled: true });
// Pick a random track to start with
const randomIndex = Math.floor(Math.random() * albumTracks.length);
const randomTrack = albumTracks[randomIndex];
console.log('[LocalLibrary Shuffle] Starting from random track index:', randomIndex, 'track:', randomTrack.title);
// Play from random track (queue will be shuffled by backend)
await handleTrackPlay(randomTrack);
} catch (err) {
console.error('Failed to shuffle album:', err);
}
}
async function fetchAlbumTracks(album: LocalAlbum): Promise<LocalTrack[]> {
try {
if (album.source === 'plex') {
if (!isPlexLibraryEnabled()) {
return [];
}
const plexTracks = await invoke<PlexCachedTrack[]>('v2_plex_cache_get_album_tracks', {
albumKey: album.id
});
const mappedTracks = plexTracks.map(mapPlexTrack);
return await hydratePlexTrackQuality(mappedTracks);
}
return await invoke<LocalTrack[]>('v2_library_get_album_tracks', {
albumGroupKey: album.id
});
} catch (err) {
console.error('Failed to load album tracks:', err);
return [];
}
}
function isLikelyFallbackPlexQuality(track: LocalTrack): boolean {
if (track.source !== 'plex') return false;
const format = (track.format || '').toLowerCase();
const bitDepth = track.bit_depth ?? 0;
const sampleRate = track.sample_rate ?? 0;
return format === 'flac' && bitDepth <= 16 && sampleRate <= 44100;
}
async function hydratePlexTrackQuality(tracks: LocalTrack[]): Promise<LocalTrack[]> {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token || tracks.length === 0) return tracks;
const candidates = tracks.filter((track) => isLikelyFallbackPlexQuality(track));
if (candidates.length === 0) return tracks;
const metadataEntries = await Promise.all(
candidates.map(async (track) => {
try {
const metadata = await invoke<PlexTrackMetadata>('v2_plex_get_track_metadata', {
baseUrl,
token,
ratingKey: track.file_path
});
return [track.file_path, metadata] as const;
} catch (error) {
console.warn('[LocalLibrary] Failed to hydrate Plex track metadata for', track.file_path, error);
return null;
}
})
);
const metadataByRatingKey = new Map<string, PlexTrackMetadata>();
const qualityUpdates: PlexTrackQualityUpdate[] = [];
for (const entry of metadataEntries) {
if (!entry) continue;
metadataByRatingKey.set(entry[0], entry[1]);
qualityUpdates.push({
ratingKey: entry[0],
container: entry[1].container ?? entry[1].codec,
samplingRateHz: entry[1].samplingRateHz,
bitDepth: entry[1].bitDepth
});
}
if (metadataByRatingKey.size === 0) return tracks;
if (qualityUpdates.length > 0) {
invoke<number>('v2_plex_cache_update_track_quality', { updates: qualityUpdates }).catch((error) => {
console.warn('[LocalLibrary] Failed to persist Plex track quality updates:', error);
});
}
return tracks.map((track) => {
const metadata = metadataByRatingKey.get(track.file_path);
if (!metadata) return track;
return {
...track,
format: (metadata.container ?? metadata.codec ?? track.format).toLowerCase(),
bit_depth: metadata.bitDepth ?? track.bit_depth,
sample_rate: metadata.samplingRateHz ?? track.sample_rate
};
});
}
async function hydrateUnhydratedPlexTracks(): Promise<void> {
try {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return;
const ratingKeys = await invoke<string[]>('v2_plex_cache_get_tracks_needing_hydration', { limit: 50 });
if (ratingKeys.length === 0) return;
console.log('[LocalLibrary] Background hydration: fetching quality for', ratingKeys.length, 'tracks');
const qualityUpdates: PlexTrackQualityUpdate[] = [];
// Fetch metadata in small batches to avoid overwhelming the Plex server
const BATCH_SIZE = 5;
for (let i = 0; i < ratingKeys.length; i += BATCH_SIZE) {
const batch = ratingKeys.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
batch.map(async (ratingKey) => {
try {
const metadata = await invoke<PlexTrackMetadata>('v2_plex_get_track_metadata', {
baseUrl, token, ratingKey
});
return { ratingKey, metadata };
} catch {
return null;
}
})
);
for (const result of results) {
if (!result) continue;
qualityUpdates.push({
ratingKey: result.ratingKey,
container: result.metadata.container ?? result.metadata.codec,
samplingRateHz: result.metadata.samplingRateHz,
bitDepth: result.metadata.bitDepth
});
}
}
if (qualityUpdates.length === 0) return;
// Persist hydrated quality to cache
await invoke<number>('v2_plex_cache_update_track_quality', { updates: qualityUpdates });
console.log('[LocalLibrary] Background hydration: persisted quality for', qualityUpdates.length, 'tracks');
// Refresh album list to show updated quality badges
const plexAlbumsRaw = await invoke<PlexCachedAlbum[]>('v2_plex_cache_get_albums').catch(() => []);
if (plexAlbumsRaw.length > 0) {
const plexAlbums = plexAlbumsRaw.map(mapPlexAlbum);
const localOnly = albums.filter(a => a.source !== 'plex');
albums = [...localOnly, ...plexAlbums];
console.log('[LocalLibrary] Background hydration: refreshed album list with updated quality');
}
} catch (err) {
console.warn('[LocalLibrary] Background hydration failed:', err);
}
}
async function setQueueForLocalTracks(tracks: LocalTrack[], startIndex = 0) {
console.log('[LocalLibrary Queue] Setting queue with', tracks.length, 'tracks, startIndex:', startIndex);
const queueTracks = tracks.map(track => ({
source: track.source === 'plex' ? 'plex' : 'local',
id: track.id,
title: track.title,
artist: track.artist,
album: track.album,
duration_secs: track.duration_secs,
artwork_url: track.artwork_path ? getArtworkUrl(track.artwork_path) : null,
hires: (track.bit_depth && track.bit_depth > 16) || track.sample_rate > 44100,
bit_depth: track.bit_depth ?? null,
sample_rate: track.sample_rate ?? null,
is_local: track.source !== 'plex',
album_id: null, // Local tracks don't have Qobuz IDs
artist_id: null,
}));
console.log('[LocalLibrary Queue] Mapped to', queueTracks.length, 'queue tracks');
console.log('[LocalLibrary Queue] Track IDs:', queueTracks.map(track => track.id));
await invoke('v2_set_queue', { tracks: queueTracks, startIndex });
onSetLocalQueue?.(tracks.filter(track => track.source !== 'plex').map(track => track.id));
console.log('[LocalLibrary Queue] Queue set successfully');
}
async function setQueueForAlbumTracks(tracks: LocalTrack[], startIndex = 0) {
await setQueueForLocalTracks(tracks, startIndex);
}
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}h ${mins}m`;
}
return `${mins} min`;
}
function formatFileSize(bytes: number): string {
if (bytes >= 1073741824) {
return `${(bytes / 1073741824).toFixed(1)} GB`;
}
if (bytes >= 1048576) {
return `${(bytes / 1048576).toFixed(1)} MB`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
async function handleHideAlbum(album: LocalAlbum) {
try {
await invoke('v2_library_set_album_hidden', { albumGroupKey: album.id, hidden: true });
await loadLibraryData();
} catch (err) {
console.error('Failed to hide album:', err);
alert(`Failed to hide album: ${err}`);
}
}
async function handleShowAlbum(album: LocalAlbum) {
try {
await invoke('v2_library_set_album_hidden', { albumGroupKey: album.id, hidden: false });
await loadHiddenAlbums();
await loadLibraryData();
} catch (err) {
console.error('Failed to show album:', err);
alert(`Failed to show album: ${err}`);
}
}
async function loadHiddenAlbums() {
try {
hiddenAlbums = await invoke<LocalAlbum[]>('v2_library_get_albums', {
includeHidden: true,
excludeNetworkFolders: shouldExcludeNetworkFolders()
});
const visibleAlbumIds = new Set(albums.map(a => a.id));
hiddenAlbums = hiddenAlbums.filter(a => !visibleAlbumIds.has(a.id));
} catch (err) {
console.error('Failed to load hidden albums:', err);
}
}
async function toggleHiddenAlbumsView() {
showHiddenAlbums = !showHiddenAlbums;
if (showHiddenAlbums && hiddenAlbums.length === 0) {
await loadHiddenAlbums();
}
}
function openAlbumEditModal() {
if (!selectedAlbum) return;
if (selectedAlbum.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true') {
showToast($t('settings.integrations.plexWriteDisabledNotice'), 'info');
return;
}
editingAlbumHidden = false;
albumMetadataRefreshed = false;
discogsImageOptions = [];
selectedDiscogsImage = null;
discogsFetchSuccessful = false;
showAlbumEditModal = true;
}
function openTagEditorFromAlbumSettings() {
if (!selectedAlbum) return;
showAlbumEditModal = false;
showTagEditorModal = true;
}
async function handleTagEditorSaved() {
if (!selectedAlbum) return;
await loadLibraryData();
await loadAlbumById(selectedAlbum.id);
}
async function handleRefreshAlbumMetadataFromFiles() {
if (!selectedAlbum || refreshingAlbumMetadata) return;
const confirmed = await ask(
'This will re-read embedded metadata from the audio files and discard QBZ sidecar overrides for this album.',
{
title: 'Refresh metadata from files?',
kind: 'warning',
okLabel: 'Refresh',
cancelLabel: 'Cancel'
}
);
if (!confirmed) return;
try {
refreshingAlbumMetadata = true;
albumMetadataRefreshed = false;
await invoke('v2_library_refresh_album_metadata_from_files', { albumGroupKey: selectedAlbum.id });
showToast($t('toast.metadataRefreshed'), 'success');
albumMetadataRefreshed = true;
await handleTagEditorSaved();
} catch (err) {
console.error('Failed to refresh metadata:', err);
alert(`Failed to refresh metadata: ${err}`);
} finally {
refreshingAlbumMetadata = false;
}
}
async function fetchDiscogsArtwork() {
if (!selectedAlbum || fetchingDiscogsImages) return;
try {
fetchingDiscogsImages = true;
discogsImageOptions = [];
selectedDiscogsImage = null;
discogsImagePage = 0;
discogsFetchSuccessful = false;
const options = await invoke<DiscogsImageOption[]>('discogs_search_artwork', {
artist: selectedAlbum.artist,
album: selectedAlbum.title,
catalogNumber: selectedAlbum.catalog_number || null
});
discogsImageOptions = options;
discogsFetchSuccessful = options.length > 0;
if (options.length === 0) {
alert('No artwork found on Discogs for this album.');
}
console.log(`Found ${options.length} Discogs artwork options`);
} catch (err) {
console.error('Failed to fetch Discogs artwork:', err);
alert(`Failed to fetch Discogs artwork: ${err}`);
} finally {
fetchingDiscogsImages = false;
}
}
// Computed values for Discogs image pagination
const paginatedDiscogsImages = $derived(
discogsImageOptions.slice(
discogsImagePage * IMAGES_PER_PAGE,
(discogsImagePage + 1) * IMAGES_PER_PAGE
)
);
const hasMoreDiscogsPages = $derived(
discogsImageOptions.length > (discogsImagePage + 1) * IMAGES_PER_PAGE
);
const hasPrevDiscogsPages = $derived(discogsImagePage > 0);
function nextDiscogsPage() {
if (hasMoreDiscogsPages) {
discogsImagePage++;
}
}
function prevDiscogsPage() {
if (hasPrevDiscogsPages) {
discogsImagePage--;
}
}
async function saveAlbumEdit() {
if (!selectedAlbum) return;
try {
// If a Discogs image was selected, download and set it
if (selectedDiscogsImage) {
console.log('Downloading Discogs artwork from:', selectedDiscogsImage);
const localPath = await invoke<string>('discogs_download_artwork', {
imageUrl: selectedDiscogsImage,
artist: selectedAlbum.artist,
album: selectedAlbum.title
});
console.log('Downloaded to:', localPath);
await invoke('v2_library_set_album_artwork', {
albumGroupKey: selectedAlbum.id,
artworkPath: localPath
});
console.log('Set album artwork successfully');
applyAlbumArtworkUpdate(selectedAlbum.id, localPath);
}
await invoke('v2_library_set_album_hidden', {
albumGroupKey: selectedAlbum.id,
hidden: editingAlbumHidden
});
// Reset Discogs state
discogsImageOptions = [];
selectedDiscogsImage = null;
discogsImagePage = 0;
showAlbumEditModal = false;
if (editingAlbumHidden) {
clearLocalAlbum();
navGoBack();
await loadLibraryData();
}
} catch (err) {
console.error('Failed to save album settings:', err);
alert(`Failed to save settings: ${err}`);
}
}
function getQualityBadge(track: LocalTrack): string {
const format = track.format.toUpperCase();
const bitDepth = track.bit_depth && track.bit_depth > 0 ? String(track.bit_depth) : '--';
const sampleRate = track.sample_rate > 0
? Number((track.sample_rate / 1000).toFixed(1)).toString()
: '--';
// Format: "FLAC 24/96" style that audiophiles love
return `${format} ${bitDepth}/${sampleRate}`;
}
function isHiRes(track: LocalTrack): boolean {
return (track.bit_depth ?? 16) >= 24 || track.sample_rate > 48000;
}
function formatSampleRate(hz: number): string {
if (hz <= 0) return '-- kHz';
return `${(hz / 1000).toFixed(1)} kHz`;
}
function formatBitDepth(bits?: number): string {
return bits && bits > 0 ? `${bits}-bit` : '--bit';
}
function getAlbumQualityBadge(album: LocalAlbum): string {
const format = album.format.toUpperCase();
const bitDepth = album.bit_depth && album.bit_depth > 0 ? String(album.bit_depth) : '--';
const sampleRate = album.sample_rate > 0
? Number((album.sample_rate / 1000).toFixed(1)).toString()
: '--';
return `${format} ${bitDepth}/${sampleRate}`;
}
function isAlbumHiRes(album: LocalAlbum): boolean {
return (album.bit_depth ?? 16) >= 24 || album.sample_rate > 48000;
}
function extractDiscNumber(track: LocalTrack): number {
if (track.disc_number && track.disc_number > 0) return track.disc_number;
const album = track.album ?? '';
const match = album.match(/(?:disc|disk|cd)\s*([0-9]+)/i);
if (match) {
const parsed = Number(match[1]);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
return 1;
}
function buildAlbumSections(tracks: LocalTrack[]) {
const sorted = [...tracks].sort((a, b) => {
const aDisc = extractDiscNumber(a);
const bDisc = extractDiscNumber(b);
if (aDisc !== bDisc) return aDisc - bDisc;
const aTrack = a.track_number ?? 0;
const bTrack = b.track_number ?? 0;
if (aTrack !== bTrack) return aTrack - bTrack;
return a.title.localeCompare(b.title);
});
const groups = new Map<number, LocalTrack[]>();
for (const track of sorted) {
const disc = extractDiscNumber(track);
if (!groups.has(disc)) {
groups.set(disc, []);
}
groups.get(disc)?.push(track);
}
const discs = [...groups.keys()].sort((a, b) => a - b);
return discs.map(disc => ({
disc,
label: `Disc ${disc}`,
tracks: groups.get(disc) ?? []
}));
}
// Memoization cache for artwork URLs to avoid repeated convertFileSrc calls
const artworkUrlCache = new Map<string, string>();
// Thumbnail URL cache (separate from full-res cache)
let thumbnailUrlCache = $state<Map<string, string>>(new Map());
// Track pending thumbnail requests to avoid duplicates
const pendingThumbnails = new Set<string>();
function getArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
// For grid/list views, prefer thumbnails
const cachedThumb = thumbnailUrlCache.get(path);
if (cachedThumb) return cachedThumb;
// Start thumbnail generation in background if not already pending
if (!pendingThumbnails.has(path)) {
pendingThumbnails.add(path);
getThumbnailUrl(path).then(thumbUrl => {
pendingThumbnails.delete(path);
// Update the reactive cache to trigger re-render
thumbnailUrlCache = new Map(thumbnailUrlCache).set(path, thumbUrl);
}).catch(() => {
pendingThumbnails.delete(path);
// On error, cache the original URL
const fallbackUrl = convertFileSrc(path);
thumbnailUrlCache = new Map(thumbnailUrlCache).set(path, fallbackUrl);
});
}
// Return full image while thumbnail loads
const cached = artworkUrlCache.get(path);
if (cached) return cached;
const url = convertFileSrc(path);
artworkUrlCache.set(path, url);
return url;
}
// For album detail view, always use full resolution
function getFullArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
const cached = artworkUrlCache.get(path);
if (cached) return cached;
const url = convertFileSrc(path);
artworkUrlCache.set(path, url);
return url;
}
function matchesAlbumSearch(album: LocalAlbum, query: string): boolean {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return (
album.title.toLowerCase().includes(needle) ||
album.artist.toLowerCase().includes(needle)
);
}
function matchesArtistSearch(artist: LocalArtist, query: string): boolean {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return artist.name.toLowerCase().includes(needle);
}
const alphaIndexLetters = ['#', ...'ABCDEFGHIJKLMNOPQRSTUVWXYZ'];
function alphaGroupKey(title: string): string {
const trimmed = title.trim();
if (!trimmed) return '#';
const first = trimmed[0].toUpperCase();
return first >= 'A' && first <= 'Z' ? first : '#';
}
function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'group';
}
function groupIdForKey(prefix: string, key: string): string {
if (key === '#') {
return `${prefix}-num`;
}
// Use encodeURIComponent instead of slugify to preserve case sensitivity
// This prevents collisions like "Aki" and "AKI" both becoming "aki"
return `${prefix}-${encodeURIComponent(key)}`;
}
function groupAlbums(items: LocalAlbum[], mode: AlbumGroupMode) {
const prefix = `album-${mode}`;
const sorted = [...items].sort((a, b) => {
if (mode === 'artist') {
// Use canonical names for sorting to keep "Alice in Chains" and "Alice In Chains" together
const aArtist = allCanonicalNames.get(a.artist) || a.artist;
const bArtist = allCanonicalNames.get(b.artist) || b.artist;
const artistCmp = aArtist.localeCompare(bArtist);
if (artistCmp !== 0) return artistCmp;
return a.title.localeCompare(b.title);
}
return a.title.localeCompare(b.title);
});
const groups = new Map<string, LocalAlbum[]>();
for (const album of sorted) {
// Use canonical name for artist grouping
const key = mode === 'artist'
? (allCanonicalNames.get(album.artist) || album.artist)
: alphaGroupKey(album.title);
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key)?.push(album);
}
const keys = [...groups.keys()].sort((a, b) => {
if (mode === 'alpha') {
if (a === '#') return -1;
if (b === '#') return 1;
}
return a.localeCompare(b);
});
return keys.map(key => ({
key,
id: groupIdForKey(prefix, key),
albums: groups.get(key) ?? []
}));
}
function scrollToGroup(prefix: string, letter: string, available: Set<string>) {
if (!available.has(letter)) return;
const id = groupIdForKey(prefix, letter);
const target = document.getElementById(id);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Scroll within the artist list container (for two-column layout)
function scrollToArtistGroup(letter: string, available: Set<string>) {
if (!available.has(letter) || !artistListScrollRef) return;
const id = groupIdForKey('artist-alpha', letter);
const target = document.getElementById(id);
if (target && artistListScrollRef) {
const containerTop = artistListScrollRef.getBoundingClientRect().top;
const targetTop = target.getBoundingClientRect().top;
const scrollOffset = targetTop - containerTop + artistListScrollRef.scrollTop;
artistListScrollRef.scrollTo({ top: scrollOffset, behavior: 'smooth' });
}
}
function scrollToGroupId(groupId?: string) {
if (!groupId) return;
const target = document.getElementById(groupId);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function scheduleTrackSearch() {
if (trackSearchTimer) {
clearTimeout(trackSearchTimer);
}
trackSearchTimer = setTimeout(() => {
loadTracks(trackSearch.trim());
}, 250);
}
function toggleSearch() {
searchOpen = !searchOpen;
if (searchOpen) {
// Focus input after it's rendered
setTimeout(() => searchInputEl?.focus(), 50);
} else {
// Clear search when closing
if (activeTab === 'albums') {
albumSearch = '';
debouncedAlbumSearch = '';
if (albumSearchTimer) clearTimeout(albumSearchTimer);
} else if (activeTab === 'artists') {
artistSearch = '';
debouncedArtistSearch = '';
if (artistSearchTimer) clearTimeout(artistSearchTimer);
} else if (activeTab === 'tracks') {
trackSearch = '';
loadTracks('');
}
}
}
function getCurrentSearchValue(): string {
if (activeTab === 'albums') return albumSearch;
if (activeTab === 'artists') return artistSearch;
return trackSearch;
}
function getCurrentSearchPlaceholder(): string {
if (activeTab === 'albums') return 'Search albums or artists...';
if (activeTab === 'artists') return 'Search artists...';
return 'Search tracks, albums, artists...';
}
function handleSearchInput(e: Event) {
const value = (e.target as HTMLInputElement).value;
if (activeTab === 'albums') {
albumSearch = value;
scheduleAlbumSearch();
} else if (activeTab === 'artists') {
artistSearch = value;
scheduleArtistSearch();
} else if (activeTab === 'tracks') {
trackSearch = value;
scheduleTrackSearch();
}
}
function scheduleAlbumSearch() {
if (albumSearchTimer) {
clearTimeout(albumSearchTimer);
}
albumSearchTimer = setTimeout(() => {
debouncedAlbumSearch = albumSearch.trim();
}, 150);
}
function scheduleArtistSearch() {
if (artistSearchTimer) {
clearTimeout(artistSearchTimer);
}
artistSearchTimer = setTimeout(() => {
debouncedArtistSearch = artistSearch.trim();
}, 150);
}
function trackSortValue(track: LocalTrack) {
const disc = track.disc_number ?? 0;
const trackNumber = track.track_number ?? 0;
return { disc, trackNumber };
}
function normalizeAlbumTitle(title: string): string {
const trimmed = title.trim();
if (!trimmed) return trimmed;
let normalized = trimmed.replace(/\s*[\[(](disc|disk|cd)\s*\d+[\])]\s*$/i, '');
normalized = normalized.replace(/\s+(disc|disk|cd)\s*\d+\s*$/i, '');
return normalized.trim() || trimmed;
}
function normalizeArtistName(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
async function resolveQobuzArtistId(name: string): Promise<number | null> {
const query = name.trim();
if (!query) return null;
const results = await invoke<SearchResults<ArtistSearchResult>>('v2_search_artists', {
query,
limit: 5,
offset: 0
});
if (!results.items.length) return null;
const normalizedQuery = normalizeArtistName(query);
const exactMatch = results.items.find(
artist => normalizeArtistName(artist.name) === normalizedQuery
);
return (exactMatch ?? results.items[0]).id;
}
async function handleLocalArtistClick(name?: string) {
if (!name) return;
// Note: "Various Artists" is allowed when clicking from artist list,
// but blocked in album header via HTML condition
// Close album detail view if open and navigate to library
if (selectedAlbum) {
clearLocalAlbum();
navigateTo('library'); // This adds to history so back button works
}
// Switch to artists tab
activeTab = 'artists';
// Load artists if not already loaded
if (artists.length === 0) {
await loadArtists();
}
// Select artist to show their albums in the right column
selectedArtistName = name;
// Scroll to the artist card after DOM updates
// Use multiple ticks + small delay to ensure content is fully rendered
await tick();
setTimeout(() => {
const artistId = `local-artist-${normalizeArtistName(name)}`;
const artistCard = document.getElementById(artistId);
if (artistCard) {
artistCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 150);
}
/**
* Load cached artist images and canonical names from database.
*/
async function loadCachedArtistImages(): Promise<void> {
try {
const artistNames = artists.map(a => a.name);
const cachedImages = await invoke<Array<{
artist_name: string;
image_url: string | null;
source: string | null;
custom_image_path: string | null;
canonical_name: string | null;
}>>('library_get_artist_images', { artistNames });
for (const cached of cachedImages) {
const imageUrl = cached.custom_image_path
? convertFileSrc(cached.custom_image_path)
: cached.image_url;
if (imageUrl) {
artistImages.set(cached.artist_name, imageUrl);
}
// Store canonical name if available and different
if (cached.canonical_name && cached.canonical_name !== cached.artist_name) {
canonicalNames.set(cached.artist_name, cached.canonical_name);
}
}
// Trigger re-render
artistImages = new Map(artistImages);
canonicalNames = new Map(canonicalNames);
} catch (err) {
console.debug('Failed to load cached artist images:', err);
}
}
/**
* Get the display name for an artist (canonical if available, otherwise original).
*/
function getArtistDisplayName(name: string): string {
return allCanonicalNames.get(name) || name;
}
/**
* Fetch missing artist images from Qobuz only (Discogs disabled due to rate limiting).
* Fetches sequentially with delays to avoid API abuse.
*/
async function fetchMissingArtistImages(): Promise<void> {
// Guard against concurrent executions
if (artistImageFetchInProgress) {
console.log('[LocalLibrary] Artist image fetch already in progress, skipping');
return;
}
// Don't fetch external artwork when offline
if (isOffline) {
console.log('[LocalLibrary] Skipping artist image fetch - offline mode');
return;
}
// Reset abort flag
artistImageFetchAborted = false;
artistImageFetchInProgress = true;
try {
// Filter out artists we already have images for and "Various Artists"
const toFetch = artists.filter(artist => {
const normalized = normalizeArtistName(artist.name);
return normalized !== 'various artists' && !artistImages.has(artist.name);
});
if (toFetch.length === 0) return;
// Limit to first 50 artists per session to avoid overwhelming APIs
const maxFetch = 50;
const limitedFetch = toFetch.slice(0, maxFetch);
console.log(`[LocalLibrary] Fetching images for ${limitedFetch.length} artists (limited from ${toFetch.length})`);
// Fetch SEQUENTIALLY with delays - no parallel requests
const requestDelay = 1000; // 1 second between each request
for (let i = 0; i < limitedFetch.length; i++) {
// Check abort conditions
if (artistImageFetchAborted || isOffline) {
console.log('[LocalLibrary] Artist image fetch aborted');
break;
}
// Add delay between requests (not before the first one)
if (i > 0) {
await new Promise(resolve => setTimeout(resolve, requestDelay));
}
const artist = limitedFetch[i];
const name = artist.name;
try {
// Only use Qobuz - Discogs causes too many issues with rate limiting
const results = await invoke<SearchResults<ArtistSearchResult>>('v2_search_artists', {
query: name.trim(),
limit: 3,
offset: 0
});
if (results.items.length > 0) {
const normalizedQuery = normalizeArtistName(name);
const exactMatch = results.items.find(
item => normalizeArtistName(item.name) === normalizedQuery
);
const match = exactMatch ?? results.items[0];
const imageUrl = match.image?.large || match.image?.thumbnail || match.image?.small;
// Store canonical name from Qobuz (properly capitalized)
const canonicalName = match.name;
if (imageUrl) {
// Cache in database with canonical name
await invoke('v2_library_cache_artist_image', {
artistName: name,
imageUrl,
source: 'qobuz',
canonicalName
});
artistImages.set(name, imageUrl);
// Also store canonical name mapping
if (canonicalName && canonicalName !== name) {
canonicalNames.set(name, canonicalName);
}
// Update state periodically (every 5 artists)
if (i % 5 === 4) {
artistImages = new Map(artistImages);
canonicalNames = new Map(canonicalNames);
}
}
}
} catch (err) {
console.debug('Failed to fetch image for artist:', name, err);
}
}
// Final state update
artistImages = new Map(artistImages);
} finally {
artistImageFetchInProgress = false;
}
}
/**
* Legacy function - kept for compatibility but now calls new implementation.
* @deprecated Use fetchMissingArtistImages() instead
*/
async function fetchArtistImages(artistNames: string[]): Promise<void> {
await fetchMissingArtistImages();
}
/**
* Upload custom artist image
*/
async function handleUploadArtistImage(artistName: string, event?: Event) {
event?.stopPropagation();
try {
const selected = await open({
multiple: false,
filters: [{
name: 'Image',
extensions: ['jpg', 'jpeg', 'png', 'webp']
}]
});
if (!selected) return;
const imagePath = Array.isArray(selected) ? selected[0] : selected;
// Save to database
await invoke('v2_library_set_custom_artist_image', {
artistName,
customImagePath: imagePath
});
// Update local state
const imageUrl = convertFileSrc(imagePath);
artistImages.set(artistName, imageUrl);
artistImages = new Map(artistImages);
} catch (err) {
console.error('Failed to upload custom artist image:', err);
}
}
function handleLocalAlbumLink(track: LocalTrack) {
if (!track.album_group_key) return;
const album = albums.find(item => item.id === track.album_group_key);
if (album) {
handleAlbumClick(album);
}
}
function groupTracks(items: LocalTrack[], mode: TrackGroupMode) {
const prefix = `track-${mode}`;
const sorted = [...items].sort((a, b) => {
if (mode === 'album') {
const albumCmp = a.album.localeCompare(b.album);
if (albumCmp !== 0) return albumCmp;
const artistCmp = a.artist.localeCompare(b.artist);
if (artistCmp !== 0) return artistCmp;
const aOrder = trackSortValue(a);
const bOrder = trackSortValue(b);
if (aOrder.disc !== bOrder.disc) return aOrder.disc - bOrder.disc;
if (aOrder.trackNumber !== bOrder.trackNumber) return aOrder.trackNumber - bOrder.trackNumber;
return a.title.localeCompare(b.title);
}
if (mode === 'artist') {
// Use canonical names for sorting to keep variants together
const aArtist = allCanonicalNames.get(a.artist) || a.artist;
const bArtist = allCanonicalNames.get(b.artist) || b.artist;
const artistCmp = aArtist.localeCompare(bArtist);
if (artistCmp !== 0) return artistCmp;
const albumCmp = a.album.localeCompare(b.album);
if (albumCmp !== 0) return albumCmp;
const aOrder = trackSortValue(a);
const bOrder = trackSortValue(b);
if (aOrder.disc !== bOrder.disc) return aOrder.disc - bOrder.disc;
if (aOrder.trackNumber !== bOrder.trackNumber) return aOrder.trackNumber - bOrder.trackNumber;
return a.title.localeCompare(b.title);
}
const titleCmp = a.title.localeCompare(b.title);
if (titleCmp !== 0) return titleCmp;
const artistCmp = a.artist.localeCompare(b.artist);
if (artistCmp !== 0) return artistCmp;
return a.album.localeCompare(b.album);
});
const groups = new Map<string, { title: string; subtitle?: string; tracks: LocalTrack[]; artists: Set<string> }>();
for (const track of sorted) {
if (mode === 'album') {
const title = track.album_group_title?.trim() || normalizeAlbumTitle(track.album);
const albumArtist = track.album_artist?.trim() || '';
const groupKey = track.album_group_key?.trim()
? track.album_group_key
: albumArtist
? `${title}|||${albumArtist}`
: title;
const entry = groups.get(groupKey);
if (!entry) {
groups.set(groupKey, {
title,
subtitle: albumArtist || undefined,
tracks: [track],
artists: new Set([track.artist || 'Unknown Artist'])
});
} else {
entry.tracks.push(track);
if (albumArtist && !entry.subtitle) {
entry.subtitle = albumArtist;
}
entry.artists.add(track.artist || 'Unknown Artist');
}
} else if (mode === 'artist') {
const rawArtist = track.artist || 'Unknown Artist';
// Use canonical name for grouping to merge "Alice in Chains" and "Alice In Chains"
const canonicalArtist = allCanonicalNames.get(rawArtist) || rawArtist;
if (!groups.has(canonicalArtist)) {
groups.set(canonicalArtist, { title: canonicalArtist, tracks: [], artists: new Set([rawArtist]) });
} else {
groups.get(canonicalArtist)?.artists.add(rawArtist);
}
groups.get(canonicalArtist)?.tracks.push(track);
} else {
const key = alphaGroupKey(track.title);
if (!groups.has(key)) {
groups.set(key, { title: key, tracks: [], artists: new Set() });
}
groups.get(key)?.tracks.push(track);
}
}
const keys = [...groups.keys()].sort((a, b) => {
if (mode === 'name') {
if (a === '#') return -1;
if (b === '#') return 1;
}
if (mode === 'album') {
const titleCmp = (groups.get(a)?.title ?? a).localeCompare(groups.get(b)?.title ?? b);
if (titleCmp !== 0) return titleCmp;
}
return a.localeCompare(b);
});
return keys.map(key => ({
key,
id: groupIdForKey(prefix, key),
title: groups.get(key)?.title ?? key,
subtitle: (() => {
const entry = groups.get(key);
if (!entry) return undefined;
if (mode === 'album') {
if (entry.subtitle) return entry.subtitle;
const artists = [...entry.artists];
if (artists.length > 1) return 'Various Artists';
return artists[0];
}
return entry.subtitle;
})(),
tracks: groups.get(key)?.tracks ?? []
}));
}
function getDisplayedTrackOrder(): LocalTrack[] {
if (!trackGroupingEnabled) return tracks;
const grouped = groupTracks(tracks, trackGroupMode);
if (trackGroupMode === 'album') {
const ordered: LocalTrack[] = [];
for (const group of grouped) {
const sections = buildAlbumSections(group.tracks);
for (const section of sections) {
ordered.push(...section.tracks);
}
}
return ordered;
}
return grouped.flatMap(group => group.tracks);
}
</script>
<ViewTransition duration={200} distance={12} direction="down">
<div class="library-view" class:virtualized-active={!selectedAlbum && ((activeTab === 'albums' && !showHiddenAlbums && albums.length > 0) || (activeTab === 'artists' && artists.length > 0) || (activeTab === 'tracks' && tracks.length > 0))}>
{#if selectedAlbum}
{@const albumSections = buildAlbumSections(albumTracks)}
{@const showDiscHeaders = albumSections.length > 1}
<!-- Album Detail View -->
<div class="album-detail">
<div class="nav-row">
<button class="back-btn" onclick={() => { clearLocalAlbum(); navGoBack(); }}>
<ArrowLeft size={16} />
<span>{$t('library.backToLibrary')}</span>
</button>
<button
class="edit-btn"
onclick={openAlbumEditModal}
title={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'
? $t('settings.integrations.plexWriteDisabledNotice')
: $t('actions.edit')}
disabled={selectedAlbum?.source === 'plex' && getUserItem(PLEX_METADATA_WRITE_KEY) !== 'true'}
>
<Edit3 size={16} />
</button>
</div>
<div class="album-header">
<div class="album-artwork">
{#if selectedAlbum.artwork_path}
<img src={getFullArtworkUrl(selectedAlbum.artwork_path)} alt={selectedAlbum.title} />
{:else}
<div class="artwork-placeholder">
<Disc3 size={64} />
</div>
{/if}
</div>
<div class="album-info">
<h1>{selectedAlbum.title}</h1>
{#if normalizeArtistName(selectedAlbum.artist) !== 'various artists'}
<button class="artist artist-link" type="button" onclick={() => handleLocalArtistClick(selectedAlbum?.artist)}>
{selectedAlbum.artist}
</button>
{:else}
<p class="artist">{selectedAlbum.artist}</p>
{/if}
<p class="meta">
{#if selectedAlbum.catalog_number}Cat# {selectedAlbum.catalog_number} &bull; {/if}
{#if selectedAlbum.year}{selectedAlbum.year} &bull; {/if}
{selectedAlbum.track_count} tracks &bull;
{formatTotalDuration(selectedAlbum.total_duration_secs)}
</p>
{#if albumTracks.length > 0}
{@const firstTrack = albumTracks[0]}
<div class="audio-specs">
<span class="spec-badge" class:hires={isHiRes(firstTrack)}>
{firstTrack.format.toUpperCase()}
</span>
<span class="spec-item">{formatBitDepth(firstTrack.bit_depth)}</span>
<span class="spec-item">{formatSampleRate(firstTrack.sample_rate)}</span>
<span class="spec-item">{firstTrack.channels === 2 ? 'Stereo' : firstTrack.channels === 1 ? 'Mono' : `${firstTrack.channels}ch`}</span>
</div>
{/if}
{#if selectedAlbum.likely_single_file_album}
<div class="single-file-notice">
<AlertCircle size={14} />
<span>{$t('library.singleFileAlbumNotice')}</span>
</div>
{/if}
<div class="album-actions">
<button class="action-btn-circle primary" onclick={handlePlayAllAlbum} title={$t('actions.playAll')}>
<Play size={20} fill="currentColor" color="currentColor" />
</button>
<button class="action-btn-circle" onclick={handleShuffleAllAlbum} title={$t('actions.shuffle')}>
<Shuffle size={18} />
</button>
</div>
</div>
</div>
<div class="track-list">
<div class="track-list-header">
<div class="col-number">#</div>
<div class="col-title">Title</div>
<div class="col-duration">Duration</div>
<div class="col-quality">Quality</div>
<div class="col-spacer"></div>
<div class="col-spacer"></div>
<div class="col-spacer"></div>
</div>
{#each albumSections as section (section.disc)}
{#if showDiscHeaders}
<div class="disc-header">{section.label}</div>
{/if}
{#each section.tracks as track, index (track.id)}
<TrackRow
number={track.track_number ?? index + 1}
title={track.title}
artist={track.artist !== selectedAlbum?.artist ? track.artist : undefined}
duration={formatDuration(track.duration_secs)}
quality={getQualityBadge(track)}
isPlaying={isPlaybackActive && activeTrackId === track.id}
isLocal={true}
localSource={track.source === 'plex' ? 'plex' : 'local'}
hideDownload={true}
hideFavorite={true}
onArtistClick={track.artist && track.artist !== selectedAlbum?.artist
? () => handleLocalArtistClick(track.artist)
: undefined}
onPlay={() => handleTrackPlay(track)}
menuActions={{
onPlayNow: () => handleTrackPlay(track),
onPlayNext: onTrackPlayNext ? () => onTrackPlayNext(track) : undefined,
onPlayLater: onTrackPlayLater ? () => onTrackPlayLater(track) : undefined,
onAddToPlaylist: onTrackAddToPlaylist && track.source !== 'plex' ? () => onTrackAddToPlaylist(track.id) : undefined
}}
/>
{/each}
{/each}
</div>
</div>
{:else}
<!-- Main Library View -->
<div class="header">
<div class="header-icon">
<HardDrive size={32} />
</div>
<div class="header-content">
<h1>{$t('library.title')}</h1>
{#if stats}
<p class="subtitle">
{stats.album_count} albums &bull; {stats.track_count} tracks &bull;
{formatTotalDuration(stats.total_duration_secs)} &bull;
{formatFileSize(stats.total_size_bytes)}
</p>
{:else}
<p class="subtitle">{$t('library.yourCollection')}</p>
{/if}
</div>
<div class="header-actions">
<button class="icon-btn" onclick={handleScan} disabled={scanning} title={$t('library.scanLibrary')}>
<RefreshCw size={20} class={scanning ? 'spinning' : ''} />
</button>
<button class="icon-btn" onclick={() => (showSettings = !showSettings)} title={$t('settings.library.title')}>
<Settings size={20} />
</button>
</div>
</div>
<!-- Offline Notice Banner -->
{#if isOffline}
<div class="offline-notice">
<AlertCircle size={16} />
<span>{$t('library.playlistOfflineMode')}</span>
</div>
{/if}
<!-- Scan Progress -->
{#if scanning && scanProgress}
<div class="scan-progress">
<div class="progress-bar">
<div
class="progress-fill"
style="width: {scanProgress.total_files > 0
? (scanProgress.processed_files / scanProgress.total_files) * 100
: 0}%"
></div>
</div>
<div class="progress-text">
<span>Scanning: {scanProgress.processed_files} / {scanProgress.total_files} files</span>
{#if scanProgress.current_file}
<span class="current-file">{scanProgress.current_file.split('/').pop()}</span>
{/if}
<button class="stop-scan-btn" onclick={handleStopScan} title="Stop scanning">
<X size={14} />
<span>Stop</span>
</button>
</div>
</div>
{/if}
<!-- Settings Panel -->
{#if showSettings}
<div class="settings-panel">
<div class="settings-header">
<h3>{$t('library.folders')}</h3>
<div class="folder-actions">
<div class="folder-search">
<Search size={14} />
<input
type="text"
placeholder="Filter folders..."
bind:value={folderSearch}
/>
</div>
<button class="icon-btn" onclick={handleAddFolder} title={$t('library.addFolder')}>
<FolderPlus size={16} />
</button>
<button
class="icon-btn"
onclick={handleEditFolder}
disabled={selectedFolders.size !== 1}
title={selectedFolders.size === 1 ? $t('library.editFolder') : $t('library.editFolderHint')}
>
<Edit3 size={16} />
</button>
<button
class="icon-btn"
onclick={handleRemoveSelectedFolders}
disabled={selectedFolders.size === 0}
title={$t('library.removeSelectedFolders')}
>
<Trash2 size={16} />
</button>
</div>
</div>
{#if folders.length === 0}
<div class="no-folders">
<p>{$t('library.noFolders')}</p>
</div>
{:else}
<div class="folder-table">
{#each folders.filter(f => !folderSearch || (f.alias ?? f.path).toLowerCase().includes(folderSearch.toLowerCase())) as folder (folder.id)}
{@const accessible = folderAccessibility.get(folder.id) ?? true}
<div
class="folder-row"
class:selected={selectedFolders.has(folder.id)}
class:disabled={!folder.enabled}
class:inaccessible={!accessible}
onclick={() => toggleFolderSelection(folder.id)}
role="checkbox"
aria-checked={selectedFolders.has(folder.id)}
tabindex="0"
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleFolderSelection(folder.id) : null}
>
<div class="folder-checkbox">
<input
type="checkbox"
checked={selectedFolders.has(folder.id)}
onclick={(e) => e.stopPropagation()}
onchange={() => toggleFolderSelection(folder.id)}
/>
</div>
<div class="folder-icon">
{#if folder.isNetwork}
<Network size={14} class={accessible ? 'network-connected' : 'network-disconnected'} />
{:else}
<HardDrive size={14} />
{/if}
</div>
<div class="folder-info" title={folder.alias ? folder.path : ''}>
{#if folder.alias}
<span class="folder-alias">{folder.alias}</span>
{:else}
<span class="folder-path">{folder.path}</span>
{/if}
</div>
{#if !folder.enabled}
<span class="folder-badge disabled-badge">{$t('library.disabled')}</span>
{:else if folder.isNetwork && !accessible}
<span class="folder-badge offline-badge">{$t('library.unavailable')}</span>
{/if}
</div>
{/each}
</div>
{/if}
<div class="settings-actions">
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
<span>{showHiddenAlbums ? 'Show Active Albums' : 'View Hidden Albums'}</span>
{#if hiddenAlbums.length > 0}
<span class="count">({hiddenAlbums.length})</span>
{/if}
</button>
{#if hasDiscogsCredentials}
<button
class="btn btn-secondary"
onclick={handleFetchMissingArtwork}
disabled={fetchingArtwork || isOffline}
title={isOffline ? $t('library.artworkOffline') : ''}
>
<ImageDown size={14} class={fetchingArtwork ? 'spinning' : ''} />
<span>{fetchingArtwork ? $t('library.fetchingArtwork') : $t('library.fetchMissingArtwork')}</span>
</button>
{:else if isOffline}
<div class="discogs-hint">
<span>{$t('library.artworkOffline')}</span>
</div>
{:else}
<div class="discogs-hint">
<span>Configure Discogs API for automatic artwork fetching</span>
</div>
{/if}
</div>
<div class="settings-bottom-row">
<div class="maintenance-section">
<div class="maintenance-label">Maintenance</div>
<button
class="btn btn-secondary"
onclick={handleCleanupMissingFiles}
disabled={cleaningUpMissingFiles}
>
<RefreshCw size={14} class={cleaningUpMissingFiles ? 'spinning' : ''} />
<span>{cleaningUpMissingFiles ? 'Cleaning up...' : 'Cleanup missing files'}</span>
</button>
{#if cleanupStatus}
<div class="cleanup-status">{cleanupStatus}</div>
{/if}
</div>
<div class="settings-divider"></div>
<div class="danger-zone">
<div class="danger-zone-label">Danger Zone</div>
<button class="danger-btn-small" onclick={(e) => handleClearLibrary(e)} disabled={clearingLibrary}>
<Trash2 size={12} />
<span>{clearingLibrary ? 'Clearing...' : 'Clear Library Database'}</span>
</button>
</div>
</div>
</div>
{/if}
<!-- Tabs Navigation -->
<div class="jump-nav">
<div class="jump-nav-left">
<div class="jump-links">
<button
class="jump-link"
class:active={activeTab === 'albums'}
onclick={() => handleTabChange('albums')}
>
Albums
</button>
<button
class="jump-link"
class:active={activeTab === 'artists'}
onclick={() => handleTabChange('artists')}
>
Artists
</button>
<button
class="jump-link"
class:active={activeTab === 'tracks'}
onclick={() => handleTabChange('tracks')}
>
Tracks
</button>
</div>
</div>
<div class="page-search" class:open={searchOpen}>
{#if searchOpen}
<div class="search-input-container">
<input
type="text"
class="search-input-sticky"
placeholder={getCurrentSearchPlaceholder()}
value={getCurrentSearchValue()}
bind:this={searchInputEl}
oninput={handleSearchInput}
onkeydown={(e) => {
if (e.key === 'Escape') toggleSearch();
}}
/>
<div class="search-controls">
<button class="search-close-btn" onclick={toggleSearch} title="Close search">
<X size={16} />
</button>
</div>
</div>
{:else}
<button class="search-toggle" onclick={toggleSearch} title="Search">
<Search size={18} />
</button>
{/if}
</div>
</div>
<!-- Content -->
<div class="content">
{#if loading}
<div class="loading">
<div class="spinner"></div>
<p>{$t('library.loadingLibrary')}</p>
</div>
{:else if error}
<div class="error">
<AlertCircle size={48} />
<p>{$t('library.failedLoadLibrary')}</p>
<p class="error-detail">{error}</p>
<button class="retry-btn" onclick={() => loadLibraryData()}>{$t('actions.retry')}</button>
</div>
{:else if activeTab === 'albums'}
{#key activeTab}
<ViewTransition duration={200} distance={12} direction="up">
{#if showHiddenAlbums}
<!-- Hidden Albums View -->
<div class="albums-section">
<div class="section-header">
<h3>Hidden Albums ({hiddenAlbums.length})</h3>
<button class="btn btn-secondary" onclick={toggleHiddenAlbumsView}>
<span>{$t('library.backToActiveAlbums')}</span>
</button>
</div>
{#if hiddenAlbums.length === 0}
<div class="empty-state">
<Disc3 size={64} />
<p>{$t('library.noHiddenAlbums')}</p>
</div>
{:else}
<div class="album-list">
{#each hiddenAlbums as album (album.id)}
<div class="album-row" role="button" tabindex="0">
<div class="album-row-art" onclick={() => handleAlbumClick(album)}>
{#if album.artwork_path}
<img src={getArtworkUrl(album.artwork_path)} alt={album.title} loading="lazy" decoding="async" />
{:else}
<div class="artwork-placeholder">
<Disc3 size={28} />
</div>
{/if}
</div>
<div class="album-row-info" onclick={() => handleAlbumClick(album)}>
<div class="album-row-title">{album.title}</div>
<div class="album-row-artist">{album.artist}</div>
<div class="album-row-meta">
{#if album.year}<span>{album.year}</span><span class="separator">•</span>{/if}
<span>{album.track_count} tracks</span>
<span class="separator">•</span>
<span>{formatTotalDuration(album.total_duration_secs)}</span>
<span class="separator">•</span>
<span class="quality-badge" class:hires={isAlbumHiRes(album)}>{getAlbumQualityBadge(album)}</span>
</div>
</div>
<button class="show-album-btn" onclick={() => handleShowAlbum(album)} title="Show album">
<span>Show</span>
</button>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Active Albums View -->
{#if albums.length === 0}
<div class="empty">
<Disc3 size={48} />
<p>{$t('library.noAlbumsInLibrary')}</p>
<p class="empty-hint">{$t('library.addFoldersHint')}</p>
</div>
{:else}
<!-- Use memoized filtered and grouped albums -->
{@const { filtered: filteredAlbums, grouped: groupedAlbums, alphaGroups } = filteredAndGroupedAlbums}
<div class="album-controls">
<div class="dropdown-container">
<button
class="control-btn"
onclick={() => (showGroupMenu = !showGroupMenu)}
title="Group albums"
>
<span>{!albumGroupingEnabled
? 'Group: Off'
: albumGroupMode === 'alpha'
? 'Group: A-Z'
: 'Group: Artist'}</span>
</button>
{#if showGroupMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={!albumGroupingEnabled}
onclick={() => { albumGroupingEnabled = false; showGroupMenu = false; }}
>
Off
</button>
<button
class="dropdown-item"
class:selected={albumGroupingEnabled && albumGroupMode === 'alpha'}
onclick={() => { albumGroupMode = 'alpha'; albumGroupingEnabled = true; showGroupMenu = false; }}
>
Alphabetical (A-Z)
</button>
<button
class="dropdown-item"
class:selected={albumGroupingEnabled && albumGroupMode === 'artist'}
onclick={() => { albumGroupMode = 'artist'; albumGroupingEnabled = true; showGroupMenu = false; }}
>
Artist
</button>
</div>
{/if}
</div>
<!-- Quality/Format Filter -->
<div class="dropdown-container" bind:this={filterPanelRef}>
<button
class="control-btn icon-only"
class:active={hasActiveFilters}
onclick={() => (showFilterPanel = !showFilterPanel)}
title="Filter by quality/format"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M4.22657 2C2.50087 2 1.58526 4.03892 2.73175 5.32873L8.99972 12.3802V19C8.99972 19.3788 9.21373 19.725 9.55251 19.8944L13.5525 21.8944C13.8625 22.0494 14.2306 22.0329 14.5255 21.8507C14.8203 21.6684 14.9997 21.3466 14.9997 21V12.3802L21.2677 5.32873C22.4142 4.03893 21.4986 2 19.7729 2H4.22657Z"/>
</svg>
{#if activeFilterCount > 0}
<span class="filter-badge">{activeFilterCount}</span>
{/if}
</button>
{#if showFilterPanel}
<div
class="filter-panel"
onmouseenter={clearFilterPanelTimer}
onmouseleave={startFilterPanelTimer}
onclick={handleFilterPanelActivity}
>
<div class="filter-panel-header">
<span>{$t('library.filters')}</span>
{#if hasActiveFilters}
<button class="clear-filters-btn" onclick={clearAllFilters}>{$t('library.clearAllFilters')}</button>
{/if}
</div>
<div class="filter-section">
<div class="filter-section-label">{$t('library.quality')}</div>
<div class="filter-checkboxes">
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterHiRes} />
<span class="checkmark"></span>
<span class="label-text">Hi-Res</span>
<span class="label-hint">24bit+</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterCdQuality} />
<span class="checkmark"></span>
<span class="label-text">CD Quality</span>
<span class="label-hint">16bit</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterLossy} />
<span class="checkmark"></span>
<span class="label-text">Lossy</span>
</label>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Format</div>
<div class="filter-checkboxes format-grid">
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterFlac} />
<span class="checkmark"></span>
<span class="label-text">FLAC</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterAlac} />
<span class="checkmark"></span>
<span class="label-text">ALAC</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterApe} />
<span class="checkmark"></span>
<span class="label-text">APE</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterWav} />
<span class="checkmark"></span>
<span class="label-text">WAV</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterMp3} />
<span class="checkmark"></span>
<span class="label-text">MP3</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterAac} />
<span class="checkmark"></span>
<span class="label-text">AAC</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterOther} />
<span class="checkmark"></span>
<span class="label-text">Other</span>
</label>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Source</div>
<div class="filter-checkboxes source-row">
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterLocalFiles} />
<span class="checkmark"></span>
<HardDrive size={14} class="filter-icon" />
<span class="label-text">Local</span>
</label>
<label class="filter-checkbox">
<input type="checkbox" bind:checked={filterOfflineCache} />
<span class="checkmark"></span>
<img src="/qobuz-logo-filled.svg" alt="" class="filter-icon qobuz-icon" />
<span class="label-text">Offline cache</span>
</label>
</div>
</div>
</div>
{/if}
</div>
<!-- Sort dropdown -->
<div class="dropdown-container">
<button
class="control-btn icon-only"
onclick={() => (showSortMenu = !showSortMenu)}
title="Sort albums"
>
<ArrowUpDown size={14} />
</button>
{#if showSortMenu}
<div class="sort-menu">
{#each sortOptions as option}
<button
class="dropdown-item"
class:selected={sortBy === option.value}
onclick={() => selectSort(option.value)}
>
<span>{option.label}</span>
{#if sortBy === option.value}
<span class="sort-indicator">{sortDirection === 'asc' ? '↑' : '↓'}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<button
class="control-btn icon-only"
onclick={() => (albumViewMode = albumViewMode === 'list' ? 'grid' : 'list')}
title={albumViewMode === 'list' ? 'Grid view' : 'List view'}
>
{#if albumViewMode === 'list'}
<LayoutGrid size={16} />
{:else}
<List size={16} />
{/if}
</button>
{#if albumGroupingEnabled && albumGroupMode === 'alpha'}
<div class="alpha-index-inline">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!alphaGroups.has(letter)}
onclick={() => {
const groupId = groupIdForKey('album-alpha', letter);
virtualizedScrollTarget = alphaGroups.has(letter) ? groupId : undefined;
}}
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
{#if filteredAlbums.length === 0}
<div class="empty">
<Disc3 size={48} />
<p>{$t('library.noAlbumsMatch')}</p>
<p class="empty-hint">{$t('library.tryDifferentSearch')}</p>
</div>
{:else}
<!-- Always use virtualization for albums - handles any library size efficiently -->
<div class="album-sections virtualized">
<div class="virtualized-container">
<VirtualizedAlbumList
groups={groupedAlbums}
viewMode={albumViewMode}
showGroupHeaders={albumGroupingEnabled}
{getArtworkUrl}
getQualityBadge={getAlbumQualityBadge}
isHiRes={isAlbumHiRes}
formatDuration={formatTotalDuration}
onAlbumClick={handleAlbumClick}
onAlbumPlay={handleAlbumPlayFromGrid}
onAlbumQueueNext={handleAlbumQueueNextFromGrid}
onAlbumQueueLater={handleAlbumQueueLaterFromGrid}
scrollToGroupId={virtualizedScrollTarget}
showSourceBadge={true}
/>
</div>
</div>
{/if}
{/if}
{/if}
</ViewTransition>
{/key}
{:else if activeTab === 'artists'}
{#key activeTab}
<ViewTransition duration={200} distance={12} direction="up">
{#if artists.length === 0}
<div class="empty">
<Mic2 size={48} />
<p>{$t('library.noArtistsInLibrary')}</p>
</div>
{:else}
{@const { grouped: groupedArtists, alphaGroups: artistAlphaGroups } = groupedArtistsMemo}
{@const filteredArtists = filteredArtistsMemo}
<div class="artists-tab-container">
<!-- Horizontal alphabetical index at the top -->
<div class="artist-alpha-index-row">
<div class="alpha-index-inline">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!artistAlphaGroups.has(letter)}
onclick={() => scrollToArtistGroup(letter, artistAlphaGroups)}
>
{letter}
</button>
{/each}
</div>
</div>
<!-- Two-column layout: Artists | Albums -->
<div class="artist-two-column-layout">
<!-- Left column: Artist cards (single column with scroll) -->
<div class="artist-column">
<div class="artist-column-header">
<span class="artist-count">{filteredArtists.length} artists</span>
</div>
{#if filteredArtists.length === 0}
<div class="empty-small">
<Mic2 size={32} />
<p>{$t('library.noArtistsMatch')}</p>
</div>
{:else}
<div class="artist-list-scroll" bind:this={artistListScrollRef}>
{#each groupedArtists as group}
{#if group.key}
<div class="artist-group-header" id={group.id}>
{group.key}
</div>
{/if}
{#each group.artists as artist}
{@const displayName = getArtistDisplayName(artist.name)}
{@const artistImage = artistImages.get(artist.name)}
<button
id="local-artist-{normalizeArtistName(artist.name)}"
class="artist-card-compact"
class:selected={selectedArtistName === artist.name}
onclick={() => handleLocalArtistClick(artist.name)}
>
<div class="artist-card-image">
{#if artistImage}
<img src={artistImage} alt={displayName} />
{:else}
<div class="artist-placeholder">
<Mic2 size={24} />
</div>
{/if}
</div>
<div class="artist-card-info">
<span class="artist-name">{displayName}</span>
<span class="artist-meta">{artist.album_count} albums &bull; {artist.track_count} tracks</span>
</div>
</button>
{/each}
{/each}
</div>
{/if}
</div>
<!-- Right column: Selected artist's albums -->
<div class="artist-albums-column">
{#if selectedArtistName}
<div class="artist-albums-header">
<h3>{getArtistDisplayName(selectedArtistName)}</h3>
<span class="album-count">{selectedArtistAlbums.length} albums</span>
</div>
{#if selectedArtistAlbums.length === 0}
<div class="empty-small">
<Disc3 size={32} />
<p>{$t('library.noAlbumsFound')}</p>
</div>
{:else}
<div class="artist-albums-grid">
{#each selectedArtistAlbums as album}
<AlbumCard
artwork={getArtworkUrl(album.artwork_path)}
title={album.title}
artist={album.artist}
quality={getAlbumQualityBadge(album)}
size="large"
showFavorite={false}
showGenre={false}
onPlay={() => handleAlbumPlayFromGrid(album)}
onPlayNext={() => handleAlbumQueueNextFromGrid(album)}
onPlayLater={() => handleAlbumQueueLaterFromGrid(album)}
onclick={() => handleAlbumClick(album)}
sourceBadge={album.source === 'plex' ? 'plex' : (album.source === 'qobuz_download' ? 'qobuz_download' : 'user')}
/>
{/each}
</div>
{/if}
{:else}
<div class="empty-small centered">
<Mic2 size={48} />
<p>{$t('library.selectArtist')}</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
</ViewTransition>
{/key}
{:else if activeTab === 'tracks'}
{#key activeTab}
<ViewTransition duration={200} distance={12} direction="up">
{#if tracks.length === 0}
<div class="empty">
<Music size={48} />
<p>{$t('library.noTracksInLibrary')}</p>
</div>
{:else}
{@const { grouped: groupedTracks, alphaGroups: trackAlphaGroups, indexTargets: trackIndexTargets } = groupedTracksMemo}
<div class="track-controls">
<div class="dropdown-container">
<button
class="control-btn"
onclick={() => (showTrackGroupMenu = !showTrackGroupMenu)}
title="Group tracks"
>
<span>
{!trackGroupingEnabled
? 'Group: Off'
: trackGroupMode === 'album'
? 'Group: Album'
: trackGroupMode === 'artist'
? 'Group: Artist'
: 'Group: Name'}
</span>
</button>
{#if showTrackGroupMenu}
<div class="dropdown-menu">
<button
class="dropdown-item"
class:selected={!trackGroupingEnabled}
onclick={() => { trackGroupingEnabled = false; showTrackGroupMenu = false; }}
>
Off
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'album'}
onclick={() => { trackGroupMode = 'album'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Album
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'artist'}
onclick={() => { trackGroupMode = 'artist'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Artist
</button>
<button
class="dropdown-item"
class:selected={trackGroupingEnabled && trackGroupMode === 'name'}
onclick={() => { trackGroupMode = 'name'; trackGroupingEnabled = true; showTrackGroupMenu = false; }}
>
Name (A-Z)
</button>
</div>
{/if}
</div>
{#if trackGroupingEnabled && (trackGroupMode === 'name' || trackGroupMode === 'artist')}
<div class="alpha-index-inline">
{#each alphaIndexLetters as letter}
<button
class="alpha-letter"
class:disabled={!trackAlphaGroups.has(letter)}
onclick={() => {
if (!trackAlphaGroups.has(letter)) return;
const groupId = trackGroupMode === 'artist'
? trackIndexTargets.get(letter)
: groupIdForKey(`track-${trackGroupMode}`, letter);
if (groupId) {
virtualizedTrackListRef?.scrollToGroup(groupId);
}
}}
>
{letter}
</button>
{/each}
</div>
{/if}
</div>
<!-- Always use virtualization for tracks - handles any library size efficiently -->
<div class="track-sections virtualized">
<div class="virtualized-container">
<VirtualizedTrackList
bind:this={virtualizedTrackListRef}
groups={groupedTracks}
groupingEnabled={trackGroupingEnabled}
groupMode={trackGroupMode}
{activeTrackId}
{isPlaybackActive}
{formatDuration}
{getQualityBadge}
{buildAlbumSections}
onTrackPlay={handleTrackPlay}
onArtistClick={handleLocalArtistClick}
onAlbumClick={handleLocalAlbumLink}
onTrackPlayNext={onTrackPlayNext}
onTrackPlayLater={onTrackPlayLater}
onTrackAddToPlaylist={onTrackAddToPlaylist}
/>
</div>
</div>
{/if}
</ViewTransition>
{/key}
{/if}
</div>
{/if}
</div>
</ViewTransition>
<!-- Album Settings Modal -->
{#if showAlbumEditModal && selectedAlbum}
<div class="modal-overlay" onclick={() => showAlbumEditModal = false}>
<div class="modal" onclick={(e: MouseEvent) => e.stopPropagation()}>
<div class="modal-header">
<h2>Album Settings</h2>
<button class="close-btn" onclick={() => showAlbumEditModal = false}>
<X size={20} />
</button>
</div>
<div class="modal-body">
<div class="album-header-grid">
<div class="album-text">
<div class="album-title">{selectedAlbum.title}</div>
<div class="album-artist">{selectedAlbum.artist}</div>
</div>
<div class="album-settings-actions">
<button
class="album-action-btn"
onclick={openTagEditorFromAlbumSettings}
title={$t('library.editAlbumMetadataTitle')}
>
<Edit3 size={18} />
<span>{$t('library.editAlbumInfo')}</span>
</button>
<button
class="album-action-btn"
onclick={handleRefreshAlbumMetadataFromFiles}
disabled={refreshingAlbumMetadata}
title="Re-read embedded file tags and discard QBZ sidecar overrides"
>
{#if albumMetadataRefreshed && !refreshingAlbumMetadata}
<Check size={18} />
{:else}
<RefreshCw size={18} class={refreshingAlbumMetadata ? 'spinning' : ''} />
{/if}
<span>{refreshingAlbumMetadata ? 'Refreshing...' : 'Refresh metadata'}</span>
</button>
</div>
</div>
<div class="form-group">
<div class="artwork-layout-header" class:discogs-active={discogsFetchSuccessful}>
<label>Album Artwork</label>
{#if discogsFetchSuccessful}
<div class="discogs-layout-label">Select Artwork from Discogs</div>
{/if}
</div>
<div class="artwork-layout" class:discogs-active={discogsFetchSuccessful}>
<div class="artwork-left">
<div class="artwork-row">
{#if selectedAlbum.artwork_path}
<img
src={getArtworkUrl(selectedAlbum.artwork_path)}
alt="Current artwork"
class="artwork-preview"
/>
{:else}
<div class="artwork-preview artwork-placeholder-mini">
<Disc3 size={24} />
</div>
{/if}
<div class="artwork-actions">
<button class="discogs-btn" onclick={handleSetAlbumArtwork} disabled={updatingArtwork}>
<Upload size={14} />
<span>{updatingArtwork ? 'Updating...' : 'Upload cover'}</span>
</button>
<button class="discogs-btn" onclick={fetchDiscogsArtwork} disabled={fetchingDiscogsImages}>
<img src="/discogs_icon.svg" alt="Discogs" class="discogs-icon" />
<span>{fetchingDiscogsImages ? 'Fetching...' : 'Get from Discogs'}</span>
</button>
</div>
</div>
</div>
{#if discogsFetchSuccessful}
<div class="discogs-panel">
{#if discogsImageOptions.length > IMAGES_PER_PAGE}
<div class="discogs-header">
<div class="carousel-controls">
<button
class="carousel-btn"
onclick={prevDiscogsPage}
disabled={!hasPrevDiscogsPages}
title="Previous"
>
<ChevronLeft size={16} />
</button>
<span class="page-indicator">
{discogsImagePage + 1} / {Math.ceil(discogsImageOptions.length / IMAGES_PER_PAGE)}
</span>
<button
class="carousel-btn"
onclick={nextDiscogsPage}
disabled={!hasMoreDiscogsPages}
title="Next"
>
<ChevronRight size={16} />
</button>
</div>
</div>
{/if}
<div class="discogs-options discogs-options-compact">
{#each paginatedDiscogsImages as option, i}
<button
class="discogs-option"
class:selected={selectedDiscogsImage === option.url}
onclick={() => selectedDiscogsImage = option.url}
title={option.release_title ? `${option.release_title}${option.release_year ? ` (${option.release_year})` : ''}` : ''}
>
<img src={option.url} alt={`Option ${discogsImagePage * IMAGES_PER_PAGE + i + 1}`} />
<div class="option-info">
{#if option.release_title}
<div class="release-title">
{option.release_title}{#if option.release_year} ({option.release_year}){/if}
</div>
{/if}
<div class="image-dims">{option.width}x{option.height}</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
<div class="modal-footer">
<div class="footer-left">
<label class="toggle-label footer-toggle">
<input type="checkbox" bind:checked={editingAlbumHidden} />
<span>Hide this album from library</span>
</label>
<p class="form-hint footer-hint">Hidden albums can be viewed from Settings</p>
</div>
<div class="footer-actions">
<button class="btn btn-secondary" onclick={() => showAlbumEditModal = false}>
Cancel
</button>
<button class="btn btn-primary" onclick={saveAlbumEdit}>
Save
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- LocalLibrary Tag Editor Modal -->
<LocalLibraryTagEditorModal
isOpen={showTagEditorModal}
album={selectedAlbum}
tracks={albumTracks}
onClose={() => (showTagEditorModal = false)}
onSaved={handleTagEditorSaved}
/>
<!-- Folder Settings Modal -->
<FolderSettingsModal
isOpen={showFolderSettingsModal}
folder={editingFolder}
onClose={() => { showFolderSettingsModal = false; editingFolder = null; }}
onSave={handleFolderSettingsSave}
onScanFolder={handleScanSingleFolder}
/>
<style>
.library-view {
padding: 0 24px 100px 18px;
padding-right: 8px;
overflow-y: auto;
height: 100%;
}
.library-view.virtualized-active {
overflow: hidden;
padding-bottom: 0;
}
/* Custom scrollbar */
.library-view::-webkit-scrollbar {
width: 6px;
}
.library-view::-webkit-scrollbar-track {
background: transparent;
}
.library-view::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.library-view::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Header */
.header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 24px;
}
.header-icon {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--accent-primary) 0%, #64b5f6 100%);
border-radius: 16px;
color: white;
}
.header-content {
flex: 1;
}
.header-content h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.subtitle {
font-size: 14px;
color: var(--text-muted);
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
}
.icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-tertiary);
border: none;
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.spinning) {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Offline Notice */
.offline-notice {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 12px;
color: #fbbf24;
font-size: 14px;
}
.offline-notice span {
color: var(--text-primary);
}
/* Scan Progress */
.scan-progress {
background: var(--bg-secondary);
border-radius: 8px;
padding: 16px;
margin-bottom: 24px;
}
.progress-bar {
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: var(--accent-primary);
transition: width 300ms ease;
}
.progress-text {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--text-muted);
}
.current-file {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stop-scan-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 6px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.stop-scan-btn:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
/* Settings Panel */
.settings-panel {
background: var(--bg-secondary);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.settings-header h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.no-folders {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
background: var(--bg-tertiary);
border-radius: 8px;
}
.folder-table {
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--bg-tertiary);
border-radius: 8px;
background: var(--bg-secondary);
}
.folder-table::-webkit-scrollbar {
width: 6px;
}
.folder-table::-webkit-scrollbar-track {
background: transparent;
}
.folder-table::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 3px;
}
.folder-table::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
.folder-row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid var(--bg-tertiary);
transition: background 150ms ease;
cursor: pointer;
min-height: 36px;
}
.folder-row:last-child {
border-bottom: none;
}
.folder-row:hover {
background: var(--bg-tertiary);
}
.folder-row.selected {
background: rgba(59, 130, 246, 0.15);
}
.folder-row.selected:hover {
background: rgba(59, 130, 246, 0.2);
}
.folder-row.disabled {
opacity: 0.5;
}
.folder-row.inaccessible {
border-left: 3px solid #ef4444;
}
.folder-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-muted);
}
.folder-icon :global(.network-connected) {
color: #22c55e;
}
.folder-icon :global(.network-disconnected) {
color: #ef4444;
}
.folder-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.folder-alias {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.folder-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
padding: 2px 6px;
border-radius: 4px;
}
.disabled-badge {
background: var(--bg-tertiary);
color: var(--text-muted);
}
.offline-badge {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.folder-checkbox {
display: flex;
align-items: center;
cursor: pointer;
margin: 0;
}
.folder-checkbox input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
margin: 0;
}
.folder-path {
flex: 1;
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-mono, 'Courier New', monospace);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.folder-actions {
display: flex;
align-items: center;
gap: 8px;
}
.folder-search {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: 6px;
color: var(--text-muted);
}
.folder-search input {
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 12px;
width: 120px;
}
.folder-search input::placeholder {
color: var(--text-muted);
}
.no-folders {
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 14px;
}
.folder-path {
font-size: 13px;
color: var(--text-primary);
font-family: var(--font-mono);
}
.settings-actions {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--bg-tertiary);
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.discogs-hint {
font-size: 12px;
color: var(--text-muted);
padding: 8px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
flex: 1;
}
.settings-bottom-row {
display: flex;
gap: 24px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--bg-tertiary);
}
.settings-divider {
width: 1px;
background: var(--bg-tertiary);
align-self: stretch;
}
.maintenance-section {
flex: 1;
display: flex;
flex-direction: column;
}
.maintenance-label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.cleanup-status {
margin-top: 8px;
font-size: 12px;
color: var(--text-secondary);
padding: 6px 10px;
background: var(--bg-tertiary);
border-radius: 4px;
}
.danger-zone {
flex: 1;
display: flex;
flex-direction: column;
}
.danger-zone-label {
font-size: 12px;
font-weight: 600;
color: #ef4444;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.danger-btn-small {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
color: #ef4444;
border: 1px solid #ef4444;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.danger-btn-small:hover {
background: #ef4444;
color: white;
}
/* Tabs */
/* Sticky Navigation */
.jump-nav {
position: sticky;
top: 0;
z-index: 10; /* Above content (z:1-2) but below dropdowns (z:50+) */
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 24px;
margin: 0 -8px 16px -24px;
width: calc(100% + 32px);
background: var(--bg-primary);
border-bottom: 1px solid var(--alpha-6);
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
}
.jump-nav-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.jump-links {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.jump-link {
padding: 4px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color 150ms ease, border-color 150ms ease;
}
.jump-link:hover {
color: var(--text-secondary);
}
.jump-link.active {
color: var(--text-primary);
border-bottom-color: var(--accent-primary);
}
/* Page Search in Nav */
.page-search {
display: flex;
align-items: center;
}
.search-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
transition: color 150ms ease;
}
.search-toggle:hover {
color: var(--text-primary);
}
.search-input-container {
display: flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--bg-tertiary);
border-radius: 6px;
padding: 0 4px 0 12px;
animation: slideInFromRight 200ms ease-out;
}
@keyframes slideInFromRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.search-input-sticky {
width: 180px;
padding: 6px 0;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.search-input-sticky::placeholder {
color: var(--text-muted);
}
.search-controls {
display: flex;
align-items: center;
gap: 2px;
margin-left: 8px;
}
.search-close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: 4px;
cursor: pointer;
transition: all 150ms ease;
}
.search-close-btn:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
/* Album Controls */
.album-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.control-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 150ms ease;
}
.control-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.control-btn.active {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
}
.control-btn.active:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.control-btn.icon-only {
width: 36px;
height: 36px;
justify-content: center;
padding: 0;
}
.album-count {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
.dropdown-container {
position: relative;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 6px;
min-width: 180px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
z-index: 20;
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);
}
.dropdown-item {
width: 100%;
text-align: left;
padding: 8px 10px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: background 150ms ease;
}
.dropdown-item:hover {
background: var(--bg-tertiary);
}
.dropdown-item.selected {
background: var(--bg-tertiary);
font-weight: 600;
}
/* Filter Panel */
.filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--accent-primary);
color: white;
font-size: 11px;
font-weight: 600;
border-radius: 9px;
margin-left: 4px;
}
.filter-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 12px;
min-width: 420px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
z-index: 20;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.filter-panel-header {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-subtle);
}
.filter-panel-header span {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.clear-filters-btn {
background: none;
border: none;
padding: 4px 8px;
font-size: 12px;
color: var(--accent-primary);
cursor: pointer;
border-radius: 4px;
transition: background 150ms ease;
}
.clear-filters-btn:hover {
background: var(--bg-tertiary);
}
.filter-section {
margin-bottom: 0;
}
.filter-section:last-child {
grid-column: 1 / -1;
}
.filter-section-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 8px;
}
.filter-checkboxes {
display: flex;
flex-direction: column;
gap: 6px;
}
.filter-checkboxes.format-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px 12px;
}
.filter-checkboxes.source-row {
flex-direction: row;
gap: 12px;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background 150ms ease;
}
.filter-checkbox:hover {
background: var(--bg-tertiary);
}
.filter-checkbox input {
display: none;
}
.filter-checkbox .checkmark {
width: 16px;
height: 16px;
border: 2px solid var(--text-muted);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 150ms ease;
flex-shrink: 0;
}
.filter-checkbox input:checked + .checkmark {
background: var(--accent-primary);
border-color: var(--accent-primary);
}
.filter-checkbox input:checked + .checkmark::after {
content: '';
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) translateY(-1px);
}
.filter-checkbox .label-text {
font-size: 12px;
color: var(--text-primary);
}
.filter-checkbox .label-hint {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.filter-checkbox .filter-icon {
color: var(--text-muted);
margin-right: 4px;
}
.filter-checkbox .filter-icon.qobuz-icon {
width: 14px;
height: 14px;
}
/* Sort dropdown */
.sort-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: 10px;
padding: 6px;
min-width: 160px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
z-index: 20;
}
.sort-menu .dropdown-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
font-size: 13px;
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: all 150ms ease;
}
.sort-menu .dropdown-item:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.sort-menu .dropdown-item.selected {
color: var(--accent-primary);
font-weight: 500;
}
.sort-menu .sort-indicator {
font-size: 12px;
opacity: 0.8;
}
/* Content */
.content {
min-height: 200px;
}
.loading,
.error,
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px;
color: var(--text-muted);
}
.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;
}
.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;
}
.empty-hint {
font-size: 13px;
margin-top: 8px;
}
/* Album Grid */
.album-sections {
display: flex;
gap: 12px;
align-items: flex-start;
}
.album-sections.virtualized {
flex: 1;
height: calc(100vh - 280px); /* Adjust based on header/controls height */
min-height: 400px;
}
.virtualized-container {
flex: 1;
height: 100%;
min-width: 0;
overflow: hidden;
}
/* Album List */
.album-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.album-row {
display: grid;
grid-template-columns: 56px 1fr auto;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-secondary);
border-radius: 10px;
cursor: pointer;
transition: background 150ms ease;
}
.album-row:hover {
background: var(--bg-tertiary);
}
.album-row-art {
width: 52px;
height: 52px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.album-row-art img {
width: 100%;
height: 100%;
object-fit: cover;
}
.album-row-info {
min-width: 0;
}
.album-row-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.album-row-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.album-row-meta span + span::before {
content: "\2022";
margin: 0 8px;
color: var(--text-muted);
}
.album-row-meta .quality-badge {
font-size: 11px;
font-weight: 600;
color: var(--alpha-85);
background: var(--alpha-10);
border: 1px solid var(--alpha-15);
border-radius: 6px;
padding: 3px 8px;
min-width: 90px;
text-align: center;
box-sizing: border-box;
}
/* Track Controls */
.track-controls {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.track-sections {
display: flex;
gap: 12px;
align-items: flex-start;
overflow-x: hidden;
}
.track-sections.virtualized {
flex: 1;
height: calc(100vh - 280px);
min-height: 400px;
overflow: hidden;
}
.alpha-index-inline {
display: flex;
flex-wrap: wrap;
gap: 2px;
flex: 1;
justify-content: center;
}
.alpha-index-inline .alpha-letter {
width: 22px;
height: 22px;
font-size: 10px;
}
.alpha-letter {
width: 20px;
height: 20px;
padding: 0;
background: none;
border: none;
color: var(--text-primary);
font-size: 11px;
font-weight: 600;
cursor: pointer;
opacity: 0.9;
}
.alpha-letter:hover {
color: var(--accent-primary);
}
.alpha-letter.disabled {
opacity: 0.25;
cursor: default;
pointer-events: none;
}
/* Track List */
.track-list {
display: flex;
flex-direction: column;
}
.disc-header {
margin-top: 16px;
margin-bottom: 8px;
font-size: 12px;
font-weight: 700;
color: var(--text-secondary);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.track-list .disc-header:first-child {
margin-top: 0;
}
.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: var(--text-muted);
font-weight: 400;
box-sizing: border-box;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 8px;
}
.track-list-header .col-number {
width: 48px;
text-align: center;
}
.track-list-header .col-title {
flex: 1;
min-width: 0;
}
.track-list-header .col-duration {
width: 80px;
text-align: center;
}
.track-list-header .col-quality {
width: 80px;
text-align: center;
}
.track-list-header .col-spacer {
width: 28px;
}
/* Album Detail */
.album-detail {
padding-bottom: 100px;
}
.back-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
margin-bottom: 24px;
transition: color 150ms ease;
}
.back-btn:hover {
color: var(--text-primary);
}
.album-header {
display: flex;
gap: 24px;
margin-bottom: 32px;
}
.album-artwork {
width: 200px;
height: 200px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
}
.album-artwork img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artwork-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
color: var(--text-muted);
}
.album-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.album-info h1 {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 8px 0;
}
.album-info .artist {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px 0;
}
.album-info .artist-link {
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
font-size: 18px;
font-weight: 500;
color: var(--accent-primary);
}
.album-info .artist-link:hover {
color: var(--text-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.album-info .meta {
font-size: 14px;
color: var(--text-muted);
margin: 0 0 12px 0;
}
.audio-specs {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.spec-badge {
padding: 4px 10px;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.spec-badge.hires {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
}
.spec-item {
font-size: 13px;
color: var(--text-secondary);
padding: 4px 8px;
background: var(--bg-secondary);
border-radius: 4px;
}
.single-file-notice {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
background: var(--bg-secondary);
border-radius: 6px;
padding: 6px 10px;
margin-bottom: 8px;
max-width: fit-content;
}
.album-actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
/* Nav row for album detail */
.nav-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.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);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.modal {
--album-settings-cover-size: 94px;
--discogs-panel-height: calc(var(--album-settings-cover-size) + 56px);
width: 100%;
max-width: 704px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
border-radius: 16px;
border: 1px solid var(--bg-tertiary);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--bg-tertiary);
}
.modal-header h2 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.close-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;
}
.close-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.modal-body {
padding: 18px 20px;
overflow-y: auto;
}
.album-header-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: start;
margin-bottom: 18px;
}
.album-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.album-artist {
margin-top: 6px;
font-size: 18px;
font-weight: 400;
color: var(--text-muted);
line-height: 1.25;
word-break: break-word;
}
.album-settings-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.album-action-btn {
width: 190px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 12px;
background: var(--bg-tertiary);
border: 1px solid var(--bg-tertiary);
border-radius: 10px;
color: var(--text-primary);
cursor: pointer;
transition: all 150ms ease;
}
.album-action-btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.album-action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 8px;
}
.artwork-layout-header {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: end;
margin-bottom: 8px;
}
.artwork-layout-header.discogs-active {
grid-template-columns: 1fr 1fr;
}
.artwork-layout-header label {
margin-bottom: 0;
}
.discogs-layout-label {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin-left: -8px;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.toggle-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--accent-primary);
cursor: pointer;
}
.toggle-label span {
font-size: 14px;
color: var(--text-primary);
}
.form-hint {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
}
.artwork-row {
display: flex;
align-items: center;
gap: 16px;
}
.artwork-layout {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
align-items: start;
}
.artwork-layout.discogs-active {
grid-template-columns: 1fr 1fr;
}
.discogs-panel {
min-width: 0;
border: 1px solid var(--bg-tertiary);
border-radius: 10px;
padding: 10px 12px;
background: var(--bg-secondary);
height: var(--discogs-panel-height);
display: flex;
flex-direction: column;
overflow: hidden;
margin-left: -8px;
width: calc(100% + 8px);
}
.discogs-hint {
margin-top: 8px;
}
.artwork-preview {
width: var(--album-settings-cover-size);
height: var(--album-settings-cover-size);
border-radius: 6px;
object-fit: cover;
background: var(--bg-tertiary);
}
.artwork-placeholder-mini {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.artwork-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.discogs-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--bg-quaternary);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 150ms ease;
}
.discogs-btn:hover:not(:disabled) {
background: var(--bg-quaternary);
border-color: var(--text-muted);
}
.discogs-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discogs-icon {
width: 16px;
height: 16px;
filter: invert(1) brightness(0.8);
}
.discogs-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
margin-top: 8px;
}
.discogs-options-compact {
grid-template-columns: repeat(3, var(--album-settings-cover-size));
grid-template-rows: var(--album-settings-cover-size);
justify-content: start;
gap: 10px;
margin-top: 0;
flex: 1;
overflow: hidden;
}
.discogs-panel .discogs-option:hover {
transform: none;
}
.discogs-option {
position: relative;
aspect-ratio: 1;
padding: 0;
background: var(--bg-tertiary);
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 150ms ease;
}
.discogs-option:hover {
border-color: var(--text-muted);
transform: scale(1.05);
}
.discogs-option.selected {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
.discogs-option img {
width: 100%;
height: 100%;
object-fit: cover;
}
.discogs-option .option-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 6px;
background: rgba(0, 0, 0, 0.8);
color: white;
font-size: 10px;
text-align: center;
display: flex;
flex-direction: column;
gap: 2px;
}
.discogs-option .release-title {
font-weight: 500;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.discogs-option .image-dims {
opacity: 0.8;
font-size: 9px;
}
.discogs-header {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 8px;
}
.carousel-controls {
display: flex;
align-items: center;
gap: 8px;
}
.carousel-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--bg-quaternary);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
transition: all 150ms ease;
}
.carousel-btn:hover:not(:disabled) {
background: var(--bg-quaternary);
border-color: var(--text-muted);
}
.carousel-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.page-indicator {
font-size: 12px;
color: var(--text-muted);
min-width: 40px;
text-align: center;
}
.modal-footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--bg-tertiary);
}
.footer-left {
display: flex;
flex-direction: column;
gap: 6px;
max-width: 60%;
}
.footer-actions {
display: flex;
gap: 12px;
}
.footer-hint {
margin-top: 0;
}
/* Artist View - Two Column Layout */
.artist-alpha-index-row {
padding: 8px 0 12px;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 12px;
}
.artists-tab-container {
display: flex;
flex-direction: column;
height: calc(100vh - 320px); /* Header + tabs + player */
min-height: 400px;
}
.artist-two-column-layout {
display: flex;
gap: 0;
flex: 1;
min-height: 0;
margin: 0 -24px 0 -18px; /* Negative margins to extend to edges */
padding: 0;
}
.artist-column {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: transparent;
border-right: 1px solid var(--bg-tertiary);
overflow: hidden;
padding-left: 18px;
}
.artist-column-header {
padding: 8px 16px 8px 0;
}
.artist-count {
font-size: 12px;
color: var(--text-muted);
}
.artist-list-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 12px 4px 0;
/* Smooth scrolling optimizations */
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior: contain;
/* GPU acceleration for smoother scrolling */
will-change: scroll-position;
contain: strict;
}
.artist-group-header {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 12px 8px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.artist-card-compact {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 8px;
/* Performance optimizations */
contain: layout style;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 150ms ease;
text-align: left;
}
.artist-card-compact:hover {
background: var(--bg-tertiary);
}
.artist-card-compact.selected {
background: var(--accent-primary);
}
.artist-card-compact.selected .artist-name {
color: white;
}
.artist-card-compact.selected .artist-meta {
color: rgba(255, 255, 255, 0.7);
}
.artist-card-image {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
}
.artist-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-primary) 100%);
color: var(--text-muted);
}
.artist-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.artist-card-info .artist-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-card-info .artist-meta {
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artist-albums-column {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
padding: 0 24px 0 24px;
}
.artist-albums-header {
display: flex;
align-items: baseline;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-tertiary);
margin-bottom: 16px;
}
.artist-albums-header h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.artist-albums-header .album-count {
font-size: 13px;
color: var(--text-muted);
}
.artist-albums-grid {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
align-content: start;
padding-right: 8px;
/* Smooth scrolling optimizations */
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
contain: strict;
}
.empty-small {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 32px;
color: var(--text-muted);
text-align: center;
}
.empty-small.centered {
flex: 1;
}
.empty-small p {
margin: 0;
font-size: 13px;
}
</style>