new tab
Build And Push Container / build-and-push (push) Successful in 58s

This commit is contained in:
2026-06-30 23:28:51 +02:00
parent d2690dd6e9
commit c5f0249dbf
3 changed files with 189 additions and 55 deletions
+26 -1
View File
@@ -45,7 +45,8 @@ pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
label: manifest.label, label: manifest.label,
size: meta.len(), size: meta.len(),
animated: true, animated: true,
frame_count: manifest.frames.len(), frame_count: manifest.source_frame_count.unwrap_or(manifest.frames.len()),
display_frame_count: manifest.frames.len(),
}); });
} else { } else {
out.push(ImageView { out.push(ImageView {
@@ -55,6 +56,7 @@ pub(crate) async fn list_images(state: &AppState) -> Result<Vec<ImageView>> {
size: meta.len(), size: meta.len(),
animated: false, animated: false,
frame_count: 1, frame_count: 1,
display_frame_count: 1,
}); });
} }
} }
@@ -92,6 +94,7 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8])
if frames.is_empty() { if frames.is_empty() {
anyhow::bail!("GIF contains no frames"); anyhow::bail!("GIF contains no frames");
} }
let source_frame_count = frames.len();
let base = make_image_stem(file_name); let base = make_image_stem(file_name);
let timestamp = Local::now().format("%Y%m%d-%H%M%S"); let timestamp = Local::now().format("%Y%m%d-%H%M%S");
@@ -132,6 +135,7 @@ async fn convert_and_store_gif(state: &AppState, file_name: &str, bytes: &[u8])
.first() .first()
.map(|frame| frame.name.clone()) .map(|frame| frame.name.clone())
.unwrap_or_default(), .unwrap_or_default(),
source_frame_count: Some(source_frame_count),
frames: stored_frames, frames: stored_frames,
}; };
let manifest_path = state.image_dir.join(&manifest_name); let manifest_path = state.image_dir.join(&manifest_name);
@@ -262,6 +266,9 @@ pub(crate) fn load_panel_rgb(image_dir: &PathBuf, image_name: &str) -> Result<Rg
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use image::{Rgb, RgbImage}; use image::{Rgb, RgbImage};
use serde_json::json;
use crate::types::GifSetManifest;
use super::{collapse_rendered_frames, MIN_GIF_FRAME_DELAY_MS}; use super::{collapse_rendered_frames, MIN_GIF_FRAME_DELAY_MS};
@@ -286,4 +293,22 @@ mod tests {
fn min_gif_frame_delay_supports_50ms_frames() { fn min_gif_frame_delay_supports_50ms_frames() {
assert!(MIN_GIF_FRAME_DELAY_MS <= 50); assert!(MIN_GIF_FRAME_DELAY_MS <= 50);
} }
#[test]
fn gif_manifest_without_source_frame_count_stays_compatible() {
let manifest: GifSetManifest = serde_json::from_value(json!({
"kind": "gif_set",
"version": 1,
"label": "demo.gif",
"preview": ".gifframe-000.jpg",
"frames": [
{ "name": ".gifframe-000.jpg", "delay_ms": 50 },
{ "name": ".gifframe-001.jpg", "delay_ms": 50 }
]
}))
.unwrap();
assert_eq!(manifest.source_frame_count, None);
assert_eq!(manifest.frames.len(), 2);
}
} }
+3
View File
@@ -72,6 +72,8 @@ pub(crate) struct GifSetManifest {
pub(crate) version: u32, pub(crate) version: u32,
pub(crate) label: String, pub(crate) label: String,
pub(crate) preview: String, pub(crate) preview: String,
#[serde(default)]
pub(crate) source_frame_count: Option<usize>,
pub(crate) frames: Vec<GifFrameMeta>, pub(crate) frames: Vec<GifFrameMeta>,
} }
@@ -117,6 +119,7 @@ pub(crate) struct ImageView {
pub(crate) url: String, pub(crate) url: String,
pub(crate) animated: bool, pub(crate) animated: bool,
pub(crate) frame_count: usize, pub(crate) frame_count: usize,
pub(crate) display_frame_count: usize,
} }
#[derive(Clone, Debug, Default, Serialize)] #[derive(Clone, Debug, Default, Serialize)]
+160 -54
View File
@@ -87,6 +87,35 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
.brand p { margin: 3px 0 0; color: var(--muted); font-size: .9rem; } .brand p { margin: 3px 0 0; color: var(--muted); font-size: .9rem; }
.layout { display: grid; grid-template-columns: minmax(0, 1.45fr) 520px; gap: 14px; } .layout { display: grid; grid-template-columns: minmax(0, 1.45fr) 520px; gap: 14px; }
.stack { display: grid; gap: 16px; } .stack { display: grid; gap: 16px; }
.tabbar {
display: inline-flex;
gap: 6px;
margin-bottom: 14px;
padding: 4px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(10, 15, 20, .72);
}
.tab-button {
min-height: 38px;
padding: 8px 14px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-weight: 700;
}
.tab-button:hover {
transform: none;
background: rgba(20, 29, 40, .8);
border-color: transparent;
}
.tab-button.active {
background: linear-gradient(135deg, rgba(110, 231, 183, .16), rgba(185, 246, 106, .16));
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(110, 231, 183, .18);
}
.tab-panel[hidden] { display: none; }
.panel, .card { .panel, .card {
background: linear-gradient(180deg, rgba(18, 25, 34, .98), rgba(13, 18, 25, .98)); background: linear-gradient(180deg, rgba(18, 25, 34, .98), rgba(13, 18, 25, .98));
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -238,6 +267,15 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
grid-template-columns: minmax(0, 1.35fr) minmax(340px, .75fr); grid-template-columns: minmax(0, 1.35fr) minmax(340px, .75fr);
gap: 14px; gap: 14px;
} }
.system-tab {
display: grid;
gap: 14px;
}
.specs-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.gallery { .gallery {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
@@ -352,7 +390,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
height: auto; height: auto;
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
.layout, .workbench { grid-template-columns: 1fr; } .layout, .workbench, .specs-summary { grid-template-columns: 1fr; }
.status-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .status-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
@@ -388,33 +426,81 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
</div> </div>
</header> </header>
<section class="layout"> <nav class="tabbar" aria-label="Views">
<div class="stack"> <button class="tab-button active" type="button" data-tab-target="screen-control">Screen Control</button>
<div class="panel"> <button class="tab-button" type="button" data-tab-target="system-specs">System Specs</button>
<div class="section-head"> </nav>
<h2>Rotation Setup</h2>
<span class="subtle">/config/Monitor3.json</span> <section class="tab-panel" data-tab-panel="screen-control">
</div> <section class="layout">
<div class="controls"> <div class="stack">
<label> <div class="panel">
<span>Switch Time</span> <div class="section-head">
<input id="switchTime" type="number" min="1" max="600" value="10"> <h2>Rotation Setup</h2>
</label> <span class="subtle">/config/Monitor3.json</span>
<label> </div>
<span>Upload Source</span> <div class="controls">
<input id="upload" type="file" accept="image/*"> <label>
</label> <span>Switch Time</span>
<div class="actions"> <input id="switchTime" type="number" min="1" max="600" value="10">
<button id="disable" class="danger" type="button">Disable</button> </label>
<label>
<span>Upload Source</span>
<input id="upload" type="file" accept="image/*">
</label>
<div class="actions">
<button id="disable" class="danger" type="button">Disable</button>
</div>
</div>
<div class="toggle-row">
<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="gifsEnabled" type="checkbox"> GIFs</label>
</div> </div>
</div>
<div class="toggle-row">
<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="gifsEnabled" type="checkbox"> GIFs</label>
</div> </div>
</div> </div>
<aside class="status-grid">
<div class="card status"><span>Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card status"><span>Images</span><strong id="imageCountValue">-</strong></div>
<div class="card status"><span>Display</span><strong id="displayStatusValue">-</strong></div>
<div class="card status"><span>Frame</span><strong id="currentImageValue">-</strong></div>
<div class="card status"><span>Target</span><strong id="displayTargetValue">-</strong></div>
<div class="card status"><span>Config</span><strong id="configPathValue">-</strong></div>
</aside>
</section>
<section class="workbench">
<div class="panel">
<div class="section-head">
<h2>Available Images</h2>
<span class="subtle">960 x 376</span>
</div>
<div id="gallery" class="gallery"></div>
</div>
<div class="panel">
<div class="section-head">
<h2>Rotation Order</h2>
<span class="subtle">Live queue</span>
</div>
<div id="order" class="order"></div>
<p id="displayErrorValue" class="muted"></p>
</div>
</section>
</section>
<section class="tab-panel system-tab" data-tab-panel="system-specs" hidden>
<section class="specs-summary">
<div class="card status"><span>Display</span><strong id="displayStatusMirror">-</strong><small id="displayTargetMirror">-</small></div>
<div class="card status"><span>Panel State</span><strong id="customPanelMirror">-</strong><small id="currentImageMirror">-</small></div>
<div class="card status"><span>Config</span><strong id="configPathMirror">-</strong><small id="imageCountMirror">-</small></div>
</section>
<section class="panel">
<div class="section-head">
<h2>System Specs</h2>
<span class="subtle">Live host metrics</span>
</div>
<section class="metrics-grid"> <section class="metrics-grid">
<div class="card metric-card"><span class="metric-label">CPU</span><strong id="cpuUsageValue">-</strong><small id="cpuMetaValue">-</small></div> <div class="card metric-card"><span class="metric-label">CPU</span><strong id="cpuUsageValue">-</strong><small id="cpuMetaValue">-</small></div>
<div class="card metric-card"><span class="metric-label">Memory</span><strong id="memUsageValue">-</strong><small id="memMetaValue">-</small></div> <div class="card metric-card"><span class="metric-label">Memory</span><strong id="memUsageValue">-</strong><small id="memMetaValue">-</small></div>
@@ -423,34 +509,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="card metric-card"><span class="metric-label">Load Avg</span><strong id="loadValue">-</strong><small id="uptimeValue">-</small></div> <div class="card metric-card"><span class="metric-label">Load Avg</span><strong id="loadValue">-</strong><small id="uptimeValue">-</small></div>
<div class="card metric-card"><span class="metric-label">Temperatures</span><strong id="tempCpuValue">-</strong><small id="tempGpuValue">-</small></div> <div class="card metric-card"><span class="metric-label">Temperatures</span><strong id="tempCpuValue">-</strong><small id="tempGpuValue">-</small></div>
</section> </section>
</div> </section>
<aside class="status-grid">
<div class="card status"><span>Panel</span><strong id="customPanelValue">-</strong></div>
<div class="card status"><span>Images</span><strong id="imageCountValue">-</strong></div>
<div class="card status"><span>Display</span><strong id="displayStatusValue">-</strong></div>
<div class="card status"><span>Frame</span><strong id="currentImageValue">-</strong></div>
<div class="card status"><span>Target</span><strong id="displayTargetValue">-</strong></div>
<div class="card status"><span>Config</span><strong id="configPathValue">-</strong></div>
</aside>
</section>
<section class="workbench">
<div class="panel">
<div class="section-head">
<h2>Available Images</h2>
<span class="subtle">960 x 376</span>
</div>
<div id="gallery" class="gallery"></div>
</div>
<div class="panel">
<div class="section-head">
<h2>Rotation Order</h2>
<span class="subtle">Live queue</span>
</div>
<div id="order" class="order"></div>
<p id="displayErrorValue" class="muted"></p>
</div>
</section> </section>
</main> </main>
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
@@ -465,11 +524,14 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false, gifs_enabled: true }, setup: { switch_time: "10", custom_panel: false, specs_enabled: false, memes_enabled: false, gifs_enabled: true },
display: {}, display: {},
system: {}, system: {},
activeTab: "screen-control",
dirty: false, dirty: false,
}; };
const el = { const el = {
gallery: document.getElementById("gallery"), gallery: document.getElementById("gallery"),
order: document.getElementById("order"), order: document.getElementById("order"),
tabButtons: Array.from(document.querySelectorAll("[data-tab-target]")),
tabPanels: Array.from(document.querySelectorAll("[data-tab-panel]")),
switchTime: document.getElementById("switchTime"), switchTime: document.getElementById("switchTime"),
specsEnabled: document.getElementById("specsEnabled"), specsEnabled: document.getElementById("specsEnabled"),
memesEnabled: document.getElementById("memesEnabled"), memesEnabled: document.getElementById("memesEnabled"),
@@ -481,6 +543,12 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
displayTargetValue: document.getElementById("displayTargetValue"), displayTargetValue: document.getElementById("displayTargetValue"),
displayErrorValue: document.getElementById("displayErrorValue"), displayErrorValue: document.getElementById("displayErrorValue"),
configPathValue: document.getElementById("configPathValue"), configPathValue: document.getElementById("configPathValue"),
displayStatusMirror: document.getElementById("displayStatusMirror"),
displayTargetMirror: document.getElementById("displayTargetMirror"),
customPanelMirror: document.getElementById("customPanelMirror"),
currentImageMirror: document.getElementById("currentImageMirror"),
configPathMirror: document.getElementById("configPathMirror"),
imageCountMirror: document.getElementById("imageCountMirror"),
cpuUsageValue: document.getElementById("cpuUsageValue"), cpuUsageValue: document.getElementById("cpuUsageValue"),
cpuMetaValue: document.getElementById("cpuMetaValue"), cpuMetaValue: document.getElementById("cpuMetaValue"),
memUsageValue: document.getElementById("memUsageValue"), memUsageValue: document.getElementById("memUsageValue"),
@@ -522,6 +590,34 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
function gifFrameLabel(image) {
if (!image || !image.animated) return "";
if (image.display_frame_count && image.display_frame_count !== image.frame_count) {
return ` • GIF • ${image.frame_count} source • ${image.display_frame_count} display`;
}
return ` • GIF • ${image.frame_count} Frames`;
}
function orderFrameLabel(image) {
if (!image || !image.animated) return "";
if (image.display_frame_count && image.display_frame_count !== image.frame_count) {
return `${image.frame_count} source • ${image.display_frame_count} display`;
}
return `${image.frame_count} Frames`;
}
function setActiveTab(name) {
state.activeTab = name;
for (const button of el.tabButtons) {
const isActive = button.dataset.tabTarget === name;
button.classList.toggle("active", isActive);
button.setAttribute("aria-selected", isActive ? "true" : "false");
}
for (const panel of el.tabPanels) {
panel.hidden = panel.dataset.tabPanel !== name;
}
}
function move(index, delta) { function move(index, delta) {
const target = index + delta; const target = index + delta;
if (target < 0 || target >= state.activeImages.length) return; if (target < 0 || target >= state.activeImages.length) return;
@@ -620,6 +716,12 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.uptimeValue.textContent = `Uptime ${state.system.uptime || "-"}`; el.uptimeValue.textContent = `Uptime ${state.system.uptime || "-"}`;
el.tempCpuValue.textContent = `CPU ${state.system.temperature_cpu || "-"}`; el.tempCpuValue.textContent = `CPU ${state.system.temperature_cpu || "-"}`;
el.tempGpuValue.textContent = `GPU ${state.system.temperature_gpu || "-"}`; el.tempGpuValue.textContent = `GPU ${state.system.temperature_gpu || "-"}`;
el.displayStatusMirror.textContent = el.displayStatusValue.textContent;
el.displayTargetMirror.textContent = state.display.device || "-";
el.customPanelMirror.textContent = el.customPanelValue.textContent;
el.currentImageMirror.textContent = state.display.current_image || "No active frame";
el.configPathMirror.textContent = data.monitor_path;
el.imageCountMirror.textContent = `${state.activeImages.length} selected`;
updateDirtyUi(); updateDirtyUi();
render(); render();
} }
@@ -685,7 +787,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<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">${safeLabel}</div>
<div class="tile-meta">${bytes(image.size)}${image.animated ? ` • GIF • ${image.frame_count} Frames` : ""}</div> <div class="tile-meta">${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="${selected ? "Remove" : "Add"}">${selected ? "Remove" : "Add"}</button>
<button class="danger" type="button">Delete</button> <button class="danger" type="button">Delete</button>
@@ -742,7 +844,7 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
<div class="index">${index + 1}</div> <div class="index">${index + 1}</div>
<div class="meta"> <div class="meta">
<div>${safeLabel}</div> <div>${safeLabel}</div>
<div class="muted">${isCurrent ? "Currently shown" : (image && image.animated ? `Animated • ${image.frame_count} Frames` : "")}</div> <div class="muted">${isCurrent ? "Currently shown" : (image && image.animated ? `Animated • ${orderFrameLabel(image)}` : "")}</div>
</div> </div>
<div class="mini"> <div class="mini">
<button class="ghost" type="button" title="Move up" aria-label="Move ${safeLabel} up">↑</button> <button class="ghost" type="button" title="Move up" aria-label="Move ${safeLabel} up">↑</button>
@@ -769,7 +871,11 @@ const INDEX_HTML: &str = r##"<!DOCTYPE html>
el.specsEnabled.addEventListener("change", markDirty); el.specsEnabled.addEventListener("change", markDirty);
el.memesEnabled.addEventListener("change", markDirty); el.memesEnabled.addEventListener("change", markDirty);
el.gifsEnabled.addEventListener("change", markDirty); el.gifsEnabled.addEventListener("change", markDirty);
el.tabButtons.forEach((button) => {
button.addEventListener("click", () => setActiveTab(button.dataset.tabTarget));
});
setActiveTab("screen-control");
load({ force: true }).catch((err) => toast(err.message)); load({ force: true }).catch((err) => toast(err.message));
setInterval(() => load().catch(() => {}), 5000); setInterval(() => load().catch(() => {}), 5000);
</script> </script>