Compare commits
2 Commits
main
..
e24bf790d3
| Author | SHA1 | Date | |
|---|---|---|---|
| e24bf790d3 | |||
| dd67b5d0a8 |
@@ -178,7 +178,7 @@
|
|||||||
.branch-display__station-card {
|
.branch-display__station-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35em;
|
gap: 0.3em;
|
||||||
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,16 +189,8 @@
|
|||||||
|
|
||||||
.branch-display__station-name {
|
.branch-display__station-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.2em 0.45em;
|
font-size: 1.1em;
|
||||||
font-size: clamp(1.35em, min(3.2vw, 5.5vh), 2.75em);
|
color: var(--bd-muted);
|
||||||
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 {
|
||||||
@@ -215,12 +207,7 @@
|
|||||||
@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.2em;
|
gap: 0.15em;
|
||||||
}
|
|
||||||
|
|
||||||
.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,16 +3,11 @@ 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 {
|
||||||
getNewServingCalls,
|
hasNewServingCall,
|
||||||
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';
|
||||||
|
|
||||||
@@ -177,38 +172,14 @@ 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 (newCalls.length > 0) {
|
if (hasNewServingCall(previous, next)) {
|
||||||
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]
|
||||||
@@ -220,7 +191,7 @@ export default function BranchDisplayPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="branch-display" onClick={unlockAnnouncements}>
|
<div className="branch-display" onClick={unlockQueueCallSound}>
|
||||||
<header className="branch-display__header">
|
<header className="branch-display__header">
|
||||||
<div className="branch-display__brand">
|
<div className="branch-display__brand">
|
||||||
<img
|
<img
|
||||||
@@ -252,6 +223,9 @@ 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
|
||||||
@@ -261,9 +235,6 @@ 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>
|
||||||
|
|||||||
@@ -45,35 +45,20 @@ 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({ onEnded } = {}) {
|
export function playQueueCallSound() {
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,37 +79,11 @@ 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) {
|
||||||
return getNewServingCalls(previous, next).length > 0;
|
if (!previous) return false;
|
||||||
}
|
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.
|
|
||||||
*/
|
|
||||||
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 false;
|
||||||
return calls;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
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