調整語音設定介面並更新 README
This commit is contained in:
@@ -29,6 +29,10 @@
|
||||
- `7` 連勝:`像神一般的`
|
||||
- `8` 連勝:`成為傳說`
|
||||
- 達到目標分數獲勝時,會跳出獲勝動畫特效
|
||||
- 內建免費瀏覽器 TTS 播報
|
||||
- 右側 `設定` 按鈕可開啟語音設定面板
|
||||
- 可分別設定是否播報誰得分、是否播報誰發球
|
||||
- 可調整語速,範圍 `0.7x ~ 10x`
|
||||
- 歷史戰績頁
|
||||
- 直接從資料庫 `history` 表讀取列表
|
||||
- 點擊任一筆可開啟得分紀錄彈窗
|
||||
|
||||
78
src/App.css
78
src/App.css
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
先選到的第 1、2 位會帶到上方隊伍,第 3、4 位會帶到下方隊伍。
|
||||
第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。
|
||||
</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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user