Add a native Slint front-end for the music player
The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport, and a JPEG decode per album card per cold start. The last three commits chased that through the service worker and the cover pipeline. This is the other direction - the same backend, the same layout, the same German copy, without a browser. Browse and play only. The typing game, the room-lights page, parent mode and the IR-remote assignment stay web-only, and nothing here touches python-backend. Four things carry the performance claim, in rough order of how much they should matter: Covers are downscaled once, ever. The backend serves one size - 640px on the long edge, no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/. Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel size would give each album a dozen near-identical files and a fresh decode for each. The cache is dropped when the websocket announces a rescan, because that is the one moment the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn about the hard way. The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the view puts those in a ListView, which instantiates only what is on screen. A flat list of 660 cards gives it no rows to skip, hence the chunking. Same purpose as content-visibility: auto on .grid > .card. The progress bar animates between the 2 Hz pushes rather than running a clock, so interpolation costs a property evaluation per frame on the render side and there is no equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a new track jumps instead of sliding across two unrelated positions. Nothing on screen animates by itself. The ambient canvas is not ported, for the reason lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get under while it runs at all. The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays the default only for desktop development. Both profiles are verified to build; the kiosk binary links libinput/libgbm/libdrm and no X11 or Wayland at all. lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their own tests, so they port across as pure Rust with theirs: 43 tests, no window required. The .slint files are layout only - nothing in them formats a number or picks a word. Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh renders the UI inside a nested headless compositor and grabs a frame per screen, which is how that was checked on a machine whose session was locked; a Slint window needs a real compositor, and under bare Xvfb nothing maps at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
312
slint-frontend/src/api.rs
Normal file
312
slint-frontend/src/api.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
//! The shapes the backend serves, and the calls that fetch them.
|
||||
//!
|
||||
//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of
|
||||
//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's
|
||||
//! share of the API is here; settings, the IR remote, Home Assistant and the typing
|
||||
//! game are all reachable and all deliberately absent.
|
||||
//!
|
||||
//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a
|
||||
//! required `Option`, so a backend that grows a field does not break this client and
|
||||
//! one that loses a field fails at the one call site that cared.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Default for a development run; `--server` overrides it.
|
||||
pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AlbumKind {
|
||||
#[default]
|
||||
Music,
|
||||
Book,
|
||||
}
|
||||
|
||||
impl AlbumKind {
|
||||
/// Shape is how you tell the two apart without reading anything: albums square,
|
||||
/// audiobooks taller than wide, everywhere they appear. Nothing else in this app
|
||||
/// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`.
|
||||
pub fn aspect(self) -> f32 {
|
||||
match self {
|
||||
Self::Music => 1.0,
|
||||
Self::Book => 0.82,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Track {
|
||||
pub title: String,
|
||||
/// Seconds, read from the file's tags at scan time. `0.0` when it carries none.
|
||||
#[serde(default)]
|
||||
pub duration: f64,
|
||||
/// A typing-reward track not yet earned. This front-end does not host the typing
|
||||
/// game, but it still has to not show what the game is withholding.
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
// `series`, and the parts of `PlayerState` the UI does not read, are kept because these
|
||||
// structs are the record of what the backend serves - dropping a field would make this
|
||||
// file a list of what this front-end happens to use today, which is a much less useful
|
||||
// thing to read next to `schemas.py`.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Album {
|
||||
pub id: String,
|
||||
/// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`,
|
||||
/// `Kinderpodcasts`. What `Group::of` reads to spot a podcast.
|
||||
pub section: String,
|
||||
#[serde(default)]
|
||||
pub kind: AlbumKind,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: String,
|
||||
#[serde(default)]
|
||||
pub series: Option<String>,
|
||||
/// The figurine whose folder this is, when it has one.
|
||||
#[serde(default)]
|
||||
pub figure: Option<String>,
|
||||
/// Series for audiobooks and podcasts, artist for music. What browsing groups by.
|
||||
#[serde(default)]
|
||||
pub category: String,
|
||||
/// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art
|
||||
/// or synthesised from the id. The generated stand-in cover is painted from these.
|
||||
#[serde(default)]
|
||||
pub colors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub has_cover: bool,
|
||||
#[serde(default)]
|
||||
pub duration: f64,
|
||||
#[serde(default)]
|
||||
pub tracks: Vec<Track>,
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Album {
|
||||
pub fn is_book(&self) -> bool {
|
||||
self.kind == AlbumKind::Book
|
||||
}
|
||||
|
||||
/// `"Album · Artist"`, unless a podcast makes those the same string.
|
||||
pub fn line(&self) -> String {
|
||||
let prefix = if self.is_book() { "📖 " } else { "" };
|
||||
if !self.artist.is_empty() && self.artist != self.title {
|
||||
format!("{prefix}{} · {}", self.title, self.artist)
|
||||
} else {
|
||||
format!("{prefix}{}", self.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// `colors` padded out to the three the painter wants, with the same fallbacks the
|
||||
/// web client uses, so a library entry missing them looks the same in both.
|
||||
pub fn palette(&self) -> [&str; 3] {
|
||||
[
|
||||
self.colors.first().map_or("#4a6fa5", String::as_str),
|
||||
self.colors.get(1).map_or("#6a8fc5", String::as_str),
|
||||
self.colors.get(2).map_or("#a5804a", String::as_str),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LibraryOut {
|
||||
albums: Vec<Album>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct Connection {
|
||||
#[serde(default)]
|
||||
pub firmware: bool,
|
||||
#[serde(default)]
|
||||
pub mqtt: bool,
|
||||
#[serde(default)]
|
||||
pub lirc: bool,
|
||||
}
|
||||
|
||||
/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine
|
||||
/// on the reader and Home Assistant all move it too, so this client follows it rather
|
||||
/// than assuming it is in charge.
|
||||
#[allow(dead_code)] // see the note above `Album`
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
pub struct PlayerState {
|
||||
#[serde(default)]
|
||||
pub playing: bool,
|
||||
#[serde(default)]
|
||||
pub album_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub album_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub artist: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<AlbumKind>,
|
||||
#[serde(default)]
|
||||
pub track_index: i32,
|
||||
#[serde(default)]
|
||||
pub track_title: Option<String>,
|
||||
#[serde(default)]
|
||||
pub track_count: i32,
|
||||
#[serde(default)]
|
||||
pub position: f64,
|
||||
#[serde(default)]
|
||||
pub duration: f64,
|
||||
/// Percent, 0..100. The device's configured range never leaves the backend.
|
||||
#[serde(default)]
|
||||
pub volume: i32,
|
||||
#[serde(default)]
|
||||
pub active_figure: Option<String>,
|
||||
#[serde(default)]
|
||||
pub connected: Connection,
|
||||
}
|
||||
|
||||
/// The push-only websocket's frames. `library` carries no payload - it is a bare "your
|
||||
/// index is stale, fetch it again" after a rescan.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ServerMessage {
|
||||
State {
|
||||
state: PlayerState,
|
||||
},
|
||||
Position {
|
||||
position: f64,
|
||||
#[serde(default)]
|
||||
duration: f64,
|
||||
},
|
||||
Library,
|
||||
/// A frame this build does not know. Ignored rather than fatal, so the backend can
|
||||
/// grow a message type without this client falling over on it.
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- client --
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Client {
|
||||
base: String,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a
|
||||
/// way the caller could branch on - the UI either has a library or says it cannot reach
|
||||
/// the mouse - so the variants are not worth splitting.
|
||||
pub type Error = String;
|
||||
|
||||
impl Client {
|
||||
pub fn new(base_url: &str) -> Self {
|
||||
Self {
|
||||
base: base_url.trim_end_matches('/').to_string(),
|
||||
agent: ureq::AgentBuilder::new()
|
||||
.timeout_connect(std::time::Duration::from_secs(4))
|
||||
.timeout_read(std::time::Duration::from_secs(30))
|
||||
.build(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but
|
||||
/// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working.
|
||||
pub fn websocket_url(&self) -> String {
|
||||
let ws = if let Some(rest) = self.base.strip_prefix("https://") {
|
||||
format!("wss://{rest}")
|
||||
} else if let Some(rest) = self.base.strip_prefix("http://") {
|
||||
format!("ws://{rest}")
|
||||
} else {
|
||||
format!("ws://{}", self.base)
|
||||
};
|
||||
format!("{ws}/api/ws")
|
||||
}
|
||||
|
||||
pub fn cover_url(&self, album_id: &str) -> String {
|
||||
format!("{}/api/albums/{album_id}/cover", self.base)
|
||||
}
|
||||
|
||||
pub fn library(&self) -> Result<Vec<Album>, Error> {
|
||||
let out: LibraryOut = self
|
||||
.agent
|
||||
.get(&format!("{}/api/library", self.base))
|
||||
.call()
|
||||
.map_err(|e| format!("GET /api/library: {e}"))?
|
||||
.into_json()
|
||||
.map_err(|e| format!("GET /api/library: bad JSON: {e}"))?;
|
||||
Ok(out.albums)
|
||||
}
|
||||
|
||||
pub fn state(&self) -> Result<PlayerState, Error> {
|
||||
self.agent
|
||||
.get(&format!("{}/api/state", self.base))
|
||||
.call()
|
||||
.map_err(|e| format!("GET /api/state: {e}"))?
|
||||
.into_json()
|
||||
.map_err(|e| format!("GET /api/state: bad JSON: {e}"))
|
||||
}
|
||||
|
||||
/// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a
|
||||
/// failure: `has_cover` says so in advance and the client paints the album's own
|
||||
/// colours instead.
|
||||
pub fn cover(&self, album_id: &str) -> Result<Option<Vec<u8>>, Error> {
|
||||
let response = match self.agent.get(&self.cover_url(album_id)).call() {
|
||||
Ok(response) => response,
|
||||
Err(ureq::Error::Status(404, _)) => return Ok(None),
|
||||
Err(e) => return Err(format!("GET cover {album_id}: {e}")),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes)
|
||||
.map_err(|e| format!("GET cover {album_id}: {e}"))?;
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- commands --
|
||||
//
|
||||
// Every one of these emits the same *intent* the physical buttons emit, so this UI
|
||||
// has no privileged path and no way to get out of step with a figure someone puts
|
||||
// on the reader. They all answer 204 with no body - nothing to parse.
|
||||
|
||||
fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
||||
let request = self.agent.post(&format!("{}/api{path}", self.base));
|
||||
let result = if body.is_null() {
|
||||
request.call()
|
||||
} else {
|
||||
request.send_json(body)
|
||||
};
|
||||
result.map(|_| ()).map_err(|e| format!("POST {path}: {e}"))
|
||||
}
|
||||
|
||||
pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> {
|
||||
self.command(
|
||||
"/play",
|
||||
serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resume(&self) -> Result<(), Error> {
|
||||
self.command("/resume", serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub fn pause(&self) -> Result<(), Error> {
|
||||
self.command("/pause", serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Result<(), Error> {
|
||||
self.command("/next", serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub fn previous(&self) -> Result<(), Error> {
|
||||
self.command("/previous", serde_json::Value::Null)
|
||||
}
|
||||
|
||||
pub fn seek(&self, position: f64) -> Result<(), Error> {
|
||||
self.command(
|
||||
"/seek",
|
||||
serde_json::json!({ "position": position.max(0.0) }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn volume(&self, percent: i32) -> Result<(), Error> {
|
||||
self.command(
|
||||
"/volume",
|
||||
serde_json::json!({ "percent": percent.clamp(0, 100) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
406
slint-frontend/src/covers.rs
Normal file
406
slint-frontend/src/covers.rs
Normal file
@@ -0,0 +1,406 @@
|
||||
//! Album art: fetched once, downscaled once, then reused forever.
|
||||
//!
|
||||
//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge,
|
||||
//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid
|
||||
//! draws that at 192 px and the play view at 384, so painting the served file directly
|
||||
//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343
|
||||
//! albums on a Pi that is the whole of "the UI feels slow".
|
||||
//!
|
||||
//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was
|
||||
//! asked for, and the decode-and-resize cost is paid once ever rather than once per
|
||||
//! launch. A miss goes to the network on a worker thread; the UI never blocks on one.
|
||||
//!
|
||||
//! **Invalidation.** The bytes behind a cover URL do change while the URL does not -
|
||||
//! the id addresses which album the art belongs to, not which art. Rather than pay a
|
||||
//! conditional request per cover per start, this cache is cleared wholesale when the
|
||||
//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the
|
||||
//! backend reprocesses cover art. See the long comment at
|
||||
//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned
|
||||
//! this the hard way.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use image::imageops::FilterType;
|
||||
use image::{Rgba, RgbaImage};
|
||||
|
||||
use crate::api::{AlbumKind, Client};
|
||||
use crate::library::Group;
|
||||
use crate::oklch::{mix, oklch_to_rgb, parse_hex};
|
||||
|
||||
/// The two sizes anything on screen actually asks for. Keeping it to two is what makes
|
||||
/// the disk cache worth having: a per-widget pixel size would give every album a dozen
|
||||
/// near-identical files and a fresh decode for each.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Tier {
|
||||
/// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which
|
||||
/// scales down from this rather than earning a tier of its own.
|
||||
Thumb,
|
||||
/// The play view's cover, and the album sheet's.
|
||||
Hero,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn px(self) -> u32 {
|
||||
match self {
|
||||
Self::Thumb => 192,
|
||||
Self::Hero => 384,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Key {
|
||||
pub album_id: String,
|
||||
pub tier: Tier,
|
||||
}
|
||||
|
||||
/// Everything the worker needs about an album, flattened so it can cross a thread
|
||||
/// boundary without the `Rc<Album>` the UI side holds.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub album_id: String,
|
||||
pub tier: Tier,
|
||||
pub has_cover: bool,
|
||||
pub is_book: bool,
|
||||
pub locked: bool,
|
||||
pub colors: [String; 3],
|
||||
}
|
||||
|
||||
/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a
|
||||
/// `slint::Image`, because the image types are built on the UI thread by whoever
|
||||
/// receives this.
|
||||
pub struct Ready {
|
||||
pub key: Key,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ generated art --
|
||||
|
||||
/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows
|
||||
/// through underneath one that has not arrived yet.
|
||||
fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||
// `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so
|
||||
// a 56 px thumbnail does not turn into a grey smear of forty bands.
|
||||
let band = (width / 13).max(6) as f32;
|
||||
RgbaImage::from_fn(width, height, |x, y| {
|
||||
// 135deg in CSS points the gradient line at the bottom-right corner, so the
|
||||
// position along it is the projection of (x, y) onto (1, 1)/sqrt(2).
|
||||
let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2;
|
||||
let c = if ((along / band) as u32).is_multiple_of(2) {
|
||||
primary
|
||||
} else {
|
||||
secondary
|
||||
};
|
||||
Rgba([c[0], c[1], c[2], 255])
|
||||
})
|
||||
}
|
||||
|
||||
/// A book: page edges down the right, a darker board down the left, over a diagonal
|
||||
/// wash. Four layers in CSS, four passes here, in the same order.
|
||||
fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage {
|
||||
// The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a
|
||||
// generated spine and the shelf it sits on are the same family by construction.
|
||||
let hue = Group::Audiobooks.hue();
|
||||
let page_light = oklch_to_rgb(0.97, 0.02, hue);
|
||||
let page_dark = oklch_to_rgb(0.90, 0.03, hue);
|
||||
|
||||
RgbaImage::from_fn(width, height, |x, y| {
|
||||
let fx = x as f32 / width.max(1) as f32;
|
||||
let fy = y as f32 / height.max(1) as f32;
|
||||
|
||||
// Layer 4 (bottom): linear-gradient(155deg, primary -> secondary).
|
||||
let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0);
|
||||
let mut c = mix(primary, secondary, t);
|
||||
|
||||
// Layer 3: the board down the left edge.
|
||||
if fx < 0.11 {
|
||||
c = secondary;
|
||||
} else if fx < 0.13 {
|
||||
c = primary;
|
||||
}
|
||||
|
||||
// Layer 2: two thin highlight rules over the board.
|
||||
if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) {
|
||||
c = mix(c, [255, 255, 255], 0.35);
|
||||
}
|
||||
|
||||
// Layer 1 (top): the page block on the right.
|
||||
if fx >= 0.975 {
|
||||
c = page_dark;
|
||||
} else if fx >= 0.95 {
|
||||
c = page_light;
|
||||
}
|
||||
|
||||
Rgba([c[0], c[1], c[2], 255])
|
||||
})
|
||||
}
|
||||
|
||||
/// What an album looks like before - or instead of - its real art.
|
||||
pub fn generated(request: &Request) -> RgbaImage {
|
||||
let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]);
|
||||
let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]);
|
||||
let px = request.tier.px();
|
||||
let (width, height) = dimensions(px, request.is_book);
|
||||
if request.is_book {
|
||||
spine(width, height, primary, secondary)
|
||||
} else {
|
||||
stripes(width, height, primary, secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape comes from the media type and from nowhere else: albums square, audiobooks
|
||||
/// taller than wide. `px` fixes the longest edge either way.
|
||||
fn dimensions(px: u32, is_book: bool) -> (u32, u32) {
|
||||
let kind = if is_book {
|
||||
AlbumKind::Book
|
||||
} else {
|
||||
AlbumKind::Music
|
||||
};
|
||||
(((px as f32) * kind.aspect()).round() as u32, px)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- worker --
|
||||
|
||||
pub struct Worker {
|
||||
client: Client,
|
||||
dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn new(client: Client) -> Self {
|
||||
let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers"));
|
||||
if let Some(dir) = &dir {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
// Not fatal: without a disk cache every cover is simply fetched and
|
||||
// resized again next launch, which is the old behaviour, not a failure.
|
||||
eprintln!("cover cache unavailable at {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
Self { client, dir }
|
||||
}
|
||||
|
||||
fn path(&self, key: &Key) -> Option<PathBuf> {
|
||||
self.dir
|
||||
.as_ref()
|
||||
.map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px())))
|
||||
}
|
||||
|
||||
/// Drop every stored thumbnail. Called when the backend says it has rescanned, and
|
||||
/// therefore possibly rewritten the art behind these ids.
|
||||
pub fn clear_disk(&self) {
|
||||
let Some(dir) = &self.dir else { return };
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&self, request: &Request) -> RgbaImage {
|
||||
let px = request.tier.px();
|
||||
let (want_w, want_h) = dimensions(px, request.is_book);
|
||||
|
||||
// A locked album is one the typing game is withholding. It has real art, and
|
||||
// showing it would give away the reward, so it never gets fetched at all.
|
||||
if request.locked || !request.has_cover {
|
||||
return generated(request);
|
||||
}
|
||||
|
||||
if let Some(path) = self.path(&Key {
|
||||
album_id: request.album_id.clone(),
|
||||
tier: request.tier,
|
||||
}) {
|
||||
if let Ok(image) = image::open(&path) {
|
||||
return image.to_rgba8();
|
||||
}
|
||||
}
|
||||
|
||||
let bytes = match self.client.cover(&request.album_id) {
|
||||
Ok(Some(bytes)) => bytes,
|
||||
// 404 is a normal answer - `has_cover` can be stale by a rescan - and a
|
||||
// network error should show the album, not a hole.
|
||||
Ok(None) | Err(_) => return generated(request),
|
||||
};
|
||||
let Ok(decoded) = image::load_from_memory(&bytes) else {
|
||||
return generated(request);
|
||||
};
|
||||
|
||||
// `resize_to_fill` rather than `resize`: a real cover is square but a book's
|
||||
// slot is not, and letterboxing one inside the other would break the shelf
|
||||
// metaphor that the whole browse screen is built on.
|
||||
let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom);
|
||||
|
||||
if let Some(path) = self.path(&Key {
|
||||
album_id: request.album_id.clone(),
|
||||
tier: request.tier,
|
||||
}) {
|
||||
// Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on
|
||||
// disk and faster to decode on the next start, which is the whole point.
|
||||
let _ = thumb
|
||||
.to_rgb8()
|
||||
.save_with_format(&path, image::ImageFormat::Jpeg);
|
||||
}
|
||||
thumb.to_rgba8()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each
|
||||
/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop.
|
||||
pub fn spawn(
|
||||
client: Client,
|
||||
jobs: Receiver<Job>,
|
||||
deliver: impl Fn(Ready) + Send + 'static,
|
||||
) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("covers".into())
|
||||
.spawn(move || {
|
||||
let worker = Worker::new(client);
|
||||
// A cover asked for while it is already in flight - a card scrolled past
|
||||
// twice, a grid rebuilt on a keystroke - must not become a second fetch.
|
||||
let mut done: HashSet<Key> = HashSet::new();
|
||||
for job in jobs {
|
||||
match job {
|
||||
Job::Clear => {
|
||||
worker.clear_disk();
|
||||
done.clear();
|
||||
}
|
||||
Job::Fetch(request) => {
|
||||
let key = Key {
|
||||
album_id: request.album_id.clone(),
|
||||
tier: request.tier,
|
||||
};
|
||||
if !done.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
let image = worker.render(&request);
|
||||
deliver(Ready {
|
||||
key,
|
||||
width: image.width(),
|
||||
height: image.height(),
|
||||
rgba: image.into_raw(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawning the cover worker")
|
||||
}
|
||||
|
||||
pub enum Job {
|
||||
Fetch(Request),
|
||||
Clear,
|
||||
}
|
||||
|
||||
/// The UI thread's half: what is already decoded, and what has been asked for.
|
||||
pub struct Store {
|
||||
jobs: Sender<Job>,
|
||||
ready: HashMap<Key, slint::Image>,
|
||||
requested: HashSet<Key>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new(jobs: Sender<Job>) -> Self {
|
||||
Self {
|
||||
jobs,
|
||||
ready: HashMap::new(),
|
||||
requested: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, ready: Ready) {
|
||||
let mut buffer =
|
||||
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(ready.width, ready.height);
|
||||
buffer.make_mut_bytes().copy_from_slice(&ready.rgba);
|
||||
self.ready
|
||||
.insert(ready.key, slint::Image::from_rgba8(buffer));
|
||||
}
|
||||
|
||||
/// The cover for an album if it is decoded, and a fetch queued if it is not. Callers
|
||||
/// paint the album's own colours in the meantime, so this returning `None` is a
|
||||
/// normal frame rather than a missing one.
|
||||
pub fn get(&mut self, request: Request) -> Option<slint::Image> {
|
||||
let key = Key {
|
||||
album_id: request.album_id.clone(),
|
||||
tier: request.tier,
|
||||
};
|
||||
if let Some(image) = self.ready.get(&key) {
|
||||
return Some(image.clone());
|
||||
}
|
||||
if self.requested.insert(key) {
|
||||
let _ = self.jobs.send(Job::Fetch(request));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.ready.clear();
|
||||
self.requested.clear();
|
||||
let _ = self.jobs.send(Job::Clear);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(is_book: bool, tier: Tier) -> Request {
|
||||
Request {
|
||||
album_id: "abc".into(),
|
||||
tier,
|
||||
has_cover: false,
|
||||
is_book,
|
||||
locked: false,
|
||||
colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_follows_the_media_type() {
|
||||
assert_eq!(dimensions(192, false), (192, 192));
|
||||
assert_eq!(dimensions(192, true), (157, 192));
|
||||
assert_eq!(dimensions(384, true), (315, 384));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_art_fills_the_tier_it_was_asked_for() {
|
||||
let music = generated(&request(false, Tier::Thumb));
|
||||
assert_eq!((music.width(), music.height()), (192, 192));
|
||||
let book = generated(&request(true, Tier::Hero));
|
||||
assert_eq!((book.width(), book.height()), (315, 384));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripes_actually_alternate() {
|
||||
let art = generated(&request(false, Tier::Thumb));
|
||||
let colours: HashSet<[u8; 3]> = (0..192)
|
||||
.map(|x| {
|
||||
let p = art.get_pixel(x, 0).0;
|
||||
[p[0], p[1], p[2]]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(colours.len(), 2, "expected exactly the two stripe colours");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_book_has_bright_page_edges_on_its_right() {
|
||||
let art = generated(&request(true, Tier::Hero));
|
||||
let (w, h) = (art.width(), art.height());
|
||||
let edge = art.get_pixel(w - 2, h / 2).0;
|
||||
let middle = art.get_pixel(w / 2, h / 2).0;
|
||||
let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32;
|
||||
assert!(
|
||||
brightness(edge) > brightness(middle),
|
||||
"page edges should be lighter than the cover"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_generated_pixel_is_opaque() {
|
||||
let art = generated(&request(true, Tier::Thumb));
|
||||
assert!(art.pixels().all(|p| p.0[3] == 255));
|
||||
}
|
||||
}
|
||||
61
slint-frontend/src/format.rs
Normal file
61
slint-frontend/src/format.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`.
|
||||
|
||||
/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs
|
||||
/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly:
|
||||
/// a position can briefly exceed a tag-derived duration, and a track with no duration
|
||||
/// tag at all arrives as `0.0`.
|
||||
pub fn clock(seconds: f64) -> String {
|
||||
if !seconds.is_finite() || seconds <= 0.0 {
|
||||
return "0:00".into();
|
||||
}
|
||||
let total = seconds.round() as u64;
|
||||
let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||
if hours > 0 {
|
||||
format!("{hours}:{minutes:02}:{secs:02}")
|
||||
} else {
|
||||
format!("{minutes}:{secs:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// How much of the album is left: the rest of this track plus every track after it.
|
||||
pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 {
|
||||
let current = durations.get(track_index).copied().unwrap_or(0.0);
|
||||
let rest: f64 = durations.iter().skip(track_index + 1).sum();
|
||||
(current - position).max(0.0) + rest
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clock_formats_minutes_and_hours() {
|
||||
assert_eq!(clock(0.0), "0:00");
|
||||
assert_eq!(clock(9.0), "0:09");
|
||||
assert_eq!(clock(61.0), "1:01");
|
||||
assert_eq!(clock(599.0), "9:59");
|
||||
assert_eq!(clock(3600.0), "1:00:00");
|
||||
assert_eq!(clock(12130.0), "3:22:10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_survives_the_inputs_a_real_player_produces() {
|
||||
assert_eq!(clock(-4.0), "0:00");
|
||||
assert_eq!(clock(f64::NAN), "0:00");
|
||||
assert_eq!(clock(f64::INFINITY), "0:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_this_track_and_the_ones_after_it() {
|
||||
let durations = [100.0, 200.0, 300.0];
|
||||
assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0);
|
||||
assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0);
|
||||
assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_position_past_the_tagged_duration_does_not_go_negative() {
|
||||
assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0);
|
||||
assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0);
|
||||
}
|
||||
}
|
||||
833
slint-frontend/src/keymap.rs
Normal file
833
slint-frontend/src/keymap.rs
Normal file
@@ -0,0 +1,833 @@
|
||||
//! What you are looking at, and what a keypress does to it.
|
||||
//!
|
||||
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
|
||||
//! keypress to a list of actions - the single most portable thing in that codebase, and
|
||||
//! tested there without a DOM for the same reason it is tested here without a window.
|
||||
//!
|
||||
//! The split this rests on: **what is playing** comes from the backend, **what you are
|
||||
//! looking at** lives here. A real player has other front-ends - the buttons on the
|
||||
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
|
||||
//! than owning it, and owns only the browse state.
|
||||
//!
|
||||
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
|
||||
//! IR-remote assign mode, none of which this front-end hosts.
|
||||
|
||||
use crate::library::{Group, Results, GROUPS};
|
||||
|
||||
pub const VOLUME_STEP: i32 = 10;
|
||||
pub const SEEK_STEP: f64 = 15.0;
|
||||
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
|
||||
pub const PAGE_ROWS: i32 = 3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UiState {
|
||||
pub search: String,
|
||||
pub tracks_mode: bool,
|
||||
/// `None` is the bare root screen (three shelves); otherwise which one is open.
|
||||
pub group: Option<Group>,
|
||||
pub category: Option<String>,
|
||||
pub sel_index: usize,
|
||||
/// Which of the three root shelves is focused. Only meaningful at the bare root,
|
||||
/// where `sel_index` doubles as "which tile in that row".
|
||||
pub shelf_row: usize,
|
||||
pub play_view: bool,
|
||||
pub open_album: Option<String>,
|
||||
/// Which track is highlighted in the open album's list.
|
||||
pub sheet_index: usize,
|
||||
/// Grid columns, recomputed from the window width - `move_selection` needs it to
|
||||
/// know what one row down means.
|
||||
pub cols: usize,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
search: String::new(),
|
||||
tracks_mode: false,
|
||||
group: None,
|
||||
category: None,
|
||||
sel_index: 0,
|
||||
shelf_row: 0,
|
||||
play_view: false,
|
||||
open_album: None,
|
||||
sheet_index: 0,
|
||||
cols: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UiState {
|
||||
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
|
||||
pub fn is_root_shelf(&self) -> bool {
|
||||
self.group.is_none()
|
||||
&& self.search.is_empty()
|
||||
&& !self.tracks_mode
|
||||
&& self.category.is_none()
|
||||
}
|
||||
|
||||
pub fn browsing(&self) -> bool {
|
||||
!self.play_view && self.open_album.is_none()
|
||||
}
|
||||
|
||||
/// One step of "back": search, then track-search mode, then group and category
|
||||
/// together - the order ESC and the corner button both use.
|
||||
///
|
||||
/// `group` and `category` clear as one step because a root shelf tile sets both at
|
||||
/// once, jumping straight to one category's albums; undoing that jump should be one
|
||||
/// step too, however the category was reached.
|
||||
pub fn browse_back(&mut self) {
|
||||
self.sel_index = 0;
|
||||
if !self.search.is_empty() {
|
||||
self.search.clear();
|
||||
} else if self.tracks_mode {
|
||||
self.tracks_mode = false;
|
||||
} else {
|
||||
self.group = None;
|
||||
self.category = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether anything is left to back out of - what the corner button's presence is.
|
||||
pub fn can_go_back(&self) -> bool {
|
||||
self.play_view
|
||||
|| self.open_album.is_some()
|
||||
|| !self.search.is_empty()
|
||||
|| self.tracks_mode
|
||||
|| self.group.is_some()
|
||||
|| self.category.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
|
||||
/// [`UiState`], applied in place.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Action {
|
||||
Play {
|
||||
album_id: String,
|
||||
track_index: usize,
|
||||
},
|
||||
Toggle,
|
||||
Next,
|
||||
Previous,
|
||||
/// Percentage points, positive or negative.
|
||||
Volume(i32),
|
||||
/// Seconds, positive or negative.
|
||||
Seek(f64),
|
||||
Mute,
|
||||
}
|
||||
|
||||
/// Where the flat selection index is pointing. Songs, then categories, then albums.
|
||||
enum Selection {
|
||||
Song {
|
||||
album_id: String,
|
||||
track_index: usize,
|
||||
},
|
||||
Category(String),
|
||||
Album(String),
|
||||
None,
|
||||
}
|
||||
|
||||
fn selection_at(results: &Results, sel_index: usize) -> Selection {
|
||||
let total = results.total();
|
||||
if total == 0 {
|
||||
return Selection::None;
|
||||
}
|
||||
let index = sel_index.min(total - 1);
|
||||
if let Some(hit) = results.songs.get(index) {
|
||||
return Selection::Song {
|
||||
album_id: hit.album.id.clone(),
|
||||
track_index: hit.index,
|
||||
};
|
||||
}
|
||||
let after_songs = index - results.songs.len();
|
||||
if let Some(category) = results.categories.get(after_songs) {
|
||||
return Selection::Category(category.key.clone());
|
||||
}
|
||||
match results.albums.get(after_songs - results.categories.len()) {
|
||||
Some(album) => Selection::Album(album.id.clone()),
|
||||
None => Selection::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The album behind the selection, when "show me the tracks" makes sense for it: an
|
||||
/// album tile directly, or - so you can reach the rest of it - the album behind a song
|
||||
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
|
||||
/// with nothing else to show.
|
||||
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
|
||||
let total = results.total();
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let index = sel_index.min(total - 1);
|
||||
let album = if let Some(hit) = results.songs.get(index) {
|
||||
Some(&hit.album)
|
||||
} else {
|
||||
let after_songs = index - results.songs.len();
|
||||
if after_songs < results.categories.len() {
|
||||
return None;
|
||||
}
|
||||
results.albums.get(after_songs - results.categories.len())
|
||||
}?;
|
||||
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
|
||||
}
|
||||
|
||||
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
|
||||
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
|
||||
/// clamp the column to the row it lands on rather than guessing.
|
||||
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
|
||||
if dy != 0 {
|
||||
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
|
||||
state.sel_index = state
|
||||
.sel_index
|
||||
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
|
||||
}
|
||||
if dx != 0 {
|
||||
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
|
||||
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
|
||||
}
|
||||
}
|
||||
|
||||
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
|
||||
let total = results.total();
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let cols = state.cols.max(1) as i32;
|
||||
let mut index = state.sel_index.min(total - 1) as i32;
|
||||
index += dx;
|
||||
if dy != 0 {
|
||||
// Song hits are a single-column list; everything below them is a grid.
|
||||
index += if (index as usize) < results.songs.len() {
|
||||
dy
|
||||
} else {
|
||||
dy * cols
|
||||
};
|
||||
}
|
||||
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
|
||||
}
|
||||
|
||||
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
|
||||
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
|
||||
/// these five - every other Shift+letter still reaches the search box. Search is
|
||||
/// case-insensitive, so nothing searchable is lost.
|
||||
fn shift_media(key: &str) -> Option<Action> {
|
||||
match key.to_ascii_uppercase().as_str() {
|
||||
"H" => Some(Action::Previous),
|
||||
"L" => Some(Action::Next),
|
||||
"K" => Some(Action::Volume(VOLUME_STEP)),
|
||||
"J" => Some(Action::Volume(-VOLUME_STEP)),
|
||||
"M" => Some(Action::Mute),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain character key's default: type it into the search box. Letters and digits
|
||||
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
|
||||
/// needs are searchable too.
|
||||
fn type_into_search(state: &mut UiState, key: &str) -> bool {
|
||||
let mut chars = key.chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) if c.is_alphanumeric() => {
|
||||
state.search.push(c);
|
||||
state.play_view = false;
|
||||
state.sel_index = 0;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate one keypress: mutate `state`, and return whatever the player should be
|
||||
/// asked to do.
|
||||
///
|
||||
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
|
||||
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
|
||||
/// that `results` does not carry but the clamping needs.
|
||||
pub fn handle_key(
|
||||
key: &str,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
state: &mut UiState,
|
||||
results: &Results,
|
||||
shelf_lengths: &[usize; 3],
|
||||
sheet_tracks: usize,
|
||||
) -> Vec<Action> {
|
||||
// Reachable from anywhere, including from the play view and with an album sheet open.
|
||||
if shift && !ctrl {
|
||||
if let Some(action) = shift_media(key) {
|
||||
return vec![action];
|
||||
}
|
||||
}
|
||||
|
||||
if key == "space" {
|
||||
// Except mid-search, where a space is punctuation the query needs, as in
|
||||
// "geolino azte".
|
||||
if state.browsing() && !state.search.is_empty() {
|
||||
state.search.push(' ');
|
||||
state.sel_index = 0;
|
||||
return vec![];
|
||||
}
|
||||
return vec![Action::Toggle];
|
||||
}
|
||||
|
||||
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
|
||||
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
|
||||
if ctrl {
|
||||
if state.open_album.is_some() {
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"j" => {
|
||||
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
|
||||
}
|
||||
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
if state.play_view {
|
||||
return vec![];
|
||||
}
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if state.is_root_shelf() {
|
||||
match lower.as_str() {
|
||||
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
|
||||
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
|
||||
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
|
||||
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
match lower.as_str() {
|
||||
"h" => move_selection(state, results, -1, 0),
|
||||
"l" => move_selection(state, results, 1, 0),
|
||||
"j" => move_selection(state, results, 0, 1),
|
||||
"k" => move_selection(state, results, 0, -1),
|
||||
"d" => move_selection(state, results, 0, PAGE_ROWS),
|
||||
"u" => move_selection(state, results, 0, -PAGE_ROWS),
|
||||
_ => {}
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let browsing = state.browsing();
|
||||
|
||||
match key {
|
||||
"tab" => {
|
||||
let current = state.group.map_or(-1, |g| g.index() as i32);
|
||||
state.group = Some(Group::from_index(
|
||||
((current + 1) % GROUPS.len() as i32) as usize,
|
||||
));
|
||||
state.category = None;
|
||||
state.sel_index = 0;
|
||||
state.play_view = false;
|
||||
vec![]
|
||||
}
|
||||
"/" => {
|
||||
state.play_view = false;
|
||||
vec![]
|
||||
}
|
||||
|
||||
// Arrows browse while browsing and drive the player while playing - the one
|
||||
// pair of keys that changes meaning with the screen, because on the play view
|
||||
// there is no selection for them to move.
|
||||
"right" => {
|
||||
if shift {
|
||||
return vec![Action::Seek(SEEK_STEP)];
|
||||
}
|
||||
if !browsing {
|
||||
return vec![Action::Next];
|
||||
}
|
||||
if state.is_root_shelf() {
|
||||
move_root_shelf(state, 1, 0, shelf_lengths);
|
||||
} else {
|
||||
move_selection(state, results, 1, 0);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
"left" => {
|
||||
if shift {
|
||||
return vec![Action::Seek(-SEEK_STEP)];
|
||||
}
|
||||
if !browsing {
|
||||
return vec![Action::Previous];
|
||||
}
|
||||
if state.is_root_shelf() {
|
||||
move_root_shelf(state, -1, 0, shelf_lengths);
|
||||
} else {
|
||||
move_selection(state, results, -1, 0);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
"down" => {
|
||||
if !browsing {
|
||||
return vec![Action::Volume(-VOLUME_STEP)];
|
||||
}
|
||||
if state.is_root_shelf() {
|
||||
move_root_shelf(state, 0, 1, shelf_lengths);
|
||||
} else {
|
||||
move_selection(state, results, 0, 1);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
"up" => {
|
||||
if !browsing {
|
||||
return vec![Action::Volume(VOLUME_STEP)];
|
||||
}
|
||||
if state.is_root_shelf() {
|
||||
move_root_shelf(state, 0, -1, shelf_lengths);
|
||||
} else {
|
||||
move_selection(state, results, 0, -1);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
|
||||
"?" => {
|
||||
state.tracks_mode = true;
|
||||
state.search.clear();
|
||||
state.sel_index = 0;
|
||||
state.play_view = false;
|
||||
vec![]
|
||||
}
|
||||
"escape" => {
|
||||
// Peel one layer at a time rather than dumping you back at the top.
|
||||
if state.open_album.is_some() {
|
||||
state.open_album = None;
|
||||
} else if state.play_view {
|
||||
state.play_view = false;
|
||||
} else {
|
||||
state.browse_back();
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
"backspace" => {
|
||||
if !state.search.is_empty() {
|
||||
state.search.pop();
|
||||
state.sel_index = 0;
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
"enter" => {
|
||||
if let Some(album_id) = state.open_album.clone() {
|
||||
return vec![Action::Play {
|
||||
album_id,
|
||||
track_index: state.sheet_index,
|
||||
}];
|
||||
}
|
||||
if browsing && state.is_root_shelf() {
|
||||
let row = state.shelf_row.min(GROUPS.len() - 1);
|
||||
state.group = Some(Group::from_index(row));
|
||||
// A shelf row with no tiles still opens its group; there is simply no
|
||||
// category to pre-select.
|
||||
state.category = None;
|
||||
state.sel_index = 0;
|
||||
return vec![];
|
||||
}
|
||||
if shift {
|
||||
if let Some(album_id) = selection_album(results, state.sel_index) {
|
||||
state.open_album = Some(album_id);
|
||||
state.sheet_index = 0;
|
||||
return vec![];
|
||||
}
|
||||
}
|
||||
match selection_at(results, state.sel_index) {
|
||||
Selection::Song {
|
||||
album_id,
|
||||
track_index,
|
||||
} => {
|
||||
vec![Action::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
}]
|
||||
}
|
||||
Selection::Album(album_id) => vec![Action::Play {
|
||||
album_id,
|
||||
track_index: 0,
|
||||
}],
|
||||
Selection::Category(key) => {
|
||||
state.category = Some(key);
|
||||
state.sel_index = 0;
|
||||
vec![]
|
||||
}
|
||||
Selection::None => vec![],
|
||||
}
|
||||
}
|
||||
"f1" => vec![],
|
||||
_ => {
|
||||
type_into_search(state, key);
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::library::{Category, Library, SongHit};
|
||||
|
||||
fn empty() -> Results {
|
||||
Results::default()
|
||||
}
|
||||
|
||||
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
|
||||
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_a_letter_searches_and_leaves_the_play_view() {
|
||||
let mut state = UiState {
|
||||
play_view: true,
|
||||
..Default::default()
|
||||
};
|
||||
press("k", &mut state);
|
||||
press("i", &mut state);
|
||||
assert_eq!(state.search, "ki");
|
||||
assert!(!state.play_view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn umlauts_are_searchable_like_any_other_letter() {
|
||||
let mut state = UiState::default();
|
||||
press("ö", &mut state);
|
||||
assert_eq!(state.search, "ö");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_toggles_but_types_mid_search() {
|
||||
let mut state = UiState::default();
|
||||
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||
|
||||
state.search = "geolino".into();
|
||||
assert!(press("space", &mut state).is_empty());
|
||||
assert_eq!(state.search, "geolino ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
|
||||
let mut state = UiState {
|
||||
search: "conni".into(),
|
||||
play_view: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_media_works_from_every_screen() {
|
||||
for play_view in [false, true] {
|
||||
let mut state = UiState {
|
||||
play_view,
|
||||
..Default::default()
|
||||
};
|
||||
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(got, vec![Action::Previous]);
|
||||
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_shifted_letters_still_reach_the_search_box() {
|
||||
let mut state = UiState::default();
|
||||
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(state.search, "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(press("right", &mut state).is_empty());
|
||||
|
||||
state.play_view = true;
|
||||
assert_eq!(press("right", &mut state), vec![Action::Next]);
|
||||
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
|
||||
assert_eq!(
|
||||
press("down", &mut state),
|
||||
vec![Action::Volume(-VOLUME_STEP)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_seek_from_either_screen() {
|
||||
let mut state = UiState::default();
|
||||
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
|
||||
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
|
||||
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
|
||||
let mut state = UiState::default();
|
||||
let lengths = [5, 2, 0];
|
||||
|
||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
|
||||
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
|
||||
|
||||
// Row 1 only has two tiles, so the column has to come back with it.
|
||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
|
||||
|
||||
// Row 2 has none at all.
|
||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
|
||||
|
||||
// And it does not walk off the bottom.
|
||||
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
|
||||
assert_eq!(state.shelf_row, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_at_the_root_opens_the_focused_shelfs_group() {
|
||||
let mut state = UiState {
|
||||
shelf_row: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(press("enter", &mut state).is_empty());
|
||||
assert_eq!(state.group, Some(Group::Audiobooks));
|
||||
assert_eq!(state.sel_index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_peels_one_layer_at_a_time() {
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
category: Some("Rolf".into()),
|
||||
search: "abc".into(),
|
||||
tracks_mode: true,
|
||||
play_view: true,
|
||||
open_album: Some("x".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
press("escape", &mut state);
|
||||
assert!(state.open_album.is_none(), "the sheet closes first");
|
||||
press("escape", &mut state);
|
||||
assert!(!state.play_view, "then the play view");
|
||||
press("escape", &mut state);
|
||||
assert_eq!(state.search, "", "then the query");
|
||||
press("escape", &mut state);
|
||||
assert!(!state.tracks_mode, "then track-search mode");
|
||||
press("escape", &mut state);
|
||||
assert_eq!(
|
||||
(state.group, state.category.clone()),
|
||||
(None, None),
|
||||
"then both at once"
|
||||
);
|
||||
assert!(!state.can_go_back());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_cycles_the_three_groups_and_wraps() {
|
||||
let mut state = UiState::default();
|
||||
for expected in [
|
||||
Group::Music,
|
||||
Group::Audiobooks,
|
||||
Group::Podcasts,
|
||||
Group::Music,
|
||||
] {
|
||||
press("tab", &mut state);
|
||||
assert_eq!(state.group, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_only_bites_when_there_is_a_query() {
|
||||
let mut state = UiState {
|
||||
search: "ab".into(),
|
||||
..Default::default()
|
||||
};
|
||||
press("backspace", &mut state);
|
||||
assert_eq!(state.search, "a");
|
||||
press("backspace", &mut state);
|
||||
press("backspace", &mut state);
|
||||
assert_eq!(state.search, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_mark_switches_to_track_search_and_clears_the_query() {
|
||||
let mut state = UiState {
|
||||
search: "conni".into(),
|
||||
..Default::default()
|
||||
};
|
||||
press("?", &mut state);
|
||||
assert!(state.tracks_mode);
|
||||
assert_eq!(state.search, "");
|
||||
}
|
||||
|
||||
// ---- selection over a real results set ----
|
||||
|
||||
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
|
||||
let library = Library::build(
|
||||
(0..albums.max(1))
|
||||
.map(|i| {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": format!("al{i}"),
|
||||
"section": "Musik",
|
||||
"kind": "music",
|
||||
"title": format!("Album {i}"),
|
||||
"artist": "A",
|
||||
"category": "A",
|
||||
"tracks": [{ "title": "T", "duration": 10.0 }],
|
||||
}))
|
||||
.unwrap()
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
Results {
|
||||
songs: (0..songs)
|
||||
.map(|i| SongHit {
|
||||
album: std::rc::Rc::clone(&library.albums[0]),
|
||||
index: i,
|
||||
title: format!("S{i}"),
|
||||
duration: 10.0,
|
||||
})
|
||||
.collect(),
|
||||
categories: (0..categories)
|
||||
.map(|i| Category {
|
||||
key: format!("C{i}"),
|
||||
albums: vec![],
|
||||
})
|
||||
.collect(),
|
||||
albums: library
|
||||
.albums
|
||||
.iter()
|
||||
.take(albums)
|
||||
.map(std::rc::Rc::clone)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
|
||||
let results = results_with(2, 2, 3);
|
||||
assert_eq!(results.total(), 7);
|
||||
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
cols: 3,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Inside the song list a vertical move is one row, not one grid row.
|
||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert_eq!(state.sel_index, 1);
|
||||
|
||||
// Past the songs it jumps a whole grid row.
|
||||
state.sel_index = 2;
|
||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert_eq!(state.sel_index, 5);
|
||||
|
||||
// And it never runs off the end.
|
||||
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert_eq!(state.sel_index, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_plays_a_song_hit_at_its_own_track_index() {
|
||||
let results = results_with(2, 0, 0);
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
sel_index: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![Action::Play {
|
||||
album_id: "al0".into(),
|
||||
track_index: 1
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_category_drills_in_rather_than_playing() {
|
||||
let results = results_with(0, 2, 0);
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
sel_index: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||
assert_eq!(state.category.as_deref(), Some("C1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_an_album_plays_it_from_the_top() {
|
||||
let results = results_with(0, 0, 3);
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
sel_index: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![Action::Play {
|
||||
album_id: "al2".into(),
|
||||
track_index: 0
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_opens_the_track_list_instead() {
|
||||
let results = results_with(0, 0, 2);
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
sel_index: 1,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
|
||||
assert_eq!(state.open_album.as_deref(), Some("al1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
|
||||
let results = results_with(0, 1, 0);
|
||||
let mut state = UiState {
|
||||
group: Some(Group::Music),
|
||||
sel_index: 0,
|
||||
..Default::default()
|
||||
};
|
||||
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
|
||||
assert!(state.open_album.is_none());
|
||||
assert_eq!(state.category.as_deref(), Some("C0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
|
||||
let mut state = UiState {
|
||||
open_album: Some("al0".into()),
|
||||
..Default::default()
|
||||
};
|
||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||
assert_eq!(state.sheet_index, 2);
|
||||
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
|
||||
assert_eq!(state.sheet_index, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
|
||||
let mut state = UiState {
|
||||
open_album: Some("al0".into()),
|
||||
sheet_index: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![Action::Play {
|
||||
album_id: "al0".into(),
|
||||
track_index: 2
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
504
slint-frontend/src/library.rs
Normal file
504
slint-frontend/src/library.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
//! Browsing and searching the library, entirely on the client.
|
||||
//!
|
||||
//! The whole index arrives in one response, so type-to-search has no round trip - which
|
||||
//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including
|
||||
//! the reason that module is shaped the way it is:
|
||||
//!
|
||||
//! The cost that matters is per keystroke, not per library load. A real collection is
|
||||
//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from
|
||||
//! scratch on every one of them for every letter typed - `normalize` runs a lowercase,
|
||||
//! a Unicode NFD decomposition and two filtering passes, so track search would mean
|
||||
//! five thousand NFD normalizations before a single comparison. That is work whose
|
||||
//! answer cannot change until the library does, so it happens once, in
|
||||
//! [`Library::build`], and a keystroke becomes `str::contains` over strings that
|
||||
//! already exist.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use crate::api::Album;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Group {
|
||||
Music,
|
||||
Audiobooks,
|
||||
Podcasts,
|
||||
}
|
||||
|
||||
pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts];
|
||||
|
||||
impl Group {
|
||||
/// Which of the three shelves an album belongs on. A podcast is exactly a
|
||||
/// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren
|
||||
/// album joins whichever of music/audiobooks matches what it actually holds.
|
||||
pub fn of(album: &Album) -> Self {
|
||||
if album.section == "Kinderpodcasts" {
|
||||
Self::Podcasts
|
||||
} else if album.is_book() {
|
||||
Self::Audiobooks
|
||||
} else {
|
||||
Self::Music
|
||||
}
|
||||
}
|
||||
|
||||
pub fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Music => 0,
|
||||
Self::Audiobooks => 1,
|
||||
Self::Podcasts => 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_index(index: usize) -> Self {
|
||||
GROUPS[index.min(2)]
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "Musik",
|
||||
Self::Audiobooks => "Hörbücher",
|
||||
Self::Podcasts => "Podcasts",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "🎵",
|
||||
Self::Audiobooks => "📖",
|
||||
Self::Podcasts => "🎙️",
|
||||
}
|
||||
}
|
||||
|
||||
/// Heading over the category tiles inside this group.
|
||||
pub fn category_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "Künstler",
|
||||
Self::Audiobooks => "Figuren",
|
||||
Self::Podcasts => "Sendungen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Heading over the album grid inside this group.
|
||||
pub fn section_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Music => "Alben",
|
||||
Self::Audiobooks => "Hörbücher",
|
||||
Self::Podcasts => "Episoden",
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-group hue in oklch degrees, shared with the generated cover art so a shelf
|
||||
/// already hints at the mood its albums play into.
|
||||
pub fn hue(self) -> f32 {
|
||||
match self {
|
||||
Self::Music => 210.0, // today's sea - the baseline hue
|
||||
Self::Audiobooks => 55.0, // warm amber
|
||||
Self::Podcasts => 300.0, // the one otherwise-unused family
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Diacritics and punctuation are noise when a child is hunting for letters.
|
||||
pub fn normalize(value: &str) -> String {
|
||||
value
|
||||
.to_lowercase()
|
||||
.nfd()
|
||||
// Combining marks, i.e. what NFD just split off the base letters.
|
||||
.filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Words of a query, normalized individually. A space-separated query like "conni rad"
|
||||
/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni",
|
||||
/// so requiring the whole phrase to be one contiguous substring is too strict.
|
||||
fn search_words(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split_whitespace()
|
||||
.map(normalize)
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every word has to show up somewhere in the haystack, in any order.
|
||||
fn matches_words(haystack: &str, words: &[String]) -> bool {
|
||||
words.iter().all(|word| haystack.contains(word.as_str()))
|
||||
}
|
||||
|
||||
pub struct AlbumEntry {
|
||||
pub album: Rc<Album>,
|
||||
/// `normalize(title + artist)`, as a naive `album_matches` would recompute per key.
|
||||
haystack: String,
|
||||
}
|
||||
|
||||
pub struct TrackEntry {
|
||||
pub album: Rc<Album>,
|
||||
/// Index within `album.tracks`, which is what playback wants.
|
||||
pub index: usize,
|
||||
pub title: String,
|
||||
pub duration: f64,
|
||||
haystack: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Category {
|
||||
pub key: String,
|
||||
pub albums: Vec<Rc<Album>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SongHit {
|
||||
pub album: Rc<Album>,
|
||||
pub index: usize,
|
||||
pub title: String,
|
||||
pub duration: f64,
|
||||
}
|
||||
|
||||
/// Everything a search needs, derived once per library payload.
|
||||
#[derive(Default)]
|
||||
pub struct Library {
|
||||
pub albums: Vec<Rc<Album>>,
|
||||
entries: Vec<AlbumEntry>,
|
||||
by_group: [Vec<usize>; 3],
|
||||
/// Each group's categories, already grouped and sorted.
|
||||
categories_by_group: [Vec<Category>; 3],
|
||||
/// Unlocked tracks only, album order then track order. A locked track shows as a
|
||||
/// question mark wherever it appears, so it has no title to match on and is left
|
||||
/// out of the index entirely rather than skipped at match time.
|
||||
tracks: Vec<TrackEntry>,
|
||||
tracks_by_group: [Vec<usize>; 3],
|
||||
by_id: HashMap<String, Rc<Album>>,
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn build(albums: Vec<Album>) -> Self {
|
||||
let mut library = Self::default();
|
||||
// One map per group rather than one overall: two groups may legitimately hold
|
||||
// the same category name, and merging them would put an artist's albums on
|
||||
// the audiobook shelf.
|
||||
let mut category_maps: [HashMap<String, Vec<Rc<Album>>>; 3] = Default::default();
|
||||
|
||||
for album in albums {
|
||||
let album = Rc::new(album);
|
||||
let group = Group::of(&album);
|
||||
|
||||
library.by_id.insert(album.id.clone(), Rc::clone(&album));
|
||||
library.albums.push(Rc::clone(&album));
|
||||
|
||||
library.by_group[group.index()].push(library.entries.len());
|
||||
library.entries.push(AlbumEntry {
|
||||
haystack: normalize(&format!("{}{}", album.title, album.artist)),
|
||||
album: Rc::clone(&album),
|
||||
});
|
||||
|
||||
category_maps[group.index()]
|
||||
.entry(album.category.clone())
|
||||
.or_default()
|
||||
.push(Rc::clone(&album));
|
||||
|
||||
for (index, track) in album.tracks.iter().enumerate() {
|
||||
if track.locked {
|
||||
continue;
|
||||
}
|
||||
library.tracks_by_group[group.index()].push(library.tracks.len());
|
||||
library.tracks.push(TrackEntry {
|
||||
haystack: normalize(&track.title),
|
||||
album: Rc::clone(&album),
|
||||
index,
|
||||
title: track.title.clone(),
|
||||
duration: track.duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) {
|
||||
let mut categories: Vec<Category> = map
|
||||
.into_iter()
|
||||
.map(|(key, albums)| Category { key, albums })
|
||||
.collect();
|
||||
// `localeCompare(_, "de")` in the original. A full collator is more than
|
||||
// this earns; normalizing first folds the umlauts, which is the whole of
|
||||
// what German ordering asks for here.
|
||||
categories.sort_by_key(|c| normalize(&c.key));
|
||||
*slot = categories;
|
||||
}
|
||||
|
||||
library
|
||||
}
|
||||
|
||||
pub fn get(&self, album_id: &str) -> Option<&Rc<Album>> {
|
||||
self.by_id.get(album_id)
|
||||
}
|
||||
|
||||
pub fn categories(&self, group: Group) -> &[Category] {
|
||||
&self.categories_by_group[group.index()]
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- queries --
|
||||
|
||||
/// `group: None` means unrestricted - typing at the root searches every group at once.
|
||||
pub struct Query<'a> {
|
||||
pub search: &'a str,
|
||||
pub tracks_mode: bool,
|
||||
pub group: Option<Group>,
|
||||
pub category: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Which of the three lists the browse view is showing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ListMode {
|
||||
Tracks,
|
||||
Albums,
|
||||
Categories,
|
||||
}
|
||||
|
||||
/// Capped, because an empty query over 900 podcast episodes is not a useful screen.
|
||||
pub const MAX_SONG_HITS: usize = 40;
|
||||
|
||||
/// Selection indices run flat across songs, then categories, then albums, so one pair
|
||||
/// of arrow keys walks the whole page.
|
||||
#[derive(Default)]
|
||||
pub struct Results {
|
||||
pub songs: Vec<SongHit>,
|
||||
pub categories: Vec<Category>,
|
||||
pub albums: Vec<Rc<Album>>,
|
||||
}
|
||||
|
||||
impl Results {
|
||||
pub fn total(&self) -> usize {
|
||||
self.songs.len() + self.categories.len() + self.albums.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
pub fn list_mode(&self, query: &Query) -> ListMode {
|
||||
if query.tracks_mode {
|
||||
ListMode::Tracks
|
||||
} else if !query.search.is_empty() || query.category.is_some() {
|
||||
ListMode::Albums
|
||||
} else {
|
||||
ListMode::Categories
|
||||
}
|
||||
}
|
||||
|
||||
fn album_pool(&self, query: &Query) -> Vec<usize> {
|
||||
match query.group {
|
||||
None => (0..self.entries.len()).collect(),
|
||||
Some(group) => self.by_group[group.index()].clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn category_matches(&self, query: &Query) -> Vec<Category> {
|
||||
// `group: None` is the bare root screen, rendered as three shelves instead of a
|
||||
// flat category list - each shelf asks again with its own group filled in.
|
||||
match (self.list_mode(query), query.group) {
|
||||
(ListMode::Categories, Some(group)) => self.categories(group).to_vec(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn album_matches(&self, query: &Query) -> Vec<Rc<Album>> {
|
||||
if query.tracks_mode {
|
||||
return Vec::new();
|
||||
}
|
||||
let words = search_words(query.search);
|
||||
if words.is_empty() && query.category.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.album_pool(query)
|
||||
.into_iter()
|
||||
.map(|i| &self.entries[i])
|
||||
.filter(|entry| match query.category {
|
||||
Some(category) => entry.album.category == category,
|
||||
None => true,
|
||||
})
|
||||
.filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words))
|
||||
.map(|entry| Rc::clone(&entry.album))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn song_matches(&self, query: &Query) -> Vec<SongHit> {
|
||||
if !query.tracks_mode {
|
||||
return Vec::new();
|
||||
}
|
||||
let words = search_words(query.search);
|
||||
let pool: &[usize] = match query.group {
|
||||
None => return self.take_song_hits(self.tracks.iter(), &words),
|
||||
Some(group) => &self.tracks_by_group[group.index()],
|
||||
};
|
||||
self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words)
|
||||
}
|
||||
|
||||
/// Stopping at the cap rather than filling every hit and slicing is what keeps an
|
||||
/// empty query in tracks mode from walking all 4969 tracks to show 40 rows.
|
||||
fn take_song_hits<'a>(
|
||||
&self,
|
||||
pool: impl Iterator<Item = &'a TrackEntry>,
|
||||
words: &[String],
|
||||
) -> Vec<SongHit> {
|
||||
pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words))
|
||||
.take(MAX_SONG_HITS)
|
||||
.map(|track| SongHit {
|
||||
album: Rc::clone(&track.album),
|
||||
index: track.index,
|
||||
title: track.title.clone(),
|
||||
duration: track.duration,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn results(&self, query: &Query) -> Results {
|
||||
Results {
|
||||
songs: self.song_matches(query),
|
||||
categories: self.category_matches(query),
|
||||
albums: self.album_matches(query),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id": id,
|
||||
"section": section,
|
||||
"kind": if section == "Musik" { "music" } else { "book" },
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"category": artist,
|
||||
"tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::<Vec<_>>(),
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn fixture() -> Library {
|
||||
Library::build(vec![
|
||||
album(
|
||||
"a",
|
||||
"Grüffelo",
|
||||
"Julia Donaldson",
|
||||
"Hörbücher",
|
||||
&["Kapitel 1", "Kapitel 2"],
|
||||
),
|
||||
album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]),
|
||||
album(
|
||||
"c",
|
||||
"Rote Lieder",
|
||||
"Rolf Zuckowski",
|
||||
"Musik",
|
||||
&["Wie schön"],
|
||||
),
|
||||
album(
|
||||
"d",
|
||||
"Maus Folge 3",
|
||||
"Sendung mit der Maus",
|
||||
"Kinderpodcasts",
|
||||
&["Folge 3"],
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_folds_umlauts_and_punctuation() {
|
||||
assert_eq!(normalize("Hörbücher!"), "horbucher");
|
||||
assert_eq!(normalize("Grüffelo"), "gruffelo");
|
||||
assert_eq!(normalize("A-B C"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_split_podcasts_out_of_books() {
|
||||
let library = fixture();
|
||||
assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts);
|
||||
assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks);
|
||||
assert_eq!(Group::of(library.get("c").unwrap()), Group::Music);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_search_is_word_order_independent() {
|
||||
let library = fixture();
|
||||
let hits = library.album_matches(&Query {
|
||||
search: "donaldson gruffelo",
|
||||
tracks_mode: false,
|
||||
group: None,
|
||||
category: None,
|
||||
});
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_query_lists_no_albums_but_does_list_categories() {
|
||||
let library = fixture();
|
||||
let query = Query {
|
||||
search: "",
|
||||
tracks_mode: false,
|
||||
group: Some(Group::Audiobooks),
|
||||
category: None,
|
||||
};
|
||||
assert!(library.album_matches(&query).is_empty());
|
||||
assert_eq!(library.category_matches(&query).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_root_screen_has_no_flat_category_list_of_its_own() {
|
||||
let library = fixture();
|
||||
let query = Query {
|
||||
search: "",
|
||||
tracks_mode: false,
|
||||
group: None,
|
||||
category: None,
|
||||
};
|
||||
assert!(library.category_matches(&query).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_category_lists_its_albums_without_a_search() {
|
||||
let library = fixture();
|
||||
let hits = library.album_matches(&Query {
|
||||
search: "",
|
||||
tracks_mode: false,
|
||||
group: Some(Group::Audiobooks),
|
||||
category: Some("Bibi"),
|
||||
});
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_search_is_capped_and_group_scoped() {
|
||||
let library = fixture();
|
||||
let hits = library.song_matches(&Query {
|
||||
search: "kapitel",
|
||||
tracks_mode: true,
|
||||
group: None,
|
||||
category: None,
|
||||
});
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.len() <= MAX_SONG_HITS);
|
||||
|
||||
let scoped = library.song_matches(&Query {
|
||||
search: "",
|
||||
tracks_mode: true,
|
||||
group: Some(Group::Music),
|
||||
category: None,
|
||||
});
|
||||
assert_eq!(scoped.len(), 1);
|
||||
assert_eq!(scoped[0].title, "Wie schön");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_sort_by_folded_key() {
|
||||
let library = fixture();
|
||||
let keys: Vec<&str> = library
|
||||
.categories(Group::Audiobooks)
|
||||
.iter()
|
||||
.map(|c| c.key.as_str())
|
||||
.collect();
|
||||
assert_eq!(keys, ["Bibi", "Julia Donaldson"]);
|
||||
}
|
||||
}
|
||||
985
slint-frontend/src/main.rs
Normal file
985
slint-frontend/src/main.rs
Normal file
@@ -0,0 +1,985 @@
|
||||
//! A native front-end for the MusicMouse player.
|
||||
//!
|
||||
//! The same split the web front-end is built on: **what is playing** comes from the
|
||||
//! backend over a push-only websocket, **what you are looking at** lives here. The
|
||||
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
|
||||
//! Home Assistant - so this follows playback rather than owning it.
|
||||
//!
|
||||
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
|
||||
//! websocket thread reads state; a cover thread fetches and downscales album art; a
|
||||
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
|
||||
//! exactly the stall this front-end exists to remove.
|
||||
|
||||
mod api;
|
||||
mod covers;
|
||||
mod format;
|
||||
mod keymap;
|
||||
mod library;
|
||||
mod oklch;
|
||||
mod ws;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{channel, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use slint::{ModelRc, SharedString, VecModel};
|
||||
|
||||
use api::{Album, Client, PlayerState};
|
||||
|
||||
use covers::Tier;
|
||||
use keymap::{Action, UiState};
|
||||
use library::{Group, Library, Query, Results, GROUPS};
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
// The application state lives on the UI thread and is reached from the event-loop
|
||||
// closures the worker relay posts. See `pump_workers`.
|
||||
thread_local! {
|
||||
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- commands --
|
||||
|
||||
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
|
||||
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
|
||||
/// frame, which is also what makes the optimistic updates below safe.
|
||||
enum Command {
|
||||
Play {
|
||||
album_id: String,
|
||||
track_index: usize,
|
||||
},
|
||||
Resume,
|
||||
Pause,
|
||||
Next,
|
||||
Previous,
|
||||
Seek(f64),
|
||||
Volume(i32),
|
||||
ReloadLibrary,
|
||||
}
|
||||
|
||||
enum FromWorker {
|
||||
Library(Result<Vec<Album>, String>),
|
||||
Cover(covers::Ready),
|
||||
Ws(WsEvent),
|
||||
}
|
||||
|
||||
enum WsEvent {
|
||||
Connected,
|
||||
Disconnected,
|
||||
State(Box<PlayerState>),
|
||||
Position { position: f64, duration: f64 },
|
||||
LibraryChanged,
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------ state --
|
||||
|
||||
struct App {
|
||||
commands: Sender<Command>,
|
||||
ui: UiState,
|
||||
player: PlayerState,
|
||||
library: Library,
|
||||
covers: covers::Store,
|
||||
online: bool,
|
||||
loaded: bool,
|
||||
error: Option<String>,
|
||||
/// Changes whenever the track itself changes, so the progress bar can stop
|
||||
/// animating across two unrelated positions instead of sliding between them.
|
||||
track_key: String,
|
||||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||||
/// lands on.
|
||||
smooth: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn send(&self, command: Command) {
|
||||
let _ = self.commands.send(command);
|
||||
}
|
||||
|
||||
fn query(&self) -> Query<'_> {
|
||||
Query {
|
||||
search: &self.ui.search,
|
||||
tracks_mode: self.ui.tracks_mode,
|
||||
group: self.ui.group,
|
||||
category: self.ui.category.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_album(&self) -> Option<&Rc<Album>> {
|
||||
self.player
|
||||
.album_id
|
||||
.as_deref()
|
||||
.and_then(|id| self.library.get(id))
|
||||
}
|
||||
|
||||
/// How long each of the three root shelves is, for the keyboard's column clamping.
|
||||
fn shelf_lengths(&self) -> [usize; 3] {
|
||||
[
|
||||
self.library.categories(Group::Music).len(),
|
||||
self.library.categories(Group::Audiobooks).len(),
|
||||
self.library.categories(Group::Podcasts).len(),
|
||||
]
|
||||
}
|
||||
|
||||
fn apply(&mut self, actions: Vec<Action>) {
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
} => {
|
||||
// Starting something switches to the full-screen view and closes
|
||||
// the track list behind it, as the web front-end does: what you
|
||||
// just chose becomes the thing on screen. ESC and `/` come back.
|
||||
self.ui.play_view = true;
|
||||
self.ui.open_album = None;
|
||||
self.send(Command::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
});
|
||||
}
|
||||
Action::Toggle => {
|
||||
// Optimistic: flip it here and let the pushed frame confirm. Safe
|
||||
// precisely because state only ever arrives pushed, never polled,
|
||||
// so there is no poll in flight to race with.
|
||||
self.player.playing = !self.player.playing;
|
||||
self.send(if self.player.playing {
|
||||
Command::Resume
|
||||
} else {
|
||||
Command::Pause
|
||||
});
|
||||
}
|
||||
Action::Next => self.send(Command::Next),
|
||||
Action::Previous => self.send(Command::Previous),
|
||||
Action::Volume(delta) => {
|
||||
self.player.volume = (self.player.volume + delta).clamp(0, 100);
|
||||
self.send(Command::Volume(self.player.volume));
|
||||
}
|
||||
Action::Seek(delta) => {
|
||||
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
|
||||
self.player.position = target;
|
||||
self.smooth = false;
|
||||
self.send(Command::Seek(target));
|
||||
}
|
||||
Action::Mute => {
|
||||
// Unmuting restores a sensible level rather than whatever it was:
|
||||
// the level before a mute is not recorded anywhere the device
|
||||
// agrees on, and coming back at 8% reads as still broken.
|
||||
let target = if self.player.volume == 0 { 60 } else { 0 };
|
||||
self.player.volume = target;
|
||||
self.send(Command::Volume(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ presentation --
|
||||
|
||||
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
|
||||
let palette = album.palette();
|
||||
store
|
||||
.get(covers::Request {
|
||||
album_id: album.id.clone(),
|
||||
tier,
|
||||
has_cover: album.has_cover,
|
||||
is_book: album.is_book(),
|
||||
locked: album.locked,
|
||||
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
|
||||
fn album_meta(album: &Album) -> String {
|
||||
let unit = if album.is_book() {
|
||||
"Kapitel"
|
||||
} else if album.tracks.len() == 1 {
|
||||
"Song"
|
||||
} else {
|
||||
"Songs"
|
||||
};
|
||||
let mut meta = format!(
|
||||
"{} {unit} · {}",
|
||||
album.tracks.len(),
|
||||
format::clock(album.duration)
|
||||
);
|
||||
if album.figure.is_some() {
|
||||
meta.push_str(" · 🧸");
|
||||
}
|
||||
meta
|
||||
}
|
||||
|
||||
/// "7 Hörbücher" - the line under a category tile.
|
||||
fn category_meta(group: Group, count: usize) -> String {
|
||||
let unit = match (group, count) {
|
||||
(Group::Music, 1) => "Album",
|
||||
(Group::Music, _) => "Alben",
|
||||
(Group::Audiobooks, _) => "Hörbücher",
|
||||
(Group::Podcasts, 1) => "Episode",
|
||||
(Group::Podcasts, _) => "Episoden",
|
||||
};
|
||||
format!("{count} {unit}")
|
||||
}
|
||||
|
||||
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
|
||||
AlbumTile {
|
||||
id: album.id.as_str().into(),
|
||||
title: if album.locked {
|
||||
"❓ Geheimnis".into()
|
||||
} else {
|
||||
album.title.as_str().into()
|
||||
},
|
||||
// A podcast episode's artist is its show, which the title already says.
|
||||
artist: if album.artist == album.title {
|
||||
SharedString::new()
|
||||
} else {
|
||||
album.artist.as_str().into()
|
||||
},
|
||||
meta: album_meta(album).into(),
|
||||
cover: cover_of(store, album, Tier::Thumb),
|
||||
is_book: album.is_book(),
|
||||
locked: album.locked,
|
||||
current,
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn category_tile(
|
||||
store: &mut covers::Store,
|
||||
group: Group,
|
||||
category: &library::Category,
|
||||
nav: usize,
|
||||
) -> CategoryTile {
|
||||
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
|
||||
// stack of these" without a label saying so.
|
||||
let mut covers_out = [
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
slint::Image::default(),
|
||||
];
|
||||
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
|
||||
*slot = cover_of(store, album, Tier::Thumb);
|
||||
}
|
||||
let [c1, c2, c3, c4] = covers_out;
|
||||
CategoryTile {
|
||||
key: category.key.as_str().into(),
|
||||
count_label: category_meta(group, category.albums.len()).into(),
|
||||
cover1: c1,
|
||||
cover2: c2,
|
||||
cover3: c3,
|
||||
cover4: c4,
|
||||
cover_count: category.albums.len().min(4) as i32,
|
||||
is_book: category.albums.first().is_some_and(|a| a.is_book()),
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_row(
|
||||
store: &mut covers::Store,
|
||||
album: &Album,
|
||||
index: usize,
|
||||
title: &str,
|
||||
duration: f64,
|
||||
current: bool,
|
||||
nav: usize,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
title: title.into(),
|
||||
subtitle: album.line().into(),
|
||||
duration: format::clock(duration).into(),
|
||||
cover: cover_of(store, album, Tier::Thumb),
|
||||
is_book: album.is_book(),
|
||||
current,
|
||||
locked: false,
|
||||
album_id: album.id.as_str().into(),
|
||||
index: index as i32,
|
||||
nav: nav as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn now_playing(app: &mut App) -> NowPlaying {
|
||||
let album = app.current_album().map(Rc::clone);
|
||||
let state = &app.player;
|
||||
let is_book = album.as_ref().is_some_and(|a| a.is_book());
|
||||
let is_podcast = album
|
||||
.as_ref()
|
||||
.is_some_and(|a| Group::of(a) == Group::Podcasts);
|
||||
|
||||
let (cover, hero) = match &album {
|
||||
Some(album) => (
|
||||
cover_of(&mut app.covers, album, Tier::Thumb),
|
||||
cover_of(&mut app.covers, album, Tier::Hero),
|
||||
),
|
||||
None => (slint::Image::default(), slint::Image::default()),
|
||||
};
|
||||
|
||||
let remaining_label = match &album {
|
||||
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
|
||||
// whole of what was chosen.
|
||||
Some(album) if !is_podcast => {
|
||||
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
|
||||
let left = format::remaining_in_album(
|
||||
&durations,
|
||||
state.track_index.max(0) as usize,
|
||||
state.position,
|
||||
);
|
||||
let what = if is_book { "im Hörbuch" } else { "im Album" };
|
||||
format!("Noch {} {what}", format::clock(left))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
NowPlaying {
|
||||
title: state
|
||||
.track_title
|
||||
.clone()
|
||||
.unwrap_or_else(|| "Wähl ein Album!".into())
|
||||
.into(),
|
||||
subtitle: album
|
||||
.as_ref()
|
||||
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
|
||||
.into(),
|
||||
counter: format!(
|
||||
"{} {} von {}",
|
||||
if is_book { "Kapitel" } else { "Song" },
|
||||
state.track_index + 1,
|
||||
state.track_count.max(1)
|
||||
)
|
||||
.into(),
|
||||
cover,
|
||||
hero_cover: hero,
|
||||
is_book,
|
||||
has_album: album.is_some(),
|
||||
clock_label: if state.duration > 0.0 {
|
||||
format!("−{}", format::clock(state.duration - state.position)).into()
|
||||
} else {
|
||||
SharedString::new()
|
||||
},
|
||||
remaining_label: remaining_label.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------- rendering --
|
||||
|
||||
fn render(app: &mut App, window: &AppWindow) {
|
||||
window.set_loading(!app.loaded);
|
||||
window.set_splash_text(
|
||||
match &app.error {
|
||||
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
|
||||
None => "Einen Moment …".to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_online(app.online);
|
||||
window.set_status_label(
|
||||
if !app.online {
|
||||
"Keine Verbindung".into()
|
||||
} else {
|
||||
match &app.player.active_figure {
|
||||
Some(figure) => format!("🧸 {figure}"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
|
||||
// How many columns fit, from the width the window is actually giving the grid. The
|
||||
// keyboard needs the same number to know what "one row down" means.
|
||||
let gap = 24.0_f32;
|
||||
let card = 180.0_f32;
|
||||
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
|
||||
app.ui.cols = cols;
|
||||
|
||||
window.set_play_view(app.ui.play_view);
|
||||
window.set_can_go_back(app.ui.can_go_back());
|
||||
window.set_playing(app.player.playing);
|
||||
window.set_volume(app.player.volume);
|
||||
window.set_smooth(app.smooth);
|
||||
window.set_progress(if app.player.duration > 0.0 {
|
||||
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
|
||||
} else {
|
||||
0.0
|
||||
});
|
||||
|
||||
let now = now_playing(app);
|
||||
let has_album = now.has_album;
|
||||
window.set_now(now);
|
||||
window.set_has_bar(has_album && !app.ui.play_view);
|
||||
|
||||
let root_screen = app.ui.is_root_shelf();
|
||||
window.set_root_screen(root_screen);
|
||||
window.set_shelf_row(app.ui.shelf_row as i32);
|
||||
window.set_sel_index(app.ui.sel_index as i32);
|
||||
|
||||
if root_screen {
|
||||
render_shelves(app, window);
|
||||
} else {
|
||||
render_results(app, window, cols);
|
||||
}
|
||||
|
||||
render_sheet(app, window);
|
||||
}
|
||||
|
||||
fn render_shelves(app: &mut App, window: &AppWindow) {
|
||||
let mut shelves = Vec::with_capacity(GROUPS.len());
|
||||
for group in GROUPS {
|
||||
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
|
||||
let tiles: Vec<CategoryTile> = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
|
||||
.collect();
|
||||
shelves.push(Shelf {
|
||||
label: group.label().into(),
|
||||
icon: group.icon().into(),
|
||||
tiles: ModelRc::new(VecModel::from(tiles)),
|
||||
empty_label: "Noch nichts hier".into(),
|
||||
});
|
||||
}
|
||||
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
|
||||
// The shelves replace the flat result lists rather than sitting beside them.
|
||||
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
|
||||
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
|
||||
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
|
||||
}
|
||||
|
||||
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||||
let results: Results = app.library.results(&app.query());
|
||||
let total = results.total();
|
||||
let current_album = app.player.album_id.clone();
|
||||
let current_track = app.player.track_index.max(0) as usize;
|
||||
|
||||
// Selection indices run flat across songs, then categories, then albums.
|
||||
let mut nav = 0usize;
|
||||
|
||||
let songs: Vec<TrackRow> = results
|
||||
.songs
|
||||
.iter()
|
||||
.map(|hit| {
|
||||
let row = track_row(
|
||||
&mut app.covers,
|
||||
&hit.album,
|
||||
hit.index,
|
||||
&hit.title,
|
||||
hit.duration,
|
||||
current_album.as_deref() == Some(hit.album.id.as_str())
|
||||
&& current_track == hit.index,
|
||||
nav,
|
||||
);
|
||||
nav += 1;
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
|
||||
let group = app.ui.group.unwrap_or(Group::Music);
|
||||
let categories: Vec<CategoryTile> = results
|
||||
.categories
|
||||
.iter()
|
||||
.map(|category| {
|
||||
let tile = category_tile(&mut app.covers, group, category, nav);
|
||||
nav += 1;
|
||||
tile
|
||||
})
|
||||
.collect();
|
||||
// Chunked like the album grid, and for the same reason: the view puts each row in
|
||||
// a virtualizing list rather than one ever-widening horizontal layout.
|
||||
let category_grid: Vec<CategoryRow> = categories
|
||||
.chunks(cols)
|
||||
.map(|chunk| CategoryRow {
|
||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tiles: Vec<AlbumTile> = results
|
||||
.albums
|
||||
.iter()
|
||||
.map(|album| {
|
||||
let tile = album_tile(
|
||||
&mut app.covers,
|
||||
album,
|
||||
current_album.as_deref() == Some(album.id.as_str()),
|
||||
nav,
|
||||
);
|
||||
nav += 1;
|
||||
tile
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
|
||||
// cards has no rows for it to skip. The web grid reaches the same place with
|
||||
// `content-visibility: auto`.
|
||||
let first_album_nav = songs.len() + categories.len();
|
||||
let grid: Vec<GridRow> = tiles
|
||||
.chunks(cols)
|
||||
.map(|chunk| GridRow {
|
||||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Which grid row the selection is in, so the scroller can follow it without
|
||||
// measuring anything - every row is the same height by construction.
|
||||
let sel_row = app
|
||||
.ui
|
||||
.sel_index
|
||||
.checked_sub(first_album_nav)
|
||||
.map_or(0, |within| within / cols.max(1));
|
||||
|
||||
let count_label = if app.ui.tracks_mode {
|
||||
format!("{} Titel gefunden", results.songs.len())
|
||||
} else if results.albums.is_empty() {
|
||||
"nichts gefunden".to_string()
|
||||
} else if results.albums.len() == 1 {
|
||||
"1 Album gefunden".to_string()
|
||||
} else {
|
||||
format!("{} Alben gefunden", results.albums.len())
|
||||
};
|
||||
|
||||
window.set_group_label(
|
||||
app.ui
|
||||
.group
|
||||
.map_or(SharedString::new(), |g| g.label().into()),
|
||||
);
|
||||
window.set_category_heading(group.category_label().into());
|
||||
window.set_album_heading(
|
||||
match app.ui.category.as_deref() {
|
||||
Some(category) => category.to_string(),
|
||||
None => group.section_label().to_string(),
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
|
||||
window.set_search_text(
|
||||
if app.ui.search.is_empty() {
|
||||
"tippe …".to_string()
|
||||
} else {
|
||||
format!("\"{}\"", app.ui.search)
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_mode_label(if app.ui.tracks_mode {
|
||||
"♪ Titel".into()
|
||||
} else {
|
||||
"🔎 Alben".into()
|
||||
});
|
||||
window.set_count_label(count_label.into());
|
||||
window.set_no_results(total == 0);
|
||||
window.set_sel_row(sel_row as i32);
|
||||
|
||||
window.set_songs(ModelRc::new(VecModel::from(songs)));
|
||||
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
|
||||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||||
}
|
||||
|
||||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||||
let Some(album) = app
|
||||
.ui
|
||||
.open_album
|
||||
.clone()
|
||||
.and_then(|id| app.library.get(&id).map(Rc::clone))
|
||||
else {
|
||||
window.set_show_sheet(false);
|
||||
return;
|
||||
};
|
||||
|
||||
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
|
||||
let current = app.player.track_index.max(0) as usize;
|
||||
let rows: Vec<TrackRow> = album
|
||||
.tracks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, track)| {
|
||||
let title = if track.locked {
|
||||
"❓".to_string()
|
||||
} else {
|
||||
track.title.clone()
|
||||
};
|
||||
track_row(
|
||||
&mut app.covers,
|
||||
&album,
|
||||
i,
|
||||
&title,
|
||||
track.duration,
|
||||
playing_here && current == i,
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kind = if album.is_book() {
|
||||
"📖 Hörbuch"
|
||||
} else {
|
||||
"🎵 Album"
|
||||
};
|
||||
let mut meta = format!("{kind} · {}", album_meta(&album));
|
||||
if let Some(figure) = &album.figure {
|
||||
meta.push_str(&format!(" · {figure}"));
|
||||
}
|
||||
|
||||
window.set_show_sheet(true);
|
||||
window.set_sheet_title(album.title.as_str().into());
|
||||
window.set_sheet_subtitle(album.artist.as_str().into());
|
||||
window.set_sheet_meta(meta.into());
|
||||
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
|
||||
window.set_sheet_is_book(album.is_book());
|
||||
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
|
||||
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------- wiring --
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let base = std::env::args()
|
||||
.skip_while(|a| a != "--server")
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||||
println!("musicmouse-slint → {base}");
|
||||
|
||||
// Nunito is the face the design is drawn in. Slint takes one file as the primary
|
||||
// font through `SLINT_DEFAULT_FONT`; the dev shell sets it, and a deployment can
|
||||
// drop the TTF next to the binary instead. Neither present just means the system
|
||||
// sans - a missing font should change how this looks, not whether it runs.
|
||||
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
|
||||
let local = std::path::Path::new("fonts/Nunito.ttf");
|
||||
if local.exists() {
|
||||
std::env::set_var("SLINT_DEFAULT_FONT", local);
|
||||
}
|
||||
}
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
let client = Client::new(&base);
|
||||
|
||||
let (to_ui, from_workers) = channel::<FromWorker>();
|
||||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||||
let (commands, command_rx) = channel::<Command>();
|
||||
|
||||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
covers::spawn(client.clone(), cover_rx, move |ready| {
|
||||
let _ = to_ui.send(FromWorker::Cover(ready));
|
||||
});
|
||||
}
|
||||
|
||||
// Commands, and the library fetch that seeds everything.
|
||||
{
|
||||
let client = client.clone();
|
||||
let to_ui = to_ui.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("commands".into())
|
||||
.spawn(move || {
|
||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||
for command in command_rx {
|
||||
let result = match command {
|
||||
Command::Play {
|
||||
album_id,
|
||||
track_index,
|
||||
} => client.play(&album_id, track_index as i32),
|
||||
Command::Resume => client.resume(),
|
||||
Command::Pause => client.pause(),
|
||||
Command::Next => client.next(),
|
||||
Command::Previous => client.previous(),
|
||||
Command::Seek(position) => client.seek(position),
|
||||
Command::Volume(percent) => client.volume(percent),
|
||||
Command::ReloadLibrary => {
|
||||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(error) = result {
|
||||
// A failed command is not fatal: the next pushed frame says
|
||||
// what actually happened, which is the only truth anyway.
|
||||
eprintln!("{error}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
// State.
|
||||
{
|
||||
let to_ui = to_ui.clone();
|
||||
let (ws_tx, ws_rx) = channel();
|
||||
ws::spawn(client.clone(), ws_tx);
|
||||
std::thread::Builder::new()
|
||||
.name("ws-relay".into())
|
||||
.spawn(move || {
|
||||
for update in ws_rx {
|
||||
let event = match update {
|
||||
ws::Update::Connected => WsEvent::Connected,
|
||||
ws::Update::Disconnected => WsEvent::Disconnected,
|
||||
ws::Update::State(state) => WsEvent::State(state),
|
||||
ws::Update::Position { position, duration } => {
|
||||
WsEvent::Position { position, duration }
|
||||
}
|
||||
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
|
||||
};
|
||||
if to_ui.send(FromWorker::Ws(event)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
|
||||
let app = Rc::new(RefCell::new(App {
|
||||
commands,
|
||||
ui: UiState::default(),
|
||||
player: PlayerState::default(),
|
||||
library: Library::default(),
|
||||
covers: covers::Store::new(cover_jobs),
|
||||
online: false,
|
||||
loaded: false,
|
||||
error: None,
|
||||
track_key: String::new(),
|
||||
smooth: true,
|
||||
}));
|
||||
|
||||
install_callbacks(&app, &window);
|
||||
pump_workers(&app, &window, from_workers);
|
||||
|
||||
render(&mut app.borrow_mut(), &window);
|
||||
window.run()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bridge the worker channels onto the Slint event loop.
|
||||
///
|
||||
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
|
||||
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
|
||||
/// thread-local here and only the *messages* cross, which are `Send` by construction.
|
||||
///
|
||||
/// The queue is drained rather than handled one at a time, and that is not incidental:
|
||||
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
|
||||
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
|
||||
/// a single render.
|
||||
fn pump_workers(
|
||||
app: &Rc<RefCell<App>>,
|
||||
window: &AppWindow,
|
||||
from_workers: std::sync::mpsc::Receiver<FromWorker>,
|
||||
) {
|
||||
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
|
||||
|
||||
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
|
||||
let weak = window.as_weak();
|
||||
let producer = Arc::clone(&queue);
|
||||
|
||||
std::thread::Builder::new()
|
||||
.name("relay".into())
|
||||
.spawn(move || {
|
||||
for message in from_workers {
|
||||
match producer.lock() {
|
||||
Ok(mut queue) => queue.push_back(message),
|
||||
// Only reachable if a UI-thread panic poisoned the lock, in which
|
||||
// case there is no UI left to deliver to.
|
||||
Err(_) => return,
|
||||
}
|
||||
let drain = Arc::clone(&producer);
|
||||
let posted = weak.upgrade_in_event_loop(move |window| {
|
||||
let messages: Vec<FromWorker> = match drain.lock() {
|
||||
Ok(mut queue) => queue.drain(..).collect(),
|
||||
Err(_) => return,
|
||||
};
|
||||
if messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
APP.with(|slot| {
|
||||
let Some(app) = slot.borrow().clone() else {
|
||||
return;
|
||||
};
|
||||
{
|
||||
let mut app = app.borrow_mut();
|
||||
for message in messages {
|
||||
handle_worker_message(&mut app, message);
|
||||
}
|
||||
}
|
||||
render(&mut app.borrow_mut(), &window);
|
||||
});
|
||||
});
|
||||
if posted.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.expect("spawning the worker relay");
|
||||
}
|
||||
|
||||
fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||||
match message {
|
||||
FromWorker::Library(Ok(albums)) => {
|
||||
app.library = Library::build(albums);
|
||||
app.loaded = true;
|
||||
app.error = None;
|
||||
}
|
||||
FromWorker::Library(Err(error)) => {
|
||||
app.error = Some(error);
|
||||
}
|
||||
FromWorker::Cover(ready) => app.covers.insert(ready),
|
||||
FromWorker::Ws(WsEvent::Connected) => app.online = true,
|
||||
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
|
||||
FromWorker::Ws(WsEvent::State(state)) => {
|
||||
let key = format!("{:?}:{}", state.album_id, state.track_index);
|
||||
// A new track means the bar must jump, not slide: animating across two
|
||||
// unrelated positions is what makes a seek look like it drifts backwards.
|
||||
app.smooth = key == app.track_key;
|
||||
app.track_key = key;
|
||||
app.player = *state;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::Position { position, duration }) => {
|
||||
app.player.position = position;
|
||||
if duration > 0.0 {
|
||||
app.player.duration = duration;
|
||||
}
|
||||
app.smooth = true;
|
||||
}
|
||||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||||
// A rescan is the one moment the backend rewrites the art behind an
|
||||
// unchanged cover URL, so the thumbnails go with it.
|
||||
app.covers.clear();
|
||||
app.send(Command::ReloadLibrary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||||
// Every callback does the same three things: mutate state, maybe queue a command,
|
||||
// re-render. `bind!` is that shape written once, so no individual handler has to
|
||||
// remember the third.
|
||||
macro_rules! bind {
|
||||
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
|
||||
let weak = window.as_weak();
|
||||
let handle = Rc::clone(app);
|
||||
window.$setter(move |$($arg: $ty),*| {
|
||||
let Some(window) = weak.upgrade() else { return };
|
||||
{
|
||||
let mut $a = handle.borrow_mut();
|
||||
$body
|
||||
}
|
||||
render(&mut handle.borrow_mut(), &window);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
|
||||
let results = a.library.results(&a.query());
|
||||
let shelves = a.shelf_lengths();
|
||||
let sheet_tracks =
|
||||
a.ui.open_album
|
||||
.as_deref()
|
||||
.and_then(|id| a.library.get(id))
|
||||
.map_or(0, |album| album.tracks.len());
|
||||
let mut ui = a.ui.clone();
|
||||
let actions = keymap::handle_key(
|
||||
key.as_str(),
|
||||
ctrl,
|
||||
shift,
|
||||
&mut ui,
|
||||
&results,
|
||||
&shelves,
|
||||
sheet_tracks,
|
||||
);
|
||||
a.ui = ui;
|
||||
a.apply(actions);
|
||||
});
|
||||
|
||||
bind!(on_toggle, |a| {
|
||||
a.apply(vec![Action::Toggle]);
|
||||
});
|
||||
bind!(on_next, |a| {
|
||||
a.apply(vec![Action::Next]);
|
||||
});
|
||||
bind!(on_previous, |a| {
|
||||
a.apply(vec![Action::Previous]);
|
||||
});
|
||||
bind!(on_mute, |a| {
|
||||
a.apply(vec![Action::Mute]);
|
||||
});
|
||||
|
||||
bind!(on_seek, |a, fraction: f32| {
|
||||
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
|
||||
a.player.position = target;
|
||||
a.smooth = false;
|
||||
a.send(Command::Seek(target));
|
||||
});
|
||||
|
||||
bind!(on_set_volume, |a, percent: i32| {
|
||||
a.player.volume = percent.clamp(0, 100);
|
||||
a.send(Command::Volume(a.player.volume));
|
||||
});
|
||||
|
||||
bind!(on_go_back, |a| {
|
||||
if a.ui.open_album.is_some() {
|
||||
a.ui.open_album = None;
|
||||
} else if a.ui.play_view {
|
||||
a.ui.play_view = false;
|
||||
} else {
|
||||
a.ui.browse_back();
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_enter_group, |a, shelf: i32| {
|
||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||
a.ui.category = None;
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
|
||||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||||
a.ui.category = Some(key.to_string());
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_open_category, |a, key: SharedString| {
|
||||
a.ui.category = Some(key.to_string());
|
||||
a.ui.sel_index = 0;
|
||||
});
|
||||
|
||||
bind!(on_open_album, |a, id: SharedString| {
|
||||
// A podcast episode is a single track with nothing to choose between, so the
|
||||
// cover plays it rather than opening a list of one.
|
||||
match a.library.get(id.as_str()).map(Rc::clone) {
|
||||
Some(album) if Group::of(&album) == Group::Podcasts => {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: album.id.clone(),
|
||||
track_index: 0,
|
||||
}]);
|
||||
}
|
||||
Some(album) => {
|
||||
a.ui.open_album = Some(album.id.clone());
|
||||
a.ui.sheet_index = 0;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
|
||||
bind!(on_play_album, |a, id: SharedString| {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: id.to_string(),
|
||||
track_index: 0,
|
||||
}]);
|
||||
});
|
||||
|
||||
bind!(on_play_song, |a, id: SharedString, index: i32| {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id: id.to_string(),
|
||||
track_index: index.max(0) as usize,
|
||||
}]);
|
||||
});
|
||||
|
||||
bind!(on_open_play_view, |a| {
|
||||
a.ui.play_view = true;
|
||||
});
|
||||
bind!(on_close_sheet, |a| {
|
||||
a.ui.open_album = None;
|
||||
});
|
||||
|
||||
bind!(on_play_sheet_track, |a, index: i32| {
|
||||
if let Some(album_id) = a.ui.open_album.clone() {
|
||||
a.apply(vec![Action::Play {
|
||||
album_id,
|
||||
track_index: index.max(0) as usize,
|
||||
}]);
|
||||
a.ui.open_album = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
95
slint-frontend/src/oklch.rs
Normal file
95
slint-frontend/src/oklch.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! oklch -> sRGB.
|
||||
//!
|
||||
//! The web front-end's palette is oklch throughout (`web/src/styles/app.css`) and
|
||||
//! Slint has no such colour space, so every value crossing over is converted here.
|
||||
//! Ported from `web/src/lib/oklch.ts`.
|
||||
//!
|
||||
//! The static palette is converted once at build time into the literals in
|
||||
//! `ui/theme.slint`; this module exists for the values that are only known at runtime -
|
||||
//! the per-group hues behind the generated book-spine art.
|
||||
|
||||
/// `l` is 0..1 (not the 0..100% that CSS writes), `c` is chroma, `h` is degrees.
|
||||
pub fn oklch_to_rgb(l: f32, c: f32, h: f32) -> [u8; 3] {
|
||||
let (sin, cos) = (h.to_radians().sin(), h.to_radians().cos());
|
||||
let (a, b) = (c * cos, c * sin);
|
||||
|
||||
let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b;
|
||||
let m_ = l - 0.105_561_34 * a - 0.063_854_17 * b;
|
||||
let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b;
|
||||
let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_);
|
||||
|
||||
let lin = [
|
||||
4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3,
|
||||
-1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3,
|
||||
-0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3,
|
||||
];
|
||||
lin.map(|v| {
|
||||
// Gamut clipping is per channel and deliberately crude: every colour this app
|
||||
// actually asks for is inside sRGB, so a channel landing outside it means a
|
||||
// typo, and a hard clamp shows that as a flat primary rather than hiding it.
|
||||
let v = v.clamp(0.0, 1.0);
|
||||
let encoded = if v <= 0.003_130_8 {
|
||||
12.92 * v
|
||||
} else {
|
||||
1.055 * v.powf(1.0 / 2.4) - 0.055
|
||||
};
|
||||
(encoded * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
})
|
||||
}
|
||||
|
||||
/// `#rrggbb` as the backend writes it. Anything unparseable falls back rather than
|
||||
/// failing: the colours are decoration, and an album with a malformed one should still
|
||||
/// appear on the shelf.
|
||||
pub fn parse_hex(hex: &str, fallback: [u8; 3]) -> [u8; 3] {
|
||||
let digits = hex.trim().trim_start_matches('#');
|
||||
if digits.len() != 6 {
|
||||
return fallback;
|
||||
}
|
||||
let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok();
|
||||
match (byte(0), byte(2), byte(4)) {
|
||||
(Some(r), Some(g), Some(b)) => [r, g, b],
|
||||
_ => fallback,
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear blend, for the spine's translucent highlight lines.
|
||||
pub fn mix(base: [u8; 3], over: [u8; 3], alpha: f32) -> [u8; 3] {
|
||||
let alpha = alpha.clamp(0.0, 1.0);
|
||||
[0, 1, 2].map(|i| (base[i] as f32 * (1.0 - alpha) + over[i] as f32 * alpha).round() as u8)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_anchors_round_trip_into_srgb() {
|
||||
// Pure white and pure black are the two values with no room to be subtly wrong.
|
||||
assert_eq!(oklch_to_rgb(1.0, 0.0, 0.0), [255, 255, 255]);
|
||||
assert_eq!(oklch_to_rgb(0.0, 0.0, 0.0), [0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_accent_pink_lands_where_the_web_ui_puts_it() {
|
||||
// oklch(70% 0.16 340) is `--accent`; the browser resolves it to ~#e A pink in
|
||||
// the red-magenta corner. Assert the shape of it rather than an exact byte.
|
||||
let [r, g, b] = oklch_to_rgb(0.70, 0.16, 340.0);
|
||||
assert!(r > g && b > g, "expected a pink, got {r},{g},{b}");
|
||||
assert!(r > 200, "expected a light pink, got {r},{g},{b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_parsing_falls_back_rather_than_failing() {
|
||||
assert_eq!(parse_hex("#4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||
assert_eq!(parse_hex("4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]);
|
||||
assert_eq!(parse_hex("nope", [1, 2, 3]), [1, 2, 3]);
|
||||
assert_eq!(parse_hex("", [1, 2, 3]), [1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mix_interpolates_between_the_two_ends() {
|
||||
assert_eq!(mix([0, 0, 0], [255, 255, 255], 0.0), [0, 0, 0]);
|
||||
assert_eq!(mix([0, 0, 0], [255, 255, 255], 1.0), [255, 255, 255]);
|
||||
assert_eq!(mix([0, 0, 0], [200, 100, 50], 0.5), [100, 50, 25]);
|
||||
}
|
||||
}
|
||||
97
slint-frontend/src/ws.rs
Normal file
97
slint-frontend/src/ws.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! The state websocket, with reconnect-and-reseed.
|
||||
//!
|
||||
//! Ported from `web/src/hooks/usePlayerState.ts`. The backend pushes and never listens,
|
||||
//! so this is a read loop and nothing else. Two details are load-bearing:
|
||||
//!
|
||||
//! * **Reseed on open, not just on connect.** A reconnect can have missed every change
|
||||
//! between the drop and the new socket, and the server only sends a snapshot to a
|
||||
//! client it already considers connected. So each successful open is followed by a
|
||||
//! `GET /api/state`.
|
||||
//! * **Position is not on the event bus.** The server polls its player on a 0.5 s timer
|
||||
//! and sends `position` frames only while something is playing - so the absence of
|
||||
//! them is not a stall, and the UI interpolates between them rather than waiting.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::api::{Client, PlayerState, ServerMessage};
|
||||
|
||||
/// What the UI thread hears from this connection. Wider than `ServerMessage` because
|
||||
/// connectivity is itself a thing the screen shows.
|
||||
pub enum Update {
|
||||
Connected,
|
||||
Disconnected,
|
||||
State(Box<PlayerState>),
|
||||
Position {
|
||||
position: f64,
|
||||
duration: f64,
|
||||
},
|
||||
/// The library was rescanned: re-fetch it, and drop the cover thumbnails, since a
|
||||
/// rescan is exactly when the art behind an unchanged URL gets rewritten.
|
||||
LibraryChanged,
|
||||
}
|
||||
|
||||
/// Matches the web client's retry interval. Short enough that the screen comes back
|
||||
/// before a child gives up on it, long enough not to spin while the backend restarts.
|
||||
const RETRY: Duration = Duration::from_millis(1500);
|
||||
|
||||
pub fn spawn(client: Client, updates: Sender<Update>) -> std::thread::JoinHandle<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("ws".into())
|
||||
.spawn(move || loop {
|
||||
match connect_once(&client, &updates) {
|
||||
Ok(()) | Err(()) => {}
|
||||
}
|
||||
// `send` failing means the UI is gone, which is the one reason to stop.
|
||||
if updates.send(Update::Disconnected).is_err() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
})
|
||||
.expect("spawning the websocket thread")
|
||||
}
|
||||
|
||||
fn connect_once(client: &Client, updates: &Sender<Update>) -> Result<(), ()> {
|
||||
let (mut socket, _) = tungstenite::connect(client.websocket_url()).map_err(|_| ())?;
|
||||
|
||||
if updates.send(Update::Connected).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
// Reseed: see the note at the top of this module.
|
||||
if let Ok(state) = client.state() {
|
||||
if updates.send(Update::State(Box::new(state))).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let message = match socket.read() {
|
||||
Ok(message) => message,
|
||||
Err(_) => return Err(()),
|
||||
};
|
||||
let text = match message {
|
||||
tungstenite::Message::Text(text) => text,
|
||||
tungstenite::Message::Close(_) => return Ok(()),
|
||||
// Ping/Pong are answered by tungstenite itself on the next write; binary
|
||||
// frames are not part of this protocol.
|
||||
_ => continue,
|
||||
};
|
||||
let parsed: ServerMessage = match serde_json::from_str(&text) {
|
||||
Ok(parsed) => parsed,
|
||||
// A frame this build cannot read is not a reason to drop the connection -
|
||||
// the next one is probably fine.
|
||||
Err(_) => continue,
|
||||
};
|
||||
let update = match parsed {
|
||||
ServerMessage::State { state } => Update::State(Box::new(state)),
|
||||
ServerMessage::Position { position, duration } => {
|
||||
Update::Position { position, duration }
|
||||
}
|
||||
ServerMessage::Library => Update::LibraryChanged,
|
||||
ServerMessage::Unknown => continue,
|
||||
};
|
||||
if updates.send(update).is_err() {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user