mirror of
https://github.com/markuryy/shark.git
synced 2025-12-12 19:51:01 +00:00
272 lines
7.6 KiB
Svelte
272 lines
7.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { convertFileSrc } from '@tauri-apps/api/core';
|
|
import { settings, loadSettings } from '$lib/stores/settings';
|
|
import { scanArtistsWithAlbums, scanAlbums } from '$lib/library/scanner';
|
|
import type { ArtistWithAlbums, Album } from '$lib/types/track';
|
|
|
|
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 loadLibrary();
|
|
});
|
|
|
|
async function loadLibrary() {
|
|
loading = true;
|
|
error = null;
|
|
|
|
if (!$settings.musicFolder) {
|
|
error = 'No music folder configured. Please set one in Settings.';
|
|
loading = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const [artistsData, albumsData] = await Promise.all([
|
|
scanArtistsWithAlbums($settings.musicFolder),
|
|
scanAlbums($settings.musicFolder)
|
|
]);
|
|
|
|
artists = artistsData;
|
|
albums = albumsData;
|
|
loading = false;
|
|
} catch (e) {
|
|
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(album: Album, index: number) {
|
|
selectedAlbumIndex = index;
|
|
const artistEncoded = encodeURIComponent(album.artist);
|
|
const albumEncoded = encodeURIComponent(album.title);
|
|
goto(`/albums/${artistEncoded}/${albumEncoded}`);
|
|
}
|
|
|
|
function getTotalTracks(artist: ArtistWithAlbums): number {
|
|
return artist.albums.reduce((sum, album) => sum + album.trackCount, 0);
|
|
}
|
|
</script>
|
|
|
|
<div class="library-wrapper">
|
|
<h2 style="padding: 8px 8px 0 8px;">Library</h2>
|
|
|
|
{#if loading}
|
|
<p style="padding: 8px;">Loading library...</p>
|
|
{:else if error}
|
|
<p class="error" style="padding: 8px;">{error}</p>
|
|
{:else if artists.length === 0 && albums.length === 0}
|
|
<p style="padding: 8px;">No music found in your music folder.</p>
|
|
{:else}
|
|
<section class="library-content">
|
|
<!-- Tabs -->
|
|
<!--
|
|
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
|
|
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
|
|
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
|
|
The <menu> element IS interactive (contains clickable <button> elements) and the
|
|
role="tablist" properly describes the semantic purpose to assistive technology.
|
|
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
|
|
-->
|
|
<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(album, 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>
|
|
.library-wrapper {
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
h2 {
|
|
margin: 0 0 8px 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.library-content {
|
|
margin: 0;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.error {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.tab-content {
|
|
margin-top: -2px;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.window-body {
|
|
padding: 0;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.table-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
min-height: 0;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
}
|
|
|
|
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>
|