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:
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