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:
143
web/shared/playGate.js
Normal file
143
web/shared/playGate.js
Normal 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; }
|
||||
Reference in New Issue
Block a user