From 6c72c128217f6ea593af52e082362146793ca022 Mon Sep 17 00:00:00 2001 From: AmanTahiliani Date: Fri, 3 Jul 2026 23:56:19 -0400 Subject: [PATCH] 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. --- .../src/components/live/TeamRadioTicker.tsx | 108 ++++++++++++++++++ frontend/src/lib/radio.ts | 22 ++++ frontend/src/pages/LiveTimingPage.tsx | 6 + frontend/src/styles/team-radio.css | 82 +++++++++++++ frontend/src/test/LiveComponents.test.tsx | 1 + frontend/src/test/TeamRadioTicker.test.tsx | 87 ++++++++++++++ frontend/src/test/live.test.ts | 13 ++- frontend/src/types.ts | 8 ++ internal/live/parser_test.go | 69 ++++++++++- internal/live/signalr.go | 3 +- internal/live/state.go | 72 ++++++++++++ internal/live/types.go | 9 ++ 12 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/live/TeamRadioTicker.tsx create mode 100644 frontend/src/lib/radio.ts create mode 100644 frontend/src/styles/team-radio.css create mode 100644 frontend/src/test/TeamRadioTicker.test.tsx diff --git a/frontend/src/components/live/TeamRadioTicker.tsx b/frontend/src/components/live/TeamRadioTicker.tsx new file mode 100644 index 0000000..459e299 --- /dev/null +++ b/frontend/src/components/live/TeamRadioTicker.tsx @@ -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 + 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(null) + const [playingKey, setPlayingKey] = useState(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 ( +
+
+ Team Radio + {captures.length > 0 && {captures.length} clips} +
+ +
+ ) +} diff --git a/frontend/src/lib/radio.ts b/frontend/src/lib/radio.ts new file mode 100644 index 0000000..6ff21d9 --- /dev/null +++ b/frontend/src/lib/radio.ts @@ -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 | null | undefined, + capture: Pick | 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}` +} diff --git a/frontend/src/pages/LiveTimingPage.tsx b/frontend/src/pages/LiveTimingPage.tsx index fd478eb..a824453 100644 --- a/frontend/src/pages/LiveTimingPage.tsx +++ b/frontend/src/pages/LiveTimingPage.tsx @@ -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() { />
+
diff --git a/frontend/src/styles/team-radio.css b/frontend/src/styles/team-radio.css new file mode 100644 index 0000000..152a405 --- /dev/null +++ b/frontend/src/styles/team-radio.css @@ -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; +} diff --git a/frontend/src/test/LiveComponents.test.tsx b/frontend/src/test/LiveComponents.test.tsx index c3df461..7af18bc 100644 --- a/frontend/src/test/LiveComponents.test.tsx +++ b/frontend/src/test/LiveComponents.test.tsx @@ -201,6 +201,7 @@ describe('TimingTower', () => { CircuitName: 'Silverstone', SessionType: 'Sprint Qualifying', SessionName: 'Sprint Qualifying', + Path: '', }} />, ) diff --git a/frontend/src/test/TeamRadioTicker.test.tsx b/frontend/src/test/TeamRadioTicker.test.tsx new file mode 100644 index 0000000..02569a1 --- /dev/null +++ b/frontend/src/test/TeamRadioTicker.test.tsx @@ -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 = { + '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() + + 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() + + 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() + }) +}) diff --git a/frontend/src/test/live.test.ts b/frontend/src/test/live.test.ts index 28b90ef..68b331b 100644 --- a/frontend/src/test/live.test.ts +++ b/frontend/src/test/live.test.ts @@ -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() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 68e4929..790c7e8 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 DriverInfo: Record @@ -277,6 +284,7 @@ export interface LiveStreamData { RCMessages: LiveRCMessage[] Weather: LiveWeatherData Session: LiveSessionMeta + TeamRadio: LiveRadioCapture[] TrackStatus: string CurrentLap: number TotalLaps: number diff --git a/internal/live/parser_test.go b/internal/live/parser_test.go index 5667a45..4f60166 100644 --- a/internal/live/parser_test.go +++ b/internal/live/parser_test.go @@ -5,6 +5,7 @@ import ( "compress/flate" "encoding/base64" "encoding/json" + "fmt" "testing" "time" @@ -211,12 +212,16 @@ func TestProcessTopicSessionInfoCircuitName(t *testing.T) { state.ProcessTopic("SessionInfo", json.RawMessage(`{ "Meeting": {"Name": "British Grand Prix", "Circuit": {"ShortName": "Silverstone"}}, "Name": "Race", - "Type": "Race" + "Type": "Race", + "Path": "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" }`)) s := state.Snapshot().Session if s.MeetingName != "British Grand Prix" || s.CircuitName != "Silverstone" { t.Fatalf("session = %+v", s) } + if s.Path != "2026/2026-07-05_British_Grand_Prix/2026-07-05_Race/" { + t.Fatalf("session path = %q", s.Path) + } } func TestProcessTopicDriverList(t *testing.T) { @@ -285,6 +290,68 @@ func TestProcessTopicRaceControlMessages(t *testing.T) { } } +func TestProcessTopicTeamRadioSnapshotAndPatch(t *testing.T) { + state := live.NewState() + snapshot := json.RawMessage(`{ + "Captures": [ + {"Utc": "2026-07-05T14:05:30Z", "RacingNumber": "4", "Path": "TeamRadio/NOR-1.mp3"}, + {"Utc": "2026-07-05T14:04:10Z", "RacingNumber": "16", "Path": "TeamRadio/LEC-1.mp3"} + ] + }`) + + if !state.ProcessTopic("TeamRadio", snapshot) { + t.Fatal("TeamRadio snapshot should update state") + } + + patch := json.RawMessage(`{ + "Captures": { + "2": {"Utc": "2026-07-05T14:06:00Z", "RacingNumber": "44", "Path": "TeamRadio/HAM-1.mp3"} + } + }`) + if !state.ProcessTopic("TeamRadio", patch) { + t.Fatal("TeamRadio keyed patch should update state") + } + + radio := state.Snapshot().TeamRadio + if len(radio) != 3 { + t.Fatalf("radio captures = %d, want 3", len(radio)) + } + if radio[0].RacingNumber != "16" || radio[1].RacingNumber != "4" || radio[2].RacingNumber != "44" { + t.Fatalf("radio order = %+v", radio) + } +} + +func TestProcessTopicTeamRadioMalformed(t *testing.T) { + state := live.NewState() + if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": {"1": {"Utc": "2026-07-05T14:06:00Z", "Path": "missing-driver.mp3"}}}`)) { + t.Fatal("incomplete TeamRadio capture should not update state") + } + if state.ProcessTopic("TeamRadio", json.RawMessage(`{"Captures": "not-a-list"}`)) { + t.Fatal("unexpected TeamRadio captures shape should not update state") + } + if len(state.Snapshot().TeamRadio) != 0 { + t.Fatalf("malformed captures mutated state: %+v", state.Snapshot().TeamRadio) + } +} + +func TestProcessTopicTeamRadioCapsAtTwenty(t *testing.T) { + state := live.NewState() + for i := 0; i < 25; i++ { + payload := json.RawMessage([]byte(fmt.Sprintf(`{ + "Captures": [{"Utc": "2026-07-05T14:%02d:00Z", "RacingNumber": "%d", "Path": "TeamRadio/%02d.mp3"}] + }`, i, i, i))) + state.ProcessTopic("TeamRadio", payload) + } + + radio := state.Snapshot().TeamRadio + if len(radio) != 20 { + t.Fatalf("radio captures = %d, want 20", len(radio)) + } + if radio[0].Utc != "2026-07-05T14:05:00Z" || radio[19].Utc != "2026-07-05T14:24:00Z" { + t.Fatalf("radio cap kept wrong captures: first=%+v last=%+v", radio[0], radio[19]) + } +} + func TestProcessTopicWeatherData(t *testing.T) { state := live.NewState() state.ProcessTopic("WeatherData", json.RawMessage(`{ diff --git a/internal/live/signalr.go b/internal/live/signalr.go index e9b8342..9265d70 100644 --- a/internal/live/signalr.go +++ b/internal/live/signalr.go @@ -105,6 +105,7 @@ func connectToF1SignalRCore(dataChan chan LiveStreamData) error { "RaceControlMessages", "WeatherData", "SessionInfo", + "TeamRadio", "CurrentTyres", "TimingAppData", "TimingStats", @@ -196,7 +197,7 @@ func connectToF1LegacySignalR(dataChan chan LiveStreamData) error { return err } - subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) + subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","Position.z","CarData.z","DriverList","LapCount","ExtrapolatedClock","TrackStatus","RaceControlMessages","WeatherData","SessionInfo","TeamRadio","CurrentTyres","TimingAppData","TimingStats"]],"I":1}`) err = c.WriteMessage(websocket.TextMessage, subscribeMsg) if err != nil { return err diff --git a/internal/live/state.go b/internal/live/state.go index bd7d8d4..08d1488 100644 --- a/internal/live/state.go +++ b/internal/live/state.go @@ -8,6 +8,8 @@ import ( "encoding/json" "fmt" "io" + "log" + "sort" "strings" "time" ) @@ -23,6 +25,7 @@ type State struct { RCMessages []LiveRCMessage Weather LiveWeatherData Session LiveSessionMeta + TeamRadio []LiveRadioCapture TrackStatus string CurrentLap int TotalLaps int @@ -34,6 +37,7 @@ type State struct { } const signalRRecordSeparator = byte(0x1e) +const maxTeamRadioCaptures = 20 // NewState returns an empty live timing accumulator. func NewState() *State { @@ -71,6 +75,8 @@ func (s *State) Snapshot() LiveStreamData { } cpyRC := make([]LiveRCMessage, len(s.RCMessages)) copy(cpyRC, s.RCMessages) + cpyRadio := make([]LiveRadioCapture, len(s.TeamRadio)) + copy(cpyRadio, s.TeamRadio) cpyStints := make(map[string][]LiveStintData, len(s.Stints)) for k, v := range s.Stints { st := make([]LiveStintData, len(v)) @@ -86,6 +92,7 @@ func (s *State) Snapshot() LiveStreamData { RCMessages: cpyRC, Weather: s.Weather, Session: s.Session, + TeamRadio: cpyRadio, TrackStatus: s.TrackStatus, CurrentLap: s.CurrentLap, TotalLaps: s.TotalLaps, @@ -335,6 +342,7 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { } `json:"Meeting"` Name string `json:"Name"` Type string `json:"Type"` + Path string `json:"Path"` } if json.Unmarshal(data, &si) == nil { if si.Meeting.Name != "" { @@ -349,8 +357,13 @@ func (s *State) ProcessTopic(topic string, data json.RawMessage) bool { if si.Type != "" { s.Session.SessionType = si.Type } + if si.Path != "" { + s.Session.Path = si.Path + } updated = true } + case "TeamRadio": + updated = s.updateTeamRadio(data) case "CurrentTyres": var ct map[string]json.RawMessage if json.Unmarshal(data, &ct) == nil { @@ -483,6 +496,62 @@ func readCompressed(r io.ReadCloser, err error) ([]byte, bool) { return out, err == nil } +func (s *State) updateTeamRadio(data json.RawMessage) bool { + var payload struct { + Captures json.RawMessage `json:"Captures"` + } + if err := json.Unmarshal(data, &payload); err != nil { + log.Printf("live: skipping malformed TeamRadio payload: %v", err) + return false + } + if len(payload.Captures) == 0 || string(payload.Captures) == "null" { + log.Printf("live: skipping TeamRadio payload without Captures") + return false + } + + captures := indexedRawValues(payload.Captures) + if len(captures) == 0 { + log.Printf("live: skipping TeamRadio payload with unexpected Captures shape") + return false + } + + updated := false + for _, captureRaw := range captures { + var capture LiveRadioCapture + if err := json.Unmarshal(captureRaw.Raw, &capture); err != nil { + log.Printf("live: skipping malformed TeamRadio capture: %v", err) + continue + } + if capture.Utc == "" || capture.RacingNumber == "" || capture.Path == "" { + log.Printf("live: skipping incomplete TeamRadio capture: utc=%q racing_number=%q path=%q", capture.Utc, capture.RacingNumber, capture.Path) + continue + } + if s.hasTeamRadioCapture(capture) { + continue + } + s.TeamRadio = append(s.TeamRadio, capture) + updated = true + } + if updated { + sort.SliceStable(s.TeamRadio, func(i, j int) bool { + return s.TeamRadio[i].Utc < s.TeamRadio[j].Utc + }) + if len(s.TeamRadio) > maxTeamRadioCaptures { + s.TeamRadio = append([]LiveRadioCapture(nil), s.TeamRadio[len(s.TeamRadio)-maxTeamRadioCaptures:]...) + } + } + return updated +} + +func (s *State) hasTeamRadioCapture(capture LiveRadioCapture) bool { + for _, existing := range s.TeamRadio { + if existing.Utc == capture.Utc && existing.RacingNumber == capture.RacingNumber && existing.Path == capture.Path { + return true + } + } + return false +} + func (s *State) updatePositions(data json.RawMessage) bool { var payload struct { Position json.RawMessage `json:"Position"` @@ -799,6 +868,9 @@ func indexedRawValues(raw json.RawMessage) []indexedRaw { fmt.Sscanf(k, "%d", &i) values = append(values, indexedRaw{Index: i, Raw: v}) } + sort.Slice(values, func(i, j int) bool { + return values[i].Index < values[j].Index + }) return values } diff --git a/internal/live/types.go b/internal/live/types.go index 72e047c..4d62f6d 100644 --- a/internal/live/types.go +++ b/internal/live/types.go @@ -85,6 +85,7 @@ type LiveSessionMeta struct { CircuitName string SessionType string SessionName string + Path string } // LivePositionData is the latest raw F1 GPS position for one driver. @@ -144,6 +145,13 @@ type LiveStintData struct { Laps int } +// LiveRadioCapture is one team radio audio clip from the live timing feed. +type LiveRadioCapture struct { + Utc string + RacingNumber string + Path string +} + // LiveStreamData is an immutable snapshot of all live timing state. type LiveStreamData struct { Drivers map[string]LiveDriverData @@ -153,6 +161,7 @@ type LiveStreamData struct { RCMessages []LiveRCMessage Weather LiveWeatherData Session LiveSessionMeta + TeamRadio []LiveRadioCapture TrackStatus string // "1"=green "2"=yellow "4"=SC "5"=red "6"=VSC CurrentLap int TotalLaps int