調整語音設定介面並更新 README

This commit is contained in:
2026-04-16 17:11:11 +08:00
parent 860e7adc0e
commit b2494fff17
3 changed files with 346 additions and 25 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { Link } from 'react-router-dom'
import {
getCourtAssignments,
@@ -16,6 +17,19 @@ import type {
ScoreState,
} from '../types'
type VoiceSettings = {
announceScore: boolean
announceServer: boolean
rate: number
}
const VOICE_SETTINGS_STORAGE_KEY = 'badminton-scoreboard::voice-settings'
const defaultVoiceSettings: VoiceSettings = {
announceScore: true,
announceServer: true,
rate: 1,
}
type ScoreboardPageProps = {
currentSelectionOrder: string[]
finishDialogError: string
@@ -82,9 +96,17 @@ export function ScoreboardPage({
onUndoLastPoint,
}: ScoreboardPageProps) {
const [pickerOpen, setPickerOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore))
const [draftTargetScore, setDraftTargetScore] = useState(() =>
String(scoreState.targetScore),
)
const [clock, setClock] = useState(() => formatClock())
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
loadVoiceSettings(),
)
const lastAnnouncedPointRef = useRef(0)
const previousScoresRef = useRef({ left: 0, right: 0 })
useEffect(() => {
const timer = window.setInterval(() => {
@@ -94,6 +116,21 @@ export function ScoreboardPage({
return () => window.clearInterval(timer)
}, [])
useEffect(() => {
window.localStorage.setItem(
VOICE_SETTINGS_STORAGE_KEY,
JSON.stringify(voiceSettings),
)
}, [voiceSettings])
useEffect(() => {
return () => {
if ('speechSynthesis' in window) {
window.speechSynthesis.cancel()
}
}
}, [])
const selectablePlayers = useMemo(() => {
if (!selectedGroup) {
return []
@@ -176,17 +213,74 @@ export function ScoreboardPage({
: null
: null
useEffect(() => {
const totalPoints = scoreState.scoreLeft + scoreState.scoreRight
if (scoreState.serving === null || !currentServer?.name || totalPoints === 0) {
lastAnnouncedPointRef.current = totalPoints
previousScoresRef.current = {
left: scoreState.scoreLeft,
right: scoreState.scoreRight,
}
return
}
if (lastAnnouncedPointRef.current === totalPoints) {
return
}
lastAnnouncedPointRef.current = totalPoints
const scorerSide =
scoreState.scoreLeft > previousScoresRef.current.left
? 'left'
: scoreState.scoreRight > previousScoresRef.current.right
? 'right'
: null
previousScoresRef.current = {
left: scoreState.scoreLeft,
right: scoreState.scoreRight,
}
const parts: string[] = []
if (voiceSettings.announceScore && scorerSide) {
parts.push(
`${getAnnouncementName(scorerSide === 'left' ? leftTeam : rightTeam)}得分`,
)
}
if (voiceSettings.announceServer) {
parts.push(`${currentServer.name}發球`)
}
if (parts.length > 0) {
speakAnnouncement(parts.join(''), voiceSettings.rate)
}
}, [
currentServer?.name,
leftTeam,
rightTeam,
scoreState.scoreLeft,
scoreState.scoreRight,
scoreState.serving,
voiceSettings.announceScore,
voiceSettings.announceServer,
voiceSettings.rate,
])
if (!selectedGroup) {
return (
<section className="page-grid">
<article className="panel panel-hero">
<p className="panel-kicker">Step 3</p>
<h2></h2>
<h2></h2>
<p className="panel-copy">
</p>
<Link className="primary-button inline-link" to="/teams">
</Link>
</article>
</section>
@@ -313,7 +407,7 @@ export function ScoreboardPage({
/>
<div className="scoreboard-center-banner">
<p>{scoreState.serving === null ? '請先設定發球方' : '點擊分數即可記分'}</p>
<p>{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}</p>
<small>
{scoreState.serving === null
? `本場 ${scoreState.targetScore} 分獲勝`
@@ -352,6 +446,14 @@ export function ScoreboardPage({
</button>
)}
<button
aria-label="語音設定"
className="rail-square-button"
type="button"
onClick={() => setSettingsOpen(true)}
>
</button>
</div>
<div className="rail-clock">{clock}</div>
@@ -382,14 +484,22 @@ export function ScoreboardPage({
/>
) : null}
{settingsOpen ? (
<VoiceSettingsModal
settings={voiceSettings}
onClose={() => setSettingsOpen(false)}
onUpdateSettings={setVoiceSettings}
/>
) : null}
{finishDialogOpen ? (
<FinishDialog
error={finishDialogError}
leftScore={scoreState.scoreLeft}
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
matchupLabel={matchupLabel}
rightScore={scoreState.scoreRight}
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
uploading={finishDialogUploading}
onClose={onCloseFinishDialog}
onConfirm={onConfirmUpload}
@@ -461,7 +571,7 @@ function ScoreboardTeamPanel({
<div className="team-head-buttons">
<button
aria-label="上下交換隊伍"
aria-label="交換上下隊伍"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
@@ -470,7 +580,7 @@ function ScoreboardTeamPanel({
</button>
<button
aria-label="左右交換球員"
aria-label="交換左右位置"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
@@ -495,11 +605,6 @@ function ScoreboardTeamPanel({
type="button"
onClick={onSetServing}
>
{showServingPrompt ? (
<span aria-hidden="true" className="serve-lane-arrow">
{teamSlot === 'top' ? '↓' : '↑'}
</span>
) : null}
<span className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span></span>
{currentServer ? (
@@ -592,7 +697,7 @@ function TeamPickerModal({
</button>
<div className="team-picker-ribbon">
<span>{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}</span>
<span>{selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}</span>
</div>
<div className="team-picker-layout">
@@ -600,7 +705,7 @@ function TeamPickerModal({
<div className="team-picker-title">
<span className="team-picker-count">{selectionCount}/4</span>
<div>
<strong></strong>
<strong></strong>
<p>
{group.id} / {sourceLabel} / {targetDate || '-'}
</p>
@@ -609,7 +714,7 @@ function TeamPickerModal({
<div className="team-picker-config-row">
<label className="team-picker-config team-picker-config-compact">
<span></span>
<span></span>
<input
className="team-picker-score-input team-picker-score-input-compact"
inputMode="numeric"
@@ -643,7 +748,7 @@ function TeamPickerModal({
<div className="team-picker-option-text">
<strong>{playerName}</strong>
<small>
{selectedOrder ? `已選第 ${selectedOrder}` : '點擊加入目前配對'}
{selectedOrder ? `已選${selectedOrder}` : '尚未加入上場名單'}
</small>
</div>
</button>
@@ -669,8 +774,8 @@ function TeamPickerModal({
<aside className="team-picker-panel team-picker-side-panel">
<div className="preset-team-block">
<div className="preset-team-head">
<strong></strong>
<small></small>
<strong></strong>
<small></small>
</div>
<div className="preset-team-list">
@@ -693,8 +798,8 @@ function TeamPickerModal({
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊帶入這組預設隊伍'
: `入第 ${selectedSlot === 0 ? '1' : '2'}`}
? '點擊套用這一隊'
: `入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
@@ -708,7 +813,7 @@ function TeamPickerModal({
</button>
<p className="picker-side-hint">
12 34
12 34
</p>
</aside>
</div>
@@ -717,6 +822,88 @@ function TeamPickerModal({
)
}
type VoiceSettingsModalProps = {
settings: VoiceSettings
onClose: () => void
onUpdateSettings: Dispatch<SetStateAction<VoiceSettings>>
}
function VoiceSettingsModal({
settings,
onClose,
onUpdateSettings,
}: VoiceSettingsModalProps) {
return (
<div className="voice-settings-overlay" role="presentation" onClick={onClose}>
<div
aria-label="語音設定"
aria-modal="true"
className="voice-settings-panel"
role="dialog"
onClick={(event) => event.stopPropagation()}
>
<button
aria-label="關閉語音設定"
className="voice-settings-close"
type="button"
onClick={onClose}
>
×
</button>
<p className="panel-kicker"></p>
<h3></h3>
<label className="voice-setting-row">
<span></span>
<input
checked={settings.announceScore}
type="checkbox"
onChange={(event) =>
onUpdateSettings((current) => ({
...current,
announceScore: event.target.checked,
}))
}
/>
</label>
<label className="voice-setting-row">
<span></span>
<input
checked={settings.announceServer}
type="checkbox"
onChange={(event) =>
onUpdateSettings((current) => ({
...current,
announceServer: event.target.checked,
}))
}
/>
</label>
<label className="voice-setting-slider">
<span></span>
<input
max="10"
min="0.7"
step="0.1"
type="range"
value={settings.rate}
onChange={(event) =>
onUpdateSettings((current) => ({
...current,
rate: Number(event.target.value),
}))
}
/>
<strong>{settings.rate.toFixed(1)}x</strong>
</label>
</div>
</div>
)
}
type FinishDialogProps = {
error: string
leftScore: number
@@ -770,7 +957,7 @@ function FinishDialog({
</div>
</div>
<p className="finish-dialog-copy"></p>
<p className="finish-dialog-copy"></p>
{error ? <p className="finish-dialog-error">{error}</p> : null}
@@ -849,3 +1036,55 @@ function formatClock() {
hour12: false,
})
}
function loadVoiceSettings(): VoiceSettings {
try {
const raw = window.localStorage.getItem(VOICE_SETTINGS_STORAGE_KEY)
if (!raw) {
return defaultVoiceSettings
}
const parsed = JSON.parse(raw) as Partial<VoiceSettings>
return {
announceScore: parsed.announceScore ?? defaultVoiceSettings.announceScore,
announceServer: parsed.announceServer ?? defaultVoiceSettings.announceServer,
rate:
typeof parsed.rate === 'number'
? Math.min(10, Math.max(0.7, parsed.rate))
: defaultVoiceSettings.rate,
}
} catch {
return defaultVoiceSettings
}
}
function getAnnouncementName(team: GroupTeam | null) {
return team?.playerA ?? '本隊'
}
function speakAnnouncement(message: string, rate: number) {
if (!('speechSynthesis' in window)) {
return
}
const synthesis = window.speechSynthesis
const utterance = new SpeechSynthesisUtterance(message)
const voices = synthesis.getVoices()
const zhVoice =
voices.find((voice) => voice.lang.toLowerCase().startsWith('zh-tw')) ??
voices.find((voice) => voice.lang.toLowerCase().startsWith('zh'))
utterance.lang = zhVoice?.lang ?? 'zh-TW'
utterance.rate = rate
utterance.pitch = 1
utterance.volume = 1
if (zhVoice) {
utterance.voice = zhVoice
}
synthesis.cancel()
synthesis.speak(utterance)
}