182 lines
6.4 KiB
JavaScript
182 lines
6.4 KiB
JavaScript
// 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);
|
|
}
|
|
});
|
|
}
|
|
|