Improve TUI clarity and status messaging
This commit is contained in:
291
internal/app.go
291
internal/app.go
@@ -20,7 +20,12 @@ type keyMap struct {
|
|||||||
Up, Down, Left, Right key.Binding
|
Up, Down, Left, Right key.Binding
|
||||||
Enter, Quit, Refresh key.Binding
|
Enter, Quit, Refresh key.Binding
|
||||||
OpenBrowser, OpenMPV key.Binding
|
OpenBrowser, OpenMPV key.Binding
|
||||||
Help, Debug key.Binding
|
Help key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
type helpKeyMap struct {
|
||||||
|
base keyMap
|
||||||
|
showMPV bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
@@ -35,7 +40,6 @@ func defaultKeys() keyMap {
|
|||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||||
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "toggle help")),
|
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "toggle help")),
|
||||||
Debug: key.NewBinding(key.WithKeys("f12"), key.WithHelp("F12", "debug panel")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +50,29 @@ func (k keyMap) ShortHelp() []key.Binding {
|
|||||||
func (k keyMap) FullHelp() [][]key.Binding {
|
func (k keyMap) FullHelp() [][]key.Binding {
|
||||||
return [][]key.Binding{
|
return [][]key.Binding{
|
||||||
{k.Up, k.Down, k.Left, k.Right},
|
{k.Up, k.Down, k.Left, k.Right},
|
||||||
{k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Help, k.Debug, k.Quit},
|
{k.Enter, k.OpenBrowser, k.OpenMPV, k.Refresh, k.Help, k.Quit},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h helpKeyMap) ShortHelp() []key.Binding {
|
||||||
|
bindings := []key.Binding{h.base.Up, h.base.Down, h.base.Left, h.base.Right, h.base.Enter, h.base.OpenBrowser}
|
||||||
|
if h.showMPV {
|
||||||
|
bindings = append(bindings, h.base.OpenMPV)
|
||||||
|
}
|
||||||
|
bindings = append(bindings, h.base.Help, h.base.Quit)
|
||||||
|
return bindings
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h helpKeyMap) FullHelp() [][]key.Binding {
|
||||||
|
row2 := []key.Binding{h.base.Enter, h.base.OpenBrowser}
|
||||||
|
if h.showMPV {
|
||||||
|
row2 = append(row2, h.base.OpenMPV)
|
||||||
|
}
|
||||||
|
row2 = append(row2, h.base.Refresh, h.base.Help, h.base.Quit)
|
||||||
|
|
||||||
|
return [][]key.Binding{
|
||||||
|
{h.base.Up, h.base.Down, h.base.Left, h.base.Right},
|
||||||
|
row2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +104,45 @@ const (
|
|||||||
const (
|
const (
|
||||||
viewMain viewMode = iota
|
viewMain viewMode = iota
|
||||||
viewHelp
|
viewHelp
|
||||||
viewDebug
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func formatViewerCount(count int) string {
|
||||||
|
if count >= 1_000_000 {
|
||||||
|
value := float64(count) / 1_000_000
|
||||||
|
formatted := fmt.Sprintf("%.1f", value)
|
||||||
|
formatted = strings.TrimSuffix(formatted, ".0")
|
||||||
|
return formatted + "m"
|
||||||
|
}
|
||||||
|
|
||||||
|
if count >= 1000 {
|
||||||
|
value := float64(count) / 1000
|
||||||
|
formatted := fmt.Sprintf("%.1f", value)
|
||||||
|
formatted = strings.TrimSuffix(formatted, ".0")
|
||||||
|
return formatted + "k"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reorderStreams(streams []Stream) []Stream {
|
||||||
|
if len(streams) == 0 {
|
||||||
|
return streams
|
||||||
|
}
|
||||||
|
|
||||||
|
regular := make([]Stream, 0, len(streams))
|
||||||
|
admin := make([]Stream, 0)
|
||||||
|
|
||||||
|
for _, st := range streams {
|
||||||
|
if strings.EqualFold(st.Source, "admin") {
|
||||||
|
admin = append(admin, st)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regular = append(regular, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(regular, admin...)
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
// MODEL
|
// MODEL
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
@@ -129,7 +191,7 @@ func New(debug bool) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if debug {
|
if debug {
|
||||||
m.currentView = viewDebug
|
m.debugLines = append(m.debugLines, "(debug logging enabled)")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name })
|
m.sports = NewListColumn[Sport]("Sports", func(s Sport) string { return s.Name })
|
||||||
@@ -139,17 +201,44 @@ func New(debug bool) Model {
|
|||||||
if mt.Teams != nil && mt.Teams.Home != nil && mt.Teams.Away != nil {
|
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)
|
title = fmt.Sprintf("%s vs %s", mt.Teams.Home.Name, mt.Teams.Away.Name)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s %s (%s)", when, title, mt.Category)
|
|
||||||
|
viewers := ""
|
||||||
|
if mt.Viewers > 0 {
|
||||||
|
viewers = fmt.Sprintf(" (%s viewers)", formatViewerCount(mt.Viewers))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s %s%s (%s)", when, title, viewers, mt.Category)
|
||||||
|
})
|
||||||
|
m.matches.SetSeparator(func(prev, curr Match) (string, bool) {
|
||||||
|
currDay := time.UnixMilli(curr.Date).Local().Format("Jan 2")
|
||||||
|
prevDay := ""
|
||||||
|
if prev.Date != 0 {
|
||||||
|
prevDay = time.UnixMilli(prev.Date).Local().Format("Jan 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
if prevDay == "" || prevDay != currDay {
|
||||||
|
return currDay, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
})
|
})
|
||||||
m.streams = NewListColumn[Stream]("Streams", func(st Stream) string {
|
m.streams = NewListColumn[Stream]("Streams", func(st Stream) string {
|
||||||
quality := "SD"
|
quality := "SD"
|
||||||
if st.HD {
|
if st.HD {
|
||||||
quality = "HD"
|
quality = "HD"
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("#%d %s (%s) – %s", st.StreamNo, st.Language, quality, st.Source)
|
viewers := formatViewerCount(st.Viewers)
|
||||||
|
return fmt.Sprintf("#%d %s (%s) – %s — (%s viewers)", st.StreamNo, st.Language, quality, st.Source, viewers)
|
||||||
|
})
|
||||||
|
m.streams.SetSeparator(func(prev, curr Stream) (string, bool) {
|
||||||
|
isAdmin := strings.EqualFold(curr.Source, "admin")
|
||||||
|
wasAdmin := strings.EqualFold(prev.Source, "admin")
|
||||||
|
if isAdmin && !wasAdmin {
|
||||||
|
return "Browser Only", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
})
|
})
|
||||||
|
|
||||||
m.status = fmt.Sprintf("Using API %s | Loading…", base)
|
m.status = fmt.Sprintf("Using API %s | Loading sports and matches…", base)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,25 +254,52 @@ func (m Model) View() string {
|
|||||||
switch m.currentView {
|
switch m.currentView {
|
||||||
case viewHelp:
|
case viewHelp:
|
||||||
return m.renderHelpPanel()
|
return m.renderHelpPanel()
|
||||||
case viewDebug:
|
|
||||||
return m.renderDebugPanel()
|
|
||||||
default:
|
default:
|
||||||
return m.renderMainView()
|
return m.renderMainView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderMainView() string {
|
func (m Model) renderMainView() string {
|
||||||
cols := lipgloss.JoinHorizontal(
|
gap := lipgloss.NewStyle().MarginRight(1)
|
||||||
lipgloss.Top,
|
sportsCol := gap.Render(m.sports.View(m.styles, m.focus == focusSports))
|
||||||
m.sports.View(m.styles, m.focus == focusSports),
|
matchesCol := gap.Render(m.matches.View(m.styles, m.focus == focusMatches))
|
||||||
m.matches.View(m.styles, m.focus == focusMatches),
|
streamsCol := m.streams.View(m.styles, m.focus == focusStreams)
|
||||||
m.streams.View(m.styles, m.focus == focusStreams),
|
|
||||||
)
|
cols := lipgloss.JoinHorizontal(lipgloss.Top, sportsCol, matchesCol, streamsCol)
|
||||||
status := m.styles.Status.Render(m.status)
|
colsWidth := lipgloss.Width(cols)
|
||||||
if m.lastError != nil {
|
debugPane := m.renderDebugPane(colsWidth)
|
||||||
status = m.styles.Error.Render(fmt.Sprintf("⚠️ %v", m.lastError))
|
status := m.renderStatusLine()
|
||||||
|
keys := helpKeyMap{base: m.keys, showMPV: m.canUseMPVShortcut()}
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, cols, debugPane, status, m.help.View(keys))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) canUseMPVShortcut() bool {
|
||||||
|
if st, ok := m.streams.Selected(); ok {
|
||||||
|
return !strings.EqualFold(st.Source, "admin")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) renderStatusLine() string {
|
||||||
|
focusLabel := m.currentFocusLabel()
|
||||||
|
statusText := fmt.Sprintf("%s | Focus: %s (←/→)", m.status, focusLabel)
|
||||||
|
if m.lastError != nil {
|
||||||
|
return m.styles.Error.Render(fmt.Sprintf("⚠️ %v | Focus: %s (Esc to dismiss)", m.lastError, focusLabel))
|
||||||
|
}
|
||||||
|
return m.styles.Status.Render(statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) currentFocusLabel() string {
|
||||||
|
switch m.focus {
|
||||||
|
case focusSports:
|
||||||
|
return "Sports"
|
||||||
|
case focusMatches:
|
||||||
|
return "Matches"
|
||||||
|
case focusStreams:
|
||||||
|
return "Streams"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
}
|
}
|
||||||
return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderHelpPanel() string {
|
func (m Model) renderHelpPanel() string {
|
||||||
@@ -197,7 +313,6 @@ func (m Model) renderHelpPanel() string {
|
|||||||
{"R", "Refresh"},
|
{"R", "Refresh"},
|
||||||
{"Q", "Quit"},
|
{"Q", "Quit"},
|
||||||
{"F1 / ?", "Toggle this help"},
|
{"F1 / ?", "Toggle this help"},
|
||||||
{"F12", "Show debug panel"},
|
|
||||||
{"Esc", "Return to main view"},
|
{"Esc", "Return to main view"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,33 +321,49 @@ func (m Model) renderHelpPanel() string {
|
|||||||
for _, b := range bindings {
|
for _, b := range bindings {
|
||||||
sb.WriteString(fmt.Sprintf("%-18s %s\n", b[0], b[1]))
|
sb.WriteString(fmt.Sprintf("%-18s %s\n", b[0], b[1]))
|
||||||
}
|
}
|
||||||
sb.WriteString("\nPress Esc to return.")
|
sb.WriteString("\n")
|
||||||
|
sb.WriteString("Admin streams can only be opened in the browser because STREAMED obfuscates them\n\n")
|
||||||
|
sb.WriteString("Press Esc to return.")
|
||||||
|
|
||||||
panel := lipgloss.NewStyle().
|
panel := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("#FA8072")).
|
BorderForeground(lipgloss.Color("#FA8072")).
|
||||||
Padding(1, 2).
|
Padding(1, 2).
|
||||||
Width(int(float64(m.TerminalWidth) * 0.97)).
|
Width(int(float64(m.TerminalWidth) * 0.95)).
|
||||||
Render(sb.String())
|
Render(sb.String())
|
||||||
|
|
||||||
return panel
|
return panel
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderDebugPanel() string {
|
func (m Model) renderDebugPane(widthHint int) string {
|
||||||
header := m.styles.Title.Render("Debug Output (F12 / Esc to close)")
|
header := m.styles.Title.Render("Debug log")
|
||||||
|
visibleLines := 4
|
||||||
if len(m.debugLines) == 0 {
|
if len(m.debugLines) == 0 {
|
||||||
m.debugLines = append(m.debugLines, "(no debug output yet)")
|
m.debugLines = append(m.debugLines, "(debug log empty)")
|
||||||
|
}
|
||||||
|
start := len(m.debugLines) - visibleLines
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
lines := m.debugLines[start:]
|
||||||
|
for len(lines) < visibleLines {
|
||||||
|
lines = append(lines, "")
|
||||||
}
|
}
|
||||||
content := strings.Join(m.debugLines, "\n")
|
|
||||||
|
|
||||||
panel := lipgloss.NewStyle().
|
content := strings.Join(lines, "\n")
|
||||||
|
width := widthHint
|
||||||
|
if width == 0 {
|
||||||
|
width = int(float64(m.TerminalWidth) * 0.95)
|
||||||
|
if width == 0 {
|
||||||
|
width = 80
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Width(width).
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(lipgloss.Color("#FA8072")).
|
Padding(0, 1).
|
||||||
Padding(1, 2).
|
Render(header + "\n" + content)
|
||||||
Width(int(float64(m.TerminalWidth) * 0.97)).
|
|
||||||
Render(header + "\n\n" + content)
|
|
||||||
|
|
||||||
return panel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
@@ -251,17 +382,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.TerminalWidth = msg.Width
|
m.TerminalWidth = msg.Width
|
||||||
usableHeight := int(float64(msg.Height) * 0.9)
|
debugPaneHeight := 7
|
||||||
totalAvailableWidth := int(float64(msg.Width) * 0.97)
|
statusHeight := 1
|
||||||
|
helpHeight := 2
|
||||||
|
reservedHeight := debugPaneHeight + statusHeight + helpHeight
|
||||||
|
usableHeight := msg.Height - reservedHeight
|
||||||
|
if usableHeight < 5 {
|
||||||
|
usableHeight = 5
|
||||||
|
}
|
||||||
|
totalAvailableWidth := int(float64(msg.Width) * 0.95)
|
||||||
borderPadding := 4
|
borderPadding := 4
|
||||||
totalBorderSpace := borderPadding * 3
|
totalBorderSpace := borderPadding * 3
|
||||||
availableWidth := totalAvailableWidth - totalBorderSpace
|
availableWidth := totalAvailableWidth - totalBorderSpace
|
||||||
colWidth := availableWidth / 3
|
|
||||||
remainder := availableWidth % 3
|
|
||||||
|
|
||||||
m.sports.SetWidth(colWidth + borderPadding)
|
// Allocate widths with weights: Sports=3, Matches=10, Streams=5 (18 total)
|
||||||
m.matches.SetWidth(colWidth + borderPadding)
|
// Streams gain an additional ~20% width by borrowing space from Matches.
|
||||||
m.streams.SetWidth(colWidth + remainder + borderPadding)
|
weightTotal := 18
|
||||||
|
unit := availableWidth / weightTotal
|
||||||
|
remainder := availableWidth - (unit * weightTotal)
|
||||||
|
|
||||||
|
sportsWidth := unit * 3
|
||||||
|
matchesWidth := unit * 10
|
||||||
|
streamsWidth := unit * 5
|
||||||
|
|
||||||
|
// Assign any leftover pixels to the widest column (matches) to keep alignment.
|
||||||
|
matchesWidth += remainder
|
||||||
|
|
||||||
|
m.sports.SetWidth(sportsWidth + borderPadding)
|
||||||
|
m.matches.SetWidth(matchesWidth + borderPadding)
|
||||||
|
m.streams.SetWidth(streamsWidth + borderPadding)
|
||||||
|
|
||||||
m.sports.SetHeight(usableHeight)
|
m.sports.SetHeight(usableHeight)
|
||||||
m.matches.SetHeight(usableHeight)
|
m.matches.SetHeight(usableHeight)
|
||||||
@@ -281,14 +430,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.currentView = viewHelp
|
m.currentView = viewHelp
|
||||||
}
|
}
|
||||||
return m, nil
|
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 {
|
if m.currentView != viewMain {
|
||||||
@@ -337,17 +478,27 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
switch m.focus {
|
switch m.focus {
|
||||||
case focusSports:
|
case focusSports:
|
||||||
if sport, ok := m.sports.Selected(); ok {
|
if sport, ok := m.sports.Selected(); ok {
|
||||||
|
m.lastError = nil
|
||||||
m.status = fmt.Sprintf("Loading matches for %s…", sport.Name)
|
m.status = fmt.Sprintf("Loading matches for %s…", sport.Name)
|
||||||
m.streams.SetItems(nil)
|
m.streams.SetItems(nil)
|
||||||
return m, m.fetchMatchesForSport(sport)
|
return m, m.fetchMatchesForSport(sport)
|
||||||
}
|
}
|
||||||
case focusMatches:
|
case focusMatches:
|
||||||
if mt, ok := m.matches.Selected(); ok {
|
if mt, ok := m.matches.Selected(); ok {
|
||||||
|
m.lastError = nil
|
||||||
m.status = fmt.Sprintf("Loading streams for %s…", mt.Title)
|
m.status = fmt.Sprintf("Loading streams for %s…", mt.Title)
|
||||||
return m, m.fetchStreamsForMatch(mt)
|
return m, m.fetchStreamsForMatch(mt)
|
||||||
}
|
}
|
||||||
case focusStreams:
|
case focusStreams:
|
||||||
if st, ok := m.streams.Selected(); ok {
|
if st, ok := m.streams.Selected(); ok {
|
||||||
|
if strings.EqualFold(st.Source, "admin") {
|
||||||
|
if st.EmbedURL != "" {
|
||||||
|
_ = openBrowser(st.EmbedURL)
|
||||||
|
m.lastError = nil
|
||||||
|
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
return m, tea.Batch(
|
return m, tea.Batch(
|
||||||
m.logToUI(fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)),
|
m.logToUI(fmt.Sprintf("Attempting extractor for %s", st.EmbedURL)),
|
||||||
m.runExtractor(st),
|
m.runExtractor(st),
|
||||||
@@ -360,6 +511,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.focus == focusStreams {
|
if m.focus == focusStreams {
|
||||||
if st, ok := m.streams.Selected(); ok && st.EmbedURL != "" {
|
if st, ok := m.streams.Selected(); ok && st.EmbedURL != "" {
|
||||||
_ = openBrowser(st.EmbedURL)
|
_ = openBrowser(st.EmbedURL)
|
||||||
|
m.lastError = nil
|
||||||
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
m.status = fmt.Sprintf("🌐 Opened in browser: %s", st.EmbedURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,28 +520,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case sportsLoadedMsg:
|
case sportsLoadedMsg:
|
||||||
m.sports.SetItems(msg)
|
sports := prependPopularSport(msg)
|
||||||
m.status = fmt.Sprintf("Loaded %d sports", len(msg))
|
m.sports.SetItems(sports)
|
||||||
|
m.lastError = nil
|
||||||
|
m.status = fmt.Sprintf("Loaded %d sports – pick one with Enter or stay on Popular Matches", len(sports))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case matchesLoadedMsg:
|
case matchesLoadedMsg:
|
||||||
m.matches.SetTitle(msg.Title)
|
m.matches.SetTitle(msg.Title)
|
||||||
m.matches.SetItems(msg.Matches)
|
m.matches.SetItems(msg.Matches)
|
||||||
m.status = fmt.Sprintf("Loaded %d matches", len(msg.Matches))
|
m.lastError = nil
|
||||||
|
m.status = fmt.Sprintf("Loaded %d matches – choose one to load streams", len(msg.Matches))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case streamsLoadedMsg:
|
case streamsLoadedMsg:
|
||||||
m.streams.SetItems(msg)
|
m.streams.SetItems(msg)
|
||||||
m.status = fmt.Sprintf("Loaded %d streams", len(msg))
|
m.lastError = nil
|
||||||
|
m.status = fmt.Sprintf("Loaded %d streams – Enter to launch mpv, o to open in browser", len(msg))
|
||||||
m.focus = focusStreams
|
m.focus = focusStreams
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case launchStreamMsg:
|
case launchStreamMsg:
|
||||||
|
m.lastError = nil
|
||||||
m.status = fmt.Sprintf("🎥 Launched mpv: %s", msg.URL)
|
m.status = fmt.Sprintf("🎥 Launched mpv: %s", msg.URL)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case errorMsg:
|
case errorMsg:
|
||||||
m.lastError = msg
|
m.lastError = msg
|
||||||
|
m.status = "Encountered an error while contacting the API"
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
@@ -421,21 +579,42 @@ func (m Model) fetchPopularMatches() tea.Cmd {
|
|||||||
|
|
||||||
func (m Model) fetchMatchesForSport(s Sport) tea.Cmd {
|
func (m Model) fetchMatchesForSport(s Sport) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
matches, err := m.apiClient.GetMatchesBySport(context.Background(), s.ID)
|
get := func() ([]Match, error) {
|
||||||
|
if strings.EqualFold(s.ID, "popular") {
|
||||||
|
return m.apiClient.GetPopularMatches(context.Background())
|
||||||
|
}
|
||||||
|
return m.apiClient.GetMatchesBySport(context.Background(), s.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
matches, err := get()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorMsg(err)
|
return errorMsg(err)
|
||||||
}
|
}
|
||||||
return matchesLoadedMsg{Matches: matches, Title: fmt.Sprintf("Matches (%s)", s.Name)}
|
title := fmt.Sprintf("Matches (%s)", s.Name)
|
||||||
|
if strings.EqualFold(s.ID, "popular") {
|
||||||
|
title = "Popular Matches"
|
||||||
|
}
|
||||||
|
return matchesLoadedMsg{Matches: matches, Title: title}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prependPopularSport(sports []Sport) []Sport {
|
||||||
|
for _, s := range sports {
|
||||||
|
if strings.EqualFold(s.ID, "popular") || strings.EqualFold(s.Name, "popular") {
|
||||||
|
return sports
|
||||||
|
}
|
||||||
|
}
|
||||||
|
popular := Sport{ID: "popular", Name: "Popular"}
|
||||||
|
return append([]Sport{popular}, sports...)
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd {
|
func (m Model) fetchStreamsForMatch(mt Match) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
streams, err := m.apiClient.GetStreamsForMatch(context.Background(), mt)
|
streams, err := m.apiClient.GetStreamsForMatch(context.Background(), mt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorMsg(err)
|
return errorMsg(err)
|
||||||
}
|
}
|
||||||
return streamsLoadedMsg(streams)
|
return streamsLoadedMsg(reorderStreams(streams))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ type Match struct {
|
|||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"sources"`
|
} `json:"sources"`
|
||||||
|
|
||||||
|
Viewers int `json:"viewers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stream struct {
|
type Stream struct {
|
||||||
@@ -71,6 +73,7 @@ type Stream struct {
|
|||||||
HD bool `json:"hd"`
|
HD bool `json:"hd"`
|
||||||
EmbedURL string `json:"embedUrl"`
|
EmbedURL string `json:"embedUrl"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
Viewers int `json:"viewers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────
|
// ────────────────────────────────
|
||||||
@@ -88,7 +91,33 @@ func (c *Client) GetSports(ctx context.Context) ([]Sport, error) {
|
|||||||
|
|
||||||
func (c *Client) GetPopularMatches(ctx context.Context) ([]Match, error) {
|
func (c *Client) GetPopularMatches(ctx context.Context) ([]Match, error) {
|
||||||
url := c.base + "/api/matches/all/popular"
|
url := c.base + "/api/matches/all/popular"
|
||||||
return c.getMatches(ctx, url)
|
matches, err := c.getMatches(ctx, url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
viewCounts, err := c.GetPopularViewCounts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range matches {
|
||||||
|
// Prefer a direct match on the match ID.
|
||||||
|
if viewers, ok := viewCounts.ByMatchID[matches[i].ID]; ok {
|
||||||
|
matches[i].Viewers = viewers
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: some IDs can differ between endpoints, so also try source IDs.
|
||||||
|
for _, src := range matches[i].Sources {
|
||||||
|
if viewers, ok := viewCounts.BySourceID[src.ID]; ok {
|
||||||
|
matches[i].Viewers = viewers
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match, error) {
|
func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match, error) {
|
||||||
@@ -96,6 +125,41 @@ func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match
|
|||||||
return c.getMatches(ctx, url)
|
return c.getMatches(ctx, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PopularViewCounts struct {
|
||||||
|
ByMatchID map[string]int
|
||||||
|
BySourceID map[string]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetPopularViewCounts(ctx context.Context) (PopularViewCounts, error) {
|
||||||
|
url := "https://streami.su/api/matches/live/popular-viewcount"
|
||||||
|
|
||||||
|
var payload []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Viewers int `json:"viewers"`
|
||||||
|
Sources []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.get(ctx, url, &payload); err != nil {
|
||||||
|
return PopularViewCounts{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
matchMap := make(map[string]int, len(payload))
|
||||||
|
sourceMap := make(map[string]int, len(payload))
|
||||||
|
for _, item := range payload {
|
||||||
|
matchMap[item.ID] = item.Viewers
|
||||||
|
for _, src := range item.Sources {
|
||||||
|
if src.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sourceMap[src.ID] = item.Viewers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PopularViewCounts{ByMatchID: matchMap, BySourceID: sourceMap}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetStreamsForMatch(ctx context.Context, mt Match) ([]Stream, error) {
|
func (c *Client) GetStreamsForMatch(ctx context.Context, mt Match) ([]Stream, error) {
|
||||||
var all []Stream
|
var all []Stream
|
||||||
for _, src := range mt.Sources {
|
for _, src := range mt.Sources {
|
||||||
|
|||||||
@@ -17,19 +17,21 @@ type Styles struct {
|
|||||||
Active lipgloss.Style
|
Active lipgloss.Style
|
||||||
Status lipgloss.Style
|
Status lipgloss.Style
|
||||||
Error lipgloss.Style // NEW: for red bold error lines
|
Error lipgloss.Style // NEW: for red bold error lines
|
||||||
|
Subtle lipgloss.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewStyles() Styles {
|
func NewStyles() Styles {
|
||||||
border := lipgloss.RoundedBorder()
|
border := lipgloss.RoundedBorder()
|
||||||
return Styles{
|
return Styles{
|
||||||
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")),
|
Title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")),
|
||||||
Box: lipgloss.NewStyle().Border(border).Padding(0, 1).MarginRight(1),
|
Box: lipgloss.NewStyle().Border(border).Padding(0, 1),
|
||||||
Active: lipgloss.NewStyle().
|
Active: lipgloss.NewStyle().
|
||||||
Border(border).
|
Border(border).
|
||||||
BorderForeground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously
|
BorderForeground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously
|
||||||
Padding(0, 1).
|
Padding(0, 1),
|
||||||
MarginRight(1),
|
|
||||||
Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
|
Status: lipgloss.NewStyle().Foreground(lipgloss.Color("8")).MarginTop(1),
|
||||||
|
Error: lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true),
|
||||||
|
Subtle: lipgloss.NewStyle().Foreground(lipgloss.Color("243")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +49,57 @@ type ListColumn[T any] struct {
|
|||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
render renderer[T]
|
render renderer[T]
|
||||||
|
|
||||||
|
separator func(prev, curr T) (string, bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewListColumn[T any](title string, r renderer[T]) *ListColumn[T] {
|
func NewListColumn[T any](title string, r renderer[T]) *ListColumn[T] {
|
||||||
return &ListColumn[T]{title: title, render: r, width: 30, height: 20}
|
return &ListColumn[T]{title: title, render: r, width: 30, height: 20}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *ListColumn[T]) SetSeparator(sep func(prev, curr T) (string, bool)) {
|
||||||
|
c.separator = sep
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateToWidth(text string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if lipgloss.Width(text) <= width {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
runes := []rune(text)
|
||||||
|
total := 0
|
||||||
|
for i, r := range runes {
|
||||||
|
rWidth := lipgloss.Width(string(r))
|
||||||
|
if total+rWidth > width {
|
||||||
|
return string(runes[:i])
|
||||||
|
}
|
||||||
|
total += rWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSeparatorLine(label string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := strings.TrimSpace(label)
|
||||||
|
padded := fmt.Sprintf(" %s ", trimmed)
|
||||||
|
remaining := width - lipgloss.Width(padded)
|
||||||
|
if remaining <= 0 {
|
||||||
|
return truncateToWidth(padded, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
left := remaining / 2
|
||||||
|
right := remaining - left
|
||||||
|
return strings.Repeat("─", left) + padded + strings.Repeat("─", right)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) SetItems(items []T) {
|
func (c *ListColumn[T]) SetItems(items []T) {
|
||||||
c.items = items
|
c.items = items
|
||||||
c.selected = 0
|
c.selected = 0
|
||||||
@@ -72,8 +119,8 @@ func (c *ListColumn[T]) SetWidth(w int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) SetHeight(h int) {
|
func (c *ListColumn[T]) SetHeight(h int) {
|
||||||
if h > 5 {
|
if h > 6 {
|
||||||
c.height = h - 5
|
c.height = h - 6
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,18 +128,14 @@ func (c *ListColumn[T]) CursorUp() {
|
|||||||
if c.selected > 0 {
|
if c.selected > 0 {
|
||||||
c.selected--
|
c.selected--
|
||||||
}
|
}
|
||||||
if c.selected < c.scroll {
|
c.ensureSelectedVisible()
|
||||||
c.scroll = c.selected
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) CursorDown() {
|
func (c *ListColumn[T]) CursorDown() {
|
||||||
if c.selected < len(c.items)-1 {
|
if c.selected < len(c.items)-1 {
|
||||||
c.selected++
|
c.selected++
|
||||||
}
|
}
|
||||||
if c.selected >= c.scroll+c.height {
|
c.ensureSelectedVisible()
|
||||||
c.scroll = c.selected - c.height + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) Selected() (T, bool) {
|
func (c *ListColumn[T]) Selected() (T, bool) {
|
||||||
@@ -103,39 +146,149 @@ func (c *ListColumn[T]) Selected() (T, bool) {
|
|||||||
return c.items[c.selected], true
|
return c.items[c.selected], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type listRow[T any] struct {
|
||||||
|
text string
|
||||||
|
isSeparator bool
|
||||||
|
itemIndex int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ListColumn[T]) buildRows() []listRow[T] {
|
||||||
|
rows := make([]listRow[T], 0, len(c.items))
|
||||||
|
var prev T
|
||||||
|
|
||||||
|
for i, item := range c.items {
|
||||||
|
if c.separator != nil {
|
||||||
|
if sepText, ok := c.separator(prev, item); ok {
|
||||||
|
rows = append(rows, listRow[T]{text: sepText, isSeparator: true, itemIndex: -1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, listRow[T]{text: c.render(item), itemIndex: i})
|
||||||
|
prev = item
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ListColumn[T]) clampScroll(totalRows int) {
|
||||||
|
if c.height <= 0 {
|
||||||
|
c.scroll = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxScroll := totalRows - c.height
|
||||||
|
if maxScroll < 0 {
|
||||||
|
maxScroll = 0
|
||||||
|
}
|
||||||
|
if c.scroll > maxScroll {
|
||||||
|
c.scroll = maxScroll
|
||||||
|
}
|
||||||
|
if c.scroll < 0 {
|
||||||
|
c.scroll = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ListColumn[T]) ensureSelectedVisible() {
|
||||||
|
if len(c.items) == 0 {
|
||||||
|
c.scroll = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := c.buildRows()
|
||||||
|
selRow := 0
|
||||||
|
for idx, row := range rows {
|
||||||
|
if row.isSeparator {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if row.itemIndex == c.selected {
|
||||||
|
selRow = idx
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.height <= 0 {
|
||||||
|
c.scroll = selRow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if selRow < c.scroll {
|
||||||
|
c.scroll = selRow
|
||||||
|
}
|
||||||
|
if selRow >= c.scroll+c.height {
|
||||||
|
c.scroll = selRow - c.height + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
c.clampScroll(len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
func (c *ListColumn[T]) View(styles Styles, focused bool) string {
|
func (c *ListColumn[T]) View(styles Styles, focused bool) string {
|
||||||
box := styles.Box
|
box := styles.Box
|
||||||
if focused {
|
if focused {
|
||||||
box = styles.Active
|
box = styles.Active
|
||||||
}
|
}
|
||||||
|
|
||||||
head := styles.Title.Render(c.title)
|
titleText := fmt.Sprintf("%s (%d)", c.title, len(c.items))
|
||||||
|
if focused {
|
||||||
|
titleText = fmt.Sprintf("▶ %s", titleText)
|
||||||
|
}
|
||||||
|
head := styles.Title.Render(titleText)
|
||||||
|
meta := styles.Subtle.Render("Waiting for data…")
|
||||||
lines := []string{}
|
lines := []string{}
|
||||||
|
|
||||||
if len(c.items) == 0 {
|
if len(c.items) == 0 {
|
||||||
lines = append(lines, "(no items)")
|
lines = append(lines, "(no items)")
|
||||||
} else {
|
} else {
|
||||||
|
rows := c.buildRows()
|
||||||
|
c.clampScroll(len(rows))
|
||||||
|
|
||||||
start := c.scroll
|
start := c.scroll
|
||||||
end := start + c.height
|
end := start + c.height
|
||||||
if end > len(c.items) {
|
if end > len(rows) {
|
||||||
end = len(c.items)
|
end = len(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startItem, endItem := -1, -1
|
||||||
|
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
|
row := rows[i]
|
||||||
cursor := " "
|
cursor := " "
|
||||||
lineText := c.render(c.items[i])
|
lineText := row.text
|
||||||
if i == c.selected {
|
|
||||||
cursor = "▸ "
|
contentWidth := c.width - lipgloss.Width(cursor)
|
||||||
lineText = lipgloss.NewStyle().
|
|
||||||
Foreground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously
|
if row.isSeparator {
|
||||||
Bold(true).
|
lineText = buildSeparatorLine(lineText, contentWidth)
|
||||||
Render(lineText)
|
lineText = styles.Subtle.Render(lineText)
|
||||||
|
} else {
|
||||||
|
if contentWidth > 1 && lipgloss.Width(lineText) > contentWidth {
|
||||||
|
lineText = fmt.Sprintf("%s…", truncateToWidth(lineText, contentWidth-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
if startItem == -1 {
|
||||||
|
startItem = row.itemIndex
|
||||||
|
}
|
||||||
|
endItem = row.itemIndex
|
||||||
|
|
||||||
|
if row.itemIndex == c.selected {
|
||||||
|
cursor = "▸ "
|
||||||
|
lineText = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#FA8072")). // Not pink, its Salmon obviously
|
||||||
|
Bold(true).
|
||||||
|
Render(lineText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
line := fmt.Sprintf("%s%s", cursor, lineText)
|
line := fmt.Sprintf("%s%s", cursor, lineText)
|
||||||
if len(line) > c.width && c.width > 3 {
|
|
||||||
line = line[:c.width-3] + "…"
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if startItem == -1 {
|
||||||
|
startItem = 0
|
||||||
|
}
|
||||||
|
if endItem == -1 {
|
||||||
|
endItem = startItem
|
||||||
|
}
|
||||||
|
|
||||||
|
meta = styles.Subtle.Render(fmt.Sprintf("Showing %d–%d of %d", startItem+1, endItem+1, len(c.items)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fill remaining lines if fewer than height
|
// Fill remaining lines if fewer than height
|
||||||
@@ -145,5 +298,5 @@ func (c *ListColumn[T]) View(styles Styles, focused bool) string {
|
|||||||
|
|
||||||
content := strings.Join(lines, "\n")
|
content := strings.Join(lines, "\n")
|
||||||
// IMPORTANT: width = interior content width + 4 (border+padding)
|
// IMPORTANT: width = interior content width + 4 (border+padding)
|
||||||
return box.Width(c.width + 4).Render(head + "\n" + content)
|
return box.Width(c.width + 4).Render(head + "\n" + meta + "\n" + content)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user