feat: add accordion overlay controls and icon API

This commit is contained in:
2026-03-06 16:28:03 +01:00
parent 027027b203
commit 35358c2d4e
16 changed files with 359 additions and 46 deletions

View File

@@ -1,5 +1,6 @@
use crate::countdown::events::AppEvent;
use crate::countdown::service::CountdownService;
use std::collections::HashMap;
use tokio::sync::broadcast;
#[derive(Clone, Debug)]
@@ -28,11 +29,30 @@ impl ClockAnchor {
}
}
}
#[derive(Debug, Clone)]
pub struct OverlayConfig {
pub icon: String,
pub text_color: String,
pub bg_color: String,
}
impl Default for OverlayConfig {
fn default() -> Self {
Self {
icon: String::new(),
text_color: "white".to_string(),
bg_color: "transparent".to_string(),
}
}
}
#[derive(Debug)]
pub struct AppState {
pub clock_anchor: ClockAnchor,
pub countdown_service: CountdownService,
pub event_bus: broadcast::Sender<AppEvent>,
pub overlay_configs: tokio::sync::Mutex<HashMap<u64, OverlayConfig>>,
}
impl AppState {
@@ -42,6 +62,7 @@ impl AppState {
clock_anchor: ClockAnchor::new(),
countdown_service: CountdownService::new(),
event_bus,
overlay_configs: tokio::sync::Mutex::new(HashMap::new()),
}
}
}

View File

@@ -169,6 +169,30 @@ pub async fn countdown_snapshot(
})
}
#[command]
pub async fn set_overlay_config(
state: State<'_, Arc<AppState>>,
id: u64,
icon: String,
text_color: String,
bg_color: String,
) -> Result<(), String> {
state
.overlay_configs
.lock()
.await
.insert(
id,
crate::app_state::OverlayConfig {
icon,
text_color,
bg_color,
},
);
let _ = state.event_bus.send(AppEvent::ConfigChanged(id));
Ok(())
}
pub(crate) fn spawn_ticker(app: AppHandle) {
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(100));

View File

@@ -13,4 +13,5 @@ pub struct CountdownTickPayload {
pub enum AppEvent {
Tick(CountdownTickPayload),
Changed(Vec<CountdownSnapshotDto>),
ConfigChanged(u64),
}

View File

@@ -9,7 +9,7 @@ use std::sync::Arc;
use crate::app_state::AppState;
use crate::countdown::commands::{
countdown_create, countdown_delete, countdown_list, countdown_pause, countdown_reset,
countdown_resume, countdown_snapshot, countdown_start, spawn_ticker,
countdown_resume, countdown_snapshot, countdown_start, set_overlay_config, spawn_ticker,
};
use tauri::Manager;
@@ -29,6 +29,7 @@ pub fn run() {
countdown_resume,
countdown_pause,
countdown_snapshot,
set_overlay_config,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")

View File

@@ -1,15 +1,19 @@
use crate::app_state::AppState;
use axum::http::Method;
use axum::{routing::get, Router};
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
mod routes;
pub async fn start(state: Arc<AppState>) {
let app = Router::new()
.route("/api/icons", get(routes::list_icons))
.route("/overlay/countdown", get(routes::overlay_countdown))
.route("/sse/countdown/{id}", get(routes::sse_countdown))
.nest_service("/static", ServeDir::new("public"))
.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET]))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:7420")

View File

@@ -1,6 +1,7 @@
use crate::app_state::AppState;
use crate::countdown::commands::build_snapshot_dtos;
use crate::countdown::events::AppEvent;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::response::sse::KeepAlive;
use axum::response::{
@@ -27,6 +28,13 @@ pub async fn overlay_countdown(
.and_then(|snaps| snaps.into_iter().find(|s| s.id == q.id))
.map(|s| format_remaining(s.duration as u64))
.unwrap_or_else(|| "??:??:??.???".to_string());
let config = state
.overlay_configs
.lock()
.await
.get(&q.id)
.cloned()
.unwrap_or_default();
let mut env = minijinja::Environment::new();
env.add_template(
@@ -37,7 +45,13 @@ pub async fn overlay_countdown(
let html = env
.get_template("page")
.unwrap()
.render(minijinja::context! { id => q.id, remaining => remaining })
.render(minijinja::context! {
id => q.id,
remaining => remaining,
icon => config.icon,
text_color => config.text_color,
bg_color => config.bg_color,
})
.unwrap();
Html(html)
}
@@ -56,6 +70,9 @@ pub async fn sse_countdown(
.event("tick")
.data(format_remaining(s.duration as u64)))
}),
Ok(AppEvent::ConfigChanged(cid)) if cid == id => {
Some(Ok(Event::default().event("reload").data("")))
}
_ => None,
});
Sse::new(stream).keep_alive(
@@ -72,3 +89,19 @@ fn format_remaining(ms: u64) -> String {
let millis = ms % 1_000;
format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis)
}
pub async fn list_icons() -> Json<Vec<String>> {
let mut names = Vec::new();
if let Ok(mut entries) = tokio::fs::read_dir("public/icons").await {
while let Ok(Some(entry)) = entries.next_entry().await {
let path = entry.path();
if matches!(path.extension().and_then(|e| e.to_str()), Some("svg" | "png")) {
if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
names.push(filename.to_string());
}
}
}
}
names.sort();
Json(names)
}

View File

@@ -1,17 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link href="/static/overlay.css" rel="stylesheet">
<meta charset="UTF-8">
<link rel="stylesheet" href="/static/overlay.css">
<style>
body { background-color: {{ bg_color }}; }
#timer { color: {{ text_color }}; }
#icon { width: 3rem; height: 3rem; vertical-align: middle; margin-right: 0.5rem; }
</style>
</head>
<body>
<div id="timer">{{ remaining }}</div>
<script>
<div id="display">
{% if icon %}<img id="icon" src="/static/icons/{{ icon }}" />{% endif %}
<span id="timer">{{ remaining }}</span>
</div>
<script>
const es = new EventSource('/sse/countdown/{{ id }}');
es.addEventListener('tick', e => {
document.getElementById('timer').textContent = e.data;
});
es.addEventListener('tick', e => { document.getElementById('timer').textContent = e.data; });
es.addEventListener('reload', () => location.reload());
es.onerror = () => setTimeout(() => location.reload(), 3000);
</script>
</script>
</body>
</html>