forked from Keelrleek/SoulStream
player bar: it grows upward, the cursor is marked by the wave itself, and the shape you leave it in survives a restart
Expanding (mock E1+T1, cubic-bezier(.22,1,.36,1) over 350ms to 90px):
`.app.player-expanded` sets --player-height beside the sidebar's own rule
and grid-template-rows joins the transition, so the bar grows into the
content and its bottom edge is pinned without a rule saying so — the
grid's height is the window, the bar's track takes 28px and 1fr gives up
exactly that. Inside the bar four blocks are absolutely placed so the two
layouts can interpolate; flex cannot, because one row to a centred
two-row stack means changing flex-direction, which does not animate. Name
and extras sit at top 50% with a -50% translate and are centred at every
height in both states. --btn-w and --extras-w are measured once rather
than assumed: extras is 180px, not the 170 the layout was drafted with,
and it differs between a stream and a library track. The toggle takes the
download button's seat (the sidebar's .gicon, verbatim) and Save to
library moves into the ... menu under the same isStream gate. Measured:
name and extras vertical error 0 in both states, transport and lane
horizontal error 0 expanded, lane exactly 10px under the transport.
The 1px playhead tick is gone from all three painters. The bar under the
cursor lifts while its neighbours dip (A4): growing alone reads as
nothing, because the median bar is 21.3px of a 24px lane and 87% of bars
have no headroom left to grow into — the dip is what carries the
silhouette. The wash is enter/leave only, one shared presence ramp with
hard span membership, so the edge cannot trail the pointer: 0-bar lag
across five jumps in both directions, and moving inside the lane
schedules no frames at all. The settled painter keeps its cached-Path2D
grouped fills whenever no pointer is on the lane.
The clipped-name edge fade from 2a83615 had never once rendered. Its
classes are applied by classList.toggle inside an action, which the
compiler cannot see, so Svelte deleted the rules as unused — that warning
is a deletion notice, not advice. :global() on the mask class only, with
the anchor left scoped, and all four states now verify against a
narrowed box.
Panel shapes persist in one key, soulstream.panels.v1, holding
nowPlaying, sidebarCollapsed and playerExpanded together so the three
cannot end up half-migrated against each other; it is read per-key so an
older blob contributes what it has. showNowPlaying() exists because
openQueue assigned ui.nowPlaying directly and would have skipped the
write.
This commit is contained in:
1 parent
e976fb88ba
commit
5786269592
3 files changed
+470
-102
No files matched your search
+17
-1
@@ -72,6 +72,7 @@
|
|||||||
class="app"
|
class="app"
|
||||||
class:np-open={ui.nowPlaying}
|
class:np-open={ui.nowPlaying}
|
||||||
class:sidebar-collapsed={ui.sidebarCollapsed}
|
class:sidebar-collapsed={ui.sidebarCollapsed}
|
||||||
|
class:player-expanded={ui.playerExpanded}
|
||||||
class:np-dragging={ui.nowPlayingDragging}
|
class:np-dragging={ui.nowPlayingDragging}
|
||||||
class:preview-open={previewUi.open}
|
class:preview-open={previewUi.open}
|
||||||
style="--np-open-width: {ui.nowPlayingWidth}px"
|
style="--np-open-width: {ui.nowPlayingWidth}px"
|
||||||
@@ -137,7 +138,13 @@
|
|||||||
padding: 0 8px 8px;
|
padding: 0 8px 8px;
|
||||||
background: var(--bg-sidebar);
|
background: var(--bg-sidebar);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
/* The player row grows UPWARD because the grid's total height is the
|
||||||
|
window: the bar's own track gets taller and `1fr` — the content —
|
||||||
|
gives up exactly that much. Nothing below the bar can move, so its
|
||||||
|
bottom edge is pinned without a rule saying so. */
|
||||||
|
transition:
|
||||||
|
grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
grid-template-rows 350ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
/* Indirection so the dragged width can be an INLINE style without
|
/* Indirection so the dragged width can be an INLINE style without
|
||||||
outranking the collapse rule below: an inline --nowplaying-width
|
outranking the collapse rule below: an inline --nowplaying-width
|
||||||
would beat `.app:not(.np-open)` and the panel could never collapse.
|
would beat `.app:not(.np-open)` and the panel could never collapse.
|
||||||
@@ -162,6 +169,15 @@
|
|||||||
--sidebar-width: 64px;
|
--sidebar-width: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expanded player: the same one-line gesture the sidebar makes, on the row
|
||||||
|
instead of the column. 90px was chosen on
|
||||||
|
research/playbar-redesign/expand-collapse.html — enough for the 34px
|
||||||
|
transport, a 10px gap and the 24px lane stacked and centred (68px of
|
||||||
|
content, 11px of air top and bottom), and no more. */
|
||||||
|
.app.player-expanded {
|
||||||
|
--player-height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Now Playing collapses to its RAIL, not to nothing — the same gesture the
|
/* 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
|
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
|
the rail on the shared 0.28s curve above; the panel keeps a presence at
|
||||||
|
|||||||
+392
-99
@@ -14,6 +14,9 @@
|
|||||||
getCatalogArt,
|
getCatalogArt,
|
||||||
playerArtSource,
|
playerArtSource,
|
||||||
resumePlayback,
|
resumePlayback,
|
||||||
|
togglePlayerExpanded,
|
||||||
|
showNowPlaying,
|
||||||
|
saveStream,
|
||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +34,6 @@
|
|||||||
PENDING_ALPHA,
|
PENDING_ALPHA,
|
||||||
} from "../lib/waveshape.js";
|
} from "../lib/waveshape.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
import SaveMenu from "./SaveMenu.svelte";
|
|
||||||
|
|
||||||
const pct = $derived(
|
const pct = $derived(
|
||||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||||||
@@ -230,9 +232,40 @@
|
|||||||
});
|
});
|
||||||
function openQueue() {
|
function openQueue() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
ui.nowPlaying = true; // the queue lives in the panel; always reveal it
|
showNowPlaying(); // the queue lives in the panel; always reveal it
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Save to library, from the ⋯ menu ---------------------------------
|
||||||
|
// Only a stream can be saved; a single queued stream has one destination
|
||||||
|
// and needs no scope choice, which is the rule the old download button
|
||||||
|
// followed. `saveStream` is the same call it made.
|
||||||
|
const canSave = $derived(!!currentItem?.stream);
|
||||||
|
const savingAlbum = $derived(player.queue.filter((it) => it.stream).length > 1);
|
||||||
|
async function saveFromMenu(scope) {
|
||||||
|
menuOpen = false;
|
||||||
|
await saveStream(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transport cluster and the extras are content-sized, and neither
|
||||||
|
// changes width while the bar expands — so ONE measurement each is enough
|
||||||
|
// and this observer fires at mount and on a real content change (the Save
|
||||||
|
// items appearing, the volume glyph swapping), never per animation frame.
|
||||||
|
let barEl = $state(null);
|
||||||
|
let btnEl = $state(null);
|
||||||
|
let extrasEl = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (!barEl || !btnEl || !extrasEl) return;
|
||||||
|
const apply = () => {
|
||||||
|
barEl.style.setProperty("--btn-w", `${Math.round(btnEl.offsetWidth)}px`);
|
||||||
|
barEl.style.setProperty("--extras-w", `${Math.round(extrasEl.offsetWidth)}px`);
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const ro = new ResizeObserver(apply);
|
||||||
|
ro.observe(btnEl);
|
||||||
|
ro.observe(extrasEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
let artUrl = $state(null);
|
let artUrl = $state(null);
|
||||||
let volTrack = $state(null);
|
let volTrack = $state(null);
|
||||||
let draggingVolume = $state(false);
|
let draggingVolume = $state(false);
|
||||||
@@ -276,7 +309,6 @@
|
|||||||
* lanes fade to the same grey. A canvas fill cannot use the CSS var;
|
* lanes fade to the same grey. A canvas fill cannot use the CSS var;
|
||||||
* if the token ever changes, change this with it. */
|
* if the token ever changes, change this with it. */
|
||||||
const UNPLAYED = "#7a7a7a";
|
const UNPLAYED = "#7a7a7a";
|
||||||
const PREVIEW = "rgba(255,255,255,0.66)";
|
|
||||||
const BAR_GAP = BAR_PITCH - BAR_W;
|
const BAR_GAP = BAR_PITCH - BAR_W;
|
||||||
|
|
||||||
// The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one
|
// The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one
|
||||||
@@ -290,23 +322,150 @@
|
|||||||
// one dark notch stranded between the accent and the wash. So under a wash
|
// one dark notch stranded between the accent and the wash. So under a wash
|
||||||
// it fades accent → the wash's own resolved colour instead.
|
// it fades accent → the wash's own resolved colour instead.
|
||||||
const MUTED_RGB = [122, 122, 122];
|
const MUTED_RGB = [122, 122, 122];
|
||||||
/** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed:
|
/** 66% white composited over an UNPLAYED bar, precomputed:
|
||||||
* 0.66·255 + 0.34·122 — what a washed bar actually renders as. */
|
* 0.66·255 + 0.34·122 — what a fully washed bar renders as. Bars now
|
||||||
|
* cross-fade between MUTED_RGB and this rather than being overpainted
|
||||||
|
* with a translucent fill, so the pair has to stay in sync by hand. */
|
||||||
const WASHED_RGB = [210, 210, 210];
|
const WASHED_RGB = [210, 210, 210];
|
||||||
|
const WHITE_RGB = [255, 255, 255];
|
||||||
|
const easeOut3 = (t) => 1 - Math.pow(1 - t, 3);
|
||||||
|
function mixRgb(a, b, t) {
|
||||||
|
return [
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||||
|
];
|
||||||
|
}
|
||||||
let blendParsedFor = "";
|
let blendParsedFor = "";
|
||||||
let blendRgb = MUTED_RGB;
|
let blendRgb = MUTED_RGB;
|
||||||
function blendAccent(accent, fill, towardWash) {
|
/** `wash` is 0..1 — how washed this bar's NEIGHBOURS currently are, not a
|
||||||
|
* boolean any more: with the per-bar crossfade the bars to its right pass
|
||||||
|
* continuously through every value between grey and wash, and a boolean
|
||||||
|
* target snapped this one bar a step ahead of them. */
|
||||||
|
function blendAccentRgb(accent, fill, wash) {
|
||||||
if (accent !== blendParsedFor) {
|
if (accent !== blendParsedFor) {
|
||||||
const n = parseInt(accent.slice(1), 16);
|
const n = parseInt(accent.slice(1), 16);
|
||||||
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
blendParsedFor = accent;
|
blendParsedFor = accent;
|
||||||
}
|
}
|
||||||
const toward = towardWash ? WASHED_RGB : MUTED_RGB;
|
return mixRgb(blendRgb, mixRgb(MUTED_RGB, WASHED_RGB, wash), 1 - fill);
|
||||||
const t = 1 - fill;
|
}
|
||||||
const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t);
|
function blendAccent(accent, fill, wash) {
|
||||||
const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t);
|
const c = blendAccentRgb(accent, fill, wash);
|
||||||
const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t);
|
return `rgb(${c[0]},${c[1]},${c[2]})`;
|
||||||
return `rgb(${r},${g},${b})`;
|
}
|
||||||
|
/** theme.accent as [r,g,b]; shares blendAccentRgb's one-shot parse. */
|
||||||
|
function accentRgb() {
|
||||||
|
blendAccentRgb(theme.accent, 1, 0);
|
||||||
|
return blendRgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the hover marker and wash ----------------------------------------
|
||||||
|
// Chosen with Kyle on research/playbar-redesign/hover-playhead.html
|
||||||
|
// (2026-08-04), variants A4 + B1 at 400ms, spread 9 bars.
|
||||||
|
//
|
||||||
|
// The pink 1px tick is GONE. The cursor is marked by the bar underneath it:
|
||||||
|
// that bar lifts while its neighbours dip away (A4 "stands proud"), so the
|
||||||
|
// signal is a silhouette and not only a colour. Growing the bar alone does
|
||||||
|
// not work — on a normal master the median bar already fills ~89% of the
|
||||||
|
// 24px lane, so ~87% of bars have no headroom to grow into and simply clip
|
||||||
|
// flat; the dip is what actually carries it at every loudness.
|
||||||
|
//
|
||||||
|
// The wash fades ONLY on enter and on leave (Kyle, 2026-08-04, watching the
|
||||||
|
// first build). B1's per-bar clocks were literally per bar, so moving the
|
||||||
|
// pointer left the wash edge easing along behind it — a white fill visibly
|
||||||
|
// trailing the cursor. Span membership is therefore instant: which bars are
|
||||||
|
// washed follows the pointer exactly, and the single thing that animates is
|
||||||
|
// how present the whole highlight is. One gesture, one ramp.
|
||||||
|
const HOVER_MS = 400;
|
||||||
|
/** Bars either side of the cursor that the marker touches — 9 across. */
|
||||||
|
const MARKER_SPREAD = 4;
|
||||||
|
const MARKER_LIFT = 0.3; // cursor bar, as a fraction of its own height
|
||||||
|
const MARKER_DIP = 0.34; // neighbours, scaled by the falloff
|
||||||
|
/** Gaussian σ, so the cluster has no hard edge at ±MARKER_SPREAD. */
|
||||||
|
const MARKER_SIGMA = MARKER_SPREAD / 1.7;
|
||||||
|
let hoverAlive = 0; // 0..1 presence of the whole highlight, the ONE ramp
|
||||||
|
let hoverLastX = -1; // remembered so the marker fades out where it was
|
||||||
|
/** Last bar of the wash span. Frozen on leave so the wash fades out over
|
||||||
|
* the span it had, instead of vanishing the instant the pointer goes. */
|
||||||
|
let hoverSpanEnd = -1;
|
||||||
|
let hoverTickAt = 0;
|
||||||
|
let hoverActive = false; // any wash or marker on screen — see tickHover
|
||||||
|
|
||||||
|
function resetHover() {
|
||||||
|
hoverAlive = 0;
|
||||||
|
hoverLastX = -1;
|
||||||
|
hoverSpanEnd = -1;
|
||||||
|
hoverTickAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance the presence ramp one frame. Returns true only while that ramp
|
||||||
|
* is running — moving the pointer inside the lane changes the span, not
|
||||||
|
* the ramp, so a plain mouse move costs no animation frames at all.
|
||||||
|
* Position events arrive ~5/s and cannot carry a 400ms fade on their own. */
|
||||||
|
function tickHover(now, washEndBar, hovering) {
|
||||||
|
// Real elapsed time, capped: a backgrounded window or a long GC pause
|
||||||
|
// would otherwise hand us one enormous step and snap the fade.
|
||||||
|
const dt = hoverTickAt ? Math.min(64, now - hoverTickAt) : 16.7;
|
||||||
|
hoverTickAt = now;
|
||||||
|
const step = dt / HOVER_MS;
|
||||||
|
let moving = false;
|
||||||
|
|
||||||
|
const want = hovering ? 1 : 0;
|
||||||
|
if (hoverAlive !== want) {
|
||||||
|
hoverAlive = Math.max(0, Math.min(1, hoverAlive + (hovering ? step : -step)));
|
||||||
|
if (Math.abs(hoverAlive - want) < 0.002) hoverAlive = want;
|
||||||
|
moving = true;
|
||||||
|
}
|
||||||
|
// Tracks the pointer exactly while hovering; held through the fade-out.
|
||||||
|
if (hovering) hoverSpanEnd = washEndBar;
|
||||||
|
// Whether the lane still needs the per-bar painter at all. Once this is
|
||||||
|
// false the settled lane is three flat regions again and goes back to
|
||||||
|
// the cached Path2D clip-fills.
|
||||||
|
hoverActive = hovering || hoverAlive > 0.001;
|
||||||
|
if (!hoverActive) hoverSpanEnd = -1;
|
||||||
|
return moving;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How washed bar `i` is, 0..1. Membership is a hard test against the live
|
||||||
|
* span; the only continuous term is the shared presence ramp. */
|
||||||
|
function washAt(i, full) {
|
||||||
|
return i > full && i <= hoverSpanEnd ? easeOut3(hoverAlive) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which bar the marker sits on, or -1. Held through the fade-out so the
|
||||||
|
* marker dies where it was rather than jumping to the lane's edge. */
|
||||||
|
let hoverMarkerBar = -1;
|
||||||
|
|
||||||
|
/** One lane bar, with the wash crossfade and the cursor marker applied.
|
||||||
|
* Every painter goes through here so the rise, the arrival morph and the
|
||||||
|
* settled lane cannot disagree about what a hovered bar looks like. */
|
||||||
|
function paintLaneBar(ctx, i, mid, h, full, playedBars, laneH) {
|
||||||
|
let col;
|
||||||
|
if (i < full) col = accentRgb();
|
||||||
|
else if (i === full && playedBars - full > 0) {
|
||||||
|
// The boundary bar fades toward whatever its RIGHT neighbour is
|
||||||
|
// currently showing, which during the ramp is a moving value.
|
||||||
|
col = blendAccentRgb(theme.accent, playedBars - full, washAt(i + 1, full));
|
||||||
|
} else col = mixRgb(MUTED_RGB, WASHED_RGB, washAt(i, full));
|
||||||
|
|
||||||
|
let scale = 1;
|
||||||
|
if (hoverMarkerBar >= 0 && hoverAlive > 0.001) {
|
||||||
|
const d = Math.abs(i - hoverMarkerBar);
|
||||||
|
if (d <= MARKER_SPREAD) {
|
||||||
|
const alive = easeOut3(hoverAlive);
|
||||||
|
const f = Math.exp(-(d * d) / (2 * MARKER_SIGMA * MARKER_SIGMA));
|
||||||
|
const k = f * alive;
|
||||||
|
if (k > 0.004) {
|
||||||
|
col = mixRgb(col, WHITE_RGB, k);
|
||||||
|
scale = d === 0 ? 1 + MARKER_LIFT * alive : 1 - MARKER_DIP * f * alive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Clamped to the lane: an unclamped lift does not grow a loud bar, it
|
||||||
|
// clips it flat against the canvas edge, which reads as damage.
|
||||||
|
paintBar(ctx, i * BAR_PITCH, mid, Math.min(laneH, h * scale), `rgb(${col[0]},${col[1]},${col[2]})`);
|
||||||
|
return Math.min(laneH, h * scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- loading / streaming motion ---------------------------------------
|
// ---- loading / streaming motion ---------------------------------------
|
||||||
@@ -401,25 +560,13 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The fade, one bar at a time — shared by the rise and the arrival
|
|
||||||
* morph, whose heights differ every frame and cannot use the grouped
|
|
||||||
* clip-fills of the settled painter. */
|
|
||||||
function barColor(i, full, playedBars, washing, washEndBar) {
|
|
||||||
if (i < full) return theme.accent;
|
|
||||||
if (i === full && playedBars - full > 0) {
|
|
||||||
return blendAccent(theme.accent, playedBars - full, washing);
|
|
||||||
}
|
|
||||||
if (washing && i > full && i <= washEndBar) return `rgb(${WASHED_RGB.join(",")})`;
|
|
||||||
return UNPLAYED;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The streaming rise. Released bars ease from the floor to their height;
|
/** The streaming rise. Released bars ease from the floor to their height;
|
||||||
* queued and beyond-frontier bars are pending dots. The colour pass is
|
* queued and beyond-frontier bars are pending dots. The colour pass is
|
||||||
* the full painter's fade — accent, boundary blend, wash, muted — done
|
* the full painter's fade — accent, boundary blend, wash, muted — done
|
||||||
* per bar because the heights differ every frame while bars grow. The
|
* per bar because the heights differ every frame while bars grow. The
|
||||||
* partial waveform describes only the audio that has ARRIVED, so it is
|
* partial waveform describes only the audio that has ARRIVED, so it is
|
||||||
* folded across the covered fraction of the lane, not the whole width. */
|
* folded across the covered fraction of the lane, not the whole width. */
|
||||||
function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) {
|
function drawRise(ctx, W, H, mid, count, now, frontier, playedBars) {
|
||||||
const coveredBars = Math.max(1, frontier);
|
const coveredBars = Math.max(1, frontier);
|
||||||
if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) {
|
if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) {
|
||||||
riseLevels = waveLevels(player.waveform, coveredBars);
|
riseLevels = waveLevels(player.waveform, coveredBars);
|
||||||
@@ -427,9 +574,6 @@
|
|||||||
riseLevelsCount = coveredBars;
|
riseLevelsCount = coveredBars;
|
||||||
}
|
}
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
const playX = playFrac * W;
|
|
||||||
const washing = hoverX > playX;
|
|
||||||
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
|
|
||||||
const full = Math.floor(playedBars);
|
const full = Math.floor(playedBars);
|
||||||
const heights = new Array(count);
|
const heights = new Array(count);
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -443,15 +587,9 @@
|
|||||||
const eased = 1 - Math.pow(1 - grow, 3);
|
const eased = 1 - Math.pow(1 - grow, 3);
|
||||||
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
||||||
const h = 2 + (target - 2) * eased;
|
const h = 2 + (target - 2) * eased;
|
||||||
heights[i] = h;
|
heights[i] = paintLaneBar(ctx, i, mid, h, full, playedBars, H);
|
||||||
paintBar(ctx, x, mid, h, barColor(i, full, playedBars, washing, washEndBar));
|
|
||||||
}
|
}
|
||||||
lastDrawnHeights = heights;
|
lastDrawnHeights = heights;
|
||||||
if (hoverX >= 0) {
|
|
||||||
const LINE_H = 18;
|
|
||||||
ctx.fillStyle = theme.accentHover;
|
|
||||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmt(secs) {
|
function fmt(secs) {
|
||||||
@@ -643,6 +781,9 @@
|
|||||||
if (!peaks || peaks.length === 0) {
|
if (!peaks || peaks.length === 0) {
|
||||||
riseKey = null;
|
riseKey = null;
|
||||||
morphFrom = null;
|
morphFrom = null;
|
||||||
|
// Nothing to wash or mark. Cleared rather than left to decay so a
|
||||||
|
// waveform arriving later does not inherit a stale half-fade.
|
||||||
|
resetHover();
|
||||||
if (hadPeaks) {
|
if (hadPeaks) {
|
||||||
holdUntil = now + ARRIVE_HOLD_MS;
|
holdUntil = now + ARRIVE_HOLD_MS;
|
||||||
hadPeaks = false;
|
hadPeaks = false;
|
||||||
@@ -669,6 +810,22 @@
|
|||||||
}
|
}
|
||||||
hadPeaks = true;
|
hadPeaks = true;
|
||||||
|
|
||||||
|
// ---- the hover crossfade, ticked exactly once per frame ------------
|
||||||
|
// Ahead of every painter and before the early-out below, because all
|
||||||
|
// three paint the marker and the fade has to advance on frames the
|
||||||
|
// playhead alone would have skipped.
|
||||||
|
const hovering = hoverX >= 0;
|
||||||
|
if (hovering) hoverLastX = hoverX;
|
||||||
|
const hoverFrac = player.duration
|
||||||
|
? Math.max(0, Math.min(1, player.position / player.duration))
|
||||||
|
: 0;
|
||||||
|
const hoverEnd = hovering && hoverX > hoverFrac * W ? Math.floor(hoverX / BAR_PITCH) : -1;
|
||||||
|
const hoverMoving = tickHover(now, hoverEnd, hovering);
|
||||||
|
hoverMarkerBar =
|
||||||
|
hoverLastX >= 0 && (hovering || hoverAlive > 0.001)
|
||||||
|
? Math.floor(hoverLastX / BAR_PITCH)
|
||||||
|
: -1;
|
||||||
|
|
||||||
// A stream whose waveform does not yet cover the whole track (or one
|
// A stream whose waveform does not yet cover the whole track (or one
|
||||||
// still standing its last bars up): the rise owns the lane. The
|
// still standing its last bars up): the rise owns the lane. The
|
||||||
// frontier is the WAVEFORM'S OWN coverage — the audio that has
|
// frontier is the WAVEFORM'S OWN coverage — the audio that has
|
||||||
@@ -718,13 +875,13 @@
|
|||||||
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
||||||
if (waveCov < 1 || riseReleased < count || growing) {
|
if (waveCov < 1 || riseReleased < count || growing) {
|
||||||
morphFrom = null; // the rise owns the lane's motion
|
morphFrom = null; // the rise owns the lane's motion
|
||||||
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
|
drawRise(ctx, W, H, mid, count, now, frontier, playedBarsNow);
|
||||||
lastPlayedBars = playedBarsNow;
|
lastPlayedBars = playedBarsNow;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
lastAccent = theme.accent;
|
lastAccent = theme.accent;
|
||||||
lastAccentHover = theme.accentHover;
|
lastAccentHover = theme.accentHover;
|
||||||
lastDuration = player.duration;
|
lastDuration = player.duration;
|
||||||
scheduleAnim(growing || riseReleased < frontier);
|
scheduleAnim(growing || riseReleased < frontier || hoverMoving);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Fully covered, fully grown: hand the lane back to the cached
|
// Fully covered, fully grown: hand the lane back to the cached
|
||||||
@@ -774,7 +931,6 @@
|
|||||||
const playFrac = player.duration
|
const playFrac = player.duration
|
||||||
? Math.max(0, Math.min(1, player.position / player.duration))
|
? Math.max(0, Math.min(1, player.position / player.duration))
|
||||||
: 0;
|
: 0;
|
||||||
const playX = playFrac * W;
|
|
||||||
const playedBars = playFrac * waveCount;
|
const playedBars = playFrac * waveCount;
|
||||||
|
|
||||||
// Mid-arrival: every bar eases from where it was to where the new
|
// Mid-arrival: every bar eases from where it was to where the new
|
||||||
@@ -786,22 +942,14 @@
|
|||||||
} else {
|
} else {
|
||||||
const k = 1 - Math.pow(1 - t, 3);
|
const k = 1 - Math.pow(1 - t, 3);
|
||||||
const full = Math.floor(playedBars);
|
const full = Math.floor(playedBars);
|
||||||
const washing = hoverX > playX;
|
|
||||||
const washEndBar = washing ? Math.floor(hoverX / BAR_PITCH) : -1;
|
|
||||||
ctx.clearRect(0, 0, W, H);
|
ctx.clearRect(0, 0, W, H);
|
||||||
const heights = new Array(waveCount);
|
const heights = new Array(waveCount);
|
||||||
for (let i = 0; i < waveCount; i++) {
|
for (let i = 0; i < waveCount; i++) {
|
||||||
const from = morphFrom[i] ?? 2;
|
const from = morphFrom[i] ?? 2;
|
||||||
const h = from + (waveHeights[i] - from) * k;
|
const h = from + (waveHeights[i] - from) * k;
|
||||||
heights[i] = h;
|
heights[i] = paintLaneBar(ctx, i, mid, h, full, playedBars, H);
|
||||||
paintBar(ctx, i * BAR_PITCH, mid, h, barColor(i, full, playedBars, washing, washEndBar));
|
|
||||||
}
|
}
|
||||||
lastDrawnHeights = heights;
|
lastDrawnHeights = heights;
|
||||||
if (hoverX >= 0) {
|
|
||||||
const LINE_H = 18;
|
|
||||||
ctx.fillStyle = theme.accentHover;
|
|
||||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
|
||||||
}
|
|
||||||
lastPlayedBars = playedBars;
|
lastPlayedBars = playedBars;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
lastAccent = theme.accent;
|
lastAccent = theme.accent;
|
||||||
@@ -814,6 +962,7 @@
|
|||||||
|
|
||||||
const visualsChanged =
|
const visualsChanged =
|
||||||
geometryChanged ||
|
geometryChanged ||
|
||||||
|
hoverMoving ||
|
||||||
hoverX !== lastHoverDrawn ||
|
hoverX !== lastHoverDrawn ||
|
||||||
theme.accent !== lastAccent ||
|
theme.accent !== lastAccent ||
|
||||||
theme.accentHover !== lastAccentHover ||
|
theme.accentHover !== lastAccentHover ||
|
||||||
@@ -849,40 +998,44 @@
|
|||||||
// matches the primary Play button and the sidebar's completed bars;
|
// matches the primary Play button and the sidebar's completed bars;
|
||||||
// `accentHover` remains reserved for hover previews and controls.
|
// `accentHover` remains reserved for hover previews and controls.
|
||||||
const full = Math.floor(playedBars);
|
const full = Math.floor(playedBars);
|
||||||
const washing = hoverX > playX;
|
|
||||||
fillClip(0, W, UNPLAYED);
|
// Under the pointer every bar carries its own wash value and the marker
|
||||||
if (washing) {
|
// cluster its own heights, so the grouped clip-fills cannot express the
|
||||||
// Bar-quantized for the same reason: the wash recolours whole bars
|
// lane — paint it a bar at a time, exactly as the rise and the morph do.
|
||||||
// between the playhead's bar and the cursor, never a sliver.
|
// This is the only state that pays for it: with no pointer on the lane
|
||||||
const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH;
|
// the picture is three flat regions and the cached Path2D wins.
|
||||||
fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW);
|
if (hoverActive) {
|
||||||
}
|
const heights = new Array(waveCount);
|
||||||
fillClip(0, full * BAR_PITCH, theme.accent);
|
for (let i = 0; i < waveCount; i++) {
|
||||||
const frac = playedBars - full;
|
heights[i] = paintLaneBar(ctx, i, mid, waveHeights[i], full, playedBars, H);
|
||||||
if (frac > 0 && full < waveCount) {
|
}
|
||||||
const h = waveHeights[full];
|
lastDrawnHeights = heights;
|
||||||
ctx.fillStyle = blendAccent(theme.accent, frac, washing);
|
} else {
|
||||||
ctx.beginPath();
|
fillClip(0, W, UNPLAYED);
|
||||||
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
fillClip(0, full * BAR_PITCH, theme.accent);
|
||||||
ctx.fill();
|
const frac = playedBars - full;
|
||||||
|
if (frac > 0 && full < waveCount) {
|
||||||
|
const h = waveHeights[full];
|
||||||
|
ctx.fillStyle = blendAccent(theme.accent, frac, 0);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
lastDrawnHeights = waveHeights;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No standing playhead rule any more: at this pitch the accent's own
|
// No standing playhead rule, and no hover tick either: the 1px
|
||||||
// leading edge marks the position, and a white line through the bars
|
// accent-hover line that used to say "a click lands here" is replaced by
|
||||||
// read as a seam. The hover tick stays — it is the only thing that
|
// the marker inside paintLaneBar — the bar itself lifts and whitens.
|
||||||
// says where a click would land.
|
|
||||||
if (hoverX >= 0) {
|
|
||||||
const LINE_H = 18;
|
|
||||||
ctx.fillStyle = theme.accentHover;
|
|
||||||
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastDrawnHeights = waveHeights;
|
|
||||||
lastPlayedBars = playedBars;
|
lastPlayedBars = playedBars;
|
||||||
lastHoverDrawn = hoverX;
|
lastHoverDrawn = hoverX;
|
||||||
lastAccent = theme.accent;
|
lastAccent = theme.accent;
|
||||||
lastAccentHover = theme.accentHover;
|
lastAccentHover = theme.accentHover;
|
||||||
lastDuration = player.duration;
|
lastDuration = player.duration;
|
||||||
|
// The settled lane is otherwise repainted only by position events
|
||||||
|
// (~5/s), which cannot carry a 400ms fade — keep frames coming until
|
||||||
|
// the crossfade and the marker have both landed.
|
||||||
|
scheduleAnim(hoverMoving);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redraw whenever the waveform, position, hover, or accent changes
|
// Redraw whenever the waveform, position, hover, or accent changes
|
||||||
@@ -906,7 +1059,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer class="player">
|
<footer class="player" class:expanded={ui.playerExpanded} bind:this={barEl}>
|
||||||
<div class="now-playing">
|
<div class="now-playing">
|
||||||
{#if artUrl}
|
{#if artUrl}
|
||||||
<img class="art cover" src={artUrl} alt="" draggable="false" />
|
<img class="art cover" src={artUrl} alt="" draggable="false" />
|
||||||
@@ -963,7 +1116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons" bind:this={btnEl}>
|
||||||
<button
|
<button
|
||||||
class="ctl small"
|
class="ctl small"
|
||||||
class:on={player.shuffle}
|
class:on={player.shuffle}
|
||||||
@@ -1024,8 +1177,21 @@
|
|||||||
<!-- The now-playing panel toggle used to live here. It is gone: the panel
|
<!-- The now-playing panel toggle used to live here. It is gone: the panel
|
||||||
carries its own collapse control, and two ways to do one thing is one
|
carries its own collapse control, and two ways to do one thing is one
|
||||||
too many. -->
|
too many. -->
|
||||||
<div class="extras">
|
<div class="extras" bind:this={extrasEl}>
|
||||||
<SaveMenu align="up" />
|
<!-- The bar's own expand control, in the slot the Save button used to
|
||||||
|
hold. Not a new component: this is the sidebar's `.gicon` — 28x28,
|
||||||
|
radius 6, a 16px chevron on the shared curve — because the app
|
||||||
|
already has one way to say "this panel opens and closes". It
|
||||||
|
points UP collapsed, since that is the direction the bar grows.
|
||||||
|
Save to library moved into the ⋯ menu below, and still sits in the
|
||||||
|
Now Playing panel where it always has. -->
|
||||||
|
<button
|
||||||
|
class="gicon"
|
||||||
|
class:down={ui.playerExpanded}
|
||||||
|
title={ui.playerExpanded ? "Collapse player" : "Expand player"}
|
||||||
|
aria-expanded={ui.playerExpanded}
|
||||||
|
onclick={togglePlayerExpanded}
|
||||||
|
><Icon name="chevron-left" size={16} /></button>
|
||||||
|
|
||||||
<!-- Everything past Save goes behind one control, so landing lyrics,
|
<!-- Everything past Save goes behind one control, so landing lyrics,
|
||||||
a mini player and output switching later costs no width. -->
|
a mini player and output switching later costs no width. -->
|
||||||
@@ -1045,6 +1211,20 @@
|
|||||||
<button role="menuitem" onclick={openQueue}>
|
<button role="menuitem" onclick={openQueue}>
|
||||||
<Icon name="playlist" size={15} /> Queue
|
<Icon name="playlist" size={15} /> Queue
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Save's new home. The bar's download button became the
|
||||||
|
expand toggle, and this menu is where everything past
|
||||||
|
Save was always going to live. Only a stream can be
|
||||||
|
saved, so the items appear only for one. -->
|
||||||
|
{#if canSave}
|
||||||
|
<button role="menuitem" onclick={() => saveFromMenu("track")}>
|
||||||
|
<Icon name="music" size={15} /> Save this track
|
||||||
|
</button>
|
||||||
|
{#if savingAlbum}
|
||||||
|
<button role="menuitem" onclick={() => saveFromMenu("album")}>
|
||||||
|
<Icon name="library" size={15} /> Save whole album
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<button role="menuitem" disabled title="Not built yet">
|
<button role="menuitem" disabled title="Not built yet">
|
||||||
<Icon name="lyrics" size={15} /> Lyrics <span class="soon">soon</span>
|
<Icon name="lyrics" size={15} /> Lyrics <span class="soon">soon</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1082,27 +1262,133 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ONE row. The transport, the scrubber and the meta share a baseline, and
|
/* FOUR blocks, absolutely placed, so the collapsed row and the expanded
|
||||||
the scrubber takes every pixel the others do not want — it no longer caps
|
stack are the same layout at two sets of coordinates and the bar can
|
||||||
at 560px while the flanks stretch, which is what left a wide window with
|
INTERPOLATE between them. Flex cannot: going from one row to a centred
|
||||||
a small waveform adrift in the middle.
|
two-row stack means changing `flex-direction`, which does not animate, so
|
||||||
|
the contents would snap into place while the height eased around them.
|
||||||
|
|
||||||
Nothing here is width-queried any more — the source slot was the only
|
The name and the extras never move vertically — `top: 50%` plus a -50%
|
||||||
container query, and it is gone — so there is no `container-type`. The
|
translate centres them at every height, in both states, with no rule per
|
||||||
row fills its grid area at every window size; the scrubber absorbs
|
state. Only the transport and the lane travel.
|
||||||
whatever the name, transport and extras do not take. */
|
|
||||||
|
Nothing here is width-queried — the source slot was the only container
|
||||||
|
query and it is gone — so there is no `container-type`. */
|
||||||
.player {
|
.player {
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
display: flex;
|
position: relative;
|
||||||
align-items: center;
|
overflow: hidden;
|
||||||
gap: 14px;
|
background: var(--bg-player);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
|
||||||
|
/* One clock for the whole gesture. Chosen on
|
||||||
|
research/playbar-redesign/expand-collapse.html: 350ms on
|
||||||
|
cubic-bezier(.22,1,.36,1), the softer landing. It pairs with the
|
||||||
|
grid-template-rows transition in App.svelte — change them together or
|
||||||
|
the bar's contents and its height stop arriving at the same moment. */
|
||||||
|
--pb-dur: 350ms;
|
||||||
|
--pb-ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
|
||||||
/* 12px, not 16: with the 8px app margin outside it that puts the cover's
|
/* 12px, not 16: with the 8px app margin outside it that puts the cover's
|
||||||
left edge at x=20 and its centre at x=40 — the column every sidebar
|
left edge at x=20 and its centre at x=40 — the column every sidebar
|
||||||
cover and nav icon already shares, and which the sidebar holds
|
cover and nav icon already shares, and which the sidebar holds
|
||||||
through its entire collapse. See the note in Sidebar.svelte. */
|
through its entire collapse. See the note in Sidebar.svelte. */
|
||||||
padding: 0 12px;
|
--pb-pad: 12px;
|
||||||
background: var(--bg-player);
|
--pb-gap: 14px;
|
||||||
border-radius: var(--radius);
|
/* 40 art + 11 gap + 240 name box. All three are fixed by design, so the
|
||||||
|
collapsed transport offset is a constant and needs no measurement. */
|
||||||
|
--np-w: 291px;
|
||||||
|
/* Measured once each — see the observer in the script. The defaults are
|
||||||
|
the real values, so the very first paint is already correct. */
|
||||||
|
--btn-w: 158px;
|
||||||
|
--extras-w: 170px;
|
||||||
|
|
||||||
|
/* The stack, expanded: 34 transport + 10 gap + 24 lane = 68. Each block
|
||||||
|
is offset from the bar's CENTRE by half the stack minus its own half,
|
||||||
|
which is what keeps the pair centred at any bar height. */
|
||||||
|
--pb-stack-gap: 10px;
|
||||||
|
--pb-btn-dy: calc((var(--pb-stack-gap) + 24px) / -2);
|
||||||
|
--pb-lane-dy: calc((var(--pb-stack-gap) + 34px) / 2);
|
||||||
|
/* Expanded, the lane is inset by the LARGER flank on both sides, so it
|
||||||
|
is genuinely centred in the bar rather than merely filling what the
|
||||||
|
name and the extras left over. */
|
||||||
|
--pb-lane-inset: max(
|
||||||
|
calc(var(--pb-pad) + var(--np-w) + var(--pb-gap)),
|
||||||
|
calc(var(--pb-pad) + var(--extras-w) + var(--pb-gap))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player > .now-playing,
|
||||||
|
.player > .extras {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.player > .now-playing {
|
||||||
|
left: var(--pb-pad);
|
||||||
|
}
|
||||||
|
.player > .extras {
|
||||||
|
right: var(--pb-pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two that travel. Both use the same 2-argument translate in both
|
||||||
|
states so the transforms interpolate component-wise instead of falling
|
||||||
|
back to a matrix. */
|
||||||
|
.player > .buttons,
|
||||||
|
.player > .scrubber {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transition:
|
||||||
|
left var(--pb-dur) var(--pb-ease),
|
||||||
|
right var(--pb-dur) var(--pb-ease),
|
||||||
|
transform var(--pb-dur) var(--pb-ease);
|
||||||
|
}
|
||||||
|
.player > .buttons {
|
||||||
|
left: calc(var(--pb-pad) + var(--np-w) + var(--pb-gap));
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
.player > .scrubber {
|
||||||
|
left: calc(
|
||||||
|
var(--pb-pad) + var(--np-w) + var(--pb-gap) + var(--btn-w) + var(--pb-gap)
|
||||||
|
);
|
||||||
|
right: calc(var(--pb-pad) + var(--extras-w) + var(--pb-gap));
|
||||||
|
transform: translate(0, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expanded: transport dead centre, lane centred on its own row beneath. */
|
||||||
|
.player.expanded > .buttons {
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, calc(-50% + var(--pb-btn-dy)));
|
||||||
|
}
|
||||||
|
.player.expanded > .scrubber {
|
||||||
|
left: var(--pb-lane-inset);
|
||||||
|
right: var(--pb-lane-inset);
|
||||||
|
transform: translate(0, calc(-50% + var(--pb-lane-dy)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The expand toggle, in the slot the download button held. Copied from
|
||||||
|
Sidebar.svelte's `.gicon` — same box, same glyph, same curve — because
|
||||||
|
the app already has one way to say "this panel opens and closes".
|
||||||
|
Rotated to point UP, which is the direction the bar grows. */
|
||||||
|
.gicon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
transition:
|
||||||
|
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
background 120ms,
|
||||||
|
color 120ms;
|
||||||
|
}
|
||||||
|
.gicon:hover {
|
||||||
|
background: var(--bg-highlight);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.gicon.down {
|
||||||
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FIXED width, deliberately: the name box is a constant (picked on
|
/* FIXED width, deliberately: the name box is a constant (picked on
|
||||||
@@ -1169,15 +1455,21 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
.namebox.maskR {
|
/* `:global()` on the mask class, and ONLY on it. These three are applied by
|
||||||
|
nameGlide with `classList.toggle`, which the compiler cannot see — so it
|
||||||
|
called them unused and DELETED them, and the fade had never once rendered
|
||||||
|
since it was written. The warning was a deletion notice, not advice.
|
||||||
|
`.namebox` stays scoped, so this is still local to the player bar; only
|
||||||
|
the runtime-applied half is exempted from the usage check. */
|
||||||
|
.namebox:global(.maskR) {
|
||||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
|
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
|
||||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
|
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 22px), transparent);
|
||||||
}
|
}
|
||||||
.namebox.maskL {
|
.namebox:global(.maskL) {
|
||||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
|
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
|
||||||
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
|
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 100%);
|
||||||
}
|
}
|
||||||
.namebox.maskLR {
|
.namebox:global(.maskLR) {
|
||||||
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
|
-webkit-mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
|
||||||
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
|
mask-image: linear-gradient(90deg, transparent 0, #000 22px, #000 calc(100% - 22px), transparent);
|
||||||
}
|
}
|
||||||
@@ -1303,11 +1595,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex: 1 1 auto;
|
|
||||||
/* Two 34px times plus the 10px gaps come out of this, so 260 is what
|
|
||||||
keeps ~170px of actual waveform at the app's 960px minimum window. */
|
|
||||||
min-width: 260px;
|
|
||||||
height: 24px;
|
height: 24px;
|
||||||
|
/* No min-width any more. It existed to stop a flex item being squeezed;
|
||||||
|
the lane's left and right edges are now stated outright, so a floor
|
||||||
|
could only fight them — at the 960px minimum the collapsed lane works
|
||||||
|
out at 259px, and a 260px floor would have pushed it past the bar's
|
||||||
|
right edge rather than protecting anything. */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.time {
|
||||||
|
|||||||
+61
-2
@@ -43,6 +43,46 @@ function readPanelWidth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every panel-shape flag, remembered across a reload AND a restart. The
|
||||||
|
* reasoning that already persists `nowPlayingWidth` applies to all of them: a
|
||||||
|
* shape you set by hand and lost on every launch is worse than not being able
|
||||||
|
* to set it. One key holding all three, so the three toggles cannot end up
|
||||||
|
* half-migrated against each other. */
|
||||||
|
const PANEL_KEY = "soulstream.panels.v1";
|
||||||
|
const PANEL_DEFAULTS = { nowPlaying: true, sidebarCollapsed: false, playerExpanded: false };
|
||||||
|
|
||||||
|
function readPanels() {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(PANEL_KEY) || "null");
|
||||||
|
if (!raw || typeof raw !== "object") return { ...PANEL_DEFAULTS };
|
||||||
|
const out = { ...PANEL_DEFAULTS };
|
||||||
|
// Per-key, so a stored blob written by an older build (or a hand-edited
|
||||||
|
// one) contributes the flags it does have and defaults the rest.
|
||||||
|
for (const k of Object.keys(PANEL_DEFAULTS)) {
|
||||||
|
if (typeof raw[k] === "boolean") out[k] = raw[k];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return { ...PANEL_DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const panels0 = readPanels();
|
||||||
|
|
||||||
|
function writePanels() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
PANEL_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
nowPlaying: ui.nowPlaying,
|
||||||
|
sidebarCollapsed: ui.sidebarCollapsed,
|
||||||
|
playerExpanded: ui.playerExpanded,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// A blocked storage quota must not break the toggle for the session.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const ui = $state({
|
export const ui = $state({
|
||||||
view: "home",
|
view: "home",
|
||||||
albumId: null, // selected album in Library view
|
albumId: null, // selected album in Library view
|
||||||
@@ -50,8 +90,11 @@ export const ui = $state({
|
|||||||
// The Now Playing panel is EXPANDED vs COLLAPSED, never gone: collapsing it
|
// The Now Playing panel is EXPANDED vs COLLAPSED, never gone: collapsing it
|
||||||
// leaves the rail (see NowPlaying.svelte), the same way the left nav
|
// leaves the rail (see NowPlaying.svelte), the same way the left nav
|
||||||
// collapses to an icon rail rather than disappearing.
|
// collapses to an icon rail rather than disappearing.
|
||||||
nowPlaying: true,
|
nowPlaying: panels0.nowPlaying,
|
||||||
sidebarCollapsed: false, // left nav collapsed to an icon rail
|
sidebarCollapsed: panels0.sidebarCollapsed, // left nav collapsed to an icon rail
|
||||||
|
// The player bar grows UPWARD into the content — the transport centres and
|
||||||
|
// the lane drops to its own row beneath it. See PlayerBar.svelte.
|
||||||
|
playerExpanded: panels0.playerExpanded,
|
||||||
// User-dragged width of the expanded panel. Persisted, unlike the other ui
|
// 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
|
// flags: a width you set by hand and lost on every launch would be worse
|
||||||
// than not being able to set it at all.
|
// than not being able to set it at all.
|
||||||
@@ -72,6 +115,21 @@ export const ui = $state({
|
|||||||
|
|
||||||
export function toggleNowPlaying() {
|
export function toggleNowPlaying() {
|
||||||
ui.nowPlaying = !ui.nowPlaying;
|
ui.nowPlaying = !ui.nowPlaying;
|
||||||
|
writePanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reveal the panel (the queue lives in it). Goes through here rather than
|
||||||
|
* assigning `ui.nowPlaying` directly so the shape is still remembered. */
|
||||||
|
export function showNowPlaying() {
|
||||||
|
if (ui.nowPlaying) return;
|
||||||
|
ui.nowPlaying = true;
|
||||||
|
writePanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grow the player bar upward, or put it back. */
|
||||||
|
export function togglePlayerExpanded() {
|
||||||
|
ui.playerExpanded = !ui.playerExpanded;
|
||||||
|
writePanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set the expanded panel's width, clamped and remembered. */
|
/** Set the expanded panel's width, clamped and remembered. */
|
||||||
@@ -87,6 +145,7 @@ export function setNowPlayingWidth(px) {
|
|||||||
|
|
||||||
export function toggleSidebar() {
|
export function toggleSidebar() {
|
||||||
ui.sidebarCollapsed = !ui.sidebarCollapsed;
|
ui.sidebarCollapsed = !ui.sidebarCollapsed;
|
||||||
|
writePanels();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const libraryStore = $state({
|
export const libraryStore = $state({
|
||||||
|
|||||||
Reference in new issue
Block a user