Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4fb7b4d8b |
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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