mirror of
https://github.com/markuryy/shark.git
synced 2025-12-13 12:01:01 +00:00
feat(layout): playlist cover art, album UI
This commit is contained in:
263
src/routes/albums/[artist]/[album]/+page.svelte
Normal file
263
src/routes/albums/[artist]/[album]/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { settings, loadSettings } from '$lib/stores/settings';
|
||||
import { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
|
||||
import type { Track } from '$lib/types/track';
|
||||
|
||||
let tracks = $state<Track[]>([]);
|
||||
let coverArtPath = $state<string | undefined>(undefined);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedTrackIndex = $state<number | null>(null);
|
||||
|
||||
let artistName = $derived(decodeURIComponent($page.params.artist ?? ''));
|
||||
let albumName = $derived(decodeURIComponent($page.params.album ?? ''));
|
||||
|
||||
onMount(async () => {
|
||||
await loadSettings();
|
||||
await loadAlbum();
|
||||
});
|
||||
|
||||
async function loadAlbum() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
if (!$settings.musicFolder) {
|
||||
error = 'Music folder not configured. Please set it in Settings.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const albumPath = `${$settings.musicFolder}/${artistName}/${albumName}`;
|
||||
|
||||
const [tracksData, coverPath] = await Promise.all([
|
||||
loadAlbumTracks(albumPath),
|
||||
findAlbumArt(albumPath)
|
||||
]);
|
||||
|
||||
tracks = tracksData;
|
||||
coverArtPath = coverPath;
|
||||
|
||||
if (tracks.length === 0) {
|
||||
error = 'No tracks found in this album.';
|
||||
}
|
||||
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
error = 'Error loading album: ' + (e as Error).message;
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getThumbnailUrl(coverPath?: string): string {
|
||||
if (!coverPath) {
|
||||
return '';
|
||||
}
|
||||
return convertFileSrc(coverPath);
|
||||
}
|
||||
|
||||
function handleTrackClick(index: number) {
|
||||
selectedTrackIndex = index;
|
||||
}
|
||||
|
||||
function getAlbumYear(): string {
|
||||
const year = tracks.find(t => t.metadata.year)?.metadata.year;
|
||||
return year ? year.toString() : '—';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="album-wrapper">
|
||||
{#if loading}
|
||||
<p style="padding: 8px;">Loading album...</p>
|
||||
{:else if error}
|
||||
<p class="error" style="padding: 8px;">{error}</p>
|
||||
{:else}
|
||||
<!-- Album Header -->
|
||||
<div class="album-header">
|
||||
{#if coverArtPath}
|
||||
<img
|
||||
src={getThumbnailUrl(coverArtPath)}
|
||||
alt="{albumName} cover"
|
||||
class="album-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="album-cover-placeholder"></div>
|
||||
{/if}
|
||||
<div class="album-info">
|
||||
<h2>{albumName}</h2>
|
||||
<p class="album-artist">{artistName}</p>
|
||||
<p class="album-meta">
|
||||
{getAlbumYear()} • {tracks.length} track{tracks.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="album-content">
|
||||
<!-- Tabs (single tab for tracks) -->
|
||||
<menu role="tablist">
|
||||
<li role="tab" aria-selected={true}>
|
||||
<button>Tracks</button>
|
||||
</li>
|
||||
</menu>
|
||||
|
||||
<!-- Track Listing -->
|
||||
<div class="window tab-content" role="tabpanel">
|
||||
<div class="window-body">
|
||||
<div class="sunken-panel table-container">
|
||||
<table class="interactive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">#</th>
|
||||
<th>Title</th>
|
||||
<th>Duration</th>
|
||||
<th>Format</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tracks as track, i}
|
||||
<tr
|
||||
class:highlighted={selectedTrackIndex === i}
|
||||
onclick={() => handleTrackClick(i)}
|
||||
>
|
||||
<td class="track-number">
|
||||
{track.metadata.trackNumber ?? i + 1}
|
||||
</td>
|
||||
<td>{track.metadata.title || track.filename}</td>
|
||||
<td class="duration">
|
||||
{#if track.metadata.duration}
|
||||
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</td>
|
||||
<td class="format">{track.format.toUpperCase()}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.album-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.album-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 8px 8px 0 8px;
|
||||
margin-bottom: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: cover;
|
||||
image-rendering: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-cover-placeholder {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
|
||||
background-size: 8px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.album-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.album-artist {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
margin: 0;
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.album-content {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.track-number {
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.duration {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.format {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user