Alerts
This commit is contained in:
+136
@@ -21,6 +21,135 @@ if (!presenceTab) {
|
||||
}
|
||||
let presenceQueue = Promise.resolve();
|
||||
let lastPresenceUpdate = null;
|
||||
const ALERTS_KEY = "thebutton-zone-alerts";
|
||||
const ALERTS_SENT_KEY = "thebutton-zone-alerts-sent";
|
||||
const ALERT_ZONES = ["blue", "yellow", "red", "black", "white"];
|
||||
let alertSettings = readAlertSettings();
|
||||
let alertObservation = null;
|
||||
let alertAudio = null;
|
||||
|
||||
function readAlertSettings() {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(ALERTS_KEY));
|
||||
return { enabled: value?.enabled === true, zone: ALERT_ZONES.includes(value?.zone) ? value.zone : "blue",
|
||||
sound: value?.sound === true, notification: value?.notification === true };
|
||||
} catch {
|
||||
return { enabled: false, zone: "blue", sound: false, notification: false };
|
||||
}
|
||||
}
|
||||
|
||||
function renderAlertSettings() {
|
||||
$("alerts-enabled").checked = alertSettings.enabled;
|
||||
$("alerts-zone").value = alertSettings.zone;
|
||||
$("alerts-sound").checked = alertSettings.sound;
|
||||
$("alerts-notification").checked = alertSettings.notification;
|
||||
$("alerts-status").textContent = alertSettings.enabled
|
||||
? `Watching for the ${alertSettings.zone} zone. Keep this page open.` : "Zone alerts are off.";
|
||||
}
|
||||
|
||||
async function prepareAlertAudio() {
|
||||
const Audio = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Audio) throw new Error("Sound is not supported by this browser.");
|
||||
alertAudio = alertAudio || new Audio();
|
||||
await alertAudio.resume();
|
||||
if (alertAudio.state !== "running") throw new Error("Click Test chime to allow sound in this browser.");
|
||||
}
|
||||
|
||||
function playAlertChime() {
|
||||
if (!alertAudio || alertAudio.state !== "running") throw new Error("Click Test chime in Alerts to enable sound for this visit.");
|
||||
const start = alertAudio.currentTime;
|
||||
[660, 880].forEach((frequency, index) => {
|
||||
const oscillator = alertAudio.createOscillator();
|
||||
const gain = alertAudio.createGain();
|
||||
const at = start + index * 0.18;
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0, at);
|
||||
gain.gain.linearRampToValueAtTime(0.08, at + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, at + 0.28);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(alertAudio.destination);
|
||||
oscillator.start(at);
|
||||
oscillator.stop(at + 0.3);
|
||||
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
||||
});
|
||||
}
|
||||
|
||||
function observeZoneAlerts(snapshot) {
|
||||
if (alertObservation && snapshot.now < alertObservation.now) return;
|
||||
const remaining = snapshot.deadline - snapshot.now;
|
||||
const zone = snapshot.zones.find((entry) => remaining > entry.remaining_to_ms)?.name;
|
||||
const previous = alertObservation;
|
||||
alertObservation = { now: snapshot.now, deadline: snapshot.deadline, zone, ended: snapshot.ended };
|
||||
if (!alertSettings.enabled || !previous || snapshot.ended || previous.ended || remaining <= 0
|
||||
|| previous.deadline !== snapshot.deadline || previous.zone === zone || zone !== alertSettings.zone) return;
|
||||
const currentIndex = snapshot.zones.findIndex((entry) => entry.name === zone);
|
||||
const previousIndex = snapshot.zones.findIndex((entry) => entry.name === previous.zone);
|
||||
if (previousIndex < 0 || currentIndex <= previousIndex) return;
|
||||
try {
|
||||
const sent = JSON.parse(localStorage.getItem(ALERTS_SENT_KEY) || "null");
|
||||
const zones = sent?.deadline === snapshot.deadline && Array.isArray(sent.zones) ? sent.zones : [];
|
||||
if (zones.includes(zone)) return;
|
||||
localStorage.setItem(ALERTS_SENT_KEY, JSON.stringify({ deadline: snapshot.deadline, zones: [...zones, zone] }));
|
||||
} catch {
|
||||
// The observation still prevents repeated alerts within this tab if storage is unavailable.
|
||||
}
|
||||
const message = `The countdown entered the ${zone} zone. ${formatPrecise(remaining)} remaining.`;
|
||||
$("alerts-status").textContent = message;
|
||||
$("alerts-error").textContent = "";
|
||||
if (alertSettings.sound) {
|
||||
try { playAlertChime(); } catch (error) { $("alerts-error").textContent = error.message; }
|
||||
}
|
||||
if (alertSettings.notification) {
|
||||
try {
|
||||
if (!("Notification" in window) || Notification.permission !== "granted") {
|
||||
throw new Error("Browser notifications aren't permitted. Save your notification settings in Alerts to try again.");
|
||||
}
|
||||
const notification = new Notification("The Button: your zone is here", { body: message, tag: "thebutton-zone", silent: true });
|
||||
notification.onclick = () => { window.focus(); notification.close(); };
|
||||
} catch (error) { $("alerts-error").textContent = error.message; }
|
||||
}
|
||||
}
|
||||
|
||||
$("alerts-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
$("alerts-error").textContent = "";
|
||||
const settings = { enabled: $("alerts-enabled").checked, zone: $("alerts-zone").value,
|
||||
sound: $("alerts-sound").checked, notification: $("alerts-notification").checked };
|
||||
if (settings.enabled && !settings.sound && !settings.notification) {
|
||||
$("alerts-error").textContent = "Choose a chime, a browser notification, or both.";
|
||||
return;
|
||||
}
|
||||
let permissionRequest = null;
|
||||
if (settings.enabled && settings.notification) {
|
||||
if (!("Notification" in window) || !window.isSecureContext) {
|
||||
$("alerts-error").textContent = "Browser notifications are unavailable here. Choose the chime instead.";
|
||||
return;
|
||||
}
|
||||
// Start permission requests directly in the submit gesture, before awaiting audio.
|
||||
try { permissionRequest = Notification.requestPermission().catch(() => "denied"); }
|
||||
catch { permissionRequest = Promise.resolve("denied"); }
|
||||
}
|
||||
if (settings.enabled && settings.sound) {
|
||||
try { await prepareAlertAudio(); } catch (error) { $("alerts-error").textContent = error.message; return; }
|
||||
}
|
||||
if (permissionRequest) {
|
||||
const permission = await permissionRequest;
|
||||
if (permission !== "granted") {
|
||||
$("alerts-error").textContent = "Notifications were not allowed. Enable them in your browser settings, or choose the chime instead.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
alertSettings = settings;
|
||||
alertObservation = null; // baseline the next server response, never alert immediately on enabling
|
||||
try { localStorage.setItem(ALERTS_KEY, JSON.stringify(settings)); }
|
||||
catch { $("alerts-error").textContent = "Settings work for this visit but could not be saved in this browser."; }
|
||||
renderAlertSettings();
|
||||
});
|
||||
$("alerts-test").addEventListener("click", async () => {
|
||||
try { await prepareAlertAudio(); playAlertChime(); $("alerts-error").textContent = ""; }
|
||||
catch (error) { $("alerts-error").textContent = error.message; }
|
||||
});
|
||||
renderAlertSettings();
|
||||
|
||||
function renderPresence(data) {
|
||||
const count = data.online_count;
|
||||
@@ -72,6 +201,11 @@ $("presence-opt-in").addEventListener("change", () => {
|
||||
});
|
||||
document.addEventListener("visibilitychange", syncPresence);
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === ALERTS_KEY) {
|
||||
alertSettings = readAlertSettings();
|
||||
alertObservation = null;
|
||||
renderAlertSettings();
|
||||
}
|
||||
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
|
||||
sharePresence = localStorage.getItem(PRESENCE_KEY) === "true";
|
||||
token = localStorage.getItem(TOKEN_KEY);
|
||||
@@ -85,6 +219,7 @@ function renderSections() {
|
||||
"account-section": true,
|
||||
"leaderboard-section": true,
|
||||
"chat-section": true,
|
||||
"alerts-section": true,
|
||||
"stats-section": statsLoaded,
|
||||
rules: true,
|
||||
"share-controls": !!currentShareText,
|
||||
@@ -254,6 +389,7 @@ async function refreshState() {
|
||||
const first = !game;
|
||||
clockOffset = s.now - (t0 + Date.now()) / 2;
|
||||
game = s;
|
||||
observeZoneAlerts(s);
|
||||
$("history-global-summary").textContent =
|
||||
`There have been ${s.total_presses.toLocaleString()} total presses and there are ${s.total_players.toLocaleString()} total players.`;
|
||||
if (first) buildStaticUi();
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<button type="button" data-section="account-section" aria-controls="account-section" aria-expanded="false">Log in / Join</button>
|
||||
<button type="button" data-section="leaderboard-section" aria-controls="leaderboard-section" aria-expanded="false">Leaderboard</button>
|
||||
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
|
||||
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
|
||||
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
|
||||
<button type="button" data-section="rules" aria-controls="rules" aria-expanded="false">Rules</button>
|
||||
<button type="button" data-section="share-controls" aria-controls="share-controls" aria-expanded="false" hidden>Share</button>
|
||||
@@ -57,6 +58,25 @@
|
||||
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
|
||||
</div>
|
||||
|
||||
<section id="alerts-section" data-panel hidden aria-labelledby="alerts-heading">
|
||||
<header class="panel-header"><p class="eyebrow">WAIT ON YOUR TERMS</p>
|
||||
<h2 id="alerts-heading">Don't miss your moment.</h2>
|
||||
<p class="panel-description">Choose a zone. We'll give you a nudge when the clock gets there.</p>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<form id="alerts-form">
|
||||
<label class="alert-option"><input id="alerts-enabled" type="checkbox"> Enable zone alerts</label>
|
||||
<label class="field" for="alerts-zone"><span>Notify me on entering</span><select id="alerts-zone" required><option value="blue">Blue zone</option><option value="yellow">Yellow zone</option><option value="red">Red zone</option><option value="black">Black zone</option><option value="white">White zone</option></select></label>
|
||||
<label class="alert-option"><input id="alerts-sound" type="checkbox"> Play a gentle chime</label>
|
||||
<label class="alert-option"><input id="alerts-notification" type="checkbox"> Show a browser notification</label>
|
||||
<div class="form-actions"><button class="primary-button" id="alerts-save" type="submit">Save alerts</button><button id="alerts-test" type="button">Test chime</button></div>
|
||||
</form>
|
||||
<p id="alerts-status" role="status" class="fine-print">Alerts are off by default.</p>
|
||||
<p id="alerts-error" class="error" role="status"></p>
|
||||
<div class="alert-note"><h3 class="subheading">A heads-up, not a guarantee</h3><p>Keep this page open. Alerts follow server updates, usually every five seconds. Background tabs may be delayed or suspended, and very short zones can be missed. Alerts do not work after the page is closed.</p><p>Opening the page, saving settings, or someone resetting the clock won't trigger an alert. Each selected zone alerts once per countdown. Preferences stay in this browser; no account is required.</p><p>Sound may need a click on Test chime after reopening the page. Browser notifications require permission and a supported secure browser (HTTPS or localhost).</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="share-controls" data-panel hidden>
|
||||
<header class="panel-header">
|
||||
<p class="eyebrow">A MOMENT WORTH SHARING</p>
|
||||
|
||||
@@ -320,6 +320,13 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
.error { color: var(--red); }
|
||||
.panel-body > .error { color: var(--red); }
|
||||
.error:empty { display: none; }
|
||||
#alerts-form { display: grid; justify-content: stretch; gap: 1rem; max-width: 460px; }
|
||||
.alert-option { display: flex; align-items: center; gap: 0.65rem; color: var(--text); font-size: 0.85rem; cursor: pointer; }
|
||||
.alert-option input { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; accent-color: var(--mauve); flex-shrink: 0; }
|
||||
#alerts-zone { width: 100%; min-height: 44px; padding: 0.65rem 0.9rem; color: var(--text); background: var(--base); border: 1px solid var(--surface); border-radius: 9px; font: inherit; }
|
||||
#alerts-zone:focus-visible { outline: 2px solid var(--mauve); outline-offset: 2px; }
|
||||
.alert-note { padding: 0.25rem 1rem 0.5rem; background: var(--base); border: 1px solid var(--surface); border-radius: 12px; margin-top: 1.25rem; }
|
||||
.alert-note p { font-size: 0.75rem; color: var(--subtext); }
|
||||
#stats-summary { padding: 0.5rem 1rem; border: 1px solid var(--surface); border-radius: 12px; background: var(--base); }
|
||||
#stats-summary p { color: var(--subtext); font-size: 0.8rem; }
|
||||
#stats-summary p:first-child { color: var(--lavender); font-weight: 600; }
|
||||
|
||||
Reference in New Issue
Block a user