功能:首頁日期預設當天、記分板語音改為報比分與發球區
摘要: - 首頁「指定日期」每次進入都預設為當天 - 記分板語音得分播報改為「比分(發球方先)+ 發球者左右發球區」,賽末點獲勝時整段改播「贏得比賽」 根本原因: - 日期原本從 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 { 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
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user