From 9aeb6c39c75334599a6029c1ef9894318e0471c6 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:04:11 -0500 Subject: [PATCH] player bar: one row, aligned to the sidebar's column, and it says who is sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 88px becomes 62px and the two stacked rows become one. The 26px goes back to the content area on every screen, and the scrubber stops capping at 560px while the flanks stretch — a 1900px window used to put a small waveform adrift in the middle of an empty bar. The cover is 40x40 at 12px of padding, which lands its centre on x=40: the column every sidebar cover and nav icon already shares, and which the sidebar holds through its entire collapse. It was 56px at 16px, centre x=52 — twelve pixels out of a column the app is otherwise strict about. The waveform is drawn as discrete bars on the Now Playing rail's exact pitch, a 2px bar on a 4px gap with a 1px radius, both deriving their count from that one pitch so neither can drift from the other. Mirrored here, where the lane is short and wide; the rail stands its bars on the floor because it sits above a caption. The standing white playhead went with it — at this pitch it read as a seam through the bars, and the accent's own leading edge marks the position. Artist and album share one line as "Artist · Album", the `.who` idiom from the album page rather than a new one. They are read from the queue item's OWN artist/album fields now: `sub` is a display convenience that the album continuation fill writes as a bare artist while carrying the album right alongside, so a record played straight through lost its album name from the second track on. When the line must shrink the ALBUM shrinks — proportional shrinking turned a short artist into "Po…", which saves nothing and reads as nothing. New: the source line. Who is sending this track, how much has arrived, how fast and how long is left, then the format. Four states, derived exactly as NowPlaying derives its status line — two surfaces disagreeing about whether a track is cached would be worse than either being wrong. A library file reports NOTHING, because it has no peer and no transfer and zeroes would read as a stalled one. It sits in its own slot rather than inside the meta cluster: sharing that budget let a live transfer readout crowd the artist and album down to "Po… · Terrestria…". Its width is measured per STATE and never per value, so a ticking percentage and a counting-down ETA move nothing. The now-playing panel toggle is gone from the shelf — the panel carries its own collapse control, and two ways to do one thing is one too many. What replaces it is a single overflow menu, so landing lyrics, a mini player and output switching later costs no width; those three are present and disabled, marked SOON, rather than wired to nothing. Co-Authored-By: Claude Opus 5 --- src/components/Icon.svelte | 7 + src/components/PlayerBar.svelte | 479 +++++++++++++++++++++++++++----- src/styles/global.css | 5 +- 3 files changed, 426 insertions(+), 65 deletions(-) diff --git a/src/components/Icon.svelte b/src/components/Icon.svelte index 90f6dfb..a949eb2 100644 --- a/src/components/Icon.svelte +++ b/src/components/Icon.svelte @@ -44,6 +44,12 @@ refresh: '', check: '', "list-plus": '', + // Player-bar overflow. Filled dots rather than stroked circles: at 17px + // a 2px-stroke ring closes into a blob anyway, and the fill stays even. + "more-horizontal": + '', + // Lyrics: ragged lines, so it cannot be mistaken for the queue's list. + lyrics: '', shuffle: '', repeat: '', "repeat-one": '', @@ -61,6 +67,7 @@ trophy: '', tag: '', globe: '', + alert: '', "brand-instagram": '', "brand-x": '', "brand-facebook": '', diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index a5f653a..d34157c 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -7,13 +7,13 @@ prevTrack, toggleShuffle, cycleRepeat, - toggleNowPlaying, openCurrentArtist, openCurrentAlbum, openCurrentSong, getArt, getCatalogArt, playerArtSource, + resumePlayback, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import Icon from "./Icon.svelte"; @@ -33,8 +33,19 @@ // The native scratch engine needs a complete local file. A network stream // keeps the reliable seek-on-drag behavior until it has been saved/cached. const canScratch = $derived(!currentItem?.stream); - // Split "Artist — Album" for the separate, clickable meta lines. + /** Artist and album for the separate, clickable meta lines. + * + * The queue item's OWN `artist` / `album` fields come first, and splitting + * `sub` on the em dash is only the fallback. `sub` is a display convenience + * that not every path fills the same way: the album-continuation fill + * (`maybeExtendAlbumQueue`) writes a bare artist into it while carrying the + * album right alongside, so a record played straight through lost its album + * name from the second track on — which is exactly what it looked like. + * Local library rows carry neither field, and those still fall back. */ const metaParts = $derived.by(() => { + if (currentItem?.artist || currentItem?.album) { + return [currentItem.artist ?? "", currentItem.album ?? ""]; + } const sub = player.track?.sub ?? ""; return sub.includes(" — ") ? sub.split(" — ").map((s) => s.trim()) @@ -48,6 +59,123 @@ : "volume", ); + // ---- the source line --------------------------------------------------- + // Every value below already exists in state — these are the same fields + // NowPlaying reads for its status line, so this needed no new command. + // The derivations are deliberately identical to that component's, because + // two surfaces disagreeing about whether a track is "cached" would be worse + // than either of them being wrong. + const isStream = $derived(!!currentItem?.stream); + const quality = $derived(currentItem?.quality ?? null); + /** Same set NowPlaying uses, so the green dot means the same thing here. */ + const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]); + const transferring = $derived( + isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal, + ); + const cachedStream = $derived( + isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal, + ); + /** What the line is ABOUT, as opposed to what it currently reads. The slot + * is re-measured when THIS changes and never when a percentage ticks. */ + const srcState = $derived( + !isStream + ? "library" + : cachedStream + ? "cached" + : transferring + ? "transferring" + : "connecting", + ); + const peerName = $derived(currentItem?.stream?.username ?? null); + const rateText = $derived( + player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null, + ); + const etaText = $derived.by(() => { + if (!transferring || player.streamSpeed <= 0) return null; + const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed; + if (!isFinite(secs) || secs < 1) return null; + return secs >= 60 + ? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left` + : `${Math.round(secs)}s left`; + }); + const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null); + const qualityChip = $derived.by(() => { + if (!quality?.format) return null; + return quality.bitrate + ? `${quality.format} ${Math.round(quality.bitrate)}k` + : quality.format; + }); + /** A library track reports NOTHING: it has no peer and no transfer, and + * zeroes would read as a stalled one. Same call NowPlaying makes. */ + const sourceParts = $derived.by(() => { + if (!isStream) return []; + if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean); + if (transferring) { + return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean); + } + return peerName ? [`Streaming from ${peerName}`] : []; + }); + + // The slot reserves a width rather than hugging its text: while a transfer + // runs, the percentage, rate and ETA change every position event, and a + // slot that tracked them would slide the transport and the whole scrubber + // sideways continuously. It GROWS the instant the text needs more room and + // SHRINKS only when the state changes, so nothing ever clips and nothing + // twitches. Written straight to the node: routing it through $state would + // make the effect that measures it depend on its own output. + let srcSlotEl = $state(null); + let srcLineEl = $state(null); + const SLOT_CAP = 340; + const SLOT_PAD_L = 10; + const SLOT_PAD_R = 8; + function sizeSourceSlot(reset = false) { + if (!srcSlotEl) return; + const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0; + const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0; + const current = parseFloat(srcSlotEl.style.width) || 0; + if (!reset && want <= current) return; + srcSlotEl.style.width = `${want}px`; + srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px"; + srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px"; + } + $effect(() => { + srcState; + qualityChip; + sizeSourceSlot(true); + }); + $effect(() => { + sourceParts; + sizeSourceSlot(false); + }); + // The slot's width is written imperatively, so a window resize — which can + // cross the @container threshold that hides it — has to re-measure. + $effect(() => { + const onResize = () => sizeSourceSlot(true); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }); + + // ---- the overflow menu ------------------------------------------------- + let menuOpen = $state(false); + let menuWrap = $state(null); + $effect(() => { + if (!menuOpen) return; + const away = (e) => { + if (menuWrap && !menuWrap.contains(e.target)) menuOpen = false; + }; + const esc = (e) => e.key === "Escape" && (menuOpen = false); + window.addEventListener("pointerdown", away); + window.addEventListener("keydown", esc); + return () => { + window.removeEventListener("pointerdown", away); + window.removeEventListener("keydown", esc); + }; + }); + function openQueue() { + menuOpen = false; + ui.nowPlaying = true; // the queue lives in the panel; always reveal it + } + let artUrl = $state(null); let volTrack = $state(null); let draggingVolume = $state(false); @@ -89,7 +217,10 @@ // Canvas colors; played/hover follow the adaptive accent theme. const UNPLAYED = "rgba(255,255,255,0.24)"; const PREVIEW = "rgba(255,255,255,0.66)"; - const PLAYHEAD = "#ffffff"; + /** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */ + const BAR_PITCH = 6; + const BAR_W = 2; + const BAR_GAP = BAR_PITCH - BAR_W; function fmt(secs) { if (secs == null || !isFinite(secs)) return "–:––"; @@ -99,7 +230,9 @@ function toggle() { if (player.status === "playing") api.playerControl("pause"); - else if (player.status === "paused") api.playerControl("resume"); + // Not a bare `resume`: a track restored from the last session has no + // audio loaded behind it and has to be re-sourced from the queue. + else if (player.status === "paused") resumePlayback(); } function seekTarget(e, el) { @@ -249,7 +382,7 @@ const dpr = window.devicePixelRatio || 1; const W = wrap.clientWidth; - const H = 26; + const H = 24; // matches .wave-wrap if (W <= 0) return; const pixelWidth = Math.max(1, Math.round(W * dpr)); const pixelHeight = Math.max(1, Math.round(H * dpr)); @@ -264,27 +397,32 @@ const N = peaks.length; const mid = H / 2; - // Thin waveform floating in the middle: cap the amplitude well short - // of the edges so loud sections don't clip flat, and keep a small - // baseline so quiet sections still read as a hairline. - const maxAmp = 7; - const amp = (v) => Math.max(0.7, v * maxAmp); const geometryChanged = resized || wavePathPeaks !== peaks || wavePathWidth !== W || wavePathHeight !== H; if (geometryChanged) { + // Discrete bars on the SAME pitch the Now Playing rail draws — a + // 2px bar on a 4px gap, 1px radius — so the two waveforms in the + // window read as one instrument rather than two. Both derive their + // bar count from that pitch, so neither can drift from the other. + // Mirrored about the centre line here, where the lane is short and + // wide; the rail stands its bars on the floor because it sits above + // a caption. const path = new Path2D(); - path.moveTo(0, mid - amp(peaks[0])); - const divisor = Math.max(1, N - 1); - for (let i = 0; i < N; i++) { - path.lineTo((i / divisor) * W, mid - amp(peaks[i])); + const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH)); + for (let i = 0; i < count; i++) { + const from = Math.floor((i / count) * N); + const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N)); + let sum = 0; + for (let j = from; j < to; j++) sum += peaks[j] ?? 0; + // The rail's shaping, verbatim: capped so a loud passage cannot + // flatten into a ruler, then gamma'd back up. + const v = sum / (to - from); + const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H); + path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1); } - for (let j = N - 1; j >= 0; j--) { - path.lineTo((j / divisor) * W, mid + amp(peaks[j])); - } - path.closePath(); wavePath = path; wavePathPeaks = peaks; wavePathWidth = W; @@ -322,15 +460,14 @@ // `accentHover` remains reserved for hover previews and controls. fillClip(0, playX, theme.accent); - // Playhead / hover markers: fixed height, padded off the edges, - // so they read as small ticks rather than full-height rules. - const LINE_H = 18; - const lineY = (H - LINE_H) / 2; - ctx.fillStyle = PLAYHEAD; - ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H); + // No standing playhead rule any more: at this pitch the accent's own + // leading edge marks the position, and a white line through the bars + // read as a seam. The hover tick stays — it is the only thing that + // says where a click would land. if (hoverX >= 0) { + const LINE_H = 18; ctx.fillStyle = theme.accentHover; - ctx.fillRect(hoverX - 0.5, lineY, 1, LINE_H); + ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H); } lastPlayX = playX; @@ -381,24 +518,50 @@ Nothing playing {/if} + {#if player.status === "buffering"} - {metaParts[0] || "buffering…"} - {:else if player.track} - {#if metaParts[0]} - - {/if} - {#if metaParts[1]} - - {/if} + {metaParts[0] || "buffering…"} + {:else if player.track && (metaParts[0] || metaParts[1])} + + {#if metaParts[0]} + + {/if} + {#if metaParts[0] && metaParts[1]}·{/if} + {#if metaParts[1]} + + {/if} + {/if} -
+ + {#if sourceParts.length || qualityChip} +
+
+ {#each sourceParts as part, i} + {#if i > 0}·{/if} + {part} + {/each} + {#if qualityChip} + {#if sourceParts.length}·{/if} + {qualityChip} + {/if} +
+
+ {/if} +
-
+
- + + + + @@ -507,26 +696,47 @@