diff --git a/src/App.tsx b/src/App.tsx index 5f4adbb..9f219ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,26 +1,26 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import './App.css' -import { - createLiveRoom, - loadMatchResults, - releaseLiveRoom, - saveMatchHistory, - sendLiveRoomHeartbeat, - updateLiveRoom, -} from './lib/api' -import { - buildManualGroups, - convertDateToKey, - convertDbRecordToGroups, - formatDateInputValue, - getMirroredCourt, - getServiceCourt, - getServingPlayer, - getTeamDisplayName, - getWinnerName, - parseRoster, - swapCourtPositions, +import { + createLiveRoom, + loadMatchResults, + releaseLiveRoom, + saveMatchHistory, + sendLiveRoomHeartbeat, + updateLiveRoom, +} from './lib/api' +import { + buildManualGroups, + convertDateToKey, + convertDbRecordToGroups, + formatDateInputValue, + getMirroredCourt, + getServiceCourt, + getServingPlayer, + getTeamDisplayName, + getWinnerName, + parseRoster, + swapCourtPositions, } from './lib/match' import { HistoryPage } from './pages/HistoryPage' import { RoomListPage } from './pages/RoomListPage' @@ -45,24 +45,23 @@ const STORAGE_KEYS = { areaA: 'badminton-scoreboard::area-a', areaB: 'badminton-scoreboard::area-b', history: 'badminton-scoreboard::history', - targetDate: 'badminton-scoreboard::target-date', } as const const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim'] -const initialScoreState: ScoreState = { - scoreLeft: 0, - scoreRight: 0, - gamesLeft: 0, - gamesRight: 0, - currentGame: 1, - targetScore: 21, - initialServing: null, - serving: null, - leftRightCourtPlayer: 'playerA', - rightRightCourtPlayer: 'playerA', -} +const initialScoreState: ScoreState = { + scoreLeft: 0, + scoreRight: 0, + gamesLeft: 0, + gamesRight: 0, + currentGame: 1, + targetScore: 21, + initialServing: null, + serving: null, + leftRightCourtPlayer: 'playerA', + rightRightCourtPlayer: 'playerA', +} type SettlementState = { error: string @@ -77,40 +76,40 @@ type StreakAnnouncement = { title: string } -type VictoryAnnouncement = { - key: number - scoreLabel: string - teamName: string - title: string -} - -type VoiceAnnouncement = { - key: number - scorerName: string - serverChanged: boolean - serverName: string -} +type VictoryAnnouncement = { + key: number + scoreLabel: string + teamName: string + title: string +} -const STREAK_TITLES: Record = { +type VoiceAnnouncement = { + key: number + servingScore: number + opponentScore: number + serverName: string + serverCourt: 'left' | 'right' + winnerTeamName: string | null +} + +const STREAK_TITLES: Record = { 3: '大殺特殺', 4: '暴走', 5: '無人能擋', 6: '主宰比賽', 7: '像神一般的', 8: '成為傳說', -} -const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' -const APP_VERSION_POLL_MS = 30000 -const LIVE_ROOM_HEARTBEAT_MS = 10_000 +} +const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' +const APP_VERSION_POLL_MS = 30000 +const LIVE_ROOM_HEARTBEAT_MS = 10_000 function App() { const location = useLocation() const navigate = useNavigate() const isScoreboardRoute = location.pathname === '/scoreboard' - const [targetDate, setTargetDate] = useState(() => - loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()), - ) + const [targetDate, setTargetDate] = useState(() => formatDateInputValue()) const [areaAInput, setAreaAInput] = useState(() => loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), ) @@ -137,27 +136,23 @@ function App() { open: false, uploading: false, }) - const [streakAnnouncement, setStreakAnnouncement] = useState(null) - const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) - const [voiceAnnouncement, setVoiceAnnouncement] = useState(null) - const [pwaUpdateReady, setPwaUpdateReady] = useState(false) - const [liveRoomSession, setLiveRoomSession] = useState(null) - const [navigationLockMessage, setNavigationLockMessage] = useState('') - const currentAppVersionRef = useRef(null) + const [streakAnnouncement, setStreakAnnouncement] = useState(null) + const [victoryAnnouncement, setVictoryAnnouncement] = useState(null) + const [voiceAnnouncement, setVoiceAnnouncement] = useState(null) + const [pwaUpdateReady, setPwaUpdateReady] = useState(false) + const [liveRoomSession, setLiveRoomSession] = useState(null) + const [navigationLockMessage, setNavigationLockMessage] = useState('') + const currentAppVersionRef = useRef(null) const creatingRoomRef = useRef(false) const lastSyncedRoomSignatureRef = useRef('') const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) - const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null - const leftTeam = activeMatchup.leftTeam - const rightTeam = activeMatchup.rightTeam - const liveRoomId = liveRoomSession?.roomId ?? null - const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) - - useEffect(() => { - window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate) - }, [targetDate]) + const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null + const leftTeam = activeMatchup.leftTeam + const rightTeam = activeMatchup.rightTeam + const liveRoomId = liveRoomSession?.roomId ?? null + const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) useEffect(() => { window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) @@ -195,9 +190,9 @@ function App() { return () => window.clearTimeout(timer) }, [streakAnnouncement]) - useEffect(() => { - if (!victoryAnnouncement) { - return + useEffect(() => { + if (!victoryAnnouncement) { + return } const timer = window.setTimeout(() => { @@ -205,27 +200,27 @@ function App() { }, 2200) return () => window.clearTimeout(timer) - }, [victoryAnnouncement]) - - useEffect(() => { - if (!navigationLockMessage) { - return - } - - const timer = window.setTimeout(() => { - setNavigationLockMessage('') - }, 1400) - - return () => window.clearTimeout(timer) - }, [navigationLockMessage]) - - useEffect(() => { - document.body.classList.toggle('body-scoreboard', isScoreboardRoute) - - return () => { - document.body.classList.remove('body-scoreboard') - } - }, [isScoreboardRoute]) + }, [victoryAnnouncement]) + + useEffect(() => { + if (!navigationLockMessage) { + return + } + + const timer = window.setTimeout(() => { + setNavigationLockMessage('') + }, 1400) + + return () => window.clearTimeout(timer) + }, [navigationLockMessage]) + + useEffect(() => { + document.body.classList.toggle('body-scoreboard', isScoreboardRoute) + + return () => { + document.body.classList.remove('body-scoreboard') + } + }, [isScoreboardRoute]) useEffect(() => { const handlePwaUpdateReady = () => { @@ -304,10 +299,10 @@ function App() { setScoreState(nextState) setScoreHistory([]) - setPointLog([]) - setStreakAnnouncement(null) - setVictoryAnnouncement(null) - setVoiceAnnouncement(null) + setPointLog([]) + setStreakAnnouncement(null) + setVictoryAnnouncement(null) + setVoiceAnnouncement(null) setSettlement({ error: '', open: false, @@ -455,17 +450,17 @@ function App() { isScoreboardRoute, ]) - useEffect(() => { - if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) { - return + useEffect(() => { + if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) { + return } - const winnerTeamName = - hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight - ? getTeamDisplayName(leftTeam) - : hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft - ? getTeamDisplayName(rightTeam) - : null + const winnerTeamName = + hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight + ? getTeamDisplayName(leftTeam) + : hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft + ? getTeamDisplayName(rightTeam) + : null const nextStatus = winnerTeamName ? 'finished' : 'live' const payload = buildLiveRoomPayload({ groupId: selectedGroup?.id ?? null, @@ -514,49 +509,49 @@ function App() { rightTeam, scoreState, selectedGroup?.id, - targetDate, - isScoreboardRoute, - ]) - - useEffect(() => { - if (!isNavigationLocked || isScoreboardRoute) { - return - } - - navigate('/scoreboard', { replace: true }) - setNavigationLockMessage('比賽進行中,請先完成結算。') - }, [isNavigationLocked, isScoreboardRoute, navigate]) - - useEffect(() => { - if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') { - return - } - - let active = true - - const syncHeartbeat = async () => { - try { - await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken) - } catch (error) { - if (active) { - console.error('live room heartbeat error:', error) - } - } - } - - void syncHeartbeat() - const timer = window.setInterval(() => { - void syncHeartbeat() - }, LIVE_ROOM_HEARTBEAT_MS) - - return () => { - active = false - window.clearInterval(timer) - } - }, [isScoreboardRoute, liveRoomSession]) - - useEffect(() => { - if (!liveRoomSession || liveRoomSession.status !== 'live') { + targetDate, + isScoreboardRoute, + ]) + + useEffect(() => { + if (!isNavigationLocked || isScoreboardRoute) { + return + } + + navigate('/scoreboard', { replace: true }) + setNavigationLockMessage('比賽進行中,請先完成結算。') + }, [isNavigationLocked, isScoreboardRoute, navigate]) + + useEffect(() => { + if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') { + return + } + + let active = true + + const syncHeartbeat = async () => { + try { + await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken) + } catch (error) { + if (active) { + console.error('live room heartbeat error:', error) + } + } + } + + void syncHeartbeat() + const timer = window.setInterval(() => { + void syncHeartbeat() + }, LIVE_ROOM_HEARTBEAT_MS) + + return () => { + active = false + window.clearInterval(timer) + } + }, [isScoreboardRoute, liveRoomSession]) + + useEffect(() => { + if (!liveRoomSession || liveRoomSession.status !== 'live') { return } @@ -682,18 +677,18 @@ function App() { scoreRight: current.scoreLeft, gamesLeft: current.gamesRight, gamesRight: current.gamesLeft, - serving: - current.serving === 'left' - ? 'right' - : current.serving === 'right' - ? 'left' - : null, - initialServing: - current.initialServing === 'left' - ? 'right' - : current.initialServing === 'right' - ? 'left' - : null, + serving: + current.serving === 'left' + ? 'right' + : current.serving === 'right' + ? 'left' + : null, + initialServing: + current.initialServing === 'left' + ? 'right' + : current.initialServing === 'right' + ? 'left' + : null, leftRightCourtPlayer: current.rightRightCourtPlayer, rightRightCourtPlayer: current.leftRightCourtPlayer, })) @@ -717,17 +712,17 @@ function App() { })) } - const setServing = (side: ScoreSide) => { - if (scoreHistory.length > 0) { - return - } - - setScoreState((current) => ({ - ...current, - initialServing: current.initialServing === side ? null : side, - serving: current.initialServing === side ? null : side, - })) - } + const setServing = (side: ScoreSide) => { + if (scoreHistory.length > 0) { + return + } + + setScoreState((current) => ({ + ...current, + initialServing: current.initialServing === side ? null : side, + serving: current.initialServing === side ? null : side, + })) + } const recordPoint = (side: ScoreSide) => { if (!leftTeam || !rightTeam || scoreState.serving === null) { @@ -756,7 +751,7 @@ function App() { }, ] - const nextScoreState: ScoreState = { + const nextScoreState: ScoreState = { ...scoreState, scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft, scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight, @@ -771,15 +766,32 @@ function App() { : scoreState.rightRightCourtPlayer, } - setScoreHistory((current) => [...current, { pointLog, scoreState }]) - setPointLog(nextPointLog) - setScoreState(nextScoreState) - setVoiceAnnouncement({ - key: Date.now(), - scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA, - serverChanged: side === scoreState.serving, - serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), - }) + const reachedTarget = hasWonGame(nextScoreState) + const winnerTeamName = reachedTarget + ? side === 'left' + ? getTeamDisplayName(leftTeam) + : getTeamDisplayName(rightTeam) + : null + + // 得分方接著發球,報分以發球方分數為先;左側隊伍的發球區需鏡像對應畫面。 + const servingScore = side === 'left' ? nextScoreState.scoreLeft : nextScoreState.scoreRight + const opponentScore = side === 'left' ? nextScoreState.scoreRight : nextScoreState.scoreLeft + const serverCourt = + side === 'left' + ? getMirroredCourt(getServiceCourt(servingScore)) + : getServiceCourt(servingScore) + + setScoreHistory((current) => [...current, { pointLog, scoreState }]) + setPointLog(nextPointLog) + setScoreState(nextScoreState) + setVoiceAnnouncement({ + key: Date.now(), + servingScore, + opponentScore, + serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), + serverCourt, + winnerTeamName, + }) if (streakTitle) { setStreakAnnouncement({ @@ -790,8 +802,6 @@ function App() { }) } - const reachedTarget = hasWonGame(nextScoreState) - if (reachedTarget) { setVictoryAnnouncement({ key: Date.now() + 1, @@ -851,7 +861,7 @@ function App() { }) } - const uploadSettledMatch = async () => { + const uploadSettledMatch = async () => { if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) { return } @@ -903,20 +913,20 @@ function App() { open: true, uploading: false, }) - } - } - - const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent) => { - if (!isNavigationLocked || targetPath === '/scoreboard') { - return - } - - event.preventDefault() - setNavigationLockMessage('比賽進行中,請先完成結算。') - } - - return ( -
+ } + } + + const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent) => { + if (!isNavigationLocked || targetPath === '/scoreboard') { + return + } + + event.preventDefault() + setNavigationLockMessage('比賽進行中,請先完成結算。') + } + + return ( +

Badminton Scoreboard

@@ -930,32 +940,32 @@ function App() {
@@ -1017,10 +1027,10 @@ function App() { rightTeam={rightTeam} scoreState={scoreState} selectedGroup={selectedGroup} - streakAnnouncement={streakAnnouncement} - victoryAnnouncement={victoryAnnouncement} - voiceAnnouncement={voiceAnnouncement} - targetDate={targetDate} + streakAnnouncement={streakAnnouncement} + victoryAnnouncement={victoryAnnouncement} + voiceAnnouncement={voiceAnnouncement} + targetDate={targetDate} onApplyMatchup={applyMatchup} onCloseFinishDialog={closeSettlementDialog} onConfirmUpload={uploadSettledMatch} @@ -1042,8 +1052,8 @@ function App() { /> - {pwaUpdateReady ? ( -
+ {pwaUpdateReady ? ( +
有新版本可更新 點重新整理後套用最新版本。 @@ -1051,17 +1061,17 @@ function App() { -
- ) : null} - - {navigationLockMessage ? ( -
- {navigationLockMessage} -
- ) : null} -
- ) -} +
+ ) : null} + + {navigationLockMessage ? ( +
+ {navigationLockMessage} +
+ ) : null} +
+ ) +} function buildHistoryPayload({ leftTeam, @@ -1101,65 +1111,65 @@ function buildHistoryPayload({ } } -function getServerHistoryIndex( - state: ScoreState, - leftTeam: GroupTeam, - rightTeam: GroupTeam, -) { - if (state.serving === 'left') { - const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft) - - if (!server) { - return null - } - - return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1 - } - - if (state.serving === 'right') { - const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight) - - if (!server) { - return null - } - - return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3 - } - - return null -} - -function getNextServerName( - state: ScoreState, - leftTeam: GroupTeam, - rightTeam: GroupTeam, - side: ScoreSide, -) { - if (side === 'left') { - return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? '' - } - - return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? '' -} - -function hasWonGame(state: ScoreState) { - const leadingScore = Math.max(state.scoreLeft, state.scoreRight) - const trailingScore = Math.min(state.scoreLeft, state.scoreRight) - - if (leadingScore < state.targetScore) { - return false - } - - if (leadingScore >= 30) { - return true - } - - if (trailingScore >= state.targetScore - 1) { - return leadingScore - trailingScore >= 2 - } - - return true -} +function getServerHistoryIndex( + state: ScoreState, + leftTeam: GroupTeam, + rightTeam: GroupTeam, +) { + if (state.serving === 'left') { + const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft) + + if (!server) { + return null + } + + return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1 + } + + if (state.serving === 'right') { + const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight) + + if (!server) { + return null + } + + return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3 + } + + return null +} + +function getNextServerName( + state: ScoreState, + leftTeam: GroupTeam, + rightTeam: GroupTeam, + side: ScoreSide, +) { + if (side === 'left') { + return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? '' + } + + return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? '' +} + +function hasWonGame(state: ScoreState) { + const leadingScore = Math.max(state.scoreLeft, state.scoreRight) + const trailingScore = Math.min(state.scoreLeft, state.scoreRight) + + if (leadingScore < state.targetScore) { + return false + } + + if (leadingScore >= 30) { + return true + } + + if (trailingScore >= state.targetScore - 1) { + return leadingScore - trailingScore >= 2 + } + + return true +} function formatPlayedAt(timestamp: number) { return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) diff --git a/src/pages/ScoreboardPage.tsx b/src/pages/ScoreboardPage.tsx index a9ec6f7..54da9ba 100644 --- a/src/pages/ScoreboardPage.tsx +++ b/src/pages/ScoreboardPage.tsx @@ -2,10 +2,10 @@ import type { Dispatch, SetStateAction } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' import { Link } from 'react-router-dom' import { - getCourtAssignments, - getMirroredCourt, - getReceivingPlayer, - getServiceCourt, + getCourtAssignments, + getMirroredCourt, + getReceivingPlayer, + getServiceCourt, getServingPlayer, getTeamDisplayName, } from '../lib/match' @@ -52,19 +52,21 @@ type ScoreboardPageProps = { teamName: string title: string } | null - victoryAnnouncement: { - key: number - scoreLabel: string - teamName: string - title: string - } | null - voiceAnnouncement: { - key: number - scorerName: string - serverChanged: boolean - serverName: string - } | null - targetDate: string + victoryAnnouncement: { + key: number + scoreLabel: string + teamName: string + title: string + } | null + voiceAnnouncement: { + key: number + servingScore: number + opponentScore: number + serverName: string + serverCourt: 'left' | 'right' + winnerTeamName: string | null + } | null + targetDate: string onApplyMatchup: ( leftTeam: GroupTeam, rightTeam: GroupTeam, @@ -92,11 +94,11 @@ export function ScoreboardPage({ liveRoomId, rightTeam, scoreState, - selectedGroup, - streakAnnouncement, - victoryAnnouncement, - voiceAnnouncement, - targetDate, + selectedGroup, + streakAnnouncement, + victoryAnnouncement, + voiceAnnouncement, + targetDate, onApplyMatchup, onCloseFinishDialog, onConfirmUpload, @@ -121,10 +123,10 @@ export function ScoreboardPage({ const [voiceSettings, setVoiceSettings] = useState(() => loadVoiceSettings(), ) - const finishHoldFrameRef = useRef(null) - const finishHoldTimerRef = useRef(null) - const finishHoldStartRef = useRef(0) - const finishTriggeredRef = useRef(false) + const finishHoldFrameRef = useRef(null) + const finishHoldTimerRef = useRef(null) + const finishHoldStartRef = useRef(0) + const finishTriggeredRef = useRef(false) useEffect(() => { const timer = window.setInterval(() => { @@ -195,16 +197,16 @@ export function ScoreboardPage({ const servingCourt = scoreState.serving === null ? null : getServiceCourt(servingScore) - const leftAssignments = useMemo( - () => - leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [], - [leftTeam, scoreState.leftRightCourtPlayer], - ) - const rightAssignments = useMemo( - () => - rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [], - [rightTeam, scoreState.rightRightCourtPlayer], - ) + const leftAssignments = useMemo( + () => + leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [], + [leftTeam, scoreState.leftRightCourtPlayer], + ) + const rightAssignments = useMemo( + () => + rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [], + [rightTeam, scoreState.rightRightCourtPlayer], + ) const currentServer = scoreState.serving === 'left' @@ -240,34 +242,44 @@ export function ScoreboardPage({ : null : null - useEffect(() => { - if (!voiceAnnouncement) { - return - } - - const parts: string[] = [] - - if (voiceSettings.announceScore) { - parts.push(`${getSpeechName(voiceAnnouncement.scorerName)}得分`) - } - - if (voiceSettings.announceServer && voiceAnnouncement.serverName) { - parts.push( - `${getSpeechName(voiceAnnouncement.serverName)}${ - voiceAnnouncement.serverChanged ? '換邊發球' : '發球' - }`, - ) - } - - if (parts.length > 0) { - speakAnnouncement(parts.join(','), voiceSettings.rate) - } - }, [ - voiceAnnouncement, - voiceSettings.announceScore, - voiceSettings.announceServer, - voiceSettings.rate, - ]) + useEffect(() => { + if (!voiceAnnouncement) { + return + } + + if (voiceAnnouncement.winnerTeamName) { + const winnerSpeech = voiceAnnouncement.winnerTeamName + .split('/') + .map((name) => getSpeechName(name.trim())) + .filter(Boolean) + .join('、') + speakAnnouncement(`${winnerSpeech}贏得比賽`, voiceSettings.rate) + return + } + + const parts: string[] = [] + + if (voiceSettings.announceScore) { + parts.push(`${voiceAnnouncement.servingScore}比${voiceAnnouncement.opponentScore}`) + } + + if (voiceSettings.announceServer && voiceAnnouncement.serverName) { + parts.push( + `${getSpeechName(voiceAnnouncement.serverName)}${ + voiceAnnouncement.serverCourt === 'left' ? '左邊' : '右邊' + }發球`, + ) + } + + if (parts.length > 0) { + speakAnnouncement(parts.join(','), voiceSettings.rate) + } + }, [ + voiceAnnouncement, + voiceSettings.announceScore, + voiceSettings.announceServer, + voiceSettings.rate, + ]) if (!selectedGroup) { return ( @@ -459,27 +471,27 @@ export function ScoreboardPage({
- onRecordPoint('left')} - onSetServing={() => onSetServing('left')} - onSwapPlayers={() => onSwapTeamPlayers('left')} - onSwapTeams={onSwapMatchup} - score={scoreState.scoreLeft} - serviceCourt={ - scoreState.serving === 'left' && servingCourt - ? getMirroredCourt(servingCourt) - : null - } - showServingPrompt={scoreState.serving === null} - team={leftTeam} - teamSlot="top" + onRecordPoint('left')} + onSetServing={() => onSetServing('left')} + onSwapPlayers={() => onSwapTeamPlayers('left')} + onSwapTeams={onSwapMatchup} + score={scoreState.scoreLeft} + serviceCourt={ + scoreState.serving === 'left' && servingCourt + ? getMirroredCourt(servingCourt) + : null + } + showServingPrompt={scoreState.serving === null} + team={leftTeam} + teamSlot="top" />
@@ -492,23 +504,23 @@ export function ScoreboardPage({
- 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" + 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" />
@@ -632,15 +644,15 @@ export function ScoreboardPage({ ) } -type ScoreboardTeamPanelProps = { - assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> - canArrangeMatch: boolean - canScore: boolean - canSetServing: boolean - currentReceiver: string | null - currentServer: string | null - hasInitialServing: boolean - onRecordPoint: () => void +type ScoreboardTeamPanelProps = { + assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> + canArrangeMatch: boolean + canScore: boolean + canSetServing: boolean + currentReceiver: string | null + currentServer: string | null + hasInitialServing: boolean + onRecordPoint: () => void onSetServing: () => void onSwapPlayers: () => void onSwapTeams: () => void @@ -652,14 +664,14 @@ type ScoreboardTeamPanelProps = { } function ScoreboardTeamPanel({ - assignments, - canArrangeMatch, - canScore, - canSetServing, - currentReceiver, - currentServer, - hasInitialServing, - onRecordPoint, + assignments, + canArrangeMatch, + canScore, + canSetServing, + currentReceiver, + currentServer, + hasInitialServing, + onRecordPoint, onSetServing, onSwapPlayers, onSwapTeams, @@ -689,7 +701,7 @@ function ScoreboardTeamPanel({ } key={assignment.slot} > - {getPlayerNumber(teamSlot, assignment.court)} + {getPlayerNumber(teamSlot, assignment.court)} {assignment.name}
))} @@ -718,31 +730,31 @@ function ScoreboardTeamPanel({ ) - const serveBar = ( - ) @@ -983,7 +995,7 @@ function VoiceSettingsModal({

播報內容