Remove the Slint front-end
It has been untouched since 2026-09-21 while the web front-end got every feature and the Pi performance work since (thumbnails, ?pi=1, number keys, clock, volume steps). Keeping it meant a second, already stale copy of the search, keymap, Home Assistant client and API types. Nothing outside slint-frontend/ referenced it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1
slint-frontend/.gitignore
vendored
1
slint-frontend/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
target
|
|
||||||
5908
slint-frontend/Cargo.lock
generated
5908
slint-frontend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "musicmouse-slint"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Native Slint front-end for the MusicMouse player"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# default-features off so the renderer is an explicit choice below, never an
|
|
||||||
# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime
|
|
||||||
# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means
|
|
||||||
# every album cover. The `skia` feature is the device's way out.
|
|
||||||
slint = { version = "1.15", default-features = false, features = [
|
|
||||||
"compat-1-2",
|
|
||||||
"std",
|
|
||||||
] }
|
|
||||||
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
# Blocking HTTP and blocking websockets, each on its own thread, rather than an async
|
|
||||||
# runtime. The whole client makes one library request, one websocket connection and a
|
|
||||||
# trickle of covers; tokio would be more machinery than the job has work.
|
|
||||||
ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] }
|
|
||||||
tungstenite = { version = "0.24", default-features = false, features = ["handshake"] }
|
|
||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
|
||||||
dirs = "5"
|
|
||||||
# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which
|
|
||||||
# is what the web client gets from String.prototype.normalize for free.
|
|
||||||
unicode-normalization = "0.1"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
slint-build = "1.15"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["desktop"]
|
|
||||||
|
|
||||||
# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in
|
|
||||||
# seconds, which is what makes it the right default *here* and the wrong one on the Pi.
|
|
||||||
# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a
|
|
||||||
# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU
|
|
||||||
# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing.
|
|
||||||
desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"]
|
|
||||||
|
|
||||||
# Same window, Skia instead - the combination the device runs, so visual checks here
|
|
||||||
# match what ships. Costs a long first build while rust-skia bootstraps.
|
|
||||||
skia = ["slint/backend-winit", "slint/renderer-skia"]
|
|
||||||
|
|
||||||
# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input
|
|
||||||
# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.)
|
|
||||||
#
|
|
||||||
# Build it with `--no-default-features --features kiosk`: cargo features are additive,
|
|
||||||
# so leaving `desktop` enabled links winit and linuxkms into the same binary.
|
|
||||||
kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"]
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = 3
|
|
||||||
lto = "thin"
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
|
|
||||||
# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize
|
|
||||||
# on a 343-album first run is otherwise the slowest thing in the app by far.
|
|
||||||
[profile.dev.package.image]
|
|
||||||
opt-level = 3
|
|
||||||
[profile.dev.package.slint]
|
|
||||||
opt-level = 2
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# Musik Delfin — the Slint front-end
|
|
||||||
|
|
||||||
A native front-end for the music player, next to `../web/`. Same backend, same layout,
|
|
||||||
same German copy; no browser. It exists because the React UI is sluggish on the Pi 5
|
|
||||||
kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what
|
|
||||||
gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a
|
|
||||||
JPEG decode per album card per cold start.
|
|
||||||
|
|
||||||
The music player and the room panel ("Mein Zimmer"). The typing game (`Tippen`), parent
|
|
||||||
mode and the IR-remote assignment are still web-only.
|
|
||||||
|
|
||||||
## Running it
|
|
||||||
|
|
||||||
Start the backend, then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
nix-shell # cargo, the GL/X11/Wayland libs, and Nunito
|
|
||||||
cargo run # windowed, FemtoVG
|
|
||||||
cargo run -- --server http://musicdolphin:8080
|
|
||||||
cargo run -- --page room # open on the room panel rather than the player
|
|
||||||
```
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo test && cargo clippy --all-targets && cargo fmt --check
|
|
||||||
```
|
|
||||||
|
|
||||||
`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless
|
|
||||||
compositor, for checking a layout change on a machine with no display (or a locked one).
|
|
||||||
A Slint window needs a real compositor - under bare Xvfb nothing maps at all.
|
|
||||||
|
|
||||||
## Build profiles
|
|
||||||
|
|
||||||
| | Backend | Renderer | For |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `cargo run` | winit | FemtoVG | development here |
|
|
||||||
| `cargo run --features skia` | winit | Skia | checking what the device will show |
|
|
||||||
| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi |
|
|
||||||
|
|
||||||
**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every
|
|
||||||
runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing -
|
|
||||||
which for this app means every album cover. Skia and the software renderer are both
|
|
||||||
unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on
|
|
||||||
desktop Mesa; `kiosk` is the profile that ships.
|
|
||||||
|
|
||||||
`--no-default-features` is not optional on that last one: the features are additive, so
|
|
||||||
leaving `desktop` on would link winit *and* linuxkms into the same binary.
|
|
||||||
|
|
||||||
`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor.
|
|
||||||
(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used
|
|
||||||
here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild.
|
|
||||||
|
|
||||||
[bug]: https://github.com/slint-ui/slint/issues/11785
|
|
||||||
|
|
||||||
## Where the speed comes from
|
|
||||||
|
|
||||||
Four things, in rough order of how much they matter on the device:
|
|
||||||
|
|
||||||
**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long
|
|
||||||
edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover
|
|
||||||
once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG
|
|
||||||
thumbnail to `~/.cache/musicmouse-slint/covers/<album-id>@<px>.jpg`. Every later start
|
|
||||||
reads that instead. There are two tiers and only two, which is what makes the cache
|
|
||||||
worth having: a per-widget pixel size would give each album a dozen near-identical files
|
|
||||||
and a fresh decode for each.
|
|
||||||
|
|
||||||
The cache is dropped wholesale when the websocket announces a rescan
|
|
||||||
(`{"type": "library"}`), because that is the one moment the backend rewrites the art
|
|
||||||
behind an unchanged cover URL. That is the bug the web client had to learn about the
|
|
||||||
hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`.
|
|
||||||
|
|
||||||
**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list
|
|
||||||
pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates
|
|
||||||
the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the
|
|
||||||
chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in
|
|
||||||
the web UI, which exists for exactly the same measurement: 340 live cards saturated the
|
|
||||||
Pi's renderer so completely that the main thread stopped scheduling anything.
|
|
||||||
|
|
||||||
**The progress bar animates instead of ticking.** Position arrives twice a second. The
|
|
||||||
web client interpolates it onto animation frames with a JS clock and a render budget;
|
|
||||||
here the fill is a Slint `animate width` between the pushed values, so interpolation
|
|
||||||
costs a property evaluation per frame on the render side and there is no clock at all.
|
|
||||||
It is suppressed for the one frame a track changes on, so a new track jumps rather than
|
|
||||||
sliding across two unrelated positions.
|
|
||||||
|
|
||||||
**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not
|
|
||||||
here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0%
|
|
||||||
and 25% was still too much: a loop repainting the viewport is a floor you cannot get
|
|
||||||
under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already
|
|
||||||
does on the device this replaces.
|
|
||||||
|
|
||||||
## What is deliberately different from the web UI
|
|
||||||
|
|
||||||
**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent
|
|
||||||
fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely
|
|
||||||
lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost
|
|
||||||
that dropping the blur was meant to avoid.
|
|
||||||
|
|
||||||
Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five
|
|
||||||
times *better* than no blur (25% of a core against 118%), because it is what promotes
|
|
||||||
each panel to its own compositing layer. That is a browser-compositor artifact and does
|
|
||||||
not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`.
|
|
||||||
|
|
||||||
**No dolphin.** The mascot and its swim/bob loops are not ported.
|
|
||||||
|
|
||||||
**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of
|
|
||||||
`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than
|
|
||||||
`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch
|
|
||||||
tokens converted to sRGB, with the oklch source kept beside each one, because oklch is
|
|
||||||
the form the design is actually maintained in.
|
|
||||||
|
|
||||||
## How it is put together
|
|
||||||
|
|
||||||
The split that matters is the web UI's, unchanged: **what is playing** comes from the
|
|
||||||
backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on
|
|
||||||
the reader and Home Assistant all move playback too, so this follows it rather than
|
|
||||||
assuming it is in charge.
|
|
||||||
|
|
||||||
Three worker threads and no async runtime - the whole client makes one library request,
|
|
||||||
one websocket connection and a trickle of covers.
|
|
||||||
|
|
||||||
| Path | What it is |
|
|
||||||
|---|---|
|
|
||||||
| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. |
|
|
||||||
| `src/ws.rs` | The state websocket, with reconnect-and-reseed. |
|
|
||||||
| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. |
|
|
||||||
| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. |
|
|
||||||
| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. |
|
|
||||||
| `src/ha.rs` | Home Assistant through the backend's proxy, and the shutter/colour conversions. Port of `web/src/hooks/useHomeAssistant.ts` and `lib/shutter.ts`. |
|
|
||||||
| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. |
|
|
||||||
| `src/main.rs` | Wiring: worker channels in, Slint models out. |
|
|
||||||
| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. |
|
|
||||||
|
|
||||||
`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure
|
|
||||||
functions with their own tests, and they keep them: 43 tests, no window required.
|
|
||||||
|
|
||||||
## Keyboard
|
|
||||||
|
|
||||||
The same map as the web UI.
|
|
||||||
|
|
||||||
| Key | |
|
|
||||||
|---|---|
|
|
||||||
| `SPACE` | play / pause (types a space mid-query) |
|
|
||||||
| `SHIFT+H` / `SHIFT+L` | previous / next |
|
|
||||||
| `SHIFT+J` / `SHIFT+K` | quieter / louder |
|
|
||||||
| `SHIFT+M` | mute |
|
|
||||||
| `SHIFT+←` / `SHIFT+→` | seek 15 s |
|
|
||||||
| `A-Z`, `0-9` | search albums as you type |
|
|
||||||
| `?` | search individual tracks |
|
|
||||||
| `TAB` | cycle Musik / Hörbücher / Podcasts |
|
|
||||||
| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing |
|
|
||||||
| `CTRL+H/J/K/L` | the same, without leaving the home row |
|
|
||||||
| `CTRL+D` / `CTRL+U` | half a page |
|
|
||||||
| `ENTER` | play the selection |
|
|
||||||
| `SHIFT+ENTER` | open the track list |
|
|
||||||
| `ESC` | back out one layer at a time |
|
|
||||||
| `/` | back to browsing |
|
|
||||||
| `CTRL+TAB` | cycle Musik / Mein Zimmer / Tippen |
|
|
||||||
|
|
||||||
Everything is clickable too.
|
|
||||||
|
|
||||||
`CTRL+TAB` is the one addition to the web front-end's map. Its tab rail is reachable
|
|
||||||
only with a pointer, which sits badly with a device whose stated design is that every
|
|
||||||
screen can be reached from the keyboard. Plain `TAB` was already the group cycle.
|
|
||||||
|
|
||||||
## Mein Zimmer
|
|
||||||
|
|
||||||
The Home Assistant room panel, in its own violet rather than the player's teal so the
|
|
||||||
two read as siblings. Lights get a toggle, five brightness steps and six colours;
|
|
||||||
shutters get four presets, up/stop/down and a window that reflects the reported
|
|
||||||
position. Nothing is animated locally - a real cover reports its own movement, and
|
|
||||||
guessing where it will end up is what would fight with that.
|
|
||||||
|
|
||||||
The whole page is a poll, not a subscription, and only while it is on screen: Home
|
|
||||||
Assistant's own auth-and-subscribe websocket is more machinery than this needs, and a
|
|
||||||
lamp is not worth a request every 2.5 s when nobody is looking. A tap patches the
|
|
||||||
entity locally and remembers when, so the poll already in flight cannot undo it - the
|
|
||||||
same reconciliation `useHomeAssistant.ts` documents, and for the same reason.
|
|
||||||
|
|
||||||
The tab appears only when the backend answers `GET /api/ha` with a config; a 404 there
|
|
||||||
means Home Assistant is not set up, which is a normal answer rather than an error.
|
|
||||||
|
|
||||||
The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and
|
|
||||||
reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable
|
|
||||||
without a window and without hardcoding keysym values.
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2
|
|
||||||
the web app self-hosts, so this wants the TTF; a deployment can drop one at
|
|
||||||
`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
fn main() {
|
|
||||||
slint_build::compile("ui/app.slint").expect("compiling ui/app.slint");
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,9 +0,0 @@
|
|||||||
Nunito, the face the design is drawn in, as a variable TTF.
|
|
||||||
|
|
||||||
`web/public/fonts/` has the same family as woff2, which is right for a browser and
|
|
||||||
useless here: Slint cannot read woff2. This is the upstream variable TTF (weights
|
|
||||||
200-1000), which `SLINT_DEFAULT_FONT` is pointed at - by `shell.nix` for development,
|
|
||||||
and by the launcher `roles/pi_slint_frontend` installs on the device.
|
|
||||||
|
|
||||||
Nunito is licensed under the SIL Open Font License 1.1:
|
|
||||||
https://github.com/googlefonts/nunito
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Dev shell for the Slint front-end.
|
|
||||||
#
|
|
||||||
# NixOS has no rustup-installed toolchain here, and Slint's renderers link against
|
|
||||||
# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own -
|
|
||||||
# hence LD_LIBRARY_PATH below rather than just a list of buildInputs.
|
|
||||||
{ pkgs ? import <nixpkgs> { } }:
|
|
||||||
|
|
||||||
let
|
|
||||||
# Everything the winit backend dlopen()s at runtime. Missing one of these shows up
|
|
||||||
# as a panic inside winit, not as a link error, so they are easy to misdiagnose.
|
|
||||||
runtimeLibs = with pkgs; [
|
|
||||||
libGL
|
|
||||||
libxkbcommon
|
|
||||||
wayland
|
|
||||||
xorg.libX11
|
|
||||||
xorg.libXcursor
|
|
||||||
xorg.libXi
|
|
||||||
xorg.libXrandr
|
|
||||||
fontconfig
|
|
||||||
freetype
|
|
||||||
];
|
|
||||||
in
|
|
||||||
pkgs.mkShell {
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
cargo
|
|
||||||
rustc
|
|
||||||
rustfmt
|
|
||||||
clippy
|
|
||||||
pkg-config
|
|
||||||
# Only needed when building with --features skia: rust-skia compiles its own
|
|
||||||
# bindings shim and wants a C++ toolchain plus python for the GN build.
|
|
||||||
clang
|
|
||||||
python3
|
|
||||||
];
|
|
||||||
|
|
||||||
# Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and
|
|
||||||
# makes it the primary font. This points at the copy vendored in `fonts/` rather than
|
|
||||||
# at nixpkgs, so development and the device render from the identical file.
|
|
||||||
SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf;
|
|
||||||
|
|
||||||
buildInputs = runtimeLibs ++ (with pkgs; [
|
|
||||||
fontconfig
|
|
||||||
# libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They
|
|
||||||
# cost nothing to have present on a desktop build.
|
|
||||||
libinput
|
|
||||||
libdrm
|
|
||||||
seatd
|
|
||||||
udev
|
|
||||||
]);
|
|
||||||
|
|
||||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
|
||||||
|
|
||||||
# rust-skia downloads a prebuilt libskia rather than building it, when it can.
|
|
||||||
SKIA_BINARIES_URL_TEMPLATE = null;
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
echo "musicmouse slint-frontend — cargo $(cargo --version | cut -d' ' -f2)"
|
|
||||||
echo " cargo run # windowed, femtovg"
|
|
||||||
echo " cargo run --features skia # windowed, skia (what the Pi uses)"
|
|
||||||
echo " cargo build --features kiosk # linuxkms + skia, for the device"
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -1,334 +0,0 @@
|
|||||||
//! 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A GET that decodes JSON, with the status code kept in the error text so a
|
|
||||||
/// caller that cares about 404 - several do, because "not configured" is a normal
|
|
||||||
/// answer here - can tell it from a connection failure.
|
|
||||||
pub(crate) fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T, Error> {
|
|
||||||
self.agent
|
|
||||||
.get(&format!("{}/api{path}", self.base))
|
|
||||||
.call()
|
|
||||||
.map_err(|e| format!("GET {path}: {e}"))?
|
|
||||||
.into_json()
|
|
||||||
.map_err(|e| format!("GET {path}: bad JSON: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A POST whose response body, if any, is not worth reading. Home Assistant's
|
|
||||||
/// relayed replies are the only ones with a body, and nothing here reads it.
|
|
||||||
pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> {
|
|
||||||
self.agent
|
|
||||||
.post(&format!("{}/api{path}", self.base))
|
|
||||||
.send_json(body)
|
|
||||||
.map(|_| ())
|
|
||||||
.map_err(|e| format!("POST {path}: {e}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
//! 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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
//! 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
//! Home Assistant, through the backend's proxy.
|
|
||||||
//!
|
|
||||||
//! Ported from `web/src/hooks/useHomeAssistant.ts`. The browser never learns Home
|
|
||||||
//! Assistant's URL or token and neither does this: the backend keeps both and relays
|
|
||||||
//! `GET /api/ha/states/{id}` and `POST /api/ha/services/{domain}/{service}`, so all
|
|
||||||
//! this client knows is which entities exist.
|
|
||||||
//!
|
|
||||||
//! A short poll rather than a subscription. HA's own auth/subscribe websocket is more
|
|
||||||
//! machinery than a room panel needs, and the panel is only on screen while someone is
|
|
||||||
//! looking at it.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::api::{Client, Error};
|
|
||||||
|
|
||||||
pub const POLL_INTERVAL: Duration = Duration::from_millis(2500);
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct Device {
|
|
||||||
pub entity_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Device {
|
|
||||||
pub fn label(&self) -> &str {
|
|
||||||
self.name.as_deref().unwrap_or(&self.entity_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn domain(&self) -> &str {
|
|
||||||
self.entity_id.split('.').next().unwrap_or("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Presence-only, like the LIRC config - there is nothing secret in a list of entity
|
|
||||||
/// ids. `None` from `GET /api/ha` means Home Assistant is not configured at all, which
|
|
||||||
/// is a normal answer and hides the tab rather than being an error.
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize)]
|
|
||||||
pub struct Config {
|
|
||||||
#[serde(default)]
|
|
||||||
pub devices: Vec<Device>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub scenes: Vec<Device>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entity's `state`/`attributes`, relayed byte-for-byte from Home Assistant.
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
|
||||||
pub struct EntityState {
|
|
||||||
#[serde(default)]
|
|
||||||
pub state: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub attributes: serde_json::Map<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EntityState {
|
|
||||||
pub fn is_on(&self) -> bool {
|
|
||||||
self.state == "on"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn number(&self, key: &str) -> Option<f64> {
|
|
||||||
self.attributes.get(key)?.as_f64()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 0..255 as Home Assistant reports it.
|
|
||||||
pub fn brightness(&self) -> f64 {
|
|
||||||
self.number("brightness").unwrap_or(0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Percent *open*, which is HA's convention. The room UI thinks in percent closed -
|
|
||||||
/// see `closed_percent`.
|
|
||||||
pub fn cover_position(&self) -> Option<f64> {
|
|
||||||
self.number("current_position")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn supported_features(&self) -> u64 {
|
|
||||||
self.number("supported_features").unwrap_or(0.0) as u64
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn rgb(&self) -> [u8; 3] {
|
|
||||||
let Some(values) = self.attributes.get("rgb_color").and_then(|v| v.as_array()) else {
|
|
||||||
// The warm default the web card falls back to, so an on-but-uncoloured lamp
|
|
||||||
// is not drawn as if it were off.
|
|
||||||
return [255, 214, 140];
|
|
||||||
};
|
|
||||||
let mut out = [255u8, 214, 140];
|
|
||||||
for (slot, value) in out.iter_mut().zip(values) {
|
|
||||||
if let Some(number) = value.as_f64() {
|
|
||||||
*slot = number.clamp(0.0, 255.0) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this light can be given a colour, as opposed to only a brightness. A
|
|
||||||
/// `brightness_pct` sent to a plain on/off bulb is harmlessly ignored by HA, so
|
|
||||||
/// only the colour row is worth branching on.
|
|
||||||
pub fn is_color(&self) -> bool {
|
|
||||||
const COLOR_MODES: [&str; 5] = ["hs", "rgb", "rgbw", "rgbww", "xy"];
|
|
||||||
self.attributes
|
|
||||||
.get("supported_color_modes")
|
|
||||||
.and_then(|v| v.as_array())
|
|
||||||
.is_some_and(|modes| {
|
|
||||||
modes
|
|
||||||
.iter()
|
|
||||||
.filter_map(|m| m.as_str())
|
|
||||||
.any(|m| COLOR_MODES.contains(&m))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------- shutters --
|
|
||||||
|
|
||||||
/// Home Assistant's `CoverEntityFeature.SET_POSITION`.
|
|
||||||
pub const SUPPORT_SET_POSITION: u64 = 4;
|
|
||||||
|
|
||||||
/// The four buttons on a shutter card, in percent *closed*.
|
|
||||||
pub const SHUTTER_PRESETS: [(&str, f64); 4] = [
|
|
||||||
("Offen", 0.0),
|
|
||||||
("Halb zu", 50.0),
|
|
||||||
("Fast zu", 85.0),
|
|
||||||
("Ganz zu", 100.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// HA reports percent *open*; the UI, following the design, thinks in percent *closed*.
|
|
||||||
/// The inversion lives in one place because it is easy to get backwards.
|
|
||||||
pub fn closed_percent(position: f64) -> f64 {
|
|
||||||
100.0 - position
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn position_from_closed(closed: f64) -> f64 {
|
|
||||||
100.0 - closed
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn closed_label(closed: f64) -> String {
|
|
||||||
if closed <= 1.0 {
|
|
||||||
"Offen".into()
|
|
||||||
} else if closed >= 99.0 {
|
|
||||||
"Ganz zu".into()
|
|
||||||
} else if closed >= 80.0 {
|
|
||||||
"Fast zu".into()
|
|
||||||
} else if closed >= 40.0 {
|
|
||||||
"Halb zu".into()
|
|
||||||
} else {
|
|
||||||
format!("{}% zu", closed.round())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------- colours --
|
|
||||||
|
|
||||||
/// The six colours the room page offers, with the oklch each was authored as. Kept as
|
|
||||||
/// literals rather than converted at runtime for the same reason `ui/theme.slint` does:
|
|
||||||
/// the oklch is the form the design is maintained in, and the sRGB is what gets drawn.
|
|
||||||
pub const SWATCHES: [(&str, [u8; 3]); 6] = [
|
|
||||||
("Warmweiß", [0xf0, 0xdc, 0xb1]), // oklch(90% 0.06 85)
|
|
||||||
("Sonnengelb", [0xef, 0xcc, 0x36]), // oklch(85% 0.16 95)
|
|
||||||
("Korallenrot", [0xf1, 0x4d, 0x4c]), // oklch(65% 0.20 25)
|
|
||||||
("Delfinblau", [0x00, 0xab, 0xed]), // oklch(70% 0.15 235)
|
|
||||||
("Riffgrün", [0x42, 0xcb, 0x80]), // oklch(75% 0.16 155)
|
|
||||||
("Quallenlila", [0xb1, 0x6a, 0xe0]), // oklch(65% 0.18 310)
|
|
||||||
];
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------- client --
|
|
||||||
|
|
||||||
impl Client {
|
|
||||||
/// `Ok(None)` when Home Assistant is not configured - a 404 here is a normal
|
|
||||||
/// answer, and the room tab simply does not appear.
|
|
||||||
pub fn ha_config(&self) -> Result<Option<Config>, Error> {
|
|
||||||
match self.get_json::<Config>("/ha") {
|
|
||||||
Ok(config) => Ok(Some(config)),
|
|
||||||
Err(error) if error.contains("404") => Ok(None),
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ha_state(&self, entity_id: &str) -> Result<EntityState, Error> {
|
|
||||||
self.get_json(&format!("/ha/states/{entity_id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn ha_call(
|
|
||||||
&self,
|
|
||||||
domain: &str,
|
|
||||||
service: &str,
|
|
||||||
body: serde_json::Value,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
self.post_json(&format!("/ha/services/{domain}/{service}"), body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the poll thread sends back.
|
|
||||||
pub struct Snapshot {
|
|
||||||
pub states: HashMap<String, EntityState>,
|
|
||||||
/// When the request that produced this went out. The UI drops any entity whose
|
|
||||||
/// optimistic patch is newer - see the note in `Room::apply`.
|
|
||||||
pub started_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Job {
|
|
||||||
/// Poll these entities once, now.
|
|
||||||
Poll(Vec<String>),
|
|
||||||
Call {
|
|
||||||
domain: String,
|
|
||||||
service: String,
|
|
||||||
body: serde_json::Value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One worker for both polling and commands, so a command can never overtake the poll
|
|
||||||
/// that was meant to confirm it.
|
|
||||||
pub fn spawn(
|
|
||||||
client: Client,
|
|
||||||
jobs: std::sync::mpsc::Receiver<Job>,
|
|
||||||
deliver: impl Fn(Snapshot) + Send + 'static,
|
|
||||||
) -> std::thread::JoinHandle<()> {
|
|
||||||
std::thread::Builder::new()
|
|
||||||
.name("ha".into())
|
|
||||||
.spawn(move || {
|
|
||||||
for job in jobs {
|
|
||||||
match job {
|
|
||||||
Job::Call {
|
|
||||||
domain,
|
|
||||||
service,
|
|
||||||
body,
|
|
||||||
} => {
|
|
||||||
if let Err(error) = client.ha_call(&domain, &service, body) {
|
|
||||||
// The next poll corrects whatever the optimistic update
|
|
||||||
// guessed, so a failed call is worth a line and nothing more.
|
|
||||||
eprintln!("{domain}.{service}: {error}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Job::Poll(entity_ids) => {
|
|
||||||
let started_at = Instant::now();
|
|
||||||
let mut states = HashMap::new();
|
|
||||||
for entity_id in entity_ids {
|
|
||||||
if let Ok(state) = client.ha_state(&entity_id) {
|
|
||||||
states.insert(entity_id, state);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deliver(Snapshot { states, started_at });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.expect("spawning the home assistant worker")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn state(json: serde_json::Value) -> EntityState {
|
|
||||||
serde_json::from_value(json).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closed_percent_inverts_home_assistants_convention() {
|
|
||||||
assert_eq!(closed_percent(100.0), 0.0); // fully open
|
|
||||||
assert_eq!(closed_percent(0.0), 100.0); // fully closed
|
|
||||||
assert_eq!(position_from_closed(85.0), 15.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closed_label_names_the_presets_and_falls_back_to_a_number() {
|
|
||||||
assert_eq!(closed_label(0.0), "Offen");
|
|
||||||
assert_eq!(closed_label(100.0), "Ganz zu");
|
|
||||||
assert_eq!(closed_label(85.0), "Fast zu");
|
|
||||||
assert_eq!(closed_label(50.0), "Halb zu");
|
|
||||||
assert_eq!(closed_label(20.0), "20% zu");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_colour_light_is_told_apart_by_its_modes() {
|
|
||||||
assert!(state(serde_json::json!({
|
|
||||||
"state": "on",
|
|
||||||
"attributes": { "supported_color_modes": ["hs", "color_temp"] }
|
|
||||||
}))
|
|
||||||
.is_color());
|
|
||||||
assert!(!state(serde_json::json!({
|
|
||||||
"state": "on",
|
|
||||||
"attributes": { "supported_color_modes": ["brightness"] }
|
|
||||||
}))
|
|
||||||
.is_color());
|
|
||||||
assert!(!state(serde_json::json!({ "state": "on", "attributes": {} })).is_color());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rgb_falls_back_to_a_warm_white_rather_than_black() {
|
|
||||||
assert_eq!(
|
|
||||||
state(serde_json::json!({"state":"on","attributes":{}})).rgb(),
|
|
||||||
[255, 214, 140]
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
state(serde_json::json!({
|
|
||||||
"state": "on", "attributes": { "rgb_color": [10, 20, 30] }
|
|
||||||
}))
|
|
||||||
.rgb(),
|
|
||||||
[10, 20, 30]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_missing_attribute_reads_as_zero_rather_than_failing() {
|
|
||||||
let s = state(serde_json::json!({ "state": "off", "attributes": {} }));
|
|
||||||
assert_eq!(s.brightness(), 0.0);
|
|
||||||
assert_eq!(s.cover_position(), None);
|
|
||||||
assert_eq!(s.supported_features(), 0);
|
|
||||||
assert!(!s.is_on());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_device_knows_its_domain_and_its_label() {
|
|
||||||
let device: Device =
|
|
||||||
serde_json::from_value(serde_json::json!({ "entity_id": "light.kitchen" })).unwrap();
|
|
||||||
assert_eq!(device.domain(), "light");
|
|
||||||
assert_eq!(device.label(), "light.kitchen");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,915 +0,0 @@
|
|||||||
//! 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;
|
|
||||||
|
|
||||||
/// Which top-level page is showing. Orthogonal to everything below it, so switching
|
|
||||||
/// pages and coming back leaves the music side exactly as it was.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Page {
|
|
||||||
Music,
|
|
||||||
Room,
|
|
||||||
Typing,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Page {
|
|
||||||
pub fn icon(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
// A plain Unicode symbol rather than the emoji: that glyph carries its own
|
|
||||||
// muted colour on some platforms and ignores `color`, which makes it
|
|
||||||
// illegible on the rail's own background.
|
|
||||||
Self::Music => "♪",
|
|
||||||
Self::Room => "💡",
|
|
||||||
Self::Typing => "⌨️",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn label(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Music => "Musik",
|
|
||||||
Self::Room => "Mein Zimmer",
|
|
||||||
Self::Typing => "Tippen",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct UiState {
|
|
||||||
pub page: Page,
|
|
||||||
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 {
|
|
||||||
page: Page::Music,
|
|
||||||
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.page != Page::Music
|
|
||||||
|| 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+Tab cycles the pages. This is an addition to the web front-end, where the
|
|
||||||
// tab rail is reachable only with a pointer - which sits badly with a device whose
|
|
||||||
// stated design is that every screen can be reached from the keyboard. Plain Tab
|
|
||||||
// is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer
|
|
||||||
// one everywhere else.
|
|
||||||
if ctrl && key == "tab" {
|
|
||||||
state.page = match state.page {
|
|
||||||
Page::Music => Page::Room,
|
|
||||||
Page::Room => Page::Typing,
|
|
||||||
Page::Typing => Page::Music,
|
|
||||||
};
|
|
||||||
return vec![];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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. The
|
|
||||||
// page is the outermost layer: ESC on the room or typing page comes back
|
|
||||||
// to the player, and only then starts unwinding the browse hierarchy.
|
|
||||||
if state.page != Page::Music {
|
|
||||||
state.page = Page::Music;
|
|
||||||
} else 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 ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() {
|
|
||||||
let mut state = UiState::default();
|
|
||||||
for expected in [Page::Room, Page::Typing, Page::Music] {
|
|
||||||
handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0);
|
|
||||||
assert_eq!(state.page, expected);
|
|
||||||
}
|
|
||||||
// And the outer cycle has not eaten the inner one.
|
|
||||||
press("tab", &mut state);
|
|
||||||
assert_eq!(state.group, Some(Group::Music));
|
|
||||||
assert_eq!(state.page, Page::Music);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() {
|
|
||||||
let mut state = UiState {
|
|
||||||
page: Page::Room,
|
|
||||||
group: Some(Group::Music),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert_eq!(state.page, Page::Music);
|
|
||||||
assert_eq!(
|
|
||||||
state.group,
|
|
||||||
Some(Group::Music),
|
|
||||||
"the player was left as it was"
|
|
||||||
);
|
|
||||||
press("escape", &mut state);
|
|
||||||
assert_eq!(state.group, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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
|
|
||||||
}]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,504 +0,0 @@
|
|||||||
//! 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"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,95 +0,0 @@
|
|||||||
//! 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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
//! 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(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# Render the UI and screenshot it without touching your session.
|
|
||||||
#
|
|
||||||
# A Slint window needs a compositor: under bare Xvfb nothing maps, and on a locked or
|
|
||||||
# remote machine there is no session to draw into. So this starts a nested headless
|
|
||||||
# sway, runs the app inside it, drives it with synthetic keys and grabs a frame per
|
|
||||||
# screen. It is how the shots in README.md were made, and how to check a layout change
|
|
||||||
# on a machine with no display.
|
|
||||||
#
|
|
||||||
# nix-shell -E '(import ./shell.nix {}).overrideAttrs (o: {
|
|
||||||
# nativeBuildInputs = o.nativeBuildInputs ++ (with (import <nixpkgs> {}); [ sway grim wtype ]);
|
|
||||||
# })' --run 'tools/headless-shots.sh out/ http://127.0.0.1:8080'
|
|
||||||
#
|
|
||||||
set -euo pipefail
|
|
||||||
OUTDIR="${1:?usage: headless-shots.sh <outdir> [server-url]}"
|
|
||||||
SERVER="${2:-http://127.0.0.1:8080}"
|
|
||||||
BIN="${BIN:-./target/release/musicmouse-slint}"
|
|
||||||
mkdir -p "$OUTDIR"
|
|
||||||
|
|
||||||
export XDG_RUNTIME_DIR="$(mktemp -d)"
|
|
||||||
unset WAYLAND_DISPLAY DISPLAY
|
|
||||||
export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman
|
|
||||||
trap 'kill %1 2>/dev/null || true; rm -rf "$XDG_RUNTIME_DIR"' EXIT
|
|
||||||
|
|
||||||
launcher="$XDG_RUNTIME_DIR/launch.sh"
|
|
||||||
printf '#!/usr/bin/env bash\nexec %q --server %q >%q/app.log 2>&1\n' \
|
|
||||||
"$(realpath "$BIN")" "$SERVER" "$XDG_RUNTIME_DIR" > "$launcher"
|
|
||||||
chmod +x "$launcher"
|
|
||||||
printf 'output HEADLESS-1 mode 1920x1080\ndefault_border none\nexec %s\n' "$launcher" \
|
|
||||||
> "$XDG_RUNTIME_DIR/sway.cfg"
|
|
||||||
|
|
||||||
sway -c "$XDG_RUNTIME_DIR/sway.cfg" >"$XDG_RUNTIME_DIR/sway.log" 2>&1 &
|
|
||||||
for _ in $(seq 30); do [ -S "$XDG_RUNTIME_DIR/wayland-1" ] && break; sleep 1; done
|
|
||||||
export WAYLAND_DISPLAY=wayland-1
|
|
||||||
sleep 16 # library fetch plus the first covers
|
|
||||||
|
|
||||||
# Each step is ONE wtype invocation, and starts with a throwaway keypress.
|
|
||||||
#
|
|
||||||
# wtype creates a virtual keyboard, sends, and exits. The client needs a moment to
|
|
||||||
# notice the seat gained a keyboard, so the first key or two of any invocation is
|
|
||||||
# dropped - which makes a per-key loop lose every key it sends. Batching a step into
|
|
||||||
# one invocation and opening it with "x" + BackSpace spends that warm-up on a keypress
|
|
||||||
# that cancels itself out.
|
|
||||||
WARM=(-s 260 "x" -k BackSpace)
|
|
||||||
shot() { sleep 2.5; grim "$OUTDIR/$1.png"; echo " $1"; }
|
|
||||||
|
|
||||||
shot 01-root
|
|
||||||
wtype "${WARM[@]}" -k Down -k Return; shot 02-categories
|
|
||||||
wtype "${WARM[@]}" -k Return; shot 03-grid
|
|
||||||
wtype "${WARM[@]}" -k Escape -k Escape "maja"; shot 04-search
|
|
||||||
wtype "${WARM[@]}" -k Escape -k question "brumm"; shot 05-tracksearch
|
|
||||||
wtype "${WARM[@]}" -k Return; sleep 4; shot 06-play
|
|
||||||
wtype "${WARM[@]}" -k Escape; shot 07-bar
|
|
||||||
|
|
||||||
echo "wrote 7 frames to $OUTDIR"
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
import { Theme } from "theme.slint";
|
|
||||||
import { RoundButton } from "widgets.slint";
|
|
||||||
import { ShelfView, ResultsView } from "browse.slint";
|
|
||||||
import { PlayerBar, PlayView, AlbumSheet } from "player.slint";
|
|
||||||
import { RoomView } from "room.slint";
|
|
||||||
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab } from "models.slint";
|
|
||||||
|
|
||||||
export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme }
|
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
|
||||||
title: "Musik Delfin";
|
|
||||||
default-font-family: Theme.font;
|
|
||||||
default-font-size: Theme.text-body;
|
|
||||||
// The stage. In the web app a per-track animated canvas sits on top of this; here
|
|
||||||
// it is the whole background, on purpose. A full-viewport repaint loop is a floor
|
|
||||||
// you cannot get under while it runs at all, and it is the first thing `?pi=1`
|
|
||||||
// already removes on the device this is meant to replace.
|
|
||||||
background: @linear-gradient(180deg,
|
|
||||||
Theme.stage-top 0%,
|
|
||||||
Theme.stage-mid 45%,
|
|
||||||
Theme.sea-deep 100%);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------- inputs --
|
|
||||||
// "music" | "room" | "typing" - named rather than an enum so Rust owns the
|
|
||||||
// ordering and the labels, as it does for every other string on screen.
|
|
||||||
in property <string> page: "music";
|
|
||||||
in property <[Tab]> tabs;
|
|
||||||
|
|
||||||
in property <[RoomRow]> room-rows;
|
|
||||||
in property <[SceneTile]> room-scenes;
|
|
||||||
in property <[Swatch]> room-swatches;
|
|
||||||
in property <[string]> shutter-presets;
|
|
||||||
in property <bool> room-loading: true;
|
|
||||||
|
|
||||||
in property <bool> loading: true;
|
|
||||||
in property <string> splash-text;
|
|
||||||
in property <string> status-label;
|
|
||||||
in property <bool> online: false;
|
|
||||||
|
|
||||||
in property <bool> play-view: false;
|
|
||||||
in property <bool> show-sheet: false;
|
|
||||||
in property <bool> can-go-back: false;
|
|
||||||
|
|
||||||
in property <[Shelf]> shelves;
|
|
||||||
in property <bool> root-screen: true;
|
|
||||||
in property <int> shelf-row: 0;
|
|
||||||
|
|
||||||
in property <string> group-label;
|
|
||||||
in property <string> category-heading;
|
|
||||||
in property <string> album-heading;
|
|
||||||
in property <bool> show-search: false;
|
|
||||||
in property <string> search-text;
|
|
||||||
in property <string> mode-label;
|
|
||||||
in property <string> count-label;
|
|
||||||
in property <[TrackRow]> songs;
|
|
||||||
in property <[CategoryRow]> category-grid;
|
|
||||||
in property <[GridRow]> grid;
|
|
||||||
in property <bool> no-results: false;
|
|
||||||
in property <int> sel-index: 0;
|
|
||||||
in property <int> sel-row: 0;
|
|
||||||
|
|
||||||
in property <NowPlaying> now;
|
|
||||||
in property <bool> playing: false;
|
|
||||||
in property <float> progress: 0.0;
|
|
||||||
in property <bool> smooth: true;
|
|
||||||
in property <int> volume: 60;
|
|
||||||
in property <bool> has-bar: false;
|
|
||||||
|
|
||||||
in property <string> sheet-title;
|
|
||||||
in property <string> sheet-subtitle;
|
|
||||||
in property <string> sheet-meta;
|
|
||||||
in property <image> sheet-cover;
|
|
||||||
in property <bool> sheet-is-book: false;
|
|
||||||
in property <[TrackRow]> sheet-tracks;
|
|
||||||
in property <int> sheet-index: 0;
|
|
||||||
|
|
||||||
// How wide the grid may be. Rust reads this back to decide the column count, so the
|
|
||||||
// chunking into `GridRow`s and the layout agree about how many fit.
|
|
||||||
out property <length> grid-width: self.width - 32px - Theme.rail-clearance;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- callbacks --
|
|
||||||
// The special keys are named here rather than in Rust because this is where the
|
|
||||||
// `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal
|
|
||||||
// character that was typed.
|
|
||||||
callback key(string, bool, bool);
|
|
||||||
callback toggle();
|
|
||||||
callback next();
|
|
||||||
callback previous();
|
|
||||||
callback seek(float);
|
|
||||||
callback set-volume(int);
|
|
||||||
callback mute();
|
|
||||||
callback go-back();
|
|
||||||
callback enter-group(int);
|
|
||||||
callback enter-category(int, string);
|
|
||||||
callback open-category(string);
|
|
||||||
callback open-album(string);
|
|
||||||
callback play-album(string);
|
|
||||||
callback play-song(string, int);
|
|
||||||
callback open-play-view();
|
|
||||||
callback close-sheet();
|
|
||||||
callback play-sheet-track(int);
|
|
||||||
callback select-page(string);
|
|
||||||
callback toggle-light(string);
|
|
||||||
callback set-light-level(string, int);
|
|
||||||
callback set-light-swatch(string, int);
|
|
||||||
callback set-shutter-preset(string, int);
|
|
||||||
callback shutter(string, string);
|
|
||||||
callback activate-scene(string);
|
|
||||||
|
|
||||||
forward-focus: keys;
|
|
||||||
|
|
||||||
keys := FocusScope {
|
|
||||||
key-pressed(event) => {
|
|
||||||
root.key(
|
|
||||||
event.text == Key.Escape ? "escape"
|
|
||||||
: event.text == Key.Return ? "enter"
|
|
||||||
: event.text == Key.Backspace ? "backspace"
|
|
||||||
: event.text == Key.Tab ? "tab"
|
|
||||||
: event.text == Key.LeftArrow ? "left"
|
|
||||||
: event.text == Key.RightArrow ? "right"
|
|
||||||
: event.text == Key.UpArrow ? "up"
|
|
||||||
: event.text == Key.DownArrow ? "down"
|
|
||||||
: event.text == Key.F1 ? "f1"
|
|
||||||
: event.text == Key.Space ? "space"
|
|
||||||
: event.text,
|
|
||||||
event.modifiers.control,
|
|
||||||
event.modifiers.shift);
|
|
||||||
accept
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
// ------------------------------------------------------------ header --
|
|
||||||
if root.page == "music" && !root.play-view: HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: 16px;
|
|
||||||
padding-top: 18px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
Text {
|
|
||||||
text: "Musik Delfin";
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
}
|
|
||||||
if root.status-label != "": VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
background: Theme.paper.with-alpha(0.16);
|
|
||||||
border-radius: Theme.pill;
|
|
||||||
width: status.preferred-width;
|
|
||||||
height: status.preferred-height;
|
|
||||||
status := HorizontalLayout {
|
|
||||||
padding-left: 14px;
|
|
||||||
padding-right: 14px;
|
|
||||||
padding-top: 6px;
|
|
||||||
padding-bottom: 6px;
|
|
||||||
Text {
|
|
||||||
text: root.status-label;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: root.online ? Theme.paper : #ffb4a2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------- body --
|
|
||||||
if root.page == "music" && !root.play-view && root.root-screen: ShelfView {
|
|
||||||
shelves: root.shelves;
|
|
||||||
shelf-row: root.shelf-row;
|
|
||||||
sel-index: root.sel-index;
|
|
||||||
enter-group(s) => { root.enter-group(s); }
|
|
||||||
enter-category(s, key) => { root.enter-category(s, key); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.page == "music" && !root.play-view && !root.root-screen: ResultsView {
|
|
||||||
group-label: root.group-label;
|
|
||||||
category-heading: root.category-heading;
|
|
||||||
album-heading: root.album-heading;
|
|
||||||
show-search: root.show-search;
|
|
||||||
search-text: root.search-text;
|
|
||||||
mode-label: root.mode-label;
|
|
||||||
count-label: root.count-label;
|
|
||||||
songs: root.songs;
|
|
||||||
category-grid: root.category-grid;
|
|
||||||
grid: root.grid;
|
|
||||||
sel-index: root.sel-index;
|
|
||||||
sel-row: root.sel-row;
|
|
||||||
empty: root.no-results;
|
|
||||||
play-song(id, i) => { root.play-song(id, i); }
|
|
||||||
open-category(key) => { root.open-category(key); }
|
|
||||||
open-album(id) => { root.open-album(id); }
|
|
||||||
play-album(id) => { root.play-album(id); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.page == "room": RoomView {
|
|
||||||
rows: root.room-rows;
|
|
||||||
scenes: root.room-scenes;
|
|
||||||
swatches: root.room-swatches;
|
|
||||||
presets: root.shutter-presets;
|
|
||||||
loading: root.room-loading;
|
|
||||||
toggle-light(id) => { root.toggle-light(id); }
|
|
||||||
set-level(id, n) => { root.set-light-level(id, n); }
|
|
||||||
set-swatch(id, i) => { root.set-light-swatch(id, i); }
|
|
||||||
set-preset(id, i) => { root.set-shutter-preset(id, i); }
|
|
||||||
shutter(id, what) => { root.shutter(id, what); }
|
|
||||||
activate-scene(id) => { root.activate-scene(id); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.page == "typing": VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: "Tippen";
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: "Noch nicht portiert.";
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-dim;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.page == "music" && root.play-view: PlayView {
|
|
||||||
now: root.now;
|
|
||||||
playing: root.playing;
|
|
||||||
progress: root.progress;
|
|
||||||
smooth: root.smooth;
|
|
||||||
volume: root.volume;
|
|
||||||
toggle => { root.toggle(); }
|
|
||||||
next => { root.next(); }
|
|
||||||
previous => { root.previous(); }
|
|
||||||
seek(f) => { root.seek(f); }
|
|
||||||
set-volume(v) => { root.set-volume(v); }
|
|
||||||
mute => { root.mute(); }
|
|
||||||
open-album => { root.open-album(root.now.title); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The player bar floats over the browse screen rather than displacing it, which is
|
|
||||||
// why the scrollers above reserve bottom padding for it. Geometry is set here
|
|
||||||
// rather than inside the component: a plain element takes its *preferred* width
|
|
||||||
// from its content, not its parent's width, so a bar that only declared a height
|
|
||||||
// left gaps for the grid behind it to show through.
|
|
||||||
if root.page == "music" && !root.play-view && root.has-bar: PlayerBar {
|
|
||||||
x: 0;
|
|
||||||
y: parent.height - self.height;
|
|
||||||
width: parent.width;
|
|
||||||
now: root.now;
|
|
||||||
playing: root.playing;
|
|
||||||
progress: root.progress;
|
|
||||||
smooth: root.smooth;
|
|
||||||
volume: root.volume;
|
|
||||||
toggle => { root.toggle(); }
|
|
||||||
next => { root.next(); }
|
|
||||||
previous => { root.previous(); }
|
|
||||||
seek(f) => { root.seek(f); }
|
|
||||||
set-volume(v) => { root.set-volume(v); }
|
|
||||||
mute => { root.mute(); }
|
|
||||||
open-play-view => { root.open-play-view(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// One fixed round button in the top-left: back, everywhere it applies, so "top
|
|
||||||
// left" always means the same thing.
|
|
||||||
if root.can-go-back: RoundButton {
|
|
||||||
x: 24px;
|
|
||||||
y: 18px;
|
|
||||||
label: "‹";
|
|
||||||
clicked => { root.go-back(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vertical tab rail. Right edge, vertically centred - the one fixed control
|
|
||||||
// that is in the same place on every page.
|
|
||||||
tab-rail := Rectangle {
|
|
||||||
x: parent.width - self.width - 16px;
|
|
||||||
y: (parent.height - self.height) / 2;
|
|
||||||
width: 60px;
|
|
||||||
height: rail.preferred-height;
|
|
||||||
border-radius: 30px;
|
|
||||||
background: Theme.paper.with-alpha(0.22);
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: Theme.paper.with-alpha(0.3);
|
|
||||||
|
|
||||||
rail := VerticalLayout {
|
|
||||||
padding: 8px;
|
|
||||||
spacing: 6px;
|
|
||||||
for tab[i] in root.tabs: Rectangle {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border-radius: 22px;
|
|
||||||
background: root.page == tab.id ? Theme.accent : transparent;
|
|
||||||
animate background { duration: 150ms; }
|
|
||||||
Text {
|
|
||||||
text: tab.icon;
|
|
||||||
font-size: 20px;
|
|
||||||
color: root.page == tab.id ? white : Theme.paper;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.select-page(tab.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.show-sheet: AlbumSheet {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
title: root.sheet-title;
|
|
||||||
subtitle: root.sheet-subtitle;
|
|
||||||
meta: root.sheet-meta;
|
|
||||||
cover: root.sheet-cover;
|
|
||||||
is-book: root.sheet-is-book;
|
|
||||||
tracks: root.sheet-tracks;
|
|
||||||
sel-index: root.sheet-index;
|
|
||||||
close => { root.close-sheet(); }
|
|
||||||
play(i) => { root.play-sheet-track(i); }
|
|
||||||
play-all => { root.play-sheet-track(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.loading: Rectangle {
|
|
||||||
background: Theme.sea-deep.with-alpha(0.92);
|
|
||||||
TouchArea { }
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: 18px;
|
|
||||||
Text {
|
|
||||||
text: "🐬";
|
|
||||||
font-size: 96px;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.splash-text;
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,567 +0,0 @@
|
|||||||
import { ListView } from "std-widgets.slint";
|
|
||||||
import { Theme } from "theme.slint";
|
|
||||||
import { GlassPanel, Cover } from "widgets.slint";
|
|
||||||
import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint";
|
|
||||||
|
|
||||||
// Heights are fixed rather than derived from content, and that is the whole reason the
|
|
||||||
// grid can be virtualized: a `ListView` that has to measure a row before it can decide
|
|
||||||
// whether to skip it has not skipped anything. The web grid does the same thing with
|
|
||||||
// `contain-intrinsic-size` for the same reason.
|
|
||||||
//
|
|
||||||
// A book is taller than an album, so the cover *slot* is sized for the taller of the
|
|
||||||
// two and each cover is centred in it. That keeps every row identical without
|
|
||||||
// flattening the shape difference that tells the two media apart.
|
|
||||||
export global Metrics {
|
|
||||||
out property <length> cover-slot: 200px;
|
|
||||||
out property <length> card-text: 78px;
|
|
||||||
out property <length> card-height: 278px; // cover-slot + card-text
|
|
||||||
out property <length> row-height: 302px; // card-height + grid-gap
|
|
||||||
out property <length> shelf-height: 236px;
|
|
||||||
out property <length> category-row-height: 260px;
|
|
||||||
}
|
|
||||||
|
|
||||||
component SelectionRing inherits Rectangle {
|
|
||||||
in property <bool> selected;
|
|
||||||
in property <bool> current;
|
|
||||||
|
|
||||||
// Selection outranks "currently playing": when the keyboard is on the album that is
|
|
||||||
// also playing, the ring it needs to see is the one that moves.
|
|
||||||
border-width: root.selected ? 5px : (root.current ? 4px : 0px);
|
|
||||||
border-color: root.selected ? Theme.paper : Theme.accent;
|
|
||||||
border-radius: Theme.radius-card + 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
component AlbumCard inherits Rectangle {
|
|
||||||
in property <AlbumTile> tile;
|
|
||||||
in property <bool> selected;
|
|
||||||
callback open();
|
|
||||||
callback play();
|
|
||||||
|
|
||||||
height: Metrics.card-height;
|
|
||||||
|
|
||||||
ring := SelectionRing {
|
|
||||||
selected: root.selected;
|
|
||||||
current: root.tile.current;
|
|
||||||
x: -7px;
|
|
||||||
y: -7px;
|
|
||||||
width: parent.width + 14px;
|
|
||||||
height: parent.height + 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
card := Rectangle {
|
|
||||||
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
|
||||||
border-radius: Theme.radius-card;
|
|
||||||
drop-shadow-color: #00101759;
|
|
||||||
drop-shadow-blur: 18px;
|
|
||||||
drop-shadow-offset-y: 6px;
|
|
||||||
// The lift on selection: 120ms, the same as the web card's transition.
|
|
||||||
y: root.selected ? -3px : 0px;
|
|
||||||
animate y { duration: 120ms; easing: ease-out; }
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 10px;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
// The cover, centred in a slot tall enough for the taller media type.
|
|
||||||
Rectangle {
|
|
||||||
height: Metrics.cover-slot - 20px;
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Cover {
|
|
||||||
source: root.tile.cover;
|
|
||||||
is-book: root.tile.is-book;
|
|
||||||
height: parent.height;
|
|
||||||
width: root.tile.is-book ? parent.height * 0.82 : parent.height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
// Cover opens the track list; the title below starts it playing.
|
|
||||||
// Two targets, because "show me what is in this" and "play this"
|
|
||||||
// are different intentions and a child has both.
|
|
||||||
clicked => { root.open(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
height: Metrics.card-text - 26px;
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 1px;
|
|
||||||
Text {
|
|
||||||
text: root.tile.title;
|
|
||||||
font-size: Theme.text-body - 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.ink;
|
|
||||||
wrap: word-wrap;
|
|
||||||
overflow: elide;
|
|
||||||
// Two lines always, whether or not the title needs them, so
|
|
||||||
// every card in the row ends at the same place.
|
|
||||||
height: (Theme.text-body - 1px) * 2.4;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.tile.artist;
|
|
||||||
font-size: Theme.text-small - 1px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.ink.with-alpha(0.65);
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.play(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.tile.meta;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.count-pink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component CategoryCard inherits Rectangle {
|
|
||||||
in property <CategoryTile> tile;
|
|
||||||
in property <bool> selected;
|
|
||||||
in property <length> tile-width: Theme.shelf-tile;
|
|
||||||
callback open();
|
|
||||||
|
|
||||||
width: root.tile-width;
|
|
||||||
height: Metrics.shelf-height;
|
|
||||||
|
|
||||||
SelectionRing {
|
|
||||||
selected: root.selected;
|
|
||||||
current: false;
|
|
||||||
x: -7px;
|
|
||||||
y: -7px;
|
|
||||||
width: parent.width + 14px;
|
|
||||||
height: parent.height + 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: root.tile.is-book ? Theme.card-book : Theme.card-music;
|
|
||||||
border-radius: Theme.radius-card;
|
|
||||||
drop-shadow-color: #00101759;
|
|
||||||
drop-shadow-blur: 18px;
|
|
||||||
drop-shadow-offset-y: 6px;
|
|
||||||
y: root.selected ? -3px : 0px;
|
|
||||||
animate y { duration: 120ms; easing: ease-out; }
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 8px;
|
|
||||||
spacing: 5px;
|
|
||||||
|
|
||||||
// One album fills the tile; several show as a 2x2 contact sheet, which is
|
|
||||||
// how a category reads as "a stack of these" without a label saying so.
|
|
||||||
Rectangle {
|
|
||||||
height: root.tile-width - 16px;
|
|
||||||
clip: true;
|
|
||||||
if root.tile.cover-count <= 1: Cover {
|
|
||||||
source: root.tile.cover1;
|
|
||||||
is-book: root.tile.is-book;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
if root.tile.cover-count > 1: VerticalLayout {
|
|
||||||
spacing: 4px;
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 4px;
|
|
||||||
Cover { source: root.tile.cover1; radius: 6px; }
|
|
||||||
Cover { source: root.tile.cover2; radius: 6px; }
|
|
||||||
}
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 4px;
|
|
||||||
Cover { source: root.tile.cover3; radius: 6px; }
|
|
||||||
Cover { source: root.tile.cover4; radius: 6px; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.tile.key;
|
|
||||||
font-size: Theme.text-body;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.ink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.tile.count-label;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.count-pink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.open(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component TrackListRow inherits Rectangle {
|
|
||||||
in property <TrackRow> track;
|
|
||||||
in property <bool> selected;
|
|
||||||
callback play();
|
|
||||||
|
|
||||||
height: 58px;
|
|
||||||
background: root.track.current ? Theme.row-active : Theme.row-idle;
|
|
||||||
border-radius: Theme.radius-row;
|
|
||||||
border-width: root.selected ? 3px : 1px;
|
|
||||||
border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border);
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding-left: 12px;
|
|
||||||
padding-right: 16px;
|
|
||||||
spacing: 12px;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Cover {
|
|
||||||
source: root.track.cover;
|
|
||||||
is-book: root.track.is-book;
|
|
||||||
radius: 8px;
|
|
||||||
height: 40px;
|
|
||||||
width: root.track.is-book ? 33px : 40px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: root.track.title;
|
|
||||||
font-size: Theme.text-body;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.paper;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.track.subtitle;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-sub.with-alpha(0.75);
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: root.track.duration;
|
|
||||||
font-family: Theme.mono;
|
|
||||||
font-size: Theme.text-small;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-dim.with-alpha(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.play(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The root screen: three shelves, each scrolling sideways.
|
|
||||||
export component ShelfView inherits Flickable {
|
|
||||||
in property <[Shelf]> shelves;
|
|
||||||
in property <int> shelf-row;
|
|
||||||
in property <int> sel-index;
|
|
||||||
callback enter-group(int); // shelf index
|
|
||||||
callback enter-category(int, string); // shelf index, category key
|
|
||||||
|
|
||||||
content-height: layout.preferred-height;
|
|
||||||
content-width: self.width;
|
|
||||||
|
|
||||||
layout := VerticalLayout {
|
|
||||||
padding-left: 32px;
|
|
||||||
padding-right: Theme.rail-clearance;
|
|
||||||
padding-top: 14px;
|
|
||||||
// Clear of the player bar, which floats over this rather than displacing it.
|
|
||||||
padding-bottom: 210px;
|
|
||||||
spacing: 25px;
|
|
||||||
|
|
||||||
for shelf[s] in root.shelves: GlassPanel {
|
|
||||||
height: Metrics.shelf-height + 74px;
|
|
||||||
// The panel itself is a target: it opens the whole group.
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.enter-group(s); }
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 14px;
|
|
||||||
spacing: 10px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 10px;
|
|
||||||
alignment: start;
|
|
||||||
// A round frosted badge behind the emoji, which otherwise renders
|
|
||||||
// thin and low-contrast straight on the stage gradient.
|
|
||||||
Rectangle {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 17px;
|
|
||||||
background: Theme.paper.with-alpha(0.2);
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: Theme.paper.with-alpha(0.4);
|
|
||||||
Text {
|
|
||||||
text: shelf.icon;
|
|
||||||
font-size: 17px;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: shelf.label;
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: "›";
|
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper.with-alpha(0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if shelf.tiles.length == 0: Text {
|
|
||||||
text: shelf.empty-label;
|
|
||||||
font-size: Theme.text-small;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-dim.with-alpha(0.6);
|
|
||||||
horizontal-alignment: center;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
if shelf.tiles.length > 0: Flickable {
|
|
||||||
content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px;
|
|
||||||
content-height: self.height;
|
|
||||||
// Keep the focused tile in view as the arrow keys walk past the fold.
|
|
||||||
content-x: root.shelf-row != s
|
|
||||||
? 0px
|
|
||||||
: -max(0px, min(
|
|
||||||
self.content-width - self.width,
|
|
||||||
root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2));
|
|
||||||
animate content-x { duration: 180ms; easing: ease-out; }
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 12px;
|
|
||||||
padding: 10px;
|
|
||||||
alignment: start;
|
|
||||||
for tile[i] in shelf.tiles: CategoryCard {
|
|
||||||
tile: tile;
|
|
||||||
selected: root.shelf-row == s && root.sel-index == i;
|
|
||||||
open => { root.enter-category(s, tile.key); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside a group, or showing search results.
|
|
||||||
//
|
|
||||||
// Exactly one of the three lists is ever populated, and that is a property of the query
|
|
||||||
// model rather than a coincidence worth defending against: track hits only exist in
|
|
||||||
// track-search mode, which forces the album list empty; the category list only exists
|
|
||||||
// when there is neither a query nor a chosen category, which is exactly when the album
|
|
||||||
// list is empty. So this shows one list filling the page, instead of stacking three
|
|
||||||
// inside a scroller and nesting a second scroller in it.
|
|
||||||
export component ResultsView inherits VerticalLayout {
|
|
||||||
in property <string> group-label;
|
|
||||||
in property <string> category-heading;
|
|
||||||
in property <string> album-heading;
|
|
||||||
in property <bool> show-search;
|
|
||||||
in property <string> search-text;
|
|
||||||
in property <string> mode-label;
|
|
||||||
in property <string> count-label;
|
|
||||||
in property <[TrackRow]> songs;
|
|
||||||
in property <[CategoryRow]> category-grid;
|
|
||||||
in property <[GridRow]> grid;
|
|
||||||
in property <int> sel-index;
|
|
||||||
/// Which row of the visible grid holds the selection. Every row is the same height
|
|
||||||
/// by construction, so following the keyboard is arithmetic, not measurement.
|
|
||||||
in property <int> sel-row;
|
|
||||||
in property <bool> empty;
|
|
||||||
callback play-song(string, int);
|
|
||||||
callback open-category(string);
|
|
||||||
callback open-album(string);
|
|
||||||
callback play-album(string);
|
|
||||||
|
|
||||||
spacing: 6px;
|
|
||||||
padding-left: 32px;
|
|
||||||
padding-right: Theme.rail-clearance;
|
|
||||||
|
|
||||||
if root.group-label != "": Text {
|
|
||||||
text: root.group-label;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.show-search: HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: 12px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: Theme.paper;
|
|
||||||
border-radius: Theme.pill;
|
|
||||||
drop-shadow-color: #00101759;
|
|
||||||
drop-shadow-blur: 14px;
|
|
||||||
drop-shadow-offset-y: 4px;
|
|
||||||
width: chip.preferred-width;
|
|
||||||
height: chip.preferred-height;
|
|
||||||
|
|
||||||
chip := HorizontalLayout {
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 20px;
|
|
||||||
padding-top: 10px;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
spacing: 10px;
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
background: Theme.ink;
|
|
||||||
border-radius: Theme.pill;
|
|
||||||
width: label.preferred-width;
|
|
||||||
label := HorizontalLayout {
|
|
||||||
padding-left: 10px;
|
|
||||||
padding-right: 10px;
|
|
||||||
padding-top: 4px;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
Text {
|
|
||||||
text: root.mode-label;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.search-text;
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.ink;
|
|
||||||
vertical-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: root.count-label;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-dim.with-alpha(0.85);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.empty: VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: "Nichts gefunden — probier andere Buchstaben!";
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- track hits -----------------------------------------------------------
|
|
||||||
// Capped at 40 rows upstream, so this one does not need virtualizing. Held to a
|
|
||||||
// readable column rather than the full width, which is what lets a long album
|
|
||||||
// line elide instead of running the width of a 1080p screen. The heading rides
|
|
||||||
// inside that column so the two are aligned with each other, not with the page.
|
|
||||||
if root.songs.length > 0: Flickable {
|
|
||||||
content-height: songs-column.preferred-height + 210px;
|
|
||||||
content-width: self.width;
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
songs-column := VerticalLayout {
|
|
||||||
// Without this the column is centred in whatever vertical space is
|
|
||||||
// left over, which on a short result list leaves it floating.
|
|
||||||
alignment: start;
|
|
||||||
width: 860px;
|
|
||||||
spacing: 8px;
|
|
||||||
Text {
|
|
||||||
text: "Titel";
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
}
|
|
||||||
GlassPanel {
|
|
||||||
height: song-rows.preferred-height;
|
|
||||||
song-rows := VerticalLayout {
|
|
||||||
padding: 12px;
|
|
||||||
spacing: 6px;
|
|
||||||
for song in root.songs: TrackListRow {
|
|
||||||
track: song;
|
|
||||||
selected: root.sel-index == song.nav;
|
|
||||||
play => { root.play-song(song.album-id, song.index); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- categories -----------------------------------------------------------
|
|
||||||
if root.category-grid.length > 0: Text {
|
|
||||||
text: root.category-heading;
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
}
|
|
||||||
if root.category-grid.length > 0: ListView {
|
|
||||||
for row in root.category-grid: HorizontalLayout {
|
|
||||||
height: Metrics.category-row-height;
|
|
||||||
spacing: Theme.grid-gap;
|
|
||||||
alignment: start;
|
|
||||||
for tile in row.tiles: CategoryCard {
|
|
||||||
tile: tile;
|
|
||||||
tile-width: Theme.card-width;
|
|
||||||
selected: root.sel-index == tile.nav;
|
|
||||||
open => { root.open-category(tile.key); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- albums ---------------------------------------------------------------
|
|
||||||
if root.grid.length > 0: Text {
|
|
||||||
text: root.album-heading;
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
}
|
|
||||||
if root.grid.length > 0: album-list := ListView {
|
|
||||||
// Follow the keyboard without measuring anything.
|
|
||||||
content-y: -max(0px, min(
|
|
||||||
max(0px, self.content-height - self.height),
|
|
||||||
root.sel-row * Metrics.row-height));
|
|
||||||
animate content-y { duration: 180ms; easing: ease-out; }
|
|
||||||
|
|
||||||
for row in root.grid: HorizontalLayout {
|
|
||||||
height: Metrics.row-height;
|
|
||||||
spacing: Theme.grid-gap;
|
|
||||||
alignment: start;
|
|
||||||
for tile in row.tiles: AlbumCard {
|
|
||||||
width: Theme.card-width;
|
|
||||||
tile: tile;
|
|
||||||
selected: root.sel-index == tile.nav;
|
|
||||||
open => { root.open-album(tile.id); }
|
|
||||||
play => { root.play-album(tile.id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
// The shapes that cross between Rust and the UI.
|
|
||||||
//
|
|
||||||
// Everything here is already *presentation*: labels are formatted, durations are
|
|
||||||
// strings, covers are decoded images. Nothing in the .slint files formats a number or
|
|
||||||
// picks a word, so the German copy and the duration format live in one place (Rust)
|
|
||||||
// rather than being split across two languages.
|
|
||||||
//
|
|
||||||
// `nav` is each item's position in the single flat selection order that runs songs ->
|
|
||||||
// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned
|
|
||||||
// in Rust, where the three lists are built together.
|
|
||||||
|
|
||||||
export struct AlbumTile {
|
|
||||||
id: string,
|
|
||||||
title: string,
|
|
||||||
artist: string,
|
|
||||||
// "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker.
|
|
||||||
meta: string,
|
|
||||||
cover: image,
|
|
||||||
is-book: bool,
|
|
||||||
locked: bool,
|
|
||||||
// This album is the one playing; drawn with the accent ring rather than the
|
|
||||||
// selection ring, and outranked by it when they coincide.
|
|
||||||
current: bool,
|
|
||||||
nav: int,
|
|
||||||
}
|
|
||||||
|
|
||||||
// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes
|
|
||||||
// its model and a flat list of 343 cards has no rows to skip. This is the direct
|
|
||||||
// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which
|
|
||||||
// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer.
|
|
||||||
export struct GridRow {
|
|
||||||
tiles: [AlbumTile],
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct CategoryTile {
|
|
||||||
key: string,
|
|
||||||
// "7 Hörbücher"
|
|
||||||
count-label: string,
|
|
||||||
// Up to four covers in a 2x2, or one filling the tile when the category holds a
|
|
||||||
// single album. Four fields rather than an array: the count is fixed and small, and
|
|
||||||
// this keeps the tile a plain struct.
|
|
||||||
cover1: image,
|
|
||||||
cover2: image,
|
|
||||||
cover3: image,
|
|
||||||
cover4: image,
|
|
||||||
cover-count: int,
|
|
||||||
is-book: bool,
|
|
||||||
nav: int,
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct CategoryRow {
|
|
||||||
tiles: [CategoryTile],
|
|
||||||
}
|
|
||||||
|
|
||||||
// One of the three shelves on the root screen.
|
|
||||||
export struct Shelf {
|
|
||||||
label: string,
|
|
||||||
icon: string,
|
|
||||||
tiles: [CategoryTile],
|
|
||||||
empty-label: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct TrackRow {
|
|
||||||
title: string,
|
|
||||||
subtitle: string,
|
|
||||||
duration: string,
|
|
||||||
cover: image,
|
|
||||||
is-book: bool,
|
|
||||||
current: bool,
|
|
||||||
locked: bool,
|
|
||||||
album-id: string,
|
|
||||||
index: int,
|
|
||||||
nav: int,
|
|
||||||
}
|
|
||||||
|
|
||||||
// What the player bar and the full-screen view both say. Shared so the two cannot
|
|
||||||
// drift apart on a copy change.
|
|
||||||
export struct NowPlaying {
|
|
||||||
title: string,
|
|
||||||
subtitle: string,
|
|
||||||
// "Song 3 von 8" / "Kapitel 3 von 8"
|
|
||||||
counter: string,
|
|
||||||
cover: image,
|
|
||||||
hero-cover: image,
|
|
||||||
is-book: bool,
|
|
||||||
has-album: bool,
|
|
||||||
// "−4:12", the time left in this track.
|
|
||||||
clock-label: string,
|
|
||||||
// "Noch 38:20 im Album" - empty for a podcast, where it means nothing.
|
|
||||||
remaining-label: string,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------- Mein Zimmer --
|
|
||||||
|
|
||||||
// One device card on the room page. Lights and shutters share a struct rather than
|
|
||||||
// having one each, because the grid renders them in the order Home Assistant's config
|
|
||||||
// lists them and two models would lose that order. `kind` is what the view branches on.
|
|
||||||
export struct RoomCard {
|
|
||||||
entity-id: string,
|
|
||||||
name: string,
|
|
||||||
// "light" | "shutter"
|
|
||||||
kind: string,
|
|
||||||
on: bool,
|
|
||||||
|
|
||||||
// --- light ---
|
|
||||||
is-color: bool,
|
|
||||||
// 0..5, matching the five brightness buttons.
|
|
||||||
level: int,
|
|
||||||
tint: color,
|
|
||||||
// Which of the six swatches the light is currently set to, or -1.
|
|
||||||
swatch: int,
|
|
||||||
|
|
||||||
// --- shutter ---
|
|
||||||
// Percent *closed*: 0 is fully open. Home Assistant reports the opposite; the
|
|
||||||
// inversion happens once, in Rust.
|
|
||||||
closed-percent: float,
|
|
||||||
// "Fährt runter …", or the preset name the position matches.
|
|
||||||
status: string,
|
|
||||||
// "" | "up" | "down"
|
|
||||||
moving: string,
|
|
||||||
supports-position: bool,
|
|
||||||
// Which of the four presets the position matches, or -1.
|
|
||||||
preset: int,
|
|
||||||
}
|
|
||||||
|
|
||||||
// The room grid, pre-chunked into rows for the same reason the album grid is: the
|
|
||||||
// view lays rows out, and how many fit is a function of the window width, which Rust
|
|
||||||
// is the one that knows.
|
|
||||||
export struct RoomRow {
|
|
||||||
cards: [RoomCard],
|
|
||||||
}
|
|
||||||
|
|
||||||
export struct SceneTile {
|
|
||||||
entity-id: string,
|
|
||||||
name: string,
|
|
||||||
active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
// One of the six colours a colour-capable light can be set to.
|
|
||||||
export struct Swatch {
|
|
||||||
name: string,
|
|
||||||
tint: color,
|
|
||||||
}
|
|
||||||
|
|
||||||
// One entry in the vertical tab rail. Built in Rust like every other string on
|
|
||||||
// screen, so the page order, the icons and the German labels live in one place.
|
|
||||||
export struct Tab {
|
|
||||||
id: string,
|
|
||||||
icon: string,
|
|
||||||
label: string,
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
import { Theme } from "theme.slint";
|
|
||||||
import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint";
|
|
||||||
import { NowPlaying, TrackRow } from "models.slint";
|
|
||||||
|
|
||||||
// The bar along the bottom of the browse screen.
|
|
||||||
export component PlayerBar inherits Rectangle {
|
|
||||||
in property <NowPlaying> now;
|
|
||||||
in property <bool> playing;
|
|
||||||
in property <float> progress;
|
|
||||||
in property <bool> smooth;
|
|
||||||
in property <int> volume;
|
|
||||||
callback toggle();
|
|
||||||
callback next();
|
|
||||||
callback previous();
|
|
||||||
callback seek(float);
|
|
||||||
callback set-volume(int);
|
|
||||||
callback mute();
|
|
||||||
callback open-play-view();
|
|
||||||
|
|
||||||
height: 96px;
|
|
||||||
background: Theme.bar-bg;
|
|
||||||
|
|
||||||
// The accent rule along the top is what separates the bar from the stage without a
|
|
||||||
// shadow, which over a gradient reads as a smudge.
|
|
||||||
Rectangle {
|
|
||||||
y: 0;
|
|
||||||
height: 3px;
|
|
||||||
width: 100%;
|
|
||||||
background: Theme.accent.with-alpha(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding-left: 24px;
|
|
||||||
padding-right: 24px;
|
|
||||||
padding-top: 16px;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
spacing: 18px;
|
|
||||||
|
|
||||||
// Cover: the way back into the full-screen view.
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
width: root.now.is-book ? 46px : 56px;
|
|
||||||
height: 56px;
|
|
||||||
Cover {
|
|
||||||
source: root.now.cover;
|
|
||||||
is-book: root.now.is-book;
|
|
||||||
radius: 10px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.open-play-view(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
width: 240px;
|
|
||||||
Text {
|
|
||||||
text: root.now.title;
|
|
||||||
font-size: Theme.text-body;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.paper;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.now.subtitle;
|
|
||||||
font-size: Theme.text-small;
|
|
||||||
font-weight: 600;
|
|
||||||
color: Theme.text-sub.with-alpha(0.75);
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
if root.now.has-album: Text {
|
|
||||||
text: root.now.counter;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.accent-dim;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
ProgressBar {
|
|
||||||
progress: root.progress;
|
|
||||||
smooth: root.smooth;
|
|
||||||
on-dark: true;
|
|
||||||
bar-height: 10px;
|
|
||||||
seek(fraction) => { root.seek(fraction); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Transport {
|
|
||||||
playing: root.playing;
|
|
||||||
size: 48px;
|
|
||||||
toggle => { root.toggle(); }
|
|
||||||
next => { root.next(); }
|
|
||||||
previous => { root.previous(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
VolumeBars {
|
|
||||||
volume: root.volume;
|
|
||||||
bar-width: 11px;
|
|
||||||
bar-height: 30px;
|
|
||||||
set-volume(v) => { root.set-volume(v); }
|
|
||||||
mute => { root.mute(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The full-screen now-playing view.
|
|
||||||
export component PlayView inherits VerticalLayout {
|
|
||||||
in property <NowPlaying> now;
|
|
||||||
in property <bool> playing;
|
|
||||||
in property <float> progress;
|
|
||||||
in property <bool> smooth;
|
|
||||||
in property <int> volume;
|
|
||||||
callback toggle();
|
|
||||||
callback next();
|
|
||||||
callback previous();
|
|
||||||
callback seek(float);
|
|
||||||
callback set-volume(int);
|
|
||||||
callback mute();
|
|
||||||
callback open-album();
|
|
||||||
|
|
||||||
alignment: center;
|
|
||||||
spacing: 18px;
|
|
||||||
padding: 32px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
height: 320px;
|
|
||||||
width: root.now.is-book ? 262px : 320px;
|
|
||||||
drop-shadow-color: #0016208c;
|
|
||||||
drop-shadow-blur: 40px;
|
|
||||||
drop-shadow-offset-y: 20px;
|
|
||||||
|
|
||||||
if root.now.has-album: Cover {
|
|
||||||
source: root.now.hero-cover;
|
|
||||||
is-book: root.now.is-book;
|
|
||||||
radius: 28px;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
if !root.now.has-album: Rectangle {
|
|
||||||
background: #2a4f57;
|
|
||||||
border-radius: 28px;
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.open-album(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 6px;
|
|
||||||
Text {
|
|
||||||
text: root.now.title;
|
|
||||||
font-size: Theme.text-hero;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.now.subtitle;
|
|
||||||
font-size: Theme.text-heading;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.text-dim.with-alpha(0.8);
|
|
||||||
horizontal-alignment: center;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
if root.now.has-album: Text {
|
|
||||||
text: root.now.counter;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.accent-dim;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
VerticalLayout {
|
|
||||||
width: 680px;
|
|
||||||
spacing: 8px;
|
|
||||||
ProgressBar {
|
|
||||||
progress: root.progress;
|
|
||||||
smooth: root.smooth;
|
|
||||||
bar-height: 14px;
|
|
||||||
seek(fraction) => { root.seek(fraction); }
|
|
||||||
}
|
|
||||||
HorizontalLayout {
|
|
||||||
Text {
|
|
||||||
text: root.now.clock-label;
|
|
||||||
font-family: Theme.mono;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.text-dim.with-alpha(0.8);
|
|
||||||
}
|
|
||||||
Rectangle { horizontal-stretch: 1; }
|
|
||||||
Text {
|
|
||||||
text: root.now.remaining-label;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.text-dim.with-alpha(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Transport {
|
|
||||||
playing: root.playing;
|
|
||||||
size: 72px;
|
|
||||||
toggle => { root.toggle(); }
|
|
||||||
next => { root.next(); }
|
|
||||||
previous => { root.previous(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
VolumeBars {
|
|
||||||
volume: root.volume;
|
|
||||||
bar-width: 18px;
|
|
||||||
bar-height: 44px;
|
|
||||||
set-volume(v) => { root.set-volume(v); }
|
|
||||||
mute => { root.mute(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The track list for one album, over a dimmed stage.
|
|
||||||
export component AlbumSheet inherits Rectangle {
|
|
||||||
in property <string> title;
|
|
||||||
in property <string> subtitle;
|
|
||||||
in property <string> meta;
|
|
||||||
in property <image> cover;
|
|
||||||
in property <bool> is-book;
|
|
||||||
in property <[TrackRow]> tracks;
|
|
||||||
in property <int> sel-index;
|
|
||||||
callback close();
|
|
||||||
callback play(int);
|
|
||||||
callback play-all();
|
|
||||||
|
|
||||||
background: Theme.backdrop.with-alpha(0.6);
|
|
||||||
|
|
||||||
// Catches every click that misses the sheet, so tapping the dimmed area closes it.
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: 640px;
|
|
||||||
height: min(root.height - 80px, 720px);
|
|
||||||
background: Theme.sheet-bg;
|
|
||||||
border-radius: Theme.radius-sheet;
|
|
||||||
drop-shadow-color: #0016208c;
|
|
||||||
drop-shadow-blur: 60px;
|
|
||||||
drop-shadow-offset-y: 24px;
|
|
||||||
|
|
||||||
// Without this the backdrop's TouchArea above would receive clicks aimed at the
|
|
||||||
// sheet's own background and close it.
|
|
||||||
TouchArea { }
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding: 26px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 18px;
|
|
||||||
Cover {
|
|
||||||
source: root.cover;
|
|
||||||
is-book: root.is-book;
|
|
||||||
width: root.is-book ? 123px : 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: start;
|
|
||||||
spacing: 6px;
|
|
||||||
Text {
|
|
||||||
text: root.title;
|
|
||||||
font-size: Theme.text-title;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.ink;
|
|
||||||
wrap: word-wrap;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.subtitle;
|
|
||||||
font-size: Theme.text-body;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.ink.with-alpha(0.7);
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.meta;
|
|
||||||
font-size: Theme.text-small;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.count-pink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent;
|
|
||||||
border-radius: Theme.pill;
|
|
||||||
width: play-all.preferred-width;
|
|
||||||
height: play-all.preferred-height;
|
|
||||||
play-all := HorizontalLayout {
|
|
||||||
padding-left: 18px;
|
|
||||||
padding-right: 18px;
|
|
||||||
padding-top: 9px;
|
|
||||||
padding-bottom: 9px;
|
|
||||||
Text {
|
|
||||||
text: "▶ Alle abspielen";
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
play-all-touch := TouchArea {
|
|
||||||
clicked => { root.play-all(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: start;
|
|
||||||
RoundButton {
|
|
||||||
label: "×";
|
|
||||||
size: 40px;
|
|
||||||
clicked => { root.close(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Flickable {
|
|
||||||
content-height: list.preferred-height;
|
|
||||||
content-width: self.width;
|
|
||||||
// Follow the keyboard through the list without measuring: every row is
|
|
||||||
// the same height, so the selected one's offset is arithmetic.
|
|
||||||
content-y: -max(0px, min(
|
|
||||||
max(0px, self.content-height - self.height),
|
|
||||||
root.sel-index * 52px - self.height / 2));
|
|
||||||
animate content-y { duration: 150ms; easing: ease-out; }
|
|
||||||
|
|
||||||
list := VerticalLayout {
|
|
||||||
spacing: 4px;
|
|
||||||
for track[i] in root.tracks: Rectangle {
|
|
||||||
height: 48px;
|
|
||||||
background: track.current ? Theme.accent.with-alpha(0.18) : transparent;
|
|
||||||
border-radius: 14px;
|
|
||||||
border-width: root.sel-index == i ? 3px : 0px;
|
|
||||||
border-color: Theme.accent;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
padding-left: 14px;
|
|
||||||
padding-right: 14px;
|
|
||||||
spacing: 12px;
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 15px;
|
|
||||||
background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12);
|
|
||||||
Text {
|
|
||||||
text: i + 1;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 800;
|
|
||||||
color: track.current ? white : Theme.ink;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: track.title;
|
|
||||||
font-size: Theme.text-small + 1px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.ink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: track.duration;
|
|
||||||
font-family: Theme.mono;
|
|
||||||
font-size: Theme.text-tiny;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.ink.with-alpha(0.6);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.play(i); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
// "Mein Zimmer" - the Home Assistant room panel.
|
|
||||||
//
|
|
||||||
// Its own violet hue rather than the player's teal, so the two pages read as siblings.
|
|
||||||
// Lights and shutters are drawn from one `RoomCard` model in Home Assistant's own
|
|
||||||
// config order; `kind` is what each card branches on.
|
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
|
||||||
import { RoomCard, RoomRow, SceneTile, Swatch } from "models.slint";
|
|
||||||
|
|
||||||
component ToggleSwitch inherits Rectangle {
|
|
||||||
in property <bool> on;
|
|
||||||
callback clicked();
|
|
||||||
|
|
||||||
width: 74px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 21px;
|
|
||||||
background: root.on ? Theme.room-accent : Theme.room-toggle-off;
|
|
||||||
animate background { duration: 140ms; }
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
x: root.on ? parent.width - self.width - 4px : 4px;
|
|
||||||
y: 4px;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 17px;
|
|
||||||
background: white;
|
|
||||||
drop-shadow-color: #0f051d66;
|
|
||||||
drop-shadow-blur: 6px;
|
|
||||||
drop-shadow-offset-y: 2px;
|
|
||||||
animate x { duration: 140ms; easing: ease-out; }
|
|
||||||
}
|
|
||||||
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.clicked(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A flat labelled button - the shutter presets and the up/stop/down row.
|
|
||||||
component RoomButton inherits Rectangle {
|
|
||||||
in property <string> label;
|
|
||||||
in property <bool> active;
|
|
||||||
in property <brush> active-fill: Theme.room-accent;
|
|
||||||
in property <length> text-size: 14px;
|
|
||||||
callback clicked();
|
|
||||||
|
|
||||||
border-radius: 14px;
|
|
||||||
background: root.active ? root.active-fill : (touch.pressed ? Theme.room-off-bg : Theme.room-btn);
|
|
||||||
animate background { duration: 120ms; }
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.label;
|
|
||||||
font-size: root.text-size;
|
|
||||||
font-weight: 800;
|
|
||||||
color: root.active ? white : Theme.room-btn-fg;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {
|
|
||||||
clicked => { root.clicked(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component RoomCardFrame inherits Rectangle {
|
|
||||||
in property <bool> glow;
|
|
||||||
in property <color> glow-tint;
|
|
||||||
|
|
||||||
background: Theme.room-card.with-alpha(0.55);
|
|
||||||
border-radius: 22px;
|
|
||||||
// Standing in for the web card's `0 0 0 3px color-mix(...)` ring: a lit lamp's own
|
|
||||||
// colour around its card is how the page reads at a glance from across the room.
|
|
||||||
border-width: root.glow ? 3px : 0px;
|
|
||||||
border-color: root.glow-tint;
|
|
||||||
drop-shadow-color: #0f051d59;
|
|
||||||
drop-shadow-blur: 24px;
|
|
||||||
drop-shadow-offset-y: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
component LightCard inherits RoomCardFrame {
|
|
||||||
in property <RoomCard> card;
|
|
||||||
in property <[Swatch]> swatches;
|
|
||||||
callback toggle();
|
|
||||||
callback set-level(int);
|
|
||||||
callback set-swatch(int);
|
|
||||||
|
|
||||||
glow: root.card.on;
|
|
||||||
glow-tint: root.card.tint;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
padding-top: 18px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
spacing: 14px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 14px;
|
|
||||||
Rectangle {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: root.card.on ? root.card.tint : Theme.room-off-bg;
|
|
||||||
drop-shadow-color: root.card.on ? root.card.tint : transparent;
|
|
||||||
drop-shadow-blur: 22px;
|
|
||||||
// A pendant for a colour lamp, a ceiling fitting for a plain one -
|
|
||||||
// drawn rather than iconographic, because there is no icon font here.
|
|
||||||
Rectangle {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
x: 11px;
|
|
||||||
y: 11px;
|
|
||||||
if root.card.is-color: Path {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
viewbox-width: 48;
|
|
||||||
viewbox-height: 48;
|
|
||||||
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
|
|
||||||
stroke-width: 3px;
|
|
||||||
commands: "M 24 7 A 17 17 0 1 1 23.9 7 Z";
|
|
||||||
}
|
|
||||||
if !root.card.is-color: Path {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
viewbox-width: 48;
|
|
||||||
viewbox-height: 48;
|
|
||||||
stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg;
|
|
||||||
stroke-width: 3px;
|
|
||||||
commands: "M 9 30 L 24 11 L 39 30 Z M 24 4 L 24 11";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
Text {
|
|
||||||
text: root.card.name;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.room-ink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
ToggleSwitch {
|
|
||||||
on: root.card.on;
|
|
||||||
clicked => { root.toggle(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Five steps rather than a slider: a child aiming at a bar hits a level.
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 6px;
|
|
||||||
height: 46px;
|
|
||||||
opacity: root.card.on ? 1.0 : 0.55;
|
|
||||||
for step[i] in [1, 2, 3, 4, 5]: Rectangle {
|
|
||||||
border-radius: 12px;
|
|
||||||
background: root.card.on && root.card.level >= step
|
|
||||||
? root.card.tint
|
|
||||||
: Theme.room-off-bg;
|
|
||||||
animate background { duration: 120ms; }
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.set-level(step); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.card.is-color: HorizontalLayout {
|
|
||||||
spacing: 10px;
|
|
||||||
height: 46px;
|
|
||||||
alignment: start;
|
|
||||||
for swatch[i] in root.swatches: Rectangle {
|
|
||||||
width: 46px;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 23px;
|
|
||||||
background: swatch.tint;
|
|
||||||
border-width: 4px;
|
|
||||||
border-color: root.card.on && root.card.swatch == i
|
|
||||||
? Theme.room-btn-fg
|
|
||||||
: Theme.room-card.with-alpha(0.8);
|
|
||||||
drop-shadow-color: #0f051d59;
|
|
||||||
drop-shadow-blur: 8px;
|
|
||||||
drop-shadow-offset-y: 3px;
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.set-swatch(i); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
component ShutterCard inherits RoomCardFrame {
|
|
||||||
in property <RoomCard> card;
|
|
||||||
in property <[string]> presets;
|
|
||||||
callback set-preset(int);
|
|
||||||
callback open();
|
|
||||||
callback stop();
|
|
||||||
callback close();
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
padding-top: 18px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
spacing: 16px;
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 14px;
|
|
||||||
alignment: start;
|
|
||||||
Rectangle {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: Theme.room-icon-bg;
|
|
||||||
Path {
|
|
||||||
x: 11px;
|
|
||||||
y: 11px;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
viewbox-width: 48;
|
|
||||||
viewbox-height: 48;
|
|
||||||
stroke: Theme.room-icon-fg;
|
|
||||||
stroke-width: 3px;
|
|
||||||
commands: "M 7 7 L 41 7 L 41 41 L 7 41 Z M 7 16 L 41 16 M 7 24 L 41 24 M 7 32 L 41 32";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: root.card.name;
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.room-ink;
|
|
||||||
overflow: elide;
|
|
||||||
}
|
|
||||||
Text {
|
|
||||||
text: root.card.status;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: Theme.room-sub.with-alpha(0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 18px;
|
|
||||||
|
|
||||||
// The window itself: sky behind, slats drawn down from the top to however
|
|
||||||
// closed the shutter is. Real covers report their own position, so this
|
|
||||||
// only ever reflects Home Assistant - no client-side movement animation.
|
|
||||||
Rectangle {
|
|
||||||
width: 104px;
|
|
||||||
height: 140px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border-width: 3px;
|
|
||||||
border-color: Theme.room-icon-fg;
|
|
||||||
clip: true;
|
|
||||||
background: @linear-gradient(180deg, Theme.room-window-a 0%, Theme.room-window-b 100%);
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
x: 0;
|
|
||||||
y: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: parent.height * clamp(root.card.closed-percent / 100, 0.0, 1.0);
|
|
||||||
clip: true;
|
|
||||||
animate height { duration: 200ms; easing: linear; }
|
|
||||||
// The slats, as a fixed ladder clipped by the parent's height.
|
|
||||||
for slat[i] in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: Rectangle {
|
|
||||||
y: i * 12px;
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
background: Theme.room-slat-a;
|
|
||||||
Rectangle {
|
|
||||||
y: 9px;
|
|
||||||
width: 100%;
|
|
||||||
height: 3px;
|
|
||||||
background: Theme.room-slat-b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
spacing: 10px;
|
|
||||||
horizontal-stretch: 1;
|
|
||||||
|
|
||||||
if root.card.supports-position: VerticalLayout {
|
|
||||||
spacing: 8px;
|
|
||||||
for row[r] in [0, 1]: HorizontalLayout {
|
|
||||||
spacing: 8px;
|
|
||||||
height: 48px;
|
|
||||||
for col[c] in [0, 1]: RoomButton {
|
|
||||||
label: root.presets[r * 2 + c];
|
|
||||||
active: root.card.preset == r * 2 + c;
|
|
||||||
clicked => { root.set-preset(r * 2 + c); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalLayout {
|
|
||||||
spacing: 8px;
|
|
||||||
height: 52px;
|
|
||||||
RoomButton {
|
|
||||||
label: "▲";
|
|
||||||
text-size: 20px;
|
|
||||||
active: root.card.moving == "up";
|
|
||||||
clicked => { root.open(); }
|
|
||||||
}
|
|
||||||
RoomButton {
|
|
||||||
label: "■";
|
|
||||||
text-size: 18px;
|
|
||||||
active: true;
|
|
||||||
active-fill: Theme.room-stop;
|
|
||||||
clicked => { root.stop(); }
|
|
||||||
}
|
|
||||||
RoomButton {
|
|
||||||
label: "▼";
|
|
||||||
text-size: 20px;
|
|
||||||
active: root.card.moving == "down";
|
|
||||||
clicked => { root.close(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component RoomView inherits Rectangle {
|
|
||||||
in property <[RoomRow]> rows;
|
|
||||||
in property <[SceneTile]> scenes;
|
|
||||||
in property <[Swatch]> swatches;
|
|
||||||
in property <[string]> presets;
|
|
||||||
in property <bool> loading;
|
|
||||||
callback toggle-light(string);
|
|
||||||
callback set-level(string, int);
|
|
||||||
callback set-swatch(string, int);
|
|
||||||
callback set-preset(string, int);
|
|
||||||
callback shutter(string, string); // entity, "open" | "stop" | "close"
|
|
||||||
callback activate-scene(string);
|
|
||||||
|
|
||||||
background: @linear-gradient(180deg,
|
|
||||||
Theme.room-top 0%,
|
|
||||||
Theme.room-mid 45%,
|
|
||||||
Theme.room-deep 100%);
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
padding-top: 18px;
|
|
||||||
spacing: 6px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: "Mein Zimmer";
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.room-paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.loading: VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: "Einen Moment …";
|
|
||||||
font-size: 22px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: Theme.room-paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !root.loading: Flickable {
|
|
||||||
content-height: body.preferred-height;
|
|
||||||
content-width: self.width;
|
|
||||||
|
|
||||||
body := VerticalLayout {
|
|
||||||
padding-left: 32px;
|
|
||||||
padding-right: Theme.rail-clearance;
|
|
||||||
padding-top: 14px;
|
|
||||||
padding-bottom: 64px;
|
|
||||||
spacing: 30px;
|
|
||||||
alignment: start;
|
|
||||||
|
|
||||||
if root.scenes.length > 0: VerticalLayout {
|
|
||||||
spacing: 14px;
|
|
||||||
Text {
|
|
||||||
text: "Szenen";
|
|
||||||
font-size: 19px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: Theme.room-paper;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: 12px;
|
|
||||||
for scene in root.scenes: Rectangle {
|
|
||||||
height: 52px;
|
|
||||||
width: pill.preferred-width;
|
|
||||||
border-radius: 26px;
|
|
||||||
background: scene.active
|
|
||||||
? Theme.room-paper
|
|
||||||
: Theme.room-paper.with-alpha(0.18);
|
|
||||||
animate background { duration: 140ms; }
|
|
||||||
pill := HorizontalLayout {
|
|
||||||
padding-left: 20px;
|
|
||||||
padding-right: 20px;
|
|
||||||
spacing: 8px;
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: "✨";
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Text {
|
|
||||||
text: scene.name;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: scene.active ? Theme.room-btn-fg : Theme.room-paper;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.activate-scene(scene.entity-id); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both kinds share one cell width, so the grid stays a grid whatever
|
|
||||||
// mix of devices Home Assistant reports.
|
|
||||||
for row in root.rows: HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: 20px;
|
|
||||||
for card in row.cards: Rectangle {
|
|
||||||
width: Theme.room-card-width;
|
|
||||||
height: card.kind == "shutter" ? 268px : 246px;
|
|
||||||
if card.kind == "light": LightCard {
|
|
||||||
width: 100%;
|
|
||||||
card: card;
|
|
||||||
swatches: root.swatches;
|
|
||||||
toggle => { root.toggle-light(card.entity-id); }
|
|
||||||
set-level(n) => { root.set-level(card.entity-id, n); }
|
|
||||||
set-swatch(i) => { root.set-swatch(card.entity-id, i); }
|
|
||||||
}
|
|
||||||
if card.kind == "shutter": ShutterCard {
|
|
||||||
width: 100%;
|
|
||||||
card: card;
|
|
||||||
presets: root.presets;
|
|
||||||
set-preset(i) => { root.set-preset(card.entity-id, i); }
|
|
||||||
open => { root.shutter(card.entity-id, "open"); }
|
|
||||||
stop => { root.shutter(card.entity-id, "stop"); }
|
|
||||||
close => { root.shutter(card.entity-id, "close"); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// The palette, converted once from the web front-end's oklch tokens
|
|
||||||
// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is
|
|
||||||
// kept beside each one, because that is the form the design is actually maintained in -
|
|
||||||
// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather
|
|
||||||
// than nudging the hex and letting the two drift.
|
|
||||||
|
|
||||||
export global Theme {
|
|
||||||
// ------------------------------------------------------------------ colours --
|
|
||||||
out property <color> ink: #113339; // oklch(30% 0.04 210)
|
|
||||||
out property <color> paper: #eef7f9; // oklch(97% 0.01 210)
|
|
||||||
out property <color> accent: #de73bd; // oklch(70% 0.16 340)
|
|
||||||
out property <color> accent-dim: #f292d3; // oklch(78% 0.14 340)
|
|
||||||
out property <color> sea-deep: #001b21; // oklch(20% 0.045 210)
|
|
||||||
out property <color> shadow: #001017; // oklch(15% 0.05 210)
|
|
||||||
|
|
||||||
// The stage gradient, which in the web app sits under an animated canvas. There is
|
|
||||||
// no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb,
|
|
||||||
// and `?pi=1` already removes it on the device this replaces.
|
|
||||||
out property <color> stage-top: #397d88; // oklch(55% 0.07 210)
|
|
||||||
out property <color> stage-mid: #0e4b54; // oklch(38% 0.06 210)
|
|
||||||
|
|
||||||
out property <color> text-dim: #c9dbdf; // oklch(88% 0.02 210)
|
|
||||||
out property <color> text-sub: #c0d2d5; // oklch(85% 0.02 210)
|
|
||||||
out property <color> count-pink: #7d0563; // oklch(40% 0.17 340)
|
|
||||||
|
|
||||||
out property <color> card-music: #e4f1f4; // oklch(95% 0.015 210)
|
|
||||||
out property <color> card-book: #ffd5ab; // oklch(92% 0.09 55)
|
|
||||||
out property <color> book-edge-1: #ffc298; // oklch(86% 0.09 55)
|
|
||||||
out property <color> book-edge-2: #e5a880; // oklch(78% 0.09 55)
|
|
||||||
|
|
||||||
out property <color> bar-bg: #00171e; // oklch(18% 0.05 210)
|
|
||||||
out property <color> sheet-bg: #e9f4f6; // oklch(96% 0.012 210)
|
|
||||||
out property <color> backdrop: #000e12; // oklch(15% 0.03 210)
|
|
||||||
// Two track colours, not one with an alpha: the seek bar sits on the pale stage in
|
|
||||||
// the play view and on the near-black player bar in the browse view, and a single
|
|
||||||
// translucent fill that reads correctly on one is invisible on the other.
|
|
||||||
out property <color> progress-track: #1b3236; // oklch(30% 0.03 210)
|
|
||||||
out property <color> progress-track-dark: #2e4f57;
|
|
||||||
out property <color> vol-empty: #c9dbdf; // oklch(88% 0.02 210)
|
|
||||||
|
|
||||||
// Glass, without the glass. `backdrop-filter` has no counterpart here, so these are
|
|
||||||
// exactly the web app's own `data-blur="off"` fallback: a flat translucent fill.
|
|
||||||
// That is a real loss of depth and the honest substitute for it - faking a blur by
|
|
||||||
// compositing a pre-blurred copy would reintroduce the per-frame cost the blur was
|
|
||||||
// dropped to avoid.
|
|
||||||
out property <brush> glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%);
|
|
||||||
out property <color> glass-border: #eef7f929;
|
|
||||||
out property <color> row-idle: #f7fcfd1a;
|
|
||||||
out property <color> row-active: #f7fcfd38;
|
|
||||||
|
|
||||||
// --------------------------------------------------- Mein Zimmer (violet) --
|
|
||||||
// Its own hue (~298-310) rather than the player's teal (~210), so the two pages
|
|
||||||
// read as siblings rather than one bleeding into the other. Same oklch-converted-
|
|
||||||
// once treatment as above.
|
|
||||||
out property <color> room-ink: #181322; // oklch(20% 0.03 300)
|
|
||||||
out property <color> room-paper: #f6f4fb; // oklch(97% 0.01 302)
|
|
||||||
out property <color> room-accent: #986dd0; // oklch(62% 0.15 302)
|
|
||||||
out property <color> room-card: #f3f0f9; // oklch(96% 0.012 300)
|
|
||||||
out property <color> room-top: #885cb5; // oklch(56% 0.14 305)
|
|
||||||
out property <color> room-mid: #4d2f77; // oklch(38% 0.12 300)
|
|
||||||
out property <color> room-deep: #1e0c37; // oklch(21% 0.08 298)
|
|
||||||
out property <color> room-icon-bg: #dad3e9; // oklch(88% 0.03 300)
|
|
||||||
out property <color> room-icon-fg: #3e3451; // oklch(35% 0.05 300)
|
|
||||||
out property <color> room-off-bg: #d9d5e3; // oklch(88% 0.02 300)
|
|
||||||
out property <color> room-off-fg: #736f7c; // oklch(55% 0.02 300)
|
|
||||||
out property <color> room-btn: #dcd8e6; // oklch(89% 0.02 300)
|
|
||||||
out property <color> room-btn-fg: #2c243a; // oklch(28% 0.04 300)
|
|
||||||
out property <color> room-stop: #e85854; // oklch(65% 0.18 25)
|
|
||||||
out property <color> room-toggle-off: #cbc9d0;
|
|
||||||
out property <color> room-sub: #585264; // oklch(45% 0.03 300)
|
|
||||||
out property <color> room-slat-a: #a19baf; // oklch(70% 0.03 300)
|
|
||||||
out property <color> room-slat-b: #7d778a; // oklch(58% 0.03 300)
|
|
||||||
out property <color> room-window-a: #87d1e8; // oklch(82% 0.08 220)
|
|
||||||
out property <color> room-window-b: #50afb4; // oklch(70% 0.09 200)
|
|
||||||
out property <length> room-card-width: 380px;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------- metrics --
|
|
||||||
out property <length> radius-panel: 22px;
|
|
||||||
out property <length> radius-card: 16px;
|
|
||||||
out property <length> radius-row: 12px;
|
|
||||||
out property <length> radius-sheet: 26px;
|
|
||||||
out property <length> pill: 999px;
|
|
||||||
|
|
||||||
// The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the
|
|
||||||
// same number keeps the same number of columns at 1920, which is what the shelf
|
|
||||||
// rows and the measured card height below were both tuned against.
|
|
||||||
out property <length> card-width: 180px;
|
|
||||||
out property <length> grid-gap: 24px;
|
|
||||||
out property <length> shelf-tile: 148px;
|
|
||||||
|
|
||||||
// How much of the right edge the page must leave clear for nothing to render
|
|
||||||
// underneath the corner controls.
|
|
||||||
out property <length> rail-clearance: 88px;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- typography --
|
|
||||||
// Heavy throughout: 700/800/900. Durations and counters use the monospace family so
|
|
||||||
// a ticking clock does not reflow the row it sits in.
|
|
||||||
out property <string> font: "Nunito";
|
|
||||||
out property <string> mono: "monospace";
|
|
||||||
|
|
||||||
out property <length> text-hero: 44px;
|
|
||||||
out property <length> text-title: 26px;
|
|
||||||
out property <length> text-heading: 20px;
|
|
||||||
out property <length> text-body: 16px;
|
|
||||||
out property <length> text-small: 13px;
|
|
||||||
out property <length> text-tiny: 12px;
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import { Theme } from "theme.slint";
|
|
||||||
|
|
||||||
// A frosted surface wrapped around a shelf or a list, so it reads as something
|
|
||||||
// floating over the stage rather than text on a gradient.
|
|
||||||
export component GlassPanel inherits Rectangle {
|
|
||||||
background: Theme.glass;
|
|
||||||
border-width: 1px;
|
|
||||||
border-color: Theme.glass-border;
|
|
||||||
border-radius: Theme.radius-panel;
|
|
||||||
drop-shadow-color: #00101759;
|
|
||||||
drop-shadow-blur: 24px;
|
|
||||||
drop-shadow-offset-y: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Album art. `source` is whatever the cover store has decoded; until it has one, the
|
|
||||||
// Rust side hands over generated art built from the album's own three colours, so this
|
|
||||||
// never has an empty frame to show.
|
|
||||||
//
|
|
||||||
// The shape comes from the media type and from nowhere else: square for an album,
|
|
||||||
// taller than wide for an audiobook, everywhere either appears.
|
|
||||||
export component Cover inherits Rectangle {
|
|
||||||
in property <image> source;
|
|
||||||
in property <bool> is-book: false;
|
|
||||||
in property <length> radius: Theme.radius-card;
|
|
||||||
|
|
||||||
clip: true;
|
|
||||||
border-radius: root.radius;
|
|
||||||
// A book keeps a sliver of its generated spine showing past real square artwork, so
|
|
||||||
// the shelf metaphor survives contact with a photograph.
|
|
||||||
background: root.is-book ? Theme.book-edge-2 : transparent;
|
|
||||||
|
|
||||||
Image {
|
|
||||||
source: root.source;
|
|
||||||
image-fit: cover;
|
|
||||||
width: root.is-book ? parent.width * 1.05 : parent.width;
|
|
||||||
height: parent.height;
|
|
||||||
x: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon
|
|
||||||
// font: there is no icon font on the device, and a missing glyph is a blank button.
|
|
||||||
export component TransportButton inherits Rectangle {
|
|
||||||
in property <length> size: 48px;
|
|
||||||
in property <bool> primary: false;
|
|
||||||
in property <bool> playing: false;
|
|
||||||
in property <string> direction: "play"; // "play" | "next" | "previous"
|
|
||||||
callback clicked();
|
|
||||||
|
|
||||||
width: root.size;
|
|
||||||
height: root.size;
|
|
||||||
border-radius: root.size / 2;
|
|
||||||
background: root.primary
|
|
||||||
? (touch.pressed ? Theme.accent-dim : Theme.accent)
|
|
||||||
: (touch.pressed ? #2a4f57 : Theme.ink);
|
|
||||||
drop-shadow-color: root.primary ? #de73bd80 : #00101766;
|
|
||||||
drop-shadow-blur: root.primary ? 20px : 10px;
|
|
||||||
drop-shadow-offset-y: 4px;
|
|
||||||
animate background { duration: 100ms; }
|
|
||||||
|
|
||||||
// Pause: two bars. Play and the skips: a triangle, clipped out of a square.
|
|
||||||
if root.direction == "play" && root.playing: HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: root.size * 0.1;
|
|
||||||
Rectangle {
|
|
||||||
width: root.size * 0.1;
|
|
||||||
height: root.size * 0.34;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
width: root.size * 0.1;
|
|
||||||
height: root.size * 0.34;
|
|
||||||
border-radius: 2px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.direction != "play" || !root.playing: HorizontalLayout {
|
|
||||||
alignment: center;
|
|
||||||
spacing: root.size * 0.06;
|
|
||||||
// "Previous" is the same triangle mirrored, plus the bar it butts against.
|
|
||||||
if root.direction == "previous": Rectangle {
|
|
||||||
width: root.size * 0.07;
|
|
||||||
height: root.size * 0.3;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
Rectangle {
|
|
||||||
width: root.size * 0.3;
|
|
||||||
height: root.size * 0.3;
|
|
||||||
Path {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
fill: white;
|
|
||||||
viewbox-width: 100;
|
|
||||||
viewbox-height: 100;
|
|
||||||
commands: root.direction == "previous"
|
|
||||||
? "M 100 0 L 100 100 L 0 50 Z"
|
|
||||||
: "M 6 0 L 100 50 L 6 100 Z";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if root.direction == "next": Rectangle {
|
|
||||||
width: root.size * 0.07;
|
|
||||||
height: root.size * 0.3;
|
|
||||||
border-radius: 1px;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {
|
|
||||||
clicked => { root.clicked(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export component Transport inherits HorizontalLayout {
|
|
||||||
in property <bool> playing;
|
|
||||||
in property <length> size: 48px;
|
|
||||||
callback toggle();
|
|
||||||
callback next();
|
|
||||||
callback previous();
|
|
||||||
|
|
||||||
alignment: center;
|
|
||||||
spacing: root.size * 0.21;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
TransportButton {
|
|
||||||
direction: "previous";
|
|
||||||
size: root.size;
|
|
||||||
clicked => { root.previous(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
TransportButton {
|
|
||||||
direction: "play";
|
|
||||||
primary: true;
|
|
||||||
playing: root.playing;
|
|
||||||
// The play button is the one target a child aims at from across the room.
|
|
||||||
size: root.size * 1.26;
|
|
||||||
clicked => { root.toggle(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
TransportButton {
|
|
||||||
direction: "next";
|
|
||||||
size: root.size;
|
|
||||||
clicked => { root.next(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The seek bar.
|
|
||||||
//
|
|
||||||
// `position` is only pushed twice a second, so the fill is *animated* between frames
|
|
||||||
// rather than recomputed on a client-side clock. That puts the interpolation on the
|
|
||||||
// render side, where it costs a property evaluation per frame instead of a layout pass,
|
|
||||||
// and it is why there is no equivalent of the web client's `usePlaybackClock` here.
|
|
||||||
//
|
|
||||||
// The animation is suppressed whenever `smooth` goes false - while dragging, and for
|
|
||||||
// the frame a track changes on - because sliding the fill across two unrelated
|
|
||||||
// positions is exactly the "it moves slightly backwards" artefact the web UI has.
|
|
||||||
export component ProgressBar inherits Rectangle {
|
|
||||||
in property <float> progress; // 0..1
|
|
||||||
in property <bool> interactive: true;
|
|
||||||
in property <length> bar-height: 10px;
|
|
||||||
in property <bool> smooth: true;
|
|
||||||
/// Set on the player bar, whose background is darker than the seek bar's own
|
|
||||||
/// track would otherwise show against.
|
|
||||||
in property <bool> on-dark: false;
|
|
||||||
callback seek(float);
|
|
||||||
|
|
||||||
height: max(root.bar-height, 28px);
|
|
||||||
|
|
||||||
track := Rectangle {
|
|
||||||
y: (parent.height - root.bar-height) / 2;
|
|
||||||
height: root.bar-height;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: root.bar-height / 2;
|
|
||||||
background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6);
|
|
||||||
clip: true;
|
|
||||||
|
|
||||||
fill := Rectangle {
|
|
||||||
x: 0;
|
|
||||||
width: parent.width * clamp(root.progress, 0.0, 1.0);
|
|
||||||
height: 100%;
|
|
||||||
border-radius: root.bar-height / 2;
|
|
||||||
background: Theme.accent;
|
|
||||||
animate width {
|
|
||||||
duration: root.smooth ? 500ms : 0ms;
|
|
||||||
easing: linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A grab handle only while it is worth having one: at player-bar size the bar is
|
|
||||||
// the target, and a knob would be smaller than the thing it sits on.
|
|
||||||
if root.interactive && root.bar-height >= 12px: Rectangle {
|
|
||||||
x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2));
|
|
||||||
y: (parent.height - self.height) / 2;
|
|
||||||
width: root.bar-height * 1.7;
|
|
||||||
height: root.bar-height * 1.7;
|
|
||||||
border-radius: self.width / 2;
|
|
||||||
background: Theme.paper;
|
|
||||||
drop-shadow-color: #00101773;
|
|
||||||
drop-shadow-blur: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
if root.interactive: TouchArea {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
// Both a click and a drag resolve to "the position under the finger", so
|
|
||||||
// scrubbing works without a separate drag state machine.
|
|
||||||
moved => {
|
|
||||||
if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
|
||||||
}
|
|
||||||
clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits
|
|
||||||
// a level, where a slider would hand back whatever pixel they landed on.
|
|
||||||
export component VolumeBars inherits HorizontalLayout {
|
|
||||||
in property <int> volume; // 0..100
|
|
||||||
in property <length> bar-width: 11px;
|
|
||||||
in property <length> bar-height: 30px;
|
|
||||||
callback set-volume(int);
|
|
||||||
callback mute();
|
|
||||||
|
|
||||||
spacing: root.bar-width * 0.3;
|
|
||||||
alignment: center;
|
|
||||||
|
|
||||||
VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
width: root.bar-height * 0.8;
|
|
||||||
height: root.bar-height * 0.8;
|
|
||||||
border-radius: self.width / 2;
|
|
||||||
background: speaker.has-hover ? #ffffff26 : transparent;
|
|
||||||
Text {
|
|
||||||
text: root.volume == 0 ? "🔇" : "🔊";
|
|
||||||
font-size: root.bar-width * 1.6;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
speaker := TouchArea {
|
|
||||||
clicked => { root.mute(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for level[i] in [20, 40, 60, 80, 100]: VerticalLayout {
|
|
||||||
alignment: center;
|
|
||||||
Rectangle {
|
|
||||||
width: root.bar-width;
|
|
||||||
// Stepped heights, so the control reads as a volume ramp at a glance.
|
|
||||||
height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5);
|
|
||||||
border-radius: 5px;
|
|
||||||
background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4);
|
|
||||||
animate background { duration: 120ms; }
|
|
||||||
TouchArea {
|
|
||||||
clicked => { root.set-volume(level); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A round icon button - back, close, and the fixed corner controls.
|
|
||||||
export component RoundButton inherits Rectangle {
|
|
||||||
in property <string> label;
|
|
||||||
in property <length> size: 44px;
|
|
||||||
in property <bool> highlighted: false;
|
|
||||||
callback clicked();
|
|
||||||
|
|
||||||
width: root.size;
|
|
||||||
height: root.size;
|
|
||||||
border-radius: root.size / 2;
|
|
||||||
background: root.highlighted
|
|
||||||
? Theme.accent
|
|
||||||
: (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95));
|
|
||||||
drop-shadow-color: #00101766;
|
|
||||||
drop-shadow-blur: 18px;
|
|
||||||
drop-shadow-offset-y: 6px;
|
|
||||||
|
|
||||||
Text {
|
|
||||||
text: root.label;
|
|
||||||
font-size: root.size * 0.45;
|
|
||||||
font-weight: 900;
|
|
||||||
color: root.highlighted ? white : Theme.ink;
|
|
||||||
vertical-alignment: center;
|
|
||||||
horizontal-alignment: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
touch := TouchArea {
|
|
||||||
clicked => { root.clicked(); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user