Initial Commit
This commit is contained in:
280
internal/app.go
Normal file
280
internal/app.go
Normal 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
139
internal/client.go
Normal 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
82
internal/columns.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user