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