調整語音設定介面並更新 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

@@ -29,6 +29,10 @@
- `7` 連勝:`像神一般的`
- `8` 連勝:`成為傳說`
- 達到目標分數獲勝時,會跳出獲勝動畫特效
- 內建免費瀏覽器 TTS 播報
- 右側 `設定` 按鈕可開啟語音設定面板
- 可分別設定是否播報誰得分、是否播報誰發球
- 可調整語速,範圍 `0.7x ~ 10x`
- 歷史戰績頁
- 直接從資料庫 `history` 表讀取列表
- 點擊任一筆可開啟得分紀錄彈窗

View File

@@ -930,6 +930,79 @@
background: linear-gradient(180deg, #f7f2e8, #e0d6c5);
}
.voice-settings-overlay {
position: fixed;
inset: 0;
z-index: 75;
display: grid;
place-items: center;
padding: 18px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
}
.voice-settings-panel {
position: relative;
width: min(420px, 100%);
display: grid;
gap: 14px;
padding: 24px 20px 20px;
border-radius: 24px;
background: linear-gradient(180deg, #fff8e8, #ffe5ad);
box-shadow:
0 0 0 4px rgba(255, 255, 255, 0.18),
inset 0 0 0 2px rgba(200, 140, 46, 0.45);
}
.voice-settings-close {
position: absolute;
top: 10px;
right: 10px;
width: 44px;
height: 44px;
border: 0;
border-radius: 999px;
cursor: pointer;
font: inherit;
font-size: 1.6rem;
color: #b34e3a;
background: linear-gradient(180deg, #ffe5bf, #f0bd7c);
box-shadow:
inset 0 0 0 1px rgba(199, 125, 63, 0.34),
0 10px 18px rgba(8, 47, 73, 0.16);
}
.voice-setting-row,
.voice-setting-slider {
display: grid;
gap: 10px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 249, 238, 0.94);
box-shadow: inset 0 0 0 1px rgba(199, 155, 83, 0.12);
}
.voice-setting-row {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
.voice-setting-row input[type='checkbox'] {
width: 22px;
height: 22px;
accent-color: #0f6a5d;
}
.voice-setting-slider strong {
justify-self: end;
color: #5b2f13;
}
.voice-setting-slider input[type='range'] {
width: 100%;
accent-color: #0f6a5d;
}
.team-picker-overlay {
position: fixed;
inset: 0;
@@ -1678,6 +1751,11 @@
font-size: 0.88rem;
}
.voice-settings-panel {
padding: 20px 14px 14px;
border-radius: 18px;
}
.scoreboard-team-head {
grid-template-columns: minmax(0, 1fr) 54px;
gap: 6px;

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)
}