調整語音設定介面並更新 README
This commit is contained in:
@@ -29,6 +29,10 @@
|
|||||||
- `7` 連勝:`像神一般的`
|
- `7` 連勝:`像神一般的`
|
||||||
- `8` 連勝:`成為傳說`
|
- `8` 連勝:`成為傳說`
|
||||||
- 達到目標分數獲勝時,會跳出獲勝動畫特效
|
- 達到目標分數獲勝時,會跳出獲勝動畫特效
|
||||||
|
- 內建免費瀏覽器 TTS 播報
|
||||||
|
- 右側 `設定` 按鈕可開啟語音設定面板
|
||||||
|
- 可分別設定是否播報誰得分、是否播報誰發球
|
||||||
|
- 可調整語速,範圍 `0.7x ~ 10x`
|
||||||
- 歷史戰績頁
|
- 歷史戰績頁
|
||||||
- 直接從資料庫 `history` 表讀取列表
|
- 直接從資料庫 `history` 表讀取列表
|
||||||
- 點擊任一筆可開啟得分紀錄彈窗
|
- 點擊任一筆可開啟得分紀錄彈窗
|
||||||
|
|||||||
78
src/App.css
78
src/App.css
@@ -930,6 +930,79 @@
|
|||||||
background: linear-gradient(180deg, #f7f2e8, #e0d6c5);
|
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 {
|
.team-picker-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1678,6 +1751,11 @@
|
|||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-settings-panel {
|
||||||
|
padding: 20px 14px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.scoreboard-team-head {
|
.scoreboard-team-head {
|
||||||
grid-template-columns: minmax(0, 1fr) 54px;
|
grid-template-columns: minmax(0, 1fr) 54px;
|
||||||
gap: 6px;
|
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 { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
getCourtAssignments,
|
getCourtAssignments,
|
||||||
@@ -16,6 +17,19 @@ import type {
|
|||||||
ScoreState,
|
ScoreState,
|
||||||
} from '../types'
|
} 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 = {
|
type ScoreboardPageProps = {
|
||||||
currentSelectionOrder: string[]
|
currentSelectionOrder: string[]
|
||||||
finishDialogError: string
|
finishDialogError: string
|
||||||
@@ -82,9 +96,17 @@ export function ScoreboardPage({
|
|||||||
onUndoLastPoint,
|
onUndoLastPoint,
|
||||||
}: ScoreboardPageProps) {
|
}: ScoreboardPageProps) {
|
||||||
const [pickerOpen, setPickerOpen] = useState(false)
|
const [pickerOpen, setPickerOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [draftPlayers, setDraftPlayers] = useState<string[]>([])
|
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 [clock, setClock] = useState(() => formatClock())
|
||||||
|
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
|
||||||
|
loadVoiceSettings(),
|
||||||
|
)
|
||||||
|
const lastAnnouncedPointRef = useRef(0)
|
||||||
|
const previousScoresRef = useRef({ left: 0, right: 0 })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
@@ -94,6 +116,21 @@ export function ScoreboardPage({
|
|||||||
return () => window.clearInterval(timer)
|
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(() => {
|
const selectablePlayers = useMemo(() => {
|
||||||
if (!selectedGroup) {
|
if (!selectedGroup) {
|
||||||
return []
|
return []
|
||||||
@@ -176,17 +213,74 @@ export function ScoreboardPage({
|
|||||||
: null
|
: null
|
||||||
: 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) {
|
if (!selectedGroup) {
|
||||||
return (
|
return (
|
||||||
<section className="page-grid">
|
<section className="page-grid">
|
||||||
<article className="panel panel-hero">
|
<article className="panel panel-hero">
|
||||||
<p className="panel-kicker">Step 3</p>
|
<p className="panel-kicker">Step 3</p>
|
||||||
<h2>先從選隊伍頁面帶入一組名單</h2>
|
<h2>請先回到選隊伍頁面</h2>
|
||||||
<p className="panel-copy">
|
<p className="panel-copy">
|
||||||
記分板會依照你挑選的組別顯示球員,再從設定隊伍裡決定這一場實際對打的兩隊。
|
目前還沒有帶入要上場的組別,先回去選擇分組,再進入記分板。
|
||||||
</p>
|
</p>
|
||||||
<Link className="primary-button inline-link" to="/teams">
|
<Link className="primary-button inline-link" to="/teams">
|
||||||
前往選隊伍
|
回到選隊伍
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -313,7 +407,7 @@ export function ScoreboardPage({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scoreboard-center-banner">
|
<div className="scoreboard-center-banner">
|
||||||
<p>{scoreState.serving === null ? '請先設定發球方' : '點擊分數即可記分'}</p>
|
<p>{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}</p>
|
||||||
<small>
|
<small>
|
||||||
{scoreState.serving === null
|
{scoreState.serving === null
|
||||||
? `本場 ${scoreState.targetScore} 分獲勝`
|
? `本場 ${scoreState.targetScore} 分獲勝`
|
||||||
@@ -352,6 +446,14 @@ export function ScoreboardPage({
|
|||||||
設定隊伍
|
設定隊伍
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="語音設定"
|
||||||
|
className="rail-square-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
>
|
||||||
|
設定
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rail-clock">{clock}</div>
|
<div className="rail-clock">{clock}</div>
|
||||||
@@ -382,14 +484,22 @@ export function ScoreboardPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{settingsOpen ? (
|
||||||
|
<VoiceSettingsModal
|
||||||
|
settings={voiceSettings}
|
||||||
|
onClose={() => setSettingsOpen(false)}
|
||||||
|
onUpdateSettings={setVoiceSettings}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{finishDialogOpen ? (
|
{finishDialogOpen ? (
|
||||||
<FinishDialog
|
<FinishDialog
|
||||||
error={finishDialogError}
|
error={finishDialogError}
|
||||||
leftScore={scoreState.scoreLeft}
|
leftScore={scoreState.scoreLeft}
|
||||||
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '尚未設定'}
|
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
|
||||||
matchupLabel={matchupLabel}
|
matchupLabel={matchupLabel}
|
||||||
rightScore={scoreState.scoreRight}
|
rightScore={scoreState.scoreRight}
|
||||||
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '尚未設定'}
|
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
|
||||||
uploading={finishDialogUploading}
|
uploading={finishDialogUploading}
|
||||||
onClose={onCloseFinishDialog}
|
onClose={onCloseFinishDialog}
|
||||||
onConfirm={onConfirmUpload}
|
onConfirm={onConfirmUpload}
|
||||||
@@ -461,7 +571,7 @@ function ScoreboardTeamPanel({
|
|||||||
|
|
||||||
<div className="team-head-buttons">
|
<div className="team-head-buttons">
|
||||||
<button
|
<button
|
||||||
aria-label="上下交換隊伍"
|
aria-label="交換上下隊伍"
|
||||||
className="team-icon-button"
|
className="team-icon-button"
|
||||||
disabled={!canArrangeMatch}
|
disabled={!canArrangeMatch}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -470,7 +580,7 @@ function ScoreboardTeamPanel({
|
|||||||
↕
|
↕
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
aria-label="左右交換球員"
|
aria-label="交換左右位置"
|
||||||
className="team-icon-button"
|
className="team-icon-button"
|
||||||
disabled={!canArrangeMatch}
|
disabled={!canArrangeMatch}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -495,11 +605,6 @@ function ScoreboardTeamPanel({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onSetServing}
|
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 className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
|
||||||
<span>先攻</span>
|
<span>先攻</span>
|
||||||
{currentServer ? (
|
{currentServer ? (
|
||||||
@@ -592,7 +697,7 @@ function TeamPickerModal({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="team-picker-ribbon">
|
<div className="team-picker-ribbon">
|
||||||
<span>{selectionCount >= 4 ? '已完成選擇' : '請依序選 4 位球員'}</span>
|
<span>{selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="team-picker-layout">
|
<div className="team-picker-layout">
|
||||||
@@ -600,7 +705,7 @@ function TeamPickerModal({
|
|||||||
<div className="team-picker-title">
|
<div className="team-picker-title">
|
||||||
<span className="team-picker-count">{selectionCount}/4</span>
|
<span className="team-picker-count">{selectionCount}/4</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>左邊逐一選人</strong>
|
<strong>依序選擇球員</strong>
|
||||||
<p>
|
<p>
|
||||||
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
第 {group.id} 組 / {sourceLabel} / {targetDate || '-'}
|
||||||
</p>
|
</p>
|
||||||
@@ -609,7 +714,7 @@ function TeamPickerModal({
|
|||||||
|
|
||||||
<div className="team-picker-config-row">
|
<div className="team-picker-config-row">
|
||||||
<label className="team-picker-config team-picker-config-compact">
|
<label className="team-picker-config team-picker-config-compact">
|
||||||
<span>勝利分數</span>
|
<span>獲勝分數</span>
|
||||||
<input
|
<input
|
||||||
className="team-picker-score-input team-picker-score-input-compact"
|
className="team-picker-score-input team-picker-score-input-compact"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -643,7 +748,7 @@ function TeamPickerModal({
|
|||||||
<div className="team-picker-option-text">
|
<div className="team-picker-option-text">
|
||||||
<strong>{playerName}</strong>
|
<strong>{playerName}</strong>
|
||||||
<small>
|
<small>
|
||||||
{selectedOrder ? `已選第 ${selectedOrder} 位` : '點擊加入目前配對'}
|
{selectedOrder ? `已選為第 ${selectedOrder} 位` : '尚未加入上場名單'}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -669,8 +774,8 @@ function TeamPickerModal({
|
|||||||
<aside className="team-picker-panel team-picker-side-panel">
|
<aside className="team-picker-panel team-picker-side-panel">
|
||||||
<div className="preset-team-block">
|
<div className="preset-team-block">
|
||||||
<div className="preset-team-head">
|
<div className="preset-team-head">
|
||||||
<strong>右邊快速選預設隊伍</strong>
|
<strong>快速選預設隊伍</strong>
|
||||||
<small>直接點兩組,或搭配左邊逐一選人</small>
|
<small>可直接點一整隊,或和左側逐一選人混用。</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="preset-team-list">
|
<div className="preset-team-list">
|
||||||
@@ -693,8 +798,8 @@ function TeamPickerModal({
|
|||||||
<strong>{getTeamDisplayName(team)}</strong>
|
<strong>{getTeamDisplayName(team)}</strong>
|
||||||
<small>
|
<small>
|
||||||
{selectedSlot === null
|
{selectedSlot === null
|
||||||
? '點擊帶入這組預設隊伍'
|
? '點擊套用這一隊'
|
||||||
: `已帶入第 ${selectedSlot === 0 ? '1' : '2'} 隊`}
|
: `已放入第 ${selectedSlot === 0 ? '1' : '2'} 隊`}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -708,7 +813,7 @@ function TeamPickerModal({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="picker-side-hint">
|
<p className="picker-side-hint">
|
||||||
先選到的第 1、2 位會帶到上方隊伍,第 3、4 位會帶到下方隊伍。
|
第 1、2 位會自動成為上方隊伍,第 3、4 位會成為下方隊伍。
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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 = {
|
type FinishDialogProps = {
|
||||||
error: string
|
error: string
|
||||||
leftScore: number
|
leftScore: number
|
||||||
@@ -770,7 +957,7 @@ function FinishDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="finish-dialog-copy">要把這場戰績上傳到資料庫嗎?</p>
|
<p className="finish-dialog-copy">要不要把這場比賽戰績上傳到資料庫?</p>
|
||||||
|
|
||||||
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
{error ? <p className="finish-dialog-error">{error}</p> : null}
|
||||||
|
|
||||||
@@ -849,3 +1036,55 @@ function formatClock() {
|
|||||||
hour12: false,
|
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