// 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:glass { on } on dark-glass flips // (applyGlass) + hello // 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 { glassOn } from "./glass.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(); // Frost state rides its own channel: hello answers with the current // value, applyGlass pushes flips live (see glass.js). void emit("mini:glass", { on: glassOn() }); }); 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); } }); }