DONE: add two voice fallback where there is no malay voicepack, update to display horizontally for number in queue in tv display
This commit is contained in:
@@ -280,31 +280,36 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45em;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0.55em;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__waiting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75em;
|
||||
padding: 0.55em 0.85em;
|
||||
justify-content: center;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0.55em 0.45em;
|
||||
border-radius: 0.4em;
|
||||
background: var(--bd-surface);
|
||||
border: 1px solid var(--bd-border);
|
||||
border-left: 0.2em solid var(--bd-green);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 0.2em solid var(--bd-green);
|
||||
}
|
||||
|
||||
.branch-display__waiting-number {
|
||||
font-size: clamp(1.35em, 2.4vw, 2.5em);
|
||||
font-size: clamp(1.2em, 2vw, 2.25em);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-accent);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__waiting-service {
|
||||
|
||||
@@ -214,6 +214,8 @@ export default function BranchDisplayPage() {
|
||||
[tickets]
|
||||
);
|
||||
|
||||
const waitingPreview = useMemo(() => waiting.slice(0, 5), [waiting]);
|
||||
|
||||
const sortedStations = useMemo(
|
||||
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
[stations]
|
||||
@@ -285,7 +287,7 @@ export default function BranchDisplayPage() {
|
||||
<p className="branch-display__empty">Tiada nombor dalam giliran.</p>
|
||||
) : (
|
||||
<ul className="branch-display__waiting-list">
|
||||
{waiting.map((ticket) => (
|
||||
{waitingPreview.map((ticket) => (
|
||||
<li key={ticket.id} className="branch-display__waiting-item">
|
||||
<span className="branch-display__waiting-number">
|
||||
{ticket.number}
|
||||
|
||||
@@ -122,7 +122,8 @@ export function getNewServingCalls(previous, nextOrTickets) {
|
||||
calls.push({
|
||||
stationId,
|
||||
ticketNumber: number,
|
||||
stationName: ticket.station.name ?? `Stesen ${stationId}`,
|
||||
// Leave unset when missing so speech can pick Stesen/Station by locale.
|
||||
stationName: ticket.station.name ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const MALAY_LANG = 'ms-MY';
|
||||
const ENGLISH_LANG = 'en-US';
|
||||
|
||||
const MALAY_DIGITS = [
|
||||
'kosong',
|
||||
@@ -13,6 +14,19 @@ const MALAY_DIGITS = [
|
||||
'sembilan',
|
||||
];
|
||||
|
||||
const ENGLISH_DIGITS = [
|
||||
'zero',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
];
|
||||
|
||||
let speechUnlocked = false;
|
||||
let speechQueue = [];
|
||||
let speaking = false;
|
||||
@@ -35,27 +49,68 @@ function pickMalayVoice() {
|
||||
);
|
||||
}
|
||||
|
||||
function pickEnglishVoice() {
|
||||
const voices = loadVoices();
|
||||
return (
|
||||
voices.find((voice) => voice.lang === ENGLISH_LANG) ||
|
||||
voices.find((voice) => voice.lang.startsWith('en')) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spell ticket numbers digit-by-digit in Malay for clearer announcements.
|
||||
* Prefer Malay when a Malay voice pack is installed; otherwise fall back to English
|
||||
* (common on Windows branches without the Malay language pack).
|
||||
*/
|
||||
export function formatTicketNumberForSpeech(ticketNumber) {
|
||||
function resolveSpeechLocale() {
|
||||
const malayVoice = pickMalayVoice();
|
||||
if (malayVoice) {
|
||||
return { lang: MALAY_LANG, voice: malayVoice, locale: 'ms' };
|
||||
}
|
||||
|
||||
const englishVoice = pickEnglishVoice();
|
||||
return {
|
||||
lang: ENGLISH_LANG,
|
||||
voice: englishVoice,
|
||||
locale: 'en',
|
||||
};
|
||||
}
|
||||
|
||||
function formatTicketNumberForSpeech(ticketNumber, digits) {
|
||||
return String(ticketNumber)
|
||||
.trim()
|
||||
.split('')
|
||||
.map((character) => {
|
||||
if (/\d/.test(character)) {
|
||||
return MALAY_DIGITS[Number(character)];
|
||||
return digits[Number(character)];
|
||||
}
|
||||
return character;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function resolveStationName(stationName, stationId, locale) {
|
||||
if (stationName) return stationName;
|
||||
return locale === 'ms' ? `Stesen ${stationId}` : `Station ${stationId}`;
|
||||
}
|
||||
|
||||
export function formatMalayAnnouncement(ticketNumber, stationName) {
|
||||
const spokenNumber = formatTicketNumberForSpeech(ticketNumber);
|
||||
const spokenNumber = formatTicketNumberForSpeech(ticketNumber, MALAY_DIGITS);
|
||||
return `Nombor giliran ${spokenNumber}, sila ke ${stationName}.`;
|
||||
}
|
||||
|
||||
export function formatEnglishAnnouncement(ticketNumber, stationName) {
|
||||
const spokenNumber = formatTicketNumberForSpeech(ticketNumber, ENGLISH_DIGITS);
|
||||
return `Queue number ${spokenNumber}, please proceed to ${stationName}.`;
|
||||
}
|
||||
|
||||
function formatAnnouncement(ticketNumber, stationName, locale) {
|
||||
if (locale === 'ms') {
|
||||
return formatMalayAnnouncement(ticketNumber, stationName);
|
||||
}
|
||||
return formatEnglishAnnouncement(ticketNumber, stationName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once after a user gesture so later speech is allowed on strict browsers.
|
||||
*/
|
||||
@@ -64,9 +119,10 @@ export function unlockQueueSpeech() {
|
||||
|
||||
try {
|
||||
loadVoices();
|
||||
const { lang } = resolveSpeechLocale();
|
||||
const utterance = new SpeechSynthesisUtterance('');
|
||||
utterance.volume = 0;
|
||||
utterance.lang = MALAY_LANG;
|
||||
utterance.lang = lang;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch {
|
||||
// Ignore unlock failures; real announcements will still be attempted.
|
||||
@@ -79,11 +135,10 @@ function processSpeechQueue() {
|
||||
if (speaking || speechQueue.length === 0 || !speechSynthesisAvailable()) return;
|
||||
|
||||
speaking = true;
|
||||
const text = speechQueue.shift();
|
||||
const { text, lang, voice } = speechQueue.shift();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = MALAY_LANG;
|
||||
utterance.lang = lang;
|
||||
|
||||
const voice = pickMalayVoice();
|
||||
if (voice) {
|
||||
utterance.voice = voice;
|
||||
}
|
||||
@@ -105,18 +160,28 @@ function processSpeechQueue() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue Malay voice announcements for one or more station calls.
|
||||
* @param {{ ticketNumber: string, stationName: string }[]} calls
|
||||
* Queue voice announcements for one or more station calls.
|
||||
* Uses Malay when available, otherwise English.
|
||||
* @param {{ ticketNumber: string, stationName?: string, stationId?: string }[]} calls
|
||||
*/
|
||||
export function announceQueueCalls(calls) {
|
||||
if (!calls?.length || !speechSynthesisAvailable()) return;
|
||||
|
||||
unlockQueueSpeech();
|
||||
|
||||
const { lang, voice, locale } = resolveSpeechLocale();
|
||||
|
||||
for (const call of calls) {
|
||||
speechQueue.push(
|
||||
formatMalayAnnouncement(call.ticketNumber, call.stationName)
|
||||
const stationName = resolveStationName(
|
||||
call.stationName,
|
||||
call.stationId,
|
||||
locale
|
||||
);
|
||||
speechQueue.push({
|
||||
text: formatAnnouncement(call.ticketNumber, stationName, locale),
|
||||
lang,
|
||||
voice,
|
||||
});
|
||||
}
|
||||
|
||||
processSpeechQueue();
|
||||
|
||||
Reference in New Issue
Block a user