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:
1 parent
9b84b30aef
commit
dc7ea16b22
33 files changed
+2432
-5
No files matched your search
@@ -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>
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "enables the default permissions",
|
"description": "enables the default permissions",
|
||||||
"windows": [
|
"windows": [
|
||||||
"main"
|
"main",
|
||||||
|
"mini"
|
||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -16,6 +17,8 @@
|
|||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-show",
|
"core:window:allow-show",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-set-size",
|
||||||
|
"core:window:allow-set-always-on-top",
|
||||||
"dialog:allow-open"
|
"dialog:allow-open"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+112
-1
@@ -1,4 +1,4 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::{Emitter, Manager, State};
|
use tauri::{Emitter, Manager, State};
|
||||||
|
|
||||||
use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event};
|
use soulseek_client::{Client, Config, DownloadState, DownloadUpdate, Event};
|
||||||
@@ -78,6 +78,8 @@ pub struct Settings {
|
|||||||
pub volume_normalization: bool,
|
pub volume_normalization: bool,
|
||||||
/// Word-by-word lyric fill — experimental, default off.
|
/// Word-by-word lyric fill — experimental, default off.
|
||||||
pub word_timing: bool,
|
pub word_timing: bool,
|
||||||
|
/// Dark-glass acrylic backdrop, default off.
|
||||||
|
pub glass_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -117,6 +119,7 @@ pub async fn get_settings(
|
|||||||
download_source_prefs: cfg.download_source_prefs,
|
download_source_prefs: cfg.download_source_prefs,
|
||||||
volume_normalization: config::volume_normalization(&cfg),
|
volume_normalization: config::volume_normalization(&cfg),
|
||||||
word_timing: config::word_timing(&cfg),
|
word_timing: config::word_timing(&cfg),
|
||||||
|
glass_mode: config::glass_mode(&cfg),
|
||||||
username: cfg.username,
|
username: cfg.username,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -145,6 +148,7 @@ pub async fn set_settings(
|
|||||||
download_source_prefs: Option<config::SourcePrefs>,
|
download_source_prefs: Option<config::SourcePrefs>,
|
||||||
volume_normalization: Option<bool>,
|
volume_normalization: Option<bool>,
|
||||||
word_timing: Option<bool>,
|
word_timing: Option<bool>,
|
||||||
|
glass_mode: Option<bool>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut cfg = config::load(&app);
|
let mut cfg = config::load(&app);
|
||||||
let library_folder_moved = download_dir
|
let library_folder_moved = download_dir
|
||||||
@@ -216,6 +220,9 @@ pub async fn set_settings(
|
|||||||
if word_timing.is_some() {
|
if word_timing.is_some() {
|
||||||
cfg.word_timing = word_timing;
|
cfg.word_timing = word_timing;
|
||||||
}
|
}
|
||||||
|
if glass_mode.is_some() {
|
||||||
|
cfg.glass_mode = glass_mode;
|
||||||
|
}
|
||||||
config::save(&app, &cfg);
|
config::save(&app, &cfg);
|
||||||
|
|
||||||
// A new library folder is a new library. Re-base immediately rather than
|
// A new library folder is a new library. Re-base immediately rather than
|
||||||
@@ -4857,6 +4864,108 @@ pub fn player_control(
|
|||||||
Ok(())
|
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)]
|
#[cfg(test)]
|
||||||
mod hero_processing_tests {
|
mod hero_processing_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -4937,3 +5046,5 @@ mod hero_processing_tests {
|
|||||||
assert_eq!(trimmed.dimensions(), (256, 120));
|
assert_eq!(trimmed.dimensions(), (256, 120));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +140,10 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub volume_normalization: Option<bool>,
|
pub volume_normalization: Option<bool>,
|
||||||
pub word_timing: 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.
|
/// 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,
|
/// 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
|
/// and the solid line-level sync must be what users live with until the
|
||||||
/// feature earns its keep (Kyle, 2026-08-19).
|
/// 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 {
|
pub fn word_timing(cfg: &AppConfig) -> bool {
|
||||||
cfg.word_timing.unwrap_or(false)
|
cfg.word_timing.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,39 @@ mod ytdlp;
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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()
|
tauri::Builder::default()
|
||||||
.manage(state::AppState::default())
|
.manage(state::AppState::default())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
@@ -108,6 +141,9 @@ pub fn run() {
|
|||||||
commands::unfollow_artist,
|
commands::unfollow_artist,
|
||||||
commands::home_artist_news,
|
commands::home_artist_news,
|
||||||
commands::home_warm,
|
commands::home_warm,
|
||||||
|
commands::mini_open,
|
||||||
|
commands::mini_close,
|
||||||
|
commands::window_glass,
|
||||||
playlists::list_playlists,
|
playlists::list_playlists,
|
||||||
playlists::create_playlist,
|
playlists::create_playlist,
|
||||||
playlists::rename_playlist,
|
playlists::rename_playlist,
|
||||||
|
|||||||
@@ -19,8 +19,23 @@
|
|||||||
"height": 820,
|
"height": 820,
|
||||||
"minWidth": 960,
|
"minWidth": 960,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"backgroundColor": "#121212",
|
"transparent": true,
|
||||||
"decorations": false
|
"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": {
|
"security": {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@
|
|||||||
restoreSession,
|
restoreSession,
|
||||||
} from "./lib/state.svelte.js";
|
} from "./lib/state.svelte.js";
|
||||||
import { floatScroll } from "./lib/scrollbar.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 = {
|
const views = {
|
||||||
home: Home,
|
home: Home,
|
||||||
@@ -97,6 +100,14 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
initEvents();
|
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();
|
autoConnect();
|
||||||
refreshLibrary();
|
refreshLibrary();
|
||||||
refreshPlaylists();
|
refreshPlaylists();
|
||||||
@@ -208,6 +219,14 @@
|
|||||||
transition: none;
|
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
|
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
||||||
grid-template-columns transition animates the reflow. */
|
grid-template-columns transition animates the reflow. */
|
||||||
.app.sidebar-collapsed {
|
.app.sidebar-collapsed {
|
||||||
|
|||||||
@@ -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: '<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-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"/>',
|
"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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
PENDING_ALPHA,
|
PENDING_ALPHA,
|
||||||
} from "../lib/waveshape.js";
|
} from "../lib/waveshape.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
import { shapeById, storedShapeId } from "../mini/shapes.js";
|
||||||
|
|
||||||
const pct = $derived(
|
const pct = $derived(
|
||||||
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
player.duration ? Math.min(100, (player.position / player.duration) * 100) : 0,
|
||||||
@@ -265,6 +266,12 @@
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
showNowPlaying(); // the queue lives in the panel; always reveal it
|
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() {
|
function openLyrics() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
|
// The lyrics PAGE (views/Lyrics.svelte) — the overlay it used to open
|
||||||
@@ -1324,8 +1331,8 @@
|
|||||||
<button role="menuitem" disabled={!currentItem} onclick={openLyrics}>
|
<button role="menuitem" disabled={!currentItem} onclick={openLyrics}>
|
||||||
<Icon name="lyrics" size={15} /> Lyrics
|
<Icon name="lyrics" size={15} /> Lyrics
|
||||||
</button>
|
</button>
|
||||||
<button role="menuitem" disabled title="Not built yet">
|
<button role="menuitem" onclick={openMini}>
|
||||||
<Icon name="panel-right" size={15} /> Mini player <span class="soon">soon</span>
|
<Icon name="panel-right" size={15} /> Mini player
|
||||||
</button>
|
</button>
|
||||||
<button role="menuitem" disabled title="Not built yet">
|
<button role="menuitem" disabled title="Not built yet">
|
||||||
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
|
<Icon name="volume" size={15} /> Output device <span class="soon">soon</span>
|
||||||
|
|||||||
@@ -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
|
// playback from events, so after a reload this is the only way to find out that
|
||||||
// audio is still running — see `restoreSession`.
|
// audio is still running — see `restoreSession`.
|
||||||
export const playerState = () => invoke("player_state");
|
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) =>
|
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
|
||||||
invoke("waveform", { path, buckets, startSecs, endSecs });
|
invoke("waveform", { path, buckets, startSecs, endSecs });
|
||||||
export const libraryAlbums = () => invoke("library_albums");
|
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 refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
||||||
export const getSettings = () => invoke("get_settings");
|
export const getSettings = () => invoke("get_settings");
|
||||||
export const setSettings = (patch) => invoke("set_settings", patch);
|
export const setSettings = (patch) => invoke("set_settings", patch);
|
||||||
|
export const windowGlass = (on) => invoke("window_glass", { on });
|
||||||
export const forgetCredentials = () => invoke("forget_credentials");
|
export const forgetCredentials = () => invoke("forget_credentials");
|
||||||
export const pickFolder = () => invoke("pick_folder");
|
export const pickFolder = () => invoke("pick_folder");
|
||||||
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
|
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5582,6 +5582,13 @@ const DEFAULT_ACCENT_HOVER = "#41b7ee";
|
|||||||
export const theme = $state({
|
export const theme = $state({
|
||||||
accent: DEFAULT_ACCENT,
|
accent: DEFAULT_ACCENT,
|
||||||
accentHover: DEFAULT_ACCENT_HOVER,
|
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;
|
let accentRaf = null;
|
||||||
@@ -5589,6 +5596,8 @@ let accentRaf = null;
|
|||||||
/** Animate --accent/--accent-hover to the target colors (ease-out). */
|
/** Animate --accent/--accent-hover to the target colors (ease-out). */
|
||||||
function crossfadeAccent(accent, hover, ms = 1200) {
|
function crossfadeAccent(accent, hover, ms = 1200) {
|
||||||
if (accent === theme.accent && hover === theme.accentHover) return;
|
if (accent === theme.accent && hover === theme.accentHover) return;
|
||||||
|
theme.accentTarget = accent;
|
||||||
|
theme.accentHoverTarget = hover;
|
||||||
if (accentRaf) cancelAnimationFrame(accentRaf);
|
if (accentRaf) cancelAnimationFrame(accentRaf);
|
||||||
const fromA = hexToRgb(theme.accent);
|
const fromA = hexToRgb(theme.accent);
|
||||||
const fromH = hexToRgb(theme.accentHover);
|
const fromH = hexToRgb(theme.accentHover);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import "./styles/global.css";
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import Mini from "./mini/Mini.svelte";
|
||||||
|
|
||||||
|
mount(Mini, { target: document.getElementById("mini") });
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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")}`;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -94,6 +94,19 @@
|
|||||||
--nowplaying-rail: 64px;
|
--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
|
/* 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
|
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. */
|
in the container's right padding, fading in on scroll/hover, out when idle. */
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
import { app, clearCatalogHeroArtCache, refreshSourcePrefs } from "../lib/state.svelte.js";
|
||||||
import { setWordFill } from "../lib/lyrics.svelte.js";
|
import { setWordFill } from "../lib/lyrics.svelte.js";
|
||||||
|
import { applyGlass } from "../lib/glass.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
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) {
|
async function setWordTiming(next) {
|
||||||
if (next !== settings.word_timing) {
|
if (next !== settings.word_timing) {
|
||||||
await commit({ wordTiming: next });
|
await commit({ wordTiming: next });
|
||||||
@@ -280,6 +289,25 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</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>
|
<section>
|
||||||
<h2>Playback</h2>
|
<h2>Playback</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
clearScreen: false,
|
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: {
|
server: {
|
||||||
port: 1421,
|
port: 1421,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
Reference in new issue
Block a user