forked from Keelrleek/SoulStream
player bar: one row, aligned to the sidebar's column, and it says who is sending
88px becomes 62px and the two stacked rows become one. The 26px goes back to the content area on every screen, and the scrubber stops capping at 560px while the flanks stretch — a 1900px window used to put a small waveform adrift in the middle of an empty bar. The cover is 40x40 at 12px of padding, which lands its centre on x=40: the column every sidebar cover and nav icon already shares, and which the sidebar holds through its entire collapse. It was 56px at 16px, centre x=52 — twelve pixels out of a column the app is otherwise strict about. The waveform is drawn as discrete bars on the Now Playing rail's exact pitch, a 2px bar on a 4px gap with a 1px radius, both deriving their count from that one pitch so neither can drift from the other. Mirrored here, where the lane is short and wide; the rail stands its bars on the floor because it sits above a caption. The standing white playhead went with it — at this pitch it read as a seam through the bars, and the accent's own leading edge marks the position. Artist and album share one line as "Artist · Album", the `.who` idiom from the album page rather than a new one. They are read from the queue item's OWN artist/album fields now: `sub` is a display convenience that the album continuation fill writes as a bare artist while carrying the album right alongside, so a record played straight through lost its album name from the second track on. When the line must shrink the ALBUM shrinks — proportional shrinking turned a short artist into "Po…", which saves nothing and reads as nothing. New: the source line. Who is sending this track, how much has arrived, how fast and how long is left, then the format. Four states, derived exactly as NowPlaying derives its status line — two surfaces disagreeing about whether a track is cached would be worse than either being wrong. A library file reports NOTHING, because it has no peer and no transfer and zeroes would read as a stalled one. It sits in its own slot rather than inside the meta cluster: sharing that budget let a live transfer readout crowd the artist and album down to "Po… · Terrestria…". Its width is measured per STATE and never per value, so a ticking percentage and a counting-down ETA move nothing. The now-playing panel toggle is gone from the shelf — the panel carries its own collapse control, and two ways to do one thing is one too many. What replaces it is a single overflow menu, so landing lyrics, a mini player and output switching later costs no width; those three are present and disabled, marked SOON, rather than wired to nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b173f60085
commit
9aeb6c39c7
3 files changed
+426
-65
No files matched your search
@@ -44,6 +44,12 @@
|
|||||||
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
refresh: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/>',
|
||||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
|
"list-plus": '<path d="M11 12H3"/><path d="M16 6H3"/><path d="M16 18H3"/><path d="M18 9v6"/><path d="M21 12h-6"/>',
|
||||||
|
// Player-bar overflow. Filled dots rather than stroked circles: at 17px
|
||||||
|
// a 2px-stroke ring closes into a blob anyway, and the fill stays even.
|
||||||
|
"more-horizontal":
|
||||||
|
'<circle cx="5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
|
||||||
|
// Lyrics: ragged lines, so it cannot be mistaken for the queue's list.
|
||||||
|
lyrics: '<path d="M4 6h16"/><path d="M4 11h10"/><path d="M4 16h13"/><path d="M4 21h7"/>',
|
||||||
shuffle: '<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/>',
|
shuffle: '<path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l6.1-8.6c.7-1.1 2-1.7 3.3-1.7H22"/><path d="m18 2 4 4-4 4"/><path d="M2 6h1.9c1.5 0 2.9.9 3.6 2.2"/><path d="M22 18h-5.9c-1.3 0-2.6-.7-3.3-1.8l-.5-.8"/><path d="m18 14 4 4-4 4"/>',
|
||||||
repeat: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
|
repeat: '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/>',
|
||||||
"repeat-one": '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/>',
|
"repeat-one": '<path d="m17 2 4 4-4 4"/><path d="M3 11v-1a4 4 0 0 1 4-4h14"/><path d="m7 22-4-4 4-4"/><path d="M21 13v1a4 4 0 0 1-4 4H3"/><path d="M11 10h1v4"/>',
|
||||||
@@ -61,6 +67,7 @@
|
|||||||
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><rect x="6" y="4" width="12" height="6" rx="1"/>',
|
trophy: '<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><rect x="6" y="4" width="12" height="6" rx="1"/>',
|
||||||
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5"/>',
|
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5"/>',
|
||||||
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
globe: '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||||
|
alert: '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" x2="12" y1="9" y2="13"/><line x1="12" x2="12.01" y1="17" y2="17"/>',
|
||||||
"brand-instagram": '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
|
"brand-instagram": '<rect width="20" height="20" x="2" y="2" rx="5" ry="5"/><path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/><line x1="17.5" x2="17.51" y1="6.5" y2="6.5"/>',
|
||||||
"brand-x": '<path d="M17.4 3H20l-6.9 7.9L21 21h-5.4l-4.2-5.5L6.5 21H4l7.4-8.4L3.5 3H9l3.8 5L17.4 3z"/>',
|
"brand-x": '<path d="M17.4 3H20l-6.9 7.9L21 21h-5.4l-4.2-5.5L6.5 21H4l7.4-8.4L3.5 3H9l3.8 5L17.4 3z"/>',
|
||||||
"brand-facebook": '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
|
"brand-facebook": '<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/>',
|
||||||
|
|||||||
+415
-64
@@ -7,13 +7,13 @@
|
|||||||
prevTrack,
|
prevTrack,
|
||||||
toggleShuffle,
|
toggleShuffle,
|
||||||
cycleRepeat,
|
cycleRepeat,
|
||||||
toggleNowPlaying,
|
|
||||||
openCurrentArtist,
|
openCurrentArtist,
|
||||||
openCurrentAlbum,
|
openCurrentAlbum,
|
||||||
openCurrentSong,
|
openCurrentSong,
|
||||||
getArt,
|
getArt,
|
||||||
getCatalogArt,
|
getCatalogArt,
|
||||||
playerArtSource,
|
playerArtSource,
|
||||||
|
resumePlayback,
|
||||||
} 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 Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
@@ -33,8 +33,19 @@
|
|||||||
// The native scratch engine needs a complete local file. A network stream
|
// 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.
|
// keeps the reliable seek-on-drag behavior until it has been saved/cached.
|
||||||
const canScratch = $derived(!currentItem?.stream);
|
const canScratch = $derived(!currentItem?.stream);
|
||||||
// Split "Artist — Album" for the separate, clickable meta lines.
|
/** Artist and album for the separate, clickable meta lines.
|
||||||
|
*
|
||||||
|
* The queue item's OWN `artist` / `album` fields come first, and splitting
|
||||||
|
* `sub` on the em dash is only the fallback. `sub` is a display convenience
|
||||||
|
* that not every path fills the same way: the album-continuation fill
|
||||||
|
* (`maybeExtendAlbumQueue`) writes a bare artist into it while carrying the
|
||||||
|
* album right alongside, so a record played straight through lost its album
|
||||||
|
* name from the second track on — which is exactly what it looked like.
|
||||||
|
* Local library rows carry neither field, and those still fall back. */
|
||||||
const metaParts = $derived.by(() => {
|
const metaParts = $derived.by(() => {
|
||||||
|
if (currentItem?.artist || currentItem?.album) {
|
||||||
|
return [currentItem.artist ?? "", currentItem.album ?? ""];
|
||||||
|
}
|
||||||
const sub = player.track?.sub ?? "";
|
const sub = player.track?.sub ?? "";
|
||||||
return sub.includes(" — ")
|
return sub.includes(" — ")
|
||||||
? sub.split(" — ").map((s) => s.trim())
|
? sub.split(" — ").map((s) => s.trim())
|
||||||
@@ -48,6 +59,123 @@
|
|||||||
: "volume",
|
: "volume",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---- the source line ---------------------------------------------------
|
||||||
|
// Every value below already exists in state — these are the same fields
|
||||||
|
// NowPlaying reads for its status line, so this needed no new command.
|
||||||
|
// The derivations are deliberately identical to that component's, because
|
||||||
|
// two surfaces disagreeing about whether a track is "cached" would be worse
|
||||||
|
// than either of them being wrong.
|
||||||
|
const isStream = $derived(!!currentItem?.stream);
|
||||||
|
const quality = $derived(currentItem?.quality ?? null);
|
||||||
|
/** Same set NowPlaying uses, so the green dot means the same thing here. */
|
||||||
|
const LOSSLESS = new Set(["FLAC", "WAV", "ALAC", "AIFF", "APE"]);
|
||||||
|
const transferring = $derived(
|
||||||
|
isStream && player.streamTotal > 0 && player.streamReceived < player.streamTotal,
|
||||||
|
);
|
||||||
|
const cachedStream = $derived(
|
||||||
|
isStream && player.streamTotal > 0 && player.streamReceived >= player.streamTotal,
|
||||||
|
);
|
||||||
|
/** What the line is ABOUT, as opposed to what it currently reads. The slot
|
||||||
|
* is re-measured when THIS changes and never when a percentage ticks. */
|
||||||
|
const srcState = $derived(
|
||||||
|
!isStream
|
||||||
|
? "library"
|
||||||
|
: cachedStream
|
||||||
|
? "cached"
|
||||||
|
: transferring
|
||||||
|
? "transferring"
|
||||||
|
: "connecting",
|
||||||
|
);
|
||||||
|
const peerName = $derived(currentItem?.stream?.username ?? null);
|
||||||
|
const rateText = $derived(
|
||||||
|
player.streamSpeed > 0 ? `${(player.streamSpeed / 1048576).toFixed(1)} MB/s` : null,
|
||||||
|
);
|
||||||
|
const etaText = $derived.by(() => {
|
||||||
|
if (!transferring || player.streamSpeed <= 0) return null;
|
||||||
|
const secs = (player.streamTotal - player.streamReceived) / player.streamSpeed;
|
||||||
|
if (!isFinite(secs) || secs < 1) return null;
|
||||||
|
return secs >= 60
|
||||||
|
? `${Math.floor(secs / 60)}m ${Math.round(secs % 60)}s left`
|
||||||
|
: `${Math.round(secs)}s left`;
|
||||||
|
});
|
||||||
|
const sizeText = (bytes) => (bytes ? `${(bytes / 1048576).toFixed(1)} MB` : null);
|
||||||
|
const qualityChip = $derived.by(() => {
|
||||||
|
if (!quality?.format) return null;
|
||||||
|
return quality.bitrate
|
||||||
|
? `${quality.format} ${Math.round(quality.bitrate)}k`
|
||||||
|
: quality.format;
|
||||||
|
});
|
||||||
|
/** A library track reports NOTHING: it has no peer and no transfer, and
|
||||||
|
* zeroes would read as a stalled one. Same call NowPlaying makes. */
|
||||||
|
const sourceParts = $derived.by(() => {
|
||||||
|
if (!isStream) return [];
|
||||||
|
if (cachedStream) return ["Cached", sizeText(player.streamTotal)].filter(Boolean);
|
||||||
|
if (transferring) {
|
||||||
|
return [peerName, `${Math.round(bufferPct)}%`, rateText, etaText].filter(Boolean);
|
||||||
|
}
|
||||||
|
return peerName ? [`Streaming from ${peerName}`] : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// The slot reserves a width rather than hugging its text: while a transfer
|
||||||
|
// runs, the percentage, rate and ETA change every position event, and a
|
||||||
|
// slot that tracked them would slide the transport and the whole scrubber
|
||||||
|
// sideways continuously. It GROWS the instant the text needs more room and
|
||||||
|
// SHRINKS only when the state changes, so nothing ever clips and nothing
|
||||||
|
// twitches. Written straight to the node: routing it through $state would
|
||||||
|
// make the effect that measures it depend on its own output.
|
||||||
|
let srcSlotEl = $state(null);
|
||||||
|
let srcLineEl = $state(null);
|
||||||
|
const SLOT_CAP = 340;
|
||||||
|
const SLOT_PAD_L = 10;
|
||||||
|
const SLOT_PAD_R = 8;
|
||||||
|
function sizeSourceSlot(reset = false) {
|
||||||
|
if (!srcSlotEl) return;
|
||||||
|
const natural = srcLineEl ? Math.ceil(srcLineEl.getBoundingClientRect().width) : 0;
|
||||||
|
const want = natural ? Math.min(natural + SLOT_PAD_L + SLOT_PAD_R, SLOT_CAP) : 0;
|
||||||
|
const current = parseFloat(srcSlotEl.style.width) || 0;
|
||||||
|
if (!reset && want <= current) return;
|
||||||
|
srcSlotEl.style.width = `${want}px`;
|
||||||
|
srcSlotEl.style.paddingLeft = want ? `${SLOT_PAD_L}px` : "0px";
|
||||||
|
srcSlotEl.style.paddingRight = want ? `${SLOT_PAD_R}px` : "0px";
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
srcState;
|
||||||
|
qualityChip;
|
||||||
|
sizeSourceSlot(true);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
sourceParts;
|
||||||
|
sizeSourceSlot(false);
|
||||||
|
});
|
||||||
|
// The slot's width is written imperatively, so a window resize — which can
|
||||||
|
// cross the @container threshold that hides it — has to re-measure.
|
||||||
|
$effect(() => {
|
||||||
|
const onResize = () => sizeSourceSlot(true);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- the overflow menu -------------------------------------------------
|
||||||
|
let menuOpen = $state(false);
|
||||||
|
let menuWrap = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const away = (e) => {
|
||||||
|
if (menuWrap && !menuWrap.contains(e.target)) menuOpen = false;
|
||||||
|
};
|
||||||
|
const esc = (e) => e.key === "Escape" && (menuOpen = false);
|
||||||
|
window.addEventListener("pointerdown", away);
|
||||||
|
window.addEventListener("keydown", esc);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointerdown", away);
|
||||||
|
window.removeEventListener("keydown", esc);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
function openQueue() {
|
||||||
|
menuOpen = false;
|
||||||
|
ui.nowPlaying = true; // the queue lives in the panel; always reveal it
|
||||||
|
}
|
||||||
|
|
||||||
let artUrl = $state(null);
|
let artUrl = $state(null);
|
||||||
let volTrack = $state(null);
|
let volTrack = $state(null);
|
||||||
let draggingVolume = $state(false);
|
let draggingVolume = $state(false);
|
||||||
@@ -89,7 +217,10 @@
|
|||||||
// Canvas colors; played/hover follow the adaptive accent theme.
|
// Canvas colors; played/hover follow the adaptive accent theme.
|
||||||
const UNPLAYED = "rgba(255,255,255,0.24)";
|
const UNPLAYED = "rgba(255,255,255,0.24)";
|
||||||
const PREVIEW = "rgba(255,255,255,0.66)";
|
const PREVIEW = "rgba(255,255,255,0.66)";
|
||||||
const PLAYHEAD = "#ffffff";
|
/** Bar geometry, identical to NowPlaying's BAR_PITCH / BAR_W. */
|
||||||
|
const BAR_PITCH = 6;
|
||||||
|
const BAR_W = 2;
|
||||||
|
const BAR_GAP = BAR_PITCH - BAR_W;
|
||||||
|
|
||||||
function fmt(secs) {
|
function fmt(secs) {
|
||||||
if (secs == null || !isFinite(secs)) return "–:––";
|
if (secs == null || !isFinite(secs)) return "–:––";
|
||||||
@@ -99,7 +230,9 @@
|
|||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
if (player.status === "playing") api.playerControl("pause");
|
if (player.status === "playing") api.playerControl("pause");
|
||||||
else if (player.status === "paused") api.playerControl("resume");
|
// Not a bare `resume`: a track restored from the last session has no
|
||||||
|
// audio loaded behind it and has to be re-sourced from the queue.
|
||||||
|
else if (player.status === "paused") resumePlayback();
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekTarget(e, el) {
|
function seekTarget(e, el) {
|
||||||
@@ -249,7 +382,7 @@
|
|||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const W = wrap.clientWidth;
|
const W = wrap.clientWidth;
|
||||||
const H = 26;
|
const H = 24; // matches .wave-wrap
|
||||||
if (W <= 0) return;
|
if (W <= 0) return;
|
||||||
const pixelWidth = Math.max(1, Math.round(W * dpr));
|
const pixelWidth = Math.max(1, Math.round(W * dpr));
|
||||||
const pixelHeight = Math.max(1, Math.round(H * dpr));
|
const pixelHeight = Math.max(1, Math.round(H * dpr));
|
||||||
@@ -264,27 +397,32 @@
|
|||||||
|
|
||||||
const N = peaks.length;
|
const N = peaks.length;
|
||||||
const mid = H / 2;
|
const mid = H / 2;
|
||||||
// Thin waveform floating in the middle: cap the amplitude well short
|
|
||||||
// of the edges so loud sections don't clip flat, and keep a small
|
|
||||||
// baseline so quiet sections still read as a hairline.
|
|
||||||
const maxAmp = 7;
|
|
||||||
const amp = (v) => Math.max(0.7, v * maxAmp);
|
|
||||||
const geometryChanged =
|
const geometryChanged =
|
||||||
resized ||
|
resized ||
|
||||||
wavePathPeaks !== peaks ||
|
wavePathPeaks !== peaks ||
|
||||||
wavePathWidth !== W ||
|
wavePathWidth !== W ||
|
||||||
wavePathHeight !== H;
|
wavePathHeight !== H;
|
||||||
if (geometryChanged) {
|
if (geometryChanged) {
|
||||||
|
// Discrete bars on the SAME pitch the Now Playing rail draws — a
|
||||||
|
// 2px bar on a 4px gap, 1px radius — so the two waveforms in the
|
||||||
|
// window read as one instrument rather than two. Both derive their
|
||||||
|
// bar count from that pitch, so neither can drift from the other.
|
||||||
|
// Mirrored about the centre line here, where the lane is short and
|
||||||
|
// wide; the rail stands its bars on the floor because it sits above
|
||||||
|
// a caption.
|
||||||
const path = new Path2D();
|
const path = new Path2D();
|
||||||
path.moveTo(0, mid - amp(peaks[0]));
|
const count = Math.max(8, Math.floor((W + BAR_GAP) / BAR_PITCH));
|
||||||
const divisor = Math.max(1, N - 1);
|
for (let i = 0; i < count; i++) {
|
||||||
for (let i = 0; i < N; i++) {
|
const from = Math.floor((i / count) * N);
|
||||||
path.lineTo((i / divisor) * W, mid - amp(peaks[i]));
|
const to = Math.max(from + 1, Math.floor(((i + 1) / count) * N));
|
||||||
|
let sum = 0;
|
||||||
|
for (let j = from; j < to; j++) sum += peaks[j] ?? 0;
|
||||||
|
// The rail's shaping, verbatim: capped so a loud passage cannot
|
||||||
|
// flatten into a ruler, then gamma'd back up.
|
||||||
|
const v = sum / (to - from);
|
||||||
|
const h = Math.max(2, Math.pow(Math.min(0.88, v * 1.18), 0.8) * H);
|
||||||
|
path.roundRect(i * BAR_PITCH, mid - h / 2, BAR_W, h, 1);
|
||||||
}
|
}
|
||||||
for (let j = N - 1; j >= 0; j--) {
|
|
||||||
path.lineTo((j / divisor) * W, mid + amp(peaks[j]));
|
|
||||||
}
|
|
||||||
path.closePath();
|
|
||||||
wavePath = path;
|
wavePath = path;
|
||||||
wavePathPeaks = peaks;
|
wavePathPeaks = peaks;
|
||||||
wavePathWidth = W;
|
wavePathWidth = W;
|
||||||
@@ -322,15 +460,14 @@
|
|||||||
// `accentHover` remains reserved for hover previews and controls.
|
// `accentHover` remains reserved for hover previews and controls.
|
||||||
fillClip(0, playX, theme.accent);
|
fillClip(0, playX, theme.accent);
|
||||||
|
|
||||||
// Playhead / hover markers: fixed height, padded off the edges,
|
// No standing playhead rule any more: at this pitch the accent's own
|
||||||
// so they read as small ticks rather than full-height rules.
|
// leading edge marks the position, and a white line through the bars
|
||||||
const LINE_H = 18;
|
// read as a seam. The hover tick stays — it is the only thing that
|
||||||
const lineY = (H - LINE_H) / 2;
|
// says where a click would land.
|
||||||
ctx.fillStyle = PLAYHEAD;
|
|
||||||
ctx.fillRect(playX - 0.75, lineY, 1.5, LINE_H);
|
|
||||||
if (hoverX >= 0) {
|
if (hoverX >= 0) {
|
||||||
|
const LINE_H = 18;
|
||||||
ctx.fillStyle = theme.accentHover;
|
ctx.fillStyle = theme.accentHover;
|
||||||
ctx.fillRect(hoverX - 0.5, lineY, 1, LINE_H);
|
ctx.fillRect(hoverX - 0.5, (H - LINE_H) / 2, 1, LINE_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastPlayX = playX;
|
lastPlayX = playX;
|
||||||
@@ -381,24 +518,50 @@
|
|||||||
<span class="title muted">Nothing playing</span>
|
<span class="title muted">Nothing playing</span>
|
||||||
{/if}
|
{/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"}
|
{#if player.status === "buffering"}
|
||||||
<span class="sub">{metaParts[0] || "buffering…"}</span>
|
<span class="who"><span class="sub">{metaParts[0] || "buffering…"}</span></span>
|
||||||
{:else if player.track}
|
{:else if player.track && (metaParts[0] || metaParts[1])}
|
||||||
{#if metaParts[0]}
|
<span class="who">
|
||||||
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
{#if metaParts[0]}
|
||||||
{metaParts[0]}
|
<button class="sub link" onclick={openCurrentArtist} title={metaParts[0]}>
|
||||||
</button>
|
{metaParts[0]}
|
||||||
{/if}
|
</button>
|
||||||
{#if metaParts[1]}
|
{/if}
|
||||||
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
{#if metaParts[0] && metaParts[1]}<span class="sep">·</span>{/if}
|
||||||
{metaParts[1]}
|
{#if metaParts[1]}
|
||||||
</button>
|
<button class="sub link album" onclick={openCurrentAlbum} title={metaParts[1]}>
|
||||||
{/if}
|
{metaParts[1]}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls">
|
<!-- Who is sending this, and how far along. Its OWN slot in the bar, not a
|
||||||
|
child of `.now-playing`: sharing that budget meant a long transfer line
|
||||||
|
("peer · 50% · 0.3 MB/s · 51s left") crowded the artist and album down
|
||||||
|
to "Po… · Terrestria…". The two now have independent room, and this one
|
||||||
|
is what the container query drops when the bar gets tight. Absent
|
||||||
|
entirely for a library track — see `sourceParts`. -->
|
||||||
|
{#if sourceParts.length || qualityChip}
|
||||||
|
<div class="src-slot" bind:this={srcSlotEl}>
|
||||||
|
<div class="src-line" bind:this={srcLineEl}>
|
||||||
|
{#each sourceParts as part, i}
|
||||||
|
{#if i > 0}<span class="sep">·</span>{/if}
|
||||||
|
<span class:peer={i === 0 && srcState === "transferring"}>{part}</span>
|
||||||
|
{/each}
|
||||||
|
{#if qualityChip}
|
||||||
|
{#if sourceParts.length}<span class="sep">·</span>{/if}
|
||||||
|
<span class="qchip" class:loss={LOSSLESS.has(quality?.format)}>{qualityChip}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button
|
<button
|
||||||
class="ctl small"
|
class="ctl small"
|
||||||
@@ -471,18 +634,44 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<span class="time">{fmt(player.duration)}</span>
|
<span class="time">{fmt(player.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="extras">
|
||||||
<SaveMenu align="up" />
|
<SaveMenu align="up" />
|
||||||
<button
|
|
||||||
class="vol-icon"
|
<!-- Everything past Save goes behind one control, so landing lyrics,
|
||||||
class:on={ui.nowPlaying}
|
a mini player and output switching later costs no width. -->
|
||||||
title="Now playing"
|
<div class="menu-wrap" bind:this={menuWrap}>
|
||||||
onclick={toggleNowPlaying}
|
<button
|
||||||
>
|
class="vol-icon"
|
||||||
<Icon name="panel-right" size={17} />
|
class:on={menuOpen}
|
||||||
</button>
|
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}>
|
<button class="vol-icon" title={player.muted ? "Unmute" : "Mute"} onclick={toggleMute}>
|
||||||
<Icon name={volumeIcon} size={18} />
|
<Icon name={volumeIcon} size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -507,26 +696,47 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<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.
|
||||||
|
|
||||||
|
`container-type` is for the shelf and the source slot below. This element
|
||||||
|
spans all three grid columns, so its inline size IS the window's usable
|
||||||
|
width — no sidebar or rail sits inside it. */
|
||||||
.player {
|
.player {
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 16px;
|
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);
|
background: var(--bg-player);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
container-type: inline-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
.now-playing {
|
.now-playing {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 11px;
|
||||||
min-width: 0;
|
min-width: 170px;
|
||||||
|
/* Capped, or a long title takes the bar: an unbounded cluster shrank
|
||||||
|
the waveform to 106px on "A Very Long Track Title…". The lane is the
|
||||||
|
point of this bar, so the title ellipsises before the lane starves.
|
||||||
|
This is now the meta ALONE — the source line has its own slot — so
|
||||||
|
the budget no longer moves when a transfer line grows. */
|
||||||
|
max-width: min(32%, 420px);
|
||||||
|
flex: 0 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 40px, the size of a sidebar recents cover, for the same reason. */
|
||||||
.art {
|
.art {
|
||||||
width: 56px;
|
width: 40px;
|
||||||
height: 56px;
|
height: 40px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -557,6 +767,38 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
min-width: 0;
|
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;
|
||||||
|
}
|
||||||
|
.who .sub:not(.album) {
|
||||||
|
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
|
/* Separate, clickable meta lines. Buttons are width-constrained so the
|
||||||
@@ -573,15 +815,16 @@
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.title.muted { color: var(--text-secondary); }
|
.title.muted { color: var(--text-secondary); }
|
||||||
.title.err { color: #f15e6c; }
|
.title.err { color: #f15e6c; }
|
||||||
.title.link:hover { text-decoration: underline; }
|
.title.link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.sub {
|
.sub {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1.35;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
.sub.link:hover {
|
.sub.link:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -590,21 +833,61 @@
|
|||||||
.sub.album {
|
.sub.album {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.sub.album.link:hover { color: var(--text-primary); }
|
.sub.album.link:hover { color: var(--text-primary); }
|
||||||
|
|
||||||
.controls {
|
/* The source line. Width is written by sizeSourceSlot(), never by the
|
||||||
|
content: while a transfer runs its text changes on every position event,
|
||||||
|
and a slot that hugged it would slide the transport and the scrubber
|
||||||
|
sideways continuously. Border-box is global, so that width INCLUDES the
|
||||||
|
padding — otherwise the padding eats the content and clips the last chip. */
|
||||||
|
.src-slot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
width 0.34s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
padding 0.34s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.src-line {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: min(45vw, 560px);
|
width: max-content;
|
||||||
|
flex: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
/* So a ticking percentage and a counting-down ETA do not reflow. */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.src-line .sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.src-line .peer {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.qchip {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 1.5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-highlight);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.qchip.loss {
|
||||||
|
color: #7ee08a;
|
||||||
|
background: rgba(80, 200, 110, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctl {
|
.ctl {
|
||||||
@@ -665,12 +948,18 @@
|
|||||||
|
|
||||||
/* Fixed height so swapping between the thin bar and the waveform
|
/* Fixed height so swapping between the thin bar and the waveform
|
||||||
canvas never reflows the buttons above. */
|
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 {
|
.scrubber {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
flex: 1 1 auto;
|
||||||
height: 26px;
|
/* 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 {
|
.time {
|
||||||
@@ -723,7 +1012,8 @@
|
|||||||
.wave-wrap {
|
.wave-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 26px;
|
min-width: 0;
|
||||||
|
height: 24px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
@@ -750,8 +1040,69 @@
|
|||||||
.extras {
|
.extras {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
justify-content: flex-end;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* What gives way as the window narrows, ordered by how much is lost. The
|
||||||
|
source line goes first — it is enrichment, and the lane is not. */
|
||||||
|
@container (max-width: 1180px) {
|
||||||
|
.src-slot { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.vol-icon {
|
.vol-icon {
|
||||||
|
|||||||
@@ -84,7 +84,10 @@
|
|||||||
--art-motion: 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
--art-motion: 160ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
--art-wash: rgba(0, 0, 0, 0.18);
|
--art-wash: rgba(0, 0, 0, 0.18);
|
||||||
--sidebar-width: 232px;
|
--sidebar-width: 232px;
|
||||||
--player-height: 88px;
|
/* One row, not two: the transport, the scrubber and the meta share a single
|
||||||
|
baseline (see PlayerBar.svelte). The 26px this frees goes to the content
|
||||||
|
area on every screen. */
|
||||||
|
--player-height: 62px;
|
||||||
--nowplaying-width: 340px;
|
--nowplaying-width: 340px;
|
||||||
/* What the Now Playing panel collapses TO. It never collapses to zero —
|
/* What the Now Playing panel collapses TO. It never collapses to zero —
|
||||||
see the note in App.svelte and the rail in NowPlaying.svelte. */
|
see the note in App.svelte and the rail in NowPlaying.svelte. */
|
||||||
|
|||||||
Reference in new issue
Block a user