now playing: collapse to a rail, and rebuild the panel around the record
The right-hand panel disappeared when you closed it. It collapses to a rail
now, exactly as the left nav does -- same 0.28s curve, same crossfade clock
for its contents -- so the record, its progress and its transfer stay on
screen at 64px instead of sliding off.
THE RAIL carries the album as a full-height bleed behind a real glass bar
(the app's own recipe: rgba(20,20,24,.28), blur(12px) saturate(1.7), 46px),
with the expand chevron in that bar, a left-edge progress line, the cover,
an equaliser while playing, stacked icons badged with what is behind them
-- tracks left, releases -- and the title set vertically.
THE PANEL stops saying "Now playing", which was the only permanently
visible line it had and told you nothing. It names what you are playing
FROM, and carries the transfer as a hairline along the glass bar's bottom
edge -- the same object in both states, so a download reads identically
whether the panel is open or collapsed. The hairline fills linearly (it
tracks progress rather than easing past it), sweeps indeterminately while
there is no byte count to be honest about, and fades out when cached rather
than sitting full forever. Hovering the header drops the whole detail:
peer, transferred, rate, and saving what you are streaming. A library track
has no peer, so none of it appears -- zeroes would read as a stalled
transfer.
Below the record, one long undifferentiated scroll becomes four shallow
panes -- Queue, Lyrics, Artist, About -- behind a sticky segmented control.
Sticky because keeping your scroll position across a switch is worthless if
the control you just used has scrolled out of reach. Queue gains an up-next
card, library ticks, and per-track Last.fm listeners past 396px; Artist has
the artist as one full-width pill whose figures spread once there is room,
the same branded social circles the artist page draws, releases with Show
all, and fans-also-like; About carries the record's description and the
album page's RELEASED block verbatim, so a record reads the same in both
places. Lyrics is a placeholder -- there is no source in the app and
pretending otherwise would be worse than saying so.
The panel is drag-resizable from its left edge, 272-560, with a 10px magnet
at the default and a width tag while dragging; the width is remembered,
because one you set by hand and lost every launch is worse than not being
able to set it.
Five things this fixed on the way, four of them mine:
- `playerArtSource` resolved a "better" cover by ARTIST + TITLE. A name
cannot identify a record: every self-titled Weezer album is "Weezer", the
resolver returned whichever one the provider answered with, and all of
them played back showing the Green Album's art. The substitution is gone;
the record's own asset is rewritten to its 1000x1000 variant instead --
a real quality win, no identity risk, and more than the panel needs even
at its widest. Verified: Blue and Green now resolve to their own hashes.
- The panel body was torn down 300ms after closing, an optimisation from
when it slid off-screen. The rail shows that artwork, so it blanked the
rail's cover and its bleed.
- An inline --nowplaying-width would have outranked the collapse rule and
made the panel un-collapsible; the dragged width goes through a
--np-open-width indirection instead.
- The segmented control was an opaque strip painted straight over the album
bleed, reading as a grey band across the artwork. It is glass now.
- The waveform was a picture of a scrubber with no way to seek, while the
player bar carried the only one. It seeks now, on the same semantics
(75ms throttle dragging, exact seek on release), and the second time
reads remaining rather than total -- the total is already implied by the
bar's length.
`soulseek:stream` reports {received, total, done} and no rate, unlike the
download path's speed_bps, so the speed behind the hairline is measured
here: a rolling 1.5s window, because a per-event delta reads as a number
flickering between 0 and several MB/s rather than a speed. There is
deliberately no "queued at peer" state -- the streaming path reports no
queue place, and the panel will not claim to know one.
`discovery` keeps the artist page's socials rather than discarding them;
they were already being fetched. 29 CSS rules for the removed sections are
gone with them.
Verified in the running app over CDP: collapse to exactly 64px and back,
rail contents and bleed at blur(17px), all four panes, sticky segments,
social chips carrying the artist page's own brand tints, the release grid
going to three columns past 430px and the pill's stats spreading past
372px, and About reading "Released May 9, 1994 - 10 tracks - 41 min / DGC -
Alternative Rock". 85 JS tests pass, production build clean with no unused
selectors. The drag gesture itself is not CDP-drivable and wants a
hands-on check.
This commit is contained in:
1 parent
35729c1f9e
commit
e334cd51c2
4 files changed
+1528
-329
No files matched your search
+11
-3
@@ -64,6 +64,7 @@
|
||||
class:np-open={ui.nowPlaying}
|
||||
class:sidebar-collapsed={ui.sidebarCollapsed}
|
||||
class:preview-open={previewUi.open}
|
||||
style="--np-open-width: {ui.nowPlayingWidth}px"
|
||||
>
|
||||
<TitleBar />
|
||||
<!-- While the preview modal is open the page is inert and the flanking
|
||||
@@ -118,6 +119,11 @@
|
||||
background: var(--bg-sidebar);
|
||||
overflow: hidden;
|
||||
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Indirection so the dragged width can be an INLINE style without
|
||||
outranking the collapse rule below: an inline --nowplaying-width
|
||||
would beat `.app:not(.np-open)` and the panel could never collapse.
|
||||
Both rules here are class-level, so the more specific one wins. */
|
||||
--nowplaying-width: var(--np-open-width, 340px);
|
||||
}
|
||||
|
||||
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
||||
@@ -126,10 +132,12 @@
|
||||
--sidebar-width: 64px;
|
||||
}
|
||||
|
||||
/* Now Playing panel slides away: the column collapses to zero (the panel
|
||||
itself keeps its width and slides off-screen — see NowPlaying.svelte). */
|
||||
/* Now Playing collapses to its RAIL, not to nothing — the same gesture the
|
||||
left nav makes. The column animates between the user's dragged width and
|
||||
the rail on the shared 0.28s curve above; the panel keeps a presence at
|
||||
every point, so there is no off-screen slide any more. */
|
||||
.app:not(.np-open) {
|
||||
--nowplaying-width: 0px;
|
||||
--nowplaying-width: var(--nowplaying-rail);
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
+1405
-311
File diff suppressed because it is too large.
Load diff
+109
-15
@@ -24,12 +24,37 @@ export const app = $state({
|
||||
showLogin: false,
|
||||
});
|
||||
|
||||
/** Now Playing panel width: the default the magnet snaps back to, and the
|
||||
* bounds the drag handle clamps between. Exported so the panel and the grid
|
||||
* can never disagree about them. */
|
||||
export const NP_WIDTH_DEFAULT = 340;
|
||||
export const NP_WIDTH_MIN = 272;
|
||||
export const NP_WIDTH_MAX = 560;
|
||||
const NP_WIDTH_KEY = "soulstream.nowPlayingWidth";
|
||||
|
||||
function readPanelWidth() {
|
||||
try {
|
||||
const raw = Number(localStorage.getItem(NP_WIDTH_KEY));
|
||||
if (!Number.isFinite(raw) || raw <= 0) return NP_WIDTH_DEFAULT;
|
||||
return Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, raw));
|
||||
} catch {
|
||||
return NP_WIDTH_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
export const ui = $state({
|
||||
view: "home",
|
||||
albumId: null, // selected album in Library view
|
||||
playlistId: null, // selected playlist in Playlist view
|
||||
nowPlaying: true, // right-side Now Playing panel open
|
||||
// The Now Playing panel is EXPANDED vs COLLAPSED, never gone: collapsing it
|
||||
// leaves the rail (see NowPlaying.svelte), the same way the left nav
|
||||
// collapses to an icon rail rather than disappearing.
|
||||
nowPlaying: true,
|
||||
sidebarCollapsed: false, // left nav collapsed to an icon rail
|
||||
// User-dragged width of the expanded panel. Persisted, unlike the other ui
|
||||
// flags: a width you set by hand and lost on every launch would be worse
|
||||
// than not being able to set it at all.
|
||||
nowPlayingWidth: readPanelWidth(),
|
||||
// Full-window artwork lightbox. `null` when closed; otherwise a descriptor
|
||||
// of whatever cover to show — { src, local, title, subtitle } — so any
|
||||
// surface can open it, not only the playing track.
|
||||
@@ -44,6 +69,17 @@ export function toggleNowPlaying() {
|
||||
ui.nowPlaying = !ui.nowPlaying;
|
||||
}
|
||||
|
||||
/** Set the expanded panel's width, clamped and remembered. */
|
||||
export function setNowPlayingWidth(px) {
|
||||
const w = Math.max(NP_WIDTH_MIN, Math.min(NP_WIDTH_MAX, Math.round(px)));
|
||||
ui.nowPlayingWidth = w;
|
||||
try {
|
||||
localStorage.setItem(NP_WIDTH_KEY, String(w));
|
||||
} catch {
|
||||
// A blocked storage quota must not break resizing for the session.
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleSidebar() {
|
||||
ui.sidebarCollapsed = !ui.sidebarCollapsed;
|
||||
}
|
||||
@@ -525,23 +561,26 @@ export function coverLoadError(
|
||||
}
|
||||
}
|
||||
|
||||
/** Cover URL to display for a currently-playing catalog item. Kicks off an
|
||||
* idempotent high-res upgrade for its album and returns the best URL to show
|
||||
* now — the verified upgrade once resolved, else the instant Deezer cover. The
|
||||
* player surfaces proxy the returned URL through getCatalogArt; because this
|
||||
* reads the reactive coverUpgrades map, their effect re-runs and re-proxies at
|
||||
* full resolution the moment the upgrade lands. */
|
||||
/**
|
||||
* Cover URL to display for a currently-playing catalog item — the record's OWN
|
||||
* asset, at the largest size that asset comes in.
|
||||
*
|
||||
* It used to also kick off a cover "upgrade", which resolves a better image by
|
||||
* ARTIST + TITLE. That is the identity trap: an artist can have several records
|
||||
* under one title — every self-titled Weezer album is "Weezer" — and the
|
||||
* resolver hands back whichever one the provider answers with, so all seven
|
||||
* played back showing the Green Album's art. The upgrade map is keyed by the
|
||||
* original cover URL and was innocent; the value put in it was wrong.
|
||||
*
|
||||
* A name cannot identify a record, so the substitution is gone. `hqCover`
|
||||
* rewrites the same Deezer asset to its 1000x1000 variant, which is a genuine
|
||||
* quality win with no identity risk, and is more than the panel needs even
|
||||
* dragged to its widest.
|
||||
*/
|
||||
export function playerArtSource(item) {
|
||||
const url = item?.artUrl;
|
||||
if (!url) return null;
|
||||
if (item.album) {
|
||||
resolveCovers(
|
||||
[{ cover: url, title: item.album }],
|
||||
item.artist ?? "",
|
||||
{ sticky: true },
|
||||
);
|
||||
}
|
||||
return coverUpgrades[url] ?? url;
|
||||
return hqCover(url);
|
||||
}
|
||||
|
||||
// Artist heroes have their own proxy because provider fallbacks may be finished
|
||||
@@ -2388,6 +2427,10 @@ export const discovery = $state({
|
||||
albums: [], // discography → "More from"
|
||||
related: [], // "Fans also like"
|
||||
bio: null, // Wikipedia summary → "About the artist"
|
||||
// Declared links for the artist ({ kind, url }), already fetched as part of
|
||||
// the artist page below — kept rather than discarded so the Now Playing
|
||||
// panel can show the same chips the artist page does.
|
||||
socials: [],
|
||||
});
|
||||
|
||||
const bioCache = new Map(); // normalized name → bio text (or null)
|
||||
@@ -2512,6 +2555,7 @@ export async function loadDiscovery(artistName) {
|
||||
discovery.albumCount = null;
|
||||
discovery.albums = [];
|
||||
discovery.related = [];
|
||||
discovery.socials = [];
|
||||
discovery.bio = bioCache.has(norm) ? touchCache(bioCache, norm) : null;
|
||||
}
|
||||
|
||||
@@ -2599,6 +2643,7 @@ export async function loadDiscovery(artistName) {
|
||||
discovery.fans = page.artist.fans ?? null;
|
||||
discovery.albumCount = page.artist.album_count ?? page.albums.length;
|
||||
discovery.albums = page.albums ?? [];
|
||||
discovery.socials = page.socials ?? [];
|
||||
discovery.pageLoaded = true;
|
||||
discovery.pageWanted = false;
|
||||
}
|
||||
@@ -3772,6 +3817,41 @@ function trimTransferHistory(store, maxTerminal, waitUntilIdle = false) {
|
||||
for (const transfer of terminal.slice(maxTerminal)) delete store[transfer.id];
|
||||
}
|
||||
|
||||
// ---- streaming rate ------------------------------------------------------
|
||||
// `player.streamSpeed` is measured here because nothing upstream reports it.
|
||||
// Samples are [timestamp, bytesReceived] over a rolling window, so a transfer
|
||||
// that stalls decays toward zero instead of reporting its last burst forever,
|
||||
// and a bursty peer reads as one speed rather than a flickering number.
|
||||
const RATE_WINDOW_MS = 1500;
|
||||
let rateSamples = [];
|
||||
|
||||
function resetStreamRate() {
|
||||
rateSamples = [];
|
||||
player.streamSpeed = 0;
|
||||
}
|
||||
|
||||
function noteStreamRate(received) {
|
||||
const now = performance.now();
|
||||
// A new file restarts the measurement: without this, the first sample of
|
||||
// the next track is compared against the last of the previous one.
|
||||
if (rateSamples.length && received < rateSamples[rateSamples.length - 1][1]) {
|
||||
rateSamples = [];
|
||||
}
|
||||
rateSamples.push([now, received]);
|
||||
while (rateSamples.length > 2 && now - rateSamples[0][0] > RATE_WINDOW_MS) {
|
||||
rateSamples.shift();
|
||||
}
|
||||
}
|
||||
|
||||
function streamRate() {
|
||||
if (rateSamples.length < 2) return 0;
|
||||
const [t0, b0] = rateSamples[0];
|
||||
const [t1, b1] = rateSamples[rateSamples.length - 1];
|
||||
const secs = (t1 - t0) / 1000;
|
||||
if (secs <= 0) return 0;
|
||||
return Math.max(0, (b1 - b0) / secs);
|
||||
}
|
||||
|
||||
export const player = $state({
|
||||
status: "idle", // idle | buffering | playing | paused
|
||||
track: null, // { title, sub, source: 'stream'|'local' }
|
||||
@@ -3785,6 +3865,11 @@ export const player = $state({
|
||||
// streaming buffer info
|
||||
streamReceived: 0,
|
||||
streamTotal: 0,
|
||||
// Bytes/sec, DERIVED here rather than reported: the `soulseek:stream` event
|
||||
// carries only {received, total, done}, and unlike the download path there
|
||||
// is no `speed_bps` to read. 0 means "not measured yet" — the panel shows
|
||||
// no rate rather than a zero, because a fresh stream is not a stalled one.
|
||||
streamSpeed: 0,
|
||||
error: "",
|
||||
// album play queue (local tracks)
|
||||
queue: [],
|
||||
@@ -4133,11 +4218,17 @@ export function initEvents() {
|
||||
});
|
||||
|
||||
api.onEvent("soulseek:stream", (s) => {
|
||||
// Rate is measured here because nothing upstream reports it. A rolling
|
||||
// average over ~1.5s of samples, not the instantaneous delta: Soulseek
|
||||
// transfers arrive in bursts, and a per-event figure reads as a number
|
||||
// flickering between 0 and several MB/s rather than a speed.
|
||||
noteStreamRate(s.received);
|
||||
latestStreamReceived = s.received;
|
||||
latestStreamTotal = s.total;
|
||||
if (ui.windowActive) {
|
||||
player.streamReceived = s.received;
|
||||
player.streamTotal = s.total;
|
||||
player.streamSpeed = streamRate();
|
||||
}
|
||||
const cur = player.queue[player.queueIndex];
|
||||
if (s.done) {
|
||||
@@ -4311,6 +4402,7 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||||
player.status = "buffering";
|
||||
player.duration = null;
|
||||
player.streamTotal = 0;
|
||||
resetStreamRate();
|
||||
loadWaveform(path);
|
||||
setAccentFromArt(artPath);
|
||||
await api.playLocal(path, null);
|
||||
@@ -4356,6 +4448,7 @@ function playQueueIndex(i) {
|
||||
// Remote track streamed from a peer while it downloads to cache.
|
||||
player.streamReceived = 0;
|
||||
player.streamTotal = item.stream.size;
|
||||
resetStreamRate();
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
appendedNextPath = null;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
@@ -4462,6 +4555,7 @@ function advanceGaplessly() {
|
||||
player.track = { title: item.title, sub: item.sub, source: "stream", path: item.path };
|
||||
player.streamReceived = 0;
|
||||
player.streamTotal = 0;
|
||||
resetStreamRate();
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
loadStreamWaveform(item);
|
||||
|
||||
@@ -86,6 +86,9 @@
|
||||
--sidebar-width: 232px;
|
||||
--player-height: 88px;
|
||||
--nowplaying-width: 340px;
|
||||
/* What the Now Playing panel collapses TO. It never collapses to zero —
|
||||
see the note in App.svelte and the rail in NowPlaying.svelte. */
|
||||
--nowplaying-rail: 64px;
|
||||
}
|
||||
|
||||
/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is
|
||||
|
||||
Reference in new issue
Block a user