diff --git a/src/App.svelte b/src/App.svelte index 9df72e4..eac10f5 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -63,6 +63,7 @@ class="app" class:np-open={ui.nowPlaying} class:sidebar-collapsed={ui.sidebarCollapsed} + class:np-dragging={ui.nowPlayingDragging} class:preview-open={previewUi.open} style="--np-open-width: {ui.nowPlayingWidth}px" > @@ -126,6 +127,17 @@ --nowplaying-width: var(--np-open-width, 340px); } + /* A drag has to track the pointer, and an eased track list cannot: the grip + moves the target every frame, each move restarts the 0.28s ease from + wherever it had crawled to, and the column ends up going almost nowhere — + measured 203px behind the pointer on a 220px drag, then racing to the + target after release. The panel already leaves the ease for a drag + (.nowplaying.dragging); if the column does not leave with it the panel + grows past its own track and the window clips it. */ + .app.np-dragging { + transition: none; + } + /* Collapsed left nav: narrow the sidebar column to an icon rail. The grid-template-columns transition animates the reflow. */ .app.sidebar-collapsed { diff --git a/src/components/NowPlaying.svelte b/src/components/NowPlaying.svelte index 852dee7..b79ac25 100644 --- a/src/components/NowPlaying.svelte +++ b/src/components/NowPlaying.svelte @@ -10,7 +10,7 @@ discovery, loadDiscovery, openCatalogArtist, - streamCatalogAlbum, + openCatalogAlbum, openCurrentArtist, openCurrentAlbum, openCurrentSong, @@ -18,6 +18,8 @@ artBroken, markArtBroken, playerArtSource, + coverUpgrades, + resolveCovers, setNowPlayingWidth, NP_WIDTH_DEFAULT, NP_WIDTH_MIN, @@ -27,23 +29,48 @@ import SaveMenu from "./SaveMenu.svelte"; import * as api from "../lib/api.js"; import { floatScroll } from "../lib/scrollbar.js"; + import { accentFromImage } from "../lib/color.js"; const currentItem = $derived(player.queue[player.queueIndex] ?? null); /** Hand the playing cover to the shared artwork lightbox. It takes an * explicit descriptor now rather than reaching into the queue itself, so * the same viewer serves an album's artwork anywhere else in the app. */ - function openArtwork() { + async function openArtwork() { const item = currentItem; if (!item) return; - const src = item.art ? item.art : item.artUrl ? playerArtSource(item) : null; - if (!src) return; - ui.artwork = { + const shot = (src, local) => ({ src, - local: Boolean(item.art), + local, title: item.title ?? "", subtitle: item.sub ?? "", - }; + }); + if (item.art) { + ui.artwork = shot(item.art, true); + return; + } + const url = item.artUrl; + if (!url) return; + + // The MASTER, the same thing the artist and album pages open — not the + // provider URL rewritten to 1000x1000, which is all playerArtSource + // can do on its own. This panel never ran the cover-upgrade pass those + // pages run over their grids, so the playing record's master had simply + // never been looked up and the viewer topped out at 1000px here while + // the very same record opened at full resolution from a page. + const known = coverUpgrades[url]; + const opened = known ?? playerArtSource(item); + ui.artwork = shot(opened, false); + if (known) return; + + // Resolve, then swap in place. Guarded on the viewer still showing + // exactly what we opened, so closing it — or opening a different + // record's art meanwhile — is never overwritten from under the user. + await resolveCovers([{ cover: url, title: albumTitle }], artistName); + const upgraded = coverUpgrades[url]; + if (upgraded && upgraded !== opened && ui.artwork?.src === opened) { + ui.artwork = shot(upgraded, false); + } } // Gate stream-only sections (quality, source, Save) on the displayed // item's own stream field — the source of truth for what's on screen. @@ -130,7 +157,10 @@ .slice(0, 6); }); - const relatedArtists = $derived(discovery.related.slice(0, 4)); + // Eight, not four: as name-sized pills that wrap they cost a fraction of + // the height the fixed row of circles did, so the section can carry twice + // the artists in less space. + const relatedArtists = $derived(discovery.related.slice(0, 8)); // How many of this artist's albums are already in the local library. const inLibraryCount = $derived.by(() => { @@ -142,6 +172,86 @@ function albumYear(a) { return a.release_date ? a.release_date.slice(0, 4) : ""; } + + /* ── The full discography, filed ────────────────────────────────────── + "Show all releases" used to be one flat date-sorted grid, so a + sixty-eight release artist arrived as an undifferentiated wall. These + are the artist page's own ten categories in its own order, empty ones + dropped, exactly as its TAB_DEFS does. + + ⚠ The page classifies with evidence this panel does not have: the album + description hints and the MusicBrainz type map, both built by the artist + page's background harvest. Here it is the flags the release itself + carries. A record whose kind is only knowable from its description (a + compilation innocently titled "Death to False Metal") will file as an + album here and as a compilation there until those hints are routed + through — a known, deliberate gap, not a bug to rediscover. */ + const RELEASE_CATEGORIES = [ + ["upcoming", "Upcoming"], + ["album", "Albums"], + ["mixtape", "Mixtapes"], + ["ep", "EPs"], + ["single", "Singles"], + ["live", "Live"], + ["compilation", "Compilations"], + ["soundtrack", "Soundtracks"], + ["remix", "Remixes"], + ["other", "Other"], + ]; + + const isFutureRelease = (date) => { + if (!date) return true; // undated announcements are not yet released + const at = Date.parse(date); + return Number.isNaN(at) || at > Date.now(); + }; + + function releaseCategory(album, byId, depth = 0) { + // An edition files WITH its base release, one hop, like the page. + if (depth < 2 && album.edition_group != null && album.id !== album.edition_group) { + const base = byId.get(album.edition_group); + if (base) return releaseCategory(base, byId, depth + 1); + } + const type = (album.record_type ?? "").toLowerCase(); + if (type === "album" && isFutureRelease(album.release_date)) return "upcoming"; + if (album.is_mixtape) return "mixtape"; + if (album.is_soundtrack) return "soundtrack"; + if (album.is_live) return "live"; + const title = (album.title ?? "").toLowerCase(); + if (/\bremix(es)?\b/.test(title)) return "remix"; + if (/\bgreatest hits\b|\bbest of\b|\banthology\b/.test(title)) return "compilation"; + if (/\bsessions?\b|\bdemos?\b|\bpractice\b|\brehearsal\b/.test(title)) return "other"; + return ["album", "ep", "single"].includes(type) ? type : "other"; + } + + /** Sections of dense rows, each row flagged with the year label it should + * carry. The label is printed only where the year CHANGES going down, so a + * run of singles from one year reads as one dated block instead of + * repeating itself on every line. */ + const releaseSections = $derived.by(() => { + const all = discovery.albums ?? []; + if (!all.length) return []; + const byId = new Map(all.map((a) => [a.id, a])); + // Editions fold into their base, so a record's pressings are one row. + const folded = all.filter((a) => a.edition_group == null || a.id === a.edition_group); + const buckets = new Map(RELEASE_CATEGORIES.map(([key]) => [key, []])); + for (const album of folded) { + buckets.get(releaseCategory(album, byId))?.push(album); + } + return RELEASE_CATEGORIES.flatMap(([key, label]) => { + const items = (buckets.get(key) ?? []).sort((a, b) => + (b.release_date ?? "").localeCompare(a.release_date ?? ""), + ); + if (!items.length) return []; + let previous = null; + const rows = items.map((album) => { + const year = key === "upcoming" ? "Upcoming" : albumYear(album) || "—"; + const heading = year === previous ? null : year; + previous = year; + return { album, heading }; + }); + return [{ key, label, count: items.length, rows }]; + }); + }); function fmtFans(n) { if (!n) return "—"; if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace(/\.0$/, "")}M`; @@ -176,13 +286,23 @@ player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0, ); - // Average dense source peaks into fixed, equally sized display bars. Using - // an average (rather than picking one peak) stops isolated transients from + // Average dense source peaks into equally sized display bars. Using an + // average (rather than picking one peak) stops isolated transients from // making random bars hit the ceiling. - // 78 × 2px bars plus 77 × 2px gaps = 310px. The sidebar's 312px - // waveform lane can center that exact integer footprint, avoiding the - // fractional gap rounding that made some neighboring bars look closer. - const BARS = 78; + // + // The COUNT is derived from the panel's width so the lane reads at one + // density everywhere. It used to be a flat 78 bars on a 2px pitch — a + // 310px block that overflowed the 272px panel and left a third of the + // 560px one empty, which is precisely what looked wrong at both ends. + // One bar per BAR_PITCH px instead: a 2px bar on a 4px gap. + // Pitch 6 chosen off research/nowplaying-fans-waveform/mockup.html (W1). + const BAR_PITCH = 6; + const BAR_W = 2; + // .np-scroll's horizontal padding, both sides — the lane is what is left. + const LANE_PAD = 28; + const BARS = $derived( + Math.max(24, Math.floor((ui.nowPlayingWidth - LANE_PAD + BAR_W) / BAR_PITCH)), + ); const bars = $derived.by(() => { const w = player.waveform; if (!w || w.length === 0) return []; @@ -198,6 +318,19 @@ }); const playedBars = $derived((playedPct / 100) * bars.length); + /** The lane is 38px of the panel's vertical rhythm whether or not peaks + * have arrived yet. `player.waveform` is emptied the instant a track + * changes, and a partial-FLAC stream decode can take seconds to refill it, + * so unmounting the lane collapsed the times, the chips and the whole + * segmented control below it and then shoved them back down — a jolt on + * every track change. It holds its space as a flat baseline instead. + * + * Same bar COUNT either way, so peaks arriving restyle the existing + * elements rather than replacing them. */ + const FLAT_BARS = $derived(Array(BARS).fill(0)); + const wavePending = $derived(bars.length === 0); + const waveBars = $derived(wavePending ? FLAT_BARS : bars); + function barHeight(value, index) { const capped = Math.pow(Math.min(0.88, value * 1.18), 0.8); // Stable per-bar grain keeps a dense loud passage from flattening @@ -265,6 +398,21 @@ } return peerName ? `Streaming from ${peerName}` : null; }); + /** What the status line is ABOUT, as opposed to what it currently reads. + * The header's entry animation is keyed on this rather than on the text: + * while transferring, the text carries a percentage, a rate and an ETA, so + * keying on it replayed the 300ms slide on every progress event — several + * times a second — and the header flashed. Peer and transfer state are what + * an arrival should announce, and they change a handful of times a track. */ + const statusPhase = $derived( + !isStream + ? "none" + : cached + ? "cached" + : transferring + ? `transfer:${peerName ?? ""}` + : `open:${peerName ?? ""}`, + ); /** The hairline has no percentage to show until bytes start arriving. */ const hairlineSeeking = $derived(isStream && player.streamTotal === 0); @@ -273,7 +421,9 @@ // delivering moves after it leaves the 9px grip, which a mousemove // listener on the element alone would not. let gripEl = $state(null); - let dragging = $state(false); + // On `ui` rather than local, because App.svelte's grid column has to leave + // the ease at the same instant this panel does. + const dragging = $derived(ui.nowPlayingDragging); let dragRaf = 0; let dragStartX = 0; let dragStartW = 0; @@ -282,7 +432,7 @@ const atDefault = $derived(ui.nowPlayingWidth === NP_WIDTH_DEFAULT); function onGripDown(e) { - dragging = true; + ui.nowPlayingDragging = true; dragStartX = e.clientX; dragStartW = ui.nowPlayingWidth; gripEl?.setPointerCapture?.(e.pointerId); @@ -303,15 +453,25 @@ setNowPlayingWidth(pendingWidth); }); } + /** Ends the drag. Bound to lostpointercapture as well as pointerup and + * pointercancel: a release the grip never sees — let go outside the window, + * the panel re-rendering mid-drag, the webview cancelling the gesture — + * would otherwise leave the app root pinned at `transition: none` and the + * collapse animation dead for the rest of the session. However capture + * ends, lostpointercapture fires, so the flag cannot outlive the gesture. */ function onGripUp(e) { if (!dragging) return; - dragging = false; + ui.nowPlayingDragging = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = 0; setNowPlayingWidth(pendingWidth); } - gripEl?.releasePointerCapture?.(e.pointerId); + // Guarded: releasing a pointer this element does not hold throws, and + // lostpointercapture arrives when it has already been given up. + if (e?.pointerId != null && gripEl?.hasPointerCapture?.(e.pointerId)) { + gripEl.releasePointerCapture(e.pointerId); + } } function resetWidth() { setNowPlayingWidth(NP_WIDTH_DEFAULT); @@ -468,6 +628,40 @@ }); const releaseList = $derived(showAllReleases ? discovery.albums : moreAlbums); + /* Hover highlight in the RECORD's own colour rather than the playing + * track's. Sampling is lazy — only the card actually under the pointer — + * and memoised per cover URL, so a grid of sixty-eight releases costs + * nothing until you point at one. Until a colour lands the card falls back + * to the global accent, so there is no flash of "no highlight". */ + const albumAccents = new Map(); // cover URL -> hex, or null once sampled dry + const accentPending = new Set(); + + async function tintFromCover(event, album) { + const cover = album?.cover; + if (!cover) return; + const el = event.currentTarget; + if (albumAccents.has(cover)) { + const known = albumAccents.get(cover); + if (known) el.style.setProperty("--album-accent", known); + return; + } + if (accentPending.has(cover)) return; + accentPending.add(cover); + try { + const dataUrl = await getCatalogArt(cover); + const colors = dataUrl ? await accentFromImage(dataUrl) : null; + // The brighter of the pair: this is a 2px line over artwork, and + // the resting accent disappears against a dark cover. + const hex = colors?.hover ?? colors?.accent ?? null; + albumAccents.set(cover, hex); + if (hex) el.style.setProperty("--album-accent", hex); + } catch { + albumAccents.set(cover, null); + } finally { + accentPending.delete(cover); + } + } + const upNext = $derived(player.queue[player.queueIndex + 1] ?? null); const fmtListeners = (n) => (n == null ? null : n.toLocaleString()); @@ -478,7 +672,10 @@ // that one (75ms throttle while dragging, exact seek on release) so the two // cannot disagree about where a click lands. let waveEl = $state(null); - let waveDragging = false; + // $state, not a plain let: it drives `class:scrubbing` in the markup, and + // without it that class never applied while dragging (the compiler warns + // on every build — non_reactive_update). + let waveDragging = $state(false); let lastWaveSeekAt = 0; function waveTarget(e) { if (!player.duration || !waveEl) return null; @@ -641,7 +838,7 @@
{#if statusLine} - {#key statusLine} + {#key statusPhase} {statusLine} {/key} {/if} @@ -680,6 +877,7 @@ {/if}
{/if} +
@@ -722,30 +920,30 @@
- - {#if bars.length} -
- {#each bars as v, i} - - {/each} -
- {/if} + +
+ {#each waveBars as v, i} + + {/each} +
{fmtTime(player.position)} + scroll, so the queue is never four sections away. An ordinary + element in the flow — it is painted where it sits and scrolls + away with everything else. -->
Releases {#if discovery.albumCount}{discovery.albumCount} total{/if}
-
- {#each releaseList as al (al.id)} - + + {/each} +
{/each} -
+ {:else} +
+ {#each releaseList as al (al.id)} + + {/each} +
+ {/if} {#if discovery.albums.length > moreAlbums.length} {/each}
@@ -968,6 +1200,7 @@ onpointermove={onGripMove} onpointerup={onGripUp} onpointercancel={onGripUp} + onlostpointercapture={onGripUp} ondblclick={resetWidth} > {#if dragging} @@ -1283,8 +1516,9 @@ overflow: hidden; } /* The status swaps with the glass bar's own breadcrumb motion, so a change - of peer or state reads as an arrival rather than a flicker. Keyed on the - text, so it only replays when the line actually changes. */ + of peer or state reads as an arrival rather than a flicker. Keyed on + `statusPhase`, NOT on the text: the text carries the live percentage, so + keying on it replayed this animation on every progress event. */ .np-status { display: block; font-size: 10.5px; @@ -1331,6 +1565,10 @@ flex: 1; min-height: 0; overflow-y: auto; + /* Never sideways. Nothing in this pane is meant to be reached by + scrolling horizontally — a stray wide row is a bug, and a scrollbar + along the bottom of the panel is not the way to find out about it. */ + overflow-x: hidden; overflow-anchor: none; overscroll-behavior: none; /* Clear the 46px glass header so the art isn't overlapped at rest; the @@ -1488,7 +1726,9 @@ display: flex; justify-content: center; align-items: end; - gap: 2px; + /* Written from BAR_PITCH - BAR_W so the gap cannot drift out of step + with the bar count derived from the same pitch. */ + gap: var(--bar-gap, 4px); height: 38px; /* It is a control now, not a picture of one. */ cursor: pointer; @@ -1502,6 +1742,11 @@ .wave.scrubbing { cursor: grabbing; } + /* Waiting on peaks: the bars rest on their 2px floor, so the lane reads as + a quiet baseline holding its place rather than as a hole in the panel. */ + .wave.pending i { + opacity: 0.4; + } .wave i { flex: 0 0 2px; width: 2px; @@ -1565,11 +1810,11 @@ /* ---- segmented control ---- */ .segwrap { - /* Sticky under the 46px glass header: keeping the scroll offset across - a pane switch is worthless if the control you just used has scrolled - out of reach. The gradient hides rows sliding under it. */ - position: sticky; - top: 46px; + /* Deliberately NOT sticky. Sticky does not mean "pinned": it scrolls + normally until it reaches its stick point, so sitting below the art, + title, waveform and chips it rode 530px up the panel before pinning, + which read as the bar sliding around. It is painted where it sits + and scrolls away like any other element. */ z-index: 4; margin: 0 -14px; padding: 7px 14px 8px; @@ -1699,6 +1944,10 @@ /* Block-level, so it tracks the panel width instead of shrinking to its own content at every size. */ width: 100%; + /* A