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>
1364 lines
47 KiB
Rust
1364 lines
47 KiB
Rust
//! A native front-end for the MusicMouse player.
|
||
//!
|
||
//! The same split the web front-end is built on: **what is playing** comes from the
|
||
//! backend over a push-only websocket, **what you are looking at** lives here. The
|
||
//! device has other front-ends - the buttons on the mouse, a figurine on the reader,
|
||
//! Home Assistant - so this follows playback rather than owning it.
|
||
//!
|
||
//! Three threads and no async runtime. The UI thread does layout and nothing else; a
|
||
//! websocket thread reads state; a cover thread fetches and downscales album art; a
|
||
//! command thread makes the HTTP calls, because a blocking POST on the UI thread is
|
||
//! exactly the stall this front-end exists to remove.
|
||
|
||
mod api;
|
||
mod covers;
|
||
mod format;
|
||
mod ha;
|
||
mod keymap;
|
||
mod library;
|
||
mod oklch;
|
||
mod ws;
|
||
|
||
use std::cell::RefCell;
|
||
use std::collections::{HashMap, VecDeque};
|
||
use std::rc::Rc;
|
||
use std::sync::mpsc::{channel, Sender};
|
||
use std::sync::{Arc, Mutex};
|
||
use std::time::Instant;
|
||
|
||
use slint::{ModelRc, SharedString, VecModel};
|
||
|
||
use api::{Album, Client, PlayerState};
|
||
|
||
use covers::Tier;
|
||
use keymap::{Action, Page, UiState};
|
||
use library::{Group, Library, Query, Results, GROUPS};
|
||
|
||
slint::include_modules!();
|
||
|
||
// The application state lives on the UI thread and is reached from the event-loop
|
||
// closures the worker relay posts. See `pump_workers`.
|
||
thread_local! {
|
||
static APP: RefCell<Option<Rc<RefCell<App>>>> = const { RefCell::new(None) };
|
||
}
|
||
|
||
// --------------------------------------------------------------------- commands --
|
||
|
||
/// Anything that talks to the backend, run off the UI thread. They all answer 204 with
|
||
/// no body, so nothing comes back: the authoritative answer arrives as a pushed state
|
||
/// frame, which is also what makes the optimistic updates below safe.
|
||
enum Command {
|
||
Play {
|
||
album_id: String,
|
||
track_index: usize,
|
||
},
|
||
Resume,
|
||
Pause,
|
||
Next,
|
||
Previous,
|
||
Seek(f64),
|
||
Volume(i32),
|
||
ReloadLibrary,
|
||
}
|
||
|
||
enum FromWorker {
|
||
Library(Result<Vec<Album>, String>),
|
||
Cover(covers::Ready),
|
||
Ws(WsEvent),
|
||
/// `None` means Home Assistant is not configured, which hides the room tab.
|
||
HaConfig(Option<ha::Config>),
|
||
HaStates(ha::Snapshot),
|
||
}
|
||
|
||
enum WsEvent {
|
||
Connected,
|
||
Disconnected,
|
||
State(Box<PlayerState>),
|
||
Position { position: f64, duration: f64 },
|
||
LibraryChanged,
|
||
}
|
||
|
||
// ------------------------------------------------------------------------ state --
|
||
|
||
struct App {
|
||
commands: Sender<Command>,
|
||
ui: UiState,
|
||
player: PlayerState,
|
||
library: Library,
|
||
covers: covers::Store,
|
||
online: bool,
|
||
loaded: bool,
|
||
error: Option<String>,
|
||
/// Changes whenever the track itself changes, so the progress bar can stop
|
||
/// animating across two unrelated positions instead of sliding between them.
|
||
track_key: String,
|
||
/// False for exactly one render: the frame a track changes on, and the frame a seek
|
||
/// lands on.
|
||
smooth: bool,
|
||
|
||
// ------------------------------------------------------------ Mein Zimmer --
|
||
ha: Sender<ha::Job>,
|
||
ha_config: Option<ha::Config>,
|
||
ha_states: HashMap<String, ha::EntityState>,
|
||
/// When each entity last got an optimistic patch. A poll that was already in
|
||
/// flight when the patch landed resolves with pre-click data; without this, that
|
||
/// stale answer puts a lamp back to "off" until the next poll catches up, which
|
||
/// is what made the web page feel laggy despite having optimistic updates.
|
||
ha_patched_at: HashMap<String, Instant>,
|
||
ha_loaded: bool,
|
||
/// Home Assistant scenes have no "currently active" state of their own, so this is
|
||
/// purely local: set by activating one, cleared by any manual device change.
|
||
active_scene: Option<String>,
|
||
}
|
||
|
||
impl App {
|
||
fn send(&self, command: Command) {
|
||
let _ = self.commands.send(command);
|
||
}
|
||
|
||
fn query(&self) -> Query<'_> {
|
||
Query {
|
||
search: &self.ui.search,
|
||
tracks_mode: self.ui.tracks_mode,
|
||
group: self.ui.group,
|
||
category: self.ui.category.as_deref(),
|
||
}
|
||
}
|
||
|
||
fn current_album(&self) -> Option<&Rc<Album>> {
|
||
self.player
|
||
.album_id
|
||
.as_deref()
|
||
.and_then(|id| self.library.get(id))
|
||
}
|
||
|
||
/// How long each of the three root shelves is, for the keyboard's column clamping.
|
||
fn shelf_lengths(&self) -> [usize; 3] {
|
||
[
|
||
self.library.categories(Group::Music).len(),
|
||
self.library.categories(Group::Audiobooks).len(),
|
||
self.library.categories(Group::Podcasts).len(),
|
||
]
|
||
}
|
||
|
||
fn ha_send(&self, job: ha::Job) {
|
||
let _ = self.ha.send(job);
|
||
}
|
||
|
||
/// Patch one entity locally so a tap feels instant, and remember when, so the poll
|
||
/// already in flight cannot undo it.
|
||
fn ha_optimistic(&mut self, entity_id: &str, patch: impl FnOnce(&mut ha::EntityState)) {
|
||
self.ha_patched_at
|
||
.insert(entity_id.to_string(), Instant::now());
|
||
if let Some(state) = self.ha_states.get_mut(entity_id) {
|
||
patch(state);
|
||
}
|
||
self.active_scene = None;
|
||
}
|
||
|
||
fn ha_call(&self, domain: &str, service: &str, body: serde_json::Value) {
|
||
self.ha_send(ha::Job::Call {
|
||
domain: domain.into(),
|
||
service: service.into(),
|
||
body,
|
||
});
|
||
}
|
||
|
||
fn ha_entity_ids(&self) -> Vec<String> {
|
||
let Some(config) = &self.ha_config else {
|
||
return Vec::new();
|
||
};
|
||
config
|
||
.devices
|
||
.iter()
|
||
.chain(config.scenes.iter())
|
||
.map(|d| d.entity_id.clone())
|
||
.collect()
|
||
}
|
||
|
||
fn apply(&mut self, actions: Vec<Action>) {
|
||
for action in actions {
|
||
match action {
|
||
Action::Play {
|
||
album_id,
|
||
track_index,
|
||
} => {
|
||
// Starting something switches to the full-screen view and closes
|
||
// the track list behind it, as the web front-end does: what you
|
||
// just chose becomes the thing on screen. ESC and `/` come back.
|
||
self.ui.play_view = true;
|
||
self.ui.open_album = None;
|
||
self.send(Command::Play {
|
||
album_id,
|
||
track_index,
|
||
});
|
||
}
|
||
Action::Toggle => {
|
||
// Optimistic: flip it here and let the pushed frame confirm. Safe
|
||
// precisely because state only ever arrives pushed, never polled,
|
||
// so there is no poll in flight to race with.
|
||
self.player.playing = !self.player.playing;
|
||
self.send(if self.player.playing {
|
||
Command::Resume
|
||
} else {
|
||
Command::Pause
|
||
});
|
||
}
|
||
Action::Next => self.send(Command::Next),
|
||
Action::Previous => self.send(Command::Previous),
|
||
Action::Volume(delta) => {
|
||
self.player.volume = (self.player.volume + delta).clamp(0, 100);
|
||
self.send(Command::Volume(self.player.volume));
|
||
}
|
||
Action::Seek(delta) => {
|
||
let target = (self.player.position + delta).clamp(0.0, self.player.duration);
|
||
self.player.position = target;
|
||
self.smooth = false;
|
||
self.send(Command::Seek(target));
|
||
}
|
||
Action::Mute => {
|
||
// Unmuting restores a sensible level rather than whatever it was:
|
||
// the level before a mute is not recorded anywhere the device
|
||
// agrees on, and coming back at 8% reads as still broken.
|
||
let target = if self.player.volume == 0 { 60 } else { 0 };
|
||
self.player.volume = target;
|
||
self.send(Command::Volume(target));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ presentation --
|
||
|
||
fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image {
|
||
let palette = album.palette();
|
||
store
|
||
.get(covers::Request {
|
||
album_id: album.id.clone(),
|
||
tier,
|
||
has_cover: album.has_cover,
|
||
is_book: album.is_book(),
|
||
locked: album.locked,
|
||
colors: [palette[0].into(), palette[1].into(), palette[2].into()],
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// "12 Songs · 45:03 · 🧸" - the line under an album's title.
|
||
fn album_meta(album: &Album) -> String {
|
||
let unit = if album.is_book() {
|
||
"Kapitel"
|
||
} else if album.tracks.len() == 1 {
|
||
"Song"
|
||
} else {
|
||
"Songs"
|
||
};
|
||
let mut meta = format!(
|
||
"{} {unit} · {}",
|
||
album.tracks.len(),
|
||
format::clock(album.duration)
|
||
);
|
||
if album.figure.is_some() {
|
||
meta.push_str(" · 🧸");
|
||
}
|
||
meta
|
||
}
|
||
|
||
/// "7 Hörbücher" - the line under a category tile.
|
||
fn category_meta(group: Group, count: usize) -> String {
|
||
let unit = match (group, count) {
|
||
(Group::Music, 1) => "Album",
|
||
(Group::Music, _) => "Alben",
|
||
(Group::Audiobooks, _) => "Hörbücher",
|
||
(Group::Podcasts, 1) => "Episode",
|
||
(Group::Podcasts, _) => "Episoden",
|
||
};
|
||
format!("{count} {unit}")
|
||
}
|
||
|
||
fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile {
|
||
AlbumTile {
|
||
id: album.id.as_str().into(),
|
||
title: if album.locked {
|
||
"❓ Geheimnis".into()
|
||
} else {
|
||
album.title.as_str().into()
|
||
},
|
||
// A podcast episode's artist is its show, which the title already says.
|
||
artist: if album.artist == album.title {
|
||
SharedString::new()
|
||
} else {
|
||
album.artist.as_str().into()
|
||
},
|
||
meta: album_meta(album).into(),
|
||
cover: cover_of(store, album, Tier::Thumb),
|
||
is_book: album.is_book(),
|
||
locked: album.locked,
|
||
current,
|
||
nav: nav as i32,
|
||
}
|
||
}
|
||
|
||
fn category_tile(
|
||
store: &mut covers::Store,
|
||
group: Group,
|
||
category: &library::Category,
|
||
nav: usize,
|
||
) -> CategoryTile {
|
||
// Up to four covers in a 2x2 contact sheet, which is how a category reads as "a
|
||
// stack of these" without a label saying so.
|
||
let mut covers_out = [
|
||
slint::Image::default(),
|
||
slint::Image::default(),
|
||
slint::Image::default(),
|
||
slint::Image::default(),
|
||
];
|
||
for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) {
|
||
*slot = cover_of(store, album, Tier::Thumb);
|
||
}
|
||
let [c1, c2, c3, c4] = covers_out;
|
||
CategoryTile {
|
||
key: category.key.as_str().into(),
|
||
count_label: category_meta(group, category.albums.len()).into(),
|
||
cover1: c1,
|
||
cover2: c2,
|
||
cover3: c3,
|
||
cover4: c4,
|
||
cover_count: category.albums.len().min(4) as i32,
|
||
is_book: category.albums.first().is_some_and(|a| a.is_book()),
|
||
nav: nav as i32,
|
||
}
|
||
}
|
||
|
||
fn track_row(
|
||
store: &mut covers::Store,
|
||
album: &Album,
|
||
index: usize,
|
||
title: &str,
|
||
duration: f64,
|
||
current: bool,
|
||
nav: usize,
|
||
) -> TrackRow {
|
||
TrackRow {
|
||
title: title.into(),
|
||
subtitle: album.line().into(),
|
||
duration: format::clock(duration).into(),
|
||
cover: cover_of(store, album, Tier::Thumb),
|
||
is_book: album.is_book(),
|
||
current,
|
||
locked: false,
|
||
album_id: album.id.as_str().into(),
|
||
index: index as i32,
|
||
nav: nav as i32,
|
||
}
|
||
}
|
||
|
||
fn now_playing(app: &mut App) -> NowPlaying {
|
||
let album = app.current_album().map(Rc::clone);
|
||
let state = &app.player;
|
||
let is_book = album.as_ref().is_some_and(|a| a.is_book());
|
||
let is_podcast = album
|
||
.as_ref()
|
||
.is_some_and(|a| Group::of(a) == Group::Podcasts);
|
||
|
||
let (cover, hero) = match &album {
|
||
Some(album) => (
|
||
cover_of(&mut app.covers, album, Tier::Thumb),
|
||
cover_of(&mut app.covers, album, Tier::Hero),
|
||
),
|
||
None => (slint::Image::default(), slint::Image::default()),
|
||
};
|
||
|
||
let remaining_label = match &album {
|
||
// "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the
|
||
// whole of what was chosen.
|
||
Some(album) if !is_podcast => {
|
||
let durations: Vec<f64> = album.tracks.iter().map(|t| t.duration).collect();
|
||
let left = format::remaining_in_album(
|
||
&durations,
|
||
state.track_index.max(0) as usize,
|
||
state.position,
|
||
);
|
||
let what = if is_book { "im Hörbuch" } else { "im Album" };
|
||
format!("Noch {} {what}", format::clock(left))
|
||
}
|
||
_ => String::new(),
|
||
};
|
||
|
||
NowPlaying {
|
||
title: state
|
||
.track_title
|
||
.clone()
|
||
.unwrap_or_else(|| "Wähl ein Album!".into())
|
||
.into(),
|
||
subtitle: album
|
||
.as_ref()
|
||
.map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line())
|
||
.into(),
|
||
counter: format!(
|
||
"{} {} von {}",
|
||
if is_book { "Kapitel" } else { "Song" },
|
||
state.track_index + 1,
|
||
state.track_count.max(1)
|
||
)
|
||
.into(),
|
||
cover,
|
||
hero_cover: hero,
|
||
is_book,
|
||
has_album: album.is_some(),
|
||
clock_label: if state.duration > 0.0 {
|
||
format!("−{}", format::clock(state.duration - state.position)).into()
|
||
} else {
|
||
SharedString::new()
|
||
},
|
||
remaining_label: remaining_label.into(),
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------- rendering --
|
||
|
||
fn render(app: &mut App, window: &AppWindow) {
|
||
window.set_loading(!app.loaded);
|
||
window.set_splash_text(
|
||
match &app.error {
|
||
Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"),
|
||
None => "Einen Moment …".to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
window.set_online(app.online);
|
||
window.set_status_label(
|
||
if !app.online {
|
||
"Keine Verbindung".into()
|
||
} else {
|
||
match &app.player.active_figure {
|
||
Some(figure) => format!("🧸 {figure}"),
|
||
None => String::new(),
|
||
}
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
// How many columns fit, from the width the window is actually giving the grid. The
|
||
// keyboard needs the same number to know what "one row down" means.
|
||
let gap = 24.0_f32;
|
||
let card = 180.0_f32;
|
||
let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1);
|
||
app.ui.cols = cols;
|
||
|
||
window.set_play_view(app.ui.play_view);
|
||
window.set_can_go_back(app.ui.can_go_back());
|
||
window.set_playing(app.player.playing);
|
||
window.set_volume(app.player.volume);
|
||
window.set_smooth(app.smooth);
|
||
window.set_progress(if app.player.duration > 0.0 {
|
||
(app.player.position / app.player.duration).clamp(0.0, 1.0) as f32
|
||
} else {
|
||
0.0
|
||
});
|
||
|
||
let now = now_playing(app);
|
||
let has_album = now.has_album;
|
||
window.set_now(now);
|
||
window.set_has_bar(has_album && !app.ui.play_view);
|
||
|
||
// The room tab only exists when Home Assistant is configured - the backend
|
||
// answering 404 for /api/ha is how that is known.
|
||
let tabs: Vec<Tab> = [Page::Music, Page::Room, Page::Typing]
|
||
.into_iter()
|
||
.filter(|page| *page != Page::Room || app.ha_config.is_some())
|
||
.map(|page| Tab {
|
||
id: match page {
|
||
Page::Music => "music",
|
||
Page::Room => "room",
|
||
Page::Typing => "typing",
|
||
}
|
||
.into(),
|
||
icon: page.icon().into(),
|
||
label: page.label().into(),
|
||
})
|
||
.collect();
|
||
window.set_tabs(ModelRc::new(VecModel::from(tabs)));
|
||
|
||
window.set_page(
|
||
match app.ui.page {
|
||
Page::Music => "music",
|
||
Page::Room => "room",
|
||
Page::Typing => "typing",
|
||
}
|
||
.into(),
|
||
);
|
||
|
||
render_room(app, window);
|
||
|
||
let root_screen = app.ui.is_root_shelf();
|
||
window.set_root_screen(root_screen);
|
||
window.set_shelf_row(app.ui.shelf_row as i32);
|
||
window.set_sel_index(app.ui.sel_index as i32);
|
||
|
||
if root_screen {
|
||
render_shelves(app, window);
|
||
} else {
|
||
render_results(app, window, cols);
|
||
}
|
||
|
||
render_sheet(app, window);
|
||
}
|
||
|
||
fn render_shelves(app: &mut App, window: &AppWindow) {
|
||
let mut shelves = Vec::with_capacity(GROUPS.len());
|
||
for group in GROUPS {
|
||
let categories: Vec<library::Category> = app.library.categories(group).to_vec();
|
||
let tiles: Vec<CategoryTile> = categories
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, category)| category_tile(&mut app.covers, group, category, i))
|
||
.collect();
|
||
shelves.push(Shelf {
|
||
label: group.label().into(),
|
||
icon: group.icon().into(),
|
||
tiles: ModelRc::new(VecModel::from(tiles)),
|
||
empty_label: "Noch nichts hier".into(),
|
||
});
|
||
}
|
||
window.set_shelves(ModelRc::new(VecModel::from(shelves)));
|
||
// The shelves replace the flat result lists rather than sitting beside them.
|
||
window.set_songs(ModelRc::new(VecModel::from(Vec::<TrackRow>::new())));
|
||
window.set_grid(ModelRc::new(VecModel::from(Vec::<GridRow>::new())));
|
||
window.set_category_grid(ModelRc::new(VecModel::from(Vec::<CategoryRow>::new())));
|
||
}
|
||
|
||
fn render_results(app: &mut App, window: &AppWindow, cols: usize) {
|
||
let results: Results = app.library.results(&app.query());
|
||
let total = results.total();
|
||
let current_album = app.player.album_id.clone();
|
||
let current_track = app.player.track_index.max(0) as usize;
|
||
|
||
// Selection indices run flat across songs, then categories, then albums.
|
||
let mut nav = 0usize;
|
||
|
||
let songs: Vec<TrackRow> = results
|
||
.songs
|
||
.iter()
|
||
.map(|hit| {
|
||
let row = track_row(
|
||
&mut app.covers,
|
||
&hit.album,
|
||
hit.index,
|
||
&hit.title,
|
||
hit.duration,
|
||
current_album.as_deref() == Some(hit.album.id.as_str())
|
||
&& current_track == hit.index,
|
||
nav,
|
||
);
|
||
nav += 1;
|
||
row
|
||
})
|
||
.collect();
|
||
|
||
let group = app.ui.group.unwrap_or(Group::Music);
|
||
let categories: Vec<CategoryTile> = results
|
||
.categories
|
||
.iter()
|
||
.map(|category| {
|
||
let tile = category_tile(&mut app.covers, group, category, nav);
|
||
nav += 1;
|
||
tile
|
||
})
|
||
.collect();
|
||
// Chunked like the album grid, and for the same reason: the view puts each row in
|
||
// a virtualizing list rather than one ever-widening horizontal layout.
|
||
let category_grid: Vec<CategoryRow> = categories
|
||
.chunks(cols)
|
||
.map(|chunk| CategoryRow {
|
||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||
})
|
||
.collect();
|
||
|
||
let tiles: Vec<AlbumTile> = results
|
||
.albums
|
||
.iter()
|
||
.map(|album| {
|
||
let tile = album_tile(
|
||
&mut app.covers,
|
||
album,
|
||
current_album.as_deref() == Some(album.id.as_str()),
|
||
nav,
|
||
);
|
||
nav += 1;
|
||
tile
|
||
})
|
||
.collect();
|
||
|
||
// Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343
|
||
// cards has no rows for it to skip. The web grid reaches the same place with
|
||
// `content-visibility: auto`.
|
||
let first_album_nav = songs.len() + categories.len();
|
||
let grid: Vec<GridRow> = tiles
|
||
.chunks(cols)
|
||
.map(|chunk| GridRow {
|
||
tiles: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||
})
|
||
.collect();
|
||
|
||
// Which grid row the selection is in, so the scroller can follow it without
|
||
// measuring anything - every row is the same height by construction.
|
||
let sel_row = app
|
||
.ui
|
||
.sel_index
|
||
.checked_sub(first_album_nav)
|
||
.map_or(0, |within| within / cols.max(1));
|
||
|
||
let count_label = if app.ui.tracks_mode {
|
||
format!("{} Titel gefunden", results.songs.len())
|
||
} else if results.albums.is_empty() {
|
||
"nichts gefunden".to_string()
|
||
} else if results.albums.len() == 1 {
|
||
"1 Album gefunden".to_string()
|
||
} else {
|
||
format!("{} Alben gefunden", results.albums.len())
|
||
};
|
||
|
||
window.set_group_label(
|
||
app.ui
|
||
.group
|
||
.map_or(SharedString::new(), |g| g.label().into()),
|
||
);
|
||
window.set_category_heading(group.category_label().into());
|
||
window.set_album_heading(
|
||
match app.ui.category.as_deref() {
|
||
Some(category) => category.to_string(),
|
||
None => group.section_label().to_string(),
|
||
}
|
||
.into(),
|
||
);
|
||
window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode);
|
||
window.set_search_text(
|
||
if app.ui.search.is_empty() {
|
||
"tippe …".to_string()
|
||
} else {
|
||
format!("\"{}\"", app.ui.search)
|
||
}
|
||
.into(),
|
||
);
|
||
window.set_mode_label(if app.ui.tracks_mode {
|
||
"♪ Titel".into()
|
||
} else {
|
||
"🔎 Alben".into()
|
||
});
|
||
window.set_count_label(count_label.into());
|
||
window.set_no_results(total == 0);
|
||
window.set_sel_row(sel_row as i32);
|
||
|
||
window.set_songs(ModelRc::new(VecModel::from(songs)));
|
||
window.set_category_grid(ModelRc::new(VecModel::from(category_grid)));
|
||
window.set_grid(ModelRc::new(VecModel::from(grid)));
|
||
}
|
||
|
||
/// Build the room page's models. One `RoomCard` list in Home Assistant's own config
|
||
/// order, because that order is the only layout information the config carries.
|
||
fn render_room(app: &mut App, window: &AppWindow) {
|
||
let Some(config) = app.ha_config.clone() else {
|
||
return;
|
||
};
|
||
window.set_room_loading(!app.ha_loaded);
|
||
|
||
let mut cards = Vec::with_capacity(config.devices.len());
|
||
for device in &config.devices {
|
||
let state = app.ha_states.get(&device.entity_id);
|
||
let mut card = RoomCard {
|
||
entity_id: device.entity_id.as_str().into(),
|
||
name: device.label().into(),
|
||
..Default::default()
|
||
};
|
||
match device.domain() {
|
||
"light" => {
|
||
let on = state.is_some_and(|s| s.is_on());
|
||
let rgb = state.map_or([255, 214, 140], |s| s.rgb());
|
||
card.kind = "light".into();
|
||
card.on = on;
|
||
card.is_color = state.is_some_and(|s| s.is_color());
|
||
// 0..5, matching the five buttons - HA reports 0..255.
|
||
card.level = state
|
||
.map_or(0.0, |s| (s.brightness() / 255.0 * 5.0).round())
|
||
.clamp(0.0, 5.0) as i32;
|
||
card.tint = slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]);
|
||
card.swatch = ha::SWATCHES
|
||
.iter()
|
||
.position(|(_, swatch)| *swatch == rgb)
|
||
.map_or(-1, |i| i as i32);
|
||
}
|
||
"cover" => {
|
||
let position = state.and_then(|s| s.cover_position());
|
||
let features = state.map_or(0, |s| s.supported_features());
|
||
let closed = ha::closed_percent(position.unwrap_or(0.0));
|
||
let moving = match state.map(|s| s.state.as_str()) {
|
||
Some("opening") => "up",
|
||
Some("closing") => "down",
|
||
_ => "",
|
||
};
|
||
card.kind = "shutter".into();
|
||
card.closed_percent = closed as f32;
|
||
card.moving = moving.into();
|
||
card.status = match moving {
|
||
"down" => "Fährt runter …".to_string(),
|
||
"up" => "Fährt hoch …".to_string(),
|
||
_ => ha::closed_label(closed),
|
||
}
|
||
.into();
|
||
card.supports_position =
|
||
position.is_some() || features & ha::SUPPORT_SET_POSITION != 0;
|
||
let label = ha::closed_label(closed);
|
||
card.preset = ha::SHUTTER_PRESETS
|
||
.iter()
|
||
.position(|(name, _)| *name == label)
|
||
.map_or(-1, |i| i as i32);
|
||
}
|
||
// Not a domain this page knows how to draw - skip rather than guess.
|
||
_ => continue,
|
||
}
|
||
cards.push(card);
|
||
}
|
||
|
||
let scenes: Vec<SceneTile> = config
|
||
.scenes
|
||
.iter()
|
||
.map(|scene| SceneTile {
|
||
entity_id: scene.entity_id.as_str().into(),
|
||
name: scene.label().into(),
|
||
active: app.active_scene.as_deref() == Some(scene.entity_id.as_str()),
|
||
})
|
||
.collect();
|
||
|
||
// Three across at 1080p, which is what the web grid's `minmax(300px, 1fr)` inside
|
||
// a 1180px column comes to on the same screen.
|
||
let columns = ((window.get_grid_width() / 400.0).floor() as usize).clamp(1, 3);
|
||
let rows: Vec<RoomRow> = cards
|
||
.chunks(columns)
|
||
.map(|chunk| RoomRow {
|
||
cards: ModelRc::new(VecModel::from(chunk.to_vec())),
|
||
})
|
||
.collect();
|
||
window.set_room_rows(ModelRc::new(VecModel::from(rows)));
|
||
window.set_room_scenes(ModelRc::new(VecModel::from(scenes)));
|
||
}
|
||
|
||
fn render_sheet(app: &mut App, window: &AppWindow) {
|
||
let Some(album) = app
|
||
.ui
|
||
.open_album
|
||
.clone()
|
||
.and_then(|id| app.library.get(&id).map(Rc::clone))
|
||
else {
|
||
window.set_show_sheet(false);
|
||
return;
|
||
};
|
||
|
||
let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str());
|
||
let current = app.player.track_index.max(0) as usize;
|
||
let rows: Vec<TrackRow> = album
|
||
.tracks
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, track)| {
|
||
let title = if track.locked {
|
||
"❓".to_string()
|
||
} else {
|
||
track.title.clone()
|
||
};
|
||
track_row(
|
||
&mut app.covers,
|
||
&album,
|
||
i,
|
||
&title,
|
||
track.duration,
|
||
playing_here && current == i,
|
||
i,
|
||
)
|
||
})
|
||
.collect();
|
||
|
||
let kind = if album.is_book() {
|
||
"📖 Hörbuch"
|
||
} else {
|
||
"🎵 Album"
|
||
};
|
||
let mut meta = format!("{kind} · {}", album_meta(&album));
|
||
if let Some(figure) = &album.figure {
|
||
meta.push_str(&format!(" · {figure}"));
|
||
}
|
||
|
||
window.set_show_sheet(true);
|
||
window.set_sheet_title(album.title.as_str().into());
|
||
window.set_sheet_subtitle(album.artist.as_str().into());
|
||
window.set_sheet_meta(meta.into());
|
||
window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero));
|
||
window.set_sheet_is_book(album.is_book());
|
||
window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32);
|
||
window.set_sheet_tracks(ModelRc::new(VecModel::from(rows)));
|
||
}
|
||
|
||
// ----------------------------------------------------------------------- wiring --
|
||
|
||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||
let arg = |name: &str| -> Option<String> { std::env::args().skip_while(|a| a != name).nth(1) };
|
||
let base = arg("--server").unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string());
|
||
// Which page to open on. A kiosk may well want to come up on the room panel rather
|
||
// than the player, and it is the only way to reach a page without a keyboard or a
|
||
// pointer.
|
||
let page = match arg("--page").as_deref() {
|
||
Some("room") => Page::Room,
|
||
Some("typing") => Page::Typing,
|
||
_ => Page::Music,
|
||
};
|
||
println!("musicmouse-slint → {base}");
|
||
|
||
// Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because
|
||
// Slint cannot read the woff2 the web front-end self-hosts. Slint takes one file as
|
||
// the primary font through `SLINT_DEFAULT_FONT`; the dev shell and the device's
|
||
// launcher both set it, and this is the fallback for running the binary directly.
|
||
// None of them finding it just means the system sans - a missing font should change
|
||
// how this looks, not whether it runs.
|
||
if std::env::var_os("SLINT_DEFAULT_FONT").is_none() {
|
||
let beside_exe = std::env::current_exe()
|
||
.ok()
|
||
// target/release/<bin> -> the crate root
|
||
.and_then(|exe| Some(exe.parent()?.parent()?.parent()?.join("fonts/Nunito.ttf")));
|
||
for candidate in [
|
||
Some(std::path::PathBuf::from("fonts/Nunito.ttf")),
|
||
beside_exe,
|
||
]
|
||
.into_iter()
|
||
.flatten()
|
||
{
|
||
if candidate.exists() {
|
||
std::env::set_var("SLINT_DEFAULT_FONT", candidate);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
let window = AppWindow::new()?;
|
||
let client = Client::new(&base);
|
||
|
||
let (to_ui, from_workers) = channel::<FromWorker>();
|
||
let (cover_jobs, cover_rx) = channel::<covers::Job>();
|
||
let (commands, command_rx) = channel::<Command>();
|
||
let (ha_jobs, ha_rx) = channel::<ha::Job>();
|
||
|
||
// Covers: fetch, downscale, cache to disk. Never on the UI thread.
|
||
{
|
||
let to_ui = to_ui.clone();
|
||
covers::spawn(client.clone(), cover_rx, move |ready| {
|
||
let _ = to_ui.send(FromWorker::Cover(ready));
|
||
});
|
||
}
|
||
|
||
// Commands, and the library fetch that seeds everything.
|
||
{
|
||
let client = client.clone();
|
||
let to_ui = to_ui.clone();
|
||
std::thread::Builder::new()
|
||
.name("commands".into())
|
||
.spawn(move || {
|
||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||
for command in command_rx {
|
||
let result = match command {
|
||
Command::Play {
|
||
album_id,
|
||
track_index,
|
||
} => client.play(&album_id, track_index as i32),
|
||
Command::Resume => client.resume(),
|
||
Command::Pause => client.pause(),
|
||
Command::Next => client.next(),
|
||
Command::Previous => client.previous(),
|
||
Command::Seek(position) => client.seek(position),
|
||
Command::Volume(percent) => client.volume(percent),
|
||
Command::ReloadLibrary => {
|
||
let _ = to_ui.send(FromWorker::Library(client.library()));
|
||
Ok(())
|
||
}
|
||
};
|
||
if let Err(error) = result {
|
||
// A failed command is not fatal: the next pushed frame says
|
||
// what actually happened, which is the only truth anyway.
|
||
eprintln!("{error}");
|
||
}
|
||
}
|
||
})?;
|
||
}
|
||
|
||
// Home Assistant, behind the backend's proxy. One worker for both the poll and
|
||
// the commands, so a command can never overtake the poll meant to confirm it.
|
||
{
|
||
let states_tx = to_ui.clone();
|
||
ha::spawn(client.clone(), ha_rx, move |snapshot| {
|
||
let _ = states_tx.send(FromWorker::HaStates(snapshot));
|
||
});
|
||
let client = client.clone();
|
||
let to_ui = to_ui.clone();
|
||
std::thread::Builder::new()
|
||
.name("ha-config".into())
|
||
.spawn(move || {
|
||
// A 404 here means Home Assistant is not configured, which is a normal
|
||
// answer: the tab simply does not appear.
|
||
let _ = to_ui.send(FromWorker::HaConfig(client.ha_config().ok().flatten()));
|
||
})?;
|
||
}
|
||
|
||
// State.
|
||
{
|
||
let to_ui = to_ui.clone();
|
||
let (ws_tx, ws_rx) = channel();
|
||
ws::spawn(client.clone(), ws_tx);
|
||
std::thread::Builder::new()
|
||
.name("ws-relay".into())
|
||
.spawn(move || {
|
||
for update in ws_rx {
|
||
let event = match update {
|
||
ws::Update::Connected => WsEvent::Connected,
|
||
ws::Update::Disconnected => WsEvent::Disconnected,
|
||
ws::Update::State(state) => WsEvent::State(state),
|
||
ws::Update::Position { position, duration } => {
|
||
WsEvent::Position { position, duration }
|
||
}
|
||
ws::Update::LibraryChanged => WsEvent::LibraryChanged,
|
||
};
|
||
if to_ui.send(FromWorker::Ws(event)).is_err() {
|
||
return;
|
||
}
|
||
}
|
||
})?;
|
||
}
|
||
|
||
let app = Rc::new(RefCell::new(App {
|
||
commands,
|
||
ui: UiState {
|
||
page,
|
||
..UiState::default()
|
||
},
|
||
player: PlayerState::default(),
|
||
library: Library::default(),
|
||
covers: covers::Store::new(cover_jobs),
|
||
ha: ha_jobs,
|
||
ha_config: None,
|
||
ha_states: HashMap::new(),
|
||
ha_patched_at: HashMap::new(),
|
||
ha_loaded: false,
|
||
active_scene: None,
|
||
online: false,
|
||
loaded: false,
|
||
error: None,
|
||
track_key: String::new(),
|
||
smooth: true,
|
||
}));
|
||
|
||
// The room page's poll. Only runs while that page is showing: a lamp's state is
|
||
// not worth a request every 2.5 s when nobody is looking at it, and this device
|
||
// has a music player to get out of the way of.
|
||
let poll = slint::Timer::default();
|
||
{
|
||
let handle = Rc::clone(&app);
|
||
let weak = window.as_weak();
|
||
poll.start(slint::TimerMode::Repeated, ha::POLL_INTERVAL, move || {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
let entity_ids = {
|
||
let app = handle.borrow();
|
||
if app.ui.page != Page::Room || app.ha_config.is_none() {
|
||
return;
|
||
}
|
||
app.ha_entity_ids()
|
||
};
|
||
handle.borrow().ha_send(ha::Job::Poll(entity_ids));
|
||
let _ = &window;
|
||
});
|
||
}
|
||
|
||
// The static parts of the room page - the six colours and the four shutter
|
||
// presets - never change, so they are set once rather than rebuilt per render.
|
||
window.set_room_swatches(ModelRc::new(VecModel::from(
|
||
ha::SWATCHES
|
||
.iter()
|
||
.map(|(name, rgb)| Swatch {
|
||
name: (*name).into(),
|
||
tint: slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]),
|
||
})
|
||
.collect::<Vec<_>>(),
|
||
)));
|
||
window.set_shutter_presets(ModelRc::new(VecModel::from(
|
||
ha::SHUTTER_PRESETS
|
||
.iter()
|
||
.map(|(label, _)| SharedString::from(*label))
|
||
.collect::<Vec<_>>(),
|
||
)));
|
||
|
||
install_callbacks(&app, &window);
|
||
pump_workers(&app, &window, from_workers);
|
||
|
||
render(&mut app.borrow_mut(), &window);
|
||
window.run()?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Bridge the worker channels onto the Slint event loop.
|
||
///
|
||
/// `invoke_from_event_loop` takes a `Send` closure, and the application state is
|
||
/// `Rc<RefCell<..>>` that never leaves the UI thread - so the state stays in a
|
||
/// thread-local here and only the *messages* cross, which are `Send` by construction.
|
||
///
|
||
/// The queue is drained rather than handled one at a time, and that is not incidental:
|
||
/// a cold start delivers a few hundred covers in a burst, and one re-render per cover
|
||
/// would rebuild every model a few hundred times. Coalescing collapses each burst into
|
||
/// a single render.
|
||
fn pump_workers(
|
||
app: &Rc<RefCell<App>>,
|
||
window: &AppWindow,
|
||
from_workers: std::sync::mpsc::Receiver<FromWorker>,
|
||
) {
|
||
APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app)));
|
||
|
||
let queue: Arc<Mutex<VecDeque<FromWorker>>> = Arc::default();
|
||
let weak = window.as_weak();
|
||
let producer = Arc::clone(&queue);
|
||
|
||
std::thread::Builder::new()
|
||
.name("relay".into())
|
||
.spawn(move || {
|
||
for message in from_workers {
|
||
match producer.lock() {
|
||
Ok(mut queue) => queue.push_back(message),
|
||
// Only reachable if a UI-thread panic poisoned the lock, in which
|
||
// case there is no UI left to deliver to.
|
||
Err(_) => return,
|
||
}
|
||
let drain = Arc::clone(&producer);
|
||
let posted = weak.upgrade_in_event_loop(move |window| {
|
||
let messages: Vec<FromWorker> = match drain.lock() {
|
||
Ok(mut queue) => queue.drain(..).collect(),
|
||
Err(_) => return,
|
||
};
|
||
if messages.is_empty() {
|
||
return;
|
||
}
|
||
APP.with(|slot| {
|
||
let Some(app) = slot.borrow().clone() else {
|
||
return;
|
||
};
|
||
{
|
||
let mut app = app.borrow_mut();
|
||
for message in messages {
|
||
handle_worker_message(&mut app, message);
|
||
}
|
||
}
|
||
render(&mut app.borrow_mut(), &window);
|
||
});
|
||
});
|
||
if posted.is_err() {
|
||
return;
|
||
}
|
||
}
|
||
})
|
||
.expect("spawning the worker relay");
|
||
}
|
||
|
||
fn handle_worker_message(app: &mut App, message: FromWorker) {
|
||
match message {
|
||
FromWorker::Library(Ok(albums)) => {
|
||
app.library = Library::build(albums);
|
||
app.loaded = true;
|
||
app.error = None;
|
||
}
|
||
FromWorker::Library(Err(error)) => {
|
||
app.error = Some(error);
|
||
}
|
||
FromWorker::Cover(ready) => app.covers.insert(ready),
|
||
FromWorker::Ws(WsEvent::Connected) => app.online = true,
|
||
FromWorker::Ws(WsEvent::Disconnected) => app.online = false,
|
||
FromWorker::Ws(WsEvent::State(state)) => {
|
||
let key = format!("{:?}:{}", state.album_id, state.track_index);
|
||
// A new track means the bar must jump, not slide: animating across two
|
||
// unrelated positions is what makes a seek look like it drifts backwards.
|
||
app.smooth = key == app.track_key;
|
||
app.track_key = key;
|
||
app.player = *state;
|
||
}
|
||
FromWorker::Ws(WsEvent::Position { position, duration }) => {
|
||
app.player.position = position;
|
||
if duration > 0.0 {
|
||
app.player.duration = duration;
|
||
}
|
||
app.smooth = true;
|
||
}
|
||
FromWorker::HaConfig(config) => {
|
||
app.ha_config = config;
|
||
if app.ha_config.is_some() {
|
||
app.ha_send(ha::Job::Poll(app.ha_entity_ids()));
|
||
}
|
||
}
|
||
FromWorker::HaStates(snapshot) => {
|
||
for (entity_id, state) in snapshot.states {
|
||
// Drop any entity whose optimistic patch is newer than the request
|
||
// that produced this - see `ha_patched_at`.
|
||
let patched = app.ha_patched_at.get(&entity_id).copied();
|
||
if patched.is_none_or(|at| snapshot.started_at >= at) {
|
||
app.ha_states.insert(entity_id, state);
|
||
}
|
||
}
|
||
app.ha_loaded = true;
|
||
}
|
||
FromWorker::Ws(WsEvent::LibraryChanged) => {
|
||
// A rescan is the one moment the backend rewrites the art behind an
|
||
// unchanged cover URL, so the thumbnails go with it.
|
||
app.covers.clear();
|
||
app.send(Command::ReloadLibrary);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn install_callbacks(app: &Rc<RefCell<App>>, window: &AppWindow) {
|
||
// Every callback does the same three things: mutate state, maybe queue a command,
|
||
// re-render. `bind!` is that shape written once, so no individual handler has to
|
||
// remember the third.
|
||
macro_rules! bind {
|
||
($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => {
|
||
let weak = window.as_weak();
|
||
let handle = Rc::clone(app);
|
||
window.$setter(move |$($arg: $ty),*| {
|
||
let Some(window) = weak.upgrade() else { return };
|
||
{
|
||
let mut $a = handle.borrow_mut();
|
||
$body
|
||
}
|
||
render(&mut handle.borrow_mut(), &window);
|
||
});
|
||
};
|
||
}
|
||
|
||
bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| {
|
||
let results = a.library.results(&a.query());
|
||
let shelves = a.shelf_lengths();
|
||
let sheet_tracks =
|
||
a.ui.open_album
|
||
.as_deref()
|
||
.and_then(|id| a.library.get(id))
|
||
.map_or(0, |album| album.tracks.len());
|
||
let mut ui = a.ui.clone();
|
||
let actions = keymap::handle_key(
|
||
key.as_str(),
|
||
ctrl,
|
||
shift,
|
||
&mut ui,
|
||
&results,
|
||
&shelves,
|
||
sheet_tracks,
|
||
);
|
||
a.ui = ui;
|
||
a.apply(actions);
|
||
});
|
||
|
||
bind!(on_toggle, |a| {
|
||
a.apply(vec![Action::Toggle]);
|
||
});
|
||
bind!(on_next, |a| {
|
||
a.apply(vec![Action::Next]);
|
||
});
|
||
bind!(on_previous, |a| {
|
||
a.apply(vec![Action::Previous]);
|
||
});
|
||
bind!(on_mute, |a| {
|
||
a.apply(vec![Action::Mute]);
|
||
});
|
||
|
||
bind!(on_seek, |a, fraction: f32| {
|
||
let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration);
|
||
a.player.position = target;
|
||
a.smooth = false;
|
||
a.send(Command::Seek(target));
|
||
});
|
||
|
||
bind!(on_set_volume, |a, percent: i32| {
|
||
a.player.volume = percent.clamp(0, 100);
|
||
a.send(Command::Volume(a.player.volume));
|
||
});
|
||
|
||
bind!(on_go_back, |a| {
|
||
if a.ui.open_album.is_some() {
|
||
a.ui.open_album = None;
|
||
} else if a.ui.play_view {
|
||
a.ui.play_view = false;
|
||
} else {
|
||
a.ui.browse_back();
|
||
}
|
||
});
|
||
|
||
bind!(on_enter_group, |a, shelf: i32| {
|
||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||
a.ui.category = None;
|
||
a.ui.sel_index = 0;
|
||
});
|
||
|
||
bind!(on_enter_category, |a, shelf: i32, key: SharedString| {
|
||
a.ui.group = Some(Group::from_index(shelf.max(0) as usize));
|
||
a.ui.category = Some(key.to_string());
|
||
a.ui.sel_index = 0;
|
||
});
|
||
|
||
bind!(on_open_category, |a, key: SharedString| {
|
||
a.ui.category = Some(key.to_string());
|
||
a.ui.sel_index = 0;
|
||
});
|
||
|
||
bind!(on_open_album, |a, id: SharedString| {
|
||
// A podcast episode is a single track with nothing to choose between, so the
|
||
// cover plays it rather than opening a list of one.
|
||
match a.library.get(id.as_str()).map(Rc::clone) {
|
||
Some(album) if Group::of(&album) == Group::Podcasts => {
|
||
a.apply(vec![Action::Play {
|
||
album_id: album.id.clone(),
|
||
track_index: 0,
|
||
}]);
|
||
}
|
||
Some(album) => {
|
||
a.ui.open_album = Some(album.id.clone());
|
||
a.ui.sheet_index = 0;
|
||
}
|
||
None => {}
|
||
}
|
||
});
|
||
|
||
bind!(on_play_album, |a, id: SharedString| {
|
||
a.apply(vec![Action::Play {
|
||
album_id: id.to_string(),
|
||
track_index: 0,
|
||
}]);
|
||
});
|
||
|
||
bind!(on_play_song, |a, id: SharedString, index: i32| {
|
||
a.apply(vec![Action::Play {
|
||
album_id: id.to_string(),
|
||
track_index: index.max(0) as usize,
|
||
}]);
|
||
});
|
||
|
||
bind!(on_open_play_view, |a| {
|
||
a.ui.play_view = true;
|
||
});
|
||
bind!(on_close_sheet, |a| {
|
||
a.ui.open_album = None;
|
||
});
|
||
|
||
bind!(on_select_page, |a, page: SharedString| {
|
||
a.ui.page = match page.as_str() {
|
||
"room" => Page::Room,
|
||
"typing" => Page::Typing,
|
||
_ => Page::Music,
|
||
};
|
||
// Ask for fresh state the moment the page comes up, rather than making the
|
||
// first thing you see be up to one poll interval stale.
|
||
if a.ui.page == Page::Room && a.ha_config.is_some() {
|
||
a.ha_send(ha::Job::Poll(a.ha_entity_ids()));
|
||
}
|
||
});
|
||
|
||
bind!(on_toggle_light, |a, id: SharedString| {
|
||
let on = a.ha_states.get(id.as_str()).is_some_and(|s| s.is_on());
|
||
a.ha_optimistic(id.as_str(), |state| {
|
||
state.state = if on { "off".into() } else { "on".into() };
|
||
});
|
||
a.ha_call(
|
||
"light",
|
||
if on { "turn_off" } else { "turn_on" },
|
||
serde_json::json!({ "entity_id": id.as_str() }),
|
||
);
|
||
});
|
||
|
||
bind!(on_set_light_level, |a, id: SharedString, level: i32| {
|
||
let percent = level.clamp(1, 5) * 20;
|
||
a.ha_optimistic(id.as_str(), |state| {
|
||
state.state = "on".into();
|
||
state.attributes.insert(
|
||
"brightness".into(),
|
||
serde_json::json!((percent as f64 / 100.0 * 255.0).round()),
|
||
);
|
||
});
|
||
a.ha_call(
|
||
"light",
|
||
"turn_on",
|
||
serde_json::json!({ "entity_id": id.as_str(), "brightness_pct": percent }),
|
||
);
|
||
});
|
||
|
||
bind!(on_set_light_swatch, |a, id: SharedString, index: i32| {
|
||
let Some((_, rgb)) = ha::SWATCHES.get(index.max(0) as usize) else {
|
||
return;
|
||
};
|
||
let rgb = *rgb;
|
||
a.ha_optimistic(id.as_str(), |state| {
|
||
state.state = "on".into();
|
||
state.attributes.insert(
|
||
"rgb_color".into(),
|
||
serde_json::json!([rgb[0], rgb[1], rgb[2]]),
|
||
);
|
||
});
|
||
a.ha_call(
|
||
"light",
|
||
"turn_on",
|
||
serde_json::json!({ "entity_id": id.as_str(), "rgb_color": [rgb[0], rgb[1], rgb[2]] }),
|
||
);
|
||
});
|
||
|
||
bind!(on_set_shutter_preset, |a, id: SharedString, index: i32| {
|
||
let Some((_, closed)) = ha::SHUTTER_PRESETS.get(index.max(0) as usize) else {
|
||
return;
|
||
};
|
||
let position = ha::position_from_closed(*closed);
|
||
a.ha_optimistic(id.as_str(), |state| {
|
||
state
|
||
.attributes
|
||
.insert("current_position".into(), serde_json::json!(position));
|
||
});
|
||
a.ha_call(
|
||
"cover",
|
||
"set_cover_position",
|
||
serde_json::json!({ "entity_id": id.as_str(), "position": position }),
|
||
);
|
||
});
|
||
|
||
bind!(on_shutter, |a, id: SharedString, what: SharedString| {
|
||
let service = match what.as_str() {
|
||
"open" => "open_cover",
|
||
"close" => "close_cover",
|
||
_ => "stop_cover",
|
||
};
|
||
// No optimistic position here: a real cover reports its own movement, and
|
||
// guessing where it will end up is exactly what would fight with that.
|
||
a.active_scene = None;
|
||
a.ha_patched_at.remove(id.as_str());
|
||
a.ha_call(
|
||
"cover",
|
||
service,
|
||
serde_json::json!({ "entity_id": id.as_str() }),
|
||
);
|
||
});
|
||
|
||
bind!(on_activate_scene, |a, id: SharedString| {
|
||
a.active_scene = Some(id.to_string());
|
||
a.ha_call(
|
||
"scene",
|
||
"turn_on",
|
||
serde_json::json!({ "entity_id": id.as_str() }),
|
||
);
|
||
});
|
||
|
||
bind!(on_play_sheet_track, |a, index: i32| {
|
||
if let Some(album_id) = a.ui.open_album.clone() {
|
||
a.apply(vec![Action::Play {
|
||
album_id,
|
||
track_index: index.max(0) as usize,
|
||
}]);
|
||
a.ui.open_album = None;
|
||
}
|
||
});
|
||
}
|