优化:公屏叫号默认播放并移除声音控制
需求:服务端下发音频已验证可用,公屏无需再显示开启声音按钮和状态提示。 实现:移除声音控制组件与首次点击门槛,新叫号到达后直接播放内置音频队列。 验证:前端 65 项测试通过,生产构建通过。
This commit is contained in:
Binary file not shown.
@@ -1,37 +0,0 @@
|
|||||||
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,6 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export type AudioAnnouncementStatus = "locked" | "enabling" | "ready" | "error";
|
|
||||||
|
|
||||||
const AUDIO_BASE_PATH = "/audio/queue-voice";
|
const AUDIO_BASE_PATH = "/audio/queue-voice";
|
||||||
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
const ANNOUNCEMENT_REPEAT_COUNT = 3;
|
||||||
@@ -62,14 +60,10 @@ export function announcementAudioQueue(announcements: string[]): string[] {
|
|||||||
export function useAudioAnnouncements() {
|
export function useAudioAnnouncements() {
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const playbackIdRef = useRef(0);
|
const playbackIdRef = useRef(0);
|
||||||
const [status, setStatus] = useState<AudioAnnouncementStatus>("locked");
|
|
||||||
|
|
||||||
const playQueue = useCallback((sources: string[], onStarted?: () => void) => {
|
const playQueue = useCallback((sources: string[]) => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio || !sources.length) {
|
if (!audio || !sources.length) return;
|
||||||
setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const playbackId = ++playbackIdRef.current;
|
const playbackId = ++playbackIdRef.current;
|
||||||
audio.pause();
|
audio.pause();
|
||||||
@@ -77,18 +71,10 @@ export function useAudioAnnouncements() {
|
|||||||
audio.onerror = null;
|
audio.onerror = null;
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let started = false;
|
const fail = () => {
|
||||||
const markStarted = () => {
|
|
||||||
if (started || playbackIdRef.current !== playbackId) return;
|
|
||||||
started = true;
|
|
||||||
onStarted?.();
|
|
||||||
};
|
|
||||||
const fail = (error?: unknown) => {
|
|
||||||
if (playbackIdRef.current !== playbackId) return;
|
if (playbackIdRef.current !== playbackId) return;
|
||||||
if (error instanceof DOMException && error.name === "AbortError") return;
|
|
||||||
audio.onended = null;
|
audio.onended = null;
|
||||||
audio.onerror = null;
|
audio.onerror = null;
|
||||||
setStatus("error");
|
|
||||||
};
|
};
|
||||||
const playNext = () => {
|
const playNext = () => {
|
||||||
if (playbackIdRef.current !== playbackId) return;
|
if (playbackIdRef.current !== playbackId) return;
|
||||||
@@ -107,28 +93,21 @@ export function useAudioAnnouncements() {
|
|||||||
try {
|
try {
|
||||||
const result = audio.play();
|
const result = audio.play();
|
||||||
if (result && typeof result.then === "function") {
|
if (result && typeof result.then === "function") {
|
||||||
void result.then(markStarted).catch(fail);
|
void result.catch(fail);
|
||||||
} else {
|
|
||||||
markStarted();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
fail(error);
|
fail();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
playNext();
|
playNext();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const enable = useCallback(() => {
|
|
||||||
setStatus("enabling");
|
|
||||||
playQueue([`${AUDIO_BASE_PATH}/ready.wav`], () => setStatus("ready"));
|
|
||||||
}, [playQueue]);
|
|
||||||
|
|
||||||
const announce = useCallback((announcements: string[]) => {
|
const announce = useCallback((announcements: string[]) => {
|
||||||
if (status !== "ready" || !announcements.length) return;
|
if (!announcements.length) return;
|
||||||
const queue = announcementAudioQueue(announcements);
|
const queue = announcementAudioQueue(announcements);
|
||||||
if (queue.length) playQueue(queue);
|
if (queue.length) playQueue(queue);
|
||||||
}, [playQueue, status]);
|
}, [playQueue]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
playbackIdRef.current += 1;
|
playbackIdRef.current += 1;
|
||||||
@@ -139,5 +118,5 @@ export function useAudioAnnouncements() {
|
|||||||
audio.onerror = null;
|
audio.onerror = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { announce, audioRef, enable, status };
|
return { announce, audioRef };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { pollingResource } = vi.hoisted(() => ({
|
const { pollingResource } = vi.hoisted(() => ({
|
||||||
@@ -95,7 +95,7 @@ describe("DisplayPage", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("新批次出现时用内置音频连续播报三次叫号内容", async () => {
|
it("新批次出现时无需人工开启即可连续播报三次叫号内容", () => {
|
||||||
const playedSources: string[] = [];
|
const playedSources: string[] = [];
|
||||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||||
@@ -104,13 +104,8 @@ describe("DisplayPage", () => {
|
|||||||
});
|
});
|
||||||
const { container, rerender } = render(<DisplayPage />);
|
const { container, rerender } = render(<DisplayPage />);
|
||||||
|
|
||||||
await act(async () => {
|
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
|
||||||
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
|
||||||
playedSources.length = 0;
|
|
||||||
|
|
||||||
pollingResource.data.current_batch = {
|
pollingResource.data.current_batch = {
|
||||||
batch_number: 3,
|
batch_number: 3,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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 { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||||
import { usePollingResource } from "../hooks/usePollingResource";
|
import { usePollingResource } from "../hooks/usePollingResource";
|
||||||
@@ -12,7 +11,7 @@ export { forecastRows } from "../lib/display";
|
|||||||
|
|
||||||
export function DisplayPage() {
|
export function DisplayPage() {
|
||||||
const { token = "" } = useParams();
|
const { token = "" } = useParams();
|
||||||
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
const { announce, audioRef } = 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 +69,7 @@ export function DisplayPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="single-display-screen">
|
<main className="single-display-screen">
|
||||||
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||||
{(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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { displayOverview, pollingResource } = vi.hoisted(() => ({
|
const { displayOverview, pollingResource } = vi.hoisted(() => ({
|
||||||
@@ -63,7 +63,7 @@ describe("PublicDisplayCenterPage", () => {
|
|||||||
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("navigation", { name: "管理任务" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("新叫号批次出现时只用内置音频连续三次播报号码", async () => {
|
it("新叫号批次出现时无需人工开启即可连续三次播报号码", () => {
|
||||||
const playedSources: string[] = [];
|
const playedSources: string[] = [];
|
||||||
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => undefined);
|
||||||
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
vi.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(function (this: HTMLMediaElement) {
|
||||||
@@ -72,13 +72,8 @@ describe("PublicDisplayCenterPage", () => {
|
|||||||
});
|
});
|
||||||
const { container, rerender } = render(<PublicDisplayCenterPage />);
|
const { container, rerender } = render(<PublicDisplayCenterPage />);
|
||||||
|
|
||||||
await act(async () => {
|
expect(screen.queryByRole("button", { name: "开启叫号声音" })).not.toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
|
expect(screen.queryByText("叫号声音已开启")).not.toBeInTheDocument();
|
||||||
await Promise.resolve();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("叫号声音已开启")).toBeVisible();
|
|
||||||
expect(playedSources).toEqual(["/audio/queue-voice/ready.wav"]);
|
|
||||||
playedSources.length = 0;
|
|
||||||
|
|
||||||
pollingResource.data = {
|
pollingResource.data = {
|
||||||
...pollingResource.data,
|
...pollingResource.data,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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 { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
import { useAudioAnnouncements } from "../hooks/useAudioAnnouncements";
|
||||||
import { usePollingResource } from "../hooks/usePollingResource";
|
import { usePollingResource } from "../hooks/usePollingResource";
|
||||||
@@ -13,7 +12,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PublicDisplayCenterPage() {
|
export function PublicDisplayCenterPage() {
|
||||||
const { announce, audioRef, enable: enableAudio, status: audioStatus } = useAudioAnnouncements();
|
const { announce, audioRef } = 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);
|
||||||
@@ -38,7 +37,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>
|
||||||
<AudioControl audioRef={audioRef} status={audioStatus} onEnable={enableAudio} />
|
<audio ref={audioRef} preload="auto" aria-hidden="true" />
|
||||||
<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