feat(library): add album and artist scanning with cover art

This commit is contained in:
2025-10-01 11:32:40 -04:00
parent de04dbc323
commit 515a744734
11 changed files with 534 additions and 39 deletions

View File

@@ -1,18 +1,26 @@
<script lang="ts">
import { onMount } from 'svelte';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings';
import { scanArtists, type Artist } from '$lib/library/scanner';
import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
import type { ArtistWithAlbums, Album } from '$lib/types/track';
let artists = $state<Artist[]>([]);
type ViewMode = 'artists' | 'albums';
let viewMode = $state<ViewMode>('artists');
let artists = $state<ArtistWithAlbums[]>([]);
let albums = $state<Album[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedArtistIndex = $state<number | null>(null);
let selectedAlbumIndex = $state<number | null>(null);
onMount(async () => {
await loadSettings();
await loadArtists();
await loadLibrary();
});
async function loadArtists() {
async function loadLibrary() {
loading = true;
error = null;
@@ -23,66 +31,208 @@
}
try {
artists = await scanArtists($settings.musicFolder);
const [artistsData, albumsData] = await Promise.all([
scanArtistsWithAlbums($settings.musicFolder),
scanAlbums($settings.musicFolder)
]);
artists = artistsData;
albums = albumsData;
loading = false;
} catch (e) {
error = 'Error loading artists: ' + (e as Error).message;
error = 'Error loading library: ' + (e as Error).message;
loading = false;
}
}
function getThumbnailUrl(coverArtPath?: string): string {
if (!coverArtPath) {
return ''; // Will use CSS background for placeholder
}
return convertFileSrc(coverArtPath);
}
function handleArtistClick(index: number) {
selectedArtistIndex = index;
}
function handleAlbumClick(index: number) {
selectedAlbumIndex = index;
}
function getTotalTracks(artist: ArtistWithAlbums): number {
return artist.albums.reduce((sum, album) => sum + album.trackCount, 0);
}
</script>
<div>
<h2>Library</h2>
<h2 style="padding: 8px 8px 0 8px;">Library</h2>
{#if loading}
<p>Loading artists...</p>
<p>Loading library...</p>
{:else if error}
<p class="error">{error}</p>
{:else if artists.length === 0}
<p>No artists found in your music folder.</p>
{:else if artists.length === 0 && albums.length === 0}
<p>No music found in your music folder.</p>
{:else}
<section class="library-content">
<table>
<thead>
<tr>
<th>Artist</th>
<th>Path</th>
</tr>
</thead>
<tbody>
{#each artists as artist}
<tr>
<td>{artist.name}</td>
<td class="path">{artist.path}</td>
</tr>
{/each}
</tbody>
</table>
<!-- Tabs -->
<menu role="tablist">
<li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button>
</li>
<li role="tab" aria-selected={viewMode === 'albums'}>
<button onclick={() => viewMode = 'albums'}>Albums</button>
</li>
</menu>
<!-- Tab Content -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
{#if viewMode === 'artists'}
<!-- Artists View -->
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="cover-column"></th>
<th>Artist</th>
<th>Albums</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
{#each artists as artist, i}
<tr
class:highlighted={selectedArtistIndex === i}
onclick={() => handleArtistClick(i)}
>
<td class="cover-cell">
{#if artist.primaryCoverArt}
<img
src={getThumbnailUrl(artist.primaryCoverArt)}
alt="{artist.name} cover"
class="thumbnail"
/>
{:else}
<div class="thumbnail-placeholder"></div>
{/if}
</td>
<td>{artist.name}</td>
<td>{artist.albums.length}</td>
<td>{getTotalTracks(artist)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- Albums View -->
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th class="cover-column"></th>
<th>Album</th>
<th>Artist</th>
<th>Year</th>
<th>Tracks</th>
</tr>
</thead>
<tbody>
{#each albums as album, i}
<tr
class:highlighted={selectedAlbumIndex === i}
onclick={() => handleAlbumClick(i)}
>
<td class="cover-cell">
{#if album.coverArtPath}
<img
src={getThumbnailUrl(album.coverArtPath)}
alt="{album.title} cover"
class="thumbnail"
/>
{:else}
<div class="thumbnail-placeholder"></div>
{/if}
</td>
<td>{album.title}</td>
<td>{album.artist}</td>
<td>{album.year ?? '—'}</td>
<td>{album.trackCount}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
</section>
{/if}
</div>
<style>
h2 {
margin-top: 0;
margin: 0 0 8px 0;
}
.library-content {
margin-top: 20px;
margin: 0;
}
.error {
color: #ff6b6b;
}
.tab-content {
margin-top: -2px;
}
.window-body {
padding: 0;
}
.table-container {
overflow-y: auto;
}
table {
width: 100%;
}
.path {
font-family: monospace;
font-size: 0.9em;
opacity: 0.7;
th {
text-align: left;
}
.cover-column {
width: 60px;
}
.cover-cell {
padding: 4px;
}
.thumbnail {
width: 48px;
height: 48px;
object-fit: cover;
display: block;
image-rendering: auto;
}
.thumbnail-placeholder {
width: 48px;
height: 48px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
display: block;
}
td:nth-child(3),
td:nth-child(4),
td:nth-child(5) {
width: 80px;
text-align: right;
}
</style>