Add display toggles for specs and memes
This commit is contained in:
Generated
+3
@@ -156,6 +156,7 @@ dependencies = [
|
|||||||
name = "aster-webui"
|
name = "aster-webui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ab_glyph",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"asterctl",
|
"asterctl",
|
||||||
"asterctl-lcd",
|
"asterctl-lcd",
|
||||||
@@ -163,6 +164,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"image",
|
"image",
|
||||||
|
"imageproc",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -894,6 +896,7 @@ dependencies = [
|
|||||||
"num",
|
"num",
|
||||||
"rand",
|
"rand",
|
||||||
"rand_distr",
|
"rand_distr",
|
||||||
|
"rayon",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ license.workspace = true
|
|||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ab_glyph = { version = "0.2.31", default-features = false, features = ["std"] }
|
||||||
anyhow = "1.0.98"
|
anyhow = "1.0.98"
|
||||||
asterctl = { path = "../asterctl", version = "0.2.0" }
|
asterctl = { path = "../asterctl", version = "0.2.0" }
|
||||||
asterctl-lcd = { path = "../asterctl-lcd", 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"
|
chrono = "0.4"
|
||||||
clap = { version = "4.5.42", features = ["derive"] }
|
clap = { version = "4.5.42", features = ["derive"] }
|
||||||
image = "0.25.6"
|
image = "0.25.6"
|
||||||
|
imageproc = "0.25.0"
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.142"
|
serde_json = "1.0.142"
|
||||||
|
|||||||
+383
-37
@@ -1,11 +1,12 @@
|
|||||||
use std::{
|
use std::{
|
||||||
net::SocketAddr,
|
net::SocketAddr,
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
sync::{Arc, RwLock},
|
sync::{Arc, OnceLock, RwLock},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use ab_glyph::{FontArc, PxScale};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use asterctl::img;
|
use asterctl::img;
|
||||||
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
use asterctl_lcd::{AooScreen, AooScreenBuilder, DISPLAY_SIZE};
|
||||||
@@ -19,7 +20,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use chrono::{Local, Utc};
|
use chrono::{Local, Utc};
|
||||||
use clap::Parser;
|
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::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sysinfo::{Components, Disks, System};
|
use sysinfo::{Components, Disks, System};
|
||||||
@@ -28,6 +33,7 @@ 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;
|
||||||
|
const SYSTEM_FRAME_NAME: &str = "System Specs";
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(author, version, about)]
|
#[command(author, version, about)]
|
||||||
@@ -68,13 +74,32 @@ struct DisplayConfig {
|
|||||||
struct RotationSnapshot {
|
struct RotationSnapshot {
|
||||||
custom_panel: bool,
|
custom_panel: bool,
|
||||||
switch_time: u32,
|
switch_time: u32,
|
||||||
|
specs_enabled: bool,
|
||||||
|
memes_enabled: bool,
|
||||||
active_images: Vec<String>,
|
active_images: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RotationSnapshot {
|
impl RotationSnapshot {
|
||||||
fn rotation_active(&self) -> bool {
|
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)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -92,6 +117,8 @@ struct StateResponse {
|
|||||||
struct SetupView {
|
struct SetupView {
|
||||||
custom_panel: bool,
|
custom_panel: bool,
|
||||||
switch_time: String,
|
switch_time: String,
|
||||||
|
specs_enabled: bool,
|
||||||
|
memes_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
@@ -108,6 +135,8 @@ struct DisplayStatus {
|
|||||||
mode: String,
|
mode: String,
|
||||||
device: String,
|
device: String,
|
||||||
custom_panel: bool,
|
custom_panel: bool,
|
||||||
|
specs_enabled: bool,
|
||||||
|
memes_enabled: bool,
|
||||||
rotation_active: bool,
|
rotation_active: bool,
|
||||||
switch_time: String,
|
switch_time: String,
|
||||||
active_images: Vec<String>,
|
active_images: Vec<String>,
|
||||||
@@ -142,6 +171,8 @@ struct SystemView {
|
|||||||
struct ActivateRequest {
|
struct ActivateRequest {
|
||||||
images: Vec<String>,
|
images: Vec<String>,
|
||||||
switch_time: Option<u32>,
|
switch_time: Option<u32>,
|
||||||
|
specs_enabled: Option<bool>,
|
||||||
|
memes_enabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -211,6 +242,8 @@ fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
|
|||||||
mode: display_mode(config),
|
mode: display_mode(config),
|
||||||
device: display_target(config),
|
device: display_target(config),
|
||||||
custom_panel: false,
|
custom_panel: false,
|
||||||
|
specs_enabled: false,
|
||||||
|
memes_enabled: false,
|
||||||
rotation_active: false,
|
rotation_active: false,
|
||||||
switch_time: "10".into(),
|
switch_time: "10".into(),
|
||||||
active_images: Vec::new(),
|
active_images: Vec::new(),
|
||||||
@@ -246,6 +279,8 @@ async fn ensure_layout(state: &AppState) -> Result<()> {
|
|||||||
"customPanel": false,
|
"customPanel": false,
|
||||||
"language": 1,
|
"language": 1,
|
||||||
"switchTime": "10",
|
"switchTime": "10",
|
||||||
|
"nativeSpecs": false,
|
||||||
|
"nativeMemes": true,
|
||||||
"operationMode": 0,
|
"operationMode": 0,
|
||||||
"theme": 1,
|
"theme": 1,
|
||||||
"diskUpdate": 300,
|
"diskUpdate": 300,
|
||||||
@@ -350,6 +385,7 @@ async fn api_activate(
|
|||||||
Json(payload): Json<ActivateRequest>,
|
Json(payload): Json<ActivateRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let switch_time = payload.switch_time.unwrap_or(10).clamp(1, 600);
|
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 {
|
let available = match list_images(&state).await {
|
||||||
Ok(items) => items,
|
Ok(items) => items,
|
||||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
||||||
@@ -363,8 +399,15 @@ async fn api_activate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if valid.is_empty() {
|
let memes_enabled = payload
|
||||||
return error_response(StatusCode::BAD_REQUEST, "No valid images selected");
|
.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 {
|
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()),
|
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 {
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||||
@@ -381,6 +424,8 @@ async fn api_activate(
|
|||||||
Json(json!({
|
Json(json!({
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"switchTime": switch_time,
|
"switchTime": switch_time,
|
||||||
|
"specsEnabled": specs_enabled,
|
||||||
|
"memesEnabled": memes_enabled,
|
||||||
"activeImages": valid,
|
"activeImages": valid,
|
||||||
}))
|
}))
|
||||||
.into_response()
|
.into_response()
|
||||||
@@ -391,10 +436,10 @@ async fn api_disable(State(state): State<Arc<AppState>>) -> Response {
|
|||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()),
|
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) {
|
set_custom_panels(&mut monitor, switch_time, false, false, &active_images);
|
||||||
setup.insert("customPanel".into(), Value::Bool(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
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 mut active_images = current_active_images(&monitor);
|
||||||
let switch_time = current_switch_time(&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();
|
let before = active_images.len();
|
||||||
active_images.retain(|name| name != &payload.name);
|
active_images.retain(|name| name != &payload.name);
|
||||||
if active_images.len() != before {
|
if active_images.len() != before {
|
||||||
set_custom_panels(
|
set_custom_panels(&mut monitor, switch_time, specs_enabled, memes_enabled, &active_images);
|
||||||
&mut monitor,
|
|
||||||
!active_images.is_empty(),
|
|
||||||
switch_time,
|
|
||||||
&active_images,
|
|
||||||
);
|
|
||||||
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
if let Err(err) = save_monitor_json(&state, &monitor).await {
|
||||||
return error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
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)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("10")
|
.unwrap_or("10")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
let specs_enabled = current_specs_enabled(&monitor);
|
||||||
|
let memes_enabled = current_memes_enabled(&monitor);
|
||||||
|
|
||||||
Ok(StateResponse {
|
Ok(StateResponse {
|
||||||
monitor_path: state.monitor_path.display().to_string(),
|
monitor_path: state.monitor_path.display().to_string(),
|
||||||
@@ -495,6 +539,8 @@ async fn build_state_response(state: &AppState) -> Result<StateResponse> {
|
|||||||
setup: SetupView {
|
setup: SetupView {
|
||||||
custom_panel,
|
custom_panel,
|
||||||
switch_time,
|
switch_time,
|
||||||
|
specs_enabled,
|
||||||
|
memes_enabled,
|
||||||
},
|
},
|
||||||
active_images: current_active_images(&monitor),
|
active_images: current_active_images(&monitor),
|
||||||
images: list_images(state).await?,
|
images: list_images(state).await?,
|
||||||
@@ -653,15 +699,42 @@ fn current_custom_panel_enabled(monitor: &Value) -> bool {
|
|||||||
.unwrap_or(false)
|
.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 {
|
fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
|
||||||
RotationSnapshot {
|
RotationSnapshot {
|
||||||
custom_panel: current_custom_panel_enabled(monitor),
|
custom_panel: current_custom_panel_enabled(monitor),
|
||||||
switch_time: current_switch_time(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),
|
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
|
let setup = monitor
|
||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.expect("monitor config must be an object")
|
.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() {
|
if let Some(setup) = setup.as_object_mut() {
|
||||||
setup.insert("customPanel".into(), Value::Bool(enabled));
|
setup.insert("customPanel".into(), Value::Bool(enabled));
|
||||||
setup.insert("switchTime".into(), Value::String(switch_time.to_string()));
|
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(
|
monitor["mianban"] = Value::Array(
|
||||||
@@ -714,6 +789,200 @@ fn render_display_panel(image: &DynamicImage) -> RgbImage {
|
|||||||
canvas
|
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 {
|
fn make_image_name(file_name: &str) -> String {
|
||||||
let base = file_name
|
let base = file_name
|
||||||
.rsplit_once('.')
|
.rsplit_once('.')
|
||||||
@@ -803,9 +1072,8 @@ fn open_screen(config: &DisplayConfig) -> Result<AooScreen> {
|
|||||||
|
|
||||||
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
||||||
let mut snapshot = RotationSnapshot::default();
|
let mut snapshot = RotationSnapshot::default();
|
||||||
let mut next_index = 0usize;
|
let mut cycle_started_at = Instant::now();
|
||||||
let mut last_switch_at = Instant::now();
|
let mut current_frame_index = None;
|
||||||
let mut force_send = true;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let monitor = match load_monitor_json_sync(&state.monitor_path) {
|
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);
|
let new_snapshot = rotation_snapshot(&monitor);
|
||||||
if new_snapshot != snapshot {
|
if new_snapshot != snapshot {
|
||||||
snapshot = new_snapshot;
|
snapshot = new_snapshot;
|
||||||
next_index = 0;
|
cycle_started_at = Instant::now();
|
||||||
force_send = true;
|
current_frame_index = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let frames = snapshot.frames();
|
||||||
|
|
||||||
update_display_status(&state.display_status, |status| {
|
update_display_status(&state.display_status, |status| {
|
||||||
status.custom_panel = snapshot.custom_panel;
|
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.rotation_active = snapshot.rotation_active();
|
||||||
status.switch_time = snapshot.switch_time.to_string();
|
status.switch_time = snapshot.switch_time.to_string();
|
||||||
status.active_images = snapshot.active_images.clone();
|
status.active_images = snapshot.active_images.clone();
|
||||||
@@ -846,12 +1118,34 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let switch_after = Duration::from_secs(snapshot.switch_time as u64);
|
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 {
|
if send_due {
|
||||||
let image_name = snapshot.active_images[next_index].clone();
|
let frame = frames[next_index].clone();
|
||||||
match load_panel_rgb(&state.image_dir, &image_name) {
|
match frame {
|
||||||
Ok(rgb_img) => {
|
RotationFrame::SystemSpecs => {
|
||||||
|
let panel_name = SYSTEM_FRAME_NAME.to_string();
|
||||||
|
let rgb_img = render_system_panel();
|
||||||
|
screen
|
||||||
|
.send_image(&rgb_img)
|
||||||
|
.context("Failed to send system specs panel")?;
|
||||||
|
|
||||||
|
update_display_status(&state.display_status, |status| {
|
||||||
|
status.current_image = Some(panel_name);
|
||||||
|
status.last_error = None;
|
||||||
|
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
|
screen
|
||||||
.send_image(&rgb_img)
|
.send_image(&rgb_img)
|
||||||
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
.with_context(|| format!("Failed to send panel {image_name}"))?;
|
||||||
@@ -873,14 +1167,12 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
|
|||||||
status.updated_at = Some(stamp_now());
|
status.updated_at = Some(stamp_now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
current_frame_index = Some(next_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
last_switch_at = Instant::now();
|
thread::sleep(rotation_sleep(frames.len(), switch_after, cycle_started_at));
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,12 +1189,15 @@ fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<RgbImage> {
|
|||||||
Ok(image.to_rgb8())
|
Ok(image.to_rgb8())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rotation_sleep(image_count: usize, switch_after: Duration, last_switch_at: Instant) -> Duration {
|
fn rotation_sleep(frame_count: usize, switch_after: Duration, cycle_started_at: Instant) -> Duration {
|
||||||
if image_count <= 1 {
|
if frame_count <= 1 {
|
||||||
return Duration::from_millis(750);
|
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) {
|
if remaining > Duration::from_millis(750) {
|
||||||
Duration::from_millis(750)
|
Duration::from_millis(750)
|
||||||
} else if remaining < Duration::from_millis(200) {
|
} 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 strong { display: block; margin-top: 8px; font-size: 1.3rem; }
|
||||||
.status small { display: block; margin-top: 6px; color: var(--muted); }
|
.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; }
|
.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; }
|
label { display: grid; gap: 8px; color: var(--muted); font-size: .9rem; }
|
||||||
input, button {
|
input, button {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -1078,6 +1380,10 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
<button id="refresh" class="ghost" type="button">Refresh</button>
|
<button id="refresh" class="ghost" type="button">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="status-grid">
|
<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">Custom Panel</span><strong id="customPanelValue">-</strong></div>
|
||||||
@@ -1113,7 +1419,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
const state = {
|
const state = {
|
||||||
images: [],
|
images: [],
|
||||||
activeImages: [],
|
activeImages: [],
|
||||||
setup: { switch_time: "10", custom_panel: false },
|
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false },
|
||||||
display: {},
|
display: {},
|
||||||
system: {},
|
system: {},
|
||||||
};
|
};
|
||||||
@@ -1121,6 +1427,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
gallery: document.getElementById("gallery"),
|
gallery: document.getElementById("gallery"),
|
||||||
order: document.getElementById("order"),
|
order: document.getElementById("order"),
|
||||||
switchTime: document.getElementById("switchTime"),
|
switchTime: document.getElementById("switchTime"),
|
||||||
|
specsEnabled: document.getElementById("specsEnabled"),
|
||||||
|
memesEnabled: document.getElementById("memesEnabled"),
|
||||||
customPanelValue: document.getElementById("customPanelValue"),
|
customPanelValue: document.getElementById("customPanelValue"),
|
||||||
imageCountValue: document.getElementById("imageCountValue"),
|
imageCountValue: document.getElementById("imageCountValue"),
|
||||||
displayStatusValue: document.getElementById("displayStatusValue"),
|
displayStatusValue: document.getElementById("displayStatusValue"),
|
||||||
@@ -1194,7 +1502,15 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
state.display = data.display || {};
|
state.display = data.display || {};
|
||||||
state.system = data.system || {};
|
state.system = data.system || {};
|
||||||
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.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.imageCountValue.textContent = String(data.active_images.length);
|
||||||
el.displayStatusValue.textContent = state.display.connected
|
el.displayStatusValue.textContent = state.display.connected
|
||||||
? `Connected (${state.display.mode || "native"})`
|
? `Connected (${state.display.mode || "native"})`
|
||||||
@@ -1236,6 +1552,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
images: state.activeImages,
|
images: state.activeImages,
|
||||||
switch_time: Number.parseInt(el.switchTime.value || "10", 10),
|
switch_time: Number.parseInt(el.switchTime.value || "10", 10),
|
||||||
|
specs_enabled: el.specsEnabled.checked,
|
||||||
|
memes_enabled: el.memesEnabled.checked,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
toast("Rotation updated");
|
toast("Rotation updated");
|
||||||
@@ -1282,7 +1600,35 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
|
|
||||||
el.order.innerHTML = "";
|
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>`;
|
el.order.innerHTML = `<p class="muted">No active images.</p>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user