- Add showGenre prop (default true) to AlbumCard
- Make genre prop optional
- Hide genre and favorite button in LocalLibrary albums
- VirtualizedAlbumList passes showGenre={false}, showFavorite={false}
- Add spacer to keep Play button centered when favorite is hidden
478 lines
13 KiB
Svelte
478 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { Disc3 } from 'lucide-svelte';
|
|
import AlbumCard from './AlbumCard.svelte';
|
|
|
|
// Types
|
|
interface LocalAlbum {
|
|
id: string;
|
|
title: string;
|
|
artist: string;
|
|
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;
|
|
}
|
|
|
|
interface AlbumGroup {
|
|
key: string;
|
|
id: string;
|
|
albums: LocalAlbum[];
|
|
}
|
|
|
|
type VirtualItem =
|
|
| { type: 'header'; key: string; id: string; height: number }
|
|
| { type: 'album'; album: LocalAlbum; height: number }
|
|
| { type: 'row'; albums: LocalAlbum[]; height: number }; // For grid mode
|
|
|
|
interface Props {
|
|
groups: AlbumGroup[];
|
|
viewMode: 'grid' | 'list';
|
|
showGroupHeaders: boolean;
|
|
getArtworkUrl: (path?: string) => string;
|
|
getQualityBadge: (album: LocalAlbum) => string;
|
|
isHiRes: (album: LocalAlbum) => boolean;
|
|
formatDuration: (secs: number) => string;
|
|
onAlbumClick: (album: LocalAlbum) => void;
|
|
onAlbumPlay: (album: LocalAlbum) => void;
|
|
onAlbumQueueNext: (album: LocalAlbum) => void;
|
|
onAlbumQueueLater: (album: LocalAlbum) => void;
|
|
scrollToGroupId?: string;
|
|
}
|
|
|
|
let {
|
|
groups,
|
|
viewMode,
|
|
showGroupHeaders,
|
|
getArtworkUrl,
|
|
getQualityBadge,
|
|
isHiRes,
|
|
formatDuration,
|
|
onAlbumClick,
|
|
onAlbumPlay,
|
|
onAlbumQueueNext,
|
|
onAlbumQueueLater,
|
|
scrollToGroupId,
|
|
}: Props = $props();
|
|
|
|
// Constants
|
|
const HEADER_HEIGHT = 44; // px
|
|
const LIST_ROW_HEIGHT = 76; // px (52px art + padding + gap)
|
|
const GRID_ROW_HEIGHT = 290; // px (180px artwork + 8px margin + ~64px info + 24px gap + buffer)
|
|
const GRID_MIN_CARD_WIDTH = 180; // px - matches AlbumCard size="large"
|
|
const GRID_GAP = 14; // px (horizontal gap between cards)
|
|
const BUFFER_ITEMS = 5; // Extra items to render above/below viewport
|
|
const BOTTOM_PADDING = 100; // px - extra space at bottom for player bar
|
|
|
|
// State
|
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
let scrollTop = $state(0);
|
|
let containerHeight = $state(0);
|
|
let containerWidth = $state(0);
|
|
|
|
// Computed: number of columns in grid mode
|
|
let gridColumns = $derived.by(() => {
|
|
if (viewMode !== 'grid' || containerWidth === 0) return 1;
|
|
return Math.max(1, Math.floor((containerWidth + GRID_GAP) / (GRID_MIN_CARD_WIDTH + GRID_GAP)));
|
|
});
|
|
|
|
// Computed: flatten groups into virtual items with cumulative positions
|
|
let virtualItems = $derived.by(() => {
|
|
const items: (VirtualItem & { top: number; groupId?: string })[] = [];
|
|
let currentTop = 0;
|
|
|
|
for (const group of groups) {
|
|
// Add header if showing
|
|
if (showGroupHeaders && group.key) {
|
|
items.push({
|
|
type: 'header',
|
|
key: group.key,
|
|
id: group.id,
|
|
height: HEADER_HEIGHT,
|
|
top: currentTop,
|
|
groupId: group.id,
|
|
});
|
|
currentTop += HEADER_HEIGHT;
|
|
}
|
|
|
|
if (viewMode === 'list') {
|
|
// List mode: one album per row
|
|
for (const album of group.albums) {
|
|
items.push({
|
|
type: 'album',
|
|
album,
|
|
height: LIST_ROW_HEIGHT,
|
|
top: currentTop,
|
|
});
|
|
currentTop += LIST_ROW_HEIGHT;
|
|
}
|
|
} else {
|
|
// Grid mode: group albums into rows based on column count
|
|
const cols = gridColumns;
|
|
for (let i = 0; i < group.albums.length; i += cols) {
|
|
const rowAlbums = group.albums.slice(i, i + cols);
|
|
items.push({
|
|
type: 'row',
|
|
albums: rowAlbums,
|
|
height: GRID_ROW_HEIGHT,
|
|
top: currentTop,
|
|
});
|
|
currentTop += GRID_ROW_HEIGHT;
|
|
}
|
|
}
|
|
}
|
|
|
|
return items;
|
|
});
|
|
|
|
// Computed: total height of all items (plus bottom padding for player bar)
|
|
let totalHeight = $derived(
|
|
virtualItems.length > 0
|
|
? virtualItems[virtualItems.length - 1].top + virtualItems[virtualItems.length - 1].height + BOTTOM_PADDING
|
|
: 0
|
|
);
|
|
|
|
// Binary search to find first item at or after a given position
|
|
function binarySearchStart(items: typeof virtualItems, targetTop: number): number {
|
|
let low = 0;
|
|
let high = items.length - 1;
|
|
let result = 0;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const item = items[mid];
|
|
if (item.top + item.height > targetTop) {
|
|
result = mid;
|
|
high = mid - 1;
|
|
} else {
|
|
low = mid + 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Binary search to find last item before a given position
|
|
function binarySearchEnd(items: typeof virtualItems, targetBottom: number, startFrom: number): number {
|
|
let low = startFrom;
|
|
let high = items.length - 1;
|
|
let result = high;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const item = items[mid];
|
|
if (item.top > targetBottom) {
|
|
result = mid;
|
|
high = mid - 1;
|
|
} else {
|
|
low = mid + 1;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Computed: visible items (those in viewport + buffer)
|
|
let visibleItems = $derived.by(() => {
|
|
if (virtualItems.length === 0) return [];
|
|
|
|
const viewportTop = scrollTop;
|
|
const viewportBottom = scrollTop + containerHeight;
|
|
|
|
// Use binary search for O(log n) instead of O(n)
|
|
const firstVisible = binarySearchStart(virtualItems, viewportTop);
|
|
const lastVisible = binarySearchEnd(virtualItems, viewportBottom, firstVisible);
|
|
|
|
const startIdx = Math.max(0, firstVisible - BUFFER_ITEMS);
|
|
const endIdx = Math.min(virtualItems.length - 1, lastVisible + BUFFER_ITEMS);
|
|
|
|
return virtualItems.slice(startIdx, endIdx + 1);
|
|
});
|
|
|
|
// Group ID to scroll position map
|
|
let groupPositions = $derived.by(() => {
|
|
const map = new Map<string, number>();
|
|
for (const item of virtualItems) {
|
|
if (item.groupId) {
|
|
map.set(item.groupId, item.top);
|
|
}
|
|
}
|
|
return map;
|
|
});
|
|
|
|
// Scroll handling
|
|
function handleScroll(e: Event) {
|
|
const target = e.target as HTMLDivElement;
|
|
scrollTop = target.scrollTop;
|
|
}
|
|
|
|
// Resize observer for container dimensions
|
|
let resizeObserver: ResizeObserver | null = null;
|
|
|
|
onMount(() => {
|
|
if (containerEl) {
|
|
containerHeight = containerEl.clientHeight;
|
|
containerWidth = containerEl.clientWidth;
|
|
|
|
resizeObserver = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
containerHeight = entry.contentRect.height;
|
|
containerWidth = entry.contentRect.width;
|
|
}
|
|
});
|
|
resizeObserver.observe(containerEl);
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
resizeObserver?.disconnect();
|
|
});
|
|
|
|
// Scroll to group when requested
|
|
$effect(() => {
|
|
if (scrollToGroupId && containerEl) {
|
|
const position = groupPositions.get(scrollToGroupId);
|
|
if (position !== undefined) {
|
|
containerEl.scrollTo({ top: position, behavior: 'smooth' });
|
|
}
|
|
}
|
|
});
|
|
|
|
// Public method to scroll to a specific group
|
|
export function scrollToGroup(groupId: string) {
|
|
const position = groupPositions.get(groupId);
|
|
if (position !== undefined && containerEl) {
|
|
containerEl.scrollTo({ top: position, behavior: 'smooth' });
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div
|
|
class="virtual-container"
|
|
bind:this={containerEl}
|
|
onscroll={handleScroll}
|
|
>
|
|
<div class="virtual-content" style="height: {totalHeight}px;">
|
|
{#each visibleItems as item (item.type === 'header' ? `header-${item.id}` : item.type === 'album' ? `album-${item.album.id}` : `row-${item.albums[0]?.id}`)}
|
|
<div
|
|
class="virtual-item"
|
|
style="transform: translateY({item.top}px); height: {item.height}px;"
|
|
>
|
|
{#if item.type === 'header'}
|
|
<div class="group-header">
|
|
<span class="group-title">{item.key}</span>
|
|
</div>
|
|
{:else if item.type === 'album'}
|
|
<!-- List mode: single album row -->
|
|
<div
|
|
class="album-row"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => onAlbumClick(item.album)}
|
|
onkeydown={(e) => e.key === 'Enter' && onAlbumClick(item.album)}
|
|
>
|
|
<div class="album-row-art">
|
|
<!-- Placeholder always visible as background -->
|
|
<div class="artwork-placeholder">
|
|
<Disc3 size={28} />
|
|
</div>
|
|
<!-- Image overlays placeholder when loaded -->
|
|
{#if item.album.artwork_path}
|
|
<img
|
|
src={getArtworkUrl(item.album.artwork_path)}
|
|
alt={item.album.title}
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
{/if}
|
|
</div>
|
|
<div class="album-row-info">
|
|
<div class="album-row-title truncate">{item.album.title}</div>
|
|
<div class="album-row-meta">
|
|
<span>{item.album.artist}</span>
|
|
{#if item.album.year}<span>{item.album.year}</span>{/if}
|
|
<span>{item.album.track_count} tracks</span>
|
|
<span>{formatDuration(item.album.total_duration_secs)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="album-row-quality">
|
|
<span class="quality-badge" class:hires={isHiRes(item.album)}>
|
|
{getQualityBadge(item.album)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{:else if item.type === 'row'}
|
|
<!-- Grid mode: row of album cards -->
|
|
<div class="album-grid-row">
|
|
{#each item.albums as album (album.id)}
|
|
<AlbumCard
|
|
artwork={getArtworkUrl(album.artwork_path)}
|
|
title={album.title}
|
|
artist={album.artist}
|
|
quality={getQualityBadge(album)}
|
|
size="large"
|
|
showFavorite={false}
|
|
showGenre={false}
|
|
onPlay={() => onAlbumPlay(album)}
|
|
onPlayNext={() => onAlbumQueueNext(album)}
|
|
onPlayLater={() => onAlbumQueueLater(album)}
|
|
onclick={() => onAlbumClick(album)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.virtual-container {
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.virtual-content {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.virtual-item {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
will-change: transform;
|
|
}
|
|
|
|
/* Group Header */
|
|
.group-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.group-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
letter-spacing: 0.04em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
/* Album Row (List Mode) */
|
|
.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;
|
|
height: 72px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.album-row:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.album-row-art {
|
|
position: relative;
|
|
width: 52px;
|
|
height: 52px;
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.album-row-art img {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
z-index: 1;
|
|
}
|
|
|
|
.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-row-info {
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.album-row-title {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.truncate {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.album-row-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.album-row-meta span:not(:last-child)::after {
|
|
content: '·';
|
|
margin-left: 8px;
|
|
}
|
|
|
|
.album-row-quality {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.quality-badge {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
padding: 4px 8px;
|
|
border-radius: 6px;
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.quality-badge.hires {
|
|
background: var(--accent-gradient, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
|
|
color: white;
|
|
}
|
|
|
|
/* Album Grid Row (Grid Mode) */
|
|
.album-grid-row {
|
|
display: flex;
|
|
gap: 14px;
|
|
padding: 0;
|
|
}
|
|
|
|
.album-grid-row :global(.album-card) {
|
|
flex-shrink: 0;
|
|
}
|
|
</style>
|