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 @@