albums: one page for every record, and streamed history rows that actually open
Three things were wrong with opening an album, and only one of them was the one that got reported. The reported half: clicking a local record in the sidebar's history went to the old in-library album view. Local albums carry no catalog identity of any kind -- `albums.mbid` is null and is not even in the DTO -- so there was nothing to route with. openAlbumByName already existed for exactly this (the now-playing links use it, and it requires BOTH artist and title to match normalized, which is the namesake guard); it now reports whether it resolved and caches settled answers, so a second click is instant. The unreported half, and the reason "half of them are not working": every STREAMED row was dead. catalog_album_id is TEXT in SQLite, so it arrives as a string, and `catalog_album` takes a u64 -- the invoke was rejected outright with "invalid type: string ... expected u64". Terrestrials, OK Human and 9 all failed silently on click. The id is coerced in checkedAlbumPayload now, where both other string sources (page seeds) pass through too. And the part that made the first half unfixable: a record no catalog carries had nowhere to go. Panda Bear's "A ? of WHEN" is not on Deezer, so no amount of resolving would ever open it. Local albums now render on the SAME page, composed from the library's own data -- its tracks, durations, bitrates and the cover already on disk -- under a namespaced `lib:` id that can never collide with a Deezer one. Playback stays local: Play and any row queue the files you already have rather than sending a record you own back through the resolver. Save is not offered (it is already saved), previews are absent (there is nothing to preview), and the attribution footer credits your library rather than claiming Deezer + MusicBrainz. With that, the old view has no callers and is deleted: the album-page block, its CSS, and the members that only it used. Nothing anywhere routes to it. Verified live: all six history rows open the new page, a Home card opens it, Play queues 14 local FLACs without streaming, and the back arrow returns.
This commit is contained in:
1 parent
3c5bccf1b6
commit
dde8c7510b
9 files changed
+252
-365
No files matched your search
+1
-337
@@ -3,19 +3,14 @@
|
||||
import { slide } from "svelte/transition";
|
||||
import {
|
||||
libraryStore,
|
||||
ui,
|
||||
getArt,
|
||||
evictArt,
|
||||
playAlbum,
|
||||
player,
|
||||
playlistStore,
|
||||
refreshPlaylists,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import AlbumCard from "../components/AlbumCard.svelte";
|
||||
import Icon from "../components/Icon.svelte";
|
||||
|
||||
let showPlaylistMenu = $state(false);
|
||||
let expandedArtist = $state(null);
|
||||
let inlineAlbumId = $state(null);
|
||||
let inlineTracks = $state([]);
|
||||
@@ -238,66 +233,13 @@
|
||||
revealRaf = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
async function addAlbumToPlaylist(playlistId) {
|
||||
showPlaylistMenu = false;
|
||||
const payload = tracks.map((t) => ({
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
duration_secs: t.duration_secs ?? null,
|
||||
path: t.path,
|
||||
art_path: album?.art_path ?? null,
|
||||
}));
|
||||
await api.addToPlaylist(playlistId, payload);
|
||||
await refreshPlaylists();
|
||||
}
|
||||
|
||||
async function newPlaylistWithAlbum() {
|
||||
const id = await api.createPlaylist(album.title);
|
||||
await addAlbumToPlaylist(id);
|
||||
}
|
||||
|
||||
let tracks = $state([]);
|
||||
let art = $state(null);
|
||||
let refreshingArt = $state(false);
|
||||
let artError = $state("");
|
||||
|
||||
async function refreshArt() {
|
||||
if (!album || refreshingArt) return;
|
||||
refreshingArt = true;
|
||||
artError = "";
|
||||
try {
|
||||
const path = await api.refreshAlbumArt(album.id);
|
||||
evictArt(path);
|
||||
forgetArtistArt(album.artist);
|
||||
art = await getArt(path);
|
||||
} catch (e) {
|
||||
artError = String(e);
|
||||
} finally {
|
||||
refreshingArt = false;
|
||||
}
|
||||
}
|
||||
|
||||
const album = $derived(libraryStore.albums.find((a) => a.id === ui.albumId) ?? null);
|
||||
|
||||
$effect(() => {
|
||||
if (!album) return;
|
||||
tracks = [];
|
||||
art = null;
|
||||
api.albumTracks(album.id).then((t) => (tracks = t));
|
||||
getArt(album.art_path).then((url) => (art = url));
|
||||
});
|
||||
|
||||
// Still used by the inline album expander in the artist rows below.
|
||||
function fmtDuration(s) {
|
||||
if (s == null) return "";
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const nowPlayingPath = $derived(
|
||||
player.queueIndex >= 0 ? player.queue[player.queueIndex]?.path : null,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !album}
|
||||
<div class="library">
|
||||
<h1>Artists</h1>
|
||||
{#if libraryStore.albums.length === 0}
|
||||
@@ -369,87 +311,6 @@
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="album-page">
|
||||
<button class="back" onclick={() => (ui.albumId = null)}>
|
||||
<Icon name="chevron-left" size={16} /> Library
|
||||
</button>
|
||||
|
||||
<header>
|
||||
{#if art}
|
||||
<img class="cover" src={art} alt="" draggable="false" />
|
||||
{:else}
|
||||
<div class="cover placeholder"><Icon name="disc" size={56} /></div>
|
||||
{/if}
|
||||
<div class="head-meta">
|
||||
<p class="kind">Album</p>
|
||||
<h1>{album.title}</h1>
|
||||
<p class="line">
|
||||
<strong>{album.artist}</strong>
|
||||
{album.year ? ` · ${album.year}` : ""} · {album.track_count} tracks
|
||||
</p>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="play-all"
|
||||
onclick={() =>
|
||||
playAlbum(tracks, `${album.artist} — ${album.title}`, 0, album.art_path)}
|
||||
disabled={tracks.length === 0}
|
||||
>▶ Play</button>
|
||||
<div class="add-menu">
|
||||
<button
|
||||
class="refresh-art"
|
||||
title="Add album to a playlist"
|
||||
onclick={() => (showPlaylistMenu = !showPlaylistMenu)}
|
||||
disabled={tracks.length === 0}
|
||||
><Icon name="list-plus" size={15} /> Playlist</button>
|
||||
{#if showPlaylistMenu}
|
||||
<div class="menu">
|
||||
<button class="menu-item new" onclick={newPlaylistWithAlbum}>
|
||||
+ New playlist
|
||||
</button>
|
||||
{#each playlistStore.playlists as pl (pl.id)}
|
||||
<button
|
||||
class="menu-item"
|
||||
onclick={() => addAlbumToPlaylist(pl.id)}
|
||||
>{pl.name}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="refresh-art"
|
||||
title="Wrong cover? Refetch the album art"
|
||||
onclick={refreshArt}
|
||||
disabled={refreshingArt}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
{refreshingArt ? "fetching…" : "art"}
|
||||
</button>
|
||||
</div>
|
||||
{#if artError}
|
||||
<p class="art-error">{artError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="tracklist">
|
||||
{#each tracks as t, i (t.id)}
|
||||
<button
|
||||
class="trow"
|
||||
class:playing={nowPlayingPath === t.path}
|
||||
onclick={() =>
|
||||
playAlbum(tracks, `${album.artist} — ${album.title}`, i, album.art_path)}
|
||||
>
|
||||
<span class="tno">{t.track_no ?? "·"}</span>
|
||||
<span class="ttitle">{t.title}</span>
|
||||
<span class="tmeta">
|
||||
{t.bitrate_kbps ? `${t.bitrate_kbps} kbps · ` : ""}{fmtDuration(t.duration_secs)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
@@ -600,201 +461,4 @@
|
||||
.inline-ttitle { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.inline-tmeta { color: var(--text-muted); font-size: 12px; }
|
||||
|
||||
/* album page */
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #2a2a2a, #3a3a3a);
|
||||
color: var(--text-muted);
|
||||
font-size: 56px;
|
||||
}
|
||||
|
||||
.kind {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.head-meta h1 {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.line {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.play-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 24px;
|
||||
background: var(--accent);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.refresh-art {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--bg-highlight);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.refresh-art:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.refresh-art:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.add-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 200px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-press);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 12px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.menu-item:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.menu-item.new {
|
||||
color: var(--accent-hover);
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-radius: 5px 5px 0 0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.art-error {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #f15e6c;
|
||||
}
|
||||
|
||||
.play-all:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.play-all:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tracklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 6px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.trow:hover {
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
|
||||
.trow.playing .ttitle {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.tno {
|
||||
width: 22px;
|
||||
text-align: right;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ttitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tmeta {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user