Files
SoulStream/src/mobile/screens/Library.svelte
T
KeelrLeek cdb861fcbd android: no bar arrives over the page — the tab pages lose their top banner
The glass bar was anonymous until you scrolled, then faded in carrying the
title the page already had in 27px type two centimetres below it. On Home that
meant "Good morning" over "Good morning", half-transparent, while the content
it covered kept moving underneath. Kyle's word: remove them.

So a tab page has no bar at all. `barOn`/`titled` and the scroll trigger they
read are gone; `showBar` is `!root`, and the element is not rendered on Home,
Search, Library, Community or You. Nothing fades in over what you are reading.

The bar was not only decoration, which is why this is more than a deletion: its
⋯ is the page overflow, and on a tab page that overflow is the app's own menu —
the queue, the sleep timer, Settings. That moves to where the page's title
already is, in `.phead`, beside Home's identicon and at the end of Library's
existing row of actions. Search and You grow the row they did not have.
Community already carried its own ⋯ and keeps it.

A pushed page still has a bar, because it carries the back chevron and a title
that is not repeated anywhere else on the screen. It is static now: rendered
only when there is somewhere to go back to, on from the first frame and titled
from the first frame. The opacity-0 default, the 180ms cross-fade, and the
`.gb-t` rise the title used to arrive on all come out of the stylesheet —
nothing is left that can catch the eye mid-scroll.

The walk reached that overflow through `.gbar .iconbtn` on a tab page, which is
now a selector that matches nothing there: `home-more` on both step lists and
the two sleep-timer steps go to `.phead .iconbtn.muted` instead. The other
`.gbar` steps are untouched on purpose — the playlist export sheet is a PUSHED
page and still has its bar, and `library-ambience` is the album page's back
chevron, not an overflow.

Checked on the device rather than argued: on Home `.gbar` is absent, the ⋯ is
present, and tapping it opens the half sheet on "SoulStream · Queue · Sleep
timer off · Settings"; on a pushed record the bar is there with its two
buttons, its title, and a computed opacity of exactly 1.

One thing this leaves: the bar was also what backed the OS status bar once a
page scrolled under it. The clock and the battery now sit on the content. That
is fine on the ground and on dark artwork, and it will not be over a white
sleeve — a short scrim with no title and no buttons would fix it without
bringing a banner back, and is deliberately not in this commit.
2026-09-14 11:09:34 -05:00

202 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/* Your library — five second-tier tabs; Albums, Artists and Playlists
are real. The sort and the view live on the tab's root entry, so they
survive a push and a pop. */
import Icon from "../../components/Icon.svelte";
import Release from "../Release.svelte";
import SetupNote from "../SetupNote.svelte";
import Cover from "../Cover.svelte";
import Tracks from "./Tracks.svelte";
import PlaylistShelf from "../PlaylistShelf.svelte";
import * as api from "../../lib/api.js";
import { lib, artists, isFollowed, followedEntry, statLabel } from "../lib.svelte.js";
import { fmtBytes } from "../util.js";
import { push, go, patch, openHalf } from "../nav.svelte.js";
import { looseTitle, yearOf } from "../catalog.svelte.js";
import { isUpcoming } from "../disco.js";
let { entry, screenEl = null } = $props();
const TABS = [["albums", "Albums"], ["artists", "Artists"], ["tracks", "Tracks"], ["playlists", "Playlists"], ["gaps", "Gaps"]];
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"], ["format", "By format"]];
const tab = $derived(entry.c.tab || "albums");
const sort = $derived(entry.c.sort || "added");
const view = $derived(entry.c.view || "grid");
const list = $derived.by(() => {
const l = lib.albums.slice();
l.sort((x, y) => {
if (sort === "az") return x.title.localeCompare(y.title);
if (sort === "artist") return x.artist.localeCompare(y.artist) || (x.year || 0) - (y.year || 0);
if (sort === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at;
if (sort === "format") {
/* lossless first, then the higher bitrate, then the name */
const fx = fmtRank(lib.stats[x.id]);
const fy = fmtRank(lib.stats[y.id]);
return fy - fx || x.title.localeCompare(y.title);
}
return y.added_at - x.added_at;
});
return l;
});
const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0));
const totalSecs = $derived(Object.values(lib.stats).reduce((n, st) => n + (st.secs || 0), 0));
const hours = $derived(totalSecs >= 3600 ? Math.round(totalSecs / 3600) + " hours" : totalSecs ? Math.round(totalSecs / 60) + " min" : "");
/* By format: a number the sort can compare — lossless by depth and rate, lossy by bitrate */
function fmtRank(st) {
if (!st || !st.formats?.length) return 0;
if (st.bit_depth && st.sample_rate) return 1_000_000 + st.bit_depth * 1000 + Math.round(st.sample_rate / 1000);
return st.avg_kbps || 1;
}
const arts = $derived(artists());
/* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf
with an ANCHORED identity — a follow — is asked, so a name can never
be guessed onto a namesake; the catalog's released records minus the
ones on the shelf, by loose title; the totals remembered so the line
can say its number before the lookup. */
const GAPS_KEY = "library.gaps.v1";
const foldKey = (x) => String(x ?? "").toLowerCase();
let gapTotals = $state((() => { try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; } })());
let gapRows = $state({}); // foldKey(artist) → catalog rows not owned
let gapOpen = $state({});
let gapLoading = $state({});
/* the lookup's catch was empty, so a failed reach put the line back to
"See what's missing" and it could be tapped for ever with no reason */
let gapErr = $state({});
const shelves = $derived(arts.filter((ar) => followedEntry(ar.name)));
const ownedTitles = (name) => new Set(lib.albums.filter((a) => foldKey(a.artist) === foldKey(name)).map((a) => looseTitle(a.title)));
async function toggleGaps(name) {
const key = foldKey(name);
if (gapOpen[key]) { gapOpen = { ...gapOpen, [key]: false }; return; }
const follow = followedEntry(name);
if (!follow) return;
if (!gapRows[key] && !gapLoading[key]) {
gapLoading = { ...gapLoading, [key]: true };
gapErr = { ...gapErr, [key]: "" };
try {
const page = await api.catalogArtist(follow.artist_id);
const released = (page?.albums ?? []).filter((a) => !isUpcoming(a) && !a.appears_on);
const owned = ownedTitles(name);
gapRows = { ...gapRows, [key]: released.filter((r) => !owned.has(looseTitle(r.title))) };
gapTotals = { ...gapTotals, [key]: { total: released.length } };
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
} catch (e) { gapErr = { ...gapErr, [key]: String(e) }; }
gapLoading = { ...gapLoading, [key]: false };
}
if (gapRows[key]) gapOpen = { ...gapOpen, [key]: true };
}
const gapCount = (ar) => {
const key = foldKey(ar.name);
const missing = gapRows[key];
const total = gapTotals[key]?.total;
return missing ? missing.length : (total ? Math.max(0, total - ar.albums) : null);
};
</script>
<div class="phead">
<div class="row">
<h1>Your library</h1>
<div class="acts">
<button class="iconbtn" onclick={() => go("search")} aria-label="Search"><Icon name="search" size={21} /></button>
<button class="iconbtn" onclick={() => patch({ tab: "playlists" })} aria-label="Playlists"><Icon name="playlist" size={21} /></button>
<button class="iconbtn" onclick={() => openHalf("librarysort")} aria-label="Sort and filter"><Icon name="layers" size={21} /></button>
<button class="iconbtn muted" onclick={() => openHalf("more")} aria-label="More"><Icon name="more" size={21} /></button>
</div>
</div>
</div>
<div class="segtabs" role="tablist" aria-label="Library sections">
{#each TABS as [k, l] (k)}
<button class:on={tab === k} onclick={() => patch({ tab: k })} role="tab" aria-selected={tab === k}>{l}</button>
{/each}
</div>
{#if tab === "albums"}
<div class="chips" style="padding-bottom:12px">
{#each SORTS as [k, l] (k)}
<button class="chip" class:on={sort === k} class:quiet={sort !== k} aria-pressed={sort === k} onclick={() => patch({ sort: k })}>{l}</button>
{/each}
</div>
<div class="countline">
<span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks{hours ? " · " + hours : ""}</span>
<button class="iconbtn sm muted" onclick={() => patch({ view: view === "grid" ? "list" : "grid" })} aria-label="Grid or list">
<Icon name={view === "grid" ? "playlist" : "layers"} size={18} />
</button>
</div>
{#if !list.length}
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
<SetupNote />
{:else if view === "grid"}
<div class="grid2">
{#each list as a (a.id)}
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : sort === "format" ? (statLabel(lib.stats[a.id]) || a.artist) : a.artist} path={a.art_path} badge={statLabel(lib.stats[a.id]) || null} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
{/each}
</div>
{:else}
{#each list as a (a.id)}
<button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}>
<span class="art"><Cover path={a.art_path} size={20} /></span>
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}{statLabel(lib.stats[a.id]) ? " · " + statLabel(lib.stats[a.id]) : ""}{lib.stats[a.id]?.bytes ? " · " + fmtBytes(lib.stats[a.id].bytes) : ""}</span></span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{/if}
{:else if tab === "artists"}
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
{#if !arts.length}
<div class="stub"><b>No artists yet</b>Every artist with a record in the library on this phone is listed here.</div>
<SetupNote />
{/if}
{#each arts as ar (ar.name)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks{isFollowed(ar.name) ? " · following" : ""}</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{/each}
{:else if tab === "tracks"}
<Tracks {entry} />
{:else if tab === "playlists"}
<PlaylistShelf {screenEl} />
{:else if tab === "gaps"}
{#if !shelves.length}
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
{:else}
<div class="sec-note">{shelves.length} followed {shelves.length === 1 ? "artist" : "artists"} on this phone</div>
{#each shelves as ar (ar.name)}
{@const key = foldKey(ar.name)}
{@const count = gapCount(ar)}
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
<span class="b">
<span class="t">{ar.name}</span>
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} here · following</span>
</span>
<span class="r"><Icon name="chevron-right" size={17} /></span>
</button>
{#if count !== 0}
<button class="gapline" onclick={() => toggleGaps(ar.name)} aria-expanded={!!gapOpen[key]}>
{#if gapLoading[key]}Checking the catalogue…{:else if gapErr[key]}Couldn't reach the catalogue — tap to try again{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
<span class="cta">{gapOpen[key] ? "hide" : "show"}<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} /></span>
</button>
{:else}
<div class="gapline done"><b>Complete</b> — every released record is here</div>
{/if}
{#if gapOpen[key] && gapRows[key]?.length}
<div class="rail gaps">
{#each gapRows[key] as r (r.id)}
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} onmore={() => openHalf("catalogAlbum", { album: { ...r, artist: r.artist || ar.name } })} />
{/each}
</div>
{/if}
{/each}
{/if}
{/if}
<div class="endpad"></div>