forked from Keelrleek/SoulStream
1868 lines
74 KiB
Svelte
1868 lines
74 KiB
Svelte
<script>
|
||
import {
|
||
player,
|
||
theme,
|
||
ui,
|
||
nextTrack,
|
||
prevTrack,
|
||
toggleShuffle,
|
||
cycleRepeat,
|
||
openCurrentArtist,
|
||
openCurrentAlbum,
|
||
openCurrentSong,
|
||
getArt,
|
||
getCatalogArt,
|
||
playerArtSource,
|
||
togglePlayPause,
|
||
togglePlayerExpanded,
|
||
showNowPlaying,
|
||
saveStream,
|
||
goToView,
|
||
engineTime,
|
||
} 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,
|
||
ARRIVE_MS,
|
||
ARRIVE_HOLD_MS,
|
||
PENDING_ALPHA,
|
||
} from "../lib/waveshape.js";
|
||
import Icon from "./Icon.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.
|
||
*
|
||
* Per FIELD, not all-or-nothing. The queue paths disagree about what an
|
||
* item carries: the album-continuation fill writes artist AND album
|
||
* fields beside a bare-artist `sub`; a library play (`playAlbum`) writes
|
||
* only `artist`, with the record's name living inside `sub`
|
||
* ("artist — album"); old restored items may carry neither. Preferring
|
||
* the item's fields as a PAIR whenever either existed took `album: null`
|
||
* at face value and erased the record from every library play — each
|
||
* half now falls back to the `sub` split on its own. */
|
||
const metaParts = $derived.by(() => {
|
||
const sub = player.track?.sub ?? "";
|
||
const split = sub.includes(" — ")
|
||
? sub.split(" — ").map((s) => s.trim())
|
||
: [sub.trim(), ""];
|
||
return [
|
||
currentItem?.artist ?? split[0] ?? "",
|
||
currentItem?.album ?? split[1] ?? "",
|
||
];
|
||
});
|
||
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 fixed name box ------------------------------------------------
|
||
// ONE constant width (picked on research/playbar-redesign/namehover.html,
|
||
// 2026-08-03): the transport is welded in place no matter what plays. A
|
||
// name that fits simply sits in the box; a longer one rests under an edge
|
||
// fade — a mask, never "…" — and glides ONLY while the pointer is over
|
||
// it: out, hold, back, repeat, then eases home on leave. The tooltip
|
||
// still carries the full text. NAME_BOX_PX pairs with `.meta`'s width in
|
||
// the styles below; change them together.
|
||
const NAME_BOX_PX = 240;
|
||
const GLIDE_PX_PER_S = 40;
|
||
const GLIDE_HOLD_MS = 1100;
|
||
const GLIDE_START_DELAY_MS = 320;
|
||
|
||
function nameGlide(node) {
|
||
const inner = node.firstElementChild;
|
||
let overflow = 0;
|
||
let x = 0;
|
||
let phase = "idle";
|
||
let phaseT0 = 0;
|
||
let homeFrom = 0;
|
||
let hovered = false;
|
||
let raf = null;
|
||
|
||
const easeIO = (k) => (k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2);
|
||
const paint = () => {
|
||
inner.style.transform = x ? `translateX(${x.toFixed(1)}px)` : "";
|
||
const right = overflow > 0 && x > -overflow + 1;
|
||
const left = overflow > 0 && x < -1;
|
||
node.classList.toggle("maskLR", left && right);
|
||
node.classList.toggle("maskR", right && !left);
|
||
node.classList.toggle("maskL", left && !right);
|
||
};
|
||
const step = () => {
|
||
raf = null;
|
||
const now = performance.now();
|
||
const dt = now - phaseT0;
|
||
const dur = Math.max(200, (overflow / GLIDE_PX_PER_S) * 1000);
|
||
switch (phase) {
|
||
case "hold0":
|
||
x = 0;
|
||
if (dt > GLIDE_START_DELAY_MS) {
|
||
phase = "out";
|
||
phaseT0 = now;
|
||
}
|
||
break;
|
||
case "out": {
|
||
const k = Math.min(1, dt / dur);
|
||
x = -overflow * easeIO(k);
|
||
if (k >= 1) {
|
||
phase = "hold1";
|
||
phaseT0 = now;
|
||
}
|
||
break;
|
||
}
|
||
case "hold1":
|
||
x = -overflow;
|
||
if (dt > GLIDE_HOLD_MS) {
|
||
phase = "back";
|
||
phaseT0 = now;
|
||
}
|
||
break;
|
||
case "back": {
|
||
const k = Math.min(1, dt / dur);
|
||
x = -overflow * (1 - easeIO(k));
|
||
if (k >= 1) {
|
||
x = 0;
|
||
phase = hovered ? "hold0" : "idle";
|
||
phaseT0 = now;
|
||
}
|
||
break;
|
||
}
|
||
case "home": {
|
||
const hdur = Math.max(120, (Math.abs(homeFrom) / (GLIDE_PX_PER_S * 2)) * 1000);
|
||
const k = Math.min(1, dt / hdur);
|
||
x = homeFrom * (1 - easeIO(k));
|
||
if (k >= 1) {
|
||
x = 0;
|
||
phase = "idle";
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
paint();
|
||
if (phase !== "idle") raf = requestAnimationFrame(step);
|
||
};
|
||
const arm = () => {
|
||
if (raf === null) raf = requestAnimationFrame(step);
|
||
};
|
||
const measure = () => {
|
||
overflow = Math.max(0, Math.ceil(inner.scrollWidth - node.clientWidth));
|
||
x = 0;
|
||
phase = "idle";
|
||
if (raf !== null) {
|
||
cancelAnimationFrame(raf);
|
||
raf = null;
|
||
}
|
||
paint();
|
||
};
|
||
const onEnter = () => {
|
||
hovered = true;
|
||
if (overflow > 0 && (phase === "idle" || phase === "home")) {
|
||
phase = "hold0";
|
||
phaseT0 = performance.now();
|
||
arm();
|
||
}
|
||
};
|
||
const onLeave = () => {
|
||
hovered = false;
|
||
if (phase !== "idle" && phase !== "home") {
|
||
homeFrom = x;
|
||
phase = "home";
|
||
phaseT0 = performance.now();
|
||
arm();
|
||
}
|
||
};
|
||
node.addEventListener("mouseenter", onEnter);
|
||
node.addEventListener("mouseleave", onLeave);
|
||
// Track changes swap the text out from under the box; re-measure a
|
||
// beat later so the DOM (and on first load, the font) has settled.
|
||
const remeasure = () => queueMicrotask(measure);
|
||
const observer = new MutationObserver(remeasure);
|
||
observer.observe(inner, { childList: true, characterData: true, subtree: true });
|
||
window.addEventListener("resize", remeasure);
|
||
document.fonts?.ready.then(remeasure);
|
||
measure();
|
||
return {
|
||
destroy() {
|
||
observer.disconnect();
|
||
window.removeEventListener("resize", remeasure);
|
||
node.removeEventListener("mouseenter", onEnter);
|
||
node.removeEventListener("mouseleave", onLeave);
|
||
if (raf !== null) cancelAnimationFrame(raf);
|
||
},
|
||
};
|
||
}
|
||
|
||
// ---- the overflow menu -------------------------------------------------
|
||
// The popup renders OUTSIDE the footer and position:fixed (the SaveMenu
|
||
// idiom): the bar is overflow:hidden, so an absolute menu inside it got
|
||
// amputated at the bar's top edge — and what poked out still lost to the
|
||
// Now Playing panel's z-indexed layers. Fixed + measured coordinates
|
||
// escape both the clip and the stacking war. It must NOT live inside
|
||
// .extras: that element's centering transform would become a fixed
|
||
// element's containing block and hand it right back to the clip.
|
||
let menuOpen = $state(false);
|
||
let menuWrap = $state(null);
|
||
let menuEl = $state(null);
|
||
let menuPos = $state({ right: 0, bottom: 0 });
|
||
function toggleMenu() {
|
||
if (!menuOpen && menuWrap) {
|
||
const r = menuWrap.getBoundingClientRect();
|
||
// Right-aligned over the button, opening upward — the same seat
|
||
// the old absolute placement (bottom + right of the wrap) gave it.
|
||
menuPos = {
|
||
right: Math.max(8, window.innerWidth - r.right - 6),
|
||
bottom: window.innerHeight - r.top + 6,
|
||
};
|
||
}
|
||
menuOpen = !menuOpen;
|
||
}
|
||
$effect(() => {
|
||
if (!menuOpen) return;
|
||
const away = (e) => {
|
||
const inside =
|
||
(menuWrap && menuWrap.contains(e.target)) ||
|
||
(menuEl && menuEl.contains(e.target));
|
||
if (!inside) menuOpen = false;
|
||
};
|
||
const esc = (e) => e.key === "Escape" && (menuOpen = false);
|
||
// The menu is anchored to coordinates measured at open; a resize
|
||
// moves the anchor out from under them, so it closes instead.
|
||
const close = () => (menuOpen = false);
|
||
window.addEventListener("pointerdown", away);
|
||
window.addEventListener("keydown", esc);
|
||
window.addEventListener("resize", close);
|
||
return () => {
|
||
window.removeEventListener("pointerdown", away);
|
||
window.removeEventListener("keydown", esc);
|
||
window.removeEventListener("resize", close);
|
||
};
|
||
});
|
||
function openQueue() {
|
||
menuOpen = false;
|
||
showNowPlaying(); // the queue lives in the panel; always reveal it
|
||
}
|
||
function openLyrics() {
|
||
menuOpen = false;
|
||
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
|
||
// is gone.
|
||
goToView("lyrics", {
|
||
label: player.track?.title ? `${player.track.title} — Lyrics` : "Lyrics",
|
||
});
|
||
}
|
||
|
||
// ---- 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 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);
|
||
});
|
||
|
||
/* The OS media card (Windows SMTC — src-tauri/src/smtc.rs). Published from
|
||
HERE, not from the store: what Windows shows should be what this bar
|
||
shows, and this is where that is derived — `metaParts` reconciles the
|
||
item's own artist/album fields against the `sub` split, and `artUrl` is
|
||
the cover after the local/catalog fork. Deriving either a second time in
|
||
Rust would be a second set of rules to keep in step.
|
||
Status and position are NOT sent: the backend's player event pump
|
||
already has both and drives the card itself, for no IPC. */
|
||
let lastCard = "";
|
||
$effect(() => {
|
||
const title = player.track?.title ?? "";
|
||
const [artist, album] = metaParts;
|
||
const art = artUrl;
|
||
if (!title) return;
|
||
// Fires twice per track — once when the title lands, again when the
|
||
// cover finishes resolving — and that is deliberate: the card should
|
||
// not wait on artwork to say what is playing. Everything past those is
|
||
// a re-render carrying the same content, dropped here rather than
|
||
// crossing IPC with a few hundred KB of base64 cover for nothing.
|
||
const key = `${title} |