System / public /script.js
Sebebeb's picture
Upload 8 files
0325642 verified
Raw
History Blame Contribute Delete
10.8 kB
// ─── Frontend 1: Device Client ────────────────────────────────────────────────
// Responsibilities:
// - Negotiate UUID with server
// - Keep notifications + schedule in localStorage
// - Cache sounds in localStorage
// - Request notification permission and show notifications
// - Schedule notifications to fire at the right time
const LS = {
get: (k) => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} },
};
const STORAGE_UUID = 'device_uuid';
const STORAGE_NOTIFICATIONS = 'device_notifications';
const STORAGE_SCHEDULE = 'device_schedule';
const STORAGE_SOUNDS = 'device_sounds'; // { [soundId]: base64 }
const STORAGE_NAME = 'device_name';
let ws = null;
let reconnectTimer = null;
let scheduledTimers = {}; // notifId -> timeoutId
let cachedSounds = LS.get(STORAGE_SOUNDS) || {};
// ─── Notification Permission ──────────────────────────────────────────────────
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
// ─── WebSocket Connection ─────────────────────────────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss' : 'wss';
ws = new WebSocket(`${proto}://${location.host}/device-ws`);
ws.addEventListener('open', () => {
console.log('[WS] connected');
clearTimeout(reconnectTimer);
const uuid = LS.get(STORAGE_UUID);
// Introduce ourselves
send({ type: 'hello', uuid });
// Report cached sounds
send({ type: 'cached_sounds', soundIds: Object.keys(cachedSounds) });
});
ws.addEventListener('message', (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
handleMessage(msg);
});
ws.addEventListener('close', () => {
console.log('[WS] closed, reconnecting in 3s...');
reconnectTimer = setTimeout(connect, 3000);
});
ws.addEventListener('error', () => {
ws.close();
});
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
// ─── Message Handler ──────────────────────────────────────────────────────────
function handleMessage(msg) {
switch (msg.type) {
case 'device_init': {
// Server assigns/confirms UUID and sends all notifications + schedule
LS.set(STORAGE_UUID, msg.uuid);
LS.set(STORAGE_NAME, msg.name);
mergeNotifications(msg.notifications || []);
LS.set(STORAGE_SCHEDULE, msg.schedule || []);
ensureSounds();
rescheduleAll();
break;
}
case 'notification_added': {
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
if (!notifs.find(n => n.id === msg.notification.id)) {
notifs.push(msg.notification);
LS.set(STORAGE_NOTIFICATIONS, notifs);
ensureSoundForNotif(msg.notification);
}
break;
}
case 'schedule_update': {
LS.set(STORAGE_SCHEDULE, msg.schedule || []);
rescheduleAll();
break;
}
case 'play_now': {
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
const notif = notifs.find(n => n.id === msg.notificationId);
if (notif) fireNotification(notif);
break;
}
case 'name_update': {
LS.set(STORAGE_NAME, msg.name);
break;
}
case 'sound_data': {
// Server sent us a sound we requested
const sound = msg.sound;
cachedSounds[sound.id] = { data: sound.data, name: sound.name };
LS.set(STORAGE_SOUNDS, cachedSounds);
send({ type: 'cached_sounds', soundIds: Object.keys(cachedSounds) });
break;
}
default:
break;
}
}
// ─── Merge Notifications ──────────────────────────────────────────────────────
function mergeNotifications(incoming) {
const local = LS.get(STORAGE_NOTIFICATIONS) || [];
const localMap = {};
for (const n of local) localMap[n.id] = n;
for (const n of incoming) {
if (!localMap[n.id]) {
localMap[n.id] = n;
} else {
// preserve local displayed status if already true
if (localMap[n.id].displayed) n.displayed = true;
localMap[n.id] = { ...localMap[n.id], ...n };
}
}
LS.set(STORAGE_NOTIFICATIONS, Object.values(localMap));
}
// ─── Sound Fetching ───────────────────────────────────────────────────────────
function ensureSounds() {
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
for (const n of notifs) ensureSoundForNotif(n);
}
function ensureSoundForNotif(notif) {
if (notif.soundId && !cachedSounds[notif.soundId]) {
send({ type: 'request_sound', soundId: notif.soundId });
}
}
// ─── Scheduling ───────────────────────────────────────────────────────────────
function rescheduleAll() {
// Clear all existing timers
for (const id of Object.keys(scheduledTimers)) {
clearTimeout(scheduledTimers[id]);
}
scheduledTimers = {};
const schedule = LS.get(STORAGE_SCHEDULE) || [];
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
const now = Date.now();
for (const entry of schedule) {
const notif = notifs.find(n => n.id === entry.notificationId);
if (!notif) continue;
const delay = entry.scheduledAt - now;
if (delay <= 0) {
// Missed β€” fire immediately if not displayed
if (!notif.displayed) {
fireNotification(notif);
}
} else {
scheduledTimers[notif.id] = setTimeout(() => {
const freshNotifs = LS.get(STORAGE_NOTIFICATIONS) || [];
const fresh = freshNotifs.find(n => n.id === notif.id);
if (fresh && !fresh.displayed) fireNotification(fresh);
}, delay);
}
}
}
// ─── Fire a Notification ──────────────────────────────────────────────────────
function fireNotification(notif) {
// Play sound
if (notif.soundId && cachedSounds[notif.soundId]) {
playBase64Audio(cachedSounds[notif.soundId].data);
}
// Show browser notification
if ('Notification' in window && Notification.permission === 'granted') {
const n = new Notification(notif.heading || notif.name, {
body: notif.body,
requireInteraction: true,
});
if (notif.hyperlink) {
n.addEventListener('click', () => {
window.open(notif.hyperlink, '_blank');
n.close();
});
}
} else {
// Fallback: in-page notification banner
showInPageBanner(notif);
}
// Mark displayed
markDisplayed(notif.id);
}
function showInPageBanner(notif) {
const banner = document.createElement('div');
Object.assign(banner.style, {
position: 'fixed',
bottom: '20px',
right: '20px',
background: '#222',
color: '#fff',
padding: '16px 24px',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0,0,0,0.4)',
zIndex: '99999',
cursor: notif.hyperlink ? 'pointer' : 'default',
maxWidth: '320px',
fontFamily: 'sans-serif',
});
banner.innerHTML = `<strong>${escapeHtml(notif.heading)}</strong><br>${escapeHtml(notif.body)}`;
if (notif.hyperlink) {
banner.addEventListener('click', () => window.open(notif.hyperlink, '_blank'));
}
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 10000);
}
function escapeHtml(str) {
return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function markDisplayed(notifId) {
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
const notif = notifs.find(n => n.id === notifId);
if (notif) {
notif.displayed = true;
LS.set(STORAGE_NOTIFICATIONS, notifs);
}
send({ type: 'mark_displayed', notificationId: notifId });
}
// ─── Audio Playback ───────────────────────────────────────────────────────────
function playBase64Audio(base64Data) {
try {
// Try direct audio element
const audio = new Audio(`data:audio/mpeg;base64,${base64Data}`);
audio.play().catch(() => {
// Fallback: decode with AudioContext
playWithAudioContext(base64Data);
});
} catch {
playWithAudioContext(base64Data);
}
}
function playWithAudioContext(base64Data) {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
ctx.decodeAudioData(bytes.buffer, (buf) => {
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
src.start(0);
});
} catch (e) {
console.warn('[Audio] playback failed:', e);
}
}
// ─── localStorage change listener ─────────────────────────────────────────────
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_SCHEDULE || e.key === STORAGE_NOTIFICATIONS) {
rescheduleAll();
}
});
// ─── Check missed notifications on load ──────────────────────────────────────
function checkMissedOnLoad() {
const schedule = LS.get(STORAGE_SCHEDULE) || [];
const notifs = LS.get(STORAGE_NOTIFICATIONS) || [];
const now = Date.now();
for (const entry of schedule) {
if (entry.scheduledAt <= now) {
const notif = notifs.find(n => n.id === entry.notificationId);
if (notif && !notif.displayed) fireNotification(notif);
}
}
}
// ─── Boot ─────────────────────────────────────────────────────────────────────
checkMissedOnLoad();
rescheduleAll();
connect();