DONE: add malay voice read, enlarge the station name #5
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<div className="branch-display" onClick={unlockQueueCallSound}>
|
||||
<div className="branch-display" onClick={unlockAnnouncements}>
|
||||
<header className="branch-display__header">
|
||||
<div className="branch-display__brand">
|
||||
<img
|
||||
@@ -223,9 +252,6 @@ export default function BranchDisplayPage() {
|
||||
key={station.id}
|
||||
className="branch-display__station-card"
|
||||
>
|
||||
<p className="branch-display__station-name">
|
||||
{station.name}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
ticket
|
||||
@@ -235,6 +261,9 @@ export default function BranchDisplayPage() {
|
||||
>
|
||||
{ticket ? ticket.number : '—'}
|
||||
</p>
|
||||
<p className="branch-display__station-name">
|
||||
{station.name}
|
||||
</p>
|
||||
<p className="branch-display__station-service">
|
||||
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'}
|
||||
</p>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user