298 lines
9.3 KiB
Svelte
298 lines
9.3 KiB
Svelte
<script lang="ts">
|
|
import type { Snippet } from 'svelte';
|
|
import type { LibraryController } from '$lib/utils/libraryController.svelte';
|
|
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
|
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
|
import AddToPlaylistModal from '$lib/components/AddToPlaylistModal.svelte';
|
|
import SourceAlbumModal from '$lib/components/SourceAlbumModal.svelte';
|
|
import LibraryFilterBar from '$lib/components/LibraryFilterBar.svelte';
|
|
import { CircleX, Play, Shuffle } from 'lucide-svelte';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
interface Props {
|
|
ctrl: LibraryController<any>;
|
|
headerIcon: Snippet;
|
|
headerTitle: string;
|
|
recentLabel?: string;
|
|
statsPanel?: Snippet;
|
|
recentCardOverlay?: Snippet<[any]>;
|
|
cardTopLeftBadge: Snippet<[any]>;
|
|
cardTopRightExtra?: Snippet<[any]>;
|
|
cardBottomLeft?: Snippet<[any]>;
|
|
cardBodyExtra?: Snippet<[any]>;
|
|
contextMenuBackdrop?: boolean;
|
|
emptyIcon: Snippet;
|
|
emptyTitle: string;
|
|
emptyDescription: string;
|
|
}
|
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
|
|
let {
|
|
ctrl,
|
|
headerIcon,
|
|
headerTitle,
|
|
recentLabel = 'Recently Played',
|
|
statsPanel,
|
|
recentCardOverlay,
|
|
cardTopLeftBadge,
|
|
cardTopRightExtra,
|
|
cardBottomLeft,
|
|
cardBodyExtra,
|
|
contextMenuBackdrop = false,
|
|
emptyIcon,
|
|
emptyTitle,
|
|
emptyDescription
|
|
}: Props = $props();
|
|
|
|
const a = ctrl.adapter;
|
|
|
|
onMount(() => ctrl.init());
|
|
onDestroy(() => ctrl.cleanup());
|
|
</script>
|
|
|
|
<div class="container mx-auto p-6">
|
|
<div class="flex items-center gap-3 mb-2">
|
|
{@render headerIcon()}
|
|
<h1 class="text-2xl font-bold">{headerTitle}</h1>
|
|
{#if ctrl.stats}
|
|
<span class="badge badge-neutral"
|
|
>{(ctrl.stats as { total_albums?: number }).total_albums ?? 0} albums</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if statsPanel}
|
|
{@render statsPanel()}
|
|
{/if}
|
|
|
|
{#if ctrl.recentAlbums.length > 0}
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold mb-3 opacity-80">{recentLabel}</h2>
|
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
|
{#each ctrl.recentAlbums as album (a.getAlbumId(album))}
|
|
<button
|
|
class="flex-shrink-0 w-36 group cursor-pointer transition-transform hover:scale-105 active:scale-95"
|
|
onclick={() => ctrl.openDetail(album)}
|
|
>
|
|
<div class="aspect-square rounded-lg overflow-hidden shadow-sm relative">
|
|
<AlbumImage
|
|
mbid={a.getAlbumMbid(album) ?? String(a.getAlbumId(album))}
|
|
customUrl={a.getAlbumImageUrl(album)}
|
|
alt={a.getAlbumName(album)}
|
|
size="full"
|
|
rounded="none"
|
|
className="w-full h-full"
|
|
/>
|
|
{#if recentCardOverlay}
|
|
{@render recentCardOverlay(album)}
|
|
{/if}
|
|
</div>
|
|
<p class="text-sm font-medium mt-1 line-clamp-1">{a.getAlbumName(album)}</p>
|
|
<p class="text-xs opacity-60 line-clamp-1">{a.getArtistName(album)}</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if a.supportsFavorites && ctrl.favoriteAlbums.length > 0}
|
|
<div class="mb-8">
|
|
<h2 class="text-lg font-semibold mb-3 opacity-80">Favorites</h2>
|
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
|
{#each ctrl.favoriteAlbums as album (a.getAlbumId(album))}
|
|
<button
|
|
class="flex-shrink-0 w-36 group cursor-pointer transition-transform hover:scale-105 active:scale-95"
|
|
onclick={() => ctrl.openDetail(album)}
|
|
>
|
|
<div class="aspect-square rounded-lg overflow-hidden shadow-sm">
|
|
<AlbumImage
|
|
mbid={a.getAlbumMbid(album) ?? String(a.getAlbumId(album))}
|
|
customUrl={a.getAlbumImageUrl(album)}
|
|
alt={a.getAlbumName(album)}
|
|
size="full"
|
|
rounded="none"
|
|
className="w-full h-full"
|
|
/>
|
|
</div>
|
|
<p class="text-sm font-medium mt-1 line-clamp-1">{a.getAlbumName(album)}</p>
|
|
<p class="text-xs opacity-60 line-clamp-1">{a.getArtistName(album)}</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="mb-6">
|
|
<h2 class="text-lg font-semibold mb-3 opacity-80">All Albums</h2>
|
|
<LibraryFilterBar
|
|
bind:searchQuery={ctrl.searchQuery}
|
|
onSearchInput={ctrl.handleSearch}
|
|
sortOptions={a.sortOptions}
|
|
sortBy={ctrl.sortBy}
|
|
onSortChange={ctrl.handleSortChange}
|
|
sortOrder={ctrl.sortOrder}
|
|
onToggleSortOrder={ctrl.toggleSortOrder}
|
|
ascValue={a.ascValue}
|
|
genres={a.supportsGenres ? ctrl.genres : undefined}
|
|
selectedGenre={a.supportsGenres ? ctrl.selectedGenre : undefined}
|
|
onGenreChange={a.supportsGenres ? ctrl.handleGenreChange : undefined}
|
|
resultCount={ctrl.loading ? null : ctrl.total}
|
|
loading={ctrl.loading}
|
|
/>
|
|
</div>
|
|
|
|
{#if ctrl.fetchError}
|
|
<div role="alert" class="alert alert-error alert-soft mb-4">
|
|
<CircleX class="h-6 w-6 shrink-0" />
|
|
<div class="flex flex-col gap-1">
|
|
<span>{ctrl.fetchError}</span>
|
|
{#if ctrl.fetchErrorCode === 'CIRCUIT_BREAKER_OPEN' || /connection|DNS|not configured/i.test(ctrl.fetchError)}
|
|
<a href="/settings" class="link link-primary text-sm">Check your settings →</a>
|
|
{/if}
|
|
</div>
|
|
<button class="btn btn-sm btn-ghost" onclick={() => ctrl.fetchAlbums(true)}>Retry</button>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if ctrl.loading}
|
|
<div
|
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
|
|
>
|
|
{#each Array(12) as _}
|
|
<div class="card bg-base-100 shadow-sm animate-pulse">
|
|
<div class="aspect-square bg-base-300"></div>
|
|
<div class="card-body p-3">
|
|
<div class="h-4 bg-base-300 rounded w-3/4"></div>
|
|
<div class="h-3 bg-base-300 rounded w-1/2 mt-1"></div>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{:else}
|
|
<div
|
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
|
|
>
|
|
{#each ctrl.albums as album (a.getAlbumId(album))}
|
|
<div
|
|
class="card bg-base-100 w-full shadow-sm group relative cursor-pointer transition-transform hover:scale-105 hover:shadow-lg active:scale-95"
|
|
onclick={() => ctrl.openDetail(album)}
|
|
onkeydown={(e) =>
|
|
(e.key === 'Enter' || e.key === ' ') &&
|
|
(e.preventDefault(), ctrl.openDetail(album))}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
<figure class="aspect-square overflow-hidden relative">
|
|
<AlbumImage
|
|
mbid={a.getAlbumMbid(album) ?? String(a.getAlbumId(album))}
|
|
customUrl={a.getAlbumImageUrl(album)}
|
|
alt={a.getAlbumName(album)}
|
|
size="full"
|
|
rounded="none"
|
|
className="w-full h-full"
|
|
/>
|
|
<div class="absolute top-2 left-2 flex items-center gap-1">
|
|
{@render cardTopLeftBadge(album)}
|
|
</div>
|
|
<div class="absolute top-2 right-2 flex items-start gap-1">
|
|
{#if cardTopRightExtra}
|
|
{@render cardTopRightExtra(album)}
|
|
{:else if a.getAlbumYear(album)}
|
|
<div class="badge badge-sm badge-ghost">{a.getAlbumYear(album)}</div>
|
|
{/if}
|
|
{#if contextMenuBackdrop}
|
|
<div class="rounded-full bg-black/50 backdrop-blur-sm">
|
|
<ContextMenu
|
|
items={ctrl.getAlbumMenuItems(album)}
|
|
position="end"
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
{:else}
|
|
<div>
|
|
<ContextMenu
|
|
items={ctrl.getAlbumMenuItems(album)}
|
|
position="end"
|
|
size="xs"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{#if cardBottomLeft}
|
|
<div class="absolute bottom-2 left-2">
|
|
{@render cardBottomLeft(album)}
|
|
</div>
|
|
{/if}
|
|
<div
|
|
class="absolute bottom-2 right-2 flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
{#if a.supportsShuffle}
|
|
<button
|
|
class="btn btn-circle btn-sm btn-ghost bg-black/50 backdrop-blur-sm text-white shadow-md hover:bg-black/70"
|
|
onclick={(e) => ctrl.quickShuffle(album, e)}
|
|
aria-label="Shuffle {a.getAlbumName(album)}"
|
|
>
|
|
<Shuffle class="h-3.5 w-3.5" />
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="btn btn-circle btn-sm btn-primary shadow-md"
|
|
onclick={(e) => ctrl.quickPlay(album, e)}
|
|
aria-label="Play {a.getAlbumName(album)}"
|
|
>
|
|
{#if ctrl.playingAlbumId === a.getAlbumId(album)}
|
|
<span class="loading loading-spinner loading-xs"></span>
|
|
{:else}
|
|
<Play class="h-4 w-4 fill-current" />
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
</figure>
|
|
|
|
<div class="card-body p-3">
|
|
<h2 class="card-title text-sm line-clamp-2 min-h-[2.5rem]">
|
|
{a.getAlbumName(album)}
|
|
</h2>
|
|
{#if cardBodyExtra}
|
|
{@render cardBodyExtra(album)}
|
|
{:else}
|
|
<p class="text-xs opacity-70 line-clamp-1">{a.getArtistName(album)}</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if ctrl.albums.length === 0}
|
|
<div class="card bg-base-200 mt-4">
|
|
<div class="card-body items-center text-center">
|
|
{@render emptyIcon()}
|
|
<p class="text-lg opacity-60">{emptyTitle}</p>
|
|
<p class="text-sm opacity-40">{emptyDescription}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if ctrl.albums.length < ctrl.total}
|
|
<div class="flex justify-center mt-6">
|
|
<button class="btn btn-ghost" onclick={ctrl.loadMore} disabled={ctrl.loadingMore}>
|
|
{#if ctrl.loadingMore}
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
{/if}
|
|
Load More
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<AddToPlaylistModal bind:this={ctrl.playlistModalRef} />
|
|
|
|
<SourceAlbumModal
|
|
bind:open={ctrl.detailModalOpen}
|
|
sourceType={a.sourceType}
|
|
album={ctrl.selectedAlbum}
|
|
onclose={ctrl.handleDetailClose}
|
|
/>
|