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

+4
View File
@@ -86,6 +86,9 @@ export const playerControl = (action, value, rate) =>
// playback from events, so after a reload this is the only way to find out that
// audio is still running — see `restoreSession`.
export const playerState = () => invoke("player_state");
// The mini player window (see mini/ and miniBridge.svelte.js).
export const miniOpen = (width, height) => invoke("mini_open", { width, height });
export const miniClose = () => invoke("mini_close");
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
invoke("waveform", { path, buckets, startSecs, endSecs });
export const libraryAlbums = () => invoke("library_albums");
@@ -153,6 +156,7 @@ export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumI
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
export const getSettings = () => invoke("get_settings");
export const setSettings = (patch) => invoke("set_settings", patch);
export const windowGlass = (on) => invoke("window_glass", { on });
export const forgetCredentials = () => invoke("forget_credentials");
export const pickFolder = () => invoke("pick_folder");
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
+10
View File
@@ -0,0 +1,10 @@
// Dark-glass mode, applied live: the CSS half is the `glass` class on the
// root (global.css thins the base surfaces), the OS half is the acrylic
// backdrop behind the transparent window (window_glass command). Both are
// idempotent, so boot and the Settings toggle share this one entry point.
import { windowGlass } from "./api.js";
export function applyGlass(on) {
document.documentElement.classList.toggle("glass", !!on);
void windowGlass(!!on).catch(() => {});
}
+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);
}
});
}
+9
View File
@@ -5582,6 +5582,13 @@ const DEFAULT_ACCENT_HOVER = "#41b7ee";
export const theme = $state({
accent: DEFAULT_ACCENT,
accentHover: DEFAULT_ACCENT_HOVER,
// Where the crossfade is HEADED, set synchronously the moment it starts.
// The mini bridge streams THESE: the lerped pair above only advances via
// rAF in this window, and rAF never fires while this window is hidden —
// mirror the lerp and the mini goes stale the moment it's the only
// window on screen.
accentTarget: DEFAULT_ACCENT,
accentHoverTarget: DEFAULT_ACCENT_HOVER,
});
let accentRaf = null;
@@ -5589,6 +5596,8 @@ let accentRaf = null;
/** Animate --accent/--accent-hover to the target colors (ease-out). */
function crossfadeAccent(accent, hover, ms = 1200) {
if (accent === theme.accent && hover === theme.accentHover) return;
theme.accentTarget = accent;
theme.accentHoverTarget = hover;
if (accentRaf) cancelAnimationFrame(accentRaf);
const fromA = hexToRgb(theme.accent);
const fromH = hexToRgb(theme.accentHover);