From ffb667be538337612f8113e839e44529171d4e22 Mon Sep 17 00:00:00 2001 From: Salastil Date: Mon, 20 Oct 2025 03:42:09 -0400 Subject: [PATCH] Initial Commit --- go.mod | 9 ++ internal/app.go | 280 ++++++++++++++++++++++++++++++++++++++++++++ internal/client.go | 139 ++++++++++++++++++++++ internal/columns.go | 82 +++++++++++++ main.go | 15 +++ 5 files changed, 525 insertions(+) create mode 100644 go.mod create mode 100644 internal/app.go create mode 100644 internal/client.go create mode 100644 internal/columns.go create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..766bbaa --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/Salastil/streamed-tui + +go 1.22 + +require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/lipgloss v0.13.0 +) \ No newline at end of file diff --git a/internal/app.go b/internal/app.go new file mode 100644 index 0000000..0e213b2 --- /dev/null +++ b/internal/app.go @@ -0,0 +1,280 @@ +package internal + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/Salastil/streamed-tui/internal/api" +) + +// ──────────────────────────────── +// KEY MAP +// ──────────────────────────────── + +type keyMap struct { + Up, Down, Left, Right key.Binding + Enter, Quit, Refresh 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")), + } +} + +// ──────────────────────────────── +// MODEL +// ──────────────────────────────── + +type focusCol int + +const ( + focusSports focusCol = iota + focusMatches + focusStreams +) + +type Model struct { + apiClient *api.Client + + styles Styles + keys keyMap + help help.Model + focus focusCol + + sports *ListColumn[api.Sport] + matches *ListColumn[api.Match] + streams *ListColumn[api.Stream] + + status string +} + +// ──────────────────────────────── +// APP ENTRYPOINT +// ──────────────────────────────── + +func Run() error { + p := tea.NewProgram(New(), tea.WithAltScreen()) + _, err := p.Run() + return err +} + +func New() Model { + base := api.BaseURLFromEnv() + client := api.NewClient(base, 15*time.Second) + + styles := NewStyles() + m := Model{ + apiClient: client, + styles: styles, + keys: defaultKeys(), + help: help.New(), + focus: focusSports, + } + + m.sports = NewListColumn[api.Sport]("Sports", func(s api.Sport) string { return s.Name }) + m.matches = NewListColumn[api.Match]("Popular Matches", func(mt api.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[api.Stream]("Streams", func(st api.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 (m Model) View() 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) + return lipgloss.JoinVertical(lipgloss.Left, cols, status, m.help.View(m.keys)) +} + +// ──────────────────────────────── +// UPDATE +// ──────────────────────────────── + +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) + 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 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.status = fmt.Sprintf("Error: %v", 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 api.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 api.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 api.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} + } +} diff --git a/internal/client.go b/internal/client.go new file mode 100644 index 0000000..3261c73 --- /dev/null +++ b/internal/client.go @@ -0,0 +1,139 @@ +package internal + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strings" + "time" +) + +// ──────────────────────────────── +// API DATA TYPES +// ──────────────────────────────── + +type Client struct { + base string + http *http.Client +} + +func NewClient(base string, timeout time.Duration) *Client { + return &Client{ + base: base, + http: &http.Client{Timeout: timeout}, + } +} + +func BaseURLFromEnv() string { + val := strings.TrimSpace(os.Getenv("STREAMED_BASE")) + if val == "" { + val = "https://streamed.pk" + } + return strings.TrimRight(val, "/") +} + +type Sport struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Team struct { + Name string `json:"name"` + Badge string `json:"badge"` +} + +type Teams struct { + Home *Team `json:"home"` + Away *Team `json:"away"` +} + +type Match struct { + ID string `json:"id"` + Title string `json:"title"` + Category string `json:"category"` + Date int64 `json:"date"` + Poster string `json:"poster"` + Popular bool `json:"popular"` + Teams *Teams `json:"teams"` + Sources []struct { + Source string `json:"source"` + ID string `json:"id"` + } `json:"sources"` +} + +type Stream struct { + ID string `json:"id"` + StreamNo int `json:"streamNo"` + Language string `json:"language"` + HD bool `json:"hd"` + EmbedURL string `json:"embedUrl"` + Source string `json:"source"` +} + +// ──────────────────────────────── +// API CLIENT +// ──────────────────────────────── + +func (c *Client) GetSports(ctx context.Context) ([]Sport, error) { + url := c.base + "/api/sports" + var out []Sport + if err := c.get(ctx, url, &out); err != nil { + return nil, err + } + return out, nil +} + +func (c *Client) GetPopularMatches(ctx context.Context) ([]Match, error) { + url := c.base + "/api/matches/all/popular" + return c.getMatches(ctx, url) +} + +func (c *Client) GetMatchesBySport(ctx context.Context, sportID string) ([]Match, error) { + url := fmt.Sprintf("%s/api/matches/%s", c.base, sportID) + return c.getMatches(ctx, url) +} + +func (c *Client) GetStreamsForMatch(ctx context.Context, mt Match) ([]Stream, error) { + var all []Stream + for _, src := range mt.Sources { + url := fmt.Sprintf("%s/api/stream/%s/%s", c.base, src.Source, src.ID) + var list []Stream + if err := c.get(ctx, url, &list); err != nil { + return nil, err + } + all = append(all, list...) + } + return all, nil +} + +func (c *Client) getMatches(ctx context.Context, url string) ([]Match, error) { + var out []Match + if err := c.get(ctx, url, &out); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Date < out[j].Date }) + return out, nil +} + +func (c *Client) get(ctx context.Context, url string, v any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "StreamedTUI/1.0 (+https://github.com/Salastil/streamed-tui)") + req.Header.Set("Accept", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("GET %s: %s", url, resp.Status) + } + return json.NewDecoder(resp.Body).Decode(v) +} diff --git a/internal/columns.go b/internal/columns.go new file mode 100644 index 0000000..e5d9fbb --- /dev/null +++ b/internal/columns.go @@ -0,0 +1,82 @@ +package internal + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// ──────────────────────────────── +// STYLES +// ──────────────────────────────── + +type Styles struct { + Title lipgloss.Style + Box lipgloss.Style + Active lipgloss.Style + Status lipgloss.Style +} + +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), + } +} + +// ──────────────────────────────── +// GENERIC LIST COLUMN +// ──────────────────────────────── + +type renderer[T any] func(T) string + +type ListColumn[T any] struct { + title string + items []T + selected int + width int + render renderer[T] +} + +func NewListColumn[T any](title string, r renderer[T]) *ListColumn[T] { + return &ListColumn[T]{title: title, render: r, width: 30} +} + +func (c *ListColumn[T]) SetItems(items []T) { c.items = items; c.selected = 0 } +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]) CursorUp() { if c.selected > 0 { c.selected-- } } +func (c *ListColumn[T]) CursorDown() { if c.selected < len(c.items)-1 { c.selected++ } } + +func (c *ListColumn[T]) Selected() (T, bool) { + var zero T + if len(c.items) == 0 { + return zero, false + } + return c.items[c.selected], true +} + +func (c *ListColumn[T]) View(styles Styles, focused bool) string { + box := styles.Box + if focused { + box = styles.Active + } + + head := styles.Title.Render(c.title) + content := "" + for i, it := range c.items { + cursor := " " + if i == c.selected { + cursor = "▸ " + } + line := fmt.Sprintf("%s%s", cursor, c.render(it)) + if len(line) > c.width && c.width > 3 { + line = line[:c.width-3] + "…" + } + content += line + "\n" + } + return box.Width(c.width + 2).Render(head + "\n" + content) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..28f9d00 --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "log" + "os" + + "github.com/Salastil/streamed-tui/internal" +) + +func main() { + if err := internal.Run(); err != nil { + log.Println("error:", err) + os.Exit(1) + } +}