Files
badminton-scoreboard/src/pages/ScoreboardPage.tsx

1098 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { Link } from 'react-router-dom'
import {
getCourtAssignments,
getReceivingPlayer,
getServiceCourt,
getServingPlayer,
getTeamDisplayName,
} from '../lib/match'
import type {
CourtSide,
GroupTeam,
PlayerSlot,
RoundGroup,
ScoreSide,
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,
}
const SPEECH_NAME_MAP: Record<string, string> = {
ruru: '嚕嚕',
}
type ScoreboardPageProps = {
currentSelectionOrder: string[]
finishDialogError: string
finishDialogOpen: boolean
finishDialogUploading: boolean
groupSource: 'idle' | 'db' | 'manual'
hasRecordedPoint: boolean
leftTeam: GroupTeam | null
rightTeam: GroupTeam | null
scoreState: ScoreState
selectedGroup: RoundGroup | null
streakAnnouncement: {
count: number
key: number
teamName: string
title: string
} | null
victoryAnnouncement: {
key: number
scoreLabel: string
teamName: string
title: string
} | null
targetDate: string
onApplyMatchup: (
leftTeam: GroupTeam,
rightTeam: GroupTeam,
targetScore: number,
) => void
onCloseFinishDialog: () => void
onConfirmUpload: () => void
onOpenFinishDialog: () => void
onRecordPoint: (side: ScoreSide) => void
onSetServing: (side: ScoreSide) => void
onSkipUpload: () => void
onSwapMatchup: () => void
onSwapTeamPlayers: (side: ScoreSide) => void
onUndoLastPoint: () => void
}
export function ScoreboardPage({
currentSelectionOrder,
finishDialogError,
finishDialogOpen,
finishDialogUploading,
groupSource,
hasRecordedPoint,
leftTeam,
rightTeam,
scoreState,
selectedGroup,
streakAnnouncement,
victoryAnnouncement,
targetDate,
onApplyMatchup,
onCloseFinishDialog,
onConfirmUpload,
onOpenFinishDialog,
onRecordPoint,
onSetServing,
onSkipUpload,
onSwapMatchup,
onSwapTeamPlayers,
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 [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(() => {
setClock(formatClock())
}, 1000)
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 []
}
const seen = new Set<string>()
const players: string[] = []
selectedGroup.teams.forEach((team) => {
if (!team.isPlaceholderA && !seen.has(team.playerA)) {
seen.add(team.playerA)
players.push(team.playerA)
}
if (!team.isPlaceholderB && !seen.has(team.playerB)) {
seen.add(team.playerB)
players.push(team.playerB)
}
})
return players
}, [selectedGroup])
const presetTeams = useMemo(
() =>
selectedGroup?.teams.filter((team) => !team.isPlaceholderA && !team.isPlaceholderB) ?? [],
[selectedGroup],
)
const canArrangeMatch = !hasRecordedPoint
const canScore = scoreState.serving !== null
const servingScore =
scoreState.serving === 'left' ? scoreState.scoreLeft : scoreState.scoreRight
const servingCourt =
scoreState.serving === null ? null : getServiceCourt(servingScore)
const leftAssignments = useMemo(
() =>
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer) : [],
[leftTeam, scoreState.leftRightCourtPlayer],
)
const rightAssignments = useMemo(
() =>
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer) : [],
[rightTeam, scoreState.rightRightCourtPlayer],
)
const currentServer =
scoreState.serving === 'left'
? leftTeam
? getServingPlayer(leftTeam, scoreState.leftRightCourtPlayer, scoreState.scoreLeft)
: null
: scoreState.serving === 'right'
? rightTeam
? getServingPlayer(
rightTeam,
scoreState.rightRightCourtPlayer,
scoreState.scoreRight,
)
: null
: null
const currentReceiver =
scoreState.serving === 'left'
? rightTeam
? getReceivingPlayer(
rightTeam,
scoreState.rightRightCourtPlayer,
scoreState.scoreLeft,
)
: null
: scoreState.serving === 'right'
? leftTeam
? getReceivingPlayer(
leftTeam,
scoreState.leftRightCourtPlayer,
scoreState.scoreRight,
)
: 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(`${getSpeechName(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>
<p className="panel-copy">
</p>
<Link className="primary-button inline-link" to="/teams">
</Link>
</article>
</section>
)
}
const matchupLabel =
leftTeam && rightTeam
? `${getTeamDisplayName(leftTeam)} vs ${getTeamDisplayName(rightTeam)}`
: '尚未設定對戰隊伍'
const openPicker = () => {
setDraftPlayers(currentSelectionOrder.filter((player) => selectablePlayers.includes(player)))
setDraftTargetScore(String(scoreState.targetScore))
setPickerOpen(true)
}
const toggleDraftPlayer = (playerName: string) => {
setDraftPlayers((current) => {
if (current.includes(playerName)) {
return current.filter((value) => value !== playerName)
}
if (current.length >= 4) {
return current
}
return [...current, playerName]
})
}
const togglePresetTeam = (team: GroupTeam) => {
setDraftPlayers((current) => {
const removed = removePresetTeamFromDraft(current, team)
if (removed.length !== current.length) {
return removed
}
if (current.length >= 4 || current.length % 2 !== 0) {
return current
}
if (current.includes(team.playerA) || current.includes(team.playerB)) {
return current
}
return [...current, team.playerA, team.playerB]
})
}
const confirmDraftTeams = () => {
if (draftPlayers.length !== 4) {
return
}
onApplyMatchup(
{
id: -1,
playerA: draftPlayers[0],
playerB: draftPlayers[1],
isPlaceholderA: false,
isPlaceholderB: false,
},
{
id: -2,
playerA: draftPlayers[3],
playerB: draftPlayers[2],
isPlaceholderA: false,
isPlaceholderB: false,
},
sanitizeTargetScore(draftTargetScore),
)
setPickerOpen(false)
}
const autoPickDraftPlayers = () => {
const shuffled = [...selectablePlayers]
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1))
;[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]
}
setDraftPlayers(shuffled.slice(0, 4))
}
return (
<>
{streakAnnouncement ? (
<div className="streak-banner" key={streakAnnouncement.key}>
<span className="streak-banner-count">{streakAnnouncement.count} </span>
<strong>{streakAnnouncement.title}</strong>
<small>{streakAnnouncement.teamName}</small>
</div>
) : null}
{victoryAnnouncement ? (
<div className="victory-banner" key={victoryAnnouncement.key}>
<span className="victory-banner-kicker"></span>
<strong>{victoryAnnouncement.title}</strong>
<small>{victoryAnnouncement.teamName}</small>
<em>{victoryAnnouncement.scoreLabel}</em>
</div>
) : null}
<section className="scoreboard-screen">
<div className="scoreboard-court">
<ScoreboardTeamPanel
assignments={leftAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
onRecordPoint={() => onRecordPoint('left')}
onSetServing={() => onSetServing('left')}
onSwapPlayers={() => onSwapTeamPlayers('left')}
onSwapTeams={onSwapMatchup}
score={scoreState.scoreLeft}
serviceCourt={scoreState.serving === 'left' ? servingCourt : null}
showServingPrompt={scoreState.serving === null}
team={leftTeam}
teamSlot="top"
/>
<div className="scoreboard-center-banner">
<p>{scoreState.serving === null ? '請先設定發球方' : '比賽進行中'}</p>
<small>
{scoreState.serving === null
? `本場 ${scoreState.targetScore} 分獲勝`
: `發球:${currentServer?.name ?? '-'}${
currentReceiver ? ` / 接發:${currentReceiver.name}` : ''
} / 目標 ${scoreState.targetScore}`}
</small>
</div>
<ScoreboardTeamPanel
assignments={rightAssignments}
canArrangeMatch={canArrangeMatch}
canScore={canScore}
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
onRecordPoint={() => onRecordPoint('right')}
onSetServing={() => onSetServing('right')}
onSwapPlayers={() => onSwapTeamPlayers('right')}
onSwapTeams={onSwapMatchup}
score={scoreState.scoreRight}
serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
showServingPrompt={scoreState.serving === null}
team={rightTeam}
teamSlot="bottom"
/>
</div>
<aside className="scoreboard-rail">
<div className="rail-icon-grid">
{hasRecordedPoint ? (
<button className="rail-square-button" type="button" onClick={onUndoLastPoint}>
</button>
) : (
<button className="rail-square-button" type="button" onClick={openPicker}>
</button>
)}
<button
aria-label="語音設定"
className="rail-square-button"
type="button"
onClick={() => setSettingsOpen(true)}
>
</button>
</div>
<div className="rail-clock">{clock}</div>
<button className="rail-pill rail-pill-danger" type="button" onClick={onOpenFinishDialog}>
</button>
</aside>
</section>
{pickerOpen ? (
<TeamPickerModal
draftPlayers={draftPlayers}
draftTargetScore={draftTargetScore}
group={selectedGroup}
presetTeams={presetTeams}
selectablePlayers={selectablePlayers}
selectionCount={draftPlayers.length}
sourceLabel={groupSource === 'db' ? 'DB' : groupSource === 'manual' ? '手動' : '-'}
targetDate={targetDate}
onAutoPick={autoPickDraftPlayers}
onClear={() => setDraftPlayers([])}
onClose={() => setPickerOpen(false)}
onConfirm={confirmDraftTeams}
onDraftTargetScoreChange={setDraftTargetScore}
onTogglePlayer={toggleDraftPlayer}
onTogglePresetTeam={togglePresetTeam}
/>
) : null}
{settingsOpen ? (
<VoiceSettingsModal
settings={voiceSettings}
onClose={() => setSettingsOpen(false)}
onUpdateSettings={setVoiceSettings}
/>
) : null}
{finishDialogOpen ? (
<FinishDialog
error={finishDialogError}
leftScore={scoreState.scoreLeft}
leftTeamName={leftTeam ? getTeamDisplayName(leftTeam) : '未設定'}
matchupLabel={matchupLabel}
rightScore={scoreState.scoreRight}
rightTeamName={rightTeam ? getTeamDisplayName(rightTeam) : '未設定'}
uploading={finishDialogUploading}
onClose={onCloseFinishDialog}
onConfirm={onConfirmUpload}
onSkip={onSkipUpload}
/>
) : null}
</>
)
}
type ScoreboardTeamPanelProps = {
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
canArrangeMatch: boolean
canScore: boolean
currentReceiver: string | null
currentServer: string | null
onRecordPoint: () => void
onSetServing: () => void
onSwapPlayers: () => void
onSwapTeams: () => void
score: number
serviceCourt: CourtSide | null
showServingPrompt: boolean
team: GroupTeam | null
teamSlot: 'top' | 'bottom'
}
function ScoreboardTeamPanel({
assignments,
canArrangeMatch,
canScore,
currentReceiver,
currentServer,
onRecordPoint,
onSetServing,
onSwapPlayers,
onSwapTeams,
score,
serviceCourt,
showServingPrompt,
team,
teamSlot,
}: ScoreboardTeamPanelProps) {
const orderedAssignments = [...assignments].sort((left, right) => {
if (left.court === right.court) {
return 0
}
return left.court === 'left' ? -1 : 1
})
const header = (
<div className="scoreboard-team-head">
<div className="team-head-main">
{orderedAssignments.map((assignment) => (
<div
className={
assignment.name === currentServer
? 'scoreboard-name-chip scoreboard-name-chip-serving'
: 'scoreboard-name-chip'
}
key={assignment.slot}
>
<span className="team-number">{getPlayerNumber(teamSlot, assignment.slot)}</span>
<strong>{assignment.name}</strong>
</div>
))}
</div>
<div className="team-head-buttons">
<button
aria-label="交換上下隊伍"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
onClick={onSwapTeams}
>
</button>
<button
aria-label="交換左右位置"
className="team-icon-button"
disabled={!canArrangeMatch}
type="button"
onClick={onSwapPlayers}
>
</button>
</div>
</div>
)
const serveBar = (
<button
className={
currentServer && !canArrangeMatch
? 'serve-lane serve-lane-locked'
: showServingPrompt
? 'serve-lane serve-lane-prompt'
: 'serve-lane'
}
disabled={!canArrangeMatch || !team}
type="button"
onClick={onSetServing}
>
<span className={currentServer ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span></span>
{currentServer ? (
<small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
</small>
) : (
<small></small>
)}
</button>
)
const scoreBoard = (
<button
className={canScore ? 'score-panel-surface score-panel-surface-live' : 'score-panel-surface'}
disabled={!canScore || !team}
type="button"
onClick={onRecordPoint}
>
<span className="score-panel-value">{score}</span>
</button>
)
return (
<section className="scoreboard-team-section">
{teamSlot === 'top' ? (
<>
{header}
{serveBar}
{scoreBoard}
</>
) : (
<>
{scoreBoard}
{serveBar}
{header}
</>
)}
</section>
)
}
type TeamPickerModalProps = {
draftPlayers: string[]
draftTargetScore: string
group: RoundGroup
presetTeams: GroupTeam[]
selectablePlayers: string[]
selectionCount: number
sourceLabel: string
targetDate: string
onAutoPick: () => void
onClear: () => void
onClose: () => void
onConfirm: () => void
onDraftTargetScoreChange: (value: string) => void
onTogglePlayer: (playerName: string) => void
onTogglePresetTeam: (team: GroupTeam) => void
}
function TeamPickerModal({
draftPlayers,
draftTargetScore,
group,
presetTeams,
selectablePlayers,
selectionCount,
sourceLabel,
targetDate,
onAutoPick,
onClear,
onClose,
onConfirm,
onDraftTargetScoreChange,
onTogglePlayer,
onTogglePresetTeam,
}: TeamPickerModalProps) {
return (
<div className="team-picker-overlay" role="presentation" onClick={onClose}>
<div
aria-label="設定隊伍"
aria-modal="true"
className="team-picker-shell"
onClick={(event) => event.stopPropagation()}
role="dialog"
>
<button aria-label="關閉設定隊伍" className="team-picker-close" type="button" onClick={onClose}>
×
</button>
<div className="team-picker-ribbon">
<span>{selectionCount >= 4 ? '已完成選擇' : '請選滿 4 位球員'}</span>
</div>
<div className="team-picker-layout">
<section className="team-picker-panel team-picker-list-panel">
<div className="team-picker-title">
<span className="team-picker-count">{selectionCount}/4</span>
<div>
<strong></strong>
<p>
{group.id} / {sourceLabel} / {targetDate || '-'}
</p>
</div>
</div>
<div className="team-picker-config-row">
<label className="team-picker-config team-picker-config-compact">
<span></span>
<input
className="team-picker-score-input team-picker-score-input-compact"
inputMode="numeric"
maxLength={2}
type="text"
value={draftTargetScore}
onChange={(event) => onDraftTargetScoreChange(event.target.value)}
/>
</label>
</div>
<div className="team-picker-list">
{selectablePlayers.map((playerName) => {
const checked = draftPlayers.includes(playerName)
const selectedOrder = checked ? draftPlayers.indexOf(playerName) + 1 : null
return (
<button
className={
checked
? 'team-picker-option team-picker-option-active'
: 'team-picker-option'
}
key={`player-option-${playerName}`}
type="button"
onClick={() => onTogglePlayer(playerName)}
>
<span className="team-picker-checkbox">
{checked ? String(selectedOrder) : ''}
</span>
<div className="team-picker-option-text">
<strong>{playerName}</strong>
<small>
{selectedOrder ? `已選為第 ${selectedOrder}` : '尚未加入上場名單'}
</small>
</div>
</button>
)
})}
</div>
<div className="team-picker-actions">
<button className="team-picker-ghost" type="button" onClick={onAutoPick}>
</button>
<button
className="team-picker-confirm"
disabled={draftPlayers.length !== 4}
type="button"
onClick={onConfirm}
>
</button>
</div>
</section>
<aside className="team-picker-panel team-picker-side-panel">
<div className="preset-team-block">
<div className="preset-team-head">
<strong></strong>
<small></small>
</div>
<div className="preset-team-list">
{presetTeams.map((team) => {
const selectedSlot = getPresetTeamSelectionSlot(draftPlayers, team)
return (
<button
className={
selectedSlot === null
? 'preset-team-card'
: 'preset-team-card preset-team-card-active'
}
key={`preset-team-${team.id}`}
type="button"
onClick={() => onTogglePresetTeam(team)}
>
<span className="preset-team-index">{team.id}</span>
<div>
<strong>{getTeamDisplayName(team)}</strong>
<small>
{selectedSlot === null
? '點擊套用這一隊'
: `已放入第 ${selectedSlot === 0 ? '1' : '2'}`}
</small>
</div>
</button>
)
})}
</div>
</div>
<button className="team-picker-clear" type="button" onClick={onClear}>
</button>
<p className="picker-side-hint">
12 34
</p>
</aside>
</div>
</div>
</div>
)
}
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
leftTeamName: string
matchupLabel: string
rightScore: number
rightTeamName: string
uploading: boolean
onClose: () => void
onConfirm: () => void
onSkip: () => void
}
function FinishDialog({
error,
leftScore,
leftTeamName,
matchupLabel,
rightScore,
rightTeamName,
uploading,
onClose,
onConfirm,
onSkip,
}: FinishDialogProps) {
return (
<div className="finish-dialog-overlay" role="presentation">
<div aria-modal="true" className="finish-dialog" role="dialog">
<button
aria-label="關閉比賽結算"
className="finish-dialog-close"
disabled={uploading}
type="button"
onClick={onClose}
>
×
</button>
<p className="panel-kicker"></p>
<h3>{matchupLabel}</h3>
<div className="finish-score">
<div>
<strong>{leftScore}</strong>
<span>{leftTeamName}</span>
</div>
<div className="finish-score-divider">:</div>
<div>
<strong>{rightScore}</strong>
<span>{rightTeamName}</span>
</div>
</div>
<p className="finish-dialog-copy"></p>
{error ? <p className="finish-dialog-error">{error}</p> : null}
<div className="finish-dialog-actions">
<button
className="team-picker-ghost"
disabled={uploading}
type="button"
onClick={onSkip}
>
</button>
<button
className="team-picker-confirm"
disabled={uploading}
type="button"
onClick={onConfirm}
>
{uploading ? '上傳中...' : '確認上傳'}
</button>
</div>
</div>
</div>
)
}
function getPlayerNumber(teamSlot: 'top' | 'bottom', slot: PlayerSlot) {
if (teamSlot === 'top') {
return slot === 'playerA' ? 1 : 2
}
return slot === 'playerA' ? 4 : 3
}
function sanitizeTargetScore(value: string) {
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
if (Number.isNaN(numeric)) {
return 21
}
return Math.min(99, Math.max(1, numeric))
}
function removePresetTeamFromDraft(players: string[], team: GroupTeam) {
const firstPairSelected = players[0] === team.playerA && players[1] === team.playerB
const secondPairSelected = players[2] === team.playerA && players[3] === team.playerB
if (firstPairSelected) {
return players.slice(2)
}
if (secondPairSelected) {
return players.slice(0, 2)
}
return players
}
function getPresetTeamSelectionSlot(players: string[], team: GroupTeam) {
if (players[0] === team.playerA && players[1] === team.playerB) {
return 0
}
if (players[2] === team.playerA && players[3] === team.playerB) {
return 1
}
return null
}
function formatClock() {
return new Date().toLocaleTimeString('zh-TW', {
hour: '2-digit',
minute: '2-digit',
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 getSpeechName(team?.playerA ?? '本隊')
}
function getSpeechName(name: string) {
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
}
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)
}