Add native display loop to aster web UI
This commit is contained in:
Generated
+2
@@ -157,6 +157,8 @@ name = "aster-webui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"asterctl",
|
||||
"asterctl-lcd",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
|
||||
@@ -29,6 +29,7 @@ Current scope:
|
||||
- normalize them to AOOSTAR-safe `960x376` JPG files
|
||||
- manage rotation order and switch interval
|
||||
- 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:
|
||||
|
||||
@@ -40,8 +41,10 @@ Important:
|
||||
|
||||
- the current web UI writes compatible `Monitor3.json` and `/config/img/*` assets
|
||||
- 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
|
||||
into a full `aoostar-rs`-based display daemon
|
||||
- the native display loop runs automatically and uses the default AOOSTAR USB UART unless
|
||||
`--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
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
chrono = "0.4"
|
||||
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"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||
|
||||
|
||||
+417
-25
@@ -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 asterctl::img;
|
||||
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::Body,
|
||||
extract::{DefaultBodyLimit, Multipart, Path, State},
|
||||
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, State},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use chrono::Local;
|
||||
use chrono::{Local, Utc};
|
||||
use clap::Parser;
|
||||
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::fs;
|
||||
use tracing::info;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const DISPLAY_WIDTH: u32 = 960;
|
||||
const DISPLAY_HEIGHT: u32 = 376;
|
||||
@@ -27,36 +35,85 @@ struct Cli {
|
||||
bind: String,
|
||||
#[arg(long, default_value = "/config")]
|
||||
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)]
|
||||
struct AppState {
|
||||
monitor_path: 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 {
|
||||
monitor_path: String,
|
||||
image_dir: String,
|
||||
setup: SetupView,
|
||||
active_images: Vec<String>,
|
||||
images: Vec<ImageView>,
|
||||
display: DisplayStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
struct SetupView {
|
||||
custom_panel: bool,
|
||||
switch_time: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Clone, Serialize)]
|
||||
struct ImageView {
|
||||
name: String,
|
||||
size: u64,
|
||||
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)]
|
||||
struct ActivateRequest {
|
||||
images: Vec<String>,
|
||||
@@ -88,12 +145,22 @@ async fn main() -> Result<()> {
|
||||
.parse()
|
||||
.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 {
|
||||
monitor_path: cli.config_dir.join("Monitor3.json"),
|
||||
image_dir: cli.config_dir.join("img"),
|
||||
display_status: Arc::new(RwLock::new(initial_display_status(&display_config))),
|
||||
});
|
||||
|
||||
ensure_layout(&state).await?;
|
||||
spawn_display_worker(state.clone(), display_config);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
@@ -113,6 +180,28 @@ async fn main() -> Result<()> {
|
||||
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<()> {
|
||||
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<()> {
|
||||
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
|
||||
.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 {
|
||||
@@ -172,8 +274,13 @@ async fn index() -> Html<&'static str> {
|
||||
Html(INDEX_HTML)
|
||||
}
|
||||
|
||||
async fn healthz() -> Json<Value> {
|
||||
Json(json!({"ok": true}))
|
||||
async fn healthz(State(state): State<Arc<AppState>>) -> Json<Value> {
|
||||
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 {
|
||||
@@ -313,7 +420,10 @@ async fn api_delete(
|
||||
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('\\') {
|
||||
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));
|
||||
response.headers_mut().insert(
|
||||
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
|
||||
}
|
||||
@@ -363,6 +474,7 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
|
||||
},
|
||||
active_images: current_active_images(&monitor),
|
||||
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)
|
||||
}
|
||||
|
||||
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]) {
|
||||
let setup = monitor
|
||||
.as_object_mut()
|
||||
@@ -482,6 +611,244 @@ fn make_image_name(file_name: &str) -> String {
|
||||
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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -510,8 +877,9 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
}
|
||||
main { width: min(1320px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 36px; }
|
||||
.hero, .content { display: grid; gap: 18px; }
|
||||
.hero { grid-template-columns: 1.5fr 1fr; margin-bottom: 18px; }
|
||||
.hero, .content, .status-grid { display: grid; gap: 18px; }
|
||||
.hero { grid-template-columns: 1.4fr 1fr; margin-bottom: 18px; }
|
||||
.status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.card {
|
||||
background: linear-gradient(180deg, rgba(16,22,30,.98), rgba(19,25,35,.9));
|
||||
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 p, .muted { color: var(--muted); }
|
||||
.status { display: grid; gap: 12px; }
|
||||
.status strong { display: block; margin-top: 8px; font-size: 1.5rem; }
|
||||
.status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
|
||||
.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; }
|
||||
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);
|
||||
}
|
||||
.index { color: var(--accent-2); font-weight: 700; }
|
||||
.meta { display: grid; gap: 4px; }
|
||||
.toast {
|
||||
position: fixed; bottom: 16px; right: 16px; min-width: 260px; max-width: 420px;
|
||||
padding: 14px 16px; border-radius: 14px; border: 1px solid var(--line);
|
||||
background: rgba(10,14,20,.94); display: none;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.hero, .content, .controls { grid-template-columns: 1fr; }
|
||||
.hero, .content, .controls, .status-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -571,7 +939,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
<div class="card">
|
||||
<p class="muted">aoostar-rs fork</p>
|
||||
<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">
|
||||
<label>
|
||||
<span>Switch Time</span>
|
||||
@@ -588,10 +956,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status">
|
||||
<div class="card"><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"><span class="muted">Config Path</span><strong id="configPathValue" style="font-size: 1rem;">-</strong></div>
|
||||
<div class="status-grid">
|
||||
<div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
|
||||
<div class="card status"><span class="muted">Active Images</span><strong id="imageCountValue">-</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>
|
||||
</section>
|
||||
<section class="content">
|
||||
@@ -602,18 +973,28 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
<div class="card">
|
||||
<h2>Rotation Order</h2>
|
||||
<div id="order" class="order"></div>
|
||||
<p id="displayErrorValue" class="muted"></p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<div id="toast" class="toast"></div>
|
||||
<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 = {
|
||||
gallery: document.getElementById("gallery"),
|
||||
order: document.getElementById("order"),
|
||||
switchTime: document.getElementById("switchTime"),
|
||||
customPanelValue: document.getElementById("customPanelValue"),
|
||||
imageCountValue: document.getElementById("imageCountValue"),
|
||||
displayStatusValue: document.getElementById("displayStatusValue"),
|
||||
currentImageValue: document.getElementById("currentImageValue"),
|
||||
displayTargetValue: document.getElementById("displayTargetValue"),
|
||||
displayErrorValue: document.getElementById("displayErrorValue"),
|
||||
configPathValue: document.getElementById("configPathValue"),
|
||||
upload: document.getElementById("upload"),
|
||||
apply: document.getElementById("apply"),
|
||||
@@ -666,9 +1047,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
state.images = data.images;
|
||||
state.activeImages = data.active_images;
|
||||
state.setup = data.setup;
|
||||
state.display = data.display || {};
|
||||
el.switchTime.value = data.setup.switch_time || "10";
|
||||
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
|
||||
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;
|
||||
render();
|
||||
}
|
||||
@@ -746,7 +1134,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
row.className = "order-item";
|
||||
row.innerHTML = `
|
||||
<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">
|
||||
<button class="ghost" type="button">Up</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)));
|
||||
|
||||
load().catch((err) => toast(err.message));
|
||||
setInterval(() => load().catch(() => {}), 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user