The second of the web front-end's three pages. Lights get a toggle, five brightness steps and six colours; shutters get four presets, up/stop/down and a window that reflects the position Home Assistant reports. Its own violet hue rather than the player's teal, so the two pages read as siblings rather than one bleeding into the other - the same reason room.css has its own tokens. The backend keeps Home Assistant's URL and token and relays the calls, so this client learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha means Home Assistant is not configured, which is a normal answer: the tab simply does not appear. Polled, not subscribed, and only while the page is on screen. HA's own auth-and- subscribe websocket is more machinery than a room panel needs, and a lamp's state is not worth a request every 2.5 seconds when nobody is looking at it - this device has a music player to stay out of the way of. A tap patches the entity locally and records when; a poll that was already in flight when the patch landed is dropped for that entity rather than putting the lamp back to "off" until the next one catches up. That reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried over for the same reason. Nothing on a shutter card animates locally. A real cover reports its own movement and position, and a client-side guess at where it will end up is precisely what would fight with that - the design mockup animated it because it had no backend to ask. Two additions beyond a straight port, both because a kiosk has no pointer: CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a front-end whose README claims every screen is reachable from the keyboard. Plain TAB was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere else. --page opens directly on a page, which a kiosk may well want and which is the only way in with neither keyboard nor pointer. Verified against the real Home Assistant: the shutter reported open and the lamp off, and both drew that way. Nothing was switched - those entities are hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
13 KiB
Plaintext
348 lines
13 KiB
Plaintext
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;
|
||
}
|
||
}
|
||
}
|
||
}
|