From 456dcc56140637d9bdb5dc0a090b71ccac529786 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 30 Jul 2025 22:38:54 -0400
Subject: [PATCH 01/17] feat: add initial implementation of broadcasts
---
src/api/lichess/broadcasts.ts | 295 +++++++++++
src/api/lichess/index.ts | 1 +
src/components/Analysis/BroadcastAnalysis.tsx | 469 ++++++++++++++++++
src/components/Analysis/BroadcastGameList.tsx | 219 ++++++++
src/components/Analysis/index.ts | 2 +
src/hooks/index.ts | 1 +
src/hooks/useBroadcastController.ts | 411 +++++++++++++++
.../broadcast/[broadcastId]/[roundId].tsx | 271 ++++++++++
src/pages/broadcast/index.tsx | 290 +++++++++++
src/types/broadcast/index.ts | 92 ++++
src/types/index.ts | 1 +
11 files changed, 2052 insertions(+)
create mode 100644 src/api/lichess/broadcasts.ts
create mode 100644 src/components/Analysis/BroadcastAnalysis.tsx
create mode 100644 src/components/Analysis/BroadcastGameList.tsx
create mode 100644 src/hooks/useBroadcastController.ts
create mode 100644 src/pages/broadcast/[broadcastId]/[roundId].tsx
create mode 100644 src/pages/broadcast/index.tsx
create mode 100644 src/types/broadcast/index.ts
diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts
new file mode 100644
index 00000000..daba0f46
--- /dev/null
+++ b/src/api/lichess/broadcasts.ts
@@ -0,0 +1,295 @@
+import { Broadcast, BroadcastGame, PGNParseResult } from 'src/types'
+
+const readStream = (processLine: (data: any) => void) => (response: any) => {
+ const stream = response.body.getReader()
+ const matcher = /\r?\n/
+ const decoder = new TextDecoder()
+ let buf = ''
+
+ const loop = () =>
+ stream.read().then(({ done, value }: { done: boolean; value: any }) => {
+ if (done) {
+ if (buf.length > 0) processLine(JSON.parse(buf))
+ } else {
+ const chunk = decoder.decode(value, {
+ stream: true,
+ })
+ buf += chunk
+
+ const parts = (buf || '').split(matcher)
+ buf = parts.pop() as string
+ for (const i of parts.filter((p) => p)) processLine(JSON.parse(i))
+
+ return loop()
+ }
+ })
+
+ return loop()
+}
+
+export const getLichessBroadcasts = async (): Promise => {
+ const response = await fetch('https://lichess.org/api/broadcast', {
+ headers: {
+ Accept: 'application/x-ndjson',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ const broadcasts: Broadcast[] = []
+
+ return new Promise((resolve, reject) => {
+ const onMessage = (message: any) => {
+ try {
+ broadcasts.push(message as Broadcast)
+ } catch (error) {
+ console.error('Error parsing broadcast message:', error)
+ }
+ }
+
+ const onComplete = () => {
+ resolve(broadcasts)
+ }
+
+ readStream(onMessage)(response).then(onComplete).catch(reject)
+ })
+}
+
+export const streamBroadcastRound = async (
+ roundId: string,
+ onPGNUpdate: (pgn: string) => void,
+ onComplete: () => void,
+ abortSignal?: AbortSignal,
+) => {
+ const stream = fetch(
+ `https://lichess.org/api/stream/broadcast/round/${roundId}.pgn`,
+ {
+ signal: abortSignal,
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ },
+ )
+
+ const onMessage = (data: string) => {
+ if (data.trim()) {
+ onPGNUpdate(data)
+ }
+ }
+
+ try {
+ const response = await stream
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ if (!response.body) {
+ throw new Error('No response body')
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+
+ while (true) {
+ const { done, value } = await reader.read()
+
+ if (done) {
+ if (buffer.trim()) {
+ onMessage(buffer)
+ }
+ break
+ }
+
+ const chunk = decoder.decode(value, { stream: true })
+ buffer += chunk
+
+ // Split on double newlines to separate PGN games
+ const parts = buffer.split('\n\n\n')
+ buffer = parts.pop() || ''
+
+ for (const part of parts) {
+ if (part.trim()) {
+ onMessage(part)
+ }
+ }
+ }
+
+ onComplete()
+ } catch (error) {
+ if (abortSignal?.aborted) {
+ console.log('Broadcast stream aborted')
+ } else {
+ console.error('Broadcast stream error:', error)
+ throw error
+ }
+ }
+}
+
+export const parsePGNData = (pgnData: string): PGNParseResult => {
+ const games: BroadcastGame[] = []
+ const errors: string[] = []
+
+ try {
+ // Split the PGN data into individual games
+ const gameStrings = pgnData
+ .split(/\n\n\[Event/)
+ .filter((game) => game.trim())
+
+ for (let i = 0; i < gameStrings.length; i++) {
+ let gameString = gameStrings[i]
+
+ // Add back the [Event header if it was removed by split
+ if (i > 0 && !gameString.startsWith('[Event')) {
+ gameString = '[Event' + gameString
+ }
+
+ try {
+ const game = parseSinglePGN(gameString)
+ if (game) {
+ games.push(game)
+ }
+ } catch (error) {
+ errors.push(`Error parsing game ${i + 1}: ${error}`)
+ }
+ }
+ } catch (error) {
+ errors.push(`Error splitting PGN data: ${error}`)
+ }
+
+ return { games, errors }
+}
+
+const parseSinglePGN = (pgnString: string): BroadcastGame | null => {
+ const lines = pgnString.trim().split('\n')
+ const headers: Record = {}
+ let movesSection = ''
+ let inMoves = false
+
+ // Parse headers and moves
+ for (const line of lines) {
+ const trimmedLine = line.trim()
+
+ if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) {
+ // Parse header
+ const match = trimmedLine.match(/^\[(\w+)\s+"([^"]*)"\]$/)
+ if (match) {
+ headers[match[1]] = match[2]
+ }
+ } else if (trimmedLine && !inMoves) {
+ inMoves = true
+ movesSection = trimmedLine
+ } else if (inMoves && trimmedLine) {
+ movesSection += ' ' + trimmedLine
+ }
+ }
+
+ // Extract essential data
+ const white = headers.White || 'Unknown'
+ const black = headers.Black || 'Unknown'
+ const result = headers.Result || '*'
+ const event = headers.Event || ''
+ const site = headers.Site || ''
+ const date = headers.Date || headers.UTCDate || ''
+ const round = headers.Round || ''
+
+ // Parse moves from moves section
+ const moves = parseMovesFromPGN(movesSection)
+ const fen = extractFENFromMoves(moves)
+
+ const game: BroadcastGame = {
+ id: generateGameId(white, black, event, site),
+ white,
+ black,
+ result,
+ moves,
+ pgn: pgnString,
+ fen: fen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
+ event,
+ site,
+ date,
+ round,
+ eco: headers.ECO,
+ opening: headers.Opening,
+ whiteElo: headers.WhiteElo ? parseInt(headers.WhiteElo) : undefined,
+ blackElo: headers.BlackElo ? parseInt(headers.BlackElo) : undefined,
+ timeControl: headers.TimeControl,
+ termination: headers.Termination,
+ annotator: headers.Annotator,
+ studyName: headers.StudyName,
+ chapterName: headers.ChapterName,
+ utcDate: headers.UTCDate,
+ utcTime: headers.UTCTime,
+ }
+
+ // Extract last move if available
+ if (moves.length > 0) {
+ const lastMove = moves[moves.length - 1]
+ // This would need proper move parsing to convert SAN to UCI
+ // For now, we'll leave it undefined and handle in the controller
+ }
+
+ return game
+}
+
+const parseMovesFromPGN = (movesSection: string): string[] => {
+ const moves: string[] = []
+
+ // Remove comments, variations, and result
+ const cleanMoves = movesSection
+ .replace(/\{[^}]*\}/g, '') // Remove comments
+ .replace(/\([^)]*\)/g, '') // Remove variations
+ .replace(/\s*(1-0|0-1|1\/2-1\/2|\*)\s*$/, '') // Remove result
+ .trim()
+
+ // Split by move numbers and extract moves
+ const tokens = cleanMoves.split(/\s+/)
+
+ for (const token of tokens) {
+ // Skip move numbers (e.g., "1.", "2.", etc.)
+ if (/^\d+\.+$/.test(token)) {
+ continue
+ }
+
+ // Skip empty tokens
+ if (!token.trim()) {
+ continue
+ }
+
+ // Add valid moves
+ if (
+ /^[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8](\=[NBRQ])?[\+\#]?$/.test(token) ||
+ token === 'O-O' ||
+ token === 'O-O-O'
+ ) {
+ moves.push(token)
+ }
+ }
+
+ return moves
+}
+
+const extractFENFromMoves = (moves: string[]): string | null => {
+ // This would require a full chess engine to calculate the FEN from moves
+ // For now, return null and handle in the controller with chess.js
+ return null
+}
+
+const generateGameId = (
+ white: string,
+ black: string,
+ event: string,
+ site: string,
+): string => {
+ const baseString = `${white}-${black}-${event}-${site}`
+ return btoa(baseString)
+ .replace(/[^a-zA-Z0-9]/g, '')
+ .substring(0, 12)
+}
diff --git a/src/api/lichess/index.ts b/src/api/lichess/index.ts
index 7f1dfdc3..024fed9a 100644
--- a/src/api/lichess/index.ts
+++ b/src/api/lichess/index.ts
@@ -1 +1,2 @@
export * from './streaming'
+export * from './broadcasts'
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
new file mode 100644
index 00000000..208fac4e
--- /dev/null
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -0,0 +1,469 @@
+import React, {
+ useMemo,
+ useState,
+ useEffect,
+ useCallback,
+ useContext,
+} from 'react'
+import { motion } from 'framer-motion'
+import type { Key } from 'chessground/types'
+import { Chess, PieceSymbol } from 'chess.ts'
+import type { DrawShape } from 'chessground/draw'
+
+import { WindowSizeContext } from 'src/contexts'
+import { MAIA_MODELS } from 'src/constants/common'
+import { GameInfo } from 'src/components/Common/GameInfo'
+import { GameBoard } from 'src/components/Board/GameBoard'
+import { PlayerInfo } from 'src/components/Common/PlayerInfo'
+import { MovesContainer } from 'src/components/Board/MovesContainer'
+import { LiveGame, GameNode, BroadcastStreamController } from 'src/types'
+import { BoardController } from 'src/components/Board/BoardController'
+import { PromotionOverlay } from 'src/components/Board/PromotionOverlay'
+import { AnalysisSidebar } from 'src/components/Analysis'
+import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens'
+import { BroadcastGameList } from 'src/components/Analysis/BroadcastGameList'
+import { useAnalysisController } from 'src/hooks/useAnalysisController'
+
+interface Props {
+ game: LiveGame
+ broadcastController: BroadcastStreamController & {
+ currentLiveGame: LiveGame | null
+ }
+ analysisController: ReturnType
+}
+
+export const BroadcastAnalysis: React.FC = ({
+ game,
+ broadcastController,
+ analysisController,
+}) => {
+ const { width } = useContext(WindowSizeContext)
+ const isMobile = useMemo(() => width > 0 && width <= 670, [width])
+
+ const [hoverArrow, setHoverArrow] = useState(null)
+ const [currentSquare, setCurrentSquare] = useState(null)
+ const [promotionFromTo, setPromotionFromTo] = useState<
+ [string, string] | null
+ >(null)
+
+ useEffect(() => {
+ setHoverArrow(null)
+ }, [analysisController.currentNode])
+
+ const hover = (move?: string) => {
+ if (move) {
+ setHoverArrow({
+ orig: move.slice(0, 2) as Key,
+ dest: move.slice(2, 4) as Key,
+ brush: 'green',
+ modifiers: {
+ lineWidth: 10,
+ },
+ })
+ } else {
+ setHoverArrow(null)
+ }
+ }
+
+ const makeMove = (move: string) => {
+ if (!analysisController.currentNode || !game.tree) return
+
+ const chess = new Chess(analysisController.currentNode.fen)
+ const moveAttempt = chess.move({
+ from: move.slice(0, 2),
+ to: move.slice(2, 4),
+ promotion: move[4] ? (move[4] as PieceSymbol) : undefined,
+ })
+
+ if (moveAttempt) {
+ const newFen = chess.fen()
+ const moveString =
+ moveAttempt.from +
+ moveAttempt.to +
+ (moveAttempt.promotion ? moveAttempt.promotion : '')
+ const san = moveAttempt.san
+
+ if (analysisController.currentNode.mainChild?.move === moveString) {
+ analysisController.goToNode(analysisController.currentNode.mainChild)
+ } else {
+ const newVariation = game.tree.addVariation(
+ analysisController.currentNode,
+ newFen,
+ moveString,
+ san,
+ analysisController.currentMaiaModel,
+ )
+ analysisController.goToNode(newVariation)
+ }
+ }
+ }
+
+ const onPlayerMakeMove = useCallback(
+ (playedMove: [string, string] | null) => {
+ if (!playedMove) return
+
+ const availableMoves: { from: string; to: string }[] = []
+ for (const [from, tos] of analysisController.availableMoves.entries()) {
+ for (const to of tos as string[]) {
+ availableMoves.push({ from, to })
+ }
+ }
+
+ const matching = availableMoves.filter((m) => {
+ return m.from === playedMove[0] && m.to === playedMove[1]
+ })
+
+ if (matching.length > 1) {
+ setPromotionFromTo(playedMove)
+ return
+ }
+
+ const moveUci = playedMove[0] + playedMove[1]
+ makeMove(moveUci)
+ },
+ [analysisController.availableMoves],
+ )
+
+ const onPlayerSelectPromotion = useCallback(
+ (piece: string) => {
+ if (!promotionFromTo) {
+ return
+ }
+ setPromotionFromTo(null)
+ const moveUci = promotionFromTo[0] + promotionFromTo[1] + piece
+ makeMove(moveUci)
+ },
+ [promotionFromTo, setPromotionFromTo],
+ )
+
+ const launchContinue = useCallback(() => {
+ const fen = analysisController.currentNode?.fen as string
+ const url = '/play' + '?fen=' + encodeURIComponent(fen)
+ window.open(url)
+ }, [analysisController.currentNode])
+
+ const currentPlayer = useMemo(() => {
+ if (!analysisController.currentNode) return 'white'
+ const chess = new Chess(analysisController.currentNode.fen)
+ return chess.turn() === 'w' ? 'white' : 'black'
+ }, [analysisController.currentNode])
+
+ const NestedGameInfo = () => (
+
+
+ {[game.whitePlayer, game.blackPlayer].map((player, index) => (
+
+
+
+
{player.name}
+
+ {player.rating ? <>({player.rating})> : null}
+
+
+ {game.termination?.winner === (index == 0 ? 'white' : 'black') ? (
+
1
+ ) : game.termination?.winner !== 'none' ? (
+
0
+ ) : game.termination === undefined ? (
+ <>>
+ ) : (
+
½
+ )}
+
+ ))}
+
+
+ {broadcastController.currentBroadcast?.tour.name}
+ {broadcastController.currentRound && (
+ <> • {broadcastController.currentRound.name}>
+ )}
+
+
+
+
+
+
+
{game.whitePlayer.name}
+ {game.whitePlayer.rating && (
+
({game.whitePlayer.rating})
+ )}
+
+
+ {broadcastController.broadcastState.isLive && !game.termination ? (
+ LIVE
+ ) : game.termination?.winner === 'none' ? (
+ ½-½
+ ) : (
+
+
+ {game.termination?.winner === 'white' ? '1' : '0'}
+
+ -
+
+ {game.termination?.winner === 'black' ? '1' : '0'}
+
+
+ )}
+
+
+
+
{game.blackPlayer.name}
+ {game.blackPlayer.rating && (
+
({game.blackPlayer.rating})
+ )}
+
+
+
+ )
+
+ const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ duration: 0.2,
+ staggerChildren: 0.05,
+ },
+ },
+ }
+
+ const itemVariants = {
+ hidden: {
+ opacity: 0,
+ y: 4,
+ },
+ visible: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.25,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ exit: {
+ opacity: 0,
+ y: -4,
+ transition: {
+ duration: 0.2,
+ ease: [0.25, 0.46, 0.45, 0.94],
+ type: 'tween',
+ },
+ },
+ }
+
+ const desktopLayout = (
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
{
+ // Analysis toggle not needed for broadcast - always enabled
+ }}
+ itemVariants={itemVariants}
+ />
+
+
+ )
+
+ const mobileLayout = (
+
+
+
+
+
+
+
+
{
+ const baseShapes = [...analysisController.arrows]
+ if (hoverArrow) {
+ baseShapes.push(hoverArrow)
+ }
+ return baseShapes
+ })()}
+ currentNode={analysisController.currentNode as GameNode}
+ orientation={analysisController.orientation}
+ onPlayerMakeMove={onPlayerMakeMove}
+ goToNode={analysisController.goToNode}
+ gameTree={game.tree}
+ />
+ {promotionFromTo ? (
+
+ ) : null}
+
+
+
+
+
+
+ )
+
+ return {isMobile ? mobileLayout : desktopLayout}
+}
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx
new file mode 100644
index 00000000..8101de4d
--- /dev/null
+++ b/src/components/Analysis/BroadcastGameList.tsx
@@ -0,0 +1,219 @@
+import React, { useState, useMemo } from 'react'
+import { motion } from 'framer-motion'
+import { BroadcastStreamController, BroadcastGame } from 'src/types'
+
+interface BroadcastGameListProps {
+ broadcastController: BroadcastStreamController
+ onGameSelected?: () => void
+}
+
+export const BroadcastGameList: React.FC = ({
+ broadcastController,
+ onGameSelected,
+}) => {
+ const [selectedRoundId, setSelectedRoundId] = useState(
+ broadcastController.currentRound?.id || '',
+ )
+
+ const handleRoundChange = (roundId: string) => {
+ setSelectedRoundId(roundId)
+ broadcastController.selectRound(roundId)
+ }
+
+ const handleGameSelect = (game: BroadcastGame) => {
+ broadcastController.selectGame(game.id)
+ onGameSelected?.()
+ }
+
+ const currentGames = useMemo(() => {
+ if (!broadcastController.roundData?.games) return []
+ return Array.from(broadcastController.roundData.games.values())
+ }, [broadcastController.roundData?.games])
+
+ const getGameStatus = (game: BroadcastGame) => {
+ if (game.result === '*') {
+ return { status: 'Live', color: 'text-red-400' }
+ } else if (game.result === '1-0') {
+ return { status: '1-0', color: 'text-primary' }
+ } else if (game.result === '0-1') {
+ return { status: '0-1', color: 'text-primary' }
+ } else if (game.result === '1/2-1/2') {
+ return { status: '½-½', color: 'text-primary' }
+ }
+ return { status: game.result, color: 'text-secondary' }
+ }
+
+ const formatPlayerName = (name: string, elo?: number) => {
+ const displayName = name.length > 12 ? name.substring(0, 12) + '...' : name
+ return elo ? `${displayName} (${elo})` : displayName
+ }
+
+ return (
+
+
+
+
+ {broadcastController.currentBroadcast?.tour.name ||
+ 'Live Broadcast'}
+
+ {broadcastController.broadcastState.isLive && (
+
+ )}
+
+
+ {/* Round Selector */}
+ {broadcastController.currentBroadcast && (
+
+
+
+
+ )}
+
+ {/* Connection Status */}
+ {broadcastController.broadcastState.error && (
+
+
+ Connection Error
+
+
+
+ )}
+
+ {broadcastController.broadcastState.isConnecting && (
+
+ )}
+
+
+
+ {currentGames.length === 0 ? (
+
+
+
+ live_tv
+
+
+ {broadcastController.broadcastState.isConnecting
+ ? 'Loading games...'
+ : 'No games available'}
+
+
+
+ ) : (
+ <>
+ {currentGames.map((game, index) => {
+ const isSelected = broadcastController.currentGame?.id === game.id
+ const gameStatus = getGameStatus(game)
+
+ return (
+
+
+
+
+ )
+ })}
+ >
+ )}
+
+
+ {/* Footer with broadcast info */}
+
+
+ )
+}
diff --git a/src/components/Analysis/index.ts b/src/components/Analysis/index.ts
index fc650a15..6a548c15 100644
--- a/src/components/Analysis/index.ts
+++ b/src/components/Analysis/index.ts
@@ -13,3 +13,5 @@ export * from './AnalysisOverlay'
export * from './InteractiveDescription'
export * from './AnalysisSidebar'
export * from './LearnFromMistakes'
+export * from './BroadcastGameList'
+export * from './BroadcastAnalysis'
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index 11ac3dbb..ed96c7cb 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -1,5 +1,6 @@
export * from './useAnalysisController'
export * from './useBaseTreeController'
+export * from './useBroadcastController'
export * from './useChessSound'
export * from './useLocalStorage'
export * from './useOpeningDrillController'
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
new file mode 100644
index 00000000..d580fb99
--- /dev/null
+++ b/src/hooks/useBroadcastController.ts
@@ -0,0 +1,411 @@
+import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
+import { Chess } from 'chess.ts'
+import { GameTree } from 'src/types/base/tree'
+import { AvailableMoves } from 'src/types/training'
+import {
+ Broadcast,
+ BroadcastRound,
+ BroadcastGame,
+ BroadcastRoundData,
+ BroadcastState,
+ BroadcastStreamController,
+ LiveGame,
+} from 'src/types'
+import {
+ getLichessBroadcasts,
+ streamBroadcastRound,
+ parsePGNData,
+} from 'src/api/lichess/broadcasts'
+
+export const useBroadcastController = (): BroadcastStreamController => {
+ const [broadcasts, setBroadcasts] = useState([])
+ const [currentBroadcast, setCurrentBroadcast] = useState(
+ null,
+ )
+ const [currentRound, setCurrentRound] = useState(null)
+ const [currentGame, setCurrentGame] = useState(null)
+ const [roundData, setRoundData] = useState(null)
+ const [broadcastState, setBroadcastState] = useState({
+ isConnected: false,
+ isConnecting: false,
+ isLive: false,
+ error: null,
+ roundStarted: false,
+ roundEnded: false,
+ gameEnded: false,
+ })
+
+ const abortController = useRef(null)
+ const currentRoundId = useRef(null)
+ const gameStates = useRef
- {broadcastController.broadcasts.length === 0 ? (
+ {broadcastController.broadcastSections.length === 0 ? (
{
) : (
-
- {broadcastController.broadcasts.map((broadcast, index) => {
- const ongoingRounds = broadcast.rounds.filter((r) => r.ongoing)
- const hasOngoingRounds = ongoingRounds.length > 0
-
- return (
+
+ {broadcastController.broadcastSections.map(
+ (section, sectionIndex) => (
-
-
-
-
- {broadcast.tour.name}
-
- {hasOngoingRounds && (
-
- )}
-
-
-
- Tier {broadcast.tour.tier}
-
- {broadcast.tour.dates.length > 0 && (
-
- {formatDate(broadcast.tour.dates[0])}
-
- )}
+
+ {section.title}
+ {(section.type === 'official-active' ||
+ section.type === 'unofficial-active') && (
+
-
+ )}
+
-
-
- Rounds ({broadcast.rounds.length})
-
-
- {broadcast.rounds.slice(0, 3).map((round) => (
-
-
- {round.name}
-
-
+ {section.broadcasts.map((broadcast, index) => {
+ const ongoingRounds = broadcast.rounds.filter(
+ (r) => r.ongoing,
+ )
+ const hasOngoingRounds = ongoingRounds.length > 0
+ const isActive = section.type.includes('active')
+ const isPast = section.type === 'past'
+
+ return (
+
+
+
+
+
+ {broadcast.tour.name}
+
+ {hasOngoingRounds && isActive && (
+
+ )}
+
+
+
+ Tier {broadcast.tour.tier}
+
+ {broadcast.tour.dates.length > 0 && (
+
+ {formatDate(broadcast.tour.dates[0])}
+
+ )}
+
+
+
+
+
+ Rounds ({broadcast.rounds.length})
+
+
+ {broadcast.rounds.slice(0, 3).map((round) => (
+
+
+ {round.name}
+
+
+ {round.ongoing
+ ? 'Live'
+ : isPast
+ ? 'Finished'
+ : 'Upcoming'}
+
+
+ ))}
+ {broadcast.rounds.length > 3 && (
+
+ +{broadcast.rounds.length - 3} more rounds
+
+ )}
+
+
+
+
- ))}
- {broadcast.rounds.length > 3 && (
-
- +{broadcast.rounds.length - 3} more rounds
+ {hasOngoingRounds
+ ? 'Watch Live'
+ : isPast
+ ? 'View Tournament'
+ : 'Coming Soon'}
+
- )}
-
-
-
-
+
+ )
+ })}
- )
- })}
+ ),
+ )}
)}
diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts
index 77f1e916..77dee34e 100644
--- a/src/types/broadcast/index.ts
+++ b/src/types/broadcast/index.ts
@@ -69,12 +69,40 @@ export interface BroadcastState {
gameEnded: boolean
}
-export interface BroadcastStreamController {
+export interface TopBroadcastItem {
+ tour: BroadcastTour
+ round: BroadcastRound
+}
+
+export interface TopBroadcastsResponse {
+ active: TopBroadcastItem[]
+ upcoming: TopBroadcastItem[]
+ past: {
+ currentPage: number
+ maxPerPage: number
+ currentPageResults: TopBroadcastItem[]
+ previousPage: number | null
+ nextPage: number | null
+ }
+}
+
+export interface BroadcastSection {
+ title: string
broadcasts: Broadcast[]
+ type:
+ | 'official-active'
+ | 'unofficial-active'
+ | 'official-upcoming'
+ | 'unofficial-upcoming'
+ | 'past'
+}
+
+export interface BroadcastStreamController {
+ broadcastSections: BroadcastSection[]
currentBroadcast: Broadcast | null
currentRound: BroadcastRound | null
currentGame: BroadcastGame | null
- currentLiveGame: any | null
+ currentLiveGame: unknown | null
roundData: BroadcastRoundData | null
broadcastState: BroadcastState
loadBroadcasts: () => Promise
From 60246292e7ec64ea898073574e8dd452a60a508c Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 30 Jul 2025 23:05:58 -0400
Subject: [PATCH 03/17] feat: support multiple games in rounds
---
src/api/lichess/broadcasts.ts | 20 +++++++
src/components/Analysis/BroadcastAnalysis.tsx | 16 +++++-
src/components/Analysis/BroadcastGameList.tsx | 26 ++++++++-
src/hooks/useBroadcastController.ts | 55 +++++++++++--------
.../broadcast/[broadcastId]/[roundId].tsx | 3 +-
5 files changed, 91 insertions(+), 29 deletions(-)
diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts
index ee5c9483..39e25e61 100644
--- a/src/api/lichess/broadcasts.ts
+++ b/src/api/lichess/broadcasts.ts
@@ -92,6 +92,25 @@ export const convertTopBroadcastToBroadcast = (
}
}
+export const getBroadcastRoundPGN = async (
+ roundId: string,
+): Promise => {
+ const response = await fetch(
+ `https://lichess.org/api/broadcast/round/${roundId}.pgn`,
+ {
+ headers: {
+ Accept: 'application/x-chess-pgn',
+ },
+ },
+ )
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+ }
+
+ return await response.text()
+}
+
export const streamBroadcastRound = async (
roundId: string,
onPGNUpdate: (pgn: string) => void,
@@ -110,6 +129,7 @@ export const streamBroadcastRound = async (
const onMessage = (data: string) => {
if (data.trim()) {
+ console.log('Received PGN data length:', data.length)
onPGNUpdate(data)
}
}
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
index 208fac4e..10b494c6 100644
--- a/src/components/Analysis/BroadcastAnalysis.tsx
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -279,8 +279,20 @@ export const BroadcastAnalysis: React.FC = ({
-
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx
index 8101de4d..0a7120e4 100644
--- a/src/components/Analysis/BroadcastGameList.tsx
+++ b/src/components/Analysis/BroadcastGameList.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from 'react'
+import React, { useState, useMemo, useEffect } from 'react'
import { motion } from 'framer-motion'
import { BroadcastStreamController, BroadcastGame } from 'src/types'
@@ -15,6 +15,16 @@ export const BroadcastGameList: React.FC
= ({
broadcastController.currentRound?.id || '',
)
+ // Sync selectedRoundId when currentRound changes
+ useEffect(() => {
+ if (
+ broadcastController.currentRound?.id &&
+ broadcastController.currentRound.id !== selectedRoundId
+ ) {
+ setSelectedRoundId(broadcastController.currentRound.id)
+ }
+ }, [broadcastController.currentRound?.id, selectedRoundId])
+
const handleRoundChange = (roundId: string) => {
setSelectedRoundId(roundId)
broadcastController.selectRound(roundId)
@@ -26,8 +36,18 @@ export const BroadcastGameList: React.FC = ({
}
const currentGames = useMemo(() => {
- if (!broadcastController.roundData?.games) return []
- return Array.from(broadcastController.roundData.games.values())
+ if (!broadcastController.roundData?.games) {
+ console.log('No round data games available')
+ return []
+ }
+ const games = Array.from(broadcastController.roundData.games.values())
+ console.log(
+ 'BroadcastGameList displaying',
+ games.length,
+ 'games:',
+ games.map((g) => `${g.white} vs ${g.black}`),
+ )
+ return games
}, [broadcastController.roundData?.games])
const getGameStatus = (game: BroadcastGame) => {
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index 38651835..cae1ced7 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -16,6 +16,7 @@ import {
getLichessBroadcasts,
getLichessTopBroadcasts,
convertTopBroadcastToBroadcast,
+ getBroadcastRoundPGN,
streamBroadcastRound,
parsePGNData,
} from 'src/api/lichess/broadcasts'
@@ -311,34 +312,41 @@ export const useBroadcastController = (): BroadcastStreamController => {
}
lastPGNData.current = pgnData
+ console.log('Processing PGN update with length:', pgnData.length)
const parseResult = parsePGNData(pgnData)
+ console.log('Parsed games count:', parseResult.games.length)
+ console.log(
+ 'Game IDs:',
+ parseResult.games.map((g) => `${g.white} vs ${g.black}`),
+ )
if (parseResult.errors.length > 0) {
console.warn('PGN parsing errors:', parseResult.errors)
}
if (parseResult.games.length === 0) {
+ console.warn('No games found in PGN data')
return
}
- // Update round data
- const newGames = new Map()
- const updatedGameStates = new Map()
- let hasNewMoves = false
+ // Determine if this is initial load (multiple games) or update (single game)
+ const isInitialLoad = parseResult.games.length > 1
+ console.log('Is initial load:', isInitialLoad)
- for (const game of parseResult.games) {
- newGames.set(game.id, game)
+ setRoundData((prevRoundData) => {
+ // Start with existing games
+ const existingGames =
+ prevRoundData?.games || new Map()
+ const updatedGames = new Map(existingGames)
- // Check if this game has new moves compared to our stored state
- const existingGameState = gameStates.current.get(game.id)
- const newLiveGame = createLiveGameFromBroadcastGame(game)
+ // Process new/updated games
+ for (const game of parseResult.games) {
+ updatedGames.set(game.id, game)
- if (
- !existingGameState ||
- existingGameState.moves.length !== newLiveGame.moves.length
- ) {
- hasNewMoves = true
+ // Update game states
+ const existingGameState = gameStates.current.get(game.id)
+ const newLiveGame = createLiveGameFromBroadcastGame(game)
// Play sound for new moves if game was already loaded
if (
@@ -352,18 +360,16 @@ export const useBroadcastController = (): BroadcastStreamController => {
.catch((e) => console.log('Could not play move sound:', e))
} catch (e) {}
}
- }
- updatedGameStates.set(game.id, newLiveGame)
- }
+ gameStates.current.set(game.id, newLiveGame)
+ }
- gameStates.current = updatedGameStates
+ console.log('Updated games map, now has', updatedGames.size, 'games')
- setRoundData((prev) => {
const newRoundData: BroadcastRoundData = {
roundId: currentRoundId.current || '',
broadcastId: currentBroadcast?.tour.id || '',
- games: newGames,
+ games: updatedGames,
lastUpdate: Date.now(),
}
return newRoundData
@@ -371,9 +377,11 @@ export const useBroadcastController = (): BroadcastStreamController => {
// Update current game if it exists in the new data
if (currentGame) {
- const updatedCurrentGame = newGames.get(currentGame.id)
- if (updatedCurrentGame) {
- setCurrentGame(updatedCurrentGame)
+ const updatedGame = parseResult.games.find(
+ (g) => g.id === currentGame.id,
+ )
+ if (updatedGame) {
+ setCurrentGame(updatedGame)
}
} else if (parseResult.games.length > 0) {
// Auto-select first game if none selected
@@ -419,6 +427,7 @@ export const useBroadcastController = (): BroadcastStreamController => {
}))
try {
+ // Start streaming - this will send all games initially, then updates
await streamBroadcastRound(
roundId,
handlePGNUpdate,
diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx
index ad04b1f5..ec59c3b9 100644
--- a/src/pages/broadcast/[broadcastId]/[roundId].tsx
+++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx
@@ -51,7 +51,8 @@ const BroadcastAnalysisPage: NextPage = () => {
}
if (!broadcast) {
- throw new Error('Broadcast not found')
+ // throw new Error('Broadcast not found')
+ return
}
// Find the round
From af3ab8d2237e139354ecc693f642e4b9c2f56ad9 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 30 Jul 2025 23:17:53 -0400
Subject: [PATCH 04/17] style: clean up UI
---
src/components/Analysis/BroadcastAnalysis.tsx | 6 ++-
src/components/Analysis/BroadcastGameList.tsx | 53 ++++++-------------
2 files changed, 20 insertions(+), 39 deletions(-)
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
index 10b494c6..55f9ee4a 100644
--- a/src/components/Analysis/BroadcastAnalysis.tsx
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -157,7 +157,9 @@ export const BroadcastAnalysis: React.FC = ({
- {player.name}
+
+ {player.name}
+
{player.rating ? <>({player.rating})> : null}
@@ -174,7 +176,7 @@ export const BroadcastAnalysis: React.FC = ({
))}
-
+
{broadcastController.currentBroadcast?.tour.name}
{broadcastController.currentRound && (
<> • {broadcastController.currentRound.name}>
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx
index 0a7120e4..aa926e1e 100644
--- a/src/components/Analysis/BroadcastGameList.tsx
+++ b/src/components/Analysis/BroadcastGameList.tsx
@@ -70,42 +70,28 @@ export const BroadcastGameList: React.FC = ({
return (
-
-
-
+
+
+
{broadcastController.currentBroadcast?.tour.name ||
'Live Broadcast'}
- {broadcastController.broadcastState.isLive && (
-
- )}
{/* Round Selector */}
{broadcastController.currentBroadcast && (
-
-
-
-
+
)}
{/* Connection Status */}
@@ -133,7 +119,7 @@ export const BroadcastGameList: React.FC
= ({
)}
-
+
{currentGames.length === 0 ? (
@@ -203,13 +189,6 @@ export const BroadcastGameList: React.FC = ({
)}
- {(game.opening || game.eco) && (
-
-
- {game.eco} {game.opening}
-
-
- )}
)
From d66f29e3529a020238d9516deef0a13399e46c3f Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 30 Jul 2025 23:39:09 -0400
Subject: [PATCH 05/17] feat: add clocks to game board
---
src/api/lichess/broadcasts.ts | 139 ++++++++++++++----
src/components/Analysis/BroadcastAnalysis.tsx | 22 +++
src/components/Analysis/BroadcastGameList.tsx | 10 +-
src/hooks/useBroadcastController.ts | 13 --
src/types/broadcast/index.ts | 10 ++
5 files changed, 144 insertions(+), 50 deletions(-)
diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts
index 39e25e61..0482b737 100644
--- a/src/api/lichess/broadcasts.ts
+++ b/src/api/lichess/broadcasts.ts
@@ -1,3 +1,4 @@
+import { Chess } from 'chess.ts'
import {
Broadcast,
BroadcastGame,
@@ -129,7 +130,6 @@ export const streamBroadcastRound = async (
const onMessage = (data: string) => {
if (data.trim()) {
- console.log('Received PGN data length:', data.length)
onPGNUpdate(data)
}
}
@@ -251,10 +251,22 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => {
const date = headers.Date || headers.UTCDate || ''
const round = headers.Round || ''
- // Parse moves from moves section
- const moves = parseMovesFromPGN(movesSection)
+ // Parse moves and clock information from full PGN
+ console.log(`Parsing PGN for ${white} vs ${black}`)
+ const parseResult = parseMovesAndClocksFromPGN(pgnString)
+ const moves = parseResult.moves
+ const { whiteClock, blackClock } = parseResult
const fen = extractFENFromMoves()
+ // Debug clock parsing
+ if (whiteClock || blackClock) {
+ console.log(`Clock data for ${white} vs ${black}:`, {
+ whiteClock,
+ blackClock,
+ movesSection: movesSection.substring(0, 200) + '...',
+ })
+ }
+
const game: BroadcastGame = {
id: generateGameId(white, black, event, site),
white,
@@ -278,6 +290,8 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => {
chapterName: headers.ChapterName,
utcDate: headers.UTCDate,
utcTime: headers.UTCTime,
+ whiteClock,
+ blackClock,
}
// Note: Last move extraction would need proper move parsing to convert SAN to UCI
@@ -286,41 +300,110 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => {
return game
}
-const parseMovesFromPGN = (movesSection: string): string[] => {
+const parseMovesAndClocksFromPGN = (
+ pgnString: string,
+): {
+ moves: string[]
+ whiteClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
+ blackClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
+} => {
const moves: string[] = []
+ let whiteClock:
+ | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number }
+ | undefined
+ let blackClock:
+ | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number }
+ | undefined
- // Remove comments, variations, and result
- const cleanMoves = movesSection
- .replace(/\{[^}]*\}/g, '') // Remove comments
- .replace(/\([^)]*\)/g, '') // Remove variations
- .replace(/\s*(1-0|0-1|1\/2-1\/2|\*)\s*$/, '') // Remove result
- .trim()
-
- // Split by move numbers and extract moves
- const tokens = cleanMoves.split(/\s+/)
+ try {
+ // Use chess.js to parse the full PGN
+ const chess = new Chess()
+ const success = chess.loadPgn(pgnString)
+
+ if (!success) {
+ console.warn(
+ 'Failed to parse PGN with chess.js, falling back to manual parsing',
+ )
+ return { moves }
+ }
- for (const token of tokens) {
- // Skip move numbers (e.g., "1.", "2.", etc.)
- if (/^\d+\.+$/.test(token)) {
- continue
+ // Get all moves from the game history
+ const history = chess.history({ verbose: true })
+ for (const move of history) {
+ moves.push(move.san)
}
- // Skip empty tokens
- if (!token.trim()) {
- continue
+ // Get comments which contain clock information
+ const comments = chess.getComments()
+ let lastWhiteClock: any = null
+ let lastBlackClock: any = null
+
+ for (const commentData of comments) {
+ const comment = commentData.comment
+
+ // Extract clock from comment using regex
+ const clockMatch = comment.match(/\[%clk\s+(\d+):(\d+)(?::(\d+))?\]/)
+ if (clockMatch) {
+ const hours = clockMatch[3] ? parseInt(clockMatch[1]) : 0
+ const minutes = clockMatch[3]
+ ? parseInt(clockMatch[2])
+ : parseInt(clockMatch[1])
+ const seconds = clockMatch[3]
+ ? parseInt(clockMatch[3])
+ : parseInt(clockMatch[2])
+
+ const timeInSeconds = hours * 3600 + minutes * 60 + seconds
+ const clockData = {
+ timeInSeconds,
+ isActive: false,
+ lastUpdateTime: Date.now(),
+ }
+
+ // Determine if this is white or black's move based on the FEN
+ const chess_temp = new Chess(commentData.fen)
+ const isWhiteToMove = chess_temp.turn() === 'b' // After white's move, it's black's turn
+
+ if (isWhiteToMove) {
+ lastWhiteClock = clockData
+ } else {
+ lastBlackClock = clockData
+ }
+
+ console.log(
+ `Found clock for ${isWhiteToMove ? 'white' : 'black'}: ${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} = ${timeInSeconds}s`,
+ )
+ }
}
- // Add valid moves
- if (
- /^[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8](\=[NBRQ])?[\+\#]?$/.test(token) ||
- token === 'O-O' ||
- token === 'O-O-O'
- ) {
- moves.push(token)
+ whiteClock = lastWhiteClock
+ blackClock = lastBlackClock
+
+ // Determine which clock is active based on current turn
+ if (moves.length > 0) {
+ const finalPosition = new Chess()
+ finalPosition.loadPgn(pgnString)
+ const isCurrentlyWhiteTurn = finalPosition.turn() === 'w'
+
+ if (whiteClock) {
+ whiteClock.isActive = isCurrentlyWhiteTurn
+ }
+ if (blackClock) {
+ blackClock.isActive = !isCurrentlyWhiteTurn
+ }
}
+ } catch (error) {
+ console.warn('Error parsing PGN with chess.js:', error)
}
- return moves
+ return { moves, whiteClock, blackClock }
}
const extractFENFromMoves = (): string | null => {
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
index 55f9ee4a..a99aea8f 100644
--- a/src/components/Analysis/BroadcastAnalysis.tsx
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -319,6 +319,23 @@ export const BroadcastAnalysis: React.FC = ({
analysisController.orientation === 'white' ? 'black' : 'white'
}
termination={game.termination?.winner}
+ clock={(() => {
+ const clock =
+ analysisController.orientation === 'white'
+ ? broadcastController.currentGame?.blackClock
+ : broadcastController.currentGame?.whiteClock
+ console.log('Top PlayerInfo clock data:', {
+ orientation: analysisController.orientation,
+ currentGame:
+ broadcastController.currentGame?.white +
+ ' vs ' +
+ broadcastController.currentGame?.black,
+ whiteClock: broadcastController.currentGame?.whiteClock,
+ blackClock: broadcastController.currentGame?.blackClock,
+ selectedClock: clock,
+ })
+ return clock
+ })()}
/>
= ({
}
termination={game.termination?.winner}
showArrowLegend={true}
+ clock={
+ analysisController.orientation === 'white'
+ ? broadcastController.currentGame?.whiteClock
+ : broadcastController.currentGame?.blackClock
+ }
/>
= ({
const currentGames = useMemo(() => {
if (!broadcastController.roundData?.games) {
- console.log('No round data games available')
return []
}
- const games = Array.from(broadcastController.roundData.games.values())
- console.log(
- 'BroadcastGameList displaying',
- games.length,
- 'games:',
- games.map((g) => `${g.white} vs ${g.black}`),
- )
- return games
+ return Array.from(broadcastController.roundData.games.values())
}, [broadcastController.roundData?.games])
const getGameStatus = (game: BroadcastGame) => {
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index cae1ced7..f94bcdc0 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -312,28 +312,17 @@ export const useBroadcastController = (): BroadcastStreamController => {
}
lastPGNData.current = pgnData
- console.log('Processing PGN update with length:', pgnData.length)
const parseResult = parsePGNData(pgnData)
- console.log('Parsed games count:', parseResult.games.length)
- console.log(
- 'Game IDs:',
- parseResult.games.map((g) => `${g.white} vs ${g.black}`),
- )
if (parseResult.errors.length > 0) {
console.warn('PGN parsing errors:', parseResult.errors)
}
if (parseResult.games.length === 0) {
- console.warn('No games found in PGN data')
return
}
- // Determine if this is initial load (multiple games) or update (single game)
- const isInitialLoad = parseResult.games.length > 1
- console.log('Is initial load:', isInitialLoad)
-
setRoundData((prevRoundData) => {
// Start with existing games
const existingGames =
@@ -364,8 +353,6 @@ export const useBroadcastController = (): BroadcastStreamController => {
gameStates.current.set(game.id, newLiveGame)
}
- console.log('Updated games map, now has', updatedGames.size, 'games')
-
const newRoundData: BroadcastRoundData = {
roundId: currentRoundId.current || '',
broadcastId: currentBroadcast?.tour.id || '',
diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts
index 77dee34e..9fcdf3bd 100644
--- a/src/types/broadcast/index.ts
+++ b/src/types/broadcast/index.ts
@@ -50,6 +50,16 @@ export interface BroadcastGame {
chapterName?: string
utcDate?: string
utcTime?: string
+ whiteClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
+ blackClock?: {
+ timeInSeconds: number
+ isActive: boolean
+ lastUpdateTime: number
+ }
}
export interface BroadcastRoundData {
From 706714fb791ca8ddc0ad2e121e451ae761a4a7bc Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 30 Jul 2025 23:48:57 -0400
Subject: [PATCH 06/17] fix: don't change games when new moves come in
---
src/hooks/useBroadcastController.ts | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index f94bcdc0..70f56f75 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -337,8 +337,10 @@ export const useBroadcastController = (): BroadcastStreamController => {
const existingGameState = gameStates.current.get(game.id)
const newLiveGame = createLiveGameFromBroadcastGame(game)
- // Play sound for new moves if game was already loaded
+ // Play sound for new moves only if this is the currently selected game
if (
+ currentGame &&
+ game.id === currentGame.id &&
existingGameState &&
newLiveGame.moves.length > existingGameState.moves.length
) {
@@ -362,16 +364,17 @@ export const useBroadcastController = (): BroadcastStreamController => {
return newRoundData
})
- // Update current game if it exists in the new data
+ // Update current game data if it's in the update, but don't switch to a different game
if (currentGame) {
- const updatedGame = parseResult.games.find(
+ const updatedCurrentGame = parseResult.games.find(
(g) => g.id === currentGame.id,
)
- if (updatedGame) {
- setCurrentGame(updatedGame)
+ if (updatedCurrentGame) {
+ // Update the currently selected game with new data (including clocks)
+ setCurrentGame(updatedCurrentGame)
}
} else if (parseResult.games.length > 0) {
- // Auto-select first game if none selected
+ // Auto-select first game only if no game is currently selected
setCurrentGame(parseResult.games[0])
}
From b8f5f4d50399756ed41a806d1fb1fb5ab2f1393b Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Thu, 31 Jul 2025 00:12:26 -0400
Subject: [PATCH 07/17] fix: prevent rebuilding of game node/tree on new move
---
src/hooks/useBroadcastController.ts | 152 +++++++++++++-----
.../broadcast/[broadcastId]/[roundId].tsx | 43 +++--
2 files changed, 142 insertions(+), 53 deletions(-)
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index 70f56f75..aa42ca7e 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -193,6 +193,10 @@ export const useBroadcastController = (): BroadcastStreamController => {
if (roundData) {
const game = roundData.games.get(gameId)
if (game) {
+ console.log(
+ 'Manual game selection:',
+ game.white + ' vs ' + game.black,
+ )
setCurrentGame(game)
}
}
@@ -226,46 +230,99 @@ export const useBroadcastController = (): BroadcastStreamController => {
const startingFen =
'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1'
- // Build game tree from moves
- const tree = new GameTree(startingFen)
- const chess = new Chess(startingFen)
- let currentNode = tree.getRoot()
-
- const gameStates = [
- {
- board: startingFen,
- lastMove: undefined as [string, string] | undefined,
- san: undefined as string | undefined,
- check: false,
- maia_values: {},
- },
- ]
+ // Check if we have an existing game state to build upon
+ const existingLiveGame = gameStates.current.get(broadcastGame.id)
+
+ let tree: GameTree
+ let movesList: any[]
+ let existingMoveCount = 0
+
+ if (existingLiveGame && existingLiveGame.tree) {
+ // Reuse existing tree and states - preserve all analysis and variations
+ tree = existingLiveGame.tree
+ movesList = [...existingLiveGame.moves]
+ existingMoveCount = movesList.length - 1 // Subtract 1 for initial position
+ console.log(
+ `Reusing existing tree with ${existingMoveCount} moves, adding ${broadcastGame.moves.length - existingMoveCount} new moves`,
+ )
+ } else {
+ // Create new tree only for new games
+ tree = new GameTree(startingFen)
+ movesList = [
+ {
+ board: startingFen,
+ lastMove: undefined as [string, string] | undefined,
+ san: undefined as string | undefined,
+ check: false,
+ maia_values: {},
+ },
+ ]
+ console.log(
+ `Creating new tree for ${broadcastGame.white} vs ${broadcastGame.black}`,
+ )
+ }
- // Process each move
- for (const moveStr of broadcastGame.moves) {
- try {
- const move = chess.move(moveStr)
- if (move) {
- const newFen = chess.fen()
- const uci =
- move.from + move.to + (move.promotion ? move.promotion : '')
-
- gameStates.push({
- board: newFen,
- lastMove: [move.from, move.to],
- san: move.san,
- check: chess.inCheck(),
- maia_values: {},
- })
-
- currentNode = tree.addMainMove(currentNode, newFen, uci, move.san)
+ // Only process new moves that we don't already have
+ if (broadcastGame.moves.length > existingMoveCount) {
+ const chess = new Chess(startingFen)
+ let currentNode = tree.getRoot()
+
+ // Replay existing moves to get to the current position
+ for (let i = 0; i < existingMoveCount; i++) {
+ try {
+ const move = chess.move(broadcastGame.moves[i])
+ if (move && currentNode.mainChild) {
+ currentNode = currentNode.mainChild
+ }
+ } catch (error) {
+ console.warn(
+ `Error replaying existing move ${broadcastGame.moves[i]}:`,
+ error,
+ )
+ break
+ }
+ }
+
+ // Add only the new moves
+ for (let i = existingMoveCount; i < broadcastGame.moves.length; i++) {
+ try {
+ const moveStr = broadcastGame.moves[i]
+ const move = chess.move(moveStr)
+ if (move) {
+ const newFen = chess.fen()
+ const uci =
+ move.from + move.to + (move.promotion ? move.promotion : '')
+
+ movesList.push({
+ board: newFen,
+ lastMove: [move.from, move.to],
+ san: move.san,
+ check: chess.inCheck(),
+ maia_values: {},
+ })
+
+ currentNode = tree.addMainMove(currentNode, newFen, uci, move.san)
+ console.log(`Added new move: ${move.san}`)
+ }
+ } catch (error) {
+ console.warn(
+ `Error processing new move ${broadcastGame.moves[i]}:`,
+ error,
+ )
+ break
}
- } catch (error) {
- console.warn(`Error processing move ${moveStr}:`, error)
- break
}
}
+ // Preserve existing availableMoves array (legacy) and extend if needed
+ const availableMoves =
+ existingLiveGame?.availableMoves || new Array(movesList.length).fill({})
+
+ // Extend availableMoves array if we have new moves
+ while (availableMoves.length < movesList.length) {
+ availableMoves.push({})
+ }
+
return {
id: broadcastGame.id,
blackPlayer: {
@@ -278,10 +335,8 @@ export const useBroadcastController = (): BroadcastStreamController => {
},
gameType: 'broadcast',
type: 'stream' as const,
- moves: gameStates,
- availableMoves: new Array(gameStates.length).fill(
- {},
- ) as AvailableMoves[],
+ moves: movesList,
+ availableMoves: availableMoves as AvailableMoves[],
termination:
broadcastGame.result === '*'
? undefined
@@ -294,8 +349,8 @@ export const useBroadcastController = (): BroadcastStreamController => {
? 'black'
: 'none',
},
- maiaEvaluations: [],
- stockfishEvaluations: [],
+ maiaEvaluations: existingLiveGame?.maiaEvaluations || [],
+ stockfishEvaluations: existingLiveGame?.stockfishEvaluations || [],
loadedFen: broadcastGame.fen,
loaded: true,
tree,
@@ -366,15 +421,30 @@ export const useBroadcastController = (): BroadcastStreamController => {
// Update current game data if it's in the update, but don't switch to a different game
if (currentGame) {
+ console.log(
+ 'Current game selected:',
+ currentGame.white + ' vs ' + currentGame.black,
+ )
const updatedCurrentGame = parseResult.games.find(
(g) => g.id === currentGame.id,
)
if (updatedCurrentGame) {
+ console.log('Updating current game with new data')
// Update the currently selected game with new data (including clocks)
setCurrentGame(updatedCurrentGame)
+ } else {
+ console.log(
+ 'Current game not in update - keeping selection unchanged',
+ )
}
+ // Important: Do NOT change game selection if current game is not in the update
} else if (parseResult.games.length > 0) {
// Auto-select first game only if no game is currently selected
+ console.log('No game selected - auto-selecting first game')
+ console.log(
+ 'Auto-selecting:',
+ parseResult.games[0].white + ' vs ' + parseResult.games[0].black,
+ )
setCurrentGame(parseResult.games[0])
}
diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx
index ec59c3b9..31914285 100644
--- a/src/pages/broadcast/[broadcastId]/[roundId].tsx
+++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx
@@ -121,31 +121,45 @@ const BroadcastAnalysisPage: NextPage = () => {
useEffect(() => {
const currentLiveGame = (broadcastController as any).currentLiveGame
- if (currentLiveGame?.tree && analysisController) {
+ if (
+ currentLiveGame?.tree &&
+ analysisController &&
+ analysisController.currentNode
+ ) {
try {
const mainLine = currentLiveGame.tree.getMainLine()
const currentMoveCount = mainLine.length
// If new moves have been added to the game
if (currentMoveCount > lastGameMoveCount.current) {
- lastGameMoveCount.current = currentMoveCount
+ console.log(
+ `New move detected: ${lastGameMoveCount.current} -> ${currentMoveCount}`,
+ )
// Find the last node in the main line
- let lastNode = currentLiveGame.tree.getRoot()
- while (lastNode.mainChild) {
- lastNode = lastNode.mainChild
- }
+ const lastNode = mainLine[mainLine.length - 1]
- // Only auto-follow if user is currently at the previous last node
- if (
- analysisController.currentNode &&
- lastNode.parent === analysisController.currentNode
- ) {
+ // Only auto-follow if user is currently at the previous last node (or close to it)
+ const isAtLatestPosition =
+ lastNode.parent === analysisController.currentNode ||
+ lastNode === analysisController.currentNode
+
+ console.log('Auto-follow check:', {
+ isAtLatestPosition,
+ currentNodeId: analysisController.currentNode.id,
+ lastNodeParentId: lastNode.parent?.id,
+ lastNodeId: lastNode.id,
+ })
+
+ if (isAtLatestPosition) {
+ console.log('Auto-following to new move')
analysisController.setCurrentNode(lastNode)
}
+
+ lastGameMoveCount.current = currentMoveCount
}
} catch (error) {
- console.error('Error setting current node:', error)
+ console.error('Error in auto-follow logic:', error)
}
}
}, [(broadcastController as any).currentLiveGame, analysisController])
@@ -157,6 +171,11 @@ const BroadcastAnalysisPage: NextPage = () => {
const mainLine = currentLiveGame.tree.getMainLine()
if (mainLine.length > 0) {
analysisController.setCurrentNode(mainLine[mainLine.length - 1])
+ // Update the move count tracker for the new game
+ lastGameMoveCount.current = mainLine.length
+ } else {
+ // Reset move count for games with no moves
+ lastGameMoveCount.current = 0
}
}
}, [broadcastController.currentGame?.id])
From bfd4dc48c9a7407cf66ba13b6e8b72162beec6ee Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Thu, 31 Jul 2025 00:17:54 -0400
Subject: [PATCH 08/17] fix: don't change games when new moves come in
---
src/hooks/useBroadcastController.ts | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index aa42ca7e..f16bfbc9 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -378,6 +378,8 @@ export const useBroadcastController = (): BroadcastStreamController => {
return
}
+ let allGamesAfterUpdate: BroadcastGame[] = []
+
setRoundData((prevRoundData) => {
// Start with existing games
const existingGames =
@@ -410,6 +412,9 @@ export const useBroadcastController = (): BroadcastStreamController => {
gameStates.current.set(game.id, newLiveGame)
}
+ // Store all games for auto-selection logic
+ allGamesAfterUpdate = Array.from(updatedGames.values())
+
const newRoundData: BroadcastRoundData = {
roundId: currentRoundId.current || '',
broadcastId: currentBroadcast?.tour.id || '',
@@ -438,14 +443,15 @@ export const useBroadcastController = (): BroadcastStreamController => {
)
}
// Important: Do NOT change game selection if current game is not in the update
- } else if (parseResult.games.length > 0) {
+ } else if (allGamesAfterUpdate.length > 0) {
// Auto-select first game only if no game is currently selected
+ // Use the first game from the complete games list, not just the updated games
console.log('No game selected - auto-selecting first game')
console.log(
'Auto-selecting:',
- parseResult.games[0].white + ' vs ' + parseResult.games[0].black,
+ allGamesAfterUpdate[0].white + ' vs ' + allGamesAfterUpdate[0].black,
)
- setCurrentGame(parseResult.games[0])
+ setCurrentGame(allGamesAfterUpdate[0])
}
// Update broadcast state
From bfeca233a3fcda353398a1d5380e663c11ad9116 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Thu, 31 Jul 2025 00:33:19 -0400
Subject: [PATCH 09/17] style: improve broadcast list page
---
src/hooks/useBroadcastController.ts | 52 +++++++++++++++--------------
src/pages/broadcast/index.tsx | 46 +++++++++++++------------
2 files changed, 51 insertions(+), 47 deletions(-)
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index f16bfbc9..f00f8a07 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -63,7 +63,7 @@ export const useBroadcastController = (): BroadcastStreamController => {
// Organize broadcasts into sections
const sections: BroadcastSection[] = []
- // Official active broadcasts
+ // 1. Official active broadcasts (Lichess official live tournaments)
const officialActive = officialBroadcasts.filter((b) =>
b.rounds.some((r) => r.ongoing),
)
@@ -75,7 +75,23 @@ export const useBroadcastController = (): BroadcastStreamController => {
})
}
- // Top active broadcasts (unofficial)
+ // 2. Official upcoming broadcasts (Lichess official upcoming tournaments) - max 4
+ const officialUpcoming = officialBroadcasts
+ .filter(
+ (b) =>
+ b.rounds.every((r) => !r.ongoing) &&
+ b.rounds.some((r) => r.startsAt > Date.now()),
+ )
+ .slice(0, 4) // Limit to 4
+ if (officialUpcoming.length > 0) {
+ sections.push({
+ title: 'Upcoming Official Tournaments',
+ broadcasts: officialUpcoming,
+ type: 'official-upcoming',
+ })
+ }
+
+ // 3. Community live broadcasts (all live community broadcasts)
const unofficialActive = topBroadcasts.active
.map(convertTopBroadcastToBroadcast)
.filter(
@@ -86,37 +102,23 @@ export const useBroadcastController = (): BroadcastStreamController => {
sections.push({
title: 'Community Live Broadcasts',
broadcasts: unofficialActive,
- type: 'unofficial-active',
- })
- }
-
- // Official upcoming broadcasts
- const officialUpcoming = officialBroadcasts.filter(
- (b) =>
- b.rounds.every((r) => !r.ongoing) &&
- b.rounds.some((r) => r.startsAt > Date.now()),
- )
- if (officialUpcoming.length > 0) {
- sections.push({
- title: 'Upcoming Official Tournaments',
- broadcasts: officialUpcoming,
- type: 'official-upcoming',
+ type: 'community-active',
})
}
- // Top upcoming broadcasts (unofficial)
- const unofficialUpcoming = topBroadcasts.upcoming.map(
- convertTopBroadcastToBroadcast,
- )
+ // 4. Community upcoming broadcasts - max 5
+ const unofficialUpcoming = topBroadcasts.upcoming
+ .map(convertTopBroadcastToBroadcast)
+ .slice(0, 5) // Limit to 5
if (unofficialUpcoming.length > 0) {
sections.push({
title: 'Upcoming Community Broadcasts',
broadcasts: unofficialUpcoming,
- type: 'unofficial-upcoming',
+ type: 'community-upcoming',
})
}
- // Past broadcasts (mix of official and top)
+ // 5. Past tournaments (separate section) - max 8
const officialPast = officialBroadcasts.filter(
(b) =>
b.rounds.every((r) => !r.ongoing) &&
@@ -127,10 +129,10 @@ export const useBroadcastController = (): BroadcastStreamController => {
...topBroadcasts.past.currentPageResults.map(
convertTopBroadcastToBroadcast,
),
- ]
+ ].slice(0, 8) // Limit to 8
if (pastBroadcasts.length > 0) {
sections.push({
- title: 'Recent Tournaments',
+ title: 'Past Tournaments',
broadcasts: pastBroadcasts,
type: 'past',
})
diff --git a/src/pages/broadcast/index.tsx b/src/pages/broadcast/index.tsx
index ff15a3c7..709af530 100644
--- a/src/pages/broadcast/index.tsx
+++ b/src/pages/broadcast/index.tsx
@@ -103,8 +103,8 @@ const BroadcastsPage: NextPage = () => {
Live Broadcasts – Maia Chess
-
-
+
+
Error Loading Broadcasts
@@ -113,7 +113,7 @@ const BroadcastsPage: NextPage = () => {
@@ -133,9 +133,9 @@ const BroadcastsPage: NextPage = () => {
/>
-
+
{
) : (
-
+
{broadcastController.broadcastSections.map(
(section, sectionIndex) => (
{section.title}
{(section.type === 'official-active' ||
- section.type === 'unofficial-active') && (
+ section.type === 'community-active') && (
@@ -192,25 +192,27 @@ const BroadcastsPage: NextPage = () => {
)}
-
+
{section.broadcasts.map((broadcast, index) => {
const ongoingRounds = broadcast.rounds.filter(
(r) => r.ongoing,
)
const hasOngoingRounds = ongoingRounds.length > 0
- const isActive = section.type.includes('active')
+ const isActive =
+ section.type.includes('active') ||
+ section.type.includes('community')
const isPast = section.type === 'past'
return (
-
-
+
+
-
+
{broadcast.tour.name}
{hasOngoingRounds && isActive && (
@@ -234,8 +236,8 @@ const BroadcastsPage: NextPage = () => {
-
-
+
+
Rounds ({broadcast.rounds.length})
@@ -252,7 +254,7 @@ const BroadcastsPage: NextPage = () => {
round.ongoing
? 'bg-red-500/20 text-red-400'
: isPast
- ? 'bg-background-3 text-secondary'
+ ? 'bg-background-2 text-secondary'
: 'bg-blue-500/20 text-blue-400'
}`}
>
@@ -275,12 +277,12 @@ const BroadcastsPage: NextPage = () => {
From 399c3af76db1cc7774e9797d41dd8b758b3dcb00 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Wed, 6 Aug 2025 00:53:44 -0400
Subject: [PATCH 15/17] fix: handle future rounds of broadcasts
---
src/components/Analysis/BroadcastGameList.tsx | 4 +-
src/hooks/useBroadcastController.ts | 40 +++++++++++++++++--
2 files changed, 40 insertions(+), 4 deletions(-)
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx
index 2716769a..5881c1ee 100644
--- a/src/components/Analysis/BroadcastGameList.tsx
+++ b/src/components/Analysis/BroadcastGameList.tsx
@@ -121,7 +121,9 @@ export const BroadcastGameList: React.FC = ({
{broadcastController.broadcastState.isConnecting
? 'Loading games...'
- : 'No games available'}
+ : broadcastController.currentRound?.ongoing
+ ? 'No games available'
+ : 'Round not started yet'}
diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts
index df93cdf7..043ec565 100644
--- a/src/hooks/useBroadcastController.ts
+++ b/src/hooks/useBroadcastController.ts
@@ -587,15 +587,49 @@ export const useBroadcastController = (): BroadcastStreamController => {
error: null,
}))
+ // Set up a timeout to handle rounds with no data (future rounds)
+ const timeoutId = setTimeout(() => {
+ console.log(
+ 'Stream timeout - no data received, likely future/empty round',
+ )
+ if (abortController.current && currentRoundId.current === roundId) {
+ // Set empty round data instead of staying in connecting state
+ setRoundData({
+ roundId: roundId,
+ broadcastId: currentBroadcast?.tour.id || '',
+ games: new Map(),
+ lastUpdate: Date.now(),
+ })
+
+ setBroadcastState({
+ isConnected: true,
+ isConnecting: false,
+ isLive: false,
+ error: null,
+ roundStarted: true,
+ roundEnded: false,
+ gameEnded: false,
+ })
+ }
+ }, 5000) // 5 second timeout
+
try {
// Start streaming - this will send all games initially, then updates
await streamBroadcastRound(
roundId,
- handlePGNUpdate,
- handleStreamComplete,
+ (pgnData) => {
+ // Clear timeout if we receive data
+ clearTimeout(timeoutId)
+ handlePGNUpdate(pgnData)
+ },
+ () => {
+ clearTimeout(timeoutId)
+ handleStreamComplete()
+ },
abortController.current.signal,
)
} catch (error) {
+ clearTimeout(timeoutId)
console.error('Round stream error:', error)
const errorMessage =
@@ -614,7 +648,7 @@ export const useBroadcastController = (): BroadcastStreamController => {
abortController.current = null
}
},
- [handlePGNUpdate, handleStreamComplete],
+ [handlePGNUpdate, handleStreamComplete, currentBroadcast],
)
const reconnect = useCallback(() => {
From 570f73ad2065a99b74a43028134c95838fe39764 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Sat, 9 Aug 2025 01:06:06 -0400
Subject: [PATCH 16/17] feat: add broadcasts to home page
---
src/components/Home/HomeHero.tsx | 2 -
.../Home/LiveChessBoardShowcase.tsx | 205 ++++++++++++++++
src/components/Home/LiveChessShowcase.tsx | 232 ++++++++++++++++++
src/pages/index.tsx | 2 +
4 files changed, 439 insertions(+), 2 deletions(-)
create mode 100644 src/components/Home/LiveChessBoardShowcase.tsx
create mode 100644 src/components/Home/LiveChessShowcase.tsx
diff --git a/src/components/Home/HomeHero.tsx b/src/components/Home/HomeHero.tsx
index 9e2ebb2e..6f471208 100644
--- a/src/components/Home/HomeHero.tsx
+++ b/src/components/Home/HomeHero.tsx
@@ -18,7 +18,6 @@ import { PlayType } from 'src/types'
import { getGlobalStats, getActiveUserCount } from 'src/api'
import { AuthContext, ModalContext } from 'src/contexts'
import { AnimatedNumber } from 'src/components/Common/AnimatedNumber'
-import { LiveChessBoard } from 'src/components/Home/LiveChessBoard'
interface Props {
scrollHandler: () => void
@@ -292,7 +291,6 @@ export const HomeHero: React.FC = ({ scrollHandler }: Props) => {
<>>
)}
-
)
diff --git a/src/components/Home/LiveChessBoardShowcase.tsx b/src/components/Home/LiveChessBoardShowcase.tsx
new file mode 100644
index 00000000..cc98bc6d
--- /dev/null
+++ b/src/components/Home/LiveChessBoardShowcase.tsx
@@ -0,0 +1,205 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react'
+import { useRouter } from 'next/router'
+import { motion } from 'framer-motion'
+import { Chess } from 'chess.ts'
+import Chessground from '@react-chess/chessground'
+import { getLichessTVGame, streamLichessGame } from 'src/api/lichess/streaming'
+import { StreamedGame, StreamedMove } from 'src/types/stream'
+
+interface LiveGameData {
+ gameId: string
+ white?: {
+ user: {
+ id: string
+ name: string
+ }
+ rating?: number
+ }
+ black?: {
+ user: {
+ id: string
+ name: string
+ }
+ rating?: number
+ }
+ currentFen?: string
+ isLive?: boolean
+}
+
+export const LiveChessBoardShowcase: React.FC = () => {
+ const router = useRouter()
+ const [liveGame, setLiveGame] = useState
(null)
+ const [currentFen, setCurrentFen] = useState(
+ 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
+ )
+ const [error, setError] = useState(null)
+ const abortController = useRef(null)
+
+ const handleGameStart = useCallback((gameData: StreamedGame) => {
+ if (gameData.fen) {
+ setCurrentFen(gameData.fen)
+ }
+ setLiveGame({
+ gameId: gameData.id,
+ white: gameData.players?.white,
+ black: gameData.players?.black,
+ currentFen: gameData.fen,
+ isLive: true,
+ })
+ }, [])
+
+ const handleMove = useCallback((moveData: StreamedMove) => {
+ if (moveData.fen) {
+ setCurrentFen(moveData.fen)
+ }
+ }, [])
+
+ const handleStreamComplete = useCallback(() => {
+ console.log('Live board showcase - Stream completed')
+ fetchNewGame()
+ }, [])
+
+ const fetchNewGame = useCallback(async () => {
+ try {
+ setError(null)
+ const tvGame = await getLichessTVGame()
+
+ // Stop current stream if any
+ if (abortController.current) {
+ abortController.current.abort()
+ }
+
+ // Start new stream
+ abortController.current = new AbortController()
+
+ setLiveGame({
+ gameId: tvGame.gameId,
+ white: tvGame.white,
+ black: tvGame.black,
+ isLive: true,
+ })
+
+ streamLichessGame(
+ tvGame.gameId,
+ handleGameStart,
+ handleMove,
+ handleStreamComplete,
+ abortController.current.signal,
+ ).catch((err) => {
+ if (err.name !== 'AbortError') {
+ console.error('Live board streaming error:', err)
+ setError('Connection lost')
+ }
+ })
+ } catch (err) {
+ console.error('Error fetching new live game:', err)
+ setError('Failed to load live game')
+ }
+ }, [handleGameStart, handleMove, handleStreamComplete])
+
+ useEffect(() => {
+ // Initial fetch
+ fetchNewGame()
+
+ // Cleanup on unmount
+ return () => {
+ if (abortController.current) {
+ abortController.current.abort()
+ }
+ }
+ }, []) // Remove fetchNewGame dependency to prevent re-renders
+
+ const handleClick = () => {
+ if (liveGame?.gameId) {
+ router.push(`/analysis/stream/${liveGame.gameId}`)
+ }
+ }
+
+ // Keep FEN only; Chessground renders from FEN directly
+
+ return (
+
+
+ {/* Live indicator */}
+ {liveGame?.isLive && (
+
+ )}
+
+ {/* Chess board */}
+
+
+
+ {/* Player names below the board */}
+ {liveGame && (
+
+
+
+
+
+ {liveGame.white?.user?.name || 'White'}
+
+ {liveGame.white?.rating && (
+
+ ({liveGame.white.rating})
+
+ )}
+
+
vs
+
+
+
+ {liveGame.black?.user?.name || 'Black'}
+
+ {liveGame.black?.rating && (
+
+ ({liveGame.black.rating})
+
+ )}
+
+
+
+ )}
+
+ {error && (
+
+ )}
+
+ )
+}
diff --git a/src/components/Home/LiveChessShowcase.tsx b/src/components/Home/LiveChessShowcase.tsx
new file mode 100644
index 00000000..42fed4f3
--- /dev/null
+++ b/src/components/Home/LiveChessShowcase.tsx
@@ -0,0 +1,232 @@
+import React, { useState, useEffect, useCallback } from 'react'
+import Link from 'next/link'
+import { motion } from 'framer-motion'
+import { LiveChessBoardShowcase } from './LiveChessBoardShowcase'
+import {
+ getLichessBroadcasts,
+ getLichessTopBroadcasts,
+ convertTopBroadcastToBroadcast,
+} from 'src/api/lichess/broadcasts'
+import { Broadcast } from 'src/types'
+
+interface BroadcastWidgetProps {
+ broadcast: Broadcast
+}
+
+const BroadcastWidget: React.FC = ({ broadcast }) => {
+ // Get the first ongoing round, or the first round if none are ongoing
+ const activeRound =
+ broadcast.rounds.find((r) => r.ongoing) || broadcast.rounds[0]
+
+ return (
+
+ {/* Tournament card */}
+
+
+ {/* Header */}
+
+
+ {broadcast.tour.name}
+
+ {activeRound?.ongoing && (
+
+
+
+ LIVE
+
+
+ )}
+
+
+ {/* Body */}
+
+
+
+ {activeRound?.name}
+
+ {/* Placeholder meta; could be expanded when we parse PGN/game counts */}
+
Ongoing round
+
+
+
+ View
+
+ chevron_right
+
+
+
+
+
+
+ {/* Spacing under card */}
+
+
+ )
+}
+
+export const LiveChessShowcase: React.FC = () => {
+ const [topBroadcasts, setTopBroadcasts] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ const fetchBroadcasts = useCallback(async () => {
+ try {
+ setError(null)
+ setIsLoading(true)
+
+ // Load both official and top broadcasts
+ const [officialBroadcasts, topBroadcastsData] = await Promise.all([
+ getLichessBroadcasts(),
+ getLichessTopBroadcasts(),
+ ])
+
+ // Get top ongoing broadcasts with live rounds (official first, then unofficial)
+ const officialActive = officialBroadcasts
+ .filter((b) => b.rounds.some((r) => r.ongoing))
+ .slice(0, 1) // Take top 1 official
+
+ const unofficialActive = topBroadcastsData.active
+ .map(convertTopBroadcastToBroadcast)
+ .filter(
+ (b) =>
+ // Must have ongoing rounds and not be in official list
+ b.rounds.some((r) => r.ongoing) &&
+ !officialActive.some((official) => official.tour.id === b.tour.id),
+ )
+ .slice(0, 1) // Take top 1 unofficial
+
+ const broadcasts = [...officialActive, ...unofficialActive].slice(0, 2)
+ setTopBroadcasts(broadcasts)
+ } catch (err) {
+ console.error('Error fetching broadcasts:', err)
+ setError('Failed to load broadcasts')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ fetchBroadcasts()
+ // Refresh every 10 minutes
+ const interval = setInterval(fetchBroadcasts, 600000)
+ return () => clearInterval(interval)
+ }, [fetchBroadcasts])
+
+ return (
+
+
+
+ {/* Header on the left */}
+
+
Live Chess
+
+ Watch live games and tournaments with real-time Maia AI analysis
+
+
+
+ {/* Live content on the right */}
+
+ {/* Live Lichess TV Game */}
+
+
+ Maia TV
+
+
+
+
+ {/* Top Live Broadcasts */}
+ {isLoading ? (
+
+
+ Live Tournament
+
+
+
+
+ stadia_controller
+
+
+ Loading tournaments...
+
+
+
+
+ ) : error ? (
+
+
+ Live Tournament
+
+
+
+
+ error
+
+
{error}
+
+
+
+
+ ) : topBroadcasts.length > 0 ? (
+
+
+ Live Tournament
+
+
+
+ ) : (
+
+
+ Live Tournament
+
+
+
+
+ stadia_controller
+
+
+ No live tournaments
+
+
+ View all
+
+
+
+
+ )}
+
+
+
+
+ )
+}
diff --git a/src/pages/index.tsx b/src/pages/index.tsx
index e39303f2..26346762 100644
--- a/src/pages/index.tsx
+++ b/src/pages/index.tsx
@@ -12,6 +12,7 @@ import {
AdditionalFeaturesSection,
PageNavigation,
} from 'src/components'
+import { LiveChessShowcase } from 'src/components/Home/LiveChessShowcase'
const Home: NextPage = () => {
const { setPlaySetupModalProps } = useContext(ModalContext)
@@ -40,6 +41,7 @@ const Home: NextPage = () => {
/>
+
From 1601e17ac4a139b63bd6c4a8a7c4fdeb04d31398 Mon Sep 17 00:00:00 2001
From: Kevin Thomas
Date: Sat, 23 Aug 2025 22:53:29 -0700
Subject: [PATCH 17/17] fix: build errors
---
src/components/Analysis/AnalysisSidebar.tsx | 4 +-
src/components/Analysis/BroadcastAnalysis.tsx | 18 +++----
src/components/Board/BoardController.tsx | 2 +-
src/components/Turing/TuringGames.tsx | 8 ++--
src/components/Turing/TuringLog.tsx | 8 ++--
src/contexts/TuringControllerContext.ts | 12 ++---
.../useAnalysisController.ts | 1 -
.../useTreeController/useTreeController.ts | 9 ++--
.../useTuringController.ts | 48 ++++++++++---------
src/pages/turing.tsx | 27 ++++++-----
10 files changed, 70 insertions(+), 67 deletions(-)
diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx
index e84a54a3..b3eacca7 100644
--- a/src/components/Analysis/AnalysisSidebar.tsx
+++ b/src/components/Analysis/AnalysisSidebar.tsx
@@ -144,7 +144,7 @@ export const AnalysisSidebar: React.FC = ({
],
}
}
- currentNode={controller.currentNode}
+ currentNode={controller.currentNode ?? undefined}
/>
@@ -254,7 +254,7 @@ export const AnalysisSidebar: React.FC
= ({
],
}
}
- currentNode={controller.currentNode}
+ currentNode={controller.currentNode ?? undefined}
/>
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx
index 63a7b804..7822b8b3 100644
--- a/src/components/Analysis/BroadcastAnalysis.tsx
+++ b/src/components/Analysis/BroadcastAnalysis.tsx
@@ -86,13 +86,15 @@ export const BroadcastAnalysis: React.FC
= ({
if (analysisController.currentNode.mainChild?.move === moveString) {
analysisController.goToNode(analysisController.currentNode.mainChild)
} else {
- const newVariation = game.tree.addVariation(
- analysisController.currentNode,
- newFen,
- moveString,
- san,
- analysisController.currentMaiaModel,
- )
+ const newVariation = game.tree
+ .getLastMainlineNode()
+ .addChild(
+ newFen,
+ moveString,
+ san,
+ false,
+ analysisController.currentMaiaModel,
+ )
analysisController.goToNode(newVariation)
}
}
@@ -289,7 +291,6 @@ export const BroadcastAnalysis: React.FC = ({
= ({
= ({
const getLast = useCallback(() => {
if (!currentNode) return
-
+
let lastNode = currentNode
while (lastNode?.mainChild) {
lastNode = lastNode.mainChild
diff --git a/src/components/Turing/TuringGames.tsx b/src/components/Turing/TuringGames.tsx
index fd23cdcd..f6f6a74a 100644
--- a/src/components/Turing/TuringGames.tsx
+++ b/src/components/Turing/TuringGames.tsx
@@ -3,17 +3,17 @@ import { useContext } from 'react'
import { TuringControllerContext } from 'src/contexts'
export const TuringGames: React.FC = () => {
- const { gameIds, setCurrentGameId, games } = useContext(
+ const { gameIds, setCurrentIndex, games } = useContext(
TuringControllerContext,
)
return (
- {gameIds.map((id) => {
- const game = games[id]
+ {gameIds.map((id, index) => {
+ const game = games[index]
return (
) : (
gameIds.map((gameId, index) => {
- const game = games[gameId]
- const isCurrentGame = gameId === currentGameId
+ const game = games[index]
+ const isCurrentGame = index === currentIndex
const getStatusInfo = () => {
if (game.result?.correct === true) {
return {
@@ -48,7 +48,7 @@ export const TuringLog: React.FC = () => {
return (