diff --git a/internal/app.go b/internal/app.go index 96f373c..f1790f9 100644 --- a/internal/app.go +++ b/internal/app.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/exec" + "strings" "time" "github.com/charmbracelet/bubbles/help" @@ -22,28 +23,31 @@ import ( 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")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + 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.Quit} + 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.Refresh, k.Quit}, + {k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Quit}, } } @@ -81,10 +85,11 @@ const ( type Model struct { apiClient *Client - styles Styles - keys keyMap - help help.Model - focus focusCol + styles Styles + keys keyMap + help help.Model + focus focusCol + lastError error sports *ListColumn[Sport] matches *ListColumn[Match] @@ -148,16 +153,53 @@ func (m Model) Init() tea.Cmd { // 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), ) - status := m.styles.Status.Render(m.status) - return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys)) + 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()) } // @@ -169,25 +211,44 @@ func (m Model) View() string { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: - m.sports.SetWidth(msg.Width / 3) - m.matches.SetWidth(msg.Width / 3) - m.streams.SetWidth(msg.Width / 3) + 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: @@ -198,6 +259,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streams.CursorUp() } return m, nil + case key.Matches(msg, m.keys.Down): switch m.focus { case focusSports: @@ -208,6 +270,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streams.CursorDown() } return m, nil + case key.Matches(msg, m.keys.Enter): switch m.focus { case focusSports: @@ -226,6 +289,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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 @@ -251,7 +337,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case errorMsg: - m.status = fmt.Sprintf("Error: %v", msg) + m.lastError = msg return m, nil } return m, nil @@ -316,3 +402,32 @@ func (m Model) launchMPV(st Stream) tea.Cmd { 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 +} diff --git a/internal/columns.go b/internal/columns.go index 91aa469..63ea10f 100644 --- a/internal/columns.go +++ b/internal/columns.go @@ -16,16 +16,19 @@ type Styles struct { Box lipgloss.Style Active lipgloss.Style Status lipgloss.Style + Error lipgloss.Style // NEW: for red bold error lines } func NewStyles() Styles { - border := lipgloss.RoundedBorder() - return Styles{ - Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), - Box: lipgloss.NewStyle().Border(border).Padding(0, 1).MarginRight(1), - Active: lipgloss.NewStyle().Border(border).BorderForeground(lipgloss.Color("10")).Padding(0, 1).MarginRight(1), - Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1), - } + border := lipgloss.RoundedBorder() + return Styles{ + Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")), + // REMOVE MarginRight(1) from both Box and Active: + Box: lipgloss.NewStyle().Border(border).Padding(0, 1), + Active: lipgloss.NewStyle().Border(border).BorderForeground(lipgloss.Color("10")).Padding(0, 1), + Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1), + Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true), + } } // ──────────────────────────────── @@ -55,7 +58,17 @@ func (c *ListColumn[T]) SetItems(items []T) { } func (c *ListColumn[T]) SetTitle(title string) { c.title = title } -func (c *ListColumn[T]) SetWidth(w int) { if w > 20 { c.width = w - 2 } } + +func (c *ListColumn[T]) SetWidth(w int) { + // w is the total width the app wants to allocate to the box. + // Subtract 4 for border (2) + padding (2) to get interior content width. + if w < 4 { + c.width = 0 + return + } + c.width = w - 4 +} + func (c *ListColumn[T]) SetHeight(h int) { if h > 5 { c.height = h - 5 } } func (c *ListColumn[T]) CursorUp() { @@ -120,5 +133,6 @@ func (c *ListColumn[T]) View(styles Styles, focused bool) string { } content := strings.Join(lines, "\n") - return box.Width(c.width + 2).Render(head + "\n" + content) -} + // IMPORTANT: width = interior content width + 4 (border+padding) + return box.Width(c.width + 4).Render(head + "\n" + content) +} \ No newline at end of file diff --git a/internal/stream_utils.go b/internal/stream_utils.go new file mode 100644 index 0000000..3e99238 --- /dev/null +++ b/internal/stream_utils.go @@ -0,0 +1,94 @@ +package internal + +import ( + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os/exec" + "regexp" + "strings" + "time" +) + +// openBrowser tries to open the embed URL in the system browser. +func openBrowser(link string) error { + if link == "" { + return errors.New("empty URL") + } + return exec.Command("xdg-open", link).Start() +} + +// deriveHeaders guesses Origin, Referer, and User-Agent based on known embed domains. +func deriveHeaders(embed string) (origin, referer, ua string, err error) { + if embed == "" { + return "", "", "", errors.New("empty embed url") + } + + u, err := url.Parse(embed) + if err != nil { + return "", "", "", fmt.Errorf("parse url: %w", err) + } + + host := u.Host + if strings.Contains(host, "embedsports") { + origin = "https://embedsports.top" + referer = "https://embedsports.top/" + } else { + origin = fmt.Sprintf("https://%s", host) + referer = fmt.Sprintf("https://%s/", host) + } + + ua = "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0" + return origin, referer, ua, nil +} + +// fetchHTML performs a GET request with proper headers and returns body text. +func fetchHTML(embed, ua, origin, referer string, timeout time.Duration) (string, error) { + client := &http.Client{Timeout: timeout} + req, err := http.NewRequest("GET", embed, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", ua) + req.Header.Set("Origin", origin) + req.Header.Set("Referer", referer) + req.Header.Set("Accept", "*/*") + req.Header.Set("Accept-Language", "en-US,en;q=0.5") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} + +// extractM3U8 uses regex to find an .m3u8 playlist link from an embed page. +func extractM3U8(html string) string { + re := regexp.MustCompile(`https?://[^\s'"]+\.m3u8[^\s'"]*`) + m := re.FindString(html) + return strings.TrimSpace(m) +} + +// launchMPV executes mpv with all the necessary HTTP headers. +func launchMPV(m3u8, ua, origin, referer string) error { + args := []string{ + "--no-terminal", + "--really-quiet", + fmt.Sprintf(`--http-header-fields=User-Agent: %s`, ua), + fmt.Sprintf(`--http-header-fields=Origin: %s`, origin), + fmt.Sprintf(`--http-header-fields=Referer: %s`, referer), + m3u8, + } + + cmd := exec.Command("mpv", args...) + return cmd.Start() +}