// ─── 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 = `${escapeHtml(notif.heading)}
${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,'&').replace(//g,'>'); } 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();