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:
KeelrLeek committed 2026-08-26 07:56:00 -05:00
1 parent 9b84b30aef
commit dc7ea16b22
33 files changed
+2432 -5

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
// The mini player's lifeline. The mini window is a REMOTE CONTROL, not a
// second brain: it never imports the player store (a second instance would
// double-run the auto-advance and fight this window over the queue — the
// same phantom-instance trap the probe rules exist for). Instead THIS window
// — the only brain, alive while hidden — streams view-state out over three
// channels and executes command intents that come back:
//
// mini:track { title, artist, album, artUrl, duration, waveform,
// coverage, accent, hover } on track/waveform change
// mini:tick { position, status, volume, muted, shuffle, repeat }
// throttled ~5/s
// mini:queue { rows: [{ title, dur }], index, next: { title, artUrl } }
// on queue shape change
// mini:cmd { op, value } mini → here
// mini:hello {} mini → here, answered
// with a full snapshot
import { emit } from "@tauri-apps/api/event";
import * as api from "./api.js";
import {
player,
theme,
togglePlayPause,
nextTrack,
prevTrack,
toggleShuffle,
cycleRepeat,
jumpToQueueIndex,
peekNextIndices,
engineTime,
getArt,
getCatalogArt,
playerArtSource,
} from "./state.svelte.js";
let started = false;
/** Split the track's sub line ("Artist · Album") the way the bar renders it. */
function meta() {
const t = player.track;
if (!t) return { title: null, artist: null, album: null };
const parts = (t.sub ?? "").split(" · ");
return { title: t.title ?? null, artist: parts[0] || null, album: parts[1] || null };
}
async function artUrlForCurrent() {
const item = player.queue?.[player.queueIndex];
if (!item) return null;
try {
if (item.art) return await getArt(item.art);
if (item.artUrl) return await getCatalogArt(playerArtSource(item));
} catch {}
return null;
}
let trackGen = 0;
async function emitTrack() {
const gen = ++trackGen;
const m = meta();
const artUrl = await artUrlForCurrent();
if (gen !== trackGen) return; // a newer track landed while art resolved
void emit("mini:track", {
...m,
artUrl,
duration: player.duration,
waveform: player.waveform,
coverage: player.waveformCoverage,
accent: theme.accentTarget,
hover: theme.accentHoverTarget,
});
}
async function emitQueue() {
const rows = (player.queue ?? []).map((it) => ({
title: it.title ?? "—",
dur: it.duration ?? it.duration_secs ?? null,
}));
let next = null;
const [ni] = peekNextIndices(1);
const nItem = ni != null ? player.queue?.[ni] : null;
if (nItem) {
let artUrl = null;
try {
if (nItem.art) artUrl = await getArt(nItem.art, 128);
else if (nItem.artUrl) artUrl = await getCatalogArt(playerArtSource(nItem));
} catch {}
next = { title: nItem.title ?? "—", artUrl };
}
void emit("mini:queue", { rows, index: player.queueIndex, next });
}
function emitTick() {
void emit("mini:tick", {
position: player.position,
status: player.status,
volume: player.muted ? 0 : player.volume,
muted: player.muted,
shuffle: player.shuffle,
repeat: player.repeat,
});
}
export function initMiniBridge() {
if (started) return;
started = true;
// ---- outbound: watch the store, emit on the three channels ----------
$effect.root(() => {
// Track identity + waveform arrivals. The waveform array is replaced
// wholesale by the store, so reference equality is the change signal.
$effect(() => {
void player.track?.title;
void player.track?.sub;
void player.duration;
void player.waveform;
void player.waveformCoverage;
void emitTrack();
});
// Accent: stream the crossfade TARGET, set synchronously when the
// fade starts, and let the mini run its own CSS fade. Never mirror
// theme.accent from here — it lerps via rAF in THIS window, which is
// hidden in mini mode, so it freezes mid-color (and a trailing
// debounce on it turned the mini's fade into one late snap).
$effect(() => {
void theme.accentTarget;
void theme.accentHoverTarget;
void emitTrack();
});
$effect(() => {
void player.queue;
void player.queueIndex;
void player.shuffle;
void player.shuffleOrder;
void player.repeat;
void emitQueue();
});
let tickTimer = null;
$effect(() => {
void player.position;
void player.status;
void player.volume;
void player.muted;
void player.shuffle;
void player.repeat;
if (tickTimer) return;
tickTimer = setTimeout(() => {
tickTimer = null;
emitTick();
}, 180);
});
});
// ---- inbound: the mini's intents, executed by this brain ------------
api.onEvent("mini:hello", () => {
void emitTrack();
void emitQueue();
emitTick();
});
api.onEvent("mini:cmd", (p) => {
const { op, value } = p ?? {};
if (op === "toggle") togglePlayPause();
else if (op === "next") nextTrack();
else if (op === "prev") prevTrack();
else if (op === "shuffle") toggleShuffle();
else if (op === "repeat") cycleRepeat();
else if (op === "jump") jumpToQueueIndex(value);
else if (op === "seek") api.playerControl("seek", engineTime(value));
else if (op === "volume") {
const frac = Math.max(0, Math.min(1, value));
player.volume = frac;
player.muted = frac === 0;
api.playerControl("volume", frac);
}
});
}