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