feat: add multi-user support for favorites management and room clock synchronization

- Implemented a new API endpoint for retrieving and managing user favorites in /api/users.
- Added functionality for admins to edit the shared "main" user's favorites.
- Created a one-shot DB smoke test script for verifying multi-user kiosk migrations.
- Introduced a RoomClock class for synchronizing server time across clients using WebSocket.
This commit is contained in:
Marco Mooren
2026-05-13 13:53:12 +02:00
parent f6cdfd975c
commit 29423288ca
41 changed files with 4229 additions and 275 deletions

143
web/shared/playGate.js Normal file
View File

@@ -0,0 +1,143 @@
// Click-to-start gate.
//
// Browsers reject `audio.play()` without a user gesture. After a hard reload
// we want to resume playback automatically when possible and fall back to a
// "tap to start" modal otherwise. Inside the Electron shell autoplay is
// permitted, so a one-time "Don't show again" pref suppresses the modal.
//
// Usage:
// const ok = await tryAutoplay(audioEl); // best-effort attempt
// if (!ok) {
// await showStartModal({ stationName, onStart, onCancel });
// }
const DISMISSED_KEY = 'oradio.autoplayDismissed';
function isElectron() {
try { return !!(typeof window !== 'undefined' && window.oradioNative?.isElectron); }
catch { return false; }
}
export function autoplayDismissed() {
// Only honour the dismissal in Electron — plain browsers can't bypass
// their autoplay policy regardless of any flag we set.
if (!isElectron()) return false;
try { return localStorage.getItem(DISMISSED_KEY) === '1'; }
catch { return false; }
}
/** Try to start an <audio> element. Resolves true on success, false on rejection. */
export async function tryAutoplay(audio) {
if (!audio) return false;
try {
const p = audio.play();
if (p && typeof p.then === 'function') await p;
return !audio.paused;
} catch {
return false;
}
}
let _open = null;
/**
* Show a blocking modal that captures a user gesture. Resolves when the user
* taps Start (which fulfils browser autoplay rules for the subsequent
* `audio.play()` the caller will perform). Rejects on Cancel.
*
* Returns a Promise. While the modal is open subsequent calls return the
* same promise (idempotent).
*/
export function showStartModal({ stationName = 'Radio', subtitle = '', onStart, onCancel } = {}) {
if (_open) return _open.promise;
let resolve, reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
const backdrop = document.createElement('div');
backdrop.className = 'play-gate-backdrop';
backdrop.setAttribute('role', 'dialog');
backdrop.setAttribute('aria-modal', 'true');
backdrop.setAttribute('aria-label', 'Tap to start audio');
const card = document.createElement('div');
card.className = 'play-gate-card';
const h = document.createElement('h2');
h.className = 'play-gate-title';
h.textContent = 'Tap to start audio';
card.appendChild(h);
const name = document.createElement('div');
name.className = 'play-gate-station';
name.textContent = stationName;
card.appendChild(name);
if (subtitle) {
const sub = document.createElement('div');
sub.className = 'play-gate-sub';
sub.textContent = subtitle;
card.appendChild(sub);
}
const row = document.createElement('div');
row.className = 'play-gate-row';
const startBtn = document.createElement('button');
startBtn.className = 'play-gate-start';
startBtn.textContent = '▶ Start';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'play-gate-cancel';
cancelBtn.textContent = 'Cancel';
row.appendChild(startBtn);
row.appendChild(cancelBtn);
card.appendChild(row);
let dismissCheckbox = null;
if (isElectron()) {
const lbl = document.createElement('label');
lbl.className = 'play-gate-dismiss';
dismissCheckbox = document.createElement('input');
dismissCheckbox.type = 'checkbox';
dismissCheckbox.id = 'play-gate-dismiss-cb';
lbl.appendChild(dismissCheckbox);
const txt = document.createElement('span');
txt.textContent = " Don't show again on this device";
lbl.appendChild(txt);
card.appendChild(lbl);
}
backdrop.appendChild(card);
document.body.appendChild(backdrop);
queueMicrotask(() => startBtn.focus());
function close() {
if (_open?.backdrop?.parentNode) _open.backdrop.parentNode.removeChild(_open.backdrop);
_open = null;
}
function persistDismissal() {
if (dismissCheckbox && dismissCheckbox.checked) {
try { localStorage.setItem(DISMISSED_KEY, '1'); } catch { /* ignore */ }
}
}
startBtn.addEventListener('click', () => {
persistDismissal();
close();
try { onStart && onStart(); } catch { /* ignore */ }
resolve();
});
cancelBtn.addEventListener('click', () => {
close();
try { onCancel && onCancel(); } catch { /* ignore */ }
reject(new Error('autoplay-cancelled'));
});
backdrop.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cancelBtn.click();
});
_open = { backdrop, promise };
return promise;
}
export function isStartModalOpen() { return !!_open; }