修复:兼容Chrome公屏语音播放限制
问题:Chrome在页面没有用户操作时拒绝speechSynthesis并返回not-allowed,导致同机Safari有声但Chrome无声。 实现:两个公屏增加一次性声音启用操作,在用户手势中解锁语音;保留每次三遍播报,并补充暂停恢复、失败提示和回归测试。 复现:直接打开公屏页面后由员工端叫号,Chrome不播放声音。
This commit is contained in:
38
web/src/components/SpeechControl.tsx
Normal file
38
web/src/components/SpeechControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
web/src/hooks/useSpeechAnnouncements.ts
Normal file
63
web/src/hooks/useSpeechAnnouncements.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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="小七孔文旅集团" />
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user