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();
|
||||
|
||||
Reference in New Issue
Block a user