Add display toggles for specs and memes
This commit is contained in:
Generated
+3
@@ -156,6 +156,7 @@ dependencies = [
|
||||
name = "aster-webui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"anyhow",
|
||||
"asterctl",
|
||||
"asterctl-lcd",
|
||||
@@ -163,6 +164,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"image",
|
||||
"imageproc",
|
||||
"mime_guess",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -894,6 +896,7 @@ dependencies = [
|
||||
"num",
|
||||
"rand",
|
||||
"rand_distr",
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -10,6 +10,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
|
||||
anyhow = "1.0.98"
|
||||
asterctl = { path = "../asterctl", version = "0.2.0" }
|
||||
asterctl-lcd = { path = "../asterctl-lcd", version = "0.2.0" }
|
||||
@@ -17,6 +18,7 @@ axum = { version = "0.8.4", features = ["multipart"] }
|
||||
chrono = "0.4"
|
||||
clap = { version = "4.5.42", features = ["derive"] }
|
||||
image = "0.25.6"
|
||||
imageproc = "0.25.0"
|
||||
mime_guess = "2.0.5"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.142"
|
||||
|
||||
+394
-48
@@ -1,11 +1,12 @@
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
sync::{Arc, RwLock},
|
||||
sync::{Arc, OnceLock, RwLock},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ab_glyph::{FontArc, PxScale};
|
||||
use anyhow::{Context, Result};
|
||||
use asterctl::img;
|
||||
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||
@@ -19,7 +20,11 @@ use axum::{
|
||||
};
|
||||
use chrono::{Local, Utc};
|
||||
use clap::Parser;
|
||||
use image::{DynamicImage, ImageFormat, RgbImage, imageops::FilterType};
|
||||
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, imageops::FilterType};
|
||||
use imageproc::{
|
||||
drawing::{draw_filled_rect_mut, draw_hollow_rect_mut, draw_text_mut},
|
||||
rect::Rect,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sysinfo::{Components, Disks, System};
|
||||
@@ -28,6 +33,7 @@ use tracing::{error, info, warn};
|
||||
|
||||
const DISPLAY_WIDTH: u32 = 960;
|
||||
const DISPLAY_HEIGHT: u32 = 376;
|
||||
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about)]
|
||||
@@ -68,13 +74,32 @@ struct DisplayConfig {
|
||||
struct RotationSnapshot {
|
||||
custom_panel: bool,
|
||||
switch_time: u32,
|
||||
specs_enabled: bool,
|
||||
memes_enabled: bool,
|
||||
active_images: Vec<String>,
|
||||
}
|
||||
|
||||
impl RotationSnapshot {
|
||||
fn rotation_active(&self) -> bool {
|
||||
self.custom_panel && !self.active_images.is_empty()
|
||||
self.custom_panel && !self.frames().is_empty()
|
||||
}
|
||||
|
||||
fn frames(&self) -> Vec<RotationFrame> {
|
||||
let mut frames = Vec::new();
|
||||
if self.memes_enabled {
|
||||
frames.extend(self.active_images.iter().cloned().map(RotationFrame::Image));
|
||||
}
|
||||
if frames.is_empty() && self.specs_enabled {
|
||||
frames.push(RotationFrame::SystemSpecs);
|
||||
}
|
||||
frames
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum RotationFrame {
|
||||
SystemSpecs,
|
||||
Image(String),
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -92,6 +117,8 @@ struct StateResponse {
|
||||
struct SetupView {
|
||||
custom_panel: bool,
|
||||
switch_time: String,
|
||||
specs_enabled: bool,
|
||||
memes_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
@@ -108,6 +135,8 @@ struct DisplayStatus {
|
||||
mode: String,
|
||||
device: String,
|
||||
custom_panel: bool,
|
||||
specs_enabled: bool,
|
||||
memes_enabled: bool,
|
||||
rotation_active: bool,
|
||||
switch_time: String,
|
||||
active_images: Vec<String>,
|
||||
@@ -142,6 +171,8 @@ struct SystemView {
|
||||
struct ActivateRequest {
|
||||
images: Vec<String>,
|
||||
switch_time: Option<u32>,
|
||||
specs_enabled: Option<bool>,
|
||||
memes_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -211,6 +242,8 @@ fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
|
||||
mode: display_mode(config),
|
||||
device: display_target(config),
|
||||
custom_panel: false,
|
||||
specs_enabled: false,
|
||||
memes_enabled: false,
|
||||
rotation_active: false,
|
||||
switch_time: "10".into(),
|
||||
active_images: Vec::new(),
|
||||
@@ -246,6 +279,8 @@ async fn ensure_layout(state: &AppState) -> Result<()> {
|
||||
"customPanel": false,
|
||||
"language": 1,
|
||||
"switchTime": "10",
|
||||
"nativeSpecs": false,
|
||||
"nativeMemes": true,
|
||||
"operationMode": 0,
|
||||
"theme": 1,
|
||||
"diskUpdate": 300,
|
||||
@@ -350,6 +385,7 @@ async fn api_activate(
|
||||
Json(payload): Json<ActivateRequest>,
|
||||
) -> Response {
|
||||
let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600);
|
||||
let specs_enabled = payload.specs_enabled.unwrap_or(false);
|
||||
let available = match list_images(&state).await {
|
||||
Ok(items) => items,
|
||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||
@@ -363,8 +399,15 @@ async fn api_activate(
|
||||
}
|
||||
}
|
||||
|
||||
if valid.is_empty() {
|
||||
return error_response(StatusCode::BAD_REQUEST, "No valid images selected");
|
||||
let memes_enabled = payload
|
||||
.memes_enabled
|
||||
.unwrap_or(!valid.is_empty());
|
||||
|
||||
if !specs_enabled && (!memes_enabled || valid.is_empty()) {
|
||||
return error_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Enable specs or select at least one meme image",
|
||||
);
|
||||
}
|
||||
|
||||
let mut monitor = match load_monitor_json(&state).await {
|
||||
@@ -372,7 +415,7 @@ async fn api_activate(
|
||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||
};
|
||||
|
||||
set_custom_panels(&mut monitor, true, switch_time, &valid);
|
||||
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &valid);
|
||||
|
||||
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
@@ -381,6 +424,8 @@ async fn api_activate(
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"switchTime": switch_time,
|
||||
"specsEnabled": specs_enabled,
|
||||
"memesEnabled": memes_enabled,
|
||||
"activeImages": valid,
|
||||
}))
|
||||
.into_response()
|
||||
@@ -391,10 +436,10 @@ async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
|
||||
Ok(value) => value,
|
||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||
};
|
||||
let switch_time = current_switch_time(&monitor);
|
||||
let active_images = current_active_images(&monitor);
|
||||
|
||||
if let Some(setup) = monitor.get_mut("setup").and_then(Value::as_object_mut) {
|
||||
setup.insert("customPanel".into(), Value::Bool(false));
|
||||
}
|
||||
set_custom_panels(&mut monitor, switch_time, false, false, &active_images);
|
||||
|
||||
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
@@ -427,15 +472,12 @@ async fn api_delete(
|
||||
|
||||
let mut active_images = current_active_images(&monitor);
|
||||
let switch_time = current_switch_time(&monitor);
|
||||
let specs_enabled = current_specs_enabled(&monitor);
|
||||
let memes_enabled = current_memes_enabled(&monitor);
|
||||
let before = active_images.len();
|
||||
active_images.retain(|name| name != &payload.name);
|
||||
if active_images.len() != before {
|
||||
set_custom_panels(
|
||||
&mut monitor,
|
||||
!active_images.is_empty(),
|
||||
switch_time,
|
||||
&active_images,
|
||||
);
|
||||
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images);
|
||||
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
@@ -488,6 +530,8 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("10")
|
||||
.to_string();
|
||||
let specs_enabled = current_specs_enabled(&monitor);
|
||||
let memes_enabled = current_memes_enabled(&monitor);
|
||||
|
||||
Ok(StateResponse {
|
||||
monitor_path: state.monitor_path.display().to_string(),
|
||||
@@ -495,6 +539,8 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
|
||||
setup: SetupView {
|
||||
custom_panel,
|
||||
switch_time,
|
||||
specs_enabled,
|
||||
memes_enabled,
|
||||
},
|
||||
active_images: current_active_images(&monitor),
|
||||
images: list_images(state).await?,
|
||||
@@ -653,15 +699,42 @@ fn current_custom_panel_enabled(monitor: &Value) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn current_specs_enabled(monitor: &Value) -> bool {
|
||||
monitor
|
||||
.get("setup")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|setup| setup.get("nativeSpecs"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn current_memes_enabled(monitor: &Value) -> bool {
|
||||
monitor
|
||||
.get("setup")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|setup| setup.get("nativeMemes"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or_else(|| !current_active_images(monitor).is_empty())
|
||||
}
|
||||
|
||||
fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
|
||||
RotationSnapshot {
|
||||
custom_panel: current_custom_panel_enabled(monitor),
|
||||
switch_time: current_switch_time(monitor),
|
||||
specs_enabled: current_specs_enabled(monitor),
|
||||
memes_enabled: current_memes_enabled(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,
|
||||
switch_time: u32,
|
||||
specs_enabled: bool,
|
||||
memes_enabled: bool,
|
||||
images: &[String],
|
||||
) {
|
||||
let enabled = specs_enabled || (memes_enabled && !images.is_empty());
|
||||
let setup = monitor
|
||||
.as_object_mut()
|
||||
.expect("monitor config must be an object")
|
||||
@@ -671,6 +744,8 @@ fn set_custom_panels(monitor: &mut Value, enabled: bool, switch_time: u32, image
|
||||
if let Some(setup) = setup.as_object_mut() {
|
||||
setup.insert("customPanel".into(), Value::Bool(enabled));
|
||||
setup.insert("switchTime".into(), Value::String(switch_time.to_string()));
|
||||
setup.insert("nativeSpecs".into(), Value::Bool(specs_enabled));
|
||||
setup.insert("nativeMemes".into(), Value::Bool(memes_enabled));
|
||||
}
|
||||
|
||||
monitor["mianban"] = Value::Array(
|
||||
@@ -714,6 +789,200 @@ fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
||||
canvas
|
||||
}
|
||||
|
||||
fn render_system_panel() -> RgbImage {
|
||||
let system = collect_system_view();
|
||||
let mut canvas = RgbImage::from_pixel(DISPLAY_WIDTH, DISPLAY_HEIGHT, Rgb([10, 14, 18]));
|
||||
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
|
||||
let timestamp = Local::now().format("%d.%m.%Y %H:%M:%S").to_string();
|
||||
|
||||
draw_filled_rect_mut(
|
||||
&mut canvas,
|
||||
Rect::at(0, 0).of_size(DISPLAY_WIDTH, 84),
|
||||
Rgb([18, 27, 36]),
|
||||
);
|
||||
draw_filled_rect_mut(
|
||||
&mut canvas,
|
||||
Rect::at(0, 84).of_size(DISPLAY_WIDTH, DISPLAY_HEIGHT - 84),
|
||||
Rgb([8, 11, 15]),
|
||||
);
|
||||
|
||||
draw_text_line(
|
||||
&mut canvas,
|
||||
Rgb([216, 253, 114]),
|
||||
28,
|
||||
20,
|
||||
36.0,
|
||||
"AOOSTAR Native Specs",
|
||||
);
|
||||
draw_text_line(
|
||||
&mut canvas,
|
||||
Rgb([146, 163, 181]),
|
||||
30,
|
||||
58,
|
||||
20.0,
|
||||
&format!("{host_name} | {timestamp}"),
|
||||
);
|
||||
|
||||
let cards = [
|
||||
(
|
||||
24,
|
||||
106,
|
||||
"CPU",
|
||||
format!("{} %", system.cpu_usage_percent),
|
||||
format!(
|
||||
"Load {} / {} / {}",
|
||||
system.load_avg_one, system.load_avg_five, system.load_avg_fifteen
|
||||
),
|
||||
Rgb([124, 231, 191]),
|
||||
),
|
||||
(
|
||||
332,
|
||||
106,
|
||||
"Memory",
|
||||
format!("{} %", system.mem_usage_percent),
|
||||
format!("{} / {}", system.mem_used, system.mem_total),
|
||||
Rgb([125, 196, 255]),
|
||||
),
|
||||
(
|
||||
640,
|
||||
106,
|
||||
"Storage",
|
||||
format!("{} %", system.disk_usage_percent),
|
||||
format!("{} / {}", system.disk_used, system.disk_total),
|
||||
Rgb([255, 199, 115]),
|
||||
),
|
||||
(
|
||||
24,
|
||||
236,
|
||||
"Swap",
|
||||
format!("{} %", system.swap_usage_percent),
|
||||
format!("{} / {}", system.swap_used, system.swap_total),
|
||||
Rgb([194, 167, 255]),
|
||||
),
|
||||
(
|
||||
332,
|
||||
236,
|
||||
"Temperatures",
|
||||
format!("CPU {}", system.temperature_cpu.as_deref().unwrap_or("-")),
|
||||
format!("GPU {}", system.temperature_gpu.as_deref().unwrap_or("-")),
|
||||
Rgb([255, 127, 115]),
|
||||
),
|
||||
(
|
||||
640,
|
||||
236,
|
||||
"System",
|
||||
system.uptime.clone(),
|
||||
format!("{} CPU / {} proc", system.cpu_count, system.process_count),
|
||||
Rgb([216, 253, 114]),
|
||||
),
|
||||
];
|
||||
|
||||
for (x, y, title, value, meta, accent) in cards {
|
||||
draw_metric_card(&mut canvas, x, y, title, &value, &meta, accent);
|
||||
}
|
||||
|
||||
canvas
|
||||
}
|
||||
|
||||
fn overlay_system_specs(canvas: &mut RgbImage) {
|
||||
let system = collect_system_view();
|
||||
let host_name = System::host_name().unwrap_or_else(|| "Unraid".into());
|
||||
let timestamp = Local::now().format("%H:%M:%S").to_string();
|
||||
|
||||
draw_filled_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([12, 18, 24]));
|
||||
draw_hollow_rect_mut(canvas, Rect::at(18, 16).of_size(924, 54), Rgb([36, 50, 67]));
|
||||
draw_text_line(
|
||||
canvas,
|
||||
Rgb([216, 253, 114]),
|
||||
34,
|
||||
28,
|
||||
22.0,
|
||||
&format!("{host_name} CPU {}% RAM {}% Disk {}%", system.cpu_usage_percent, system.mem_usage_percent, system.disk_usage_percent),
|
||||
);
|
||||
draw_text_line(
|
||||
canvas,
|
||||
Rgb([146, 163, 181]),
|
||||
34,
|
||||
52,
|
||||
16.0,
|
||||
&format!(
|
||||
"Load {} / {} / {} Temp {} / {} Uptime {} {}",
|
||||
system.load_avg_one,
|
||||
system.load_avg_five,
|
||||
system.load_avg_fifteen,
|
||||
system.temperature_cpu.as_deref().unwrap_or("-"),
|
||||
system.temperature_gpu.as_deref().unwrap_or("-"),
|
||||
system.uptime,
|
||||
timestamp
|
||||
),
|
||||
);
|
||||
|
||||
draw_filled_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([12, 18, 24]));
|
||||
draw_hollow_rect_mut(canvas, Rect::at(18, 306).of_size(924, 52), Rgb([36, 50, 67]));
|
||||
draw_text_line(
|
||||
canvas,
|
||||
Rgb([237, 242, 247]),
|
||||
34,
|
||||
320,
|
||||
18.0,
|
||||
&format!(
|
||||
"Mem {} / {} Swap {} / {} Proc {} CPU {} GPU {}",
|
||||
system.mem_used,
|
||||
system.mem_total,
|
||||
system.swap_used,
|
||||
system.swap_total,
|
||||
system.process_count,
|
||||
system.temperature_cpu.as_deref().unwrap_or("-"),
|
||||
system.temperature_gpu.as_deref().unwrap_or("-"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_metric_card(
|
||||
canvas: &mut RgbImage,
|
||||
x: i32,
|
||||
y: i32,
|
||||
title: &str,
|
||||
value: &str,
|
||||
meta: &str,
|
||||
accent: Rgb<u8>,
|
||||
) {
|
||||
draw_filled_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([16, 22, 30]));
|
||||
draw_hollow_rect_mut(canvas, Rect::at(x, y).of_size(284, 112), Rgb([36, 50, 67]));
|
||||
draw_filled_rect_mut(canvas, Rect::at(x + 1, y + 1).of_size(6, 110), accent);
|
||||
|
||||
draw_text_line(canvas, accent, x + 20, y + 16, 19.0, title);
|
||||
draw_text_line(canvas, Rgb([237, 242, 247]), x + 20, y + 44, 28.0, value);
|
||||
draw_text_line(canvas, Rgb([146, 163, 181]), x + 20, y + 82, 18.0, meta);
|
||||
}
|
||||
|
||||
fn draw_text_line(
|
||||
canvas: &mut RgbImage,
|
||||
color: Rgb<u8>,
|
||||
x: i32,
|
||||
y: i32,
|
||||
size: f32,
|
||||
text: &str,
|
||||
) {
|
||||
draw_text_mut(
|
||||
canvas,
|
||||
color,
|
||||
x,
|
||||
y,
|
||||
PxScale { x: size, y: size },
|
||||
display_font(),
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
fn display_font() -> &'static FontArc {
|
||||
static FONT: OnceLock<FontArc> = OnceLock::new();
|
||||
FONT.get_or_init(|| {
|
||||
FontArc::try_from_slice(include_bytes!("../../../fonts/DejaVuSans.ttf"))
|
||||
.expect("embedded display font must be valid")
|
||||
})
|
||||
}
|
||||
|
||||
fn make_image_name(file_name: &str) -> String {
|
||||
let base = file_name
|
||||
.rsplit_once('.')
|
||||
@@ -803,9 +1072,8 @@ fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
|
||||
|
||||
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;
|
||||
let mut cycle_started_at = Instant::now();
|
||||
let mut current_frame_index = None;
|
||||
|
||||
loop {
|
||||
let monitor = match load_monitor_json_sync(&state.monitor_path) {
|
||||
@@ -824,12 +1092,16 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
||||
let new_snapshot = rotation_snapshot(&monitor);
|
||||
if new_snapshot != snapshot {
|
||||
snapshot = new_snapshot;
|
||||
next_index = 0;
|
||||
force_send = true;
|
||||
cycle_started_at = Instant::now();
|
||||
current_frame_index = None;
|
||||
}
|
||||
|
||||
let frames = snapshot.frames();
|
||||
|
||||
update_display_status(&state.display_status, |status| {
|
||||
status.custom_panel = snapshot.custom_panel;
|
||||
status.specs_enabled = snapshot.specs_enabled;
|
||||
status.memes_enabled = snapshot.memes_enabled;
|
||||
status.rotation_active = snapshot.rotation_active();
|
||||
status.switch_time = snapshot.switch_time.to_string();
|
||||
status.active_images = snapshot.active_images.clone();
|
||||
@@ -846,41 +1118,61 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
||||
}
|
||||
|
||||
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
|
||||
let send_due = force_send || last_switch_at.elapsed() >= switch_after;
|
||||
let elapsed = cycle_started_at.elapsed();
|
||||
let frame_slot = elapsed.as_secs() / snapshot.switch_time as u64;
|
||||
let next_index = (frame_slot as usize) % frames.len();
|
||||
let send_due = current_frame_index != Some(next_index);
|
||||
|
||||
if send_due {
|
||||
let image_name = snapshot.active_images[next_index].clone();
|
||||
match load_panel_rgb(&state.image_dir, &image_name) {
|
||||
Ok(rgb_img) => {
|
||||
let frame = frames[next_index].clone();
|
||||
match frame {
|
||||
RotationFrame::SystemSpecs => {
|
||||
let panel_name = SYSTEM_FRAME_NAME.to_string();
|
||||
let rgb_img = render_system_panel();
|
||||
screen
|
||||
.send_image(&rgb_img)
|
||||
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
||||
.context("Failed to send system specs panel")?;
|
||||
|
||||
update_display_status(&state.display_status, |status| {
|
||||
status.current_image = Some(image_name.clone());
|
||||
status.current_image = Some(panel_name);
|
||||
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());
|
||||
});
|
||||
}
|
||||
}
|
||||
RotationFrame::Image(image_name) => match load_panel_rgb(&state.image_dir, &image_name)
|
||||
{
|
||||
Ok(mut rgb_img) => {
|
||||
if snapshot.specs_enabled {
|
||||
overlay_system_specs(&mut rgb_img);
|
||||
}
|
||||
screen
|
||||
.send_image(&rgb_img)
|
||||
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
||||
|
||||
last_switch_at = Instant::now();
|
||||
force_send = false;
|
||||
next_index = (next_index + 1) % snapshot.active_images.len();
|
||||
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());
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
current_frame_index = Some(next_index);
|
||||
}
|
||||
|
||||
thread::sleep(rotation_sleep(snapshot.active_images.len(), switch_after, last_switch_at));
|
||||
thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,12 +1189,15 @@ fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
|
||||
Ok(image.to_rgb8())
|
||||
}
|
||||
|
||||
fn rotation_sleep(image_count: usize, switch_after: Duration, last_switch_at: Instant) -> Duration {
|
||||
if image_count <= 1 {
|
||||
fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
|
||||
if frame_count <= 1 {
|
||||
return Duration::from_millis(750);
|
||||
}
|
||||
|
||||
let remaining = switch_after.saturating_sub(last_switch_at.elapsed());
|
||||
let elapsed = cycle_started_at.elapsed();
|
||||
let switch_secs = switch_after.as_secs().max(1);
|
||||
let next_boundary_secs = ((elapsed.as_secs() / switch_secs) + 1) * switch_secs;
|
||||
let remaining = Duration::from_secs(next_boundary_secs).saturating_sub(elapsed);
|
||||
if remaining > Duration::from_millis(750) {
|
||||
Duration::from_millis(750)
|
||||
} else if remaining < Duration::from_millis(200) {
|
||||
@@ -1012,6 +1307,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
.status strong { display: block; margin-top: 8px; font-size: 1.3rem; }
|
||||
.status small { display: block; margin-top: 6px; color: var(--muted); }
|
||||
.controls { display: grid; grid-template-columns: 180px 1fr auto; gap: 12px; align-items: end; margin-top: 16px; }
|
||||
.toggle-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
|
||||
.toggle-pill {
|
||||
display: inline-flex; align-items: center; gap: 10px; padding: 10px 14px;
|
||||
border: 1px solid var(--line); border-radius: 999px; background: rgba(9,14,20,.7);
|
||||
color: var(--text); font-size: .95rem;
|
||||
}
|
||||
.toggle-pill input { margin: 0; width: 18px; height: 18px; accent-color: var(--accent); }
|
||||
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
|
||||
input, button {
|
||||
border-radius: 14px;
|
||||
@@ -1078,6 +1380,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
<button id="refresh" class="ghost" type="button">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs on LCD</label>
|
||||
<label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes on LCD</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-grid">
|
||||
<div class="card status"><span class="muted">Custom Panel</span><strong id="customPanelValue">-</strong></div>
|
||||
@@ -1113,7 +1419,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
const state = {
|
||||
images: [],
|
||||
activeImages: [],
|
||||
setup: { switch_time: "10", custom_panel: false },
|
||||
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false },
|
||||
display: {},
|
||||
system: {},
|
||||
};
|
||||
@@ -1121,6 +1427,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
gallery: document.getElementById("gallery"),
|
||||
order: document.getElementById("order"),
|
||||
switchTime: document.getElementById("switchTime"),
|
||||
specsEnabled: document.getElementById("specsEnabled"),
|
||||
memesEnabled: document.getElementById("memesEnabled"),
|
||||
customPanelValue: document.getElementById("customPanelValue"),
|
||||
imageCountValue: document.getElementById("imageCountValue"),
|
||||
displayStatusValue: document.getElementById("displayStatusValue"),
|
||||
@@ -1194,7 +1502,15 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
state.display = data.display || {};
|
||||
state.system = data.system || {};
|
||||
el.switchTime.value = data.setup.switch_time || "10";
|
||||
el.customPanelValue.textContent = data.setup.custom_panel ? "Active" : "Disabled";
|
||||
el.specsEnabled.checked = !!data.setup.specs_enabled;
|
||||
el.memesEnabled.checked = !!data.setup.memes_enabled;
|
||||
const enabledModes = [
|
||||
data.setup.specs_enabled ? "Specs" : "",
|
||||
data.setup.memes_enabled ? "Memes" : "",
|
||||
].filter(Boolean);
|
||||
el.customPanelValue.textContent = data.setup.custom_panel
|
||||
? (enabledModes.join(" + ") || "Active")
|
||||
: "Disabled";
|
||||
el.imageCountValue.textContent = String(data.active_images.length);
|
||||
el.displayStatusValue.textContent = state.display.connected
|
||||
? `Connected (${state.display.mode || "native"})`
|
||||
@@ -1236,6 +1552,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
body: JSON.stringify({
|
||||
images: state.activeImages,
|
||||
switch_time: Number.parseInt(el.switchTime.value || "10", 10),
|
||||
specs_enabled: el.specsEnabled.checked,
|
||||
memes_enabled: el.memesEnabled.checked,
|
||||
}),
|
||||
});
|
||||
toast("Rotation updated");
|
||||
@@ -1282,7 +1600,35 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||
}
|
||||
|
||||
el.order.innerHTML = "";
|
||||
if (!state.activeImages.length) {
|
||||
if (state.setup.specs_enabled && !state.activeImages.length) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "order-item";
|
||||
row.innerHTML = `
|
||||
<div class="index">S</div>
|
||||
<div class="meta">
|
||||
<div>System Specs</div>
|
||||
<div class="muted">${state.display.current_image === "System Specs" ? "Currently shown" : "Fixed first frame when enabled"}</div>
|
||||
</div>
|
||||
<div class="mini"><span class="muted">Toggle above</span></div>
|
||||
`;
|
||||
el.order.appendChild(row);
|
||||
}
|
||||
|
||||
if (state.setup.specs_enabled && state.activeImages.length) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "order-item";
|
||||
row.innerHTML = `
|
||||
<div class="index">S</div>
|
||||
<div class="meta">
|
||||
<div>System Specs Overlay</div>
|
||||
<div class="muted">Live stats are drawn over every meme while enabled.</div>
|
||||
</div>
|
||||
<div class="mini"><span class="muted">Toggle above</span></div>
|
||||
`;
|
||||
el.order.appendChild(row);
|
||||
}
|
||||
|
||||
if (!state.activeImages.length && !state.setup.specs_enabled) {
|
||||
el.order.innerHTML = `<p class="muted">No active images.</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user