功能:首頁日期預設當天、記分板語音改為報比分與發球區

摘要:
- 首頁「指定日期」每次進入都預設為當天
- 記分板語音得分播報改為「比分(發球方先)+ 發球者左右發球區」,賽末點獲勝時整段改播「贏得比賽」

根本原因:
- 日期原本從 localStorage 讀上次選的值,重開會停在舊日期
- 語音原本只唸「誰得分、誰發球」,現場較不直覺,球敘想聽到即時比分與發球位置

影響:
- src/App.tsx:targetDate 改為直接用當天、移除日期的 localStorage 記憶;recordPoint 算出發球方比分與發球區,重整 voiceAnnouncement 欄位
- src/pages/ScoreboardPage.tsx:語音組字改為「X比Y」「OO左/右邊發球」,獲勝時改播「贏得比賽」;語音設定「播報誰得分」更名「播報比分」

修法:
- 發球方分數先報;上方(左側)隊伍發球區做左右鏡像以對齊畫面,下方隊伍不鏡像

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 15:33:22 +08:00
parent 3677162747
commit 091e654bdb
2 changed files with 473 additions and 451 deletions
+306 -296
View File
@@ -1,26 +1,26 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { NavLink, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import './App.css' import './App.css'
import { import {
createLiveRoom, createLiveRoom,
loadMatchResults, loadMatchResults,
releaseLiveRoom, releaseLiveRoom,
saveMatchHistory, saveMatchHistory,
sendLiveRoomHeartbeat, sendLiveRoomHeartbeat,
updateLiveRoom, updateLiveRoom,
} from './lib/api' } from './lib/api'
import { import {
buildManualGroups, buildManualGroups,
convertDateToKey, convertDateToKey,
convertDbRecordToGroups, convertDbRecordToGroups,
formatDateInputValue, formatDateInputValue,
getMirroredCourt, getMirroredCourt,
getServiceCourt, getServiceCourt,
getServingPlayer, getServingPlayer,
getTeamDisplayName, getTeamDisplayName,
getWinnerName, getWinnerName,
parseRoster, parseRoster,
swapCourtPositions, swapCourtPositions,
} from './lib/match' } from './lib/match'
import { HistoryPage } from './pages/HistoryPage' import { HistoryPage } from './pages/HistoryPage'
import { RoomListPage } from './pages/RoomListPage' import { RoomListPage } from './pages/RoomListPage'
@@ -45,24 +45,23 @@ const STORAGE_KEYS = {
areaA: 'badminton-scoreboard::area-a', areaA: 'badminton-scoreboard::area-a',
areaB: 'badminton-scoreboard::area-b', areaB: 'badminton-scoreboard::area-b',
history: 'badminton-scoreboard::history', history: 'badminton-scoreboard::history',
targetDate: 'badminton-scoreboard::target-date',
} as const } as const
const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧'] const defaultAreaA = ['柏威', '建喵', 'Yuki', '阿釧']
const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim'] const defaultAreaB = ['RURU', '玟瑄', '培根', 'Tim']
const initialScoreState: ScoreState = { const initialScoreState: ScoreState = {
scoreLeft: 0, scoreLeft: 0,
scoreRight: 0, scoreRight: 0,
gamesLeft: 0, gamesLeft: 0,
gamesRight: 0, gamesRight: 0,
currentGame: 1, currentGame: 1,
targetScore: 21, targetScore: 21,
initialServing: null, initialServing: null,
serving: null, serving: null,
leftRightCourtPlayer: 'playerA', leftRightCourtPlayer: 'playerA',
rightRightCourtPlayer: 'playerA', rightRightCourtPlayer: 'playerA',
} }
type SettlementState = { type SettlementState = {
error: string error: string
@@ -77,40 +76,40 @@ type StreakAnnouncement = {
title: string title: string
} }
type VictoryAnnouncement = { type VictoryAnnouncement = {
key: number key: number
scoreLabel: string scoreLabel: string
teamName: string teamName: string
title: string title: string
} }
type VoiceAnnouncement = {
key: number
scorerName: string
serverChanged: boolean
serverName: string
}
const STREAK_TITLES: Record<number, string> = { type VoiceAnnouncement = {
key: number
servingScore: number
opponentScore: number
serverName: string
serverCourt: 'left' | 'right'
winnerTeamName: string | null
}
const STREAK_TITLES: Record<number, string> = {
3: '大殺特殺', 3: '大殺特殺',
4: '暴走', 4: '暴走',
5: '無人能擋', 5: '無人能擋',
6: '主宰比賽', 6: '主宰比賽',
7: '像神一般的', 7: '像神一般的',
8: '成為傳說', 8: '成為傳說',
} }
const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready' const PWA_UPDATE_EVENT = 'badminton-scoreboard:pwa-update-ready'
const APP_VERSION_POLL_MS = 30000 const APP_VERSION_POLL_MS = 30000
const LIVE_ROOM_HEARTBEAT_MS = 10_000 const LIVE_ROOM_HEARTBEAT_MS = 10_000
function App() { function App() {
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const isScoreboardRoute = location.pathname === '/scoreboard' const isScoreboardRoute = location.pathname === '/scoreboard'
const [targetDate, setTargetDate] = useState(() => const [targetDate, setTargetDate] = useState(() => formatDateInputValue())
loadStoredText(STORAGE_KEYS.targetDate, formatDateInputValue()),
)
const [areaAInput, setAreaAInput] = useState(() => const [areaAInput, setAreaAInput] = useState(() =>
loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')), loadStoredText(STORAGE_KEYS.areaA, defaultAreaA.join('\n')),
) )
@@ -137,27 +136,23 @@ function App() {
open: false, open: false,
uploading: false, uploading: false,
}) })
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null) const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null) const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null) const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
const [pwaUpdateReady, setPwaUpdateReady] = useState(false) const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null) const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
const [navigationLockMessage, setNavigationLockMessage] = useState('') const [navigationLockMessage, setNavigationLockMessage] = useState('')
const currentAppVersionRef = useRef<string | null>(null) const currentAppVersionRef = useRef<string | null>(null)
const creatingRoomRef = useRef(false) const creatingRoomRef = useRef(false)
const lastSyncedRoomSignatureRef = useRef('') const lastSyncedRoomSignatureRef = useRef('')
const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput]) const parsedAreaA = useMemo(() => parseRoster(areaAInput), [areaAInput])
const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput]) const parsedAreaB = useMemo(() => parseRoster(areaBInput), [areaBInput])
const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null const selectedGroup = groups.find((group) => group.id === selectedGroupId) ?? null
const leftTeam = activeMatchup.leftTeam const leftTeam = activeMatchup.leftTeam
const rightTeam = activeMatchup.rightTeam const rightTeam = activeMatchup.rightTeam
const liveRoomId = liveRoomSession?.roomId ?? null const liveRoomId = liveRoomSession?.roomId ?? null
const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null) const isNavigationLocked = Boolean(leftTeam && rightTeam && scoreState.serving !== null)
useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.targetDate, targetDate)
}, [targetDate])
useEffect(() => { useEffect(() => {
window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput) window.localStorage.setItem(STORAGE_KEYS.areaA, areaAInput)
@@ -195,9 +190,9 @@ function App() {
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [streakAnnouncement]) }, [streakAnnouncement])
useEffect(() => { useEffect(() => {
if (!victoryAnnouncement) { if (!victoryAnnouncement) {
return return
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
@@ -205,27 +200,27 @@ function App() {
}, 2200) }, 2200)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [victoryAnnouncement]) }, [victoryAnnouncement])
useEffect(() => { useEffect(() => {
if (!navigationLockMessage) { if (!navigationLockMessage) {
return return
} }
const timer = window.setTimeout(() => { const timer = window.setTimeout(() => {
setNavigationLockMessage('') setNavigationLockMessage('')
}, 1400) }, 1400)
return () => window.clearTimeout(timer) return () => window.clearTimeout(timer)
}, [navigationLockMessage]) }, [navigationLockMessage])
useEffect(() => { useEffect(() => {
document.body.classList.toggle('body-scoreboard', isScoreboardRoute) document.body.classList.toggle('body-scoreboard', isScoreboardRoute)
return () => { return () => {
document.body.classList.remove('body-scoreboard') document.body.classList.remove('body-scoreboard')
} }
}, [isScoreboardRoute]) }, [isScoreboardRoute])
useEffect(() => { useEffect(() => {
const handlePwaUpdateReady = () => { const handlePwaUpdateReady = () => {
@@ -304,10 +299,10 @@ function App() {
setScoreState(nextState) setScoreState(nextState)
setScoreHistory([]) setScoreHistory([])
setPointLog([]) setPointLog([])
setStreakAnnouncement(null) setStreakAnnouncement(null)
setVictoryAnnouncement(null) setVictoryAnnouncement(null)
setVoiceAnnouncement(null) setVoiceAnnouncement(null)
setSettlement({ setSettlement({
error: '', error: '',
open: false, open: false,
@@ -455,17 +450,17 @@ function App() {
isScoreboardRoute, isScoreboardRoute,
]) ])
useEffect(() => { useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) { if (!isScoreboardRoute || !liveRoomSession || !leftTeam || !rightTeam) {
return return
} }
const winnerTeamName = const winnerTeamName =
hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight hasWonGame(scoreState) && scoreState.scoreLeft > scoreState.scoreRight
? getTeamDisplayName(leftTeam) ? getTeamDisplayName(leftTeam)
: hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft : hasWonGame(scoreState) && scoreState.scoreRight > scoreState.scoreLeft
? getTeamDisplayName(rightTeam) ? getTeamDisplayName(rightTeam)
: null : null
const nextStatus = winnerTeamName ? 'finished' : 'live' const nextStatus = winnerTeamName ? 'finished' : 'live'
const payload = buildLiveRoomPayload({ const payload = buildLiveRoomPayload({
groupId: selectedGroup?.id ?? null, groupId: selectedGroup?.id ?? null,
@@ -514,49 +509,49 @@ function App() {
rightTeam, rightTeam,
scoreState, scoreState,
selectedGroup?.id, selectedGroup?.id,
targetDate, targetDate,
isScoreboardRoute, isScoreboardRoute,
]) ])
useEffect(() => { useEffect(() => {
if (!isNavigationLocked || isScoreboardRoute) { if (!isNavigationLocked || isScoreboardRoute) {
return return
} }
navigate('/scoreboard', { replace: true }) navigate('/scoreboard', { replace: true })
setNavigationLockMessage('比賽進行中,請先完成結算。') setNavigationLockMessage('比賽進行中,請先完成結算。')
}, [isNavigationLocked, isScoreboardRoute, navigate]) }, [isNavigationLocked, isScoreboardRoute, navigate])
useEffect(() => { useEffect(() => {
if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') { if (!isScoreboardRoute || !liveRoomSession || liveRoomSession.status !== 'live') {
return return
} }
let active = true let active = true
const syncHeartbeat = async () => { const syncHeartbeat = async () => {
try { try {
await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken) await sendLiveRoomHeartbeat(liveRoomSession.roomId, liveRoomSession.hostToken)
} catch (error) { } catch (error) {
if (active) { if (active) {
console.error('live room heartbeat error:', error) console.error('live room heartbeat error:', error)
} }
} }
} }
void syncHeartbeat() void syncHeartbeat()
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void syncHeartbeat() void syncHeartbeat()
}, LIVE_ROOM_HEARTBEAT_MS) }, LIVE_ROOM_HEARTBEAT_MS)
return () => { return () => {
active = false active = false
window.clearInterval(timer) window.clearInterval(timer)
} }
}, [isScoreboardRoute, liveRoomSession]) }, [isScoreboardRoute, liveRoomSession])
useEffect(() => { useEffect(() => {
if (!liveRoomSession || liveRoomSession.status !== 'live') { if (!liveRoomSession || liveRoomSession.status !== 'live') {
return return
} }
@@ -682,18 +677,18 @@ function App() {
scoreRight: current.scoreLeft, scoreRight: current.scoreLeft,
gamesLeft: current.gamesRight, gamesLeft: current.gamesRight,
gamesRight: current.gamesLeft, gamesRight: current.gamesLeft,
serving: serving:
current.serving === 'left' current.serving === 'left'
? 'right' ? 'right'
: current.serving === 'right' : current.serving === 'right'
? 'left' ? 'left'
: null, : null,
initialServing: initialServing:
current.initialServing === 'left' current.initialServing === 'left'
? 'right' ? 'right'
: current.initialServing === 'right' : current.initialServing === 'right'
? 'left' ? 'left'
: null, : null,
leftRightCourtPlayer: current.rightRightCourtPlayer, leftRightCourtPlayer: current.rightRightCourtPlayer,
rightRightCourtPlayer: current.leftRightCourtPlayer, rightRightCourtPlayer: current.leftRightCourtPlayer,
})) }))
@@ -717,17 +712,17 @@ function App() {
})) }))
} }
const setServing = (side: ScoreSide) => { const setServing = (side: ScoreSide) => {
if (scoreHistory.length > 0) { if (scoreHistory.length > 0) {
return return
} }
setScoreState((current) => ({ setScoreState((current) => ({
...current, ...current,
initialServing: current.initialServing === side ? null : side, initialServing: current.initialServing === side ? null : side,
serving: current.initialServing === side ? null : side, serving: current.initialServing === side ? null : side,
})) }))
} }
const recordPoint = (side: ScoreSide) => { const recordPoint = (side: ScoreSide) => {
if (!leftTeam || !rightTeam || scoreState.serving === null) { if (!leftTeam || !rightTeam || scoreState.serving === null) {
@@ -756,7 +751,7 @@ function App() {
}, },
] ]
const nextScoreState: ScoreState = { const nextScoreState: ScoreState = {
...scoreState, ...scoreState,
scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft, scoreLeft: side === 'left' ? scoreState.scoreLeft + 1 : scoreState.scoreLeft,
scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight, scoreRight: side === 'right' ? scoreState.scoreRight + 1 : scoreState.scoreRight,
@@ -771,15 +766,32 @@ function App() {
: scoreState.rightRightCourtPlayer, : scoreState.rightRightCourtPlayer,
} }
setScoreHistory((current) => [...current, { pointLog, scoreState }]) const reachedTarget = hasWonGame(nextScoreState)
setPointLog(nextPointLog) const winnerTeamName = reachedTarget
setScoreState(nextScoreState) ? side === 'left'
setVoiceAnnouncement({ ? getTeamDisplayName(leftTeam)
key: Date.now(), : getTeamDisplayName(rightTeam)
scorerName: side === 'left' ? leftTeam.playerA : rightTeam.playerA, : null
serverChanged: side === scoreState.serving,
serverName: getNextServerName(nextScoreState, leftTeam, rightTeam, side), // 得分方接著發球,報分以發球方分數為先;左側隊伍的發球區需鏡像對應畫面。
}) 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) { if (streakTitle) {
setStreakAnnouncement({ setStreakAnnouncement({
@@ -790,8 +802,6 @@ function App() {
}) })
} }
const reachedTarget = hasWonGame(nextScoreState)
if (reachedTarget) { if (reachedTarget) {
setVictoryAnnouncement({ setVictoryAnnouncement({
key: Date.now() + 1, key: Date.now() + 1,
@@ -851,7 +861,7 @@ function App() {
}) })
} }
const uploadSettledMatch = async () => { const uploadSettledMatch = async () => {
if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) { if (!leftTeam || !rightTeam || !selectedGroup || pointLog.length === 0) {
return return
} }
@@ -903,20 +913,20 @@ function App() {
open: true, open: true,
uploading: false, uploading: false,
}) })
} }
} }
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => { const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
if (!isNavigationLocked || targetPath === '/scoreboard') { if (!isNavigationLocked || targetPath === '/scoreboard') {
return return
} }
event.preventDefault() event.preventDefault()
setNavigationLockMessage('比賽進行中,請先完成結算。') setNavigationLockMessage('比賽進行中,請先完成結算。')
} }
return ( return (
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}> <div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}>
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}> <header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
<div className="branding"> <div className="branding">
<p className="eyebrow">Badminton Scoreboard</p> <p className="eyebrow">Badminton Scoreboard</p>
@@ -930,32 +940,32 @@ function App() {
</div> </div>
<nav className="topnav" aria-label="主要導覽"> <nav className="topnav" aria-label="主要導覽">
<NavLink <NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/teams')} onClick={handleNavAttempt('/teams')}
to="/teams" to="/teams"
> >
</NavLink> </NavLink>
<NavLink <NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/scoreboard')} onClick={handleNavAttempt('/scoreboard')}
to="/scoreboard" to="/scoreboard"
> >
</NavLink> </NavLink>
<NavLink <NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/history')} onClick={handleNavAttempt('/history')}
to="/history" to="/history"
> >
</NavLink> </NavLink>
<NavLink <NavLink
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')} className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
onClick={handleNavAttempt('/rooms')} onClick={handleNavAttempt('/rooms')}
to="/rooms" to="/rooms"
> >
</NavLink> </NavLink>
</nav> </nav>
@@ -1017,10 +1027,10 @@ function App() {
rightTeam={rightTeam} rightTeam={rightTeam}
scoreState={scoreState} scoreState={scoreState}
selectedGroup={selectedGroup} selectedGroup={selectedGroup}
streakAnnouncement={streakAnnouncement} streakAnnouncement={streakAnnouncement}
victoryAnnouncement={victoryAnnouncement} victoryAnnouncement={victoryAnnouncement}
voiceAnnouncement={voiceAnnouncement} voiceAnnouncement={voiceAnnouncement}
targetDate={targetDate} targetDate={targetDate}
onApplyMatchup={applyMatchup} onApplyMatchup={applyMatchup}
onCloseFinishDialog={closeSettlementDialog} onCloseFinishDialog={closeSettlementDialog}
onConfirmUpload={uploadSettledMatch} onConfirmUpload={uploadSettledMatch}
@@ -1042,8 +1052,8 @@ function App() {
/> />
</Routes> </Routes>
{pwaUpdateReady ? ( {pwaUpdateReady ? (
<div className="pwa-update-toast" role="status" aria-live="polite"> <div className="pwa-update-toast" role="status" aria-live="polite">
<div className="pwa-update-copy"> <div className="pwa-update-copy">
<strong></strong> <strong></strong>
<span></span> <span></span>
@@ -1051,17 +1061,17 @@ function App() {
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button"> <button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
</button> </button>
</div> </div>
) : null} ) : null}
{navigationLockMessage ? ( {navigationLockMessage ? (
<div className="floating-status-bubble" role="status" aria-live="polite"> <div className="floating-status-bubble" role="status" aria-live="polite">
{navigationLockMessage} {navigationLockMessage}
</div> </div>
) : null} ) : null}
</div> </div>
) )
} }
function buildHistoryPayload({ function buildHistoryPayload({
leftTeam, leftTeam,
@@ -1101,65 +1111,65 @@ function buildHistoryPayload({
} }
} }
function getServerHistoryIndex( function getServerHistoryIndex(
state: ScoreState, state: ScoreState,
leftTeam: GroupTeam, leftTeam: GroupTeam,
rightTeam: GroupTeam, rightTeam: GroupTeam,
) { ) {
if (state.serving === 'left') { if (state.serving === 'left') {
const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft) const server = getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)
if (!server) { if (!server) {
return null return null
} }
return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1 return getMirroredCourt(getServiceCourt(state.scoreLeft)) === 'left' ? 0 : 1
} }
if (state.serving === 'right') { if (state.serving === 'right') {
const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight) const server = getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)
if (!server) { if (!server) {
return null return null
} }
return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3 return getServiceCourt(state.scoreRight) === 'right' ? 2 : 3
} }
return null return null
} }
function getNextServerName( function getNextServerName(
state: ScoreState, state: ScoreState,
leftTeam: GroupTeam, leftTeam: GroupTeam,
rightTeam: GroupTeam, rightTeam: GroupTeam,
side: ScoreSide, side: ScoreSide,
) { ) {
if (side === 'left') { if (side === 'left') {
return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? '' return getServingPlayer(leftTeam, state.leftRightCourtPlayer, state.scoreLeft)?.name ?? ''
} }
return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? '' return getServingPlayer(rightTeam, state.rightRightCourtPlayer, state.scoreRight)?.name ?? ''
} }
function hasWonGame(state: ScoreState) { function hasWonGame(state: ScoreState) {
const leadingScore = Math.max(state.scoreLeft, state.scoreRight) const leadingScore = Math.max(state.scoreLeft, state.scoreRight)
const trailingScore = Math.min(state.scoreLeft, state.scoreRight) const trailingScore = Math.min(state.scoreLeft, state.scoreRight)
if (leadingScore < state.targetScore) { if (leadingScore < state.targetScore) {
return false return false
} }
if (leadingScore >= 30) { if (leadingScore >= 30) {
return true return true
} }
if (trailingScore >= state.targetScore - 1) { if (trailingScore >= state.targetScore - 1) {
return leadingScore - trailingScore >= 2 return leadingScore - trailingScore >= 2
} }
return true return true
} }
function formatPlayedAt(timestamp: number) { function formatPlayedAt(timestamp: number) {
return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false }) return new Date(timestamp * 1000).toLocaleString('zh-TW', { hour12: false })
+167 -155
View File
@@ -2,10 +2,10 @@ import type { Dispatch, SetStateAction } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
getCourtAssignments, getCourtAssignments,
getMirroredCourt, getMirroredCourt,
getReceivingPlayer, getReceivingPlayer,
getServiceCourt, getServiceCourt,
getServingPlayer, getServingPlayer,
getTeamDisplayName, getTeamDisplayName,
} from '../lib/match' } from '../lib/match'
@@ -52,19 +52,21 @@ type ScoreboardPageProps = {
teamName: string teamName: string
title: string title: string
} | null } | null
victoryAnnouncement: { victoryAnnouncement: {
key: number key: number
scoreLabel: string scoreLabel: string
teamName: string teamName: string
title: string title: string
} | null } | null
voiceAnnouncement: { voiceAnnouncement: {
key: number key: number
scorerName: string servingScore: number
serverChanged: boolean opponentScore: number
serverName: string serverName: string
} | null serverCourt: 'left' | 'right'
targetDate: string winnerTeamName: string | null
} | null
targetDate: string
onApplyMatchup: ( onApplyMatchup: (
leftTeam: GroupTeam, leftTeam: GroupTeam,
rightTeam: GroupTeam, rightTeam: GroupTeam,
@@ -92,11 +94,11 @@ export function ScoreboardPage({
liveRoomId, liveRoomId,
rightTeam, rightTeam,
scoreState, scoreState,
selectedGroup, selectedGroup,
streakAnnouncement, streakAnnouncement,
victoryAnnouncement, victoryAnnouncement,
voiceAnnouncement, voiceAnnouncement,
targetDate, targetDate,
onApplyMatchup, onApplyMatchup,
onCloseFinishDialog, onCloseFinishDialog,
onConfirmUpload, onConfirmUpload,
@@ -121,10 +123,10 @@ export function ScoreboardPage({
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() => const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>(() =>
loadVoiceSettings(), loadVoiceSettings(),
) )
const finishHoldFrameRef = useRef<number | null>(null) const finishHoldFrameRef = useRef<number | null>(null)
const finishHoldTimerRef = useRef<number | null>(null) const finishHoldTimerRef = useRef<number | null>(null)
const finishHoldStartRef = useRef(0) const finishHoldStartRef = useRef(0)
const finishTriggeredRef = useRef(false) const finishTriggeredRef = useRef(false)
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@@ -195,16 +197,16 @@ export function ScoreboardPage({
const servingCourt = const servingCourt =
scoreState.serving === null ? null : getServiceCourt(servingScore) scoreState.serving === null ? null : getServiceCourt(servingScore)
const leftAssignments = useMemo( const leftAssignments = useMemo(
() => () =>
leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [], leftTeam ? getCourtAssignments(leftTeam, scoreState.leftRightCourtPlayer, true) : [],
[leftTeam, scoreState.leftRightCourtPlayer], [leftTeam, scoreState.leftRightCourtPlayer],
) )
const rightAssignments = useMemo( const rightAssignments = useMemo(
() => () =>
rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [], rightTeam ? getCourtAssignments(rightTeam, scoreState.rightRightCourtPlayer, false) : [],
[rightTeam, scoreState.rightRightCourtPlayer], [rightTeam, scoreState.rightRightCourtPlayer],
) )
const currentServer = const currentServer =
scoreState.serving === 'left' scoreState.serving === 'left'
@@ -240,34 +242,44 @@ export function ScoreboardPage({
: null : null
: null : null
useEffect(() => { useEffect(() => {
if (!voiceAnnouncement) { if (!voiceAnnouncement) {
return return
} }
const parts: string[] = [] if (voiceAnnouncement.winnerTeamName) {
const winnerSpeech = voiceAnnouncement.winnerTeamName
if (voiceSettings.announceScore) { .split('/')
parts.push(`${getSpeechName(voiceAnnouncement.scorerName)}得分`) .map((name) => getSpeechName(name.trim()))
} .filter(Boolean)
.join('、')
if (voiceSettings.announceServer && voiceAnnouncement.serverName) { speakAnnouncement(`${winnerSpeech}贏得比賽`, voiceSettings.rate)
parts.push( return
`${getSpeechName(voiceAnnouncement.serverName)}${ }
voiceAnnouncement.serverChanged ? '換邊發球' : '發球'
}`, const parts: string[] = []
)
} if (voiceSettings.announceScore) {
parts.push(`${voiceAnnouncement.servingScore}${voiceAnnouncement.opponentScore}`)
if (parts.length > 0) { }
speakAnnouncement(parts.join(''), voiceSettings.rate)
} if (voiceSettings.announceServer && voiceAnnouncement.serverName) {
}, [ parts.push(
voiceAnnouncement, `${getSpeechName(voiceAnnouncement.serverName)}${
voiceSettings.announceScore, voiceAnnouncement.serverCourt === 'left' ? '左邊' : '右邊'
voiceSettings.announceServer, }發球`,
voiceSettings.rate, )
]) }
if (parts.length > 0) {
speakAnnouncement(parts.join(''), voiceSettings.rate)
}
}, [
voiceAnnouncement,
voiceSettings.announceScore,
voiceSettings.announceServer,
voiceSettings.rate,
])
if (!selectedGroup) { if (!selectedGroup) {
return ( return (
@@ -459,27 +471,27 @@ export function ScoreboardPage({
<section className="scoreboard-screen"> <section className="scoreboard-screen">
<div className="scoreboard-court"> <div className="scoreboard-court">
<ScoreboardTeamPanel <ScoreboardTeamPanel
assignments={leftAssignments} assignments={leftAssignments}
canArrangeMatch={canArrangeMatch} canArrangeMatch={canArrangeMatch}
canScore={canScore} canScore={canScore}
canSetServing={canArrangeMatch} canSetServing={canArrangeMatch}
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null} currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null} currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
hasInitialServing={scoreState.initialServing === 'left'} hasInitialServing={scoreState.initialServing === 'left'}
onRecordPoint={() => onRecordPoint('left')} onRecordPoint={() => onRecordPoint('left')}
onSetServing={() => onSetServing('left')} onSetServing={() => onSetServing('left')}
onSwapPlayers={() => onSwapTeamPlayers('left')} onSwapPlayers={() => onSwapTeamPlayers('left')}
onSwapTeams={onSwapMatchup} onSwapTeams={onSwapMatchup}
score={scoreState.scoreLeft} score={scoreState.scoreLeft}
serviceCourt={ serviceCourt={
scoreState.serving === 'left' && servingCourt scoreState.serving === 'left' && servingCourt
? getMirroredCourt(servingCourt) ? getMirroredCourt(servingCourt)
: null : null
} }
showServingPrompt={scoreState.serving === null} showServingPrompt={scoreState.serving === null}
team={leftTeam} team={leftTeam}
teamSlot="top" teamSlot="top"
/> />
<div className="scoreboard-center-banner"> <div className="scoreboard-center-banner">
@@ -492,23 +504,23 @@ export function ScoreboardPage({
</small> </small>
</div> </div>
<ScoreboardTeamPanel <ScoreboardTeamPanel
assignments={rightAssignments} assignments={rightAssignments}
canArrangeMatch={canArrangeMatch} canArrangeMatch={canArrangeMatch}
canScore={canScore} canScore={canScore}
canSetServing={canArrangeMatch} canSetServing={canArrangeMatch}
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null} currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null} currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
hasInitialServing={scoreState.initialServing === 'right'} hasInitialServing={scoreState.initialServing === 'right'}
onRecordPoint={() => onRecordPoint('right')} onRecordPoint={() => onRecordPoint('right')}
onSetServing={() => onSetServing('right')} onSetServing={() => onSetServing('right')}
onSwapPlayers={() => onSwapTeamPlayers('right')} onSwapPlayers={() => onSwapTeamPlayers('right')}
onSwapTeams={onSwapMatchup} onSwapTeams={onSwapMatchup}
score={scoreState.scoreRight} score={scoreState.scoreRight}
serviceCourt={scoreState.serving === 'right' ? servingCourt : null} serviceCourt={scoreState.serving === 'right' ? servingCourt : null}
showServingPrompt={scoreState.serving === null} showServingPrompt={scoreState.serving === null}
team={rightTeam} team={rightTeam}
teamSlot="bottom" teamSlot="bottom"
/> />
</div> </div>
@@ -632,15 +644,15 @@ export function ScoreboardPage({
) )
} }
type ScoreboardTeamPanelProps = { type ScoreboardTeamPanelProps = {
assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }> assignments: Array<{ slot: PlayerSlot; name: string; court: CourtSide }>
canArrangeMatch: boolean canArrangeMatch: boolean
canScore: boolean canScore: boolean
canSetServing: boolean canSetServing: boolean
currentReceiver: string | null currentReceiver: string | null
currentServer: string | null currentServer: string | null
hasInitialServing: boolean hasInitialServing: boolean
onRecordPoint: () => void onRecordPoint: () => void
onSetServing: () => void onSetServing: () => void
onSwapPlayers: () => void onSwapPlayers: () => void
onSwapTeams: () => void onSwapTeams: () => void
@@ -652,14 +664,14 @@ type ScoreboardTeamPanelProps = {
} }
function ScoreboardTeamPanel({ function ScoreboardTeamPanel({
assignments, assignments,
canArrangeMatch, canArrangeMatch,
canScore, canScore,
canSetServing, canSetServing,
currentReceiver, currentReceiver,
currentServer, currentServer,
hasInitialServing, hasInitialServing,
onRecordPoint, onRecordPoint,
onSetServing, onSetServing,
onSwapPlayers, onSwapPlayers,
onSwapTeams, onSwapTeams,
@@ -689,7 +701,7 @@ function ScoreboardTeamPanel({
} }
key={assignment.slot} key={assignment.slot}
> >
<span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span> <span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span>
<strong>{assignment.name}</strong> <strong>{assignment.name}</strong>
</div> </div>
))} ))}
@@ -718,31 +730,31 @@ function ScoreboardTeamPanel({
</div> </div>
) )
const serveBar = ( const serveBar = (
<button <button
className={ className={
hasInitialServing && !canSetServing hasInitialServing && !canSetServing
? 'serve-lane serve-lane-locked' ? 'serve-lane serve-lane-locked'
: showServingPrompt : showServingPrompt
? 'serve-lane serve-lane-prompt' ? 'serve-lane serve-lane-prompt'
: 'serve-lane' : 'serve-lane'
} }
disabled={!canSetServing || !team} disabled={!canSetServing || !team}
type="button" type="button"
onClick={onSetServing} onClick={onSetServing}
> >
<span className={hasInitialServing ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} /> <span className={hasInitialServing ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
<span></span> <span></span>
{currentServer ? ( {currentServer ? (
<small> <small>
{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'} {serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
{currentReceiver ? ` / 接發:${currentReceiver}` : ''} {currentReceiver ? ` / 接發:${currentReceiver}` : ''}
</small> </small>
) : hasInitialServing ? ( ) : hasInitialServing ? (
<small></small> <small></small>
) : ( ) : (
<small></small> <small></small>
)} )}
</button> </button>
) )
@@ -983,7 +995,7 @@ function VoiceSettingsModal({
<h3></h3> <h3></h3>
<label className="voice-setting-row"> <label className="voice-setting-row">
<span></span> <span></span>
<input <input
checked={settings.announceScore} checked={settings.announceScore}
type="checkbox" type="checkbox"
@@ -1112,13 +1124,13 @@ function FinishDialog({
) )
} }
function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) { function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) {
if (teamSlot === 'top') { if (teamSlot === 'top') {
return court === 'left' ? 1 : 2 return court === 'left' ? 1 : 2
} }
return court === 'right' ? 3 : 4 return court === 'right' ? 3 : 4
} }
function sanitizeTargetScore(value: string) { function sanitizeTargetScore(value: string) {
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10) const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
@@ -1188,9 +1200,9 @@ function loadVoiceSettings(): VoiceSettings {
} }
} }
function getSpeechName(name: string) { function getSpeechName(name: string) {
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
} }
function speakAnnouncement(message: string, rate: number) { function speakAnnouncement(message: string, rate: number) {
if (!('speechSynthesis' in window)) { if (!('speechSynthesis' in window)) {