功能:首頁日期預設當天、記分板語音改為報比分與發球區
摘要: - 首頁「指定日期」每次進入都預設為當天 - 記分板語音得分播報改為「比分(發球方先)+ 發球者左右發球區」,賽末點獲勝時整段改播「贏得比賽」 根本原因: - 日期原本從 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:
+306
-296
@@ -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<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: '大殺特殺',
|
||||
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<StreakAnnouncement | null>(null)
|
||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
|
||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||
const currentAppVersionRef = useRef<string | null>(null)
|
||||
const [streakAnnouncement, setStreakAnnouncement] = useState<StreakAnnouncement | null>(null)
|
||||
const [victoryAnnouncement, setVictoryAnnouncement] = useState<VictoryAnnouncement | null>(null)
|
||||
const [voiceAnnouncement, setVoiceAnnouncement] = useState<VoiceAnnouncement | null>(null)
|
||||
const [pwaUpdateReady, setPwaUpdateReady] = useState(false)
|
||||
const [liveRoomSession, setLiveRoomSession] = useState<LiveRoomSession | null>(null)
|
||||
const [navigationLockMessage, setNavigationLockMessage] = useState('')
|
||||
const currentAppVersionRef = useRef<string | null>(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<HTMLAnchorElement>) => {
|
||||
if (!isNavigationLocked || targetPath === '/scoreboard') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setNavigationLockMessage('比賽進行中,請先完成結算。')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}>
|
||||
}
|
||||
}
|
||||
|
||||
const handleNavAttempt = (targetPath: string) => (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!isNavigationLocked || targetPath === '/scoreboard') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
setNavigationLockMessage('比賽進行中,請先完成結算。')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={isScoreboardRoute ? 'app-shell app-shell-scoreboard app-shell-scoreboard-fit' : 'app-shell'}>
|
||||
<header className={isScoreboardRoute ? 'topbar topbar-compact' : 'topbar'}>
|
||||
<div className="branding">
|
||||
<p className="eyebrow">Badminton Scoreboard</p>
|
||||
@@ -930,32 +940,32 @@ function App() {
|
||||
</div>
|
||||
|
||||
<nav className="topnav" aria-label="主要導覽">
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/teams')}
|
||||
to="/teams"
|
||||
>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/teams')}
|
||||
to="/teams"
|
||||
>
|
||||
選隊伍
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/scoreboard')}
|
||||
to="/scoreboard"
|
||||
>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/scoreboard')}
|
||||
to="/scoreboard"
|
||||
>
|
||||
記分板
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/history')}
|
||||
to="/history"
|
||||
>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/history')}
|
||||
to="/history"
|
||||
>
|
||||
歷史戰績
|
||||
</NavLink>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/rooms')}
|
||||
to="/rooms"
|
||||
>
|
||||
<NavLink
|
||||
className={({ isActive }) => (isActive ? 'nav-pill nav-pill-active' : 'nav-pill')}
|
||||
onClick={handleNavAttempt('/rooms')}
|
||||
to="/rooms"
|
||||
>
|
||||
房間列表
|
||||
</NavLink>
|
||||
</nav>
|
||||
@@ -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() {
|
||||
/>
|
||||
</Routes>
|
||||
|
||||
{pwaUpdateReady ? (
|
||||
<div className="pwa-update-toast" role="status" aria-live="polite">
|
||||
{pwaUpdateReady ? (
|
||||
<div className="pwa-update-toast" role="status" aria-live="polite">
|
||||
<div className="pwa-update-copy">
|
||||
<strong>有新版本可更新</strong>
|
||||
<span>點重新整理後套用最新版本。</span>
|
||||
@@ -1051,17 +1061,17 @@ function App() {
|
||||
<button className="pwa-update-button" onClick={refreshForPwaUpdate} type="button">
|
||||
重新整理
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{navigationLockMessage ? (
|
||||
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||
{navigationLockMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{navigationLockMessage ? (
|
||||
<div className="floating-status-bubble" role="status" aria-live="polite">
|
||||
{navigationLockMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 })
|
||||
|
||||
+167
-155
@@ -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<VoiceSettings>(() =>
|
||||
loadVoiceSettings(),
|
||||
)
|
||||
const finishHoldFrameRef = useRef<number | null>(null)
|
||||
const finishHoldTimerRef = useRef<number | null>(null)
|
||||
const finishHoldStartRef = useRef(0)
|
||||
const finishTriggeredRef = useRef(false)
|
||||
const finishHoldFrameRef = useRef<number | null>(null)
|
||||
const finishHoldTimerRef = useRef<number | null>(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({
|
||||
|
||||
<section className="scoreboard-screen">
|
||||
<div className="scoreboard-court">
|
||||
<ScoreboardTeamPanel
|
||||
assignments={leftAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
canSetServing={canArrangeMatch}
|
||||
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
|
||||
hasInitialServing={scoreState.initialServing === 'left'}
|
||||
onRecordPoint={() => 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"
|
||||
<ScoreboardTeamPanel
|
||||
assignments={leftAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
canSetServing={canArrangeMatch}
|
||||
currentReceiver={scoreState.serving === 'right' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'left' ? currentServer?.name ?? null : null}
|
||||
hasInitialServing={scoreState.initialServing === 'left'}
|
||||
onRecordPoint={() => 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"
|
||||
/>
|
||||
|
||||
<div className="scoreboard-center-banner">
|
||||
@@ -492,23 +504,23 @@ export function ScoreboardPage({
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<ScoreboardTeamPanel
|
||||
assignments={rightAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
canSetServing={canArrangeMatch}
|
||||
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
|
||||
hasInitialServing={scoreState.initialServing === 'right'}
|
||||
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"
|
||||
<ScoreboardTeamPanel
|
||||
assignments={rightAssignments}
|
||||
canArrangeMatch={canArrangeMatch}
|
||||
canScore={canScore}
|
||||
canSetServing={canArrangeMatch}
|
||||
currentReceiver={scoreState.serving === 'left' ? currentReceiver?.name ?? null : null}
|
||||
currentServer={scoreState.serving === 'right' ? currentServer?.name ?? null : null}
|
||||
hasInitialServing={scoreState.initialServing === 'right'}
|
||||
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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span>
|
||||
<span className="team-number">{getPlayerNumber(teamSlot, assignment.court)}</span>
|
||||
<strong>{assignment.name}</strong>
|
||||
</div>
|
||||
))}
|
||||
@@ -718,31 +730,31 @@ function ScoreboardTeamPanel({
|
||||
</div>
|
||||
)
|
||||
|
||||
const serveBar = (
|
||||
<button
|
||||
className={
|
||||
hasInitialServing && !canSetServing
|
||||
? 'serve-lane serve-lane-locked'
|
||||
: showServingPrompt
|
||||
? 'serve-lane serve-lane-prompt'
|
||||
: 'serve-lane'
|
||||
}
|
||||
disabled={!canSetServing || !team}
|
||||
type="button"
|
||||
onClick={onSetServing}
|
||||
>
|
||||
<span className={hasInitialServing ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
|
||||
const serveBar = (
|
||||
<button
|
||||
className={
|
||||
hasInitialServing && !canSetServing
|
||||
? 'serve-lane serve-lane-locked'
|
||||
: showServingPrompt
|
||||
? 'serve-lane serve-lane-prompt'
|
||||
: 'serve-lane'
|
||||
}
|
||||
disabled={!canSetServing || !team}
|
||||
type="button"
|
||||
onClick={onSetServing}
|
||||
>
|
||||
<span className={hasInitialServing ? 'serve-lane-box serve-lane-box-checked' : 'serve-lane-box'} />
|
||||
<span>先攻</span>
|
||||
{currentServer ? (
|
||||
<small>
|
||||
發球區:{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
|
||||
</small>
|
||||
) : hasInitialServing ? (
|
||||
<small>本局先攻</small>
|
||||
) : (
|
||||
<small>點擊設定這一隊先攻</small>
|
||||
)}
|
||||
{currentServer ? (
|
||||
<small>
|
||||
發球區:{serviceCourt === 'left' ? '左' : serviceCourt === 'right' ? '右' : '-'}
|
||||
{currentReceiver ? ` / 接發:${currentReceiver}` : ''}
|
||||
</small>
|
||||
) : hasInitialServing ? (
|
||||
<small>本局先攻</small>
|
||||
) : (
|
||||
<small>點擊設定這一隊先攻</small>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -983,7 +995,7 @@ function VoiceSettingsModal({
|
||||
<h3>播報內容</h3>
|
||||
|
||||
<label className="voice-setting-row">
|
||||
<span>播報誰得分</span>
|
||||
<span>播報比分</span>
|
||||
<input
|
||||
checked={settings.announceScore}
|
||||
type="checkbox"
|
||||
@@ -1112,13 +1124,13 @@ function FinishDialog({
|
||||
)
|
||||
}
|
||||
|
||||
function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) {
|
||||
if (teamSlot === 'top') {
|
||||
return court === 'left' ? 1 : 2
|
||||
}
|
||||
|
||||
return court === 'right' ? 3 : 4
|
||||
}
|
||||
function getPlayerNumber(teamSlot: 'top' | 'bottom', court: CourtSide) {
|
||||
if (teamSlot === 'top') {
|
||||
return court === 'left' ? 1 : 2
|
||||
}
|
||||
|
||||
return court === 'right' ? 3 : 4
|
||||
}
|
||||
|
||||
function sanitizeTargetScore(value: string) {
|
||||
const numeric = Number.parseInt(value.replace(/[^\d]/g, ''), 10)
|
||||
@@ -1188,9 +1200,9 @@ function loadVoiceSettings(): VoiceSettings {
|
||||
}
|
||||
}
|
||||
|
||||
function getSpeechName(name: string) {
|
||||
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
|
||||
}
|
||||
function getSpeechName(name: string) {
|
||||
return SPEECH_NAME_MAP[name.trim().toLowerCase()] ?? name
|
||||
}
|
||||
|
||||
function speakAnnouncement(message: string, rate: number) {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
|
||||
Reference in New Issue
Block a user