diff --git a/README.md b/README.md index 4be9627..86d8f7f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ - `7` 連勝:`像神一般的` - `8` 連勝:`成為傳說` - 達到目標分數獲勝時,會跳出獲勝動畫特效 + - 內建免費瀏覽器 TTS 播報 + - 右側 `設定` 按鈕可開啟語音設定面板 + - 可分別設定是否播報誰得分、是否播報誰發球 + - 可調整語速,範圍 `0.7x ~ 10x` - 歷史戰績頁 - 直接從資料庫 `history` 表讀取列表 - 點擊任一筆可開啟得分紀錄彈窗 diff --git a/src/App.css b/src/App.css index aa9faab..1217477 100644 --- a/src/App.css +++ b/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; diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index 2b5aeb5..0d02cb4 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -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([]) - const [draftTargetScore, setDraftTargetScore] = useState(() => String(scoreState.targetScore)) + const [draftTargetScore, setDraftTargetScore] = useState(() => + String(scoreState.targetScore), + ) const [clock, setClock] = useState(() => formatClock()) + const [voiceSettings, setVoiceSettings] = useState(() => + 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 (

Step 3

-

先從選隊伍頁面帶入一組名單

+

請先回到選隊伍頁面

- 記分板會依照你挑選的組別顯示球員,再從設定隊伍裡決定這一場實際對打的兩隊。 + 目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。

- 前往選隊伍 + 回到選隊伍
@@ -313,7 +407,7 @@ export function ScoreboardPage({ />
-

{scoreState.serving === null ? '請先設定發球方' : '點擊分數即可記分'}

+

{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}

{scoreState.serving === null ? `本場 ${scoreState.targetScore} 分獲勝` @@ -352,6 +446,14 @@ export function ScoreboardPage({ 設定隊伍 )} +
{clock}
@@ -382,14 +484,22 @@ export function ScoreboardPage({ /> ) : null} + {settingsOpen ? ( + setSettingsOpen(false)} + onUpdateSettings={setVoiceSettings} + /> + ) : null} + {finishDialogOpen ? (
- {selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'} + {selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}
@@ -600,7 +705,7 @@ function TeamPickerModal({
{selectionCount}/4
- 左邊逐一選人 + 依序選擇球員

第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}

@@ -609,7 +714,7 @@ function TeamPickerModal({
@@ -669,8 +774,8 @@ function TeamPickerModal({
@@ -717,6 +822,88 @@ function TeamPickerModal({ ) } +type VoiceSettingsModalProps = { + settings: VoiceSettings + onClose: () => void + onUpdateSettings: Dispatch> +} + +function VoiceSettingsModal({ + settings, + onClose, + onUpdateSettings, +}: VoiceSettingsModalProps) { + return ( +
+
event.stopPropagation()} + > + + +

語音設定

+

播報內容

+ + + + + + +
+
+ ) +} + type FinishDialogProps = { error: string leftScore: number @@ -770,7 +957,7 @@ function FinishDialog({
-

要把這場戰績上傳到資料庫嗎?

+

要不要把這場比賽戰績上傳到資料庫?

{error ?

{error}

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