Add native display loop to aster web UI
Rust / Clippy, Rustfmt, Tests (push) Has been cancelled
Rust / Linux-x64 build (push) Has been cancelled
Rust / GitHub release (push) Has been cancelled

This commit is contained in:
2026-06-09 16:07:36 +02:00
parent 1e1d37bfc2
commit b48bbd9b2e
4 changed files with 426 additions and 28 deletions
Generated
+2
View File
@@ -157,6 +157,8 @@ name = "aster-webui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"asterctl",
"asterctl-lcd",
"axum", "axum",
"chrono", "chrono",
"clap", "clap",
+5 -2
View File
@@ -29,6 +29,7 @@ Current scope:
- normalize them to AOOSTAR-safe `960x376` JPG files - normalize them to AOOSTAR-safe `960x376` JPG files
- manage rotation order and switch interval - manage rotation order and switch interval
- enable or disable custom panels without the vendor web UI - enable or disable custom panels without the vendor web UI
- drive the AOOSTAR LCD directly through the native `aoostar-rs` serial implementation
Start it from the workspace root: Start it from the workspace root:
@@ -40,8 +41,10 @@ Important:
- the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets - the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets
- animated GIF uploads currently use the first frame only - animated GIF uploads currently use the first frame only
- native screen driving is still handled separately; the next step is wiring the web UI directly - the native display loop runs automatically and uses the default AOOSTAR USB UART unless
into a full `aoostar-rs`-based display daemon `--device`, `--usb`, `--simulate`, or `--disable-display` is specified
- the native path currently targets custom image panels; built-in vendor sensor themes are not yet
reimplemented in the web UI
## Disclaimer ## Disclaimer
+2 -1
View File
@@ -11,6 +11,8 @@ repository.workspace = true
[dependencies] [dependencies]
anyhow = "1.0.98" anyhow = "1.0.98"
asterctl = { path = "../asterctl", version = "0.2.0" }
asterctl-lcd = { path = "../asterctl-lcd", version = "0.2.0" }
axum = { version = "0.8.4", features = ["multipart"] } axum = { version = "0.8.4", features = ["multipart"] }
chrono = "0.4" chrono = "0.4"
clap = { version = "4.5.42", features = ["derive"] } clap = { version = "4.5.42", features = ["derive"] }
@@ -21,4 +23,3 @@ serde_json = "1.0.142"
tokio = { version = "1.47.1", features = ["fs", "macros", "net", "rt-multi-thread"] } tokio = { version = "1.47.1", features = ["fs", "macros", "net", "rt-multi-thread"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] } tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
+417 -25
View File
@@ -1,21 +1,29 @@
use std::{net::SocketAddr, path::PathBuf, sync::Arc}; use std::{
net::SocketAddr,
path::PathBuf,
sync::{Arc, RwLock},
thread,
time::{Duration, Instant},
};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use asterctl::img;
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
use axum::{ use axum::{
Json, Router, Json, Router,
body::Body, body::Body,
extract::{DefaultBodyLimit, Multipart, Path, State}, extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
http::{HeaderValue, StatusCode, header}, http::{HeaderValue, StatusCode, header},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
}; };
use chrono::Local; use chrono::{Local, Utc};
use clap::Parser; use clap::Parser;
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType}; use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::fs; use tokio::fs;
use tracing::info; use tracing::{error, info, warn};
const DISPLAY_WIDTH: u32 = 960; const DISPLAY_WIDTH: u32 = 960;
const DISPLAY_HEIGHT: u32 = 376; const DISPLAY_HEIGHT: u32 = 376;
@@ -27,36 +35,85 @@ struct Cli {
bind: String, bind: String,
#[arg(long, default_value = "/config")] #[arg(long, default_value = "/config")]
config_dir: PathBuf, config_dir: PathBuf,
#[arg(long)]
device: Option<String>,
#[arg(long)]
usb: Option<String>,
#[arg(long)]
simulate: bool,
#[arg(long)]
write_only: bool,
#[arg(long)]
disable_display: bool,
} }
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
monitor_path: PathBuf, monitor_path: PathBuf,
image_dir: PathBuf, image_dir: PathBuf,
display_status: Arc<RwLock<DisplayStatus>>,
} }
#[derive(Serialize)] #[derive(Clone)]
struct DisplayConfig {
device: Option<String>,
usb: Option<String>,
simulate: bool,
write_only: bool,
native_enabled: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RotationSnapshot {
custom_panel: bool,
switch_time: u32,
active_images: Vec<String>,
}
impl RotationSnapshot {
fn rotation_active(&self) -> bool {
self.custom_panel && !self.active_images.is_empty()
}
}
#[derive(Clone, Serialize)]
struct StateResponse { struct StateResponse {
monitor_path: String, monitor_path: String,
image_dir: String, image_dir: String,
setup: SetupView, setup: SetupView,
active_images: Vec<String>, active_images: Vec<String>,
images: Vec<ImageView>, images: Vec<ImageView>,
display: DisplayStatus,
} }
#[derive(Serialize)] #[derive(Clone, Serialize)]
struct SetupView { struct SetupView {
custom_panel: bool, custom_panel: bool,
switch_time: String, switch_time: String,
} }
#[derive(Serialize)] #[derive(Clone, Serialize)]
struct ImageView { struct ImageView {
name: String, name: String,
size: u64, size: u64,
url: String, url: String,
} }
#[derive(Clone, Debug, Default, Serialize)]
struct DisplayStatus {
native_enabled: bool,
connected: bool,
mode: String,
device: String,
custom_panel: bool,
rotation_active: bool,
switch_time: String,
active_images: Vec<String>,
current_image: Option<String>,
last_error: Option<String>,
updated_at: Option<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ActivateRequest { struct ActivateRequest {
images: Vec<String>, images: Vec<String>,
@@ -88,12 +145,22 @@ async fn main() -> Result<()> {
.parse() .parse()
.with_context(|| format!("Invalid bind address: {}", cli.bind))?; .with_context(|| format!("Invalid bind address: {}", cli.bind))?;
let display_config = DisplayConfig {
device: cli.device,
usb: cli.usb,
simulate: cli.simulate,
write_only: cli.write_only,
native_enabled: !cli.disable_display,
};
let state = Arc::new(AppState { let state = Arc::new(AppState {
monitor_path: cli.config_dir.join("Monitor3.json"), monitor_path: cli.config_dir.join("Monitor3.json"),
image_dir: cli.config_dir.join("img"), image_dir: cli.config_dir.join("img"),
display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
}); });
ensure_layout(&state).await?; ensure_layout(&state).await?;
spawn_display_worker(state.clone(), display_config);
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(index))
@@ -113,6 +180,28 @@ async fn main() -> Result<()> {
Ok(()) Ok(())
} }
fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
let mut status = DisplayStatus {
native_enabled: config.native_enabled,
connected: false,
mode: display_mode(config),
device: display_target(config),
custom_panel: false,
rotation_active: false,
switch_time: "10".into(),
active_images: Vec::new(),
current_image: None,
last_error: None,
updated_at: Some(stamp_now()),
};
if !config.native_enabled {
status.last_error = Some("Native display loop disabled via --disable-display".into());
}
status
}
async fn ensure_layout(state: &AppState) -> Result<()> { async fn ensure_layout(state: &AppState) -> Result<()> {
fs::create_dir_all(&state.image_dir).await?; fs::create_dir_all(&state.image_dir).await?;
@@ -156,9 +245,22 @@ async fn load_monitor_json(state: &AppState) -> Result<Value> {
async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> { async fn save_monitor_json(state: &AppState, value: &Value) -> Result<()> {
let payload = serde_json::to_string_pretty(value)?; let payload = serde_json::to_string_pretty(value)?;
fs::write(&state.monitor_path, payload) let tmp_path = temp_monitor_path(&state.monitor_path);
fs::write(&tmp_path, payload)
.await .await
.with_context(|| format!("Failed to write {:?}", state.monitor_path)) .with_context(|| format!("Failed to write {:?}", tmp_path))?;
fs::rename(&tmp_path, &state.monitor_path)
.await
.with_context(|| format!("Failed to replace {:?}", state.monitor_path))
}
fn temp_monitor_path(path: &PathBuf) -> PathBuf {
let file_name = path
.file_name()
.map(|name| format!("{}.tmp", name.to_string_lossy()))
.unwrap_or_else(|| "Monitor3.json.tmp".into());
path.with_file_name(file_name)
} }
fn error_response(status: StatusCode, message: impl Into<String>) -> Response { fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
@@ -172,8 +274,13 @@ async fn index() -> Html<&'static str> {
Html(INDEX_HTML) Html(INDEX_HTML)
} }
async fn healthz() -> Json<Value> { async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
Json(json!({"ok": true})) let display = read_display_status(&state.display_status);
Json(json!({
"ok": true,
"nativeDisplay": display.native_enabled,
"displayConnected": display.connected,
}))
} }
async fn api_state(State(state): State<Arc<AppState>>) -> Response { async fn api_state(State(state): State<Arc<AppState>>) -> Response {
@@ -313,7 +420,10 @@ async fn api_delete(
Json(json!({ "ok": true })).into_response() Json(json!({ "ok": true })).into_response()
} }
async fn api_image(Path(name): Path<String>, State(state): State<Arc<AppState>>) -> Response { async fn api_image(
AxumPath(name): AxumPath<String>,
State(state): State<Arc<AppState>>,
) -> Response {
if name.contains('/') || name.contains('\\') { if name.contains('/') || name.contains('\\') {
return error_response(StatusCode::BAD_REQUEST, "Invalid file name"); return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
} }
@@ -331,7 +441,8 @@ async fn api_image(Path(name): Path<String>, State(state): State<Arc<AppState>>)
let mut response = Response::new(Body::from(bytes)); let mut response = Response::new(Body::from(bytes));
response.headers_mut().insert( response.headers_mut().insert(
header::CONTENT_TYPE, header::CONTENT_TYPE,
HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static("application/octet-stream")), HeaderValue::from_str(mime.as_ref())
.unwrap_or(HeaderValue::from_static("application/octet-stream")),
); );
response response
} }
@@ -363,6 +474,7 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
}, },
active_images: current_active_images(&monitor), active_images: current_active_images(&monitor),
images: list_images(state).await?, images: list_images(state).await?,
display: read_display_status(&state.display_status),
}) })
} }
@@ -411,6 +523,23 @@ fn current_switch_time(monitor: &Value) -> u32 {
.unwrap_or(10) .unwrap_or(10)
} }
fn current_custom_panel_enabled(monitor: &Value) -> bool {
monitor
.get("setup")
.and_then(Value::as_object)
.and_then(|setup| setup.get("customPanel"))
.and_then(Value::as_bool)
.unwrap_or(false)
}
fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
RotationSnapshot {
custom_panel: current_custom_panel_enabled(monitor),
switch_time: current_switch_time(monitor),
active_images: current_active_images(monitor),
}
}
fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) { fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, images: &[String]) {
let setup = monitor let setup = monitor
.as_object_mut() .as_object_mut()
@@ -482,6 +611,244 @@ fn make_image_name(file_name: &str) -> String {
format!("panel-{timestamp}-{clean}.jpg") format!("panel-{timestamp}-{clean}.jpg")
} }
fn spawn_display_worker(state: Arc<AppState>, config: DisplayConfig) {
thread::spawn(move || run_display_worker(state, config));
}
fn run_display_worker(state: Arc<AppState>, config: DisplayConfig) {
if !config.native_enabled {
info!("Native display loop disabled");
return;
}
loop {
match open_screen(&config) {
Ok(mut screen) => {
info!("Display target opened: {}", display_target(&config));
if let Err(err) = screen.init() {
warn!("Display init failed: {err}");
set_display_error(
&state.display_status,
false,
format!("Display init failed: {err}"),
);
thread::sleep(Duration::from_secs(3));
continue;
}
update_display_status(&state.display_status, |status| {
status.connected = true;
status.last_error = None;
status.updated_at = Some(stamp_now());
});
if let Err(err) = run_display_session(&state, &mut screen) {
error!("Display loop error: {err}");
set_display_error(
&state.display_status,
false,
format!("Display loop error: {err}"),
);
}
}
Err(err) => {
warn!("Failed to open display target: {err}");
set_display_error(
&state.display_status,
false,
format!("Failed to open display target: {err}"),
);
}
}
thread::sleep(Duration::from_secs(3));
}
}
fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
let mut builder = AooScreenBuilder::new();
builder.no_init_check(config.write_only);
if config.simulate {
builder.simulate()
} else if let Some(device) = &config.device {
builder.open_device(device)
} else if let Some(usb) = &config.usb {
builder.open_usb_id(usb)
} else {
builder.open_default()
}
}
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
let mut snapshot = RotationSnapshot::default();
let mut next_index = 0usize;
let mut last_switch_at = Instant::now();
let mut force_send = true;
loop {
let monitor = match load_monitor_json_sync(&state.monitor_path) {
Ok(monitor) => monitor,
Err(err) => {
update_display_status(&state.display_status, |status| {
status.last_error = Some(format!("Config read failed: {err}"));
status.connected = true;
status.updated_at = Some(stamp_now());
});
thread::sleep(Duration::from_secs(1));
continue;
}
};
let new_snapshot = rotation_snapshot(&monitor);
if new_snapshot != snapshot {
snapshot = new_snapshot;
next_index = 0;
force_send = true;
}
update_display_status(&state.display_status, |status| {
status.custom_panel = snapshot.custom_panel;
status.rotation_active = snapshot.rotation_active();
status.switch_time = snapshot.switch_time.to_string();
status.active_images = snapshot.active_images.clone();
if !snapshot.rotation_active() {
status.current_image = None;
}
status.connected = true;
status.updated_at = Some(stamp_now());
});
if !snapshot.rotation_active() {
thread::sleep(Duration::from_millis(750));
continue;
}
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
let send_due = force_send || last_switch_at.elapsed() >= switch_after;
if send_due {
let image_name = snapshot.active_images[next_index].clone();
match load_panel_rgb(&state.image_dir, &image_name) {
Ok(rgb_img) => {
screen
.send_image(&rgb_img)
.with_context(|| format!("Failed to send panel {image_name}"))?;
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.last_error = None;
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
Err(err) => {
warn!("Skipping panel {image_name}: {err}");
update_display_status(&state.display_status, |status| {
status.current_image = Some(image_name.clone());
status.last_error =
Some(format!("Panel {image_name} could not be rendered: {err}"));
status.connected = true;
status.updated_at = Some(stamp_now());
});
}
}
last_switch_at = Instant::now();
force_send = false;
next_index = (next_index + 1) % snapshot.active_images.len();
}
thread::sleep(rotation_sleep(snapshot.active_images.len(), switch_after, last_switch_at));
}
}
fn load_monitor_json_sync(path: &PathBuf) -> Result<Value> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&raw).context("Failed to parse Monitor3.json")
}
fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
let path = image_dir.join(image_name);
let image = img::load_image(&path, Some(DISPLAY_SIZE))
.with_context(|| format!("Failed to load panel image {}", path.display()))?;
Ok(image.to_rgb8())
}
fn rotation_sleep(image_count: usize, switch_after: Duration, last_switch_at: Instant) -> Duration {
if image_count <= 1 {
return Duration::from_millis(750);
}
let remaining = switch_after.saturating_sub(last_switch_at.elapsed());
if remaining > Duration::from_millis(750) {
Duration::from_millis(750)
} else if remaining < Duration::from_millis(200) {
Duration::from_millis(200)
} else {
remaining
}
}
fn read_display_status(status: &Arc<RwLock<DisplayStatus>>) -> DisplayStatus {
status
.read()
.expect("display status lock poisoned")
.clone()
}
fn update_display_status(
status: &Arc<RwLock<DisplayStatus>>,
apply: impl FnOnce(&mut DisplayStatus),
) {
let mut guard = status.write().expect("display status lock poisoned");
apply(&mut guard);
}
fn set_display_error(status: &Arc<RwLock<DisplayStatus>>, connected: bool, message: String) {
update_display_status(status, |current| {
current.connected = connected;
current.last_error = Some(message);
current.updated_at = Some(stamp_now());
if !connected {
current.current_image = None;
}
});
}
fn display_mode(config: &DisplayConfig) -> String {
if !config.native_enabled {
"disabled".into()
} else if config.simulate {
"simulate".into()
} else if config.device.is_some() {
"device".into()
} else if config.usb.is_some() {
"usb-id".into()
} else {
"usb-default".into()
}
}
fn display_target(config: &DisplayConfig) -> String {
if !config.native_enabled {
"native loop disabled".into()
} else if config.simulate {
"simulated LCD".into()
} else if let Some(device) = &config.device {
device.clone()
} else if let Some(usb) = &config.usb {
format!("USB {usb}")
} else {
"default AOOSTAR USB UART 0416:90A1".into()
}
}
fn stamp_now() -> String {
Utc::now().to_rfc3339()
}
const INDEX_HTML: &str = r##"<!DOCTYPE html> const INDEX_HTML: &str = r##"<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -510,8 +877,9 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
font-family: "IBM Plex Sans", sans-serif; font-family: "IBM Plex Sans", sans-serif;
} }
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; } main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
.hero, .content { display: grid; gap: 18px; } .hero, .content, .status-grid { display: grid; gap: 18px; }
.hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; } .hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; }
.status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.card { .card {
background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9)); background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -520,8 +888,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
} }
.hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; } .hero h1 { margin: 0 0 8px; font-size: clamp(2rem, 3vw, 3.4rem); line-height: 1; }
.hero p, .muted { color: var(--muted); } .hero p, .muted { color: var(--muted); }
.status { display: grid; gap: 12px; } .status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
.status strong { display: block; margin-top: 8px; font-size: 1.5rem; }
.controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; } .controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; } label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
input, button { input, button {
@@ -555,13 +922,14 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78); border: 1px solid var(--line); border-radius: 18px; padding: 12px 14px; background: rgba(8,12,18,.78);
} }
.index { color: var(--accent-2); font-weight: 700; } .index { color: var(--accent-2); font-weight: 700; }
.meta { display: grid; gap: 4px; }
.toast { .toast {
position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px; position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line); padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
background: rgba(10,14,20,.94); display: none; background: rgba(10,14,20,.94); display: none;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.hero, .content, .controls { grid-template-columns: 1fr; } .hero, .content, .controls, .status-grid { grid-template-columns: 1fr; }
} }
</style> </style>
</head> </head>
@@ -571,7 +939,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card"> <div class="card">
<p class="muted">aoostar-rs fork</p> <p class="muted">aoostar-rs fork</p>
<h1>aster-webui</h1> <h1>aster-webui</h1>
<p>Eigene Rust-WebUI fuer Panelbilder, Rotation und `Monitor3.json`-Steuerung. Uploads werden auf 960x376 JPG normalisiert; animierte GIFs nutzen aktuell nur das erste Frame.</p> <p>Eigene Rust-WebUI mit nativer Display-Logik. Uploads werden auf 960x376 JPG normalisiert; die Rotation laeuft direkt ueber `aoostar-rs` ohne Vendor-Binary.</p>
<div class="controls"> <div class="controls">
<label> <label>
<span>Switch Time</span> <span>Switch Time</span>
@@ -588,10 +956,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
</div> </div>
</div> </div>
<div class="status"> <div class="status-grid">
<div class="card"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div> <div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div> <div class="card status"><span class="muted">Active Images</span><strong id="imageCountValue">-</strong></div>
<div class="card"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div> <div class="card status"><span class="muted">Display Status</span><strong id="displayStatusValue">-</strong></div>
<div class="card status"><span class="muted">Current Frame</span><strong id="currentImageValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Display Target</span><strong id="displayTargetValue" style="font-size: 1rem;">-</strong></div>
<div class="card status"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
</div> </div>
</section> </section>
<section class="content"> <section class="content">
@@ -602,18 +973,28 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card"> <div class="card">
<h2>Rotation Order</h2> <h2>Rotation Order</h2>
<div id="order" class="order"></div> <div id="order" class="order"></div>
<p id="displayErrorValue" class="muted"></p>
</div> </div>
</section> </section>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<script> <script>
const state = { images: [], activeImages: [], setup: { switch_time: "10", custom_panel: false } }; const state = {
images: [],
activeImages: [],
setup: { switch_time: "10", custom_panel: false },
display: {},
};
const el = { const el = {
gallery: document.getElementById("gallery"), gallery: document.getElementById("gallery"),
order: document.getElementById("order"), order: document.getElementById("order"),
switchTime: document.getElementById("switchTime"), switchTime: document.getElementById("switchTime"),
customPanelValue: document.getElementById("customPanelValue"), customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"), imageCountValue: document.getElementById("imageCountValue"),
displayStatusValue: document.getElementById("displayStatusValue"),
currentImageValue: document.getElementById("currentImageValue"),
displayTargetValue: document.getElementById("displayTargetValue"),
displayErrorValue: document.getElementById("displayErrorValue"),
configPathValue: document.getElementById("configPathValue"), configPathValue: document.getElementById("configPathValue"),
upload: document.getElementById("upload"), upload: document.getElementById("upload"),
apply: document.getElementById("apply"), apply: document.getElementById("apply"),
@@ -666,9 +1047,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
state.images = data.images; state.images = data.images;
state.activeImages = data.active_images; state.activeImages = data.active_images;
state.setup = data.setup; state.setup = data.setup;
state.display = data.display || {};
el.switchTime.value = data.setup.switch_time || "10"; el.switchTime.value = data.setup.switch_time || "10";
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled"; el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
el.imageCountValue.textContent = String(data.active_images.length); el.imageCountValue.textContent = String(data.active_images.length);
el.displayStatusValue.textContent = state.display.connected
? `Connected (${state.display.mode || "native"})`
: (state.display.native_enabled ? "Disconnected" : "Disabled");
el.currentImageValue.textContent = state.display.current_image || "-";
el.displayTargetValue.textContent = state.display.device || "-";
el.displayErrorValue.textContent = state.display.last_error || "";
el.configPathValue.textContent = data.monitor_path; el.configPathValue.textContent = data.monitor_path;
render(); render();
} }
@@ -746,7 +1134,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
row.className = "order-item"; row.className = "order-item";
row.innerHTML = ` row.innerHTML = `
<div class="index">${index + 1}</div> <div class="index">${index + 1}</div>
<div>${name}</div> <div class="meta">
<div>${name}</div>
<div class="muted">${state.display.current_image === name ? "Currently shown" : ""}</div>
</div>
<div class="mini"> <div class="mini">
<button class="ghost" type="button">Up</button> <button class="ghost" type="button">Up</button>
<button class="ghost" type="button">Down</button> <button class="ghost" type="button">Down</button>
@@ -767,6 +1158,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message))); el.refresh.addEventListener("click", () => load().catch((err) => toast(err.message)));
load().catch((err) => toast(err.message)); load().catch((err) => toast(err.message));
setInterval(() => load().catch(() => {}), 5000);
</script> </script>
</body> </body>
</html> </html>