diff --git a/box-box b/box-box new file mode 100755 index 0000000..421d7c2 Binary files /dev/null and b/box-box differ diff --git a/box-box-bin b/box-box-bin new file mode 100755 index 0000000..421d7c2 Binary files /dev/null and b/box-box-bin differ diff --git a/go.mod b/go.mod index 63d6a5c..9204e7f 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect diff --git a/go.sum b/go.sum index a1bcdef..9e7500c 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/internal/api/openf1.go b/internal/api/openf1.go index f40ca1e..4a4f09f 100644 --- a/internal/api/openf1.go +++ b/internal/api/openf1.go @@ -305,7 +305,11 @@ func (c *OpenF1Client) GetOvertakesForSession(sessionKey int) ([]models.Overtake } func (c *OpenF1Client) GetPositions(sessionKey, driverNumber int) ([]models.Position, error) { - body, err := c.get(fmt.Sprintf("%s/v1/position?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber)) + url := fmt.Sprintf("%s/v1/position?session_key=%d", c.url, sessionKey) + if driverNumber != 0 { + url += fmt.Sprintf("&driver_number=%d", driverNumber) + } + body, err := c.get(url) if err != nil { return nil, err } @@ -319,7 +323,11 @@ func (c *OpenF1Client) GetPositions(sessionKey, driverNumber int) ([]models.Posi } func (c *OpenF1Client) GetIntervals(sessionKey, driverNumber int) ([]models.Interval, error) { - body, err := c.get(fmt.Sprintf("%s/v1/intervals?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber)) + url := fmt.Sprintf("%s/v1/intervals?session_key=%d", c.url, sessionKey) + if driverNumber != 0 { + url += fmt.Sprintf("&driver_number=%d", driverNumber) + } + body, err := c.get(url) if err != nil { return nil, err } diff --git a/internal/ui/app.go b/internal/ui/app.go index 0da929f..440bc87 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -15,14 +15,16 @@ import ( type tabIndex int const ( - tabStandings tabIndex = 0 - tabCalendar tabIndex = 1 - tabRaceDetail tabIndex = 2 - tabDriver tabIndex = 3 + tabDashboard tabIndex = 0 + tabStandings tabIndex = 1 + tabCalendar tabIndex = 2 + tabRaceDetail tabIndex = 3 + tabDriver tabIndex = 4 + tabLive tabIndex = 5 ) -var tabNames = []string{"Standings", "Calendar", "Race", "Drivers"} -var tabIcons = []string{"🏆", "📅", "🏁", "👤"} +var tabNames = []string{"Home", "Standings", "Calendar", "Race", "Drivers", "Live"} +var tabIcons = []string{"🏠", "🏆", "📅", "🏁", "👤", "🔴"} // splashDoneMsg is sent after the splash screen duration has elapsed. type splashDoneMsg struct{} @@ -40,6 +42,8 @@ type AppModel struct { calendar CalendarModel raceDetail RaceDetailModel driver DriverModel + dashboard DashboardModel + live OfficialLiveModel meetings []models.Meeting @@ -49,7 +53,7 @@ type AppModel struct { } func NewAppModel(client *api.OpenF1Client) AppModel { - year := 2025 + year := time.Now().Year() sp := spinner.New() sp.Spinner = spinner.Points @@ -57,12 +61,14 @@ func NewAppModel(client *api.OpenF1Client) AppModel { return AppModel{ client: client, - activeTab: tabStandings, + activeTab: tabDashboard, year: year, standings: NewStandingsModel(client, year), calendar: NewCalendarModel(client, year), raceDetail: NewRaceDetailModel(client), driver: NewDriverModel(client), + dashboard: NewDashboardModel(client, year), + live: NewOfficialLiveModel(), showSplash: true, splashSpinner: sp, } @@ -76,6 +82,8 @@ func splashTimer() tea.Cmd { func (m AppModel) Init() tea.Cmd { return tea.Batch( + m.dashboard.Init(), + m.live.Init(), m.standings.Init(), m.calendar.Init(), m.raceDetail.Init(), @@ -94,12 +102,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height contentHeight := m.height - 5 // tab bar(2) + status bar + help + spacing m.raceDetail.SetSize(m.width-4, contentHeight) - var cmd1, cmd2, cmd3, cmd4 tea.Cmd - m.standings, cmd1 = m.standings.Update(msg) - m.calendar, cmd2 = m.calendar.Update(msg) - m.raceDetail, cmd3 = m.raceDetail.Update(msg) - m.driver, cmd4 = m.driver.Update(msg) - return m, tea.Batch(cmd1, cmd2, cmd3, cmd4) + var cmd1, cmd2, cmd3, cmd4, cmd5, cmd6 tea.Cmd + m.dashboard, cmd1 = m.dashboard.Update(msg) + m.live, cmd2 = m.live.Update(msg) + m.standings, cmd3 = m.standings.Update(msg) + m.calendar, cmd4 = m.calendar.Update(msg) + m.raceDetail, cmd5 = m.raceDetail.Update(msg) + m.driver, cmd6 = m.driver.Update(msg) + return m, tea.Batch(cmd1, cmd2, cmd3, cmd4, cmd5, cmd6) case splashDoneMsg: m.showSplash = false @@ -119,22 +129,28 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Tab switching switch { case matchKey(msg, GlobalKeys.Tab1): - m.activeTab = tabStandings + m.activeTab = tabDashboard return m, nil case matchKey(msg, GlobalKeys.Tab2): - m.activeTab = tabCalendar + m.activeTab = tabStandings return m, nil case matchKey(msg, GlobalKeys.Tab3): - m.activeTab = tabRaceDetail + m.activeTab = tabCalendar return m, nil case matchKey(msg, GlobalKeys.Tab4): + m.activeTab = tabRaceDetail + return m, nil + case matchKey(msg, GlobalKeys.Tab5): m.activeTab = tabDriver var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case matchKey(msg, GlobalKeys.Tab6): + m.activeTab = tabLive + return m, nil case matchKey(msg, GlobalKeys.NextTab): - m.activeTab = (m.activeTab + 1) % 4 + m.activeTab = (m.activeTab + 1) % 6 if m.activeTab == tabDriver { var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() @@ -142,7 +158,7 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(cmds...) case matchKey(msg, GlobalKeys.PrevTab): - m.activeTab = (m.activeTab - 1 + 4) % 4 + m.activeTab = (m.activeTab - 1 + 6) % 6 if m.activeTab == tabDriver { var cmd tea.Cmd m.driver, cmd = m.driver.TriggerLoad() @@ -156,8 +172,8 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } case matchKey(msg, GlobalKeys.Year): - // Cycle years: 2025 -> 2023 -> 2024 -> 2025 - if m.year == 2025 { + // Cycle years: up to current year, wrap to 2023 + if m.year >= time.Now().Year() { m.year = 2023 } else { m.year++ @@ -281,6 +297,12 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) return m, tea.Batch(cmds...) + case wsDataMsg: + var cmd tea.Cmd + m.live, cmd = m.live.Update(msg) + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) + case spinner.TickMsg: if m.showSplash { var cmd tea.Cmd @@ -298,6 +320,14 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Route keyboard input to active tab switch m.activeTab { + case tabDashboard: + var cmd tea.Cmd + m.dashboard, cmd = m.dashboard.Update(msg) + cmds = append(cmds, cmd) + case tabLive: + var cmd tea.Cmd + m.live, cmd = m.live.Update(msg) + cmds = append(cmds, cmd) case tabStandings: var cmd tea.Cmd m.standings, cmd = m.standings.Update(msg) @@ -338,6 +368,10 @@ func (m AppModel) View() string { // Content area var content string switch m.activeTab { + case tabDashboard: + content = m.dashboard.View() + case tabLive: + content = m.live.View() case tabStandings: content = m.standings.View() case tabCalendar: diff --git a/internal/ui/calendar.go b/internal/ui/calendar.go index 1e69eab..4ff7b23 100644 --- a/internal/ui/calendar.go +++ b/internal/ui/calendar.go @@ -317,11 +317,15 @@ func formatMeetingDates(m models.Meeting) string { if len(m.DateStart) >= 10 { start, _ = time.Parse("2006-01-02", m.DateStart[:10]) } + } else { + start = start.Local() } if err2 != nil { if len(m.DateEnd) >= 10 { end, _ = time.Parse("2006-01-02", m.DateEnd[:10]) } + } else { + end = end.Local() } if start.Month() == end.Month() { diff --git a/internal/ui/dashboard.go b/internal/ui/dashboard.go new file mode 100644 index 0000000..f540942 --- /dev/null +++ b/internal/ui/dashboard.go @@ -0,0 +1,225 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type DashboardModel struct { + client *api.OpenF1Client + year int + width int + height int + loading bool + spinner spinner.Model + meetings []models.Meeting + next *models.Meeting + sessions []models.Session + err error +} + +func NewDashboardModel(client *api.OpenF1Client, year int) DashboardModel { + sp := spinner.New() + sp.Spinner = spinner.Points + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + + return DashboardModel{ + client: client, + year: year, + loading: true, + spinner: sp, + } +} + +func (m DashboardModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + fetchDashboardMeetings(m.client, m.year), + tickCountdown(), + ) +} + +func fetchDashboardMeetings(client *api.OpenF1Client, year int) tea.Cmd { + return func() tea.Msg { + meetings, err := client.GetMeetingsForYear(year) + if err != nil { + return dashboardMeetingsLoadedMsg{err: err} + } + return dashboardMeetingsLoadedMsg{meetings: meetings} + } +} + +func fetchDashboardSessions(client *api.OpenF1Client, meetingKey int) tea.Cmd { + return func() tea.Msg { + sessions, err := client.GetSessionsForMeeting(meetingKey) + if err != nil { + return dashboardSessionsLoadedMsg{err: err} + } + // Sort by DateStart + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].DateStart < sessions[j].DateStart + }) + return dashboardSessionsLoadedMsg{sessions: sessions} + } +} + +type dashboardMeetingsLoadedMsg struct { + meetings []models.Meeting + err error +} + +type dashboardSessionsLoadedMsg struct { + sessions []models.Session + err error +} + +type tickCountdownMsg time.Time + +func tickCountdown() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { + return tickCountdownMsg(t) + }) +} + +func (m DashboardModel) Update(msg tea.Msg) (DashboardModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case spinner.TickMsg: + if m.loading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + case dashboardMeetingsLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.loading = false + return m, nil + } + m.meetings = msg.meetings + now := time.Now() + + for i := range m.meetings { + mtg := m.meetings[i] + end, _ := time.Parse(time.RFC3339, mtg.DateEnd) + if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour { // also keep showing for 24h after + m.next = &mtg + break + } + } + + if m.next != nil { + return m, fetchDashboardSessions(m.client, int(m.next.MeetingKey)) + } + m.loading = false + return m, nil + + case dashboardSessionsLoadedMsg: + m.loading = false + if msg.err == nil { + m.sessions = msg.sessions + } + return m, nil + + case tickCountdownMsg: + return m, tickCountdown() + } + return m, nil +} + +func (m DashboardModel) View() string { + if m.loading { + return fmt.Sprintf("\n %s Loading dashboard...", m.spinner.View()) + } + if m.err != nil { + return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) + } + + if m.next == nil { + return styleMuted.Render(fmt.Sprintf("\n No upcoming races found for %d.\n", m.year)) + } + + var sb strings.Builder + w := m.width + if w < 40 { + w = 40 + } + + titleStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color(colorF1Red)). + Bold(true) + + sb.WriteString("\n") + sb.WriteString(titleStyle.Render(fmt.Sprintf(" NEXT RACE: %s", m.next.MeetingOfficialName)) + "\n") + sb.WriteString(fmt.Sprintf(" %s • %s\n", countryFlag(m.next.CountryCode), m.next.Location)) + + now := time.Now() + end, _ := time.Parse(time.RFC3339, m.next.DateEnd) + endLocal := end.Local() + + // Show countdown to first session if available, else use start date + var nextStart time.Time + startFound := false + for _, s := range m.sessions { + st, _ := time.Parse(time.RFC3339, s.DateStart) + if now.Before(st.Local()) { + nextStart = st.Local() + startFound = true + break + } + } + + if !startFound { + nextStart, _ = time.Parse(time.RFC3339, m.next.DateStart) + nextStart = nextStart.Local() + } + + sb.WriteString("\n") + if now.After(nextStart) && now.Before(endLocal) { + liveBadge := lipgloss.NewStyle().Background(lipgloss.Color(colorSoft)).Foreground(lipgloss.Color(colorSurface0)).Bold(true).Padding(0, 1).Render("LIVE") + sb.WriteString(" " + liveBadge + "\n") + } else if now.Before(nextStart) { + diff := nextStart.Sub(now) + days := int(diff.Hours() / 24) + hours := int(diff.Hours()) % 24 + mins := int(diff.Minutes()) % 60 + secs := int(diff.Seconds()) % 60 + sb.WriteString(fmt.Sprintf(" Starts in: %dd %02dh %02dm %02ds\n", days, hours, mins, secs)) + } else { + sb.WriteString(" " + lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Render("Weekend Finished") + "\n") + } + + sb.WriteString("\n") + + // Weekend Schedule + if len(m.sessions) > 0 { + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)).Bold(true).Render(" WEEKEND SCHEDULE (Local Time)") + "\n") + for _, s := range m.sessions { + st, _ := time.Parse(time.RFC3339, s.DateStart) + stLocal := st.Local() + day := stLocal.Format("Mon 02 Jan") + tStr := stLocal.Format("15:04") + + rowStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorWhite)) + if now.After(stLocal) { + rowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)) + } + + sb.WriteString(rowStyle.Render(fmt.Sprintf(" %-15s %-12s %s", s.SessionName, day, tStr)) + "\n") + } + } + + sb.WriteString("\n" + helpBar("1-6 tabs", "q quit")) + return sb.String() +} diff --git a/internal/ui/driver.go b/internal/ui/driver.go index 4a71b82..797de9e 100644 --- a/internal/ui/driver.go +++ b/internal/ui/driver.go @@ -34,6 +34,7 @@ type DriverModel struct { pits []models.Pit positions []models.Position teamRadios []models.TeamRadio + raceControl []models.RaceControl view driverView loading bool @@ -44,7 +45,6 @@ type DriverModel struct { scroll int input textinput.Model - // Detail view viewport detailView viewport.Model detailViewReady bool @@ -884,11 +884,10 @@ func (m DriverModel) renderTeamRadio() string { var sb strings.Builder - countStr := styleMuted.Render(fmt.Sprintf(" %d messages during session", len(m.teamRadios))) + countStr := styleMuted.Render(fmt.Sprintf(" %d audio messages (open URL in browser to listen)", len(m.teamRadios))) sb.WriteString(countStr + "\n") for i, radio := range m.teamRadios { - // Parse timestamp t := "--:--:--" if len(radio.Date) >= 19 { pt, err := time.Parse(time.RFC3339, radio.Date) @@ -909,7 +908,6 @@ func (m DriverModel) renderTeamRadio() string { sb.WriteString(fmt.Sprintf(" %s %s %s\n", icon, timeStr, urlStyled)) - // Limit display to 15 entries if i >= 14 && i < len(m.teamRadios)-1 { remaining := len(m.teamRadios) - i - 1 sb.WriteString(styleMuted.Render(fmt.Sprintf(" ... and %d more messages\n", remaining))) diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 4cdb4c2..835da95 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -8,6 +8,8 @@ type GlobalKeyMap struct { Tab2 key.Binding Tab3 key.Binding Tab4 key.Binding + Tab5 key.Binding + Tab6 key.Binding NextTab key.Binding PrevTab key.Binding Quit key.Binding @@ -27,19 +29,27 @@ type GlobalKeyMap struct { var GlobalKeys = GlobalKeyMap{ Tab1: key.NewBinding( key.WithKeys("1"), - key.WithHelp("1", "standings"), + key.WithHelp("1", "home"), ), Tab2: key.NewBinding( key.WithKeys("2"), - key.WithHelp("2", "calendar"), + key.WithHelp("2", "standings"), ), Tab3: key.NewBinding( key.WithKeys("3"), - key.WithHelp("3", "race detail"), + key.WithHelp("3", "calendar"), ), Tab4: key.NewBinding( key.WithKeys("4"), - key.WithHelp("4", "drivers"), + key.WithHelp("4", "race detail"), + ), + Tab5: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "drivers"), + ), + Tab6: key.NewBinding( + key.WithKeys("6"), + key.WithHelp("6", "live"), ), NextTab: key.NewBinding( key.WithKeys("tab", "right"), diff --git a/internal/ui/live.go b/internal/ui/live.go new file mode 100644 index 0000000..ebf3315 --- /dev/null +++ b/internal/ui/live.go @@ -0,0 +1,243 @@ +package ui + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/AmanTahiliani/box-box/internal/api" + "github.com/AmanTahiliani/box-box/internal/models" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type LiveModel struct { + client *api.OpenF1Client + width int + height int + loading bool + spinner spinner.Model + session *models.Session + positions map[int]models.Position + intervals map[int]models.Interval + err error +} + +func NewLiveModel(client *api.OpenF1Client) LiveModel { + sp := spinner.New() + sp.Spinner = spinner.Points + sp.Style = lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)) + + return LiveModel{ + client: client, + loading: true, + spinner: sp, + positions: make(map[int]models.Position), + intervals: make(map[int]models.Interval), + } +} + +func (m LiveModel) Init() tea.Cmd { + return tea.Batch( + m.spinner.Tick, + fetchActiveSession(m.client), + tickLiveTelemetry(), + ) +} + +type liveSessionLoadedMsg struct { + session *models.Session + err error +} + +func fetchActiveSession(client *api.OpenF1Client) tea.Cmd { + return func() tea.Msg { + year := time.Now().Year() + meetings, err := client.GetMeetingsForYear(year) + if err != nil { + return liveSessionLoadedMsg{err: err} + } + + now := time.Now() + var currentMtg *models.Meeting + for i := range meetings { + end, _ := time.Parse(time.RFC3339, meetings[i].DateEnd) + if now.Before(end.Local()) || now.Sub(end.Local()) < 24*time.Hour { + currentMtg = &meetings[i] + break + } + } + + if currentMtg == nil { + return liveSessionLoadedMsg{err: fmt.Errorf("no active weekend found")} + } + + sessions, err := client.GetSessionsForMeeting(int(currentMtg.MeetingKey)) + if err != nil { + return liveSessionLoadedMsg{err: err} + } + + var activeSess *models.Session + for i := range sessions { + st, _ := time.Parse(time.RFC3339, sessions[i].DateStart) + en, _ := time.Parse(time.RFC3339, sessions[i].DateEnd) + if now.After(st.Local()) && now.Before(en.Local().Add(2*time.Hour)) { + activeSess = &sessions[i] + } + } + + if activeSess == nil && len(sessions) > 0 { + activeSess = &sessions[len(sessions)-1] + } + + return liveSessionLoadedMsg{session: activeSess} + } +} + +type tickLiveTelemetryMsg time.Time + +func tickLiveTelemetry() tea.Cmd { + return tea.Tick(10*time.Second, func(t time.Time) tea.Msg { + return tickLiveTelemetryMsg(t) + }) +} + +type liveTelemetryLoadedMsg struct { + positions []models.Position + intervals []models.Interval + err error +} + +func fetchLiveTelemetry(client *api.OpenF1Client, sessionKey int) tea.Cmd { + return func() tea.Msg { + positions, err := client.GetPositions(sessionKey, 0) + if err != nil { + return liveTelemetryLoadedMsg{err: err} + } + intervals, err := client.GetIntervals(sessionKey, 0) + if err != nil { + return liveTelemetryLoadedMsg{err: err} + } + return liveTelemetryLoadedMsg{positions: positions, intervals: intervals} + } +} + +func (m LiveModel) Update(msg tea.Msg) (LiveModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case spinner.TickMsg: + if m.loading { + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + } + case liveSessionLoadedMsg: + if msg.err != nil { + m.err = msg.err + m.loading = false + return m, nil + } + m.session = msg.session + if m.session != nil { + return m, fetchLiveTelemetry(m.client, m.session.SessionKey) + } + m.loading = false + return m, nil + case tickLiveTelemetryMsg: + if m.session != nil { + return m, tea.Batch(fetchLiveTelemetry(m.client, m.session.SessionKey), tickLiveTelemetry()) + } + return m, tickLiveTelemetry() + case liveTelemetryLoadedMsg: + m.loading = false + if msg.err != nil { + // Don't overwrite the screen on transient errors, just log to error field + m.err = msg.err + return m, nil + } + m.err = nil + + // Get latest positions and intervals per driver + for _, p := range msg.positions { + current, exists := m.positions[p.DriverNumber] + if !exists || p.Date > current.Date { + m.positions[p.DriverNumber] = p + } + } + for _, i := range msg.intervals { + current, exists := m.intervals[i.DriverNumber] + if !exists || i.Date > current.Date { + m.intervals[i.DriverNumber] = i + } + } + return m, nil + } + return m, nil +} + +func (m LiveModel) View() string { + if m.loading { + return fmt.Sprintf("\n %s Loading live telemetry...", m.spinner.View()) + } + if m.err != nil && len(m.positions) == 0 { + return styleError.Render(fmt.Sprintf("\n Error: %v\n", m.err)) + } + if m.session == nil { + return styleMuted.Render("\n No active session found.\n") + } + + var sb strings.Builder + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(colorF1Red)).Bold(true) + + sb.WriteString("\n " + titleStyle.Render(fmt.Sprintf("LIVE: %s", m.session.SessionName)) + "\n\n") + + if len(m.positions) == 0 { + sb.WriteString(styleMuted.Render(" Waiting for telemetry data...\n")) + sb.WriteString("\n\n" + helpBar("1-6 tabs", "q quit")) + return sb.String() + } + + // Sort drivers by position + var drivers []int + for d := range m.positions { + drivers = append(drivers, d) + } + sort.Slice(drivers, func(i, j int) bool { + return m.positions[drivers[i]].Position < m.positions[drivers[j]].Position + }) + + sb.WriteString(styleMuted.Render(" POS NO GAP INT") + "\n") + sb.WriteString(" " + strings.Repeat("─", 30) + "\n") + + for _, d := range drivers { + pos := m.positions[d].Position + interval := m.intervals[d] + + gapToLeader := "LAP" + if interval.GapToLeader != nil { + gapToLeader = fmt.Sprintf("+%.3fs", *interval.GapToLeader) + } + if pos == 1 { + gapToLeader = "Leader" + } + + gapToFront := "LAP" + if interval.Interval != nil { + gapToFront = fmt.Sprintf("+%.3fs", *interval.Interval) + } + if pos == 1 { + gapToFront = "-" + } + + row := fmt.Sprintf(" %-4d %-4d %-10s %-10s", pos, d, gapToLeader, gapToFront) + sb.WriteString(row + "\n") + } + + sb.WriteString("\n" + helpBar("1-6 tabs", "q quit")) + return sb.String() +} diff --git a/internal/ui/messages.go b/internal/ui/messages.go index 7140604..427d35c 100644 --- a/internal/ui/messages.go +++ b/internal/ui/messages.go @@ -92,6 +92,12 @@ type driverTeamRadioLoadedMsg struct { err error } +// driverRaceControlLoadedMsg carries race control messages for a selected driver. +type driverRaceControlLoadedMsg struct { + messages []models.RaceControl + err error +} + // overtakesLoadedMsg carries overtake data for a session. type overtakesLoadedMsg struct { overtakes []models.Overtake diff --git a/internal/ui/official_live.go b/internal/ui/official_live.go new file mode 100644 index 0000000..b7e0c8d --- /dev/null +++ b/internal/ui/official_live.go @@ -0,0 +1,355 @@ +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() +} diff --git a/internal/ui/racedetail.go b/internal/ui/racedetail.go index 4d8cb37..4eb7a82 100644 --- a/internal/ui/racedetail.go +++ b/internal/ui/racedetail.go @@ -414,7 +414,7 @@ func (m RaceDetailModel) renderSessionPills() string { if len(sess.DateStart) >= 10 { t, err := time.Parse(time.RFC3339, sess.DateStart) if err == nil { - dateStr = t.Format("Mon 2") + dateStr = t.Local().Format("Mon 2") } else { dateStr = sess.DateStart[:10] } diff --git a/main b/main new file mode 100755 index 0000000..89217a6 Binary files /dev/null and b/main differ