From a4fb7b4d8b1a02cf4772230c2d7bd353c958e5f5 Mon Sep 17 00:00:00 2001 From: ismailmasseran Date: Tue, 1 Sep 2026 16:09:51 +0800 Subject: [PATCH] DONE: add malay voice read, enlarge the station name (#5) Co-authored-by: ISMAIL MASSERAN Reviewed-on: https://git.koppkb.com/KoPKB/QMS/pulls/5 --- .../BranchDisplayPage/BranchDisplayPage.css | 21 ++- .../BranchDisplayPage/BranchDisplayPage.jsx | 43 +++++- fe/web/teller-app/src/utils/queueCallSound.js | 57 ++++++-- .../teller-app/src/utils/queueCallSpeech.js | 130 ++++++++++++++++++ 4 files changed, 232 insertions(+), 19 deletions(-) create mode 100644 fe/web/teller-app/src/utils/queueCallSpeech.js diff --git a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css index 8286614..1a3a72a 100644 --- a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css +++ b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.css @@ -178,7 +178,7 @@ .branch-display__station-card { display: flex; flex-direction: column; - gap: 0.3em; + gap: 0.35em; padding: 0.9em 0.65em; border-radius: 0.5em; background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); @@ -189,8 +189,16 @@ .branch-display__station-name { margin: 0; - font-size: 1.1em; - color: var(--bd-muted); + padding: 0.2em 0.45em; + font-size: clamp(1.35em, min(3.2vw, 5.5vh), 2.75em); + font-weight: 700; + line-height: 1.15; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--bd-text); + background: rgba(7, 26, 16, 0.45); + border-radius: 0.25em; + border: 1px solid var(--bd-border-strong); } .branch-display__ticket-number { @@ -207,7 +215,12 @@ @media (max-height: 800px) { .branch-display__station-card { padding: 0.55em 0.5em; - gap: 0.15em; + gap: 0.2em; + } + + .branch-display__station-name { + font-size: clamp(1.15em, min(2.6vw, 4.5vh), 2em); + padding: 0.15em 0.35em; } .branch-display__ticket-number { diff --git a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx index 1ab24e3..57c632a 100644 --- a/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx +++ b/fe/web/teller-app/src/pages/BranchDisplayPage/BranchDisplayPage.jsx @@ -3,11 +3,16 @@ import { useParams } from 'react-router-dom'; import { fetchData } from '../../fetching/Fetch.js'; import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js'; import { - hasNewServingCall, + getNewServingCalls, playQueueCallSound, servingSnapshot, unlockQueueCallSound, } from '../../utils/queueCallSound.js'; +import { + announceQueueCalls, + cancelQueueSpeech, + unlockQueueSpeech, +} from '../../utils/queueCallSpeech.js'; import AdCarousel from './AdCarousel.jsx'; import './BranchDisplayPage.css'; @@ -172,14 +177,38 @@ export default function BranchDisplayPage() { useEffect(() => { const next = servingSnapshot(tickets); const previous = previousServingRef.current; + const newCalls = getNewServingCalls(previous, tickets); - if (hasNewServingCall(previous, next)) { - playQueueCallSound(); + if (newCalls.length > 0) { + playQueueCallSound({ + onEnded: () => announceQueueCalls(newCalls), + }); } previousServingRef.current = next; }, [tickets]); + useEffect(() => { + if (!window.speechSynthesis) return undefined; + + const loadVoices = () => { + window.speechSynthesis.getVoices(); + }; + + loadVoices(); + window.speechSynthesis.addEventListener('voiceschanged', loadVoices); + + return () => { + window.speechSynthesis.removeEventListener('voiceschanged', loadVoices); + cancelQueueSpeech(); + }; + }, []); + + const unlockAnnouncements = useCallback(() => { + unlockQueueCallSound(); + unlockQueueSpeech(); + }, []); + const waiting = useMemo( () => tickets.filter((ticket) => ticket.station == null), [tickets] @@ -191,7 +220,7 @@ export default function BranchDisplayPage() { ); return ( -
+
-

- {station.name} -

{ticket ? ticket.number : '—'}

+

+ {station.name} +

{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}

diff --git a/fe/web/teller-app/src/utils/queueCallSound.js b/fe/web/teller-app/src/utils/queueCallSound.js index aff413a..03c4334 100644 --- a/fe/web/teller-app/src/utils/queueCallSound.js +++ b/fe/web/teller-app/src/utils/queueCallSound.js @@ -45,20 +45,35 @@ export function unlockQueueCallSound() { /** * Play the queue-call notification chime. * Safe to call repeatedly; restarts from the beginning each time. + * @param {{ onEnded?: () => void }} [options] */ -export function playQueueCallSound() { +export function playQueueCallSound({ onEnded } = {}) { + let endedNotified = false; + const notifyEnded = () => { + if (endedNotified || !onEnded) return; + endedNotified = true; + onEnded(); + }; + try { const audio = getCallAudio(); audio.muted = false; audio.currentTime = 0; + + if (onEnded) { + audio.addEventListener('ended', notifyEnded, { once: true }); + } + const playPromise = audio.play(); if (playPromise?.catch) { playPromise.catch((error) => { console.warn('Could not play queue call sound:', error); + notifyEnded(); }); } } catch (error) { console.warn('Could not play queue call sound:', error); + notifyEnded(); } } @@ -79,11 +94,37 @@ export function servingSnapshot(tickets) { * Returns true if any station got a new/different ticket number vs the previous snapshot. */ export function hasNewServingCall(previous, next) { - if (!previous) return false; - for (const [stationId, number] of Object.entries(next)) { - if (previous[stationId] !== number) { - return true; - } - } - return false; + return getNewServingCalls(previous, next).length > 0; +} + +/** + * Returns stations whose assigned ticket number changed since the previous snapshot. + * Pass tickets when station names are needed for voice announcements. + */ +export function getNewServingCalls(previous, nextOrTickets) { + if (!previous) return []; + + const tickets = Array.isArray(nextOrTickets) + ? nextOrTickets + : Object.entries(nextOrTickets).map(([stationId, number]) => ({ + station: { id: stationId }, + number, + })); + + const calls = []; + for (const ticket of tickets) { + if (ticket?.station?.id == null || ticket?.number == null) continue; + + const stationId = String(ticket.station.id); + const number = String(ticket.number); + if (previous[stationId] === number) continue; + + calls.push({ + stationId, + ticketNumber: number, + stationName: ticket.station.name ?? `Stesen ${stationId}`, + }); + } + + return calls; } diff --git a/fe/web/teller-app/src/utils/queueCallSpeech.js b/fe/web/teller-app/src/utils/queueCallSpeech.js new file mode 100644 index 0000000..58f02ba --- /dev/null +++ b/fe/web/teller-app/src/utils/queueCallSpeech.js @@ -0,0 +1,130 @@ +const MALAY_LANG = 'ms-MY'; + +const MALAY_DIGITS = [ + 'kosong', + 'satu', + 'dua', + 'tiga', + 'empat', + 'lima', + 'enam', + 'tujuh', + 'lapan', + 'sembilan', +]; + +let speechUnlocked = false; +let speechQueue = []; +let speaking = false; + +function speechSynthesisAvailable() { + return typeof window !== 'undefined' && 'speechSynthesis' in window; +} + +function loadVoices() { + if (!speechSynthesisAvailable()) return []; + return window.speechSynthesis.getVoices(); +} + +function pickMalayVoice() { + const voices = loadVoices(); + return ( + voices.find((voice) => voice.lang === MALAY_LANG) || + voices.find((voice) => voice.lang.startsWith('ms')) || + null + ); +} + +/** + * Spell ticket numbers digit-by-digit in Malay for clearer announcements. + */ +export function formatTicketNumberForSpeech(ticketNumber) { + return String(ticketNumber) + .trim() + .split('') + .map((character) => { + if (/\d/.test(character)) { + return MALAY_DIGITS[Number(character)]; + } + return character; + }) + .join(' '); +} + +export function formatMalayAnnouncement(ticketNumber, stationName) { + const spokenNumber = formatTicketNumberForSpeech(ticketNumber); + return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`; +} + +/** + * Call once after a user gesture so later speech is allowed on strict browsers. + */ +export function unlockQueueSpeech() { + if (speechUnlocked || !speechSynthesisAvailable()) return; + + try { + loadVoices(); + const utterance = new SpeechSynthesisUtterance(''); + utterance.volume = 0; + utterance.lang = MALAY_LANG; + window.speechSynthesis.speak(utterance); + } catch { + // Ignore unlock failures; real announcements will still be attempted. + } finally { + speechUnlocked = true; + } +} + +function processSpeechQueue() { + if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return; + + speaking = true; + const text = speechQueue.shift(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = MALAY_LANG; + + const voice = pickMalayVoice(); + if (voice) { + utterance.voice = voice; + } + + utterance.rate = 0.75; + + const finish = () => { + speaking = false; + processSpeechQueue(); + }; + + utterance.onend = finish; + utterance.onerror = () => { + console.warn('Could not speak queue announcement:', text); + finish(); + }; + + window.speechSynthesis.speak(utterance); +} + +/** + * Queue Malay voice announcements for one or more station calls. + * @param {{ ticketNumber: string, stationName: string }[]} calls + */ +export function announceQueueCalls(calls) { + if (!calls?.length || !speechSynthesisAvailable()) return; + + unlockQueueSpeech(); + + for (const call of calls) { + speechQueue.push( + formatMalayAnnouncement(call.ticketNumber, call.stationName) + ); + } + + processSpeechQueue(); +} + +export function cancelQueueSpeech() { + if (!speechSynthesisAvailable()) return; + window.speechSynthesis.cancel(); + speechQueue = []; + speaking = false; +}