feat: add built-in ricardo display mode
Build And Push Container / build-and-push (push) Successful in 58s

This commit is contained in:
2026-06-30 23:45:45 +02:00
parent 382e82601a
commit 33967f3b7b
7 changed files with 177 additions and 101 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

+40 -39
View File
@@ -10,7 +10,10 @@ use chrono::Utc;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
images::{is_gif_manifest_name, load_gif_manifest, load_panel_rgb, MIN_GIF_FRAME_DELAY_MS}, images::{
is_gif_manifest_name, load_gif_manifest, load_panel_rgb, MIN_GIF_FRAME_DELAY_MS,
RICARDO_MANIFEST_NAME,
},
monitor::{load_monitor_json_sync, rotation_snapshot}, monitor::{load_monitor_json_sync, rotation_snapshot},
system::{overlay_system_specs, render_system_panel}, system::{overlay_system_specs, render_system_panel},
types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot}, types::{ActiveFrame, AppState, DisplayConfig, DisplayStatus, RotationSlot, RotationSnapshot},
@@ -36,7 +39,7 @@ pub(crate) fn initial_display_status(config: &DisplayConfig) -> DisplayStatus {
custom_panel: false, custom_panel: false,
specs_enabled: false, specs_enabled: false,
memes_enabled: false, memes_enabled: false,
gifs_enabled: false, ricardo_enabled: false,
rotation_active: false, rotation_active: false,
switch_time: "10".into(), switch_time: "10".into(),
active_images: Vec::new(), active_images: Vec::new(),
@@ -153,7 +156,7 @@ fn run_display_session(state: &AppState, screen: &mut AooScreen) -> Result<()> {
status.custom_panel = snapshot.custom_panel; status.custom_panel = snapshot.custom_panel;
status.specs_enabled = snapshot.specs_enabled; status.specs_enabled = snapshot.specs_enabled;
status.memes_enabled = snapshot.memes_enabled; status.memes_enabled = snapshot.memes_enabled;
status.gifs_enabled = snapshot.gifs_enabled; status.ricardo_enabled = snapshot.ricardo_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();
@@ -239,37 +242,11 @@ fn build_rotation_slots(
image_dir: &std::path::PathBuf, image_dir: &std::path::PathBuf,
) -> Vec<RotationSlot> { ) -> Vec<RotationSlot> {
let mut slots = Vec::new(); let mut slots = Vec::new();
if snapshot.memes_enabled || snapshot.gifs_enabled { if snapshot.memes_enabled {
for name in &snapshot.active_images { for name in &snapshot.active_images {
if is_gif_manifest_name(name) { if is_gif_manifest_name(name) {
if !snapshot.gifs_enabled { continue;
continue; } else {
}
match load_gif_manifest(image_dir, name) {
Ok(manifest) if !manifest.frames.is_empty() => {
let mut frames = Vec::new();
for (idx, frame) in manifest.frames.iter().enumerate() {
frames.push((
ActiveFrame {
key: format!("{name}#{idx}"),
image_name: frame.name.clone(),
display_name: format!("{} [{}]", manifest.label, idx + 1),
animated: true,
},
Duration::from_millis(
frame.delay_ms.max(MIN_GIF_FRAME_DELAY_MS) as u64
),
));
}
slots.push(RotationSlot {
total_duration: Duration::from_secs(snapshot.switch_time as u64),
frames,
});
}
Ok(_) => {}
Err(err) => warn!("Skipping GIF set {name}: {err}"),
}
} else if snapshot.memes_enabled {
slots.push(RotationSlot { slots.push(RotationSlot {
total_duration: Duration::from_secs(snapshot.switch_time as u64), total_duration: Duration::from_secs(snapshot.switch_time as u64),
frames: vec![( frames: vec![(
@@ -281,8 +258,32 @@ fn build_rotation_slots(
}, },
Duration::from_secs(snapshot.switch_time as u64), Duration::from_secs(snapshot.switch_time as u64),
)], )],
})
}
}
}
if snapshot.ricardo_enabled {
match load_gif_manifest(image_dir, RICARDO_MANIFEST_NAME) {
Ok(manifest) if !manifest.frames.is_empty() => {
let mut frames = Vec::new();
for (idx, frame) in manifest.frames.iter().enumerate() {
frames.push((
ActiveFrame {
key: format!("{RICARDO_MANIFEST_NAME}#{idx}"),
image_name: frame.name.clone(),
display_name: format!("{} [{}]", manifest.label, idx + 1),
animated: true,
},
Duration::from_millis(frame.delay_ms.max(MIN_GIF_FRAME_DELAY_MS) as u64),
));
}
slots.push(RotationSlot {
total_duration: Duration::from_secs(snapshot.switch_time as u64),
frames,
}); });
} }
Ok(_) => {}
Err(err) => warn!("Skipping Ricardo GIF: {err}"),
} }
} }
if slots.is_empty() && snapshot.specs_enabled { if slots.is_empty() && snapshot.specs_enabled {
@@ -633,14 +634,14 @@ mod tests {
} }
#[test] #[test]
fn build_rotation_slots_uses_switch_time_for_gif_slot_duration() { fn build_rotation_slots_uses_switch_time_for_ricardo_slot_duration() {
let unique = SystemTime::now() let unique = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
.as_nanos(); .as_nanos();
let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-test-{unique}")); let image_dir = std::env::temp_dir().join(format!("aster-webui-gif-test-{unique}"));
fs::create_dir_all(&image_dir).unwrap(); fs::create_dir_all(&image_dir).unwrap();
let manifest_name = "panel-test.gifset.json"; let manifest_name = RICARDO_MANIFEST_NAME;
fs::write( fs::write(
image_dir.join(manifest_name), image_dir.join(manifest_name),
r#"{ r#"{
@@ -662,8 +663,8 @@ mod tests {
specs_enabled: false, specs_enabled: false,
specs_mode: "cards".into(), specs_mode: "cards".into(),
memes_enabled: false, memes_enabled: false,
gifs_enabled: true, ricardo_enabled: true,
active_images: vec![manifest_name.into()], active_images: Vec::new(),
}; };
let slots = build_rotation_slots(&snapshot, &image_dir); let slots = build_rotation_slots(&snapshot, &image_dir);
@@ -675,7 +676,7 @@ mod tests {
} }
#[test] #[test]
fn build_rotation_slots_skips_gif_when_gifs_are_disabled() { fn build_rotation_slots_skips_uploaded_gif_when_only_memes_are_enabled() {
let unique = SystemTime::now() let unique = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap() .unwrap()
@@ -704,8 +705,8 @@ mod tests {
switch_time: 7, switch_time: 7,
specs_enabled: false, specs_enabled: false,
specs_mode: "cards".into(), specs_mode: "cards".into(),
memes_enabled: false, memes_enabled: true,
gifs_enabled: false, ricardo_enabled: false,
active_images: vec![manifest_name.into()], active_images: vec![manifest_name.into()],
}; };
+47 -3
View File
@@ -15,9 +15,12 @@ use crate::{
}; };
pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif"); pub(crate) const NYAN_CAT_GIF: &[u8] = include_bytes!("../assets/nyan-cat.gif");
pub(crate) const RICARDO_GIF: &[u8] = include_bytes!("../assets/ricardo.gif");
pub(crate) const MIN_GIF_FRAME_DELAY_MS: u32 = 33; pub(crate) const MIN_GIF_FRAME_DELAY_MS: u32 = 33;
const GIF_FRAME_PREFIX: &str = ".gifframe-"; const GIF_FRAME_PREFIX: &str = ".gifframe-";
const GIF_MANIFEST_SUFFIX: &str = ".gifset.json"; const GIF_MANIFEST_SUFFIX: &str = ".gifset.json";
pub(crate) const RICARDO_MANIFEST_NAME: &str = "builtin-ricardo.gifset.json";
const RICARDO_FRAME_STEM: &str = "builtin-ricardo";
pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> { pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
let mut out = Vec::new(); let mut out = Vec::new();
@@ -36,6 +39,9 @@ pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
} }
let meta = entry.metadata().await?; let meta = entry.metadata().await?;
if is_gif_manifest_name(&name) { if is_gif_manifest_name(&name) {
if is_builtin_ricardo_manifest_name(&name) {
continue;
}
let raw = fs::read_to_string(&path).await?; let raw = fs::read_to_string(&path).await?;
let manifest: GifSetManifest = serde_json::from_str(&raw) let manifest: GifSetManifest = serde_json::from_str(&raw)
.with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?; .with_context(|| format!("Failed to parse GIF manifest {}", path.display()))?;
@@ -85,6 +91,33 @@ pub(crate) async fn convert_and_store_image(
} }
async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> { async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8]) -> Result<String> {
store_gif_frames(state, file_name, bytes, None, None).await
}
pub(crate) async fn ensure_builtin_ricardo_gif(state: &AppState) -> Result<()> {
let manifest_path = state.image_dir.join(RICARDO_MANIFEST_NAME);
if fs::try_exists(&manifest_path).await? {
return Ok(());
}
store_gif_frames(
state,
"ricardo.gif",
RICARDO_GIF,
Some(RICARDO_MANIFEST_NAME),
Some(RICARDO_FRAME_STEM),
)
.await
.map(|_| ())
}
async fn store_gif_frames(
state: &AppState,
file_name: &str,
bytes: &[u8],
manifest_name_override: Option<&str>,
frame_stem_override: Option<&str>,
) -> Result<String> {
let decoder = GifDecoder::new(Cursor::new(bytes)) let decoder = GifDecoder::new(Cursor::new(bytes))
.with_context(|| format!("Unsupported GIF format: {file_name}"))?; .with_context(|| format!("Unsupported GIF format: {file_name}"))?;
let frames = decoder let frames = decoder
@@ -96,9 +129,12 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8])
} }
let source_frame_count = frames.len(); let source_frame_count = frames.len();
let base = make_image_stem(file_name); let base = frame_stem_override.unwrap_or(file_name);
let clean_base = make_image_stem(base);
let timestamp = Local::now().format("%Y%m%d-%H%M%S"); let timestamp = Local::now().format("%Y%m%d-%H%M%S");
let manifest_name = format!("panel-{timestamp}-{base}{GIF_MANIFEST_SUFFIX}"); let manifest_name = manifest_name_override
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("panel-{timestamp}-{clean_base}{GIF_MANIFEST_SUFFIX}"));
let mut rendered_frames = Vec::new(); let mut rendered_frames = Vec::new();
for frame in frames { for frame in frames {
@@ -116,7 +152,11 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8])
let mut stored_frames = Vec::new(); let mut stored_frames = Vec::new();
for (idx, (rendered, delay_ms)) in collapsed_frames.into_iter().enumerate() { for (idx, (rendered, delay_ms)) in collapsed_frames.into_iter().enumerate() {
let frame_name = format!("{GIF_FRAME_PREFIX}{timestamp}-{base}-{idx:03}.jpg"); let frame_name = if manifest_name_override.is_some() {
format!("{GIF_FRAME_PREFIX}{clean_base}-{idx:03}.jpg")
} else {
format!("{GIF_FRAME_PREFIX}{timestamp}-{clean_base}-{idx:03}.jpg")
};
let frame_path = state.image_dir.join(&frame_name); let frame_path = state.image_dir.join(&frame_name);
rendered rendered
.save_with_format(&frame_path, ImageFormat::Jpeg) .save_with_format(&frame_path, ImageFormat::Jpeg)
@@ -210,6 +250,10 @@ pub(crate) fn is_gif_manifest_name(name: &str) -> bool {
name.ends_with(GIF_MANIFEST_SUFFIX) name.ends_with(GIF_MANIFEST_SUFFIX)
} }
pub(crate) fn is_builtin_ricardo_manifest_name(name: &str) -> bool {
name == RICARDO_MANIFEST_NAME
}
fn is_gif_frame_name(name: &str) -> bool { fn is_gif_frame_name(name: &str) -> bool {
name.starts_with(GIF_FRAME_PREFIX) name.starts_with(GIF_FRAME_PREFIX)
} }
+24 -13
View File
@@ -6,13 +6,14 @@ use tokio::fs;
use crate::{ use crate::{
display::read_display_status, display::read_display_status,
images::list_images, images::{ensure_builtin_ricardo_gif, list_images},
system::collect_system_view, system::collect_system_view,
types::{AppState, RotationSnapshot, SetupView, StateResponse}, types::{AppState, RotationSnapshot, SetupView, StateResponse},
}; };
pub(crate) async fn ensure_layout(state: &AppState) -> Result<()> { pub(crate) async fn ensure_layout(state: &AppState) -> Result<()> {
fs::create_dir_all(&state.image_dir).await?; fs::create_dir_all(&state.image_dir).await?;
ensure_builtin_ricardo_gif(state).await?;
if fs::try_exists(&state.monitor_path).await? { if fs::try_exists(&state.monitor_path).await? {
return Ok(()); return Ok(());
@@ -34,7 +35,7 @@ pub(crate) async fn ensure_layout(state: &AppState) -> Result<()> {
"nativeSpecs": false, "nativeSpecs": false,
"nativeSpecsMode": "cards", "nativeSpecsMode": "cards",
"nativeMemes": true, "nativeMemes": true,
"nativeGifs": true, "nativeRicardo": false,
"operationMode": 0, "operationMode": 0,
"theme": 1, "theme": 1,
"diskUpdate": 300, "diskUpdate": 300,
@@ -96,7 +97,7 @@ pub(crate) async fn build_state_response(state: &AppState) -> Result<StateRespon
let specs_enabled = current_specs_enabled(&monitor); let specs_enabled = current_specs_enabled(&monitor);
let specs_mode = current_specs_mode(&monitor); let specs_mode = current_specs_mode(&monitor);
let memes_enabled = current_memes_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor);
let gifs_enabled = current_gifs_enabled(&monitor); let ricardo_enabled = current_ricardo_enabled(&monitor);
Ok(StateResponse { Ok(StateResponse {
monitor_path: state.monitor_path.display().to_string(), monitor_path: state.monitor_path.display().to_string(),
@@ -107,7 +108,7 @@ pub(crate) async fn build_state_response(state: &AppState) -> Result<StateRespon
specs_enabled, specs_enabled,
specs_mode, specs_mode,
memes_enabled, memes_enabled,
gifs_enabled, ricardo_enabled,
}, },
active_images: current_active_images(&monitor), active_images: current_active_images(&monitor),
images: list_images(state).await?, images: list_images(state).await?,
@@ -123,6 +124,7 @@ pub(crate) fn current_active_images(monitor: &Value) -> Vec<String> {
.into_iter() .into_iter()
.flatten() .flatten()
.filter_map(|panel| panel.get("img").and_then(Value::as_str)) .filter_map(|panel| panel.get("img").and_then(Value::as_str))
.filter(|name| *name != crate::images::RICARDO_MANIFEST_NAME)
.map(ToString::to_string) .map(ToString::to_string)
.collect() .collect()
} }
@@ -175,13 +177,17 @@ pub(crate) fn current_specs_mode(monitor: &Value) -> String {
.unwrap_or_else(|| "cards".into()) .unwrap_or_else(|| "cards".into())
} }
pub(crate) fn current_gifs_enabled(monitor: &Value) -> bool { pub(crate) fn current_ricardo_enabled(monitor: &Value) -> bool {
monitor monitor
.get("setup") .get("setup")
.and_then(Value::as_object) .and_then(Value::as_object)
.and_then(|setup| setup.get("nativeGifs")) .and_then(|setup| {
setup
.get("nativeRicardo")
.or_else(|| setup.get("nativeGifs"))
})
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(true) .unwrap_or(false)
} }
pub(crate) fn rotation_snapshot(monitor: &Value) -> RotationSnapshot { pub(crate) fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
@@ -191,7 +197,7 @@ pub(crate) fn rotation_snapshot(monitor: &Value) -> RotationSnapshot {
specs_enabled: current_specs_enabled(monitor), specs_enabled: current_specs_enabled(monitor),
specs_mode: current_specs_mode(monitor), specs_mode: current_specs_mode(monitor),
memes_enabled: current_memes_enabled(monitor), memes_enabled: current_memes_enabled(monitor),
gifs_enabled: current_gifs_enabled(monitor), ricardo_enabled: current_ricardo_enabled(monitor),
active_images: current_active_images(monitor), active_images: current_active_images(monitor),
} }
} }
@@ -202,10 +208,10 @@ pub(crate) fn set_custom_panels(
specs_enabled: bool, specs_enabled: bool,
specs_mode: &str, specs_mode: &str,
memes_enabled: bool, memes_enabled: bool,
gifs_enabled: bool, ricardo_enabled: bool,
images: &[String], images: &[String],
) { ) {
let enabled = specs_enabled || ((memes_enabled || gifs_enabled) && !images.is_empty()); let enabled = specs_enabled || (memes_enabled && !images.is_empty()) || ricardo_enabled;
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")
@@ -221,16 +227,21 @@ pub(crate) fn set_custom_panels(
Value::String(normalize_specs_mode(specs_mode)), Value::String(normalize_specs_mode(specs_mode)),
); );
setup.insert("nativeMemes".into(), Value::Bool(memes_enabled)); setup.insert("nativeMemes".into(), Value::Bool(memes_enabled));
setup.insert("nativeGifs".into(), Value::Bool(gifs_enabled)); setup.insert("nativeRicardo".into(), Value::Bool(ricardo_enabled));
}
let mut diy_images = images.to_vec();
if ricardo_enabled {
diy_images.push(crate::images::RICARDO_MANIFEST_NAME.into());
} }
monitor["mianban"] = Value::Array( monitor["mianban"] = Value::Array(
(1..=images.len()) (1..=diy_images.len())
.map(|index| Value::Number((index as u64).into())) .map(|index| Value::Number((index as u64).into()))
.collect(), .collect(),
); );
monitor["diy"] = Value::Array( monitor["diy"] = Value::Array(
images diy_images
.iter() .iter()
.map(|name| json!({"type": 5, "img": name, "sensor": []})) .map(|name| json!({"type": 5, "img": name, "sensor": []}))
.collect(), .collect(),
+15 -16
View File
@@ -13,13 +13,13 @@ use tokio::fs;
use crate::{ use crate::{
images::{ images::{
convert_and_store_image, delete_image_asset, is_gif_manifest_name, list_images, convert_and_store_image, delete_image_asset, is_builtin_ricardo_manifest_name,
NYAN_CAT_GIF, is_gif_manifest_name, list_images, NYAN_CAT_GIF,
}, },
monitor::{ monitor::{
build_state_response, current_active_images, current_gifs_enabled, current_memes_enabled, build_state_response, current_active_images, current_memes_enabled,
current_specs_enabled, current_specs_mode, current_switch_time, load_monitor_json, current_ricardo_enabled, current_specs_enabled, current_specs_mode, current_switch_time,
save_monitor_json, set_custom_panels, load_monitor_json, save_monitor_json, set_custom_panels,
}, },
types::{ActivateRequest, AppState, DeleteRequest, ErrorResponse}, types::{ActivateRequest, AppState, DeleteRequest, ErrorResponse},
ui::index_response, ui::index_response,
@@ -103,6 +103,7 @@ async fn api_activate(
) -> 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 specs_enabled = payload.specs_enabled.unwrap_or(false);
let ricardo_enabled = payload.ricardo_enabled.unwrap_or(false);
let specs_mode = payload let specs_mode = payload
.specs_mode .specs_mode
.as_deref() .as_deref()
@@ -128,17 +129,12 @@ async fn api_activate(
} }
let has_static_selection = valid.iter().any(|name| !is_gif_manifest_name(name)); let has_static_selection = valid.iter().any(|name| !is_gif_manifest_name(name));
let has_gif_selection = valid.iter().any(|name| is_gif_manifest_name(name));
let memes_enabled = payload.memes_enabled.unwrap_or(has_static_selection); let memes_enabled = payload.memes_enabled.unwrap_or(has_static_selection);
let gifs_enabled = payload.gifs_enabled.unwrap_or(has_gif_selection);
if !specs_enabled if !specs_enabled && !(memes_enabled && has_static_selection) && !ricardo_enabled {
&& !(memes_enabled && has_static_selection)
&& !(gifs_enabled && has_gif_selection)
{
return error_response( return error_response(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
"Enable specs, memes with a still image, or GIFs with an animated image", "Enable specs, memes with a still image, or Ricardo",
); );
} }
@@ -153,7 +149,7 @@ async fn api_activate(
specs_enabled, specs_enabled,
&specs_mode, &specs_mode,
memes_enabled, memes_enabled,
gifs_enabled, ricardo_enabled,
&valid, &valid,
); );
@@ -167,7 +163,7 @@ async fn api_activate(
"specsEnabled": specs_enabled, "specsEnabled": specs_enabled,
"specsMode": specs_mode, "specsMode": specs_mode,
"memesEnabled": memes_enabled, "memesEnabled": memes_enabled,
"gifsEnabled": gifs_enabled, "ricardoEnabled": ricardo_enabled,
"activeImages": valid, "activeImages": valid,
})) }))
.into_response() .into_response()
@@ -206,6 +202,9 @@ async fn api_delete(
if payload.name.contains('/') || payload.name.contains('\\') { if payload.name.contains('/') || payload.name.contains('\\') {
return error_response(StatusCode::BAD_REQUEST, "Invalid file name"); return error_response(StatusCode::BAD_REQUEST, "Invalid file name");
} }
if is_builtin_ricardo_manifest_name(&payload.name) {
return error_response(StatusCode::BAD_REQUEST, "Ricardo is a built-in asset");
}
if let Err(err) = delete_image_asset(&state, &payload.name).await { if let Err(err) = delete_image_asset(&state, &payload.name).await {
return match err.downcast_ref::<std::io::Error>() { return match err.downcast_ref::<std::io::Error>() {
@@ -226,7 +225,7 @@ async fn api_delete(
let specs_enabled = current_specs_enabled(&monitor); let specs_enabled = current_specs_enabled(&monitor);
let specs_mode = current_specs_mode(&monitor); let specs_mode = current_specs_mode(&monitor);
let memes_enabled = current_memes_enabled(&monitor); let memes_enabled = current_memes_enabled(&monitor);
let gifs_enabled = current_gifs_enabled(&monitor); let ricardo_enabled = current_ricardo_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 {
@@ -236,7 +235,7 @@ async fn api_delete(
specs_enabled, specs_enabled,
&specs_mode, &specs_mode,
memes_enabled, memes_enabled,
gifs_enabled, ricardo_enabled,
&active_images, &active_images,
); );
if let Err(err) = save_monitor_json(&state, &monitor).await { if let Err(err) = save_monitor_json(&state, &monitor).await {
+6 -5
View File
@@ -49,7 +49,7 @@ pub(crate) struct RotationSnapshot {
pub(crate) specs_enabled: bool, pub(crate) specs_enabled: bool,
pub(crate) specs_mode: String, pub(crate) specs_mode: String,
pub(crate) memes_enabled: bool, pub(crate) memes_enabled: bool,
pub(crate) gifs_enabled: bool, pub(crate) ricardo_enabled: bool,
pub(crate) active_images: Vec<String>, pub(crate) active_images: Vec<String>,
} }
@@ -57,7 +57,8 @@ impl RotationSnapshot {
pub(crate) fn rotation_active(&self) -> bool { pub(crate) fn rotation_active(&self) -> bool {
self.custom_panel self.custom_panel
&& (self.specs_enabled && (self.specs_enabled
|| ((self.memes_enabled || self.gifs_enabled) && !self.active_images.is_empty())) || (self.memes_enabled && !self.active_images.is_empty())
|| self.ricardo_enabled)
} }
} }
@@ -110,7 +111,7 @@ pub(crate) struct SetupView {
pub(crate) specs_enabled: bool, pub(crate) specs_enabled: bool,
pub(crate) specs_mode: String, pub(crate) specs_mode: String,
pub(crate) memes_enabled: bool, pub(crate) memes_enabled: bool,
pub(crate) gifs_enabled: bool, pub(crate) ricardo_enabled: bool,
} }
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
@@ -133,7 +134,7 @@ pub(crate) struct DisplayStatus {
pub(crate) custom_panel: bool, pub(crate) custom_panel: bool,
pub(crate) specs_enabled: bool, pub(crate) specs_enabled: bool,
pub(crate) memes_enabled: bool, pub(crate) memes_enabled: bool,
pub(crate) gifs_enabled: bool, pub(crate) ricardo_enabled: bool,
pub(crate) rotation_active: bool, pub(crate) rotation_active: bool,
pub(crate) switch_time: String, pub(crate) switch_time: String,
pub(crate) active_images: Vec<String>, pub(crate) active_images: Vec<String>,
@@ -177,7 +178,7 @@ pub(crate) struct ActivateRequest {
pub(crate) specs_enabled: Option<bool>, pub(crate) specs_enabled: Option<bool>,
pub(crate) specs_mode: Option<String>, pub(crate) specs_mode: Option<String>,
pub(crate) memes_enabled: Option<bool>, pub(crate) memes_enabled: Option<bool>,
pub(crate) gifs_enabled: Option<bool>, pub(crate) ricardo_enabled: Option<bool>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
+45 -25
View File
@@ -145,7 +145,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
.subtle { color: var(--faint); font-size: .82rem; } .subtle { color: var(--faint); font-size: .82rem; }
.controls { .controls {
display: grid; display: grid;
grid-template-columns: 132px minmax(220px, 1fr) auto; grid-template-columns: 132px minmax(220px, 1fr) 180px auto;
gap: 10px; gap: 10px;
align-items: end; align-items: end;
} }
@@ -197,6 +197,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
margin-top: 12px; margin-top: 12px;
} }
.select-wrap select { .select-wrap select {
width: 100%;
min-height: 42px; min-height: 42px;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -304,13 +305,17 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: 10px; gap: 10px;
align-items: stretch;
} }
.tile { .tile {
display: grid;
grid-template-rows: auto 1fr;
border: 1px solid var(--line-soft); border: 1px solid var(--line-soft);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: #0b1016; background: #0b1016;
min-width: 0; min-width: 0;
min-height: 246px;
} }
.tile.selected { .tile.selected {
border-color: var(--accent-2); border-color: var(--accent-2);
@@ -327,11 +332,16 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
.tile-body { .tile-body {
padding: 10px; padding: 10px;
display: grid; display: grid;
grid-template-rows: 4.7em 2.2em 1fr auto;
gap: 9px; gap: 9px;
min-height: 0;
} }
.tile-title { .tile-title {
min-height: 2.5em;
line-height: 1.28; line-height: 1.28;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
word-break: break-word; word-break: break-word;
color: var(--text); color: var(--text);
} }
@@ -339,8 +349,17 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
color: var(--faint); color: var(--faint);
font-size: .82rem; font-size: .82rem;
line-height: 1.25; line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mini {
display: flex;
gap: 7px;
flex-wrap: wrap;
align-items: center;
align-self: end;
} }
.mini { display: flex; gap: 7px; flex-wrap: wrap; align-items: center; }
.mini button { .mini button {
min-height: 34px; min-height: 34px;
padding: 7px 10px; padding: 7px 10px;
@@ -491,6 +510,13 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<span>Upload Source</span> <span>Upload Source</span>
<input id="upload" type="file" accept="image/*"> <input id="upload" type="file" accept="image/*">
</label> </label>
<label class="select-wrap">
<span>Specs View</span>
<select id="specsMode">
<option value="cards">Cards</option>
<option value="meters">Htop Style</option>
</select>
</label>
<div class="actions"> <div class="actions">
<button id="disable" class="danger" type="button">Disable</button> <button id="disable" class="danger" type="button">Disable</button>
</div> </div>
@@ -498,16 +524,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="toggle-row"> <div class="toggle-row">
<label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs</label> <label class="toggle-pill"><input id="specsEnabled" type="checkbox"> System Specs</label>
<label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes</label> <label class="toggle-pill"><input id="memesEnabled" type="checkbox"> Memes</label>
<label class="toggle-pill"><input id="gifsEnabled" type="checkbox"> GIFs</label> <label class="toggle-pill"><input id="ricardoEnabled" type="checkbox"> Ricardo</label>
</div>
<div class="toggle-row">
<label class="select-wrap">
<span>Screen Layout</span>
<select id="specsMode">
<option value="cards">Cards</option>
<option value="meters">Meters</option>
</select>
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -573,7 +590,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
const state = { const state = {
images: [], images: [],
activeImages: [], activeImages: [],
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, specs_mode: "cards", memes_enabled: false, gifs_enabled: true }, setup: { switch_time: "10", custom_panel: false, specs_enabled: false, specs_mode: "cards", memes_enabled: false, ricardo_enabled: false },
display: {}, display: {},
system: {}, system: {},
activeTab: "screen-control", activeTab: "screen-control",
@@ -588,7 +605,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
specsEnabled: document.getElementById("specsEnabled"), specsEnabled: document.getElementById("specsEnabled"),
specsMode: document.getElementById("specsMode"), specsMode: document.getElementById("specsMode"),
memesEnabled: document.getElementById("memesEnabled"), memesEnabled: document.getElementById("memesEnabled"),
gifsEnabled: document.getElementById("gifsEnabled"), ricardoEnabled: document.getElementById("ricardoEnabled"),
customPanelValue: document.getElementById("customPanelValue"), customPanelValue: document.getElementById("customPanelValue"),
imageCountValue: document.getElementById("imageCountValue"), imageCountValue: document.getElementById("imageCountValue"),
displayStatusValue: document.getElementById("displayStatusValue"), displayStatusValue: document.getElementById("displayStatusValue"),
@@ -732,7 +749,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
specs_enabled: el.specsEnabled.checked, specs_enabled: el.specsEnabled.checked,
specs_mode: el.specsMode.value || data.setup.specs_mode || "cards", specs_mode: el.specsMode.value || data.setup.specs_mode || "cards",
memes_enabled: el.memesEnabled.checked, memes_enabled: el.memesEnabled.checked,
gifs_enabled: el.gifsEnabled.checked, ricardo_enabled: el.ricardoEnabled.checked,
} }
: data.setup; : data.setup;
state.display = data.display || {}; state.display = data.display || {};
@@ -742,12 +759,12 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.specsEnabled.checked = !!data.setup.specs_enabled; el.specsEnabled.checked = !!data.setup.specs_enabled;
el.specsMode.value = data.setup.specs_mode || "cards"; el.specsMode.value = data.setup.specs_mode || "cards";
el.memesEnabled.checked = !!data.setup.memes_enabled; el.memesEnabled.checked = !!data.setup.memes_enabled;
el.gifsEnabled.checked = data.setup.gifs_enabled !== false; el.ricardoEnabled.checked = !!data.setup.ricardo_enabled;
} }
const enabledModes = [ const enabledModes = [
state.setup.specs_enabled ? "Specs" : "", state.setup.specs_enabled ? "Specs" : "",
state.setup.memes_enabled ? "Memes" : "", state.setup.memes_enabled ? "Memes" : "",
state.setup.gifs_enabled ? "GIFs" : "", state.setup.ricardo_enabled ? "Ricardo" : "",
].filter(Boolean); ].filter(Boolean);
el.customPanelValue.textContent = state.setup.custom_panel el.customPanelValue.textContent = state.setup.custom_panel
? (enabledModes.join(" + ") || "Active") ? (enabledModes.join(" + ") || "Active")
@@ -802,7 +819,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
specs_enabled: el.specsEnabled.checked, specs_enabled: el.specsEnabled.checked,
specs_mode: el.specsMode.value, specs_mode: el.specsMode.value,
memes_enabled: el.memesEnabled.checked, memes_enabled: el.memesEnabled.checked,
gifs_enabled: el.gifsEnabled.checked, ricardo_enabled: el.ricardoEnabled.checked,
}), }),
}); });
clearDirty(); clearDirty();
@@ -833,7 +850,8 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
const imageMap = new Map(state.images.map((image) => [image.name, image])); const imageMap = new Map(state.images.map((image) => [image.name, image]));
el.gallery.innerHTML = ""; el.gallery.innerHTML = "";
for (const image of state.images) { for (const image of state.images) {
const selected = state.activeImages.includes(image.name); const selectable = !image.animated;
const selected = selectable && state.activeImages.includes(image.name);
const tile = document.createElement("article"); const tile = document.createElement("article");
const safeLabel = escapeHtml(image.label); const safeLabel = escapeHtml(image.label);
const safeName = escapeHtml(image.name); const safeName = escapeHtml(image.name);
@@ -842,16 +860,18 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
tile.innerHTML = ` tile.innerHTML = `
<img src="${safeUrl}" alt="${safeName}"> <img src="${safeUrl}" alt="${safeName}">
<div class="tile-body"> <div class="tile-body">
<div class="tile-title">${safeLabel}</div> <div class="tile-title" title="${safeLabel}">${safeLabel}</div>
<div class="tile-meta">${bytes(image.size)}${gifFrameLabel(image)}</div> <div class="tile-meta" title="${bytes(image.size)}${gifFrameLabel(image)}">${bytes(image.size)}${gifFrameLabel(image)}</div>
<div class="mini"> <div class="mini">
<button class="ghost" type="button" title="${selected ? "Remove" : "Add"}">${selected ? "Remove" : "Add"}</button> <button class="ghost" type="button" title="${selectable ? (selected ? "Remove" : "Add") : "Animated GIFs are handled by the Ricardo toggle"}"${selectable ? "" : " disabled"}>${selectable ? (selected ? "Remove" : "Add") : "Built-in only"}</button>
<button class="danger" type="button">Delete</button> <button class="danger" type="button">Delete</button>
</div> </div>
</div> </div>
`; `;
const [toggleBtn, deleteBtn] = tile.querySelectorAll("button"); const [toggleBtn, deleteBtn] = tile.querySelectorAll("button");
toggleBtn.addEventListener("click", () => toggle(image.name)); if (selectable) {
toggleBtn.addEventListener("click", () => toggle(image.name));
}
deleteBtn.addEventListener("click", () => removeImage(image.name)); deleteBtn.addEventListener("click", () => removeImage(image.name));
el.gallery.appendChild(tile); el.gallery.appendChild(tile);
} }
@@ -927,7 +947,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.specsEnabled.addEventListener("change", markDirty); el.specsEnabled.addEventListener("change", markDirty);
el.specsMode.addEventListener("change", markDirty); el.specsMode.addEventListener("change", markDirty);
el.memesEnabled.addEventListener("change", markDirty); el.memesEnabled.addEventListener("change", markDirty);
el.gifsEnabled.addEventListener("change", markDirty); el.ricardoEnabled.addEventListener("change", markDirty);
el.tabButtons.forEach((button) => { el.tabButtons.forEach((button) => {
button.addEventListener("click", () => setActiveTab(button.dataset.tabTarget)); button.addEventListener("click", () => setActiveTab(button.dataset.tabTarget));
}); });