| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
| const STORAGE_NAME = 'device_name'; |
|
|
| let ws = null; |
| let reconnectTimer = null; |
| let scheduledTimers = {}; |
| let cachedSounds = LS.get(STORAGE_SOUNDS) || {}; |
|
|
| |
| if ('Notification' in window && Notification.permission === 'default') { |
| Notification.requestPermission(); |
| } |
|
|
| |
| 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); |
| |
| send({ type: 'hello', uuid }); |
| |
| 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)); |
| } |
| } |
|
|
| |
| function handleMessage(msg) { |
| switch (msg.type) { |
|
|
| case 'device_init': { |
| |
| 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': { |
| |
| 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; |
| } |
| } |
|
|
| |
| 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 { |
| |
| if (localMap[n.id].displayed) n.displayed = true; |
| localMap[n.id] = { ...localMap[n.id], ...n }; |
| } |
| } |
| LS.set(STORAGE_NOTIFICATIONS, Object.values(localMap)); |
| } |
|
|
| |
| 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 }); |
| } |
| } |
|
|
| |
| function rescheduleAll() { |
| |
| 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) { |
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| function fireNotification(notif) { |
| |
| if (notif.soundId && cachedSounds[notif.soundId]) { |
| playBase64Audio(cachedSounds[notif.soundId].data); |
| } |
|
|
| |
| 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 { |
| |
| showInPageBanner(notif); |
| } |
|
|
| |
| 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,'&').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 }); |
| } |
|
|
| |
| function playBase64Audio(base64Data) { |
| try { |
| |
| const audio = new Audio(`data:audio/mpeg;base64,${base64Data}`); |
| audio.play().catch(() => { |
| |
| 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); |
| } |
| } |
|
|
| |
| window.addEventListener('storage', (e) => { |
| if (e.key === STORAGE_SCHEDULE || e.key === STORAGE_NOTIFICATIONS) { |
| rescheduleAll(); |
| } |
| }); |
|
|
| |
| 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); |
| } |
| } |
| } |
|
|
| |
| checkMissedOnLoad(); |
| rescheduleAll(); |
| connect(); |
|
|