From 5a8844410f3bf2a8274567dd947fd21ed3754fb9 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 29 Jun 2026 22:01:45 +0000 Subject: [PATCH] Fix display voice announcements broken by poll refactor. Replace Alpine-incompatible Set and stuck speaking lock with a plain-object queue; resume speechSynthesis and exempt display ack from CSRF. Co-authored-by: Cursor --- bootstrap/app.php | 3 ++ resources/js/kiosk-flow.js | 86 +++++++++++++++++++++----------------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/bootstrap/app.php b/bootstrap/app.php index db83f09..e2b6248 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -39,6 +39,9 @@ return Application::configure(basePath: dirname(__DIR__)) }, ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'display/*/announcements/*/played', + ]); $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ 'redirect' => $request->fullUrl(), ])); diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index fed33e0..e6d6c8c 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -56,8 +56,8 @@ export function registerKioskFlow(Alpine) { Alpine.data('displayBoard', (config = {}) => ({ payload: null, - spokenIds: new Set(), - speaking: false, + announcedIds: {}, + isAnnouncing: false, dataUrl: typeof config === 'string' ? config : config.dataUrl, playedUrlTemplate: config.playedUrl ?? null, pollMs: config.pollMs ?? 1200, @@ -67,61 +67,70 @@ export function registerKioskFlow(Alpine) { try { const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); this.payload = await res.json(); - await this.handleAnnouncements(this.payload?.announcements ?? []); + this.maybeAnnounce(this.payload?.announcements ?? []); } catch (e) { console.error('Display poll failed', e); } }, - async handleAnnouncements(announcements) { - if (! window.speechSynthesis || this.speaking) return; - - for (const item of announcements) { - if (! item?.id || ! item?.message || this.spokenIds.has(item.id)) { - continue; - } - - this.speaking = true; - try { - await this.speakAnnouncement(item); - this.spokenIds.add(item.id); - await this.ackPlayed(item.id); - } finally { - this.speaking = false; - } + maybeAnnounce(announcements) { + if (! window.speechSynthesis || this.isAnnouncing || ! announcements.length) { + return; } + + const next = announcements.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]); + if (! next) { + return; + } + + this.isAnnouncing = true; + this.announcedIds[next.id] = true; + this.speakAnnouncement(next); }, speakAnnouncement(item) { const repeats = Math.max(1, Number(item.repeat_count) || 1); const volume = Math.min(1, Math.max(0, (Number(item.volume) || 80) / 100)); + let remaining = repeats; - return new Promise((resolve) => { - let remaining = repeats; + const finish = () => { + this.isAnnouncing = false; + this.ackPlayed(item.id); + this.maybeAnnounce(this.payload?.announcements ?? []); + }; - const speakOnce = () => { - const utter = new SpeechSynthesisUtterance(item.message); - utter.volume = volume; - utter.lang = item.locale === 'fr' ? 'fr-FR' : item.locale === 'tw' ? 'ak-GH' : 'en-US'; - utter.onend = () => { - remaining -= 1; - if (remaining > 0) { - speakOnce(); - } else { - resolve(); - } - }; - utter.onerror = () => resolve(); - window.speechSynthesis.speak(utter); + const speakOnce = () => { + window.speechSynthesis.resume?.(); + + const utter = new SpeechSynthesisUtterance(item.message); + utter.volume = volume; + utter.lang = item.locale === 'fr' ? 'fr-FR' : item.locale === 'tw' ? 'ak-GH' : 'en-US'; + utter.onend = () => { + remaining -= 1; + if (remaining > 0) { + speakOnce(); + } else { + finish(); + } + }; + utter.onerror = () => { + delete this.announcedIds[item.id]; + finish(); }; - speakOnce(); - }); + window.speechSynthesis.speak(utter); + }; + + speakOnce(); }, async ackPlayed(id) { - if (! this.playedUrlTemplate) return; + if (! this.playedUrlTemplate) { + return; + } + const url = this.playedUrlTemplate.replace('__ID__', String(id)); + try { await fetch(url, { method: 'POST', @@ -132,6 +141,7 @@ export function registerKioskFlow(Alpine) { }); } catch (e) { console.error('Announcement ack failed', e); + delete this.announcedIds[id]; } },