feat(Q4): kiosk ambient mode with camera rotation, alert takeover, dimming

Add GET /kiosk/ambient route and standalone fullscreen template with
rotating camera snapshots (crossfade), top bar clock/date, pet status
bottom bar, SSE-driven alert takeover with HLS playback, configurable
screen dimming, and 6-hour auto-refresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee 2026-04-03 19:06:57 -04:00
parent d69bf6d6af
commit b4dbb41624
3 changed files with 504 additions and 1 deletions

View File

@ -0,0 +1,29 @@
import pytest
from vigilar.config import VigilarConfig
from vigilar.web.app import create_app
@pytest.fixture
def kiosk_app():
cfg = VigilarConfig()
app = create_app(cfg)
app.config["TESTING"] = True
return app
def test_ambient_route_exists(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert rv.status_code == 200
def test_ambient_contains_clock(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert b"clock" in rv.data
def test_ambient_contains_hls(kiosk_app):
with kiosk_app.test_client() as c:
rv = c.get("/kiosk/ambient")
assert b"hls.min.js" in rv.data

View File

@ -1,4 +1,4 @@
"""Kiosk blueprint — fullscreen 2x2 grid for TV display."""
"""Kiosk blueprint — fullscreen 2x2 grid and ambient mode."""
from flask import Blueprint, current_app, render_template
@ -10,3 +10,12 @@ def kiosk_view():
cfg = current_app.config.get("VIGILAR_CONFIG")
cameras = cfg.cameras if cfg else []
return render_template("kiosk.html", cameras=cameras)
@kiosk_bp.route("/ambient")
def ambient_view():
cfg = current_app.config.get("VIGILAR_CONFIG")
cameras = cfg.cameras if cfg else []
raw_kiosk = cfg.kiosk if cfg and hasattr(cfg, "kiosk") else None
kiosk_cfg = raw_kiosk.model_dump() if raw_kiosk is not None and hasattr(raw_kiosk, "model_dump") else raw_kiosk
return render_template("kiosk/ambient.html", cameras=cameras, kiosk_config=kiosk_cfg)

View File

@ -0,0 +1,465 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="21600">
<title>Vigilar — Ambient</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
landscape: orientation;
}
/* ── Layout ── */
#ambient-root {
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
}
/* ── Top bar (10%) ── */
#top-bar {
flex: 0 0 10%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2.5rem;
background: rgba(0, 0, 0, 0.5);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}
#clock {
font-size: 3.5rem;
font-weight: 200;
letter-spacing: 0.05em;
line-height: 1;
}
#date-label {
font-size: 1.1rem;
color: #aaa;
text-align: center;
}
#weather-label {
font-size: 1.5rem;
color: #aaa;
min-width: 6rem;
text-align: right;
}
/* ── Center camera (70%) ── */
#camera-area {
flex: 0 0 70%;
position: relative;
overflow: hidden;
background: #000;
}
.cam-slide {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 1s ease-in-out;
}
.cam-slide.visible { opacity: 1; }
.cam-slide img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cam-label {
position: absolute;
bottom: 1rem;
left: 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
color: #fff;
text-shadow: 0 1px 4px rgba(0,0,0,0.9);
pointer-events: none;
}
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #22c55e;
flex-shrink: 0;
animation: live-pulse 2s infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
#no-camera {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #555;
font-size: 1.2rem;
}
/* ── Bottom bar (20%) ── */
#bottom-bar {
flex: 0 0 20%;
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0 2rem;
background: rgba(0, 0, 0, 0.5);
border-top: 1px solid rgba(255, 255, 255, 0.07);
overflow-x: auto;
}
#bottom-bar::-webkit-scrollbar { display: none; }
.pet-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
min-width: 7rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.06);
}
.pet-name {
font-size: 1rem;
font-weight: 600;
color: #e5e7eb;
}
.pet-location {
font-size: 0.75rem;
color: #9ca3af;
text-align: center;
}
.pet-time {
font-size: 0.7rem;
color: #6b7280;
}
#bottom-placeholder {
color: #555;
font-size: 0.9rem;
}
/* ── Alert takeover ── */
#alert-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 100;
background: #000;
flex-direction: column;
}
#alert-overlay.active { display: flex; }
#alert-video-wrap {
flex: 1;
position: relative;
}
#alert-video {
width: 100%;
height: 100%;
object-fit: cover;
}
#alert-badge {
position: absolute;
top: 1.25rem;
left: 50%;
transform: translateX(-50%);
padding: 0.4rem 1.25rem;
border-radius: 99px;
background: rgba(220, 53, 69, 0.9);
color: #fff;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
}
#alert-countdown {
position: absolute;
bottom: 1rem;
right: 1.5rem;
font-size: 0.85rem;
color: rgba(255,255,255,0.5);
}
/* ── Dim overlay ── */
#dim-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 50;
background: rgba(0, 0, 0, 0.7);
pointer-events: none;
}
#dim-overlay.active { display: block; }
</style>
</head>
<body>
<div id="ambient-root">
<div id="top-bar">
<div id="clock">--:--</div>
<div id="date-label">Loading…</div>
<div id="weather-label"></div>
</div>
<div id="camera-area">
<div id="cam-slide-a" class="cam-slide visible">
<img id="cam-img-a" src="" alt="">
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-a"></span></div>
</div>
<div id="cam-slide-b" class="cam-slide">
<img id="cam-img-b" src="" alt="">
<div class="cam-label"><span class="live-dot"></span><span id="cam-name-b"></span></div>
</div>
<div id="no-camera" style="display:none;">No cameras configured</div>
</div>
<div id="bottom-bar">
<span id="bottom-placeholder">Loading pet status…</span>
</div>
</div>
<!-- Alert takeover -->
<div id="alert-overlay">
<div id="alert-video-wrap">
<video id="alert-video" autoplay muted playsinline></video>
<div id="alert-badge">Alert</div>
<div id="alert-countdown"></div>
</div>
</div>
<!-- Screen dim overlay -->
<div id="dim-overlay"></div>
<script src="/static/js/hls.min.js"></script>
<script>
(function () {
'use strict';
// ── Config (from server, with JS fallbacks) ──────────────────────────
const serverCfg = {{ kiosk_config | tojson }};
const cfg = {
rotation_interval_s: (serverCfg && serverCfg.rotation_interval_s) || 10,
dim_start: (serverCfg && serverCfg.dim_start) || "23:00",
dim_end: (serverCfg && serverCfg.dim_end) || "06:00",
alert_timeout_s: (serverCfg && serverCfg.alert_timeout_s) || 30,
predator_alert_timeout_s: (serverCfg && serverCfg.predator_alert_timeout_s) || 60,
};
const cameras = {{ cameras | tojson }};
// ── Clock & Date ─────────────────────────────────────────────────────
function updateClock() {
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
document.getElementById('clock').textContent = hh + ':' + mm;
const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
document.getElementById('date-label').textContent =
days[now.getDay()] + ', ' + months[now.getMonth()] + ' ' + now.getDate();
}
updateClock();
setInterval(updateClock, 60000);
// ── Screen dimming ───────────────────────────────────────────────────
function parseTOD(str) {
const [h, m] = str.split(':').map(Number);
return h * 60 + (m || 0);
}
function checkDim() {
const now = new Date();
const cur = now.getHours() * 60 + now.getMinutes();
const start = parseTOD(cfg.dim_start);
const end = parseTOD(cfg.dim_end);
let dimmed;
if (start > end) {
// spans midnight
dimmed = cur >= start || cur < end;
} else {
dimmed = cur >= start && cur < end;
}
document.getElementById('dim-overlay').classList.toggle('active', dimmed);
}
checkDim();
setInterval(checkDim, 60000);
// ── Camera rotation ──────────────────────────────────────────────────
let camIndex = 0;
let activeSlot = 'a'; // 'a' or 'b'
function snapshotUrl(cam) {
return '/cameras/' + cam.id + '/snapshot';
}
function loadCamera(slot, cam) {
const img = document.getElementById('cam-img-' + slot);
const name = document.getElementById('cam-name-' + slot);
img.src = snapshotUrl(cam) + '?t=' + Date.now();
name.textContent = cam.display_name;
}
function rotateCameras() {
if (!cameras || cameras.length === 0) {
document.getElementById('no-camera').style.display = 'flex';
document.getElementById('cam-slide-a').style.display = 'none';
document.getElementById('cam-slide-b').style.display = 'none';
return;
}
const nextSlot = activeSlot === 'a' ? 'b' : 'a';
camIndex = (camIndex + 1) % cameras.length;
loadCamera(nextSlot, cameras[camIndex]);
// Crossfade
const showing = document.getElementById('cam-slide-' + activeSlot);
const incoming = document.getElementById('cam-slide-' + nextSlot);
incoming.classList.add('visible');
setTimeout(() => {
showing.classList.remove('visible');
activeSlot = nextSlot;
}, 1000);
}
// Initial load
if (cameras && cameras.length > 0) {
loadCamera('a', cameras[0]);
if (cameras.length > 1) {
loadCamera('b', cameras[1]);
}
} else {
document.getElementById('no-camera').style.display = 'flex';
document.getElementById('cam-slide-a').style.display = 'none';
document.getElementById('cam-slide-b').style.display = 'none';
}
setInterval(rotateCameras, cfg.rotation_interval_s * 1000);
// ── Pet status ───────────────────────────────────────────────────────
function timeAgo(isoStr) {
if (!isoStr) return '';
const diff = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000);
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
return Math.floor(diff / 3600) + 'h ago';
}
function fetchPetStatus() {
fetch('/pets/api/status')
.then(r => r.ok ? r.json() : null)
.then(data => {
const bar = document.getElementById('bottom-bar');
const placeholder = document.getElementById('bottom-placeholder');
if (!data || !data.pets || data.pets.length === 0) {
if (placeholder) placeholder.textContent = 'No pets tracked';
return;
}
if (placeholder) placeholder.remove();
// Rebuild pet cards (preserve existing to avoid flicker on re-render)
bar.innerHTML = '';
data.pets.forEach(pet => {
const card = document.createElement('div');
card.className = 'pet-card';
card.innerHTML =
'<div class="pet-name">' + (pet.name || 'Unknown') + '</div>' +
'<div class="pet-location">' + (pet.last_camera || '—') + '</div>' +
'<div class="pet-time">' + timeAgo(pet.last_seen) + '</div>';
bar.appendChild(card);
});
})
.catch(() => {});
}
fetchPetStatus();
setInterval(fetchPetStatus, 30000);
// ── Alert takeover (SSE) ─────────────────────────────────────────────
let alertTimer = null;
let alertHls = null;
function dismissAlert() {
if (alertTimer) { clearTimeout(alertTimer); alertTimer = null; }
if (alertHls) { alertHls.destroy(); alertHls = null; }
const vid = document.getElementById('alert-video');
vid.src = '';
document.getElementById('alert-overlay').classList.remove('active');
}
function showAlert(event) {
const overlay = document.getElementById('alert-overlay');
const badge = document.getElementById('alert-badge');
const countdown = document.getElementById('alert-countdown');
const vid = document.getElementById('alert-video');
// Dismiss any previous alert
dismissAlert();
// Set badge text
const label = event.type || 'Alert';
badge.textContent = label;
// Choose timeout
const isPredator = (event.type || '').toLowerCase().includes('predator');
const timeout = (isPredator ? cfg.predator_alert_timeout_s : cfg.alert_timeout_s) * 1000;
// Load HLS feed if camera provided
if (event.camera_id) {
const hlsUrl = '/cameras/' + event.camera_id + '/stream.m3u8';
if (Hls && Hls.isSupported()) {
alertHls = new Hls();
alertHls.loadSource(hlsUrl);
alertHls.attachMedia(vid);
} else if (vid.canPlayType('application/vnd.apple.mpegurl')) {
vid.src = hlsUrl;
}
}
overlay.classList.add('active');
// Countdown display
let remaining = Math.ceil(timeout / 1000);
countdown.textContent = remaining + 's';
const countdownInterval = setInterval(() => {
remaining -= 1;
countdown.textContent = remaining + 's';
if (remaining <= 0) clearInterval(countdownInterval);
}, 1000);
alertTimer = setTimeout(() => {
clearInterval(countdownInterval);
dismissAlert();
}, timeout);
}
// SSE listener
function connectSSE() {
const es = new EventSource('/events/stream');
es.onmessage = function (e) {
try {
const event = JSON.parse(e.data);
if (event && event.type) {
showAlert(event);
}
} catch (_) {}
};
es.onerror = function () {
es.close();
// Reconnect after 5s
setTimeout(connectSSE, 5000);
};
}
connectSSE();
})();
</script>
</body>
</html>