Initial Commit

This commit is contained in:
Salastil
2025-10-20 03:42:09 -04:00
parent 0bd7b049c5
commit ffb667be53
5 changed files with 525 additions and 0 deletions

280
internal/app.go Normal file
View File

@@ -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}
}
}

139
internal/client.go Normal file
View File

@@ -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)
}

82
internal/columns.go Normal file
View File

@@ -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)
}