- 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.
144 lines
4.7 KiB
JavaScript
144 lines
4.7 KiB
JavaScript
// 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; }
|