This commit is contained in:
TutorialsGHG
2026-04-12 22:04:59 +02:00
commit 13bf50b01e
10 changed files with 3388 additions and 0 deletions

695
pwa/index.html Normal file
View File

@@ -0,0 +1,695 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#08080a">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NotifyPulse">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="manifest" href="/pwa/manifest.json">
<title>NotifyPulse</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap');
:root {
--bg: #08080a;
--card: #111115;
--border: #1e1e28;
--accent: #4f8ef7;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--text: #e2e2e8;
--muted: #6b6b7b;
--safe-t: env(safe-area-inset-top, 0px);
--safe-b: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body { height: 100%; background: var(--bg); overflow: hidden; }
body { font-family: 'Space Grotesk', system-ui, sans-serif; color: var(--text); }
/* ── SCREENS ── */
.screen {
position: fixed; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
transition: opacity .45s ease, transform .45s ease;
overflow: hidden;
}
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); }
.screen.gone { display: none; }
/* ── SHARED BACKGROUND (main.png, persists across screens) ── */
#shared-bg {
position: fixed; inset: 0; z-index: 0;
background-size: contain; background-position: center; background-repeat: no-repeat;
transition: opacity 1s ease;
opacity: 0;
}
#shared-bg.loaded { opacity: 1; }
/* Overlay darkening — heavier on selector, lighter on splash */
#shared-bg-overlay {
position: fixed; inset: 0; z-index: 1;
background: rgba(8,8,10,0);
transition: background .6s ease;
}
#shared-bg-overlay.for-splash { background: rgba(8,8,10,.45); }
#shared-bg-overlay.for-selector { background: rgba(8,8,10,.72); backdrop-filter: blur(18px) saturate(.7); -webkit-backdrop-filter: blur(18px) saturate(.7); }
#shared-bg-overlay.for-app { background: rgba(8,8,10,.88); }
/* ── SPLASH ── */
#splash { z-index: 10; background: transparent; }
.splash-content {
position: relative; z-index: 2;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding-bottom: 40px;
animation: fadeUp .8s ease both; animation-delay: .3s;
}
@keyframes fadeUp { from { opacity:0; transform:translateY(18px); } to { opacity:1; transform:translateY(0); } }
.splash-logo {
font-family: 'Space Mono', monospace; font-size: 1.6rem; font-weight: 700;
letter-spacing: .1em; color: #fff;
text-shadow: 0 2px 40px rgba(79,142,247,.7);
}
.splash-sub {
font-size: .72rem; color: rgba(255,255,255,.45);
letter-spacing: .2em; text-transform: uppercase; font-family: 'Space Mono', monospace;
}
.splash-tap {
margin-top: 36px; font-size: .78rem; color: rgba(255,255,255,.3);
letter-spacing: .1em; text-transform: uppercase;
animation: blink 2.2s infinite;
}
@keyframes blink { 0%,100%{opacity:.3} 50%{opacity:.65} }
/* ── SELECTOR ── */
#selector {
z-index: 5; background: transparent;
justify-content: flex-start; align-items: stretch;
}
.sel-header {
position: relative; z-index: 2;
padding: calc(var(--safe-t) + 22px) 20px 16px;
text-align: center; flex-shrink: 0;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.sel-header h1 {
font-family: 'Space Mono', monospace; font-size: 1.05rem; font-weight: 700;
color: rgba(255,255,255,.9); letter-spacing: .1em;
text-shadow: 0 1px 20px rgba(79,142,247,.5);
}
.sel-header p { font-size: .76rem; color: rgba(255,255,255,.4); margin-top: 4px; }
.uc-list {
flex: 1; overflow-y: auto; position: relative; z-index: 2;
padding: 14px 16px calc(var(--safe-b) + 16px);
display: flex; flex-direction: column; gap: 10px;
}
.uc-item {
background: rgba(17,17,21,.75); border: 1px solid rgba(255,255,255,.1);
border-radius: 14px; padding: 15px 17px;
display: flex; align-items: center; gap: 13px;
cursor: pointer; transition: .2s; position: relative; overflow: hidden;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.uc-item::before {
content:''; position:absolute; left:0; top:0; bottom:0; width:4px;
background: var(--uc-color, var(--accent));
border-radius: 14px 0 0 14px;
}
.uc-item:active { transform: scale(.98); }
.uc-item.active { border-color: rgba(255,255,255,.2); background: rgba(79,142,247,.12); }
.uc-dot {
width: 38px; height: 38px; border-radius: 11px; flex-shrink: 0;
background: var(--uc-color, var(--accent));
opacity: .25; display: flex; align-items: center; justify-content: center; font-size: 1.1rem;
}
.uc-item.active .uc-dot { opacity: .45; }
.uc-info { flex: 1; min-width: 0; }
.uc-name-big { font-size: .92rem; font-weight: 600; color: rgba(255,255,255,.9); }
.uc-blocks-sm { font-size: .63rem; color: rgba(255,255,255,.4); margin-top: 3px; font-family: 'Space Mono', monospace; }
.uc-active-tag {
font-size: .58rem; font-family: 'Space Mono', monospace; color: var(--green);
background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.25);
border-radius: 4px; padding: 2px 6px; text-transform: uppercase; letter-spacing: .08em;
flex-shrink: 0;
}
.uc-arrow { color: rgba(255,255,255,.25); font-size: .9rem; flex-shrink: 0; }
/* ── APP ── */
#app { z-index: 1; background: transparent; justify-content: flex-start; align-items: stretch; }
.app-header {
position: relative; z-index: 2;
padding: calc(var(--safe-t) + 17px) 17px 13px;
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,.08);
}
.app-header h1 {
font-family: 'Space Mono', monospace; font-size: .92rem; font-weight: 700;
color: var(--accent); letter-spacing: .06em;
}
.conn-badge {
display: flex; align-items: center; gap: 6px; padding: 5px 12px;
border-radius: 999px; font-size: .7rem; font-weight: 600;
border: 1px solid rgba(255,255,255,.1); background: rgba(17,17,21,.6); color: var(--muted);
transition: .3s;
}
.conn-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.conn-badge.ok { border-color: rgba(34,197,94,.35); color: var(--green); }
.conn-badge.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.conn-badge.err { border-color: rgba(239,68,68,.35); color: var(--red); }
.conn-badge.err .dot { background: var(--red); }
.app-body {
flex: 1; overflow-y: auto; position: relative; z-index: 2;
padding: 13px 15px calc(var(--safe-b) + 18px);
display: flex; flex-direction: column; gap: 11px;
}
/* Stat row */
.stat-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 11px 13px; backdrop-filter: blur(4px); }
.sl { font-size: 9px; color: var(--muted); font-family: 'Space Mono', monospace; text-transform: uppercase; letter-spacing: .07em; margin-bottom: 3px; }
.sv { font-size: .92rem; font-weight: 700; }
.sv.green { color: var(--green); }
.sv.yellow { color: var(--yellow); }
.sv.accent { color: var(--accent); }
/* Cards */
.c { background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.08); border-radius: 13px; padding: 14px; backdrop-filter: blur(4px); }
.ct { font-size: .6rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); font-family: 'Space Mono', monospace; margin-bottom: 11px; }
/* Wallpaper section */
.wp-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
.wp-tab {
flex: 1; padding: 8px; border-radius: 8px;
border: 1px solid rgba(255,255,255,.1); background: transparent;
color: var(--muted); font-size: .74rem; font-weight: 600;
cursor: pointer; font-family: inherit; transition: .15s;
}
.wp-tab.active { border-color: var(--accent); color: var(--accent); background: rgba(79,142,247,.1); }
.wp-frame {
border-radius: 10px; overflow: hidden; background: rgba(0,0,0,.4);
border: 1px solid rgba(255,255,255,.08); aspect-ratio: 9/16;
max-height: 250px; display: flex; align-items: center; justify-content: center; position: relative;
}
.wp-frame img { width: 100%; height: 100%; object-fit: cover; transition: opacity .4s; }
.wp-ph { display: flex; flex-direction: column; align-items: center; gap: 7px; color: var(--muted); font-size: .72rem; }
/* Buttons */
.btn {
width: 100%; padding: 12px; border: none; border-radius: 10px; cursor: pointer;
font-family: inherit; font-size: .83rem; font-weight: 600; transition: .15s;
display: flex; align-items: center; justify-content: center; gap: 7px;
}
.btn-accent { background: var(--accent); color: #fff; }
.btn-accent:active { filter: brightness(.9); }
.btn-ghost { background: rgba(255,255,255,.07); color: var(--text); border: 1px solid rgba(255,255,255,.1); }
.btn-ghost:active { background: rgba(255,255,255,.12); }
.btn-green { background: rgba(34,197,94,.1); color: var(--green); border: 1px solid rgba(34,197,94,.2); }
.btn-green:active { background: rgba(34,197,94,.2); }
/* Usecase chip at top of app */
.uc-chip {
display: flex; align-items: center; gap: 8px;
background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.1);
border-radius: 10px; padding: 10px 14px; cursor: pointer;
backdrop-filter: blur(4px);
}
.uc-chip-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.uc-chip-name { font-size: .85rem; font-weight: 600; flex: 1; }
.uc-chip-change { font-size: .7rem; color: var(--muted); }
.save-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* ── PWA OVERLAY ── */
#pwa-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: #000;
opacity: 0; pointer-events: none;
transition: opacity .35s ease;
}
#pwa-overlay.visible {
opacity: 1; pointer-events: all;
}
#pwa-overlay img {
width: 100%; height: 100%; object-fit: contain;
display: block;
}
/* ── BG SETTINGS PANEL ── */
#bg-settings-btn {
position: fixed; bottom: calc(var(--safe-b) + 18px); right: 18px;
z-index: 20; width: 36px; height: 36px; border-radius: 50%;
background: rgba(17,17,21,.7); border: 1px solid rgba(255,255,255,.15);
color: rgba(255,255,255,.4); font-size: 1rem; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(6px); transition: .2s;
}
#bg-settings-btn:hover { color: rgba(255,255,255,.8); border-color: rgba(255,255,255,.3); }
#bg-settings-panel {
position: fixed; bottom: calc(var(--safe-b) + 62px); right: 14px;
z-index: 20; width: 240px;
background: rgba(17,17,21,.92); border: 1px solid rgba(255,255,255,.15);
border-radius: 14px; padding: 16px;
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px rgba(0,0,0,.5);
opacity: 0; pointer-events: none; transform: translateY(8px);
transition: opacity .2s ease, transform .2s ease;
}
#bg-settings-panel.open { opacity: 1; pointer-events: all; transform: translateY(0); }
.bgs-title { font-size: .65rem; font-family: 'Space Mono', monospace; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: 14px; }
.bgs-row { margin-bottom: 12px; }
.bgs-label { display: flex; justify-content: space-between; align-items: center; font-size: .74rem; color: rgba(255,255,255,.7); margin-bottom: 6px; }
.bgs-label span { font-family: 'Space Mono', monospace; color: var(--accent); font-size: .7rem; }
.bgs-slider {
-webkit-appearance: none; appearance: none;
width: 100%; height: 4px; border-radius: 2px;
background: rgba(255,255,255,.15); outline: none; cursor: pointer;
}
.bgs-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer; border: 2px solid #fff;
box-shadow: 0 1px 6px rgba(79,142,247,.5);
}
.bgs-save { margin-top: 14px; }
/* Toast */
.toast {
position: fixed; bottom: calc(var(--safe-b) + 80px); left: 50%; transform: translateX(-50%);
background: var(--green); color: #000; padding: 9px 20px; border-radius: 999px;
font-size: .8rem; font-weight: 700; white-space: nowrap;
pointer-events: none; opacity: 0; transition: .3s; z-index: 99;
}
.toast.show { opacity: 1; }
</style>
</head>
<body>
<!-- Persistent blurred background image (behind all screens) -->
<div id="shared-bg"></div>
<div id="shared-bg-overlay" class="for-splash"></div>
<!-- SPLASH -->
<div class="screen" id="splash" onclick="goToSelector()">
<div class="splash-content">
<div class="splash-logo" id="splashName">NOTIFYPULSE</div>
<div class="splash-sub" id="splashSub">V3</div>
<div class="splash-tap">Tap to continue</div>
</div>
</div>
<!-- SELECTOR -->
<div class="screen hidden gone" id="selector">
<div class="sel-header">
<h1 id="selHeaderName">NOTIFYPULSE</h1>
<p>Choose a usecase</p>
</div>
<div class="uc-list" id="ucList">Loading…</div>
</div>
<!-- APP -->
<div class="screen hidden gone" id="app">
<div class="app-header">
<h1 id="appNameEl">NOTIFYPULSE</h1>
<div class="conn-badge" id="connBadge">
<div class="dot"></div><span id="connText">Connecting</span>
</div>
</div>
<div class="app-body">
<div class="uc-chip" onclick="goToSelector()">
<div class="uc-chip-dot" id="ucChipDot" style="background:var(--accent)"></div>
<span class="uc-chip-name" id="ucChipName"></span>
<span class="uc-chip-change">Switch ↗</span>
</div>
<div class="stat-row">
<div class="stat"><div class="sl">Status</div><div class="sv" id="a-status"></div></div>
<div class="stat"><div class="sl">Next Fire</div><div class="sv accent" id="a-next"></div></div>
</div>
<div class="c" id="wpCard">
<div class="ct">Mobile Wallpaper</div>
<div class="wp-tabs">
<button class="wp-tab active" onclick="wpTab('lockscreen',this)">Lock Screen</button>
<button class="wp-tab" onclick="wpTab('background',this)">Home Screen</button>
</div>
<div class="wp-frame">
<img id="wpImg" src="" style="opacity:0">
<div class="wp-ph" id="wpPh">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" opacity=".4"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<span>No wallpaper yet</span>
</div>
</div>
<div style="height:8px"></div>
<div class="save-row">
<button class="btn btn-ghost" onclick="saveWp('lockscreen')">💾 Save Lock</button>
<button class="btn btn-ghost" onclick="saveWp('background')">💾 Save Home</button>
</div>
<div style="height:8px"></div>
<button class="btn btn-green" onclick="requestWallpaper()">🔄 Request New Wallpaper</button>
</div>
<button class="btn btn-ghost" onclick="goToSelector()">↩ Back to Usecases</button>
</div>
</div>
<!-- BG Settings button (always visible) -->
<button id="bg-settings-btn" onclick="toggleBgSettings()" title="Background settings"></button>
<div id="bg-settings-panel">
<div class="bgs-title">Background Appearance</div>
<div class="bgs-row">
<div class="bgs-label">Blur <span id="blurVal">18px</span></div>
<input class="bgs-slider" id="blurSlider" type="range" min="0" max="40" step="1" value="18" oninput="previewBg()">
</div>
<div class="bgs-row">
<div class="bgs-label">Darkness <span id="opacVal">72%</span></div>
<input class="bgs-slider" id="opacSlider" type="range" min="0" max="95" step="1" value="72" oninput="previewBg()">
</div>
<button class="btn btn-accent bgs-save" onclick="saveBgSettings()">Save</button>
</div>
<!-- PWA Overlay — mirrors the desktop screen overlay -->
<div id="pwa-overlay"><img id="pwa-overlay-img" src="" alt=""></div>
<div class="toast" id="toast"></div>
<script>
const CLIENT_ID = 'pwa_' + Math.random().toString(36).slice(2,11);
let appInfo = null, activeUCId = '', allBlocks = [];
let wpImages = {lockscreen:null, background:null}, wpCurTab = 'lockscreen';
let pingInterval, wallpaperInterval, statInterval;
let bgImageLoaded = false;
// ── Screen transitions ─────────────────────────────────────
function show(id) {
// Update background overlay style
const overlay = document.getElementById('shared-bg-overlay');
overlay.className = 'for-' + (id === 'splash' ? 'splash' : id === 'selector' ? 'selector' : 'app');
document.querySelectorAll('.screen').forEach(s => {
s.classList.remove('gone');
s.classList.toggle('hidden', s.id !== id);
});
setTimeout(() => {
document.querySelectorAll('.screen.hidden').forEach(s => s.classList.add('gone'));
}, 500);
}
function goToSelector() {
show('selector');
loadSelectorList();
}
function goToApp(ucId) {
show('app');
setActiveUC(ucId);
}
// ── Init ──────────────────────────────────────────────────
async function init() {
// Load splash image and keep it as shared background
const splashR = await fetchJSON('/api/pwa/splash_image').catch(()=>null);
if (splashR && splashR.image) {
const bg = document.getElementById('shared-bg');
bg.style.backgroundImage = `url('${splashR.image}')`;
requestAnimationFrame(() => { bg.classList.add('loaded'); bgImageLoaded = true; });
}
// Load app info
const info = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (info) {
appInfo = info;
activeUCId = info.active || '';
allBlocks = info.blocks || [];
const settingsName = info.settings_app_name || info.app_name || 'NOTIFYPULSE';
document.getElementById('splashName').textContent = settingsName.toUpperCase();
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
document.getElementById('splashSub').textContent = `${(info.usecases||[]).length} USECASE(S)`;
// Apply saved background appearance
applyBgSettings(
info.pwa_bg_blur !== undefined ? info.pwa_bg_blur : 18,
info.pwa_bg_opacity !== undefined ? info.pwa_bg_opacity : 0.72
);
}
startPing();
startOverlayPoll();
}
// ── Selector list ─────────────────────────────────────────
async function loadSelectorList() {
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (!r) { document.getElementById('ucList').innerHTML = '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">Connection error</p>'; return; }
const settingsName = r.settings_app_name || r.app_name || 'NOTIFYPULSE';
document.getElementById('selHeaderName').textContent = settingsName.toUpperCase();
activeUCId = r.active || '';
allBlocks = r.blocks || [];
const usecases = r.usecases || [];
document.getElementById('ucList').innerHTML = usecases.length ? usecases.map(uc => {
const isActive = uc.id === activeUCId;
const blockIcons = (uc.blocks||[]).map(bid => {
const b = allBlocks.find(x=>x.id===bid);
return b ? b.icon : '';
}).join(' ');
return `<div class="uc-item ${isActive?'active':''}" style="--uc-color:${uc.color}" onclick="pickUC('${uc.id}','${uc.color}')">
<div class="uc-dot" style="background:${uc.color}">${uc.name[0]}</div>
<div class="uc-info">
<div class="uc-name-big">${escHtml(uc.name)}</div>
<div class="uc-blocks-sm">${blockIcons}</div>
</div>
${isActive ? '<span class="uc-active-tag">Active</span>' : '<span class="uc-arrow"></span>'}
</div>`;
}).join('') : '<p style="color:rgba(255,255,255,.3);padding:20px;text-align:center">No usecases yet.<br>Create them in the desktop UI.</p>';
}
async function pickUC(id, color) {
// Activate on server
await postJSON('/api/pwa/activate_usecase', {usecase_id: id}).catch(()=>null);
activeUCId = id;
goToApp(id);
}
// ── App view ──────────────────────────────────────────────
async function setActiveUC(ucId) {
const r = await fetchJSON('/api/pwa/app_name').catch(()=>null);
if (!r) return;
const uc = (r.usecases||[]).find(u=>u.id===ucId);
if (!uc) return;
document.getElementById('appNameEl').textContent = uc.name.toUpperCase();
document.getElementById('ucChipName').textContent = uc.name;
document.getElementById('ucChipDot').style.background = uc.color;
const hasMobile = (uc.blocks||[]).includes('mobile_wallpaper');
document.getElementById('wpCard').style.display = hasMobile ? 'block' : 'none';
clearInterval(statInterval);
statInterval = setInterval(updateStats, 3000);
updateStats();
clearInterval(wallpaperInterval);
if (hasMobile) wallpaperInterval = setInterval(pollWallpaper, 4000);
}
async function updateStats() {
const s = await fetchJSON('/api/state').catch(()=>null);
if (!s) return;
const paused = s.paused;
document.getElementById('a-status').textContent = paused ? '⏸ Paused' : '▶ Running';
document.getElementById('a-status').className = 'sv ' + (paused ? 'yellow' : 'green');
const next = s.next_fire_at ? Math.max(0, Math.round(s.next_fire_at - Date.now()/1000)) : 0;
document.getElementById('a-next').textContent = next > 0 ? `${Math.floor(next/60)}m ${next%60}s` : '—';
// Overlay must vanish immediately when paused
if (paused && _overlayActive) hideOverlay();
}
// ── Wallpaper ─────────────────────────────────────────────
async function pollWallpaper() {
const r = await fetchJSON(`/api/pwa/wallpaper?client_id=${CLIENT_ID}`).catch(()=>null);
if (!r || !r.pending) return;
if (r.lockscreen) wpImages.lockscreen = r.lockscreen;
if (r.background) wpImages.background = r.background;
displayWp(wpCurTab);
showToast('New wallpaper received!');
}
async function requestWallpaper() {
await postJSON('/api/pwa/trigger_wallpaper', {});
showToast('Request sent…');
}
function wpTab(tab, btn) {
wpCurTab = tab;
document.querySelectorAll('.wp-tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
displayWp(tab);
}
function displayWp(tab) {
const img = document.getElementById('wpImg');
const ph = document.getElementById('wpPh');
const src = wpImages[tab];
if (src) { img.src=src; img.style.opacity='1'; ph.style.display='none'; }
else { img.style.opacity='0'; ph.style.display='flex'; }
}
function saveWp(tab) {
const src = wpImages[tab];
if (!src) { showToast('No image to save'); return; }
const a = document.createElement('a');
a.href = src; a.download = tab==='lockscreen'?'Lockscreen.jpg':'Background.jpg'; a.click();
}
// ── Ping ──────────────────────────────────────────────────
function startPing() {
clearInterval(pingInterval);
pingInterval = setInterval(doPing, 4000);
doPing();
}
async function doPing() {
try {
await postJSON('/api/pwa/ping', {client_id: CLIENT_ID});
document.getElementById('connBadge').className = 'conn-badge ok';
document.getElementById('connText').textContent = 'Connected';
} catch {
document.getElementById('connBadge').className = 'conn-badge err';
document.getElementById('connText').textContent = 'Offline';
}
}
// ── Helpers ───────────────────────────────────────────────
async function fetchJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return r.json();
}
async function postJSON(url, body) {
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}).then(r=>r.json());
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(()=>t.classList.remove('show'), 2500);
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// Countdown ticker
setInterval(()=>{
const el = document.getElementById('a-next');
if (!el) return;
const m = el.textContent.match(/(\d+)m (\d+)s/);
if (!m) return;
let t = parseInt(m[1])*60 + parseInt(m[2]) - 1;
if (t < 0) t = 0;
el.textContent = `${Math.floor(t/60)}m ${t%60}s`;
}, 1000);
// ── PWA Overlay ───────────────────────────────────────────
let _overlayInterval = null;
let _overlayDismissTimer = null;
let _overlayActive = false;
function startOverlayPoll() {
clearInterval(_overlayInterval);
_overlayInterval = setInterval(pollOverlay, 1000);
pollOverlay();
}
async function pollOverlay() {
const r = await fetchJSON('/api/pwa/overlay').catch(() => null);
if (!r) return;
const el = document.getElementById('pwa-overlay');
const img = document.getElementById('pwa-overlay-img');
if (r.active && r.image) {
if (!_overlayActive) {
img.src = r.image;
el.classList.add('visible');
_overlayActive = true;
}
// Auto-dismiss after remaining_ms (keep in sync with desktop)
clearTimeout(_overlayDismissTimer);
if (r.remaining_ms > 0) {
_overlayDismissTimer = setTimeout(hideOverlay, r.remaining_ms);
}
} else {
hideOverlay();
}
}
function hideOverlay() {
clearTimeout(_overlayDismissTimer);
_overlayActive = false;
const el = document.getElementById('pwa-overlay');
el.classList.remove('visible');
// Clear src after transition so old image doesn't flash
setTimeout(() => {
if (!_overlayActive) document.getElementById('pwa-overlay-img').src = '';
}, 400);
}
// ── Background Settings ───────────────────────────────────
let _bgBlur = 18, _bgOpac = 0.72;
function applyBgSettings(blur, opac) {
_bgBlur = blur; _bgOpac = opac;
// Rebuild the dynamic CSS classes for selector and app
let styleEl = document.getElementById('bg-dynamic-style');
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'bg-dynamic-style';
document.head.appendChild(styleEl);
}
styleEl.textContent = `
#shared-bg-overlay.for-selector {
background: rgba(8,8,10,${opac}) !important;
backdrop-filter: blur(${blur}px) saturate(.7) !important;
-webkit-backdrop-filter: blur(${blur}px) saturate(.7) !important;
}
#shared-bg-overlay.for-app {
background: rgba(8,8,10,${Math.min(opac + 0.16, 0.97)}) !important;
}
`;
// Sync slider UI
document.getElementById('blurSlider').value = blur;
document.getElementById('opacSlider').value = Math.round(opac * 100);
document.getElementById('blurVal').textContent = blur + 'px';
document.getElementById('opacVal').textContent = Math.round(opac * 100) + '%';
}
function previewBg() {
const blur = parseInt(document.getElementById('blurSlider').value);
const opac = parseInt(document.getElementById('opacSlider').value) / 100;
applyBgSettings(blur, opac);
}
function toggleBgSettings() {
document.getElementById('bg-settings-panel').classList.toggle('open');
}
async function saveBgSettings() {
const blur = parseInt(document.getElementById('blurSlider').value);
const opac = parseInt(document.getElementById('opacSlider').value) / 100;
await postJSON('/api/settings', { pwa_bg_blur: blur, pwa_bg_opacity: opac }).catch(() => null);
applyBgSettings(blur, opac);
document.getElementById('bg-settings-panel').classList.remove('open');
showToast('Saved!');
}
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/pwa/sw.js');
init();
</script>
</body>
</html>