forked from Keelrleek/SoulStream
mini player + dark glass: eight shapes run by the hidden main as the one brain, dissolving covers and accents, drag from anywhere, and the space between the panes turned to real acrylic - with the webview flags that keep rest frames honest
This commit is contained in:
1 parent
9b84b30aef
commit
dc7ea16b22
33 files changed
+2432
-5
No files matched your search
@@ -0,0 +1,119 @@
|
||||
<script>
|
||||
// The mini player window's root: boots the mirror, sizes the window to
|
||||
// the chosen shape, and mounts it. The shared control classes (.m-disc,
|
||||
// .m-skip, .m-mode) live here as :global so eight shapes speak one
|
||||
// dialect — the disc is the app's one true play control (filled accent,
|
||||
// padding: 0 against the UA-padding trap).
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { initMini, shapeState, setShape, mini } from "./mini.svelte.js";
|
||||
import Strip from "./shapes/Strip.svelte";
|
||||
import Sleeve from "./shapes/Sleeve.svelte";
|
||||
import Sliver from "./shapes/Sliver.svelte";
|
||||
import Wave from "./shapes/Wave.svelte";
|
||||
import Focus from "./shapes/Focus.svelte";
|
||||
import Deck from "./shapes/Deck.svelte";
|
||||
import OnDeck from "./shapes/OnDeck.svelte";
|
||||
import Queue from "./shapes/Queue.svelte";
|
||||
|
||||
const SHAPE_MAP = {
|
||||
strip: Strip,
|
||||
sleeve: Sleeve,
|
||||
sliver: Sliver,
|
||||
wave: Wave,
|
||||
focus: Focus,
|
||||
deck: Deck,
|
||||
ondeck: OnDeck,
|
||||
queue: Queue,
|
||||
};
|
||||
const Current = $derived(SHAPE_MAP[shapeState.id] ?? Strip);
|
||||
|
||||
initMini();
|
||||
// Assert the stored shape's size on boot (the window opens with the
|
||||
// last-known size from mini_open, which may belong to another shape).
|
||||
void setShape(shapeState.id);
|
||||
|
||||
// Drag ANYWHERE to move the window — the whole mini is a titlebar except
|
||||
// the things you actually operate. One owner for dragging: the shapes'
|
||||
// data-tauri-drag-region patchwork is gone (the attribute only fires when
|
||||
// the press lands ON the attributed element, so art, wash, padding and
|
||||
// rows were all dead zones — "finicky").
|
||||
const NO_DRAG =
|
||||
"button, a, input, select, textarea, [role='slider'], .wave-wrap, .volwrap, .fly, .qlist, .picker, [data-nodrag]";
|
||||
function dragAnywhere(e) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target.closest?.(NO_DRAG)) return;
|
||||
void getCurrentWindow().startDragging();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerdown={dragAnywhere} />
|
||||
|
||||
<div class="miniroot">
|
||||
<Current />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* The accent crossfade, the mini's way: --accent is a registered
|
||||
@property (<color>), so a CSS transition interpolates it and every
|
||||
var() consumer — plus the per-frame getComputedStyle readers in Lane
|
||||
and Wave — rides one fade. Ease matches the main's t·(2−t) lerp. */
|
||||
:global(html) {
|
||||
transition:
|
||||
--accent 1200ms cubic-bezier(0.25, 0.46, 0.45, 0.94),
|
||||
--accent-hover 1200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
.miniroot {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
user-select: none;
|
||||
}
|
||||
:global(.m-disc) {
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #0b0b0e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0; /* global.css leaves UA button padding on — never trust it */
|
||||
transition: background 120ms, transform 120ms;
|
||||
}
|
||||
:global(.m-disc:hover) {
|
||||
background: var(--accent-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
:global(.m-disc.big) {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
:global(.m-disc.small) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
:global(.m-skip) {
|
||||
color: var(--text-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
flex: none;
|
||||
}
|
||||
:global(.m-skip:hover) {
|
||||
color: #fff;
|
||||
}
|
||||
:global(.m-mode) {
|
||||
color: var(--text-muted);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
flex: none;
|
||||
}
|
||||
:global(.m-mode:hover) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
:global(.m-mode.on) {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
// A cover slot that DISSOLVES on track change instead of popping: the
|
||||
// outgoing record holds underneath while the incoming one fades in over
|
||||
// it, then the old one is dropped. untrack the self-read — the guard
|
||||
// must not make this effect re-trigger on its own write.
|
||||
import { untrack } from "svelte";
|
||||
|
||||
let { url = null } = $props();
|
||||
|
||||
let shown = $state(null);
|
||||
let prev = $state(null);
|
||||
let timer;
|
||||
$effect(() => {
|
||||
const next = url;
|
||||
if (next === untrack(() => shown)) return;
|
||||
prev = untrack(() => shown);
|
||||
shown = next;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (prev = null), 900);
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="artfade" aria-hidden="true">
|
||||
{#if prev}<img class="under" src={prev} alt="" draggable="false" />{/if}
|
||||
{#key shown}
|
||||
{#if shown}<img src={shown} alt="" draggable="false" />{/if}
|
||||
{/key}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.artfade {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
animation: artin 500ms ease;
|
||||
}
|
||||
img.under {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes artin {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script>
|
||||
// Window chrome for the mini: pin, shape picker, back-to-app. It reveals
|
||||
// only while the pointer is over the ARTWORK's top strip — the one
|
||||
// surface no shape puts controls on — so the pill can never cover
|
||||
// something clickable (the mockup round that settled this). The shape
|
||||
// that hosts it passes its art element as the anchor.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import { mini, togglePin, backToApp, SHAPES, shapeState, setShape } from "../mini.svelte.js";
|
||||
|
||||
let { anchor = null } = $props();
|
||||
|
||||
let show = $state(false);
|
||||
let picker = $state(false);
|
||||
|
||||
function onMove(e) {
|
||||
if (!anchor) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const inZone =
|
||||
e.clientX >= a.left &&
|
||||
e.clientX <= a.right &&
|
||||
e.clientY >= a.top &&
|
||||
e.clientY <= Math.min(a.bottom, a.top + 46);
|
||||
show = inZone || picker;
|
||||
}
|
||||
function onLeave() {
|
||||
show = picker;
|
||||
}
|
||||
function pick(id) {
|
||||
picker = false;
|
||||
show = false;
|
||||
void setShape(id);
|
||||
}
|
||||
// The open picker COVERS the pill (so it fits the shortest shapes), which
|
||||
// hides its own toggle — outside-click and Escape are the way back out.
|
||||
function outside(e) {
|
||||
if (picker && !e.target.closest(".picker, .chrome")) {
|
||||
picker = false;
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
function key(e) {
|
||||
if (e.key === "Escape" && picker) {
|
||||
picker = false;
|
||||
show = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointermove={onMove} onpointerdown={outside} onkeydown={key} />
|
||||
<svelte:document onpointerleave={onLeave} />
|
||||
|
||||
<div class="chrome" class:show={show || picker}>
|
||||
<button title={mini.pinned ? "Unpin from top" : "Pin on top"} class:on={mini.pinned} onclick={togglePin}>
|
||||
<Icon name="pin" size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
<button title="Change shape" class:on={picker} onclick={() => (picker = !picker)}>
|
||||
<Icon name="shapes" size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
<button title="Back to full app" onclick={backToApp}>
|
||||
<Icon name="restore" size={12} strokeWidth={2.4} />
|
||||
</button>
|
||||
</div>
|
||||
{#if picker}
|
||||
<div class="picker">
|
||||
{#each SHAPES as s (s.id)}
|
||||
<button class:on={s.id === shapeState.id} onclick={() => pick(s.id)}>{s.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.chrome {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
background: rgba(12, 12, 14, 0.78);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 130ms ease;
|
||||
}
|
||||
.chrome.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.chrome button {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
}
|
||||
.chrome button:hover {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.chrome button.on {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* Four across and OVER the pill, not below it: the shortest shape
|
||||
(Sliver, 68px) can't host a dropdown — a 2×4 card starting at the top
|
||||
edge is the only geometry that fits every window. */
|
||||
.picker {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 5px;
|
||||
z-index: 31;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, auto);
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
background: rgba(14, 14, 17, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
.picker button {
|
||||
font-size: 11px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 5px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.picker button:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
.picker button.on {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script>
|
||||
// The playbar's scrubber lane, miniaturised: same waveLevels shaping,
|
||||
// same BAR_PITCH/BAR_W geometry, same colors (unplayed --text-muted,
|
||||
// played the accent, boundary bar cross-faded by its fractional fill).
|
||||
// The playbar's hover marker and loading motions stay where they are —
|
||||
// this lane is for a window a tenth the size.
|
||||
import { BAR_PITCH, BAR_W, waveLevels } from "../../lib/waveshape.js";
|
||||
import { mini, seekTo, fmt } from "../mini.svelte.js";
|
||||
|
||||
let { slim = false, showTimes = true } = $props();
|
||||
|
||||
let canvas = $state(null);
|
||||
let wrap = $state(null);
|
||||
let levels = [];
|
||||
let levelsFor = { wf: null, count: 0 };
|
||||
|
||||
const UNPLAYED = "#7a7a7a"; // --text-muted, the lanes' shared grey
|
||||
const MUTED_RGB = [122, 122, 122];
|
||||
|
||||
function accentRgb() {
|
||||
// --accent is a registered @property (<color>) in global.css, so the
|
||||
// COMPUTED value comes back as "rgb(r, g, b)" — not the authored hex.
|
||||
// Parse both forms; fall back to the muted grey, never to black.
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
|
||||
if (v[0] === "#") {
|
||||
const n = parseInt(v.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
const m = v.match(/[\d.]+/g);
|
||||
return m && m.length >= 3 ? [+m[0], +m[1], +m[2]] : MUTED_RGB;
|
||||
}
|
||||
|
||||
export function draw() {
|
||||
if (!canvas || !wrap) return;
|
||||
const w = wrap.clientWidth;
|
||||
const h = wrap.clientHeight;
|
||||
if (!w) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (canvas.width !== Math.round(w * dpr)) {
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const count = Math.max(8, Math.floor((w + (BAR_PITCH - BAR_W)) / BAR_PITCH));
|
||||
if (levelsFor.wf !== mini.waveform || levelsFor.count !== count) {
|
||||
levels = waveLevels(mini.waveform, count);
|
||||
levelsFor = { wf: mini.waveform, count };
|
||||
}
|
||||
const mid = h / 2;
|
||||
const dur = mini.duration || 1;
|
||||
const exact = (mini.position / dur) * count;
|
||||
const full = Math.floor(exact);
|
||||
const acc = accentRgb();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const amp = levels.length ? levels[i] : 0.09; // resting dots
|
||||
const bh = Math.max(2, amp * h);
|
||||
let color;
|
||||
if (i < full) color = `rgb(${acc.join(",")})`;
|
||||
else if (i === full) {
|
||||
const f = exact - full;
|
||||
const m = MUTED_RGB.map((c, k) => Math.round(c + (acc[k] - c) * f));
|
||||
color = `rgb(${m.join(",")})`;
|
||||
} else color = UNPLAYED;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(i * BAR_PITCH, mid - bh / 2, BAR_W, bh, 1);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// Redraw on every mirrored change + rAF for the smoothed position.
|
||||
$effect(() => {
|
||||
void mini.waveform;
|
||||
void mini.duration;
|
||||
let alive = true;
|
||||
const loop = () => {
|
||||
if (!alive) return;
|
||||
draw();
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
loop();
|
||||
return () => (alive = false);
|
||||
});
|
||||
|
||||
function seekAt(e) {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
const f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
||||
const t = f * (mini.duration || 0);
|
||||
mini.position = t; // optimistic, the tick confirms
|
||||
seekTo(t);
|
||||
}
|
||||
function onDown(e) {
|
||||
seekAt(e);
|
||||
const mv = (ev) => seekAt(ev);
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lane" class:slim>
|
||||
{#if showTimes}<span class="time">{fmt(mini.position)}</span>{/if}
|
||||
<div class="wave-wrap" bind:this={wrap} onpointerdown={onDown}>
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
{#if showTimes}<span class="time">{fmt(mini.duration)}</span>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.lane {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* The playbar's own numbers: 11px secondary, min-width 32, centred. */
|
||||
.time {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
min-width: 32px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: none;
|
||||
}
|
||||
.wave-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.lane.slim .wave-wrap {
|
||||
height: 16px;
|
||||
}
|
||||
.lane.slim .time {
|
||||
font-size: 9.5px;
|
||||
min-width: 26px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<script>
|
||||
// Speaker button + the vertical flyout (the taskbar-volume gesture Kyle
|
||||
// picked on the mockup). The icon follows the playbar's own state rule —
|
||||
// mute/zero → volume-mute, under half → volume-low, else volume — and the
|
||||
// track is the playbar's knobless geometry stood upright, plus the knob
|
||||
// the flyout needs for grabbing.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import { mini, setVolume } from "../mini.svelte.js";
|
||||
|
||||
let open = $state(false);
|
||||
let btn = $state(null);
|
||||
let below = $state(false);
|
||||
|
||||
const volIcon = $derived(
|
||||
mini.muted || mini.volume === 0 ? "volume-mute" : mini.volume < 0.5 ? "volume-low" : "volume",
|
||||
);
|
||||
|
||||
function toggle() {
|
||||
if (!open && btn) below = btn.getBoundingClientRect().top < 138;
|
||||
open = !open;
|
||||
}
|
||||
function outside(e) {
|
||||
if (open && !e.target.closest(".volwrap")) open = false;
|
||||
}
|
||||
function key(e) {
|
||||
if (e.key === "Escape") open = false;
|
||||
}
|
||||
|
||||
let track = $state(null);
|
||||
function setAt(e) {
|
||||
const r = track.getBoundingClientRect();
|
||||
setVolume(1 - (e.clientY - r.top) / r.height);
|
||||
}
|
||||
function onDown(e) {
|
||||
e.stopPropagation();
|
||||
setAt(e);
|
||||
const mv = (ev) => setAt(ev);
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", mv);
|
||||
window.removeEventListener("pointerup", up);
|
||||
};
|
||||
window.addEventListener("pointermove", mv);
|
||||
window.addEventListener("pointerup", up);
|
||||
}
|
||||
function wheel(e) {
|
||||
e.preventDefault();
|
||||
setVolume(mini.volume - Math.sign(e.deltaY) * 0.05);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onpointerdown={outside} onkeydown={key} />
|
||||
|
||||
<span class="volwrap">
|
||||
<button class="spk" class:on={open} bind:this={btn} title="Volume" onclick={toggle} onwheel={wheel}>
|
||||
<Icon name={volIcon} size={18} />
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="fly" class:below onwheel={wheel}>
|
||||
<div
|
||||
class="vtrack"
|
||||
bind:this={track}
|
||||
onpointerdown={onDown}
|
||||
role="slider"
|
||||
aria-label="Volume"
|
||||
aria-valuenow={Math.round(mini.volume * 100)}
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="vfill" style="height: {(mini.volume * 100).toFixed(1)}%"></div>
|
||||
<div class="vknob" style="bottom: {(mini.volume * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.volwrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
}
|
||||
.spk {
|
||||
color: var(--text-secondary);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.spk:hover,
|
||||
.spk.on {
|
||||
color: #fff;
|
||||
}
|
||||
.fly {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 8px);
|
||||
transform: translateX(-50%);
|
||||
width: 34px;
|
||||
height: 126px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 40;
|
||||
background: rgba(20, 20, 24, 0.55);
|
||||
backdrop-filter: blur(14px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.6);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
animation: flyup 130ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.fly.below {
|
||||
bottom: auto;
|
||||
top: calc(100% + 8px);
|
||||
}
|
||||
@keyframes flyup {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(6px) scale(0.96);
|
||||
}
|
||||
}
|
||||
.vtrack {
|
||||
position: relative;
|
||||
width: 4px;
|
||||
height: 98px;
|
||||
border-radius: 2px;
|
||||
background: hsla(0, 0%, 100%, 0.2);
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
transition: width 100ms;
|
||||
}
|
||||
.vtrack:hover {
|
||||
width: 6px;
|
||||
}
|
||||
.vfill {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.vtrack:hover .vfill {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.vknob {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transform: translate(-50%, 50%);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.55);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script>
|
||||
// The record washing the card — the Now Playing panel's bleed, sized for
|
||||
// the mini. Same rules learned the hard way there (2026-08-25): the
|
||||
// wrapper's opacity 0.99 is the FLATTEN GROUP — img and fade must raster
|
||||
// as one surface or the rounded corners grow a light rim whenever the
|
||||
// compositor splits them. Never round it up to 1, never add a filter or
|
||||
// transform to the wrapper. (Both dissolve imgs live INSIDE the group,
|
||||
// so the corner arc still antialiases once.)
|
||||
import { untrack } from "svelte";
|
||||
|
||||
let { artUrl = null, fade = "side" } = $props();
|
||||
const FADES = {
|
||||
side: "linear-gradient(90deg, rgba(24,24,24,.14), rgba(24,24,24,.74) 55%, rgba(24,24,24,.92))",
|
||||
down: "linear-gradient(rgba(24,24,24,.2), rgba(24,24,24,.66))",
|
||||
deep: "linear-gradient(rgba(24,24,24,.3), rgba(24,24,24,.78))",
|
||||
head: "linear-gradient(rgba(24,24,24,.28), rgba(24,24,24,.9) 45%)",
|
||||
};
|
||||
|
||||
// Track-change dissolve, same shape as bits/Art.svelte.
|
||||
let shown = $state(null);
|
||||
let prev = $state(null);
|
||||
let timer;
|
||||
$effect(() => {
|
||||
const next = artUrl;
|
||||
if (next === untrack(() => shown)) return;
|
||||
prev = untrack(() => shown);
|
||||
shown = next;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => (prev = null), 900);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown || prev}
|
||||
<div class="wash" aria-hidden="true">
|
||||
{#if prev}<img class="under" src={prev} alt="" draggable="false" />{/if}
|
||||
{#key shown}
|
||||
{#if shown}<img src={shown} alt="" draggable="false" />{/if}
|
||||
{/key}
|
||||
<span class="fade" style="background: {FADES[fade] ?? FADES.side}"></span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.wash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
opacity: 0.99; /* the flatten group — see the note above */
|
||||
pointer-events: none;
|
||||
}
|
||||
.wash img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(24px) saturate(1.3);
|
||||
opacity: 0.85;
|
||||
transform: scale(1.15);
|
||||
animation: washin 700ms ease;
|
||||
}
|
||||
.wash img.under {
|
||||
animation: none;
|
||||
}
|
||||
@keyframes washin {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.fade {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,130 @@
|
||||
// The mini window's whole world: a mirror of what the main window streams
|
||||
// over the mini:* channels, and intent senders that go back to it. This file
|
||||
// deliberately does NOT import state.svelte.js — the main window is the one
|
||||
// brain (see miniBridge.svelte.js for the channel contract and the why).
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { onEvent } from "../lib/api.js";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow, LogicalSize } from "@tauri-apps/api/window";
|
||||
|
||||
export const mini = $state({
|
||||
// mini:track
|
||||
title: null,
|
||||
artist: null,
|
||||
album: null,
|
||||
artUrl: null,
|
||||
duration: null,
|
||||
waveform: [],
|
||||
coverage: 1,
|
||||
// mini:tick
|
||||
position: 0,
|
||||
status: "idle",
|
||||
volume: 0.7,
|
||||
muted: false,
|
||||
shuffle: false,
|
||||
repeat: "off",
|
||||
// mini:queue
|
||||
rows: [],
|
||||
index: -1,
|
||||
next: null, // { title, artUrl }
|
||||
// local
|
||||
pinned: true,
|
||||
});
|
||||
|
||||
export const playing = () => mini.status === "playing";
|
||||
|
||||
// ---- shapes ---------------------------------------------------------------
|
||||
import { SHAPES, storedShapeId } from "./shapes.js";
|
||||
export { SHAPES };
|
||||
export const shapeState = $state({ id: storedShapeId() });
|
||||
|
||||
export async function setShape(id) {
|
||||
const s = SHAPES.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
shapeState.id = id;
|
||||
localStorage.setItem("miniShape", id);
|
||||
try {
|
||||
await getCurrentWindow().setSize(new LogicalSize(s.w, s.h));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ---- intents --------------------------------------------------------------
|
||||
export const cmd = (op, value) => void emit("mini:cmd", { op, value });
|
||||
export const seekTo = (secs) => cmd("seek", secs);
|
||||
export function setVolume(frac) {
|
||||
mini.volume = Math.max(0, Math.min(1, frac));
|
||||
mini.muted = mini.volume === 0;
|
||||
cmd("volume", mini.volume);
|
||||
}
|
||||
|
||||
export async function togglePin() {
|
||||
mini.pinned = !mini.pinned;
|
||||
try {
|
||||
await getCurrentWindow().setAlwaysOnTop(mini.pinned);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const backToApp = () => void invoke("mini_close");
|
||||
|
||||
// ---- boot -----------------------------------------------------------------
|
||||
let booted = false;
|
||||
export function initMini() {
|
||||
if (booted) return;
|
||||
booted = true;
|
||||
// Dev-only probe handle, the mini's __soulstream: CDP drives the LIVE
|
||||
// module through this — a bare-URL import can be a phantom second copy.
|
||||
globalThis.__mini = { mini, shapeState, setShape, cmd, setVolume };
|
||||
onEvent("mini:track", (p) => {
|
||||
p = p ?? {};
|
||||
mini.title = p.title;
|
||||
mini.artist = p.artist;
|
||||
mini.album = p.album;
|
||||
mini.artUrl = p.artUrl;
|
||||
mini.duration = p.duration;
|
||||
mini.waveform = p.waveform ?? [];
|
||||
mini.coverage = p.coverage ?? 1;
|
||||
if (p.accent) {
|
||||
document.documentElement.style.setProperty("--accent", p.accent);
|
||||
document.documentElement.style.setProperty("--accent-hover", p.hover ?? p.accent);
|
||||
}
|
||||
});
|
||||
onEvent("mini:tick", (p) => {
|
||||
p = p ?? {};
|
||||
mini.position = p.position ?? mini.position;
|
||||
mini.status = p.status ?? mini.status;
|
||||
if (typeof p.volume === "number") mini.volume = p.volume;
|
||||
mini.muted = !!p.muted;
|
||||
mini.shuffle = !!p.shuffle;
|
||||
mini.repeat = p.repeat ?? mini.repeat;
|
||||
});
|
||||
onEvent("mini:queue", (p) => {
|
||||
p = p ?? {};
|
||||
mini.rows = p.rows ?? [];
|
||||
mini.index = p.index ?? -1;
|
||||
mini.next = p.next ?? null;
|
||||
});
|
||||
// Ask the brain for a full snapshot; retry briefly in case the mini won
|
||||
// the race against the bridge's listener registration.
|
||||
void emit("mini:hello", {});
|
||||
setTimeout(() => {
|
||||
if (mini.title == null) void emit("mini:hello", {});
|
||||
}, 600);
|
||||
// Smooth position between ticks while playing (the brain ticks ~5/s).
|
||||
let last = performance.now();
|
||||
const step = (now) => {
|
||||
const dt = (now - last) / 1000;
|
||||
last = now;
|
||||
if (playing() && mini.duration) {
|
||||
mini.position = Math.min(mini.duration, mini.position + dt);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
export const fmt = (s) => {
|
||||
if (s == null || !isFinite(s)) return "–:––";
|
||||
s = Math.max(0, Math.floor(s));
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// The mini player's shape catalogue — shared between the mini window (which
|
||||
// renders them) and the main window (which needs a size to open the window
|
||||
// at). Logical sizes; the card IS the window, the OS draws the shadow.
|
||||
export const SHAPES = [
|
||||
{ id: "strip", label: "Strip", w: 484, h: 96 },
|
||||
{ id: "sleeve", label: "Sleeve", w: 300, h: 452 },
|
||||
{ id: "sliver", label: "Sliver", w: 356, h: 68 },
|
||||
{ id: "wave", label: "Wave", w: 396, h: 184 },
|
||||
{ id: "focus", label: "Focus", w: 288, h: 330 },
|
||||
{ id: "deck", label: "Deck", w: 600, h: 72 },
|
||||
{ id: "ondeck", label: "On Deck", w: 416, h: 104 },
|
||||
{ id: "queue", label: "Queue", w: 344, h: 262 },
|
||||
];
|
||||
|
||||
export function shapeById(id) {
|
||||
return SHAPES.find((s) => s.id === id) ?? SHAPES[0];
|
||||
}
|
||||
|
||||
export function storedShapeId() {
|
||||
try {
|
||||
return localStorage.getItem("miniShape") || "strip";
|
||||
} catch {
|
||||
return "strip";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script>
|
||||
// V6 · The Deck — Kyle's horizontal reference: art, transport cluster,
|
||||
// centered name over a slim lane, modes and volume on the right.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="side" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="lay">
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="cluster">
|
||||
<button class="m-skip" title="Previous" onclick={() => cmd("prev")}><Icon name="skip-back" size={18} /></button>
|
||||
<button class="m-disc" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={16} /></button>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={18} /></button>
|
||||
</div>
|
||||
<div class="mid">
|
||||
<div class="name"><b>{mini.artist ?? "Nothing playing"}</b>{mini.title ? ` - ${mini.title}` : ""}</div>
|
||||
<Lane slim />
|
||||
</div>
|
||||
<div class="modes">
|
||||
<button class="m-mode" class:on={mini.shuffle} title="Shuffle" onclick={() => cmd("shuffle")}><Icon name="shuffle" size={16} /></button>
|
||||
<button class="m-mode" class:on={mini.repeat !== "off"} title="Repeat" onclick={() => cmd("repeat")}><Icon name={mini.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
|
||||
<VolumeFly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 0 14px 0 8px;
|
||||
}
|
||||
.art {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.cluster {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
.mid {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
.name {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
.name b {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script>
|
||||
// V5 · The Focus card — Kyle's vertical reference: transport riding the
|
||||
// art, centered three-line meta (with the album), the lane, and one
|
||||
// modes row (shuffle · repeat · volume together, per his centring note).
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="deep" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="lay">
|
||||
<div class="artrow">
|
||||
<button class="m-skip" title="Previous" onclick={() => cmd("prev")}><Icon name="skip-back" size={18} /></button>
|
||||
<div class="artbox" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
<button class="m-disc onart" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={19} /></button>
|
||||
</div>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={18} /></button>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="tt">{mini.title ?? "Nothing playing"}</div>
|
||||
<div class="ar">{mini.artist ?? ""}</div>
|
||||
<div class="al">{mini.album ?? ""}</div>
|
||||
</div>
|
||||
<Lane />
|
||||
<div class="modes">
|
||||
<button class="m-mode" class:on={mini.shuffle} title="Shuffle" onclick={() => cmd("shuffle")}><Icon name="shuffle" size={16} /></button>
|
||||
<button class="m-mode" class:on={mini.repeat !== "off"} title="Repeat" onclick={() => cmd("repeat")}><Icon name={mini.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
|
||||
<VolumeFly />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px 14px 12px;
|
||||
gap: 9px;
|
||||
}
|
||||
.artrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.artrow .m-skip {
|
||||
width: 26px;
|
||||
}
|
||||
.artbox {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
/* The disc rides the art but only shows itself under the pointer — the
|
||||
cover stays a cover until you reach for it. Hidden = click-through,
|
||||
so a press on the resting art moves the window like anywhere else. */
|
||||
:global(.m-disc.onart) {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 150ms, background 120ms, transform 120ms;
|
||||
}
|
||||
.artbox:hover :global(.m-disc.onart) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
:global(.m-disc.onart:hover) {
|
||||
transform: translate(-50%, -50%) scale(1.06);
|
||||
}
|
||||
.meta {
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ar {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.al {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.modes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<script>
|
||||
// V7 · On Deck — the queue made visible: the NEXT record leans against
|
||||
// the strip's edge; clicking it plays it now. SoulStream is a gapless
|
||||
// queue player — this is the shape only it can wear.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="side" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="lay">
|
||||
<div class="row">
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="tt">{mini.title ?? "Nothing playing"}</div>
|
||||
<div class="ar">{mini.artist ?? ""}</div>
|
||||
</div>
|
||||
<VolumeFly />
|
||||
<button class="m-disc" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={16} /></button>
|
||||
{#if mini.next}
|
||||
<button class="ondeck" title="Play the next track now" onclick={() => cmd("next")}>
|
||||
{#if mini.next.artUrl}<img src={mini.next.artUrl} alt="" draggable="false" />{/if}
|
||||
<span class="nx"><span class="lbl">Up next</span><span class="nt">{mini.next.title}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<Lane />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 9px 12px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.art {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ar {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ondeck {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
flex: none;
|
||||
max-width: 128px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 7px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
transition: background 120ms;
|
||||
text-align: left;
|
||||
}
|
||||
.ondeck:hover {
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
.ondeck img {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 4px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
flex: none;
|
||||
}
|
||||
.nx {
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
.lbl {
|
||||
font-size: 8.5px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
}
|
||||
.nt {
|
||||
font-size: 10.5px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: block;
|
||||
}
|
||||
.lay :global(.lane) {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,229 @@
|
||||
<script>
|
||||
// V8 · The Queue card — the Rdio shape in our clothes: a header player
|
||||
// over the live queue, the current row wearing the app's stepped
|
||||
// equalizer (height keyframes, never transform — the corner saga).
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd, fmt } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="head" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="head">
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="hr">
|
||||
<div class="toprow">
|
||||
<div class="name">
|
||||
<b>{mini.title ?? "Nothing playing"}</b>{mini.artist ? ` — ${mini.artist}` : ""}
|
||||
</div>
|
||||
<button class="m-mode" class:on={mini.shuffle} title="Shuffle" onclick={() => cmd("shuffle")}><Icon name="shuffle" size={15} /></button>
|
||||
<button class="m-mode" class:on={mini.repeat !== "off"} title="Repeat" onclick={() => cmd("repeat")}><Icon name={mini.repeat === "one" ? "repeat-one" : "repeat"} size={15} /></button>
|
||||
<VolumeFly />
|
||||
</div>
|
||||
<div class="ctlrow">
|
||||
<button class="m-skip" title="Previous" onclick={() => cmd("prev")}><Icon name="skip-back" size={16} /></button>
|
||||
<button class="m-disc small" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={13} /></button>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={16} /></button>
|
||||
<Lane slim />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue">
|
||||
<div class="qhead"><span>Queue</span><span>{mini.rows.length} tracks</span></div>
|
||||
<div class="qlist">
|
||||
{#each mini.rows as row, i (i)}
|
||||
<button class="qrow" class:now={i === mini.index} onclick={() => cmd("jump", i)}>
|
||||
{#if i === mini.index}
|
||||
<span class="eq" class:active={playing()}><b></b><b></b><b></b></span>
|
||||
{:else}
|
||||
<span class="n">{i + 1}</span>
|
||||
{/if}
|
||||
<span class="qt">{row.title}</span>
|
||||
<span class="qd">{row.dur != null ? fmt(row.dur) : ""}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
padding: 11px 12px 9px;
|
||||
flex: none;
|
||||
}
|
||||
.art {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.hr {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.toprow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.name b {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.ctlrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.ctlrow :global(.lane) {
|
||||
flex: 1;
|
||||
}
|
||||
.queue {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.07);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.qhead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px 4px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
flex: none;
|
||||
}
|
||||
.qlist {
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
.qrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
height: 30px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
.qrow:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.n {
|
||||
width: 16px;
|
||||
flex: none;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.qt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.qd {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.qrow.now {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.qrow.now .qd {
|
||||
color: var(--accent);
|
||||
}
|
||||
/* the stepped equalizer — HEIGHT keyframes, never transform */
|
||||
.eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 1.5px;
|
||||
width: 16px;
|
||||
height: 11px;
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
}
|
||||
.eq b {
|
||||
width: 2.5px;
|
||||
background: var(--accent);
|
||||
border-radius: 1px;
|
||||
}
|
||||
.eq.active b {
|
||||
animation: eq 1.2s steps(1, end) infinite;
|
||||
}
|
||||
.eq b:nth-child(1) {
|
||||
height: 40%;
|
||||
--eq-hi: 40%;
|
||||
--eq-lo: 16%;
|
||||
animation-delay: -0.2s;
|
||||
}
|
||||
.eq b:nth-child(2) {
|
||||
height: 90%;
|
||||
--eq-hi: 90%;
|
||||
--eq-lo: 36%;
|
||||
}
|
||||
.eq b:nth-child(3) {
|
||||
height: 60%;
|
||||
--eq-hi: 60%;
|
||||
--eq-lo: 24%;
|
||||
animation-delay: -0.4s;
|
||||
}
|
||||
@keyframes eq {
|
||||
0%,
|
||||
100% {
|
||||
height: var(--eq-lo);
|
||||
}
|
||||
50% {
|
||||
height: var(--eq-hi);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script>
|
||||
// V2 · The Sleeve — the vertical record card: full art, glass foot,
|
||||
// centered meta, the lane, and a three-column transport so the disc sits
|
||||
// on the card's axis no matter what flanks it (Kyle's centring note).
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="down" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="foot">
|
||||
<div class="titlerow">
|
||||
<span class="check" title="In your library"><Icon name="check" size={15} strokeWidth={2.6} /></span>
|
||||
<div class="meta">
|
||||
<div class="tt">{mini.title ?? "Nothing playing"}</div>
|
||||
<div class="ar">{mini.artist ?? ""}</div>
|
||||
</div>
|
||||
<span class="gap"></span>
|
||||
</div>
|
||||
<Lane />
|
||||
<div class="transport">
|
||||
<span class="side l">
|
||||
<button class="m-mode" class:on={mini.shuffle} title="Shuffle" onclick={() => cmd("shuffle")}><Icon name="shuffle" size={16} /></button>
|
||||
</span>
|
||||
<span class="mid">
|
||||
<button class="m-skip" title="Previous" onclick={() => cmd("prev")}><Icon name="skip-back" size={18} /></button>
|
||||
<button class="m-disc big" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={19} /></button>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={18} /></button>
|
||||
</span>
|
||||
<span class="side r">
|
||||
<button class="m-mode" class:on={mini.repeat !== "off"} title="Repeat" onclick={() => cmd("repeat")}><Icon name={mini.repeat === "one" ? "repeat-one" : "repeat"} size={16} /></button>
|
||||
<VolumeFly />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.art {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.foot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 11px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.foot::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
background: rgba(20, 20, 24, 0.3);
|
||||
backdrop-filter: blur(14px) saturate(1.6);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(1.6);
|
||||
}
|
||||
.titlerow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.check {
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
.gap {
|
||||
width: 26px;
|
||||
flex: none;
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
.tt {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ar {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.transport {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
}
|
||||
.side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.side.l {
|
||||
justify-content: flex-start;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.side.r {
|
||||
justify-content: flex-end;
|
||||
padding-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script>
|
||||
// V3 · The Sliver — one row and a slim lane; the smallest footprint.
|
||||
// Long names glide like the playbar's (transform animation only while
|
||||
// it actually runs — never will-change, per the corner saga).
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
let box = $state(null);
|
||||
let glider = $state(null);
|
||||
let over = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
void mini.title;
|
||||
void mini.artist;
|
||||
requestAnimationFrame(() => {
|
||||
if (box && glider) over = Math.max(0, glider.scrollWidth - box.clientWidth);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="side" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="lay">
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="mid">
|
||||
<div class="namebox" class:masked={over > 0} class:gliding={over > 0} style="--glide: {over + 12}px" bind:this={box}>
|
||||
<div class="glider" bind:this={glider}>
|
||||
<b>{mini.title ?? "Nothing playing"}</b><span>{mini.artist ?? ""}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Lane slim />
|
||||
</div>
|
||||
<button class="m-disc" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={16} /></button>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={18} /></button>
|
||||
<VolumeFly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
padding: 0 10px 0 8px;
|
||||
}
|
||||
.art {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.mid {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
}
|
||||
.namebox {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.glider {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
font-size: 12.5px;
|
||||
align-items: baseline;
|
||||
}
|
||||
.glider b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.glider span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.namebox.masked {
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 20px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 20px), transparent);
|
||||
}
|
||||
.namebox.gliding .glider {
|
||||
animation: glide 9s linear infinite;
|
||||
}
|
||||
@keyframes glide {
|
||||
0%,
|
||||
12% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50%,
|
||||
62% {
|
||||
transform: translateX(calc(-1 * var(--glide, 80px)));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script>
|
||||
// V1 · The Strip — the playbar distilled: cover, meta, transport, lane.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let artEl = $state(null);
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="side" />
|
||||
<Chrome anchor={artEl} />
|
||||
<div class="lay">
|
||||
<div class="art" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="row">
|
||||
<div class="meta">
|
||||
<div class="tt">{mini.title ?? "Nothing playing"}</div>
|
||||
<div class="ar">{mini.artist ?? ""}</div>
|
||||
</div>
|
||||
<button class="m-skip" title="Previous" onclick={() => cmd("prev")}><Icon name="skip-back" size={18} /></button>
|
||||
<button class="m-disc" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={16} /></button>
|
||||
<button class="m-skip" title="Next" onclick={() => cmd("next")}><Icon name="skip-forward" size={18} /></button>
|
||||
<VolumeFly />
|
||||
</div>
|
||||
<Lane />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
.art {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tt {
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.ar {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.row :global(.lane) {
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script>
|
||||
// V4 · The Wave — the flowing-line visualizer. Unlike the mockup's BPM
|
||||
// guess, the envelope here is the RECORD's own dynamics: the waveform
|
||||
// peaks the backend already computed, sampled around the playhead — the
|
||||
// line pumps when the song does, because it IS the song.
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Wash from "../bits/Wash.svelte";
|
||||
import Art from "../bits/Art.svelte";
|
||||
import Lane from "../bits/Lane.svelte";
|
||||
import VolumeFly from "../bits/VolumeFly.svelte";
|
||||
import Chrome from "../bits/Chrome.svelte";
|
||||
import { waveLevels } from "../../lib/waveshape.js";
|
||||
import { mini, playing, cmd } from "../mini.svelte.js";
|
||||
|
||||
let flowEl = $state(null);
|
||||
let artEl = $state(null);
|
||||
let phase = 0;
|
||||
let env = 0.3; // smoothed energy
|
||||
|
||||
// A finer fold than the lane's, for reading level at the playhead.
|
||||
let fine = [];
|
||||
let fineFor = null;
|
||||
function levelNow() {
|
||||
if (fineFor !== mini.waveform) {
|
||||
fine = waveLevels(mini.waveform, 512);
|
||||
fineFor = mini.waveform;
|
||||
}
|
||||
if (!fine.length || !mini.duration) return 0.35;
|
||||
const i = Math.min(fine.length - 1, Math.floor((mini.position / mini.duration) * fine.length));
|
||||
// A touch of look-ahead so hits land ON the beat, not after it.
|
||||
const j = Math.min(fine.length - 1, i + 1);
|
||||
return Math.max(fine[i], fine[j] * 0.8);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
let alive = true;
|
||||
let last = performance.now();
|
||||
const loop = (now) => {
|
||||
if (!alive) return;
|
||||
const dt = Math.min(0.05, (now - last) / 1000);
|
||||
last = now;
|
||||
draw(dt);
|
||||
requestAnimationFrame(loop);
|
||||
};
|
||||
requestAnimationFrame(loop);
|
||||
return () => (alive = false);
|
||||
});
|
||||
|
||||
function draw(dt) {
|
||||
const c = flowEl;
|
||||
if (!c || !c.clientWidth) return;
|
||||
const w = c.clientWidth;
|
||||
const h = c.clientHeight;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
if (c.width !== Math.round(w * dpr)) {
|
||||
c.width = Math.round(w * dpr);
|
||||
c.height = Math.round(h * dpr);
|
||||
}
|
||||
const x = c.getContext("2d");
|
||||
x.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
x.clearRect(0, 0, w, h);
|
||||
const target = playing() ? levelNow() : 0.12;
|
||||
// fast attack, slow release — how meters (and eyes) like it
|
||||
env += (target - env) * (target > env ? 0.5 : 0.06);
|
||||
phase += dt * 1.7 * (playing() ? 1 : 0.1);
|
||||
const mid = h / 2;
|
||||
const accent = getComputedStyle(document.documentElement).getPropertyValue("--accent").trim();
|
||||
const layer = (amp, freq, ph, color, width, alpha) => {
|
||||
x.beginPath();
|
||||
for (let px = 0; px <= w; px += 2) {
|
||||
const t = px / w;
|
||||
const e = Math.sin(t * Math.PI);
|
||||
const y =
|
||||
mid +
|
||||
Math.sin(t * freq * Math.PI * 2 + ph) * amp * e +
|
||||
Math.sin(t * freq * 0.53 * Math.PI * 2 - ph * 0.7) * amp * 0.45 * e;
|
||||
px === 0 ? x.moveTo(px, y) : x.lineTo(px, y);
|
||||
}
|
||||
x.strokeStyle = color;
|
||||
x.globalAlpha = alpha;
|
||||
x.lineWidth = width;
|
||||
x.lineCap = "round";
|
||||
x.stroke();
|
||||
x.globalAlpha = 1;
|
||||
};
|
||||
layer(h * 0.42 * env, 2.6, phase * 0.62 + 1.7, "#ffffff", 2, 0.1);
|
||||
layer(h * 0.5 * env, 2.1, phase * 0.8, accent, 2.5, 0.32);
|
||||
layer(h * 0.4 * env, 1.6, phase, accent, 2.5, 0.9);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shape">
|
||||
<Wash artUrl={mini.artUrl} fade="side" />
|
||||
<Chrome anchor={flowEl} />
|
||||
<div class="lay">
|
||||
<canvas class="flow" bind:this={flowEl}></canvas>
|
||||
<div class="namerow">
|
||||
<div class="nart" bind:this={artEl}>
|
||||
<Art url={mini.artUrl} />
|
||||
</div>
|
||||
<div class="name">
|
||||
<b>{mini.artist ?? "Nothing playing"}</b><span> - {mini.title ?? ""}</span>
|
||||
</div>
|
||||
<VolumeFly />
|
||||
<button class="m-disc" title="Play/Pause" onclick={() => cmd("toggle")}><Icon name={playing() ? "pause" : "play"} size={16} /></button>
|
||||
</div>
|
||||
<Lane />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shape {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--bg-player);
|
||||
}
|
||||
.lay {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 16px 10px;
|
||||
}
|
||||
.flow {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.namerow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 4px 0 6px;
|
||||
}
|
||||
.nart {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.name b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.name span {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user