修复:兼容Chrome公屏语音播放限制

问题:Chrome在页面没有用户操作时拒绝speechSynthesis并返回not-allowed,导致同机Safari有声但Chrome无声。
实现:两个公屏增加一次性声音启用操作,在用户手势中解锁语音;保留每次三遍播报,并补充暂停恢复、失败提示和回归测试。
复现:直接打开公屏页面后由员工端叫号,Chrome不播放声音。
This commit is contained in:
2026-07-31 18:01:36 +08:00
parent 6f3d14d314
commit d26d36c3d3
7 changed files with 175 additions and 30 deletions

View File

@@ -0,0 +1,38 @@
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>
);
}

View File

@@ -0,0 +1,63 @@
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 };
}

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { pollingResource } = vi.hoisted(() => ({
@@ -96,15 +96,27 @@ describe("DisplayPage", () => {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
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 });
vi.stubGlobal("speechSynthesis", { speak, cancel, paused: false, resume: vi.fn() });
const { rerender } = render(<DisplayPage />);
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => {
speak.mock.calls[0][0].onstart?.();
speak.mock.calls[0][0].onerror?.({ error: "interrupted" });
});
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
cancel.mockClear();
pollingResource.data.current_batch = {
batch_number: 3,
status: "CALLED",

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
import { api } from "../api";
import { SpeechControl } from "../components/SpeechControl";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, formatDateTime, formatTicketNumberRange, isTimestampStale, projectStatusMeta } from "../lib/format";
import type { CallBatchDto } from "../types";
@@ -42,6 +44,7 @@ export function forecastRows(batch: CallBatchDto | null | undefined, waitingCoun
export function DisplayPage() {
const { token = "" } = useParams();
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.display(token, signal), {
enabled: Boolean(token),
intervalMs: 3_000,
@@ -64,18 +67,10 @@ export function DisplayPage() {
|| previous.token !== token
|| previous.key === currentCallKey
|| !currentAnnouncement
|| !("speechSynthesis" in window)
|| typeof SpeechSynthesisUtterance === "undefined"
) return;
window.speechSynthesis.cancel();
for (let repeat = 0; repeat < 3; repeat += 1) {
const utterance = new SpeechSynthesisUtterance(currentAnnouncement);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
window.speechSynthesis.speak(utterance);
}
}, [currentAnnouncement, currentCallKey, hasSnapshot, token]);
announce([currentAnnouncement]);
}, [announce, currentAnnouncement, currentCallKey, hasSnapshot, token]);
const waitingTicketCount = data?.waiting_ticket_count ?? data?.waiting_count ?? 0;
const waitingPeopleCount = data?.waiting_people_count ?? 0;
const forecasts = data ? forecastRows(current, waitingTicketCount, data.estimated_wait) : [];
@@ -106,6 +101,7 @@ export function DisplayPage() {
return (
<main className="display-page">
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<header className="display-header">
<div className="display-brand">
<img src="/xiaoqikong-logo.jpg" alt="小七孔文旅集团" />

View File

@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { displayOverview, pollingResource } = vi.hoisted(() => ({
@@ -67,15 +67,24 @@ describe("PublicDisplayCenterPage", () => {
class SpeechSynthesisUtteranceMock {
text: string;
lang = "";
rate = 1;
onstart: (() => void) | null = null;
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: vi.fn() });
vi.stubGlobal("speechSynthesis", { speak, cancel: vi.fn(), paused: false, resume: vi.fn() });
const { rerender } = render(<PublicDisplayCenterPage />);
fireEvent.click(screen.getByRole("button", { name: "开启叫号声音" }));
act(() => speak.mock.calls[0][0].onstart?.());
expect(screen.getByText("叫号声音已开启")).toBeVisible();
speak.mockClear();
pollingResource.data = {
...pollingResource.data,
projects: [{

View File

@@ -1,7 +1,9 @@
import { useEffect, useRef } from "react";
import { api } from "../api";
import { FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { SpeechControl } from "../components/SpeechControl";
import { usePollingResource } from "../hooks/usePollingResource";
import { useSpeechAnnouncements } from "../hooks/useSpeechAnnouncements";
import { formatCallAnnouncement, isTimestampStale } from "../lib/format";
import type { CallBatchDto } from "../types";
import { DisplayCenter } from "./AdminPage";
@@ -11,6 +13,7 @@ function projectCurrentBatch(value: unknown): CallBatchDto | null {
}
export function PublicDisplayCenterPage() {
const { announce, enable: enableSpeech, status: speechStatus } = useSpeechAnnouncements();
const resource = usePollingResource((signal) => api.displayOverview(signal), { intervalMs: 5_000 });
const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
@@ -29,26 +32,13 @@ export function PublicDisplayCenterPage() {
if (announcement) announcements.push(`${project.name}${announcement}`);
}
lastCallsRef.current = nextCalls;
if (
!announcements.length
|| !("speechSynthesis" in window)
|| typeof SpeechSynthesisUtterance === "undefined"
) return;
window.speechSynthesis.cancel();
for (const announcement of announcements) {
for (let repeat = 0; repeat < 3; repeat += 1) {
const utterance = new SpeechSynthesisUtterance(announcement);
utterance.lang = "zh-CN";
utterance.rate = 0.9;
window.speechSynthesis.speak(utterance);
}
}
}, [data]);
announce(announcements);
}, [announce, data]);
return (
<div className="app-shell app-shell--admin app-shell--no-primary-action">
<a className="skip-link" href="#main-content"></a>
<SpeechControl status={speechStatus} onEnable={enableSpeech} />
<header className="app-header">
<div className="brand-lockup" aria-label="景区排队叫号系统">
<span className="brand-logo-frame" aria-hidden="true"><img className="brand-logo" src="/xiaoqikong-logo.jpg" alt="" /></span>

View File

@@ -2481,6 +2481,43 @@ tbody tr:hover {
font-weight: 700;
}
.speech-control {
position: fixed;
z-index: 60;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: var(--space-4);
max-width: min(620px, calc(100vw - 36px));
border: 1px solid #fbbf24;
border-radius: var(--radius-card);
padding: var(--space-3) var(--space-4);
background: rgba(20, 28, 38, .96);
box-shadow: 0 16px 40px rgba(0, 0, 0, .28);
color: #fff;
}
.speech-control > div,
.speech-control--ready {
display: grid;
gap: 2px;
}
.speech-control span {
color: #dbe5ee;
font-size: .88rem;
}
.speech-control--ready {
border-color: #34d399;
background: rgba(5, 76, 52, .96);
}
.speech-control--error {
border-color: #fb7185;
}
.display-header {
grid-row: 1;
display: flex;