package internal import ( "context" "fmt" "strings" "time" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) // ──────────────────────────────── // KEYMAP // ──────────────────────────────── type keyMap struct { Up, Down, Left, Right key.Binding Enter, Quit, Refresh key.Binding OpenBrowser, OpenMPV key.Binding Help, Debug key.Binding } func defaultKeys() keyMap { return keyMap{ Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "focus left")), Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "focus right")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "open in browser")), OpenMPV: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "open in mpv")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "toggle help")), Debug: key.NewBinding(key.WithKeys("f12"), key.WithHelp("F12", "debug panel")), } } func (k keyMap) ShortHelp() []key.Binding { return []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Enter, k.OpenBrowser, k.OpenMPV, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Up, k.Down, k.Left, k.Right}, {k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Help, k.Debug, k.Quit}, } } // ──────────────────────────────── // TYPES & CONSTANTS // ──────────────────────────────── type ( sportsLoadedMsg []Sport matchesLoadedMsg struct { Matches []Match Title string } streamsLoadedMsg []Stream errorMsg error launchStreamMsg struct{ URL string } debugLogMsg string ) type focusCol int type viewMode int const ( focusSports focusCol = iota focusMatches focusStreams ) const ( viewMain viewMode = iota viewHelp viewDebug ) // ──────────────────────────────── // MODEL // ──────────────────────────────── type Model struct { apiClient *Client styles Styles keys keyMap help help.Model focus focusCol lastError error currentView viewMode sports *ListColumn[Sport] matches *ListColumn[Match] streams *ListColumn[Stream] status string debugLines []string TerminalWidth int } // ──────────────────────────────── // ENTRY POINT // ──────────────────────────────── func Run(debug bool) error { p := tea.NewProgram(New(debug), tea.WithAltScreen()) _, err := p.Run() return err } func New(debug bool) Model { base := BaseURLFromEnv() client := NewClient(base, 15*time.Second) styles := NewStyles() m := Model{ apiClient: client, styles: styles, keys: defaultKeys(), help: help.New(), focus: focusSports, currentView: viewMain, debugLines: []string{}, } if debug { m.currentView = viewDebug } m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name }) m.matches = NewListColumn[Match]("Popular Matches", func(mt Match) string { when := time.UnixMilli(mt.Date).Local().Format("Jan 2 15:04") title := mt.Title if mt.Teams != nil && mt.Teams.Home != nil && mt.Teams.Away != nil { title = fmt.Sprintf("%s vs %s", mt.Teams.Home.Name, mt.Teams.Away.Name) } return fmt.Sprintf("%s %s (%s)", when, title, mt.Category) }) m.streams = NewListColumn[Stream]("Streams", func(st Stream) string { quality := "SD" if st.HD { quality = "HD" } return fmt.Sprintf("#%d %s (%s) – %s", st.StreamNo, st.Language, quality, st.Source) }) m.status = fmt.Sprintf("Using API %s | Loading…", base) return m } // ──────────────────────────────── // VIEW MANAGEMENT // ──────────────────────────────── func (m Model) Init() tea.Cmd { return tea.Batch(m.fetchSports(), m.fetchPopularMatches()) } func (m Model) View() string { switch m.currentView { case viewHelp: return m.renderHelpPanel() case viewDebug: return m.renderDebugPanel() default: return m.renderMainView() } } func (m Model) renderMainView() string { cols := lipgloss.JoinHorizontal( lipgloss.Top, m.sports.View(m.styles, m.focus == focusSports), m.matches.View(m.styles, m.focus == focusMatches), m.streams.View(m.styles, m.focus == focusStreams), ) status := m.styles.Status.Render(m.status) if m.lastError != nil { status = m.styles.Error.Render(fmt.Sprintf("⚠️ %v", m.lastError)) } return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys)) } func (m Model) renderHelpPanel() string { header := m.styles.Title.Render("Keybindings Help") bindings := [][]string{ {"↑/↓ or k/j", "Navigate list"}, {"←/→ or h/l", "Move focus between columns"}, {"Enter", "Select / Open"}, {"O", "Open in browser"}, {"P", "Open in mpv"}, {"R", "Refresh"}, {"Q", "Quit"}, {"F1 / ?", "Toggle this help"}, {"F12", "Show debug panel"}, {"Esc", "Return to main view"}, } var sb strings.Builder sb.WriteString(header + "\n\n") for _, b := range bindings { sb.WriteString(fmt.Sprintf("%-18s %s\n", b[0], b[1])) } sb.WriteString("\nPress Esc to return.") panel := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FA8072")). Padding(1, 2). Width(int(float64(m.TerminalWidth) * 0.97)). Render(sb.String()) return panel } func (m Model) renderDebugPanel() string { header := m.styles.Title.Render("Debug Output (F12 / Esc to close)") if len(m.debugLines) == 0 { m.debugLines = append(m.debugLines, "(no debug output yet)") } content := strings.Join(m.debugLines, "\n") panel := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("#FA8072")). Padding(1, 2). Width(int(float64(m.TerminalWidth) * 0.97)). Render(header + "\n\n" + content) return panel } // ──────────────────────────────── // UPDATE LOOP // ──────────────────────────────── func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case debugLogMsg: m.debugLines = append(m.debugLines, string(msg)) if len(m.debugLines) > 200 { m.debugLines = m.debugLines[len(m.debugLines)-200:] } return m, nil case tea.WindowSizeMsg: m.TerminalWidth = msg.Width usableHeight := int(float64(msg.Height) * 0.9) totalAvailableWidth := int(float64(msg.Width) * 0.97) borderPadding := 4 totalBorderSpace := borderPadding * 3 availableWidth := totalAvailableWidth - totalBorderSpace colWidth := availableWidth / 3 remainder := availableWidth % 3 m.sports.SetWidth(colWidth + borderPadding) m.matches.SetWidth(colWidth + borderPadding) m.streams.SetWidth(colWidth + remainder + borderPadding) m.sports.SetHeight(usableHeight) m.matches.SetHeight(usableHeight) m.streams.SetHeight(usableHeight) return m, nil case tea.KeyMsg: switch { case msg.String() == "esc": m.currentView = viewMain return m, nil case key.Matches(msg, m.keys.Help): if m.currentView == viewHelp { m.currentView = viewMain } else { m.currentView = viewHelp } return m, nil case key.Matches(msg, m.keys.Debug): if m.currentView == viewDebug { m.currentView = viewMain } else { m.currentView = viewDebug } return m, nil } if m.currentView != viewMain { return m, nil } switch { case key.Matches(msg, m.keys.Quit): return m, tea.Quit case key.Matches(msg, m.keys.Left): if m.focus > focusSports { m.focus-- } return m, nil case key.Matches(msg, m.keys.Right): if m.focus < focusStreams { m.focus++ } return m, nil case key.Matches(msg, m.keys.Up): switch m.focus { case focusSports: m.sports.CursorUp() case focusMatches: m.matches.CursorUp() case focusStreams: m.streams.CursorUp() } return m, nil case key.Matches(msg, m.keys.Down): switch m.focus { case focusSports: m.sports.CursorDown() case focusMatches: m.matches.CursorDown() case focusStreams: m.streams.CursorDown() } return m, nil case key.Matches(msg, m.keys.Enter): switch m.focus { case focusSports: if sport, ok := m.sports.Selected(); ok { m.status = fmt.Sprintf("Loading matches for %s…", sport.Name) m.streams.SetItems(nil) return m, m.fetchMatchesForSport(sport) } case focusMatches: if mt, ok := m.matches.Selected(); ok { m.status = fmt.Sprintf("Loading streams for %s…", mt.Title) return m, m.fetchStreamsForMatch(mt) } case focusStreams: if st, ok := m.streams.Selected(); ok { return m, tea.Batch( m.logToUI(fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)), m.runExtractor(st), ) } } return m, nil case key.Matches(msg, m.keys.OpenBrowser): if m.focus == focusStreams { if st, ok := m.streams.Selected(); ok && st.EmbedURL != "" { _ = openBrowser(st.EmbedURL) m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL) } } return m, nil } return m, nil case sportsLoadedMsg: m.sports.SetItems(msg) m.status = fmt.Sprintf("Loaded %d sports", len(msg)) return m, nil case matchesLoadedMsg: m.matches.SetTitle(msg.Title) m.matches.SetItems(msg.Matches) m.status = fmt.Sprintf("Loaded %d matches", len(msg.Matches)) return m, nil case streamsLoadedMsg: m.streams.SetItems(msg) m.status = fmt.Sprintf("Loaded %d streams", len(msg)) m.focus = focusStreams return m, nil case launchStreamMsg: m.status = fmt.Sprintf("🎥 Launched mpv: %s", msg.URL) return m, nil case errorMsg: m.lastError = msg return m, nil } return m, nil } // ──────────────────────────────── // FETCHERS // ──────────────────────────────── func (m Model) fetchSports() tea.Cmd { return func() tea.Msg { sports, err := m.apiClient.GetSports(context.Background()) if err != nil { return errorMsg(err) } return sportsLoadedMsg(sports) } } func (m Model) fetchPopularMatches() tea.Cmd { return func() tea.Msg { matches, err := m.apiClient.GetPopularMatches(context.Background()) if err != nil { return errorMsg(err) } return matchesLoadedMsg{Matches: matches, Title: "Popular Matches"} } } func (m Model) fetchMatchesForSport(s Sport) tea.Cmd { return func() tea.Msg { matches, err := m.apiClient.GetMatchesBySport(context.Background(), s.ID) if err != nil { return errorMsg(err) } return matchesLoadedMsg{Matches: matches, Title: fmt.Sprintf("Matches (%s)", s.Name)} } } func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd { return func() tea.Msg { streams, err := m.apiClient.GetStreamsForMatch(context.Background(), mt) if err != nil { return errorMsg(err) } return streamsLoadedMsg(streams) } } // ──────────────────────────────── // EXTRACTOR (chromedp integration) // ──────────────────────────────── func (m Model) runExtractor(st Stream) tea.Cmd { return func() tea.Msg { if st.EmbedURL == "" { return debugLogMsg("Extractor aborted: empty embed URL") } logcb := func(line string) { m.debugLines = append(m.debugLines, line) if len(m.debugLines) > 200 { m.debugLines = m.debugLines[len(m.debugLines)-200:] } } logcb(fmt.Sprintf("[extractor] Starting puppeteer extractor for %s", st.EmbedURL)) m3u8, hdrs, err := extractM3U8Lite(st.EmbedURL, func(line string) { m.debugLines = append(m.debugLines, line) }) if err != nil { logcb(fmt.Sprintf("[extractor] ❌ %v", err)) return debugLogMsg(fmt.Sprintf("Extractor failed: %v", err)) } logcb(fmt.Sprintf("[extractor] ✅ Found M3U8: %s", m3u8)) if len(hdrs) > 0 { logcb(fmt.Sprintf("[extractor] Captured %d headers", len(hdrs))) } if err := LaunchMPVWithHeaders(m3u8, hdrs, logcb); err != nil { logcb(fmt.Sprintf("[mpv] ❌ %v", err)) return debugLogMsg(fmt.Sprintf("MPV error: %v", err)) } logcb(fmt.Sprintf("[mpv] ▶ Streaming started for %s", st.EmbedURL)) return debugLogMsg("Extractor completed successfully") } } // ──────────────────────────────── // LOG TO UI // ──────────────────────────────── func (m Model) logToUI(line string) tea.Cmd { return func() tea.Msg { return debugLogMsg(line) } }