434 lines
12 KiB
Go
434 lines
12 KiB
Go
package internal
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/charmbracelet/bubbles/help"
|
||
"github.com/charmbracelet/bubbles/key"
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
)
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// KEY MAP + HELP
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
type keyMap struct {
|
||
Up, Down, Left, Right key.Binding
|
||
Enter, Quit, Refresh key.Binding
|
||
OpenBrowser, OpenMPV 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")),
|
||
}
|
||
}
|
||
|
||
// implement help.KeyMap interface
|
||
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.Quit},
|
||
}
|
||
}
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// MESSAGE TYPES
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
type (
|
||
sportsLoadedMsg []Sport
|
||
matchesLoadedMsg struct {
|
||
Matches []Match
|
||
Title string
|
||
}
|
||
streamsLoadedMsg []Stream
|
||
errorMsg error
|
||
launchStreamMsg struct{ URL string }
|
||
)
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// MODEL
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
type focusCol int
|
||
|
||
const (
|
||
focusSports focusCol = iota
|
||
focusMatches
|
||
focusStreams
|
||
)
|
||
|
||
type Model struct {
|
||
apiClient *Client
|
||
|
||
styles Styles
|
||
keys keyMap
|
||
help help.Model
|
||
focus focusCol
|
||
lastError error
|
||
|
||
sports *ListColumn[Sport]
|
||
matches *ListColumn[Match]
|
||
streams *ListColumn[Stream]
|
||
|
||
status string
|
||
}
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// APP ENTRYPOINT
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
func Run() error {
|
||
p := tea.NewProgram(New(), tea.WithAltScreen())
|
||
_, err := p.Run()
|
||
return err
|
||
}
|
||
|
||
func New() Model {
|
||
base := BaseURLFromEnv()
|
||
client := NewClient(base, 15*time.Second)
|
||
|
||
styles := NewStyles()
|
||
m := Model{
|
||
apiClient: client,
|
||
styles: styles,
|
||
keys: defaultKeys(),
|
||
help: help.New(),
|
||
focus: focusSports,
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (m Model) Init() tea.Cmd {
|
||
return tea.Batch(m.fetchSports(), m.fetchPopularMatches())
|
||
}
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// VIEW
|
||
// ────────────────────────────────
|
||
//
|
||
func padToHeight(s string, height int) string {
|
||
lines := strings.Split(s, "\n")
|
||
for len(lines) < height {
|
||
lines = append(lines, "")
|
||
}
|
||
return strings.Join(lines[:height], "\n")
|
||
}
|
||
|
||
func (m Model) dynamicHelp() string {
|
||
switch m.focus {
|
||
case focusSports, focusMatches:
|
||
return m.help.View(keyMap{
|
||
Up: m.keys.Up, Down: m.keys.Down, Left: m.keys.Left, Right: m.keys.Right,
|
||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
|
||
OpenBrowser: m.keys.OpenBrowser, OpenMPV: m.keys.OpenMPV,
|
||
Quit: m.keys.Quit, Refresh: m.keys.Refresh,
|
||
})
|
||
case focusStreams:
|
||
return m.help.View(keyMap{
|
||
Up: m.keys.Up, Down: m.keys.Down, Left: m.keys.Left, Right: m.keys.Right,
|
||
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "mpv / browser")),
|
||
OpenBrowser: m.keys.OpenBrowser, OpenMPV: m.keys.OpenMPV,
|
||
Quit: m.keys.Quit, Refresh: m.keys.Refresh,
|
||
})
|
||
default:
|
||
return m.help.View(m.keys)
|
||
}
|
||
}
|
||
|
||
func (m Model) View() string {
|
||
// Copy styles so we can tweak the rightmost margin to 0
|
||
right := m.styles
|
||
right.Box = right.Box.MarginRight(0)
|
||
right.Active = right.Active.MarginRight(0)
|
||
|
||
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),
|
||
)
|
||
cols += " " // one-char right-edge buffer
|
||
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.dynamicHelp())
|
||
}
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// UPDATE
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||
switch msg := msg.(type) {
|
||
case tea.WindowSizeMsg:
|
||
total := msg.Width
|
||
bordersAndPads := 4 // 2 border + 2 padding per column
|
||
|
||
// Compute equal thirds, but reserve space for borders/pads
|
||
colWidth := (total / 3) - (bordersAndPads / 3)
|
||
|
||
// Give the rightmost column any leftover width to avoid clipping
|
||
remainder := total - (colWidth * 3)
|
||
rightWidth := colWidth + remainder - 1 // leave 1-char breathing room
|
||
|
||
usableHeight := int(float64(msg.Height) * 0.9)
|
||
|
||
m.sports.SetWidth(colWidth)
|
||
m.matches.SetWidth(colWidth)
|
||
m.streams.SetWidth(rightWidth)
|
||
|
||
m.sports.SetHeight(usableHeight)
|
||
m.matches.SetHeight(usableHeight)
|
||
m.streams.SetHeight(usableHeight)
|
||
return m, nil
|
||
|
||
case tea.KeyMsg:
|
||
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, m.launchMPV(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
|
||
|
||
case key.Matches(msg, m.keys.OpenMPV):
|
||
if m.focus == focusStreams {
|
||
if st, ok := m.streams.Selected(); ok {
|
||
go func(st Stream) {
|
||
if err := m.forceMPVLaunch(st); err != nil {
|
||
m.lastError = err
|
||
}
|
||
}(st)
|
||
m.status = fmt.Sprintf("🎞️ Attempting mpv: %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
|
||
}
|
||
|
||
//
|
||
// ────────────────────────────────
|
||
// COMMANDS
|
||
// ────────────────────────────────
|
||
//
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
func (m Model) launchMPV(st Stream) tea.Cmd {
|
||
return func() tea.Msg {
|
||
url := st.EmbedURL
|
||
if url == "" {
|
||
return errorMsg(fmt.Errorf("empty embedUrl for stream %s", st.ID))
|
||
}
|
||
cmd := exec.Command("mpv", "--no-terminal", "--really-quiet", url)
|
||
cmd.Stdout = os.Stdout
|
||
cmd.Stderr = os.Stderr
|
||
_ = cmd.Start()
|
||
return launchStreamMsg{URL: url}
|
||
}
|
||
}
|
||
|
||
// forceMPVLaunch attempts to extract .m3u8 and open it in mpv directly.
|
||
func (m Model) forceMPVLaunch(st Stream) error {
|
||
embed := strings.TrimSpace(st.EmbedURL)
|
||
if embed == "" {
|
||
return fmt.Errorf("no embed URL for stream %s", st.ID)
|
||
}
|
||
|
||
origin, referer, ua, err := deriveHeaders(embed)
|
||
if err != nil {
|
||
return fmt.Errorf("bad embed URL: %w", err)
|
||
}
|
||
|
||
body, err := fetchHTML(embed, ua, origin, referer, 12*time.Second)
|
||
if err != nil {
|
||
return fmt.Errorf("fetch failed: %w", err)
|
||
}
|
||
|
||
m3u8 := extractM3U8(body)
|
||
if m3u8 == "" {
|
||
return fmt.Errorf("no .m3u8 found in embed page")
|
||
}
|
||
|
||
if err := launchMPV(m3u8, ua, origin, referer); err != nil {
|
||
return fmt.Errorf("mpv launch failed: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}
|