diff --git a/Cargo.lock b/Cargo.lock index ae40a9c..88e6dcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/aster-webui/Cargo.toml b/crates/aster-webui/Cargo.toml index 6c38c1d..000d46d 100644 --- a/crates/aster-webui/Cargo.toml +++ b/crates/aster-webui/Cargo.toml @@ -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" diff --git a/crates/aster-webui/src/main.rs b/crates/aster-webui/src/main.rs index ee093e9..d49836c 100644 --- a/crates/aster-webui/src/main.rs +++ b/crates/aster-webui/src/main.rs @@ -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, } 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 { + 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, @@ -142,6 +171,8 @@ struct SystemView { struct ActivateRequest { images: Vec, switch_time: Option, + specs_enabled: Option, + memes_enabled: Option, } #[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, ) -> 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>) -> 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 { .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 { 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, +) { + 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, + 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 = 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 { 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 { 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##" .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##" +
+ + +
Custom Panel-
@@ -1113,7 +1419,7 @@ const INDEX_HTML: &str = r##" 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##" 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##" 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##" 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##" } 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 = ` +
S
+
+
System Specs
+
${state.display.current_image === "System Specs" ? "Currently shown" : "Fixed first frame when enabled"}
+
+
Toggle above
+ `; + el.order.appendChild(row); + } + + if (state.setup.specs_enabled && state.activeImages.length) { + const row = document.createElement("div"); + row.className = "order-item"; + row.innerHTML = ` +
S
+
+
System Specs Overlay
+
Live stats are drawn over every meme while enabled.
+
+
Toggle above
+ `; + el.order.appendChild(row); + } + + if (!state.activeImages.length && !state.setup.specs_enabled) { el.order.innerHTML = `

No active images.

`; return; }