forked from Keelrleek/SoulStream
The source chips are gone — the rail already tells that story — and with them the measured slot, its effects, and the bar's only container query. The now-playing cluster hugs the song name with no cap, giving ground only once the scrubber hits its 260px floor; the artist's 55% ceiling applies only when an album sits beside it, so a lone loading artist no longer renders as two letters in a twelve-pixel box. The waveform is rebuilt on honesty. WAVE_BUCKETS rises 400 → 2000 (~128ms per bucket — at 400, every display bar was ~2s of audio averaged twice), and one shared shaping in waveshape.js replaces both lanes' gain-into-clamp rule, which flat-topped 53.8% of the bars on a real decode and was propped up by per-bar noise in the rail — deleted; the lane shows the record, not a hash of the bar index. Bars fold with a mean+peak blend and stretch across the track's own p05–p97, so a loud master stops pinning to the ceiling and a quiet one stops vanishing. The playbar copies the rail's fade verbatim: whole bars blend accent→grey with cuts landing in the 4px gap, and the one bar under the playhead crossfades toward whatever its neighbours are — the wash while hovering, the muted grey otherwise. The redraw gate watches that blend instead of a 0.75px playhead move, so both lanes now step on every position event. Loading is drawn, not implied: the solid fallback bar is gone and the lane is always the bars, always seekable. Empty, it rests as the rail's dots; a swell crosses them while a waveform is computed (2s sweep, 0.5s rest); a streaming track's bars stand up left→right on a 40ms cadence, each rising over 2s. The rise frontier is the waveform's OWN coverage, reported by stream_waveform beside the peaks — the old contract zero-padded partial peaks to full length and let the UI fold them against a fresher byte count, which stretched the whole shape between refreshes. Where the bars end is how much of the track has actually loaded; cached and local tracks arrive at coverage 1 and pop in complete. The first partial is also requested eagerly (fresh refresh clock per track, 700ms retries until peaks exist), closing most of the audio-before-lane gap.
1235 lines
45 KiB
Svelte
1235 lines
45 KiB
Svelte
<script>
|
||
import {
|
||
player,
|
||
theme,
|
||
ui,
|
||
nextTrack,
|
||
prevTrack,
|
||
toggleShuffle,
|
||
cycleRepeat,
|
||
openCurrentArtist,
|
||
openCurrentAlbum,
|
||
openCurrentSong,
|
||
getArt,
|
||
getCatalogArt,
|
||
playerArtSource,
|
||
resumePlayback,
|
||
} from "../lib/state.svelte.js";
|
||
import * as api from "../lib/api.js";
|
||
import {
|
||
BAR_PITCH,
|
||
BAR_W,
|
||
waveLevels,
|
||
SWELL_SWEEP_MS,
|
||
SWELL_REST_MS,
|
||
SWELL_AMP_PX,
|
||
SWELL_WIDTH_BARS,
|
||
RISE_MS,
|
||
RISE_CADENCE_MS,
|
||
PENDING_ALPHA,
|
||
} from "../lib/waveshape.js";
|
||
import Icon from "./Icon.svelte";
|
||
import SaveMenu from "./SaveMenu.svelte";
|
||
|
||
const pct = $derived(
|
||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||
);
|
||
const idle = $derived(player.status === "idle");
|
||
const currentItem = $derived(player.queue[player.queueIndex] ?? null);
|
||
// 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);
|
||
/** 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())
|
||
: [sub.trim(), ""];
|
||
});
|
||
const volumeIcon = $derived(
|
||
player.muted || player.volume === 0
|
||
? "volume-mute"
|
||
: player.volume < 0.5
|
||
? "volume-low"
|
||
: "volume",
|
||
);
|
||
|
||
// The source line — peer, percentage, rate, ETA, size, format — used to
|
||
// live here in a slot whose width was measured and written by hand. It is
|
||
// gone: the Now Playing rail already carries every one of those fields, and
|
||
// reserving room for them here made the transport's position a function of
|
||
// how much a peer had sent. Nothing reads `player.streamSpeed`,
|
||
// `streamTotal` or `currentItem.quality` in this component any more.
|
||
|
||
// ---- 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);
|
||
let draggingSeek = $state(false);
|
||
let lastAudibleVolume = 0.7;
|
||
let lastScrubSeekAt = 0;
|
||
let scrubStartX = 0;
|
||
let scrubLastX = 0;
|
||
let scrubLastAt = 0;
|
||
let scratchActive = false;
|
||
|
||
$effect(() => {
|
||
const item = currentItem;
|
||
artUrl = null;
|
||
if (!item) return;
|
||
let active = true;
|
||
if (item.art) {
|
||
getArt(item.art).then((url) => {
|
||
if (active) artUrl = url;
|
||
});
|
||
} else if (item.artUrl) {
|
||
// Streamed catalog track: cover comes from the catalog CDN, upgraded
|
||
// to the verified high-res source (Apple/CAA/Deezer-max) when ready.
|
||
getCatalogArt(playerArtSource(item)).then((url) => {
|
||
if (active) artUrl = url;
|
||
});
|
||
}
|
||
return () => (active = false);
|
||
});
|
||
|
||
// Waveform canvas + hover state.
|
||
let waveWrap = $state(null);
|
||
let waveCanvas = $state(null);
|
||
let hoverX = $state(-1);
|
||
const hoverTime = $derived(
|
||
waveWrap && player.duration ? (hoverX / waveWrap.clientWidth) * player.duration : 0,
|
||
);
|
||
|
||
// Canvas colors; played/hover follow the adaptive accent theme.
|
||
/** The rail's unplayed colour — `--text-muted`, copied verbatim so both
|
||
* lanes fade to the same grey. A canvas fill cannot use the CSS var;
|
||
* if the token ever changes, change this with it. */
|
||
const UNPLAYED = "#7a7a7a";
|
||
const PREVIEW = "rgba(255,255,255,0.66)";
|
||
const BAR_GAP = BAR_PITCH - BAR_W;
|
||
|
||
// The rail's `color-mix(in srgb, accent × fill%, --text-muted)` for the one
|
||
// bar the playhead is inside. `theme.accent` is always "#rrggbb" (state's
|
||
// rgbToHex emits nothing else), parsed once per accent value — it only
|
||
// changes while a crossfade lerps.
|
||
//
|
||
// The bar's crossfade target must match its NEIGHBOURS, not a constant:
|
||
// normally it fades accent → the rail's grey, but while the hover wash is
|
||
// up, every bar to its right is wash-white — fading toward grey then left
|
||
// one dark notch stranded between the accent and the wash. So under a wash
|
||
// it fades accent → the wash's own resolved colour instead.
|
||
const MUTED_RGB = [122, 122, 122];
|
||
/** PREVIEW (66% white) composited over an UNPLAYED bar, precomputed:
|
||
* 0.66·255 + 0.34·122 — what a washed bar actually renders as. */
|
||
const WASHED_RGB = [210, 210, 210];
|
||
let blendParsedFor = "";
|
||
let blendRgb = MUTED_RGB;
|
||
function blendAccent(accent, fill, towardWash) {
|
||
if (accent !== blendParsedFor) {
|
||
const n = parseInt(accent.slice(1), 16);
|
||
blendRgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||
blendParsedFor = accent;
|
||
}
|
||
const toward = towardWash ? WASHED_RGB : MUTED_RGB;
|
||
const t = 1 - fill;
|
||
const r = Math.round(blendRgb[0] + (toward[0] - blendRgb[0]) * t);
|
||
const g = Math.round(blendRgb[1] + (toward[1] - blendRgb[1]) * t);
|
||
const b = Math.round(blendRgb[2] + (toward[2] - blendRgb[2]) * t);
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
// ---- loading / streaming motion ---------------------------------------
|
||
// The lane is ALWAYS the bar waveform now. With nothing to show it rests
|
||
// as dim dots (the rail's pending look, mirrored); while a waveform is
|
||
// being computed the loading SWELL crosses those dots; while a stream
|
||
// downloads, bars stand up left→right on a fixed rhythm (the RISE) —
|
||
// released one per RISE_CADENCE_MS behind the transfer, each easing up
|
||
// over RISE_MS, so a fast peer cannot machine-gun the lane. Numbers and
|
||
// provenance live in waveshape.js. The solid fallback track this replaces
|
||
// also carried the buffer fill; that story is the rise frontier now.
|
||
let riseKey = null; // which queue item the rise bookkeeping belongs to
|
||
let riseReleased = 0; // bars allowed to stand so far
|
||
let riseLastRelease = 0; // clock of the newest release, for the cadence
|
||
let riseBorn = []; // per-bar release clock, for the ease
|
||
let riseLevels = [];
|
||
let riseLevelsPeaks = null;
|
||
let riseLevelsCount = 0;
|
||
let animRaf = null;
|
||
|
||
function paintBar(ctx, x, mid, h, color) {
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.roundRect(x, mid - h / 2, BAR_W, h, 1);
|
||
ctx.fill();
|
||
}
|
||
|
||
/** One frame while the lane animates; re-arms itself from drawWave as long
|
||
* as the state it painted still moves. Position events also repaint, but
|
||
* they arrive ~5/s — the swell and the rise need real frames. */
|
||
function scheduleAnim(active) {
|
||
if (!active || animRaf !== null || !windowActive) return;
|
||
animRaf = requestAnimationFrame(() => {
|
||
animRaf = null;
|
||
drawWave(true);
|
||
});
|
||
}
|
||
$effect(() => () => {
|
||
if (animRaf !== null) cancelAnimationFrame(animRaf);
|
||
});
|
||
|
||
/** Resting dots, with the loading swell crossing them while a waveform is
|
||
* actually being computed (`player.wavePending`). No accent here — accent
|
||
* means played, and this lane has nothing to play yet. */
|
||
function drawUnloaded(ctx, W, H, mid, count, now) {
|
||
ctx.clearRect(0, 0, W, H);
|
||
const pending = player.wavePending;
|
||
const period = SWELL_SWEEP_MS + SWELL_REST_MS;
|
||
const u = (now % period) / SWELL_SWEEP_MS; // > 1 during the rest beat
|
||
const centre = u * (count + SWELL_WIDTH_BARS * 2) - SWELL_WIDTH_BARS;
|
||
for (let i = 0; i < count; i++) {
|
||
let env = 0;
|
||
if (pending && u <= 1) {
|
||
const d = (i - centre) / (SWELL_WIDTH_BARS * 0.55);
|
||
env = Math.exp(-d * d);
|
||
}
|
||
const alpha = PENDING_ALPHA + (0.85 - PENDING_ALPHA) * env;
|
||
paintBar(ctx, i * BAR_PITCH, mid, 2 + env * SWELL_AMP_PX * 2, `rgba(122,122,122,${alpha})`);
|
||
}
|
||
return pending;
|
||
}
|
||
|
||
/** The streaming rise. Released bars ease from the floor to their height;
|
||
* queued and beyond-frontier bars are pending dots. The colour pass is
|
||
* the full painter's fade — accent, boundary blend, wash, muted — done
|
||
* per bar because the heights differ every frame while bars grow. The
|
||
* partial waveform describes only the audio that has ARRIVED, so it is
|
||
* folded across the covered fraction of the lane, not the whole width. */
|
||
function drawRise(ctx, W, H, mid, count, now, frontier, playFrac, playedBars) {
|
||
const coveredBars = Math.max(1, frontier);
|
||
if (riseLevelsPeaks !== player.waveform || riseLevelsCount !== coveredBars) {
|
||
riseLevels = waveLevels(player.waveform, coveredBars);
|
||
riseLevelsPeaks = player.waveform;
|
||
riseLevelsCount = coveredBars;
|
||
}
|
||
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);
|
||
for (let i = 0; i < count; i++) {
|
||
const x = i * BAR_PITCH;
|
||
if (i >= riseReleased) {
|
||
paintBar(ctx, x, mid, 2, `rgba(122,122,122,${PENDING_ALPHA})`);
|
||
continue;
|
||
}
|
||
const grow = Math.min(1, (now - (riseBorn[i] ?? now)) / RISE_MS);
|
||
const eased = 1 - Math.pow(1 - grow, 3);
|
||
const target = Math.max(2, (riseLevels[i] ?? 0.12) * H);
|
||
const h = 2 + (target - 2) * eased;
|
||
let color;
|
||
if (i < full) color = theme.accent;
|
||
else if (i === full && playedBars - full > 0) {
|
||
color = blendAccent(theme.accent, playedBars - full, washing);
|
||
} else if (washing && i > full && i <= washEndBar) {
|
||
color = `rgb(${WASHED_RGB.join(",")})`;
|
||
} else {
|
||
color = UNPLAYED;
|
||
}
|
||
paintBar(ctx, x, mid, h, color);
|
||
}
|
||
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) {
|
||
if (secs == null || !isFinite(secs)) return "–:––";
|
||
const s = Math.max(0, Math.floor(secs));
|
||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
function toggle() {
|
||
if (player.status === "playing") api.playerControl("pause");
|
||
// 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) {
|
||
if (!player.duration || idle) return;
|
||
const rect = el.getBoundingClientRect();
|
||
const f = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||
return f * player.duration;
|
||
}
|
||
|
||
function previewScrub(e, el) {
|
||
const target = seekTarget(e, el);
|
||
if (target == null) return;
|
||
player.position = target; // instant visual feedback while dragging
|
||
return target;
|
||
}
|
||
|
||
function regularScrub(target, force = false) {
|
||
const now = performance.now();
|
||
if (force || now - lastScrubSeekAt >= 75) {
|
||
lastScrubSeekAt = now;
|
||
api.playerControl("seek", target);
|
||
}
|
||
}
|
||
|
||
function startScrub(e, el) {
|
||
if (!player.duration || idle) return;
|
||
draggingSeek = true;
|
||
player.scrubbing = true;
|
||
el.setPointerCapture(e.pointerId);
|
||
scrubStartX = scrubLastX = e.clientX;
|
||
scrubLastAt = performance.now();
|
||
scratchActive = false;
|
||
previewScrub(e, el);
|
||
}
|
||
|
||
function moveScrub(e, el) {
|
||
if (!draggingSeek) return;
|
||
const target = previewScrub(e, el);
|
||
if (target == null) return;
|
||
const now = performance.now();
|
||
if (canScratch && Math.abs(e.clientX - scrubStartX) > 3) {
|
||
const activatingScratch = !scratchActive;
|
||
scratchActive = true;
|
||
const elapsed = Math.max(1, now - scrubLastAt);
|
||
// Map hand velocity (pixels/ms) to a musical 0.25×–4× rate.
|
||
const rate = Math.min(4, Math.max(0.25, Math.abs(e.clientX - scrubLastX) / elapsed * 1.6));
|
||
if (activatingScratch || now - lastScrubSeekAt >= 50) {
|
||
lastScrubSeekAt = now;
|
||
api.playerControl("scratch", target, e.clientX < scrubLastX ? -rate : rate);
|
||
}
|
||
} else if (!canScratch) {
|
||
regularScrub(target);
|
||
}
|
||
scrubLastX = e.clientX;
|
||
scrubLastAt = now;
|
||
}
|
||
|
||
function endScrub(e, el) {
|
||
if (!draggingSeek) return;
|
||
const target = previewScrub(e, el);
|
||
if (target != null) {
|
||
if (scratchActive) api.playerControl("end-scratch", target);
|
||
else regularScrub(target, true); // a simple click remains exact seek
|
||
}
|
||
draggingSeek = false;
|
||
player.scrubbing = false;
|
||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId);
|
||
}
|
||
|
||
function onWaveHover(e) {
|
||
const rect = waveWrap.getBoundingClientRect();
|
||
hoverX = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
|
||
}
|
||
|
||
function setVolume(e) {
|
||
const rect = volTrack.getBoundingClientRect();
|
||
const frac = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||
player.volume = frac;
|
||
player.muted = frac === 0;
|
||
if (frac > 0) lastAudibleVolume = frac;
|
||
api.playerControl("volume", frac);
|
||
}
|
||
|
||
function startVolumeDrag(e) {
|
||
draggingVolume = true;
|
||
volTrack.setPointerCapture(e.pointerId);
|
||
setVolume(e);
|
||
}
|
||
|
||
function endVolumeDrag(e) {
|
||
draggingVolume = false;
|
||
if (volTrack.hasPointerCapture(e.pointerId)) volTrack.releasePointerCapture(e.pointerId);
|
||
}
|
||
|
||
function volumeKeydown(e) {
|
||
const step = e.shiftKey ? 0.1 : 0.05;
|
||
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.max(0, player.volume - step) * volTrack.clientWidth });
|
||
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setVolume({ clientX: volTrack.getBoundingClientRect().left + Math.min(1, player.volume + step) * volTrack.clientWidth });
|
||
} else if (e.key === "Home" || e.key === "End") {
|
||
e.preventDefault();
|
||
setVolume({ clientX: volTrack.getBoundingClientRect().left + (e.key === "Home" ? 0 : volTrack.clientWidth) });
|
||
}
|
||
}
|
||
|
||
function toggleMute() {
|
||
if (player.muted || player.volume === 0) {
|
||
const volume = lastAudibleVolume || 0.7;
|
||
player.volume = volume;
|
||
player.muted = false;
|
||
api.playerControl("volume", volume);
|
||
} else {
|
||
lastAudibleVolume = player.volume;
|
||
player.muted = true;
|
||
api.playerControl("volume", 0);
|
||
}
|
||
}
|
||
|
||
// Wave geometry is invariant while a track and player-bar size stay the
|
||
// same. Cache the shaped bars instead of refolding 2000 peaks and
|
||
// resetting the canvas backing store on every 60 ms position event.
|
||
let wavePath = null; // one Path2D of every bar, for the grouped fills
|
||
let waveHeights = []; // per-bar heights, for the one bar the playhead is in
|
||
let waveCount = 0;
|
||
let wavePathPeaks = null;
|
||
let wavePathWidth = 0;
|
||
let wavePathHeight = 0;
|
||
let lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||
let lastHoverDrawn = Number.NEGATIVE_INFINITY;
|
||
let lastAccent = "";
|
||
let lastAccentHover = "";
|
||
let lastDuration = null;
|
||
const windowActive = $derived(ui.windowActive);
|
||
|
||
// Draw one solid mirrored waveform, colored by played / hover / unplayed,
|
||
// with a white playhead line and a green hover line.
|
||
function drawWave(force = false) {
|
||
// Position events still update playback/history while the window is
|
||
// minimized, but there is no visible canvas to repaint. Visibility
|
||
// return below forces one exact catch-up frame.
|
||
if (!windowActive) return;
|
||
const canvas = waveCanvas;
|
||
const wrap = waveWrap;
|
||
const peaks = player.waveform;
|
||
if (!canvas || !wrap) return;
|
||
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const W = wrap.clientWidth;
|
||
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));
|
||
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
|
||
if (resized) {
|
||
canvas.width = pixelWidth;
|
||
canvas.height = pixelHeight;
|
||
}
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
||
const mid = H / 2;
|
||
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
||
const now = performance.now();
|
||
|
||
// Nothing to draw yet: resting dots, with the swell crossing them
|
||
// while a waveform request is actually working.
|
||
if (!peaks || peaks.length === 0) {
|
||
riseKey = null;
|
||
const moving = drawUnloaded(ctx, W, H, mid, count, now);
|
||
lastPlayedBars = Number.NEGATIVE_INFINITY;
|
||
scheduleAnim(moving);
|
||
return;
|
||
}
|
||
|
||
// 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
|
||
// frontier is the WAVEFORM'S OWN coverage — the audio that has
|
||
// actually been decoded, reported by the backend with the peaks —
|
||
// never the live byte counter, whose fresher number stretched the
|
||
// drawn shape between refreshes. Where the bars end IS how much of
|
||
// the track has loaded: the edge parks when it catches the download
|
||
// and marches again when the next chunk lands. A track whose FIRST
|
||
// decode already covers everything — a cached replay, a local file —
|
||
// never rises at all; it pops in complete.
|
||
const waveCov = Math.max(0, Math.min(1, player.waveformCoverage));
|
||
const riseCandidate = player.track?.source === "stream";
|
||
const riseTrackKey = currentItem?.path ?? player.track?.title ?? "?";
|
||
if (riseCandidate && (waveCov < 1 || riseKey === riseTrackKey)) {
|
||
if (riseKey !== riseTrackKey) {
|
||
riseKey = riseTrackKey;
|
||
riseReleased = 0;
|
||
riseLastRelease = now;
|
||
riseBorn = [];
|
||
}
|
||
const frontier = Math.floor(waveCov * count);
|
||
let guard = 0;
|
||
while (
|
||
riseReleased < frontier &&
|
||
now - riseLastRelease >= RISE_CADENCE_MS &&
|
||
guard++ < 600
|
||
) {
|
||
riseBorn[riseReleased] = now;
|
||
riseReleased += 1;
|
||
riseLastRelease += RISE_CADENCE_MS;
|
||
}
|
||
// An idle cadence clock would otherwise bank a backlog and dump it
|
||
// all at once when the next coverage arrives.
|
||
if (riseReleased >= frontier) {
|
||
riseLastRelease = Math.max(riseLastRelease, now - RISE_CADENCE_MS);
|
||
}
|
||
const playFracNow = player.duration
|
||
? Math.max(0, Math.min(1, player.position / player.duration))
|
||
: 0;
|
||
const playedBarsNow = playFracNow * count;
|
||
// Played audio is never hidden behind the queue.
|
||
for (let i = riseReleased; i <= Math.min(Math.floor(playedBarsNow), frontier - 1); i++) {
|
||
riseBorn[i] = now - RISE_MS;
|
||
riseReleased = i + 1;
|
||
}
|
||
const newest = riseReleased > 0 ? (riseBorn[riseReleased - 1] ?? 0) : 0;
|
||
const growing = riseReleased > 0 && now - newest < RISE_MS;
|
||
if (waveCov < 1 || riseReleased < count || growing) {
|
||
drawRise(ctx, W, H, mid, count, now, frontier, playFracNow, playedBarsNow);
|
||
lastPlayedBars = playedBarsNow;
|
||
lastHoverDrawn = hoverX;
|
||
lastAccent = theme.accent;
|
||
lastAccentHover = theme.accentHover;
|
||
lastDuration = player.duration;
|
||
scheduleAnim(growing || riseReleased < frontier);
|
||
return;
|
||
}
|
||
// Fully covered, fully grown: hand the lane back to the cached
|
||
// painter for good.
|
||
riseKey = null;
|
||
} else if (!riseCandidate) {
|
||
riseKey = null;
|
||
}
|
||
|
||
const geometryChanged =
|
||
resized ||
|
||
wavePathPeaks !== peaks ||
|
||
wavePathWidth !== W ||
|
||
wavePathHeight !== H;
|
||
if (geometryChanged) {
|
||
// Same pitch AND same shaping as the Now Playing rail — both come
|
||
// from waveshape.js — so the two lanes in the window read as one
|
||
// instrument and 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 count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
||
const levels = waveLevels(peaks, count);
|
||
const path = new Path2D();
|
||
waveHeights = levels.map((level) => Math.max(2, level * H));
|
||
for (let i = 0; i < count; i++) {
|
||
path.roundRect(i * BAR_PITCH, mid - waveHeights[i] / 2, BAR_W, waveHeights[i], 1);
|
||
}
|
||
wavePath = path;
|
||
waveCount = count;
|
||
wavePathPeaks = peaks;
|
||
wavePathWidth = W;
|
||
wavePathHeight = H;
|
||
}
|
||
|
||
const playFrac = player.duration
|
||
? Math.max(0, Math.min(1, player.position / player.duration))
|
||
: 0;
|
||
const playX = playFrac * W;
|
||
const playedBars = playFrac * waveCount;
|
||
const visualsChanged =
|
||
geometryChanged ||
|
||
hoverX !== lastHoverDrawn ||
|
||
theme.accent !== lastAccent ||
|
||
theme.accentHover !== lastAccentHover ||
|
||
player.duration !== lastDuration;
|
||
// The playhead's visible quantum is the boundary bar's BLEND, not a
|
||
// clip edge: the fills cut on bar boundaries, so the only thing a
|
||
// small move changes is that one bar's colour. The old skip here — no
|
||
// redraw under 0.75px of playhead travel — was written for the
|
||
// continuous fill and quantized that crossfade into 12% jumps at
|
||
// ~200ms, visibly chunkier than the rail blending the same amount
|
||
// every position event. Redraw when the blend has moved ≥2% of a bar
|
||
// (every event on a normal lane) and still skip the no-op repaints of
|
||
// a paused player.
|
||
if (!force && !visualsChanged && Math.abs(playedBars - lastPlayedBars) < 0.02) return;
|
||
|
||
ctx.clearRect(0, 0, W, H);
|
||
const fillClip = (x0, x1, color) => {
|
||
if (x1 <= x0) return;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(x0, 0, x1 - x0, H);
|
||
ctx.clip();
|
||
ctx.fillStyle = color;
|
||
ctx.fill(wavePath);
|
||
ctx.restore();
|
||
};
|
||
|
||
// The rail's fade, copied: a bar is one colour, blended from accent to
|
||
// the rail's grey by how far the playhead is through it. Fully played
|
||
// and unplayed regions clip on bar BOUNDARIES — the cut lands in the
|
||
// 4px gap — and only the single bar the playhead is inside gets the
|
||
// mixed fill, so no bar is ever chopped down its middle. The accent
|
||
// matches the primary Play button and the sidebar's completed bars;
|
||
// `accentHover` remains reserved for hover previews and controls.
|
||
const full = Math.floor(playedBars);
|
||
const washing = hoverX > playX;
|
||
fillClip(0, W, UNPLAYED);
|
||
if (washing) {
|
||
// Bar-quantized for the same reason: the wash recolours whole bars
|
||
// between the playhead's bar and the cursor, never a sliver.
|
||
const washEnd = (Math.floor(hoverX / BAR_PITCH) + 1) * BAR_PITCH;
|
||
fillClip((full + 1) * BAR_PITCH, washEnd, PREVIEW);
|
||
}
|
||
fillClip(0, full * BAR_PITCH, theme.accent);
|
||
const frac = playedBars - full;
|
||
if (frac > 0 && full < waveCount) {
|
||
const h = waveHeights[full];
|
||
ctx.fillStyle = blendAccent(theme.accent, frac, washing);
|
||
ctx.beginPath();
|
||
ctx.roundRect(full * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
||
ctx.fill();
|
||
}
|
||
|
||
// 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, (H - LINE_H) / 2, 1, LINE_H);
|
||
}
|
||
|
||
lastPlayedBars = playedBars;
|
||
lastHoverDrawn = hoverX;
|
||
lastAccent = theme.accent;
|
||
lastAccentHover = theme.accentHover;
|
||
lastDuration = player.duration;
|
||
}
|
||
|
||
// Redraw whenever the waveform, position, hover, or accent changes
|
||
// (the accent lerps every frame during a crossfade, animating the fill).
|
||
$effect(() => {
|
||
player.waveform;
|
||
player.position;
|
||
player.duration;
|
||
hoverX;
|
||
theme.accent;
|
||
theme.accentHover;
|
||
drawWave();
|
||
});
|
||
|
||
// Redraw on player-bar resize.
|
||
$effect(() => {
|
||
if (!waveWrap) return;
|
||
const ro = new ResizeObserver(() => drawWave(true));
|
||
ro.observe(waveWrap);
|
||
return () => ro.disconnect();
|
||
});
|
||
</script>
|
||
|
||
<footer class="player">
|
||
<div class="now-playing">
|
||
{#if artUrl}
|
||
<img class="art cover" src={artUrl} alt="" draggable="false" />
|
||
{:else}
|
||
<div class="art placeholder" class:pulsing={player.status === "buffering"}>
|
||
{#if player.track}<Icon name="music" size={22} />{/if}
|
||
</div>
|
||
{/if}
|
||
<div class="meta">
|
||
{#if player.error}
|
||
<span class="title err">⚠ {player.error}</span>
|
||
{:else if player.track}
|
||
<button class="title link" onclick={openCurrentSong} title={player.track.title}>
|
||
{player.track.title}
|
||
</button>
|
||
{:else}
|
||
<span class="title muted">Nothing playing</span>
|
||
{/if}
|
||
|
||
<!-- Artist and album on ONE line, which is what a 62px bar can pay
|
||
for. The `.who` idiom from the album page: a 50%-opacity middot
|
||
between them, and no underline until hover. -->
|
||
{#if player.status === "buffering"}
|
||
<span class="who"><span class="sub">{metaParts[0] || "buffering…"}</span></span>
|
||
{:else if player.track && (metaParts[0] || metaParts[1])}
|
||
<span class="who">
|
||
{#if metaParts[0]}
|
||
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||
{metaParts[0]}
|
||
</button>
|
||
{/if}
|
||
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
|
||
{#if metaParts[1]}
|
||
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||
{metaParts[1]}
|
||
</button>
|
||
{/if}
|
||
</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="buttons">
|
||
<button
|
||
class="ctl small"
|
||
class:on={player.shuffle}
|
||
title="Shuffle"
|
||
onclick={toggleShuffle}
|
||
><Icon name="shuffle" size={16} /></button>
|
||
<button class="ctl" title="Previous" onclick={prevTrack} disabled={idle}>
|
||
<Icon name="skip-back" size={18} />
|
||
</button>
|
||
<button
|
||
class="play"
|
||
title="Play/Pause"
|
||
onclick={toggle}
|
||
disabled={idle || player.status === "buffering"}
|
||
>
|
||
<Icon name={player.status === "playing" ? "pause" : "play"} size={16} />
|
||
</button>
|
||
<button class="ctl" title="Next" onclick={() => nextTrack(false)} disabled={idle}>
|
||
<Icon name="skip-forward" size={18} />
|
||
</button>
|
||
<button
|
||
class="ctl small"
|
||
class:on={player.repeat !== "off"}
|
||
title={player.repeat === "one" ? "Repeat one" : player.repeat === "all" ? "Repeat all" : "Repeat"}
|
||
onclick={cycleRepeat}
|
||
><Icon name={player.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
|
||
</div>
|
||
<div class="scrubber">
|
||
<span class="time">{fmt(player.position)}</span>
|
||
<!-- ALWAYS the bar lane — the solid fallback track is gone. With no
|
||
waveform the canvas rests as dots (swelling while one loads,
|
||
standing up as a stream arrives), and it stays a seek surface
|
||
the whole time: same handlers in every state. The tooltip
|
||
needs a duration to have anything truthful to say. -->
|
||
<div
|
||
class="wave-wrap"
|
||
bind:this={waveWrap}
|
||
role="slider"
|
||
aria-label="Seek"
|
||
aria-valuenow={Math.round(pct)}
|
||
tabindex="0"
|
||
onmousemove={onWaveHover}
|
||
onmouseleave={() => (hoverX = -1)}
|
||
class:dragging={draggingSeek}
|
||
onpointerdown={(e) => startScrub(e, waveWrap)}
|
||
onpointermove={(e) => moveScrub(e, waveWrap)}
|
||
onpointerup={(e) => endScrub(e, waveWrap)}
|
||
onpointercancel={(e) => endScrub(e, waveWrap)}
|
||
>
|
||
<canvas bind:this={waveCanvas} class="wave-canvas"></canvas>
|
||
{#if hoverX >= 0 && player.duration}
|
||
<div class="wave-tip" style="left: {hoverX}px">{fmt(hoverTime)}</div>
|
||
{/if}
|
||
</div>
|
||
<span class="time">{fmt(player.duration)}</span>
|
||
</div>
|
||
|
||
<!-- 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
|
||
too many. -->
|
||
<div class="extras">
|
||
<SaveMenu align="up" />
|
||
|
||
<!-- Everything past Save goes behind one control, so landing lyrics,
|
||
a mini player and output switching later costs no width. -->
|
||
<div class="menu-wrap" bind:this={menuWrap}>
|
||
<button
|
||
class="vol-icon"
|
||
class:on={menuOpen}
|
||
title="More"
|
||
aria-haspopup="menu"
|
||
aria-expanded={menuOpen}
|
||
onclick={() => (menuOpen = !menuOpen)}
|
||
>
|
||
<Icon name="more-horizontal" size={17} />
|
||
</button>
|
||
{#if menuOpen}
|
||
<div class="menu" role="menu">
|
||
<button role="menuitem" onclick={openQueue}>
|
||
<Icon name="playlist" size={15} /> Queue
|
||
</button>
|
||
<button role="menuitem" disabled title="Not built yet">
|
||
<Icon name="lyrics" size={15} /> Lyrics <span class="soon">soon</span>
|
||
</button>
|
||
<button role="menuitem" disabled title="Not built yet">
|
||
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
|
||
</button>
|
||
<button role="menuitem" disabled title="Not built yet">
|
||
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
|
||
</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
|
||
<Icon name={volumeIcon} size={18} />
|
||
</button>
|
||
<div
|
||
class="track vol"
|
||
bind:this={volTrack}
|
||
class:dragging={draggingVolume}
|
||
onpointerdown={startVolumeDrag}
|
||
onpointermove={(e) => draggingVolume && setVolume(e)}
|
||
onpointerup={endVolumeDrag}
|
||
onpointercancel={endVolumeDrag}
|
||
onkeydown={volumeKeydown}
|
||
role="slider"
|
||
aria-label="Volume"
|
||
aria-valuenow={Math.round((player.muted ? 0 : player.volume) * 100)}
|
||
tabindex="0"
|
||
>
|
||
<div class="fill" style="width: {(player.muted ? 0 : player.volume) * 100}%"></div>
|
||
<div class="vol-thumb" style="left: {(player.muted ? 0 : player.volume) * 100}%"></div>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
|
||
<style>
|
||
/* ONE row. The transport, the scrubber and the meta share a baseline, and
|
||
the scrubber takes every pixel the others do not want — it no longer caps
|
||
at 560px while the flanks stretch, which is what left a wide window with
|
||
a small waveform adrift in the middle.
|
||
|
||
Nothing here is width-queried any more — the source slot was the only
|
||
container query, and it is gone — so there is no `container-type`. The
|
||
row fills its grid area at every window size; the scrubber absorbs
|
||
whatever the name, transport and extras do not take. */
|
||
.player {
|
||
grid-area: player;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
/* 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
|
||
cover and nav icon already shares, and which the sidebar holds
|
||
through its entire collapse. See the note in Sidebar.svelte. */
|
||
padding: 0 12px;
|
||
background: var(--bg-player);
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.now-playing {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 11px;
|
||
min-width: 170px;
|
||
/* The song name sets this width, uncapped. `0 1 auto` means the cluster
|
||
sits at its natural content width and gives ground ONLY once the
|
||
scrubber has reached its 260px floor — at which point the title
|
||
ellipsises, and not before. Chosen off
|
||
`research/playbar-redesign/width-rule.html` against a capped variant:
|
||
the cap bounded how far the transport travelled between tracks (525px
|
||
vs 597px on the longest title in that set) at the cost of truncating
|
||
names that had room to be read. The name won. */
|
||
flex: 0 1 auto;
|
||
}
|
||
|
||
/* 40px, the size of a sidebar recents cover, for the same reason. */
|
||
.art {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--text-muted);
|
||
font-size: 20px;
|
||
}
|
||
|
||
.art.placeholder {
|
||
background: var(--bg-highlight);
|
||
}
|
||
|
||
.art.cover {
|
||
object-fit: cover;
|
||
background: var(--bg-highlight);
|
||
}
|
||
|
||
.art.pulsing {
|
||
animation: pulse 1.2s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
50% { opacity: 0.5; }
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
min-width: 0;
|
||
gap: 1px;
|
||
}
|
||
|
||
/* Artist and album on one line — the `.who` idiom, copied rather than
|
||
invented: separator at 50% opacity, underline only on hover. A third
|
||
line is what 62px cannot pay for. */
|
||
.who {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
}
|
||
/* When the line does have to give, the ALBUM gives. Shrinking both in
|
||
proportion turned a short artist into "Po…", which is unreadable and
|
||
saves almost nothing; the album is the longer half and the one you can
|
||
lose the tail of and still recognise. */
|
||
.who .sub {
|
||
min-width: 0;
|
||
}
|
||
/* ...but ONLY when there is an album to give way to. `55%` resolves against
|
||
`.who`, whose own width is shrink-to-fit of its children — so when the
|
||
artist is the only child, the cap is 55% of the artist's own width and it
|
||
eats itself: "M83" rendered as "M…" in a 12px box. That is exactly the
|
||
state the bar is in while a track loads, where the buffering branch puts
|
||
one child in this line. Measured, not reasoned: 23px of text in a 12px
|
||
box, with and without the old cluster cap, so this predates that change.
|
||
Alone, it keeps the default `0 1 auto` and ellipsises like anything else. */
|
||
.who .sub:not(.album):not(:only-child) {
|
||
flex: 0 0 auto;
|
||
max-width: 55%;
|
||
}
|
||
.who .sub.album {
|
||
flex: 0 1 auto;
|
||
}
|
||
.who .sep {
|
||
opacity: 0.5;
|
||
flex: none;
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Separate, clickable meta lines. Buttons are width-constrained so the
|
||
hover underline only spans the text, and ellipsis-truncate. */
|
||
.title,
|
||
.sub {
|
||
max-width: 100%;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
text-align: left;
|
||
display: block;
|
||
}
|
||
|
||
.title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
}
|
||
.title.muted { color: var(--text-secondary); }
|
||
.title.err { color: #f15e6c; }
|
||
.title.link:hover { text-decoration: underline; }
|
||
|
||
.sub {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.3;
|
||
}
|
||
.sub.link:hover {
|
||
color: var(--text-primary);
|
||
text-decoration: underline;
|
||
}
|
||
.sub.album {
|
||
font-size: 11px;
|
||
color: var(--text-muted);
|
||
min-width: 0;
|
||
}
|
||
.sub.album.link:hover { color: var(--text-primary); }
|
||
|
||
.buttons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
flex: none;
|
||
}
|
||
|
||
.ctl {
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.ctl.small {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.ctl:disabled {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.ctl:hover:not(:disabled) {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* shuffle / repeat active state */
|
||
.ctl.on {
|
||
color: var(--accent-hover);
|
||
position: relative;
|
||
}
|
||
|
||
.ctl.on::after {
|
||
content: "";
|
||
position: absolute;
|
||
bottom: -6px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
.play {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: var(--text-primary);
|
||
color: #000;
|
||
font-size: 14px;
|
||
transition: transform 0.1s;
|
||
}
|
||
|
||
.play:disabled {
|
||
opacity: 0.4;
|
||
}
|
||
|
||
.play:hover:not(:disabled) {
|
||
transform: scale(1.06);
|
||
}
|
||
|
||
/* Fixed height so swapping between the thin bar and the waveform
|
||
canvas never reflows the buttons above. */
|
||
/* Takes the slack instead of capping: the wider the window, the longer the
|
||
waveform. `min-width` is the floor everything else yields to — the lane
|
||
is the point of the bar. */
|
||
.scrubber {
|
||
display: flex;
|
||
align-items: center;
|
||
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;
|
||
}
|
||
|
||
.time {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
min-width: 32px;
|
||
text-align: center;
|
||
}
|
||
|
||
.track {
|
||
flex: 1;
|
||
height: 4px;
|
||
border-radius: 2px;
|
||
background: hsla(0, 0%, 100%, 0.2);
|
||
overflow: hidden;
|
||
position: relative;
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
}
|
||
|
||
.track:hover {
|
||
height: 6px;
|
||
}
|
||
|
||
.track.dragging {
|
||
height: 6px;
|
||
}
|
||
|
||
/* Always the accent — matching the waveform scrubber's played region
|
||
(user decision 2026-07-23; the old white-until-hover read as off). */
|
||
.fill {
|
||
position: relative;
|
||
height: 100%;
|
||
background: var(--accent);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.track:hover .fill {
|
||
background: var(--accent-hover);
|
||
}
|
||
|
||
/* waveform scrubber */
|
||
.wave-wrap {
|
||
position: relative;
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 24px;
|
||
cursor: pointer;
|
||
touch-action: none;
|
||
}
|
||
|
||
.wave-canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.wave-tip {
|
||
position: absolute;
|
||
top: -22px;
|
||
transform: translateX(-50%);
|
||
background: #000;
|
||
color: #fff;
|
||
font-size: 11px;
|
||
padding: 2px 7px;
|
||
border-radius: 5px;
|
||
white-space: nowrap;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.extras {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
justify-content: flex-end;
|
||
flex: none;
|
||
}
|
||
|
||
/* ---- the overflow menu ---- */
|
||
.menu-wrap {
|
||
position: relative;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.menu {
|
||
position: absolute;
|
||
bottom: 30px;
|
||
right: -6px;
|
||
z-index: 40;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 186px;
|
||
padding: 7px;
|
||
border-radius: 9px;
|
||
background: #0d0d0d;
|
||
border: 1px solid var(--border);
|
||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.7);
|
||
}
|
||
.menu button {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
width: 100%;
|
||
padding: 6px 8px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
text-align: left;
|
||
}
|
||
.menu button:hover:not(:disabled) {
|
||
background: var(--bg-highlight);
|
||
color: var(--text-primary);
|
||
}
|
||
.menu button:disabled {
|
||
opacity: 0.45;
|
||
cursor: default;
|
||
}
|
||
.soon {
|
||
margin-left: auto;
|
||
font-size: 8px;
|
||
font-weight: 900;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: #ffcf6a;
|
||
background: rgba(255, 180, 60, 0.13);
|
||
border: 1px solid rgba(255, 180, 60, 0.32);
|
||
padding: 1px 5px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.vol-icon {
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.vol-icon:hover {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.vol-icon.on {
|
||
color: var(--accent-hover);
|
||
}
|
||
|
||
.track.vol {
|
||
flex: 0 0 90px;
|
||
overflow: visible;
|
||
touch-action: none;
|
||
}
|
||
|
||
.track.vol::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: -8px 0;
|
||
}
|
||
|
||
.track.vol .fill {
|
||
pointer-events: none;
|
||
}
|
||
|
||
.vol-thumb {
|
||
position: absolute;
|
||
top: 50%;
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
background: var(--text-primary);
|
||
transform: translate(-50%, -50%) scale(0);
|
||
transition: transform 0.12s ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.track.vol:hover .vol-thumb,
|
||
.track.vol.dragging .vol-thumb,
|
||
.track.vol:focus-visible .vol-thumb {
|
||
transform: translate(-50%, -50%) scale(1);
|
||
}
|
||
|
||
.track.vol:hover,
|
||
.track.vol.dragging {
|
||
height: 5px;
|
||
}
|
||
|
||
.track.vol:hover .fill,
|
||
.track.vol.dragging .fill {
|
||
background: var(--accent);
|
||
}
|
||
</style>
|