功能:内置公屏叫号音频并仅播号码
需求:屏行天下等设备缺少中文语音服务,speechSynthesis 无法播报;公屏不再播项目名。 实现:内置中文数字和提示语 WAV 素材,统一通过普通 audio 元素顺序组合播放,每次叫号重复三遍,并保留首次点击解锁。 验证:前端 65 项测试通过,生产构建通过,真实浏览器完成 WAV 解码播放且无媒体错误。
This commit is contained in:
BIN
web/public/audio/queue-voice/digit-0.wav
Normal file
BIN
web/public/audio/queue-voice/digit-0.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-1.wav
Normal file
BIN
web/public/audio/queue-voice/digit-1.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-2.wav
Normal file
BIN
web/public/audio/queue-voice/digit-2.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-3.wav
Normal file
BIN
web/public/audio/queue-voice/digit-3.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-4.wav
Normal file
BIN
web/public/audio/queue-voice/digit-4.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-5.wav
Normal file
BIN
web/public/audio/queue-voice/digit-5.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-6.wav
Normal file
BIN
web/public/audio/queue-voice/digit-6.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-7.wav
Normal file
BIN
web/public/audio/queue-voice/digit-7.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-8.wav
Normal file
BIN
web/public/audio/queue-voice/digit-8.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/digit-9.wav
Normal file
BIN
web/public/audio/queue-voice/digit-9.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/entrance.wav
Normal file
BIN
web/public/audio/queue-voice/entrance.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/gap.wav
Normal file
BIN
web/public/audio/queue-voice/gap.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/number.wav
Normal file
BIN
web/public/audio/queue-voice/number.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/please.wav
Normal file
BIN
web/public/audio/queue-voice/please.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/ready.wav
Normal file
BIN
web/public/audio/queue-voice/ready.wav
Normal file
Binary file not shown.
BIN
web/public/audio/queue-voice/through.wav
Normal file
BIN
web/public/audio/queue-voice/through.wav
Normal file
Binary file not shown.
37
web/src/components/AudioControl.tsx
Normal file
37
web/src/components/AudioControl.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { AudioAnnouncementStatus } from "../hooks/useAudioAnnouncements";
|
||||||
|
|
||||||
|
interface AudioControlProps {
|
||||||
|
audioRef: RefObject<HTMLAudioElement | null>;
|
||||||
|
status: AudioAnnouncementStatus;
|
||||||
|
onEnable: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioControl({ audioRef, status, onEnable }: AudioControlProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||||
|
{status === "ready" ? (
|
||||||
|
<aside className="speech-control speech-control--ready" role="status">
|
||||||
|
<strong>叫号声音已开启</strong>
|
||||||
|
<span>后续叫号将自动播报三次</span>
|
||||||
|
</aside>
|
||||||
|
) : (
|
||||||
|
<aside className={`speech-control${status === "error" ? " speech-control--error" : ""}`} role="status">
|
||||||
|
<div>
|
||||||
|
<strong>{status === "error" ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
|
||||||
|
<span>{status === "error" ? "请检查设备媒体音量后重试" : "请点击一次,之后将自动播报"}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="button button--primary"
|
||||||
|
type="button"
|
||||||
|
disabled={status === "enabling"}
|
||||||
|
onClick={onEnable}
|
||||||
|
>
|
||||||
|
{status === "enabling" ? "正在开启叫号声音" : status === "error" ? "重新开启叫号声音" : "开启叫号声音"}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { SpeechAnnouncementStatus } from "../hooks/useSpeechAnnouncements";
|
|
||||||
|
|
||||||
interface SpeechControlProps {
|
|
||||||
status: SpeechAnnouncementStatus;
|
|
||||||
onEnable: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SpeechControl({ status, onEnable }: SpeechControlProps) {
|
|
||||||
if (status === "ready") {
|
|
||||||
return (
|
|
||||||
<aside className="speech-control speech-control--ready" role="status">
|
|
||||||
<strong>叫号声音已开启</strong>
|
|
||||||
<span>后续叫号将自动播报三次</span>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const unsupported = status === "unsupported";
|
|
||||||
const failed = status === "error";
|
|
||||||
return (
|
|
||||||
<aside className={`speech-control${failed || unsupported ? " speech-control--error" : ""}`} role="status">
|
|
||||||
<div>
|
|
||||||
<strong>{unsupported ? "当前浏览器不支持叫号声音" : failed ? "叫号声音开启失败" : "叫号声音尚未开启"}</strong>
|
|
||||||
<span>{unsupported || failed ? "请确认系统已安装中文语音服务" : "Chrome 需要先点击一次,之后才允许自动播报"}</span>
|
|
||||||
</div>
|
|
||||||
{!unsupported ? (
|
|
||||||
<button
|
|
||||||
className="button button--primary"
|
|
||||||
type="button"
|
|
||||||
disabled={status === "enabling"}
|
|
||||||
onClick={onEnable}
|
|
||||||
>
|
|
||||||
{status === "enabling" ? "正在开启叫号声音" : failed ? "重新开启叫号声音" : "开启叫号声音"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
37
web/src/hooks/useAudioAnnouncements.test.ts
Normal file
37
web/src/hooks/useAudioAnnouncements.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { announcementAudioQueue } from "./useAudioAnnouncements";
|
||||||
|
|
||||||
|
describe("announcementAudioQueue", () => {
|
||||||
|
it("忽略项目名并把单号码播报素材重复三遍", () => {
|
||||||
|
const once = [
|
||||||
|
"/audio/queue-voice/please.wav",
|
||||||
|
"/audio/queue-voice/digit-0.wav",
|
||||||
|
"/audio/queue-voice/digit-0.wav",
|
||||||
|
"/audio/queue-voice/digit-0.wav",
|
||||||
|
"/audio/queue-voice/digit-1.wav",
|
||||||
|
"/audio/queue-voice/digit-6.wav",
|
||||||
|
"/audio/queue-voice/number.wav",
|
||||||
|
"/audio/queue-voice/entrance.wav",
|
||||||
|
"/audio/queue-voice/gap.wav",
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(announcementAudioQueue(["东门观光车,请零零零一六号,前往入口。"]))
|
||||||
|
.toEqual([...once, ...once, ...once]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持连续号码范围", () => {
|
||||||
|
const queue = announcementAudioQueue(["请一七号至一九号,前往入口。"]);
|
||||||
|
|
||||||
|
expect(queue.slice(0, 9)).toEqual([
|
||||||
|
"/audio/queue-voice/please.wav",
|
||||||
|
"/audio/queue-voice/digit-1.wav",
|
||||||
|
"/audio/queue-voice/digit-7.wav",
|
||||||
|
"/audio/queue-voice/number.wav",
|
||||||
|
"/audio/queue-voice/through.wav",
|
||||||
|
"/audio/queue-voice/digit-1.wav",
|
||||||
|
"/audio/queue-voice/digit-9.wav",
|
||||||
|
"/audio/queue-voice/number.wav",
|
||||||
|
"/audio/queue-voice/entrance.wav",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
143
web/src/hooks/useAudioAnnouncements.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type AudioAnnouncementStatus = "locked" | "enabling" | "ready" | "error";
|
||||||
|
|
||||||
|
const AUDIO_BASE_PATH = "/audio/queue-voice";
|
||||||
|
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
||||||
|
const GAP_AUDIO = `${AUDIO_BASE_PATH}/gap.wav`;
|
||||||
|
|
||||||
|
const DIGIT_AUDIO: Record<string, string> = {
|
||||||
|
零: `${AUDIO_BASE_PATH}/digit-0.wav`,
|
||||||
|
一: `${AUDIO_BASE_PATH}/digit-1.wav`,
|
||||||
|
二: `${AUDIO_BASE_PATH}/digit-2.wav`,
|
||||||
|
三: `${AUDIO_BASE_PATH}/digit-3.wav`,
|
||||||
|
四: `${AUDIO_BASE_PATH}/digit-4.wav`,
|
||||||
|
五: `${AUDIO_BASE_PATH}/digit-5.wav`,
|
||||||
|
六: `${AUDIO_BASE_PATH}/digit-6.wav`,
|
||||||
|
七: `${AUDIO_BASE_PATH}/digit-7.wav`,
|
||||||
|
八: `${AUDIO_BASE_PATH}/digit-8.wav`,
|
||||||
|
九: `${AUDIO_BASE_PATH}/digit-9.wav`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHRASE_AUDIO = [
|
||||||
|
["前往入口", `${AUDIO_BASE_PATH}/entrance.wav`],
|
||||||
|
["请", `${AUDIO_BASE_PATH}/please.wav`],
|
||||||
|
["号", `${AUDIO_BASE_PATH}/number.wav`],
|
||||||
|
["至", `${AUDIO_BASE_PATH}/through.wav`],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function clipsForAnnouncement(announcement: string): string[] {
|
||||||
|
const callStart = announcement.indexOf("请");
|
||||||
|
if (callStart < 0) return [];
|
||||||
|
|
||||||
|
const callText = announcement.slice(callStart);
|
||||||
|
const clips: string[] = [];
|
||||||
|
for (let index = 0; index < callText.length;) {
|
||||||
|
const phrase = PHRASE_AUDIO.find(([text]) => callText.startsWith(text, index));
|
||||||
|
if (phrase) {
|
||||||
|
clips.push(phrase[1]);
|
||||||
|
index += phrase[0].length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digit = DIGIT_AUDIO[callText[index]];
|
||||||
|
if (digit) clips.push(digit);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clips.length ? [...clips, GAP_AUDIO] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function announcementAudioQueue(announcements: string[]): string[] {
|
||||||
|
const queue: string[] = [];
|
||||||
|
for (const announcement of announcements) {
|
||||||
|
const clips = clipsForAnnouncement(announcement);
|
||||||
|
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
|
||||||
|
queue.push(...clips);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioAnnouncements() {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const playbackIdRef = useRef(0);
|
||||||
|
const [status, setStatus] = useState<AudioAnnouncementStatus>("locked");
|
||||||
|
|
||||||
|
const playQueue = useCallback((sources: string[], onStarted?: () => void) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio || !sources.length) {
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackId = ++playbackIdRef.current;
|
||||||
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
|
||||||
|
let cursor = 0;
|
||||||
|
let started = false;
|
||||||
|
const markStarted = () => {
|
||||||
|
if (started || playbackIdRef.current !== playbackId) return;
|
||||||
|
started = true;
|
||||||
|
onStarted?.();
|
||||||
|
};
|
||||||
|
const fail = (error?: unknown) => {
|
||||||
|
if (playbackIdRef.current !== playbackId) return;
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") return;
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
setStatus("error");
|
||||||
|
};
|
||||||
|
const playNext = () => {
|
||||||
|
if (playbackIdRef.current !== playbackId) return;
|
||||||
|
if (cursor >= sources.length) {
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.src = sources[cursor];
|
||||||
|
cursor += 1;
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.onended = playNext;
|
||||||
|
audio.onerror = () => fail();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = audio.play();
|
||||||
|
if (result && typeof result.then === "function") {
|
||||||
|
void result.then(markStarted).catch(fail);
|
||||||
|
} else {
|
||||||
|
markStarted();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
playNext();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enable = useCallback(() => {
|
||||||
|
setStatus("enabling");
|
||||||
|
playQueue([`${AUDIO_BASE_PATH}/ready.wav`], () => setStatus("ready"));
|
||||||
|
}, [playQueue]);
|
||||||
|
|
||||||
|
const announce = useCallback((announcements: string[]) => {
|
||||||
|
if (status !== "ready" || !announcements.length) return;
|
||||||
|
const queue = announcementAudioQueue(announcements);
|
||||||
|
if (queue.length) playQueue(queue);
|
||||||
|
}, [playQueue, status]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
playbackIdRef.current += 1;
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
audio.pause();
|
||||||
|
audio.onended = null;
|
||||||
|
audio.onerror = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { announce, audioRef, enable, status };
|
||||||
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { useCallback, useState } from "react";
|
|
||||||
|
|
||||||
export type SpeechAnnouncementStatus = "locked" | "enabling" | "ready" | "error" | "unsupported";
|
|
||||||
|
|
||||||
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
|
||||||
|
|
||||||
function speechSupported(): boolean {
|
|
||||||
return typeof window !== "undefined"
|
|
||||||
&& "speechSynthesis" in window
|
|
||||||
&& typeof SpeechSynthesisUtterance !== "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
function createUtterance(text: string): SpeechSynthesisUtterance {
|
|
||||||
const utterance = new SpeechSynthesisUtterance(text);
|
|
||||||
utterance.lang = "zh-CN";
|
|
||||||
utterance.rate = 0.9;
|
|
||||||
return utterance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSpeechAnnouncements() {
|
|
||||||
const [status, setStatus] = useState<SpeechAnnouncementStatus>(() => (
|
|
||||||
speechSupported() ? "locked" : "unsupported"
|
|
||||||
));
|
|
||||||
|
|
||||||
const enable = useCallback(() => {
|
|
||||||
if (!speechSupported()) {
|
|
||||||
setStatus("unsupported");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const synthesis = window.speechSynthesis;
|
|
||||||
const utterance = createUtterance("叫号声音已开启。");
|
|
||||||
utterance.onstart = () => setStatus("ready");
|
|
||||||
utterance.onend = () => setStatus("ready");
|
|
||||||
utterance.onerror = (event) => {
|
|
||||||
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
|
|
||||||
};
|
|
||||||
|
|
||||||
setStatus("enabling");
|
|
||||||
synthesis.cancel();
|
|
||||||
if (synthesis.paused) synthesis.resume();
|
|
||||||
synthesis.speak(utterance);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const announce = useCallback((announcements: string[]) => {
|
|
||||||
if (status !== "ready" || !announcements.length || !speechSupported()) return;
|
|
||||||
|
|
||||||
const synthesis = window.speechSynthesis;
|
|
||||||
synthesis.cancel();
|
|
||||||
if (synthesis.paused) synthesis.resume();
|
|
||||||
for (const announcement of announcements) {
|
|
||||||
for (let repeat = 0; repeat < ANNOUNCEMENT_REPEAT_COUNT; repeat += 1) {
|
|
||||||
const utterance = createUtterance(announcement);
|
|
||||||
utterance.onerror = (event) => {
|
|
||||||
if (event.error !== "canceled" && event.error !== "interrupted") setStatus("error");
|
|
||||||
};
|
|
||||||
synthesis.speak(utterance);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
return { announce, enable, status };
|
|
||||||
}
|
|
||||||
@@ -48,6 +48,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { DisplayPage, forecastRows } from "./DisplayPage";
|
import { DisplayPage, forecastRows } from "./DisplayPage";
|
||||||
|
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||||
|
|
||||||
describe("DisplayPage", () => {
|
describe("DisplayPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -94,33 +95,22 @@ describe("DisplayPage", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("新批次出现时连续播报三次叫号内容", () => {
|
it("新批次出现时用内置音频连续播报三次叫号内容", async () => {
|
||||||
const speak = vi.fn();
|
const playedSources: string[] = [];
|
||||||
const cancel = vi.fn();
|
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||||
class SpeechSynthesisUtteranceMock {
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||||
text: string;
|
playedSources.push(this.getAttribute("src") ?? "");
|
||||||
lang = "";
|
return Promise.resolve();
|
||||||
rate = 1;
|
});
|
||||||
onstart: (() => void) | null = null;
|
const { container, rerender } = render(<DisplayPage />);
|
||||||
onend: (() => void) | null = null;
|
|
||||||
onerror: ((event: { error: string }) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(text: string) {
|
|
||||||
this.text = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
|
|
||||||
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
|
|
||||||
const { rerender } = render(<DisplayPage />);
|
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||||
act(() => {
|
await Promise.resolve();
|
||||||
speak.mock.calls[0][0].onstart?.();
|
|
||||||
speak.mock.calls[0][0].onerror?.({ error: "interrupted" });
|
|
||||||
});
|
});
|
||||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
||||||
speak.mockClear();
|
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
||||||
cancel.mockClear();
|
playedSources.length = 0;
|
||||||
|
|
||||||
pollingResource.data.current_batch = {
|
pollingResource.data.current_batch = {
|
||||||
batch_number: 3,
|
batch_number: 3,
|
||||||
@@ -134,14 +124,12 @@ describe("DisplayPage", () => {
|
|||||||
rerender(<DisplayPage />);
|
rerender(<DisplayPage />);
|
||||||
rerender(<DisplayPage />);
|
rerender(<DisplayPage />);
|
||||||
|
|
||||||
expect(cancel).toHaveBeenCalledOnce();
|
const expectedQueue = announcementAudioQueue(["请零零零零五号至零零零零六号,前往入口。"]).slice();
|
||||||
expect(speak).toHaveBeenCalledTimes(3);
|
const audio = container.querySelector("audio");
|
||||||
for (const [utterance] of speak.mock.calls) {
|
expect(audio).not.toBeNull();
|
||||||
expect(utterance).toMatchObject({
|
for (let index = 1; index < expectedQueue.length; index += 1) {
|
||||||
text: "请零零零零五号至零零零零六号,前往入口。",
|
fireEvent.ended(audio!);
|
||||||
lang: "zh-CN",
|
|
||||||
rate: 0.9,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
expect(playedSources).toEqual(expectedQueue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { AudioControl } from "../components/AudioControl";
|
||||||
import { ProjectScreenTile } from "../components/ProjectScreenTile";
|
import { ProjectScreenTile } from "../components/ProjectScreenTile";
|
||||||
import { SpeechControl } from "../components/SpeechControl";
|
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||||
import { usePollingResource } from "../hooks/usePollingResource";
|
import { usePollingResource } from "../hooks/usePollingResource";
|
||||||
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
|
|
||||||
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
|
import { formatCallAnnouncement, formatDateTime, isTimestampStale } from "../lib/format";
|
||||||
import type { AdminProjectDto } from "../types";
|
import type { AdminProjectDto } from "../types";
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export { forecastRows } from "../lib/display";
|
|||||||
|
|
||||||
export function DisplayPage() {
|
export function DisplayPage() {
|
||||||
const { token = "" } = useParams();
|
const { token = "" } = useParams();
|
||||||
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
|
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
||||||
const resource = usePollingResource((signal) => api.display(token, signal), {
|
const resource = usePollingResource((signal) => api.display(token, signal), {
|
||||||
enabled: Boolean(token),
|
enabled: Boolean(token),
|
||||||
intervalMs: 3_000,
|
intervalMs: 3_000,
|
||||||
@@ -70,7 +70,7 @@ export function DisplayPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="single-display-screen">
|
<main className="single-display-screen">
|
||||||
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
|
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
||||||
{(resource.offline || stale || resource.error) ? (
|
{(resource.offline || stale || resource.error) ? (
|
||||||
<div className="display-alert" role="status">
|
<div className="display-alert" role="status">
|
||||||
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>
|
<strong>{resource.offline ? "连接已中断" : "数据更新延迟"}</strong>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ vi.mock("../hooks/usePollingResource", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
|
import { PublicDisplayCenterPage } from "./PublicDisplayCenterPage";
|
||||||
|
import { announcementAudioQueue } from "../hooks/useAudioAnnouncements";
|
||||||
|
|
||||||
describe("PublicDisplayCenterPage", () => {
|
describe("PublicDisplayCenterPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -62,28 +63,22 @@ describe("PublicDisplayCenterPage", () => {
|
|||||||
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("新叫号批次出现时连续三次播报项目和号码", () => {
|
it("新叫号批次出现时只用内置音频连续三次播报号码", async () => {
|
||||||
const speak = vi.fn();
|
const playedSources: string[] = [];
|
||||||
class SpeechSynthesisUtteranceMock {
|
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||||
text: string;
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||||
lang = "";
|
playedSources.push(this.getAttribute("src") ?? "");
|
||||||
rate = 1;
|
return Promise.resolve();
|
||||||
onstart: (() => void) | null = null;
|
});
|
||||||
onend: (() => void) | null = null;
|
const { container, rerender } = render(<PublicDisplayCenterPage />);
|
||||||
onerror: ((event: { error: string }) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(text: string) {
|
|
||||||
this.text = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vi.stubGlobal("SpeechSynthesisUtterance", SpeechSynthesisUtteranceMock);
|
|
||||||
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() });
|
|
||||||
const { rerender } = render(<PublicDisplayCenterPage />);
|
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
||||||
act(() => speak.mock.calls[0][0].onstart?.());
|
await Promise.resolve();
|
||||||
|
});
|
||||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
||||||
speak.mockClear();
|
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
||||||
|
playedSources.length = 0;
|
||||||
|
|
||||||
pollingResource.data = {
|
pollingResource.data = {
|
||||||
...pollingResource.data,
|
...pollingResource.data,
|
||||||
@@ -104,12 +99,12 @@ describe("PublicDisplayCenterPage", () => {
|
|||||||
};
|
};
|
||||||
rerender(<PublicDisplayCenterPage />);
|
rerender(<PublicDisplayCenterPage />);
|
||||||
|
|
||||||
expect(speak).toHaveBeenCalledTimes(3);
|
const expectedQueue = announcementAudioQueue(["请零零零一六号,前往入口。"]).slice();
|
||||||
for (const [utterance] of speak.mock.calls) {
|
const audio = container.querySelector("audio");
|
||||||
expect(utterance).toMatchObject({
|
expect(audio).not.toBeNull();
|
||||||
text: "东门观光车,请零零零一六号,前往入口。",
|
for (let index = 1; index < expectedQueue.length; index += 1) {
|
||||||
lang: "zh-CN",
|
fireEvent.ended(audio!);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
expect(playedSources).toEqual(expectedQueue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { api } from "../api";
|
import { api } from "../api";
|
||||||
|
import { AudioControl } from "../components/AudioControl";
|
||||||
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
|
||||||
import { SpeechControl } from "../components/SpeechControl";
|
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||||
import { usePollingResource } from "../hooks/usePollingResource";
|
import { usePollingResource } from "../hooks/usePollingResource";
|
||||||
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
|
|
||||||
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
|
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
|
||||||
import type { CallBatchDto } from "../types";
|
import type { CallBatchDto } from "../types";
|
||||||
import { DisplayCenter } from "./AdminPage";
|
import { DisplayCenter } from "./AdminPage";
|
||||||
@@ -13,7 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PublicDisplayCenterPage() {
|
export function PublicDisplayCenterPage() {
|
||||||
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
|
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
||||||
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
|
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
|
||||||
const data = resource.data;
|
const data = resource.data;
|
||||||
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
|
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
|
||||||
@@ -29,7 +29,7 @@ export function PublicDisplayCenterPage() {
|
|||||||
nextCalls.set(project.id, key);
|
nextCalls.set(project.id, key);
|
||||||
if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue;
|
if (!previousCalls?.has(project.id) || !key || previousCalls.get(project.id) === key) continue;
|
||||||
const announcement = formatCallAnnouncement(batch);
|
const announcement = formatCallAnnouncement(batch);
|
||||||
if (announcement) announcements.push(`${project.name},${announcement}`);
|
if (announcement) announcements.push(announcement);
|
||||||
}
|
}
|
||||||
lastCallsRef.current = nextCalls;
|
lastCallsRef.current = nextCalls;
|
||||||
announce(announcements);
|
announce(announcements);
|
||||||
@@ -38,7 +38,7 @@ export function PublicDisplayCenterPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
<div className="app-shell app-shell--admin app-shell--no-primary-action">
|
||||||
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
<a className="skip-link" href="#main-content">跳到主要内容</a>
|
||||||
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
|
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="brand-lockup" aria-label="景区排队叫号系统">
|
<div className="brand-lockup" aria-label="景区排队叫号系统">
|
||||||
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>
|
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user