mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
356 lines
8.9 KiB
Go
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()
|
|
}
|