mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
feat(live): add team radio ticker
Subscribe to TeamRadio on the F1 live feed, parse defensive capture snapshots and patches, and relay capped captures through live snapshots with the SessionInfo static path. Add the frontend CDN URL helper and ticker using one shared audio element.
This commit is contained in:
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
108
frontend/src/components/live/TeamRadioTicker.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Pause, Play } from 'lucide-react'
|
||||
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../../types'
|
||||
import { radioCaptureKey, radioClipUrl } from '../../lib/radio'
|
||||
import { teamColor } from '../../utils'
|
||||
import '../../styles/team-radio.css'
|
||||
|
||||
interface Props {
|
||||
captures?: LiveRadioCapture[]
|
||||
driverInfo?: Record<string, LiveDriverInfo>
|
||||
session?: LiveSessionMeta
|
||||
}
|
||||
|
||||
function relativeTime(utc: string): string {
|
||||
const timestamp = new Date(utc).getTime()
|
||||
if (!utc || Number.isNaN(timestamp)) return '--'
|
||||
|
||||
const diffSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000))
|
||||
if (diffSeconds < 60) return `${diffSeconds}s ago`
|
||||
const minutes = Math.floor(diffSeconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return new Date(utc).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function driverLabel(info: LiveDriverInfo | undefined, racingNumber: string): string {
|
||||
return info?.Tla || racingNumber
|
||||
}
|
||||
|
||||
export function TeamRadioTicker({ captures = [], driverInfo = {}, session }: Props) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingKey, setPlayingKey] = useState<string | null>(null)
|
||||
|
||||
const newestFirst = useMemo(() => [...captures].reverse(), [captures])
|
||||
|
||||
const toggleCapture = (capture: LiveRadioCapture) => {
|
||||
const key = radioCaptureKey(capture)
|
||||
const audio = audioRef.current
|
||||
const url = radioClipUrl(session, capture)
|
||||
if (!audio || !url) return
|
||||
|
||||
if (playingKey === key) {
|
||||
audio.pause()
|
||||
setPlayingKey(null)
|
||||
return
|
||||
}
|
||||
|
||||
audio.pause()
|
||||
audio.src = url
|
||||
setPlayingKey(key)
|
||||
const play = audio.play()
|
||||
if (play) {
|
||||
play.catch(() => setPlayingKey(null))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="team-radio panel-glass" data-testid="team-radio-ticker">
|
||||
<div className="sec-header sticky-header">
|
||||
<span className="sec-title">Team Radio</span>
|
||||
{captures.length > 0 && <span className="sec-meta">{captures.length} clips</span>}
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
className="team-radio-audio"
|
||||
onEnded={() => setPlayingKey(null)}
|
||||
/>
|
||||
|
||||
{newestFirst.length === 0 ? (
|
||||
<div className="missing-notice">No team radio clips in the current live snapshot.</div>
|
||||
) : (
|
||||
<div className="team-radio-list">
|
||||
{newestFirst.map((capture) => {
|
||||
const key = radioCaptureKey(capture)
|
||||
const info = driverInfo[capture.RacingNumber]
|
||||
const label = driverLabel(info, capture.RacingNumber)
|
||||
const isPlaying = playingKey === key
|
||||
const url = radioClipUrl(session, capture)
|
||||
|
||||
return (
|
||||
<div className="team-radio-row" key={key}>
|
||||
<button
|
||||
className="team-radio-play"
|
||||
type="button"
|
||||
aria-label={`${isPlaying ? 'Pause' : 'Play'} ${label} radio`}
|
||||
disabled={!url}
|
||||
onClick={() => toggleCapture(capture)}
|
||||
>
|
||||
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<span
|
||||
className="team-radio-driver"
|
||||
style={{ borderColor: teamColor(info?.TeamColour) }}
|
||||
title={info?.TeamName || undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="team-radio-time">{relativeTime(capture.Utc)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
22
frontend/src/lib/radio.ts
Normal file
22
frontend/src/lib/radio.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { LiveRadioCapture, LiveSessionMeta } from '../types'
|
||||
|
||||
export const LIVE_TIMING_STATIC_BASE = 'https://livetiming.formula1.com/static/'
|
||||
|
||||
export function radioClipUrl(
|
||||
session: Pick<LiveSessionMeta, 'Path'> | null | undefined,
|
||||
capture: Pick<LiveRadioCapture, 'Path'> | null | undefined,
|
||||
): string {
|
||||
const sessionPath = session?.Path?.trim()
|
||||
const capturePath = capture?.Path?.trim()
|
||||
if (!sessionPath || !capturePath) return ''
|
||||
if (/^https?:\/\//i.test(capturePath)) return capturePath
|
||||
|
||||
const base = LIVE_TIMING_STATIC_BASE.replace(/\/+$/, '')
|
||||
const normalizedSession = sessionPath.replace(/^\/+|\/+$/g, '')
|
||||
const normalizedCapture = capturePath.replace(/^\/+/g, '')
|
||||
return `${base}/${normalizedSession}/${normalizedCapture}`
|
||||
}
|
||||
|
||||
export function radioCaptureKey(capture: LiveRadioCapture): string {
|
||||
return `${capture.Utc}-${capture.RacingNumber}-${capture.Path}`
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { TimingTower } from '../components/live/TimingTower'
|
||||
import { BattleChips } from '../components/live/BattleChips'
|
||||
import { PinnedDrivers } from '../components/live/PinnedDrivers'
|
||||
import { RaceControlFeed } from '../components/live/RaceControlFeed'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { TrackMap } from '../components/live/TrackMap'
|
||||
import { Radio } from 'lucide-react'
|
||||
|
||||
@@ -211,6 +212,11 @@ export function LiveTimingPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="live-rc-col">
|
||||
<TeamRadioTicker
|
||||
captures={snapshot.TeamRadio ?? []}
|
||||
driverInfo={snapshot.DriverInfo}
|
||||
session={snapshot.Session}
|
||||
/>
|
||||
<RaceControlFeed messages={snapshot.RCMessages ?? []} driverInfo={snapshot.DriverInfo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
82
frontend/src/styles/team-radio.css
Normal file
82
frontend/src/styles/team-radio.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.team-radio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-radio-audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.team-radio-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.team-radio-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(44px, 56px) minmax(68px, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.team-radio-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.team-radio-play {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
color: var(--text-1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-radio-play:hover:not(:disabled),
|
||||
.team-radio-play:focus-visible:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.36);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.team-radio-play:disabled {
|
||||
color: var(--text-3);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.team-radio-driver {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 24px;
|
||||
border-left: 3px solid var(--text-3);
|
||||
color: var(--text-1);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.team-radio-time {
|
||||
min-width: 0;
|
||||
color: var(--text-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -201,6 +201,7 @@ describe('TimingTower', () => {
|
||||
CircuitName: 'Silverstone',
|
||||
SessionType: 'Sprint Qualifying',
|
||||
SessionName: 'Sprint Qualifying',
|
||||
Path: '',
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal file
87
frontend/src/test/TeamRadioTicker.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { TeamRadioTicker } from '../components/live/TeamRadioTicker'
|
||||
import { radioClipUrl } from '../lib/radio'
|
||||
import type { LiveDriverInfo, LiveRadioCapture, LiveSessionMeta } from '../types'
|
||||
|
||||
const session: LiveSessionMeta = {
|
||||
MeetingName: 'British Grand Prix',
|
||||
CircuitName: 'Silverstone',
|
||||
SessionType: 'Race',
|
||||
SessionName: 'Race',
|
||||
Path: '/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/',
|
||||
}
|
||||
|
||||
const driverInfo: Record<string, LiveDriverInfo> = {
|
||||
'4': {
|
||||
RacingNumber: '4',
|
||||
BroadcastName: 'L NORRIS',
|
||||
Tla: 'NOR',
|
||||
TeamName: 'McLaren',
|
||||
TeamColour: 'ff8000',
|
||||
FirstName: 'Lando',
|
||||
LastName: 'Norris',
|
||||
},
|
||||
'16': {
|
||||
RacingNumber: '16',
|
||||
BroadcastName: 'C LECLERC',
|
||||
Tla: 'LEC',
|
||||
TeamName: 'Ferrari',
|
||||
TeamColour: 'e8002d',
|
||||
FirstName: 'Charles',
|
||||
LastName: 'Leclerc',
|
||||
},
|
||||
}
|
||||
|
||||
const captures: LiveRadioCapture[] = [
|
||||
{ Utc: '2026-07-05T14:05:00Z', RacingNumber: '16', Path: '/TeamRadio/LEC-1.mp3' },
|
||||
{ Utc: '2026-07-05T14:07:00Z', RacingNumber: '4', Path: 'TeamRadio/NOR-1.mp3' },
|
||||
]
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('radioClipUrl', () => {
|
||||
it('builds static CDN URLs from the session path and capture path', () => {
|
||||
expect(radioClipUrl(session, captures[0])).toBe(
|
||||
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/LEC-1.mp3',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty URL without path data', () => {
|
||||
expect(radioClipUrl({ ...session, Path: '' }, captures[0])).toBe('')
|
||||
expect(radioClipUrl(session, { ...captures[0], Path: '' })).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TeamRadioTicker', () => {
|
||||
it('renders captures newest first with driver labels', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-05T14:10:00Z'))
|
||||
|
||||
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
|
||||
|
||||
const rows = screen.getAllByRole('button')
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(screen.getByText('NOR')).toBeInTheDocument()
|
||||
expect(screen.getByText('LEC')).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/m ago/).map((node) => node.textContent)).toEqual(['3m ago', '5m ago'])
|
||||
})
|
||||
|
||||
it('uses one audio element and toggles play/pause per clip', () => {
|
||||
const play = vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
const pause = vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
|
||||
|
||||
render(<TeamRadioTicker captures={captures} driverInfo={driverInfo} session={session} />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Play NOR radio'))
|
||||
expect(play).toHaveBeenCalledTimes(1)
|
||||
expect(document.querySelectorAll('audio')).toHaveLength(1)
|
||||
expect(document.querySelector('audio')?.getAttribute('src')).toBe(
|
||||
'https://livetiming.formula1.com/static/2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/TeamRadio/NOR-1.mp3',
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Pause NOR radio'))
|
||||
expect(pause).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -100,7 +100,8 @@ const snapshot: LiveStreamData = {
|
||||
{ Time: '14:08', Category: 'Drs', Flag: '', Message: 'DRS ENABLED', Lap: 3 },
|
||||
],
|
||||
Weather: { AirTemp: 20, TrackTemp: 31, Humidity: 55, WindSpeed: 2, WindDir: 180, Rainfall: false },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
Session: { MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '2026/Monaco/Race' },
|
||||
TeamRadio: [],
|
||||
TrackStatus: '1',
|
||||
CurrentLap: 21,
|
||||
TotalLaps: 78,
|
||||
@@ -220,7 +221,7 @@ describe('track status mapping', () => {
|
||||
describe('live qualifying display', () => {
|
||||
it('puts the SQ1 cutoff after P17 for a 22-car sprint qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying' },
|
||||
{ MeetingName: 'British Grand Prix', CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', Path: '' },
|
||||
rows(22),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('SQ1')
|
||||
@@ -232,7 +233,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('keeps the normal Q1 cutoff after P15 for a 20-car qualifying session', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q1')
|
||||
@@ -241,7 +242,7 @@ describe('live qualifying display', () => {
|
||||
|
||||
it('moves phase 2 cutoff after P10 once five cars are knocked out', () => {
|
||||
const display = liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Qualifying', Path: '' },
|
||||
rows(20, 5),
|
||||
)
|
||||
expect(display.phaseLabel).toBe('Q2')
|
||||
@@ -251,13 +252,13 @@ describe('live qualifying display', () => {
|
||||
it('shows no cutoff for race sessions or Q3', () => {
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Race', SessionName: 'Race', Path: '' },
|
||||
rows(20),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
expect(
|
||||
liveSessionDisplay(
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3' },
|
||||
{ MeetingName: 'Monaco Grand Prix', CircuitName: 'Monaco', SessionType: 'Qualifying', SessionName: 'Q3', Path: '' },
|
||||
rows(10),
|
||||
).cutoffPosition,
|
||||
).toBeNull()
|
||||
|
||||
@@ -261,6 +261,7 @@ export interface LiveSessionMeta {
|
||||
CircuitName: string
|
||||
SessionType: string
|
||||
SessionName: string
|
||||
Path: string
|
||||
}
|
||||
|
||||
export interface LiveStintData {
|
||||
@@ -269,6 +270,12 @@ export interface LiveStintData {
|
||||
Laps: number
|
||||
}
|
||||
|
||||
export interface LiveRadioCapture {
|
||||
Utc: string
|
||||
RacingNumber: string
|
||||
Path: string
|
||||
}
|
||||
|
||||
export interface LiveStreamData {
|
||||
Drivers: Record<string, LiveDriverData>
|
||||
DriverInfo: Record<string, LiveDriverInfo>
|
||||
@@ -277,6 +284,7 @@ export interface LiveStreamData {
|
||||
RCMessages: LiveRCMessage[]
|
||||
Weather: LiveWeatherData
|
||||
Session: LiveSessionMeta
|
||||
TeamRadio: LiveRadioCapture[]
|
||||
TrackStatus: string
|
||||
CurrentLap: number
|
||||
TotalLaps: number
|
||||
|
||||
Reference in New Issue
Block a user