mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat: audio controls, reboot button, update lock, ONVIF refresh
- Audio: kiosk/src/audio.rs — PipeWire/ALSA volume, mute, output selection. WS commands volume-set/volume-mute/audio-output. Heartbeat reports audio state. Admin UI volume buttons + mute. - Reboot: admin button with confirmation, WS reboot command, kiosk runs systemctl reboot. - Firmware update now reboots (not exit) to clear state fully. - Update lock: FIRMWARE_LOCK + OS_UPDATE_LOCK mutexes prevent concurrent update attempts from heartbeat + WS paths. - ONVIF: auto-refresh stale/failed subs (>24h or failed state), mark_event_received with proper epoch timestamp, parse Key section for PlateNumber. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
55b11f2ffa
commit
5ce526eb33
8 changed files with 304 additions and 6 deletions
165
kiosk/src/audio.rs
Normal file
165
kiosk/src/audio.rs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
//! Audio output control — volume, mute, output selection.
|
||||||
|
//!
|
||||||
|
//! Tries PipeWire (`wpctl`) first, falls back to ALSA (`amixer`).
|
||||||
|
//! Pi 5 with Debian Bookworm uses PipeWire by default under cage.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
|
pub struct AudioState {
|
||||||
|
pub volume_percent: u32,
|
||||||
|
pub muted: bool,
|
||||||
|
pub output_name: String,
|
||||||
|
pub available_outputs: Vec<AudioOutput>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct AudioOutput {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_default: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_state() -> AudioState {
|
||||||
|
if has_wpctl() {
|
||||||
|
get_state_pipewire()
|
||||||
|
} else {
|
||||||
|
get_state_alsa()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(percent: u32) -> bool {
|
||||||
|
let pct = percent.min(100);
|
||||||
|
info!("audio: set volume {pct}%");
|
||||||
|
if has_wpctl() {
|
||||||
|
run_ok("wpctl", &["set-volume", "@DEFAULT_AUDIO_SINK@", &format!("{:.2}", pct as f32 / 100.0)])
|
||||||
|
} else {
|
||||||
|
run_ok("amixer", &["sset", "Master", &format!("{pct}%")])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_mute(muted: bool) -> bool {
|
||||||
|
info!("audio: set mute={muted}");
|
||||||
|
if has_wpctl() {
|
||||||
|
let val = if muted { "1" } else { "0" };
|
||||||
|
run_ok("wpctl", &["set-mute", "@DEFAULT_AUDIO_SINK@", val])
|
||||||
|
} else {
|
||||||
|
let val = if muted { "mute" } else { "unmute" };
|
||||||
|
run_ok("amixer", &["sset", "Master", val])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_output(id: &str) -> bool {
|
||||||
|
info!("audio: set output={id}");
|
||||||
|
if has_wpctl() {
|
||||||
|
run_ok("wpctl", &["set-default", id])
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_wpctl() -> bool {
|
||||||
|
Command::new("wpctl").arg("--version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_ok(cmd: &str, args: &[&str]) -> bool {
|
||||||
|
match Command::new(cmd).args(args)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Ok(s) => s.success(),
|
||||||
|
Err(e) => { warn!("audio: {cmd} failed: {e}"); false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_state_pipewire() -> AudioState {
|
||||||
|
let mut state = AudioState::default();
|
||||||
|
|
||||||
|
if let Ok(out) = Command::new("wpctl").args(["get-volume", "@DEFAULT_AUDIO_SINK@"]).output() {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
// "Volume: 0.75" or "Volume: 0.75 [MUTED]"
|
||||||
|
state.muted = text.contains("[MUTED]");
|
||||||
|
if let Some(vol_str) = text.split_whitespace().nth(1) {
|
||||||
|
if let Ok(v) = vol_str.parse::<f32>() {
|
||||||
|
state.volume_percent = (v * 100.0).round() as u32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(out) = Command::new("wpctl").args(["status"]).output() {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let mut in_sinks = false;
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.contains("Audio/Sink") || trimmed.contains("Sinks:") {
|
||||||
|
in_sinks = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if in_sinks && trimmed.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if in_sinks {
|
||||||
|
let is_default = trimmed.contains('*');
|
||||||
|
let clean = trimmed.trim_start_matches(['│', '├', '└', '─', ' ', '*', '·']);
|
||||||
|
let parts: Vec<&str> = clean.splitn(2, '.').collect();
|
||||||
|
if parts.len() == 2 {
|
||||||
|
let id = parts[0].trim().to_string();
|
||||||
|
let name = parts[1].trim().trim_start_matches(' ').to_string();
|
||||||
|
if is_default {
|
||||||
|
state.output_name = name.clone();
|
||||||
|
}
|
||||||
|
state.available_outputs.push(AudioOutput { id, name, is_default });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_state_alsa() -> AudioState {
|
||||||
|
let mut state = AudioState::default();
|
||||||
|
state.output_name = "Master".to_string();
|
||||||
|
|
||||||
|
if let Ok(out) = Command::new("amixer").args(["sget", "Master"]).output() {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
// "Mono: Playback 32768 [50%] [on]" or "[off]"
|
||||||
|
if trimmed.contains('[') && trimmed.contains('%') {
|
||||||
|
if let Some(pct_str) = trimmed.split('[').nth(1) {
|
||||||
|
if let Some(pct) = pct_str.strip_suffix("%]") {
|
||||||
|
state.volume_percent = pct.parse().unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.muted = trimmed.contains("[off]");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(out) = Command::new("aplay").args(["-l"]).output() {
|
||||||
|
let text = String::from_utf8_lossy(&out.stdout);
|
||||||
|
for line in text.lines() {
|
||||||
|
if line.starts_with("card ") {
|
||||||
|
let name = line.split(':').nth(1).unwrap_or("").trim().to_string();
|
||||||
|
let id = line.split_whitespace().nth(1).unwrap_or("0")
|
||||||
|
.trim_end_matches(':').to_string();
|
||||||
|
state.available_outputs.push(AudioOutput {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
is_default: state.available_outputs.is_empty(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,9 @@ pub fn apply_public(server: &str, info: &UpdateInfo) -> Result<(), String> {
|
||||||
let _ = fs::rename(&bin, &prev_path);
|
let _ = fs::rename(&bin, &prev_path);
|
||||||
}
|
}
|
||||||
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
|
fs::rename(&new_path, &bin).map_err(|e| format!("rename: {e}"))?;
|
||||||
info!("preboot firmware: updated to {}, exiting for restart", info.version);
|
info!("preboot firmware: updated to {}, rebooting", info.version);
|
||||||
|
let _ = std::process::Command::new("systemctl").arg("reboot").status();
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,10 +269,18 @@ pub fn apply(
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.send();
|
.send();
|
||||||
|
|
||||||
on_progress("Restarting", 100);
|
on_progress("Rebooting", 100);
|
||||||
info!("firmware: swap complete → exiting for systemd to relaunch");
|
info!("firmware: swap complete → rebooting to pick up new binary");
|
||||||
// systemd Restart=always picks up the new binary on next start.
|
match std::process::Command::new("systemctl").arg("reboot").status() {
|
||||||
std::process::exit(0);
|
Ok(_) => {
|
||||||
|
std::thread::sleep(Duration::from_secs(30));
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
info!("systemctl reboot failed: {e}, falling back to exit");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> {
|
fn verify_signature(public_key_pem: &str, sha256_hex: &str, sig_b64url: &str) -> Result<(), String> {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod at_rest;
|
mod at_rest;
|
||||||
|
mod audio;
|
||||||
mod axiom;
|
mod axiom;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
mod cec;
|
mod cec;
|
||||||
|
|
@ -27,6 +28,11 @@ pub enum ServerMsg {
|
||||||
display_id: Option<String>,
|
display_id: Option<String>,
|
||||||
layout_id: String,
|
layout_id: String,
|
||||||
},
|
},
|
||||||
|
/// Audio controls from admin.
|
||||||
|
VolumeSet(u32),
|
||||||
|
VolumeMute(bool),
|
||||||
|
AudioOutputSet(String),
|
||||||
|
Reboot,
|
||||||
/// Server-pushed "go check for a firmware update now".
|
/// Server-pushed "go check for a firmware update now".
|
||||||
FirmwareCheck,
|
FirmwareCheck,
|
||||||
/// Server-pushed "go check for an OS update now".
|
/// Server-pushed "go check for an OS update now".
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,7 @@ pub fn heartbeat(
|
||||||
"network_interfaces": network_interfaces,
|
"network_interfaces": network_interfaces,
|
||||||
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
|
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
|
||||||
"partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(),
|
"partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(),
|
||||||
|
"audio": serde_json::to_value(crate::audio::get_state()).unwrap_or_default(),
|
||||||
}))
|
}))
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
use std::cell::{Cell, RefCell};
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::mpsc;
|
use std::sync::{mpsc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
static FIRMWARE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
static OS_UPDATE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use gtk4::{
|
use gtk4::{
|
||||||
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
|
self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture,
|
||||||
|
|
@ -267,6 +270,18 @@ fn activate(app: &Application) {
|
||||||
}
|
}
|
||||||
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
||||||
}
|
}
|
||||||
|
ServerMsg::VolumeSet(vol) => {
|
||||||
|
crate::audio::set_volume(vol);
|
||||||
|
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
||||||
|
}
|
||||||
|
ServerMsg::VolumeMute(muted) => {
|
||||||
|
crate::audio::set_mute(muted);
|
||||||
|
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
||||||
|
}
|
||||||
|
ServerMsg::AudioOutputSet(id) => {
|
||||||
|
crate::audio::set_output(&id);
|
||||||
|
send_heartbeat_now(&server_for_reload, &key_for_reload);
|
||||||
|
}
|
||||||
ServerMsg::SwitchLayout {
|
ServerMsg::SwitchLayout {
|
||||||
display_id,
|
display_id,
|
||||||
layout_id,
|
layout_id,
|
||||||
|
|
@ -276,6 +291,10 @@ fn activate(app: &Application) {
|
||||||
layout_id,
|
layout_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
ServerMsg::Reboot => {
|
||||||
|
info!("reboot requested by admin");
|
||||||
|
let _ = std::process::Command::new("systemctl").arg("reboot").status();
|
||||||
|
}
|
||||||
ServerMsg::FirmwareCheck => {
|
ServerMsg::FirmwareCheck => {
|
||||||
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
|
maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload);
|
||||||
}
|
}
|
||||||
|
|
@ -533,6 +552,10 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender<Wo
|
||||||
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
|
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let Ok(_lock) = OS_UPDATE_LOCK.try_lock() else {
|
||||||
|
info!("os-update: another update already in progress, skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(info) = os_update::check(server_url, kiosk_key) else {
|
let Some(info) = os_update::check(server_url, kiosk_key) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -579,6 +602,10 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sen
|
||||||
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
|
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let Ok(_lock) = FIRMWARE_LOCK.try_lock() else {
|
||||||
|
info!("firmware: another update already in progress, skipping");
|
||||||
|
return;
|
||||||
|
};
|
||||||
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
|
let current = option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
|
||||||
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
|
let Some(info) = firmware::check(server_url, kiosk_key, current) else {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,8 @@ async fn handle_message(
|
||||||
if let Some(layout_id) = layout_id {
|
if let Some(layout_id) = layout_id {
|
||||||
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
|
let _ = tx.send(ServerMsg::SwitchLayout { display_id, layout_id });
|
||||||
}
|
}
|
||||||
|
} else if text.contains("\"type\":\"reboot\"") {
|
||||||
|
let _ = tx.send(ServerMsg::Reboot);
|
||||||
} else if text.contains("\"type\":\"firmware_check\"") {
|
} else if text.contains("\"type\":\"firmware_check\"") {
|
||||||
let _ = tx.send(ServerMsg::FirmwareCheck);
|
let _ = tx.send(ServerMsg::FirmwareCheck);
|
||||||
} else if text.contains("\"type\":\"os_check\"") {
|
} else if text.contains("\"type\":\"os_check\"") {
|
||||||
|
|
@ -183,6 +185,20 @@ async fn handle_message(
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let _ = tx.send(ServerMsg::Fan(pwm));
|
let _ = tx.send(ServerMsg::Fan(pwm));
|
||||||
|
} else if text.contains("\"type\":\"volume-set\"") {
|
||||||
|
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||||||
|
if let Some(vol) = msg.get("volume").and_then(|v| v.as_u64()) {
|
||||||
|
let _ = tx.send(ServerMsg::VolumeSet(vol.min(100) as u32));
|
||||||
|
}
|
||||||
|
} else if text.contains("\"type\":\"volume-mute\"") {
|
||||||
|
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||||||
|
let muted = msg.get("muted").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
let _ = tx.send(ServerMsg::VolumeMute(muted));
|
||||||
|
} else if text.contains("\"type\":\"audio-output\"") {
|
||||||
|
let Ok(msg) = serde_json::from_str::<serde_json::Value>(text) else { return };
|
||||||
|
if let Some(id) = msg.get("output_id").and_then(|v| v.as_str()) {
|
||||||
|
let _ = tx.send(ServerMsg::AudioOutputSet(id.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Journal streaming --------------------------------------------------
|
// ---- Journal streaming --------------------------------------------------
|
||||||
} else if text.contains("\"type\":\"journal-start\"") {
|
} else if text.contains("\"type\":\"journal-start\"") {
|
||||||
|
|
|
||||||
|
|
@ -2193,6 +2193,29 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/reboot", async (event) => {
|
||||||
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
|
getCoordinator().sendToKiosk(id, { type: "reboot" });
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/volume", async (event) => {
|
||||||
|
const id = (getRouterParam(event, "id") ?? "");
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const action = body?.["action"];
|
||||||
|
if (action === "mute") {
|
||||||
|
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: true });
|
||||||
|
} else if (action === "unmute") {
|
||||||
|
getCoordinator().sendToKiosk(id, { type: "volume-mute", muted: false });
|
||||||
|
} else if (action === "output") {
|
||||||
|
getCoordinator().sendToKiosk(id, { type: "audio-output", output_id: body?.["output_id"] ?? "" });
|
||||||
|
} else {
|
||||||
|
const vol = Math.max(0, Math.min(100, Number(body?.["volume"]) || 0));
|
||||||
|
getCoordinator().sendToKiosk(id, { type: "volume-set", volume: vol });
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
|
// ---- JSON API (admin scope) — used by Node-RED bf-* nodes ---------------
|
||||||
//
|
//
|
||||||
// All payloads run through `stripSecrets` so credential-bearing fields
|
// All payloads run through `stripSecrets` so credential-bearing fields
|
||||||
|
|
|
||||||
|
|
@ -1915,6 +1915,56 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
}}
|
}}
|
||||||
>Full</button>
|
>Full</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0; display:flex; gap:0.5rem; align-items:center">
|
||||||
|
<div style="font-size:0.8rem; font-weight:600">Power</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" style="color:#c00" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/reboot`,
|
||||||
|
"hx-swap": "none",
|
||||||
|
"hx-confirm": "Reboot this kiosk? It will be offline for ~30 seconds.",
|
||||||
|
}}>Reboot</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1rem; padding-top:0.75rem; border-top:1px solid #f0f0f0">
|
||||||
|
<div style="font-size:0.8rem; font-weight:600; margin-bottom:0.5rem">Audio</div>
|
||||||
|
<div style="display:flex; gap:0.5rem; align-items:center; flex-wrap:wrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ volume: "0" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>0%</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ volume: "25" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>25%</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ volume: "50" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>50%</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ volume: "75" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>75%</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ volume: "100" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>100%</button>
|
||||||
|
<span style="color:#999">|</span>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ action: "mute" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>Mute</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-ghost" {...{
|
||||||
|
"hx-post": `/admin/kiosks/${String(k.id)}/volume`,
|
||||||
|
"hx-vals": JSON.stringify({ action: "unmute" }),
|
||||||
|
"hx-swap": "none",
|
||||||
|
}}>Unmute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue