feat: add accordion overlay controls and icon API
This commit is contained in:
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -13,4 +13,5 @@ pub struct CountdownTickPayload {
|
||||
pub enum AppEvent {
|
||||
Tick(CountdownTickPayload),
|
||||
Changed(Vec<CountdownSnapshotDto>),
|
||||
ConfigChanged(u64),
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user