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

+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SoulStream Mini</title>
</head>
<body>
<div id="mini"></div>
<script type="module" src="/src/mini-main.js"></script>
</body>
</html>
+4 -1
View File
@@ -3,7 +3,8 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
"main",
"mini"
],
"permissions": [
"core:default",
@@ -16,6 +17,8 @@
"core:window:allow-is-maximized",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-set-size",
"core:window:allow-set-always-on-top",
"dialog:allow-open"
]
}
+112 -1
View File
@@ -1,4 +1,4 @@
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, Manager, State};
use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event};
@@ -78,6 +78,8 @@ pub struct Settings {
pub volume_normalization: bool,
/// Word-by-word lyric fill — experimental, default off.
pub word_timing: bool,
/// Dark-glass acrylic backdrop, default off.
pub glass_mode: bool,
}
#[tauri::command]
@@ -117,6 +119,7 @@ pub async fn get_settings(
download_source_prefs: cfg.download_source_prefs,
volume_normalization: config::volume_normalization(&cfg),
word_timing: config::word_timing(&cfg),
glass_mode: config::glass_mode(&cfg),
username: cfg.username,
})
}
@@ -145,6 +148,7 @@ pub async fn set_settings(
download_source_prefs: Option<config::SourcePrefs>,
volume_normalization: Option<bool>,
word_timing: Option<bool>,
glass_mode: Option<bool>,
) -> Result<(), String> {
let mut cfg = config::load(&app);
let library_folder_moved = download_dir
@@ -216,6 +220,9 @@ pub async fn set_settings(
if word_timing.is_some() {
cfg.word_timing = word_timing;
}
if glass_mode.is_some() {
cfg.glass_mode = glass_mode;
}
config::save(&app, &cfg);
// A new library folder is a new library. Re-base immediately rather than
@@ -4857,6 +4864,108 @@ pub fn player_control(
Ok(())
}
/// Enter mini mode: show the pre-built mini window (declared in
/// tauri.conf.json, created hidden at startup — runtime window creation
/// from a command half-initialises on Windows) and hide the main one. The
/// main window is HIDDEN, never closed: its frontend stays alive as the one
/// brain the mini remote-controls (see src/lib/miniBridge.svelte.js).
#[tauri::command]
pub async fn mini_open(app: tauri::AppHandle, width: f64, height: f64) -> Result<(), String> {
let mini = app
.get_webview_window("mini")
.ok_or_else(|| "mini window missing from config".to_string())?;
let _ = mini.set_size(tauri::LogicalSize::new(width, height));
mini.show().map_err(|e| e.to_string())?;
let _ = mini.set_focus();
if let Some(main) = app.get_webview_window("main") {
let _ = main.hide();
}
Ok(())
}
/// Leave mini mode: bring the full app back and tuck the mini away warm
/// (hidden, not closed — reopening is instant and its mirror stays hot).
#[tauri::command]
pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> {
if let Some(main) = app.get_webview_window("main") {
let _ = main.show();
let _ = main.set_focus();
}
if let Some(mini) = app.get_webview_window("mini") {
let _ = mini.hide();
}
Ok(())
}
/// The OS half of dark-glass mode: acrylic blur behind the calling window
/// (the window itself is transparent; the CSS half — :root.glass — thins the
/// window canvas so the blur shows through).
///
/// NOT tauri's Effect::Acrylic: on Win11 22H2+ that picks the DWMSBT system
/// backdrop, which Windows SUSPENDS whenever the maximized webview gets
/// promoted to a hardware overlay — every hover animation or compositor
/// scroll blinked the glass dark for a beat (Kyle, 2026-08-26). The legacy
/// accent-policy acrylic below is composed with the window itself and holds
/// through interaction.
#[tauri::command]
pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> {
// Clear any system backdrop a previous toggle/build may have applied.
let _ = window.set_effects(None::<tauri::utils::config::WindowEffectsConfig>);
let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void;
#[repr(C)]
struct AccentPolicy {
state: u32,
flags: u32,
gradient: u32, // 0xAABBGGRR
animation: u32,
}
#[repr(C)]
struct AttribData {
attrib: u32,
data: *mut std::ffi::c_void,
size: usize,
}
#[allow(non_snake_case)]
#[link(name = "kernel32")]
extern "system" {
fn GetModuleHandleA(name: *const u8) -> *mut std::ffi::c_void;
fn GetProcAddress(
module: *mut std::ffi::c_void,
name: *const u8,
) -> *mut std::ffi::c_void;
}
type Swca = unsafe extern "system" fn(*mut std::ffi::c_void, *mut AttribData) -> i32;
unsafe {
let user32 = GetModuleHandleA(b"user32.dll\0".as_ptr());
let f = if user32.is_null() {
std::ptr::null_mut()
} else {
GetProcAddress(user32, b"SetWindowCompositionAttribute\0".as_ptr())
};
if f.is_null() {
return Err("SetWindowCompositionAttribute unavailable".into());
}
let f: Swca = std::mem::transmute(f);
let mut policy = AccentPolicy {
state: if on { 4 } else { 0 }, // ACCENT_ENABLE_ACRYLICBLURBEHIND / ACCENT_DISABLED
flags: 0,
// A whisper of black in the OS layer (acrylic treats alpha 0 as
// "don't draw"); the real darkening is the CSS canvas tint.
gradient: if on { 0x2E00_0000 } else { 0 },
animation: 0,
};
let mut data = AttribData {
attrib: 0x13, // WCA_ACCENT_POLICY
data: &mut policy as *mut _ as _,
size: std::mem::size_of::<AccentPolicy>(),
};
f(hwnd, &mut data);
}
Ok(())
}
#[cfg(test)]
mod hero_processing_tests {
use super::*;
@@ -4937,3 +5046,5 @@ mod hero_processing_tests {
assert_eq!(trimmed.dimensions(), (256, 120));
}
}
+8
View File
@@ -140,6 +140,10 @@ pub struct AppConfig {
#[serde(default)]
pub volume_normalization: Option<bool>,
pub word_timing: Option<bool>,
/// Dark-glass mode: acrylic blur behind a transparent window, with the
/// base surfaces thinned in CSS so the desktop glows through. None → false.
#[serde(default)]
pub glass_mode: Option<bool>,
}
/// Default longest edge for embedded covers when the user hasn't chosen.
@@ -185,6 +189,10 @@ pub fn volume_normalization(cfg: &AppConfig) -> bool {
/// default: generated word spans still drift on dense or echo-heavy mixes,
/// and the solid line-level sync must be what users live with until the
/// feature earns its keep (Kyle, 2026-08-19).
pub fn glass_mode(cfg: &AppConfig) -> bool {
cfg.glass_mode.unwrap_or(false)
}
pub fn word_timing(cfg: &AppConfig) -> bool {
cfg.word_timing.unwrap_or(false)
}
+36
View File
@@ -27,6 +27,39 @@ mod ytdlp;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Two WebView2 presentation fixes, both delivered by env var because
// tauri.conf's additionalBrowserArgs never reaches the browser process
// (verified via its command line). Merge, don't clobber — dev launches
// carry the CDP port here. Both found 2026-08-26 via the dark-glass work:
//
// 1. NO delegated compositing: with it, DWM composes the webview's layers
// at rest and blends semi-transparent darks in the wrong colorspace —
// the app rested washed-out LIGHT and looked correct only while an
// animation forced Chromium to compose in-process.
// 2. Force the sRGB color profile: on an HDR display the MAXIMIZED
// window's rest path takes a different SDR-white tone mapping than
// the animated path — covers blew out near-white at rest and snapped
// correct on hover. One profile, one mapping, both paths agree.
#[cfg(windows)]
{
const KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS";
let mut args = std::env::var(KEY).unwrap_or_default();
let mut ensure = |flag: &str, key: &str| {
if !args.contains(key) {
if !args.is_empty() {
args.push(' ');
}
args.push_str(flag);
}
};
ensure(
"--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection,DelegatedCompositing",
"DelegatedCompositing",
);
ensure("--force-color-profile=srgb", "force-color-profile");
std::env::set_var(KEY, args);
}
tauri::Builder::default()
.manage(state::AppState::default())
.setup(|app| {
@@ -108,6 +141,9 @@ pub fn run() {
commands::unfollow_artist,
commands::home_artist_news,
commands::home_warm,
commands::mini_open,
commands::mini_close,
commands::window_glass,
playlists::list_playlists,
playlists::create_playlist,
playlists::rename_playlist,
+16 -1
View File
@@ -19,8 +19,23 @@
"height": 820,
"minWidth": 960,
"minHeight": 600,
"backgroundColor": "#121212",
"transparent": true,
"decorations": false
},
{
"label": "mini",
"title": "SoulStream Mini",
"url": "mini.html",
"width": 484,
"height": 96,
"visible": false,
"decorations": false,
"resizable": false,
"maximizable": false,
"minimizable": false,
"alwaysOnTop": true,
"shadow": true,
"backgroundColor": "#181818"
}
],
"security": {
+19
View File
@@ -36,6 +36,9 @@
restoreSession,
} from "./lib/state.svelte.js";
import { floatScroll } from "./lib/scrollbar.js";
import { initMiniBridge } from "./lib/miniBridge.svelte.js";
import { applyGlass } from "./lib/glass.js";
import { getSettings } from "./lib/api.js";
const views = {
home: Home,
@@ -97,6 +100,14 @@
]);
initEvents();
// The mini player's channel to this window — this window stays the one
// brain while the mini is up (see miniBridge.svelte.js).
initMiniBridge();
// Dark-glass mode is persisted in config; the window boots solid and
// glasses up as soon as the setting arrives (no-op when off).
void getSettings()
.then((s) => s?.glass_mode && applyGlass(true))
.catch(() => {});
autoConnect();
refreshLibrary();
refreshPlaylists();
@@ -208,6 +219,14 @@
transition: none;
}
/* Dark-glass mode: the CANVAS alone — the black the panes float on (grid
gaps, edge padding, the titlebar strip) — goes translucent over the OS
acrylic. The panes above it all paint their own opaque surfaces and
must stay untouched. */
:global(:root.glass) .app {
background: rgba(0, 0, 0, 0.7);
}
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
grid-template-columns transition animates the reflow. */
.app.sidebar-collapsed {
+4
View File
@@ -25,6 +25,10 @@
volume: '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>',
"volume-low": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>',
"volume-mute": '<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="22" x2="16" y1="9" y2="15"/><line x1="16" x2="22" y1="9" y2="15"/>',
// The mini player's chrome (Lucide pin / shapes / maximize-2).
pin: '<path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
shapes: '<path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/>',
restore: '<path d="M15 3h6v6"/><path d="M9 21H3v-6"/><path d="M21 3l-8 8"/><path d="M3 21l8-8"/>',
music: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
disc: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="2"/>',
folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
+9 -2
View File
@@ -36,6 +36,7 @@
PENDING_ALPHA,
} from "../lib/waveshape.js";
import Icon from "./Icon.svelte";
import { shapeById, storedShapeId } from "../mini/shapes.js";
const pct = $derived(
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
@@ -265,6 +266,12 @@
menuOpen = false;
showNowPlaying(); // the queue lives in the panel; always reveal it
}
function openMini() {
menuOpen = false;
// Open at the stored shape's size; the mini asserts it again on boot.
const s = shapeById(storedShapeId());
void api.miniOpen(s.w, s.h);
}
function openLyrics() {
menuOpen = false;
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
@@ -1324,8 +1331,8 @@
<button role="menuitem" disabled={!currentItem} onclick={openLyrics}>
<Icon name="lyrics" size={15} /> Lyrics
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
<button role="menuitem" onclick={openMini}>
<Icon name="panel-right" size={15} /> Mini player
</button>
<button role="menuitem" disabled title="Not built yet">
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
+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);
+5
View File
@@ -0,0 +1,5 @@
import "./styles/global.css";
import { mount } from "svelte";
import Mini from "./mini/Mini.svelte";
mount(Mini, { target: document.getElementById("mini") });
+119
View File
@@ -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>
+54
View File
@@ -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>
+141
View File
@@ -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>
+150
View File
@@ -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>
+154
View File
@@ -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>
+74
View File
@@ -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>
+130
View File
@@ -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")}`;
};
+25
View File
@@ -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";
}
}
+96
View File
@@ -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>
+131
View File
@@ -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>
+135
View File
@@ -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>
+229
View File
@@ -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>
+146
View File
@@ -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>
+120
View File
@@ -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>
+92
View File
@@ -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>
+162
View File
@@ -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>
+13
View File
@@ -94,6 +94,19 @@
--nowplaying-rail: 64px;
}
/* Dark-glass mode (Settings → Appearance): the window is transparent and the
OS puts acrylic blur behind it (window_glass command). ONLY the window
canvas thins over it — the black frame the panes float on, painted by .app
in App.svelte. Every pane keeps its own opaque surface; do NOT thin the
shared --bg vars here — they paint the sidebar, the content pane and the
artist page, and going translucent washed the whole app out (2026-08-26).
html and body must both stop painting, or their solid base blocks the
blur (html alone held a solid #121212 and ate the first attempt). */
:root.glass,
:root.glass body {
background: transparent;
}
/* Floating overlay scrollbar (see src/lib/scrollbar.js). The native bar is
hidden so it never reserves width or reflows content; a thin thumb floats
in the container's right padding, fading in on scroll/hover, out when idle. */
+28
View File
@@ -1,6 +1,7 @@
<script>
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
import { setWordFill } from "../lib/lyrics.svelte.js";
import { applyGlass } from "../lib/glass.js";
import * as api from "../lib/api.js";
import Icon from "../components/Icon.svelte";
@@ -105,6 +106,14 @@
}
}
async function setGlassMode(next) {
if (next !== settings.glass_mode) {
// Applies live — the acrylic backdrop and the CSS thinning land
// together the moment the save succeeds.
if (await commit({ glassMode: next })) applyGlass(next);
}
}
async function setWordTiming(next) {
if (next !== settings.word_timing) {
await commit({ wordTiming: next });
@@ -280,6 +289,25 @@
</p>
</section>
<section>
<h2>Appearance</h2>
<div class="row">
<div class="label">
<div class="name">Dark glass</div>
<div class="hint">Lets whatever is behind the window glow through the app's dark surfaces with a live blur (Windows acrylic). Panels, menus and cards stay solid so nothing gets hard to read.</div>
</div>
<select
class="device-select"
value={settings.glass_mode ? "on" : "off"}
disabled={saving}
onchange={(e) => setGlassMode(e.currentTarget.value === "on")}
>
<option value="off">Off — solid</option>
<option value="on">On — dark glass</option>
</select>
</div>
</section>
<section>
<h2>Playback</h2>
<div class="row">
+10
View File
@@ -4,6 +4,16 @@ import { svelte } from "@sveltejs/vite-plugin-svelte";
export default defineConfig({
plugins: [svelte()],
clearScreen: false,
build: {
rollupOptions: {
// Two documents, one app: the full shell and the mini player
// window load the same modules through different mount points.
input: {
main: "index.html",
mini: "mini.html",
},
},
},
server: {
port: 1421,
strictPort: true,