Files
box-box/internal/ui/official_live.go
2026-03-07 23:53:16 -05:00

356 lines
8.9 KiB
Go

package ui
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/gorilla/websocket"
)
type F1SignalRMessage struct {
M []struct {
A []json.RawMessage `json:"A"`
} `json:"M"`
R json.RawMessage `json:"R"`
}
type F1TimingLine struct {
GapToLeader interface{} `json:"GapToLeader"`
IntervalToPositionAhead struct {
Value interface{} `json:"Value"`
} `json:"IntervalToPositionAhead"`
Position string `json:"Position"`
RacingNumber string `json:"RacingNumber"`
}
type F1DriverListEntry struct {
RacingNumber string `json:"RacingNumber"`
BroadcastName string `json:"BroadcastName"`
Tla string `json:"Tla"`
TeamName string `json:"TeamName"`
TeamColour string `json:"TeamColour"`
FirstName string `json:"FirstName"`
LastName string `json:"LastName"`
}
type LiveStreamData struct {
Drivers map[string]LiveDriverData
DriverInfo map[string]F1DriverListEntry
}
type LiveDriverData struct {
RacingNumber string
Position int
GapToLeader string
Interval string
}
func ConnectToF1LiveTiming(dataChan chan LiveStreamData) error {
hubName := `[{"name":"Streaming"}]`
negotiateURL := fmt.Sprintf("https://livetiming.formula1.com/signalr/negotiate?clientProtocol=1.5&connectionData=%s", url.QueryEscape(hubName))
req, err := http.NewRequest("GET", negotiateURL, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
cookies := resp.Cookies()
defer resp.Body.Close()
var neg struct {
ConnectionToken string `json:"ConnectionToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&neg); err != nil {
return err
}
wsURL := fmt.Sprintf("wss://livetiming.formula1.com/signalr/connect?clientProtocol=1.5&transport=webSockets&connectionToken=%s&connectionData=%s",
url.QueryEscape(neg.ConnectionToken),
url.QueryEscape(hubName),
)
header := http.Header{}
for _, cookie := range cookies {
header.Add("Cookie", cookie.String())
}
header.Add("User-Agent", "BestHTTP")
c, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
return err
}
subscribeMsg := []byte(`{"H":"Streaming","M":"Subscribe","A":[["Heartbeat","TimingData","DriverList"]],"I":1}`)
err = c.WriteMessage(websocket.TextMessage, subscribeMsg)
if err != nil {
return err
}
go func() {
defer c.Close()
drivers := make(map[string]LiveDriverData)
driverInfo := make(map[string]F1DriverListEntry)
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("WS Read Error:", err)
return
}
var parsed F1SignalRMessage
if err := json.Unmarshal(message, &parsed); err != nil {
continue
}
updated := false
// Check full state payload (R)
if len(parsed.R) > 2 {
var rMap map[string]json.RawMessage
if err := json.Unmarshal(parsed.R, &rMap); err == nil {
if tdRaw, ok := rMap["TimingData"]; ok {
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if json.Unmarshal(tdRaw, &td) == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line)
updated = true
}
}
}
}
if dlRaw, ok := rMap["DriverList"]; ok {
var dlMap map[string]json.RawMessage
if json.Unmarshal(dlRaw, &dlMap) == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
driverInfo[num] = entry
updated = true
}
}
}
}
}
}
// Check incremental feed (M)
for _, m := range parsed.M {
if len(m.A) > 1 {
var topic string
json.Unmarshal(m.A[0], &topic)
switch topic {
case "TimingData":
var td struct {
Lines map[string]json.RawMessage `json:"Lines"`
}
if err := json.Unmarshal(m.A[1], &td); err == nil {
for num, lineRaw := range td.Lines {
var line F1TimingLine
if json.Unmarshal(lineRaw, &line) == nil {
updateDriver(drivers, num, line)
updated = true
}
}
}
case "DriverList":
var dlMap map[string]json.RawMessage
if err := json.Unmarshal(m.A[1], &dlMap); err == nil {
for num, entryRaw := range dlMap {
var entry F1DriverListEntry
if json.Unmarshal(entryRaw, &entry) == nil && entry.Tla != "" {
driverInfo[num] = entry
updated = true
}
}
}
}
}
}
if updated {
// Send copies to avoid race conditions
cpyDrivers := make(map[string]LiveDriverData)
for k, v := range drivers {
cpyDrivers[k] = v
}
cpyInfo := make(map[string]F1DriverListEntry)
for k, v := range driverInfo {
cpyInfo[k] = v
}
select {
case dataChan <- LiveStreamData{Drivers: cpyDrivers, DriverInfo: cpyInfo}:
default:
}
}
}
}()
return nil
}
func updateDriver(drivers map[string]LiveDriverData, num string, line F1TimingLine) {
d, exists := drivers[num]
if !exists {
d = LiveDriverData{RacingNumber: num}
if line.RacingNumber != "" {
d.RacingNumber = line.RacingNumber
}
}
if line.Position != "" {
fmt.Sscanf(line.Position, "%d", &d.Position)
}
if line.GapToLeader != nil {
d.GapToLeader = fmt.Sprintf("%v", line.GapToLeader)
}
if line.IntervalToPositionAhead.Value != nil {
d.Interval = fmt.Sprintf("%v", line.IntervalToPositionAhead.Value)
}
drivers[num] = d
}
// ----------------------------------------------------------------------------
// Model wrapper
// ----------------------------------------------------------------------------
type wsDataMsg LiveStreamData
func listenForWSData(sub chan LiveStreamData) tea.Cmd {
return func() tea.Msg {
return wsDataMsg(<-sub)
}
}
func parseGap(val string) string {
if val == "" {
return ""
}
return val
}
type OfficialLiveModel struct {
width int
height int
dataChan chan LiveStreamData
drivers map[string]LiveDriverData
driverInfo map[string]F1DriverListEntry
err error
}
func NewOfficialLiveModel() OfficialLiveModel {
return OfficialLiveModel{
dataChan: make(chan LiveStreamData, 10),
drivers: make(map[string]LiveDriverData),
driverInfo: make(map[string]F1DriverListEntry),
}
}
func (m OfficialLiveModel) Init() tea.Cmd {
err := ConnectToF1LiveTiming(m.dataChan)
if err != nil {
return func() tea.Msg { return err }
}
return listenForWSData(m.dataChan)
}
func (m OfficialLiveModel) Update(msg tea.Msg) (OfficialLiveModel, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
return m, nil
case error:
m.err = msg
return m, nil
case wsDataMsg:
m.drivers = msg.Drivers
m.driverInfo = msg.DriverInfo
return m, listenForWSData(m.dataChan)
}
return m, nil
}
func (m OfficialLiveModel) View() string {
if m.err != nil {
return fmt.Sprintf("\n Error connecting to F1 live stream: %v", m.err)
}
if len(m.drivers) == 0 {
return "\n Connecting to Official F1 Live Timing Stream...\n"
}
var sb strings.Builder
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true)
sb.WriteString("\n " + titleStyle.Render("LIVE TIMING") + "\n\n")
var drivers []LiveDriverData
for _, d := range m.drivers {
if d.Position > 0 {
drivers = append(drivers, d)
}
}
sort.Slice(drivers, func(i, j int) bool {
return drivers[i].Position < drivers[j].Position
})
// Header
headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted))
sb.WriteString(headerStyle.Render(fmt.Sprintf(" %-4s %-4s %-4s %-16s %-12s %-12s", "POS", "NO", "TLA", "DRIVER", "GAP", "INT")) + "\n")
sb.WriteString(" " + strings.Repeat("─", 58) + "\n")
for _, d := range drivers {
gap := parseGap(d.GapToLeader)
intv := parseGap(d.Interval)
if d.Position == 1 {
gap = "LEADER"
intv = ""
}
// Look up driver info
info, hasInfo := m.driverInfo[d.RacingNumber]
tla := d.RacingNumber
name := ""
teamColor := colorMuted
if hasInfo {
tla = info.Tla
name = info.BroadcastName
if info.TeamColour != "" {
teamColor = "#" + info.TeamColour
} else {
teamColor = teamColorFromName(info.TeamName)
}
}
colorBar := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Render("┃")
posStr := fmt.Sprintf("%-4d", d.Position)
noStr := lipgloss.NewStyle().Foreground(lipgloss.Color(teamColor)).Bold(true).Render(fmt.Sprintf("%-4s", d.RacingNumber))
tlaStr := lipgloss.NewStyle().Bold(true).Render(fmt.Sprintf("%-4s", tla))
nameStr := styleMuted.Render(fmt.Sprintf("%-16s", name))
gapStr := fmt.Sprintf("%-12s", gap)
intvStr := fmt.Sprintf("%-12s", intv)
sb.WriteString(fmt.Sprintf(" %s %s %s %s %s %s %s\n", colorBar, posStr, noStr, tlaStr, nameStr, gapStr, intvStr))
}
sb.WriteString("\n" + helpBar("1-6 tabs", "q quit"))
return sb.String()
}