1 Commits

Author SHA1 Message Date
ismailmasseran a4fb7b4d8b DONE: add malay voice read, enlarge the station name (#5)
Build Docker Image / build-admin (push) Successful in 19s
Build Docker Image / build-teller (push) Successful in 9s
Build Docker Image / build-customer (push) Successful in 5s
Build Docker Image / build-backend (push) Successful in 2m13s
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Reviewed-on: #5
2026-09-01 16:09:51 +08:00
4 changed files with 232 additions and 19 deletions
@@ -178,7 +178,7 @@
.branch-display__station-card { .branch-display__station-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3em; gap: 0.35em;
padding: 0.9em 0.65em; padding: 0.9em 0.65em;
border-radius: 0.5em; border-radius: 0.5em;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
@@ -189,8 +189,16 @@
.branch-display__station-name { .branch-display__station-name {
margin: 0; margin: 0;
font-size: 1.1em; padding: 0.2em 0.45em;
color: var(--bd-muted); 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 { .branch-display__ticket-number {
@@ -207,7 +215,12 @@
@media (max-height: 800px) { @media (max-height: 800px) {
.branch-display__station-card { .branch-display__station-card {
padding: 0.55em 0.5em; 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 { .branch-display__ticket-number {
@@ -3,11 +3,16 @@ import { useParams } from 'react-router-dom';
import { fetchData } from '../../fetching/Fetch.js'; import { fetchData } from '../../fetching/Fetch.js';
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js'; import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
import { import {
hasNewServingCall, getNewServingCalls,
playQueueCallSound, playQueueCallSound,
servingSnapshot, servingSnapshot,
unlockQueueCallSound, unlockQueueCallSound,
} from '../../utils/queueCallSound.js'; } from '../../utils/queueCallSound.js';
import {
announceQueueCalls,
cancelQueueSpeech,
unlockQueueSpeech,
} from '../../utils/queueCallSpeech.js';
import AdCarousel from './AdCarousel.jsx'; import AdCarousel from './AdCarousel.jsx';
import './BranchDisplayPage.css'; import './BranchDisplayPage.css';
@@ -172,14 +177,38 @@ export default function BranchDisplayPage() {
useEffect(() => { useEffect(() => {
const next = servingSnapshot(tickets); const next = servingSnapshot(tickets);
const previous = previousServingRef.current; const previous = previousServingRef.current;
const newCalls = getNewServingCalls(previous, tickets);
if (hasNewServingCall(previous, next)) { if (newCalls.length > 0) {
playQueueCallSound(); playQueueCallSound({
onEnded: () => announceQueueCalls(newCalls),
});
} }
previousServingRef.current = next; previousServingRef.current = next;
}, [tickets]); }, [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( const waiting = useMemo(
() => tickets.filter((ticket) => ticket.station == null), () => tickets.filter((ticket) => ticket.station == null),
[tickets] [tickets]
@@ -191,7 +220,7 @@ export default function BranchDisplayPage() {
); );
return ( return (
<div className="branch-display" onClick={unlockQueueCallSound}> <div className="branch-display" onClick={unlockAnnouncements}>
<header className="branch-display__header"> <header className="branch-display__header">
<div className="branch-display__brand"> <div className="branch-display__brand">
<img <img
@@ -223,9 +252,6 @@ export default function BranchDisplayPage() {
key={station.id} key={station.id}
className="branch-display__station-card" className="branch-display__station-card"
> >
<p className="branch-display__station-name">
{station.name}
</p>
<p <p
className={ className={
ticket ticket
@@ -235,6 +261,9 @@ export default function BranchDisplayPage() {
> >
{ticket ? ticket.number : '—'} {ticket ? ticket.number : '—'}
</p> </p>
<p className="branch-display__station-name">
{station.name}
</p>
<p className="branch-display__station-service"> <p className="branch-display__station-service">
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'} {ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
</p> </p>
+49 -8
View File
@@ -45,20 +45,35 @@ export function unlockQueueCallSound() {
/** /**
* Play the queue-call notification chime. * Play the queue-call notification chime.
* Safe to call repeatedly; restarts from the beginning each time. * 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 { try {
const audio = getCallAudio(); const audio = getCallAudio();
audio.muted = false; audio.muted = false;
audio.currentTime = 0; audio.currentTime = 0;
if (onEnded) {
audio.addEventListener('ended', notifyEnded, { once: true });
}
const playPromise = audio.play(); const playPromise = audio.play();
if (playPromise?.catch) { if (playPromise?.catch) {
playPromise.catch((error) => { playPromise.catch((error) => {
console.warn('Could not play queue call sound:', error); console.warn('Could not play queue call sound:', error);
notifyEnded();
}); });
} }
} catch (error) { } catch (error) {
console.warn('Could not play queue call sound:', 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. * Returns true if any station got a new/different ticket number vs the previous snapshot.
*/ */
export function hasNewServingCall(previous, next) { export function hasNewServingCall(previous, next) {
if (!previous) return false; return getNewServingCalls(previous, next).length > 0;
for (const [stationId, number] of Object.entries(next)) { }
if (previous[stationId] !== number) {
return true; /**
} * Returns stations whose assigned ticket number changed since the previous snapshot.
} * Pass tickets when station names are needed for voice announcements.
return false; */
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;
} }
@@ -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;
}