tsplay/internal/tui/model.go
28allday 8ee58b905f Initial commit: tuistream-play (tsplay) v0.1
Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:36:49 +01:00

426 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package tui is the BubbleTea front-end: a login form, a stack-based library
// browser, and a now-playing view that drives mpv. It owns the api.Client and
// the active player.Player.
package tui
import (
"context"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"tuistream-play/internal/api"
"tuistream-play/internal/cliamp"
"tuistream-play/internal/config"
"tuistream-play/internal/discovery"
"tuistream-play/internal/player"
"tuistream-play/internal/theme"
)
type screen int
const (
screenLogin screen = iota
screenBrowse
screenPlaying
)
// listItem adapts an api.Item to the bubbles/list interfaces.
type listItem struct {
item api.Item
}
func (l listItem) Title() string { return displayTitle(l.item) }
func (l listItem) Description() string { return displayDesc(l.item) }
func (l listItem) FilterValue() string { return l.item.Name }
// Model is the whole application state.
type Model struct {
cfg *config.Config
client *api.Client
st styles
screen screen
width, height int
status string // transient message / error shown in the footer
// login form
inputs []textinput.Model
focus int
logging bool
discovered []discovery.Server // Jellyfin servers found on the LAN
discIdx int // which discovered server is selected
// browse: a stack of list levels; the last is the visible one
stack []list.Model
// playing
plr *player.Player
nowPlaying api.Item
lastReport time.Time
// queue is the sibling list the current item was launched from, so audio
// can auto-advance to the next playable track. queueIdx is the position of
// nowPlaying within it. Empty when playback wasn't started from a list.
queue []api.Item
queueIdx int
}
// New builds the initial model from saved config.
func New(cfg *config.Config, t theme.Theme) Model {
m := Model{
cfg: cfg,
st: newStyles(t),
}
if cfg.LoggedIn() {
m.client = api.New(cfg.ServerURL, cfg.Token, cfg.UserID, cfg.DeviceID)
m.screen = screenBrowse
} else {
m.screen = screenLogin
m.initLogin()
}
return m
}
func (m *Model) initLogin() {
labels := []string{"Server URL (https://media.example.com)", "Username", "Password"}
m.inputs = make([]textinput.Model, 3)
for i := range m.inputs {
ti := textinput.New()
ti.Placeholder = labels[i]
ti.Prompt = " "
if i == 2 {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
}
m.inputs[i] = ti
}
if m.cfg.ServerURL != "" {
m.inputs[0].SetValue(m.cfg.ServerURL)
}
if m.cfg.Username != "" {
m.inputs[1].SetValue(m.cfg.Username)
}
m.focus = 0
m.inputs[0].Focus()
m.status = "Scanning the network for Jellyfin servers…"
}
// Init kicks off the first load: libraries if logged in, else LAN discovery.
func (m Model) Init() tea.Cmd {
if m.screen == screenBrowse {
return m.loadViews()
}
return tea.Batch(textinput.Blink, discoverCmd())
}
// ---- messages ----
type loginResultMsg struct {
res *api.AuthResult
err error
}
type levelMsg struct {
title string
items []api.Item
err error
}
type playStartedMsg struct {
plr *player.Player
item api.Item
err error
}
type tickMsg time.Time
func tick() tea.Cmd {
return tea.Tick(500*time.Millisecond, func(t time.Time) tea.Msg { return tickMsg(t) })
}
type discoveredMsg struct {
servers []discovery.Server
}
// discoverCmd runs LAN auto-discovery off the UI thread.
func discoverCmd() tea.Cmd {
return func() tea.Msg {
servers, _ := discovery.Discover(2 * time.Second)
return discoveredMsg{servers: servers}
}
}
// ---- async commands ----
func (m Model) doLogin(server, user, pass string) tea.Cmd {
cfg := m.cfg
return func() tea.Msg {
c := api.New(server, "", "", cfg.DeviceID)
res, err := c.Login(context.Background(), user, pass)
return loginResultMsg{res: res, err: err}
}
}
func (m Model) loadViews() tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Views(context.Background())
return levelMsg{title: "Libraries", items: items, err: err}
}
}
func (m Model) loadChildren(title, parentID string) tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Children(context.Background(), parentID)
return levelMsg{title: title, items: items, err: err}
}
}
func (m Model) loadResume() tea.Cmd {
c := m.client
return func() tea.Msg {
items, err := c.Resume(context.Background())
return levelMsg{title: "Continue Watching", items: items, err: err}
}
}
func (m Model) startPlayback(it api.Item) tea.Cmd {
c := m.client
vol := m.cfg.VolumeOr(100) // resume at the last-used volume
return func() tea.Msg {
url := c.StreamURL(it.ID)
startSec := float64(it.UserData.PlaybackPositionTicks) / 1e7
p, err := player.Start(url, displayTitle(it), startSec, it.AudioOnly(), vol)
if err == nil {
_ = c.ReportStart(context.Background(), it.ID)
}
return playStartedMsg{plr: p, item: it, err: err}
}
}
// nextAudioIndex returns the index of the next playable audio sibling after
// `from` in the queue, or -1 if there isn't one. Used for auto-advance, which
// is audio-only by design — a finished movie/episode just returns to the list.
func (m Model) nextAudioIndex(from int) int {
for i := from + 1; i < len(m.queue); i++ {
if m.queue[i].Playable() && m.queue[i].AudioOnly() {
return i
}
}
return -1
}
// prevAudioIndex returns the index of the previous playable audio sibling
// before `from` in the queue, or -1 if there isn't one. Used for the
// previous-track control on the now-playing screen.
func (m Model) prevAudioIndex(from int) int {
for i := from - 1; i >= 0; i-- {
if i < len(m.queue) && m.queue[i].Playable() && m.queue[i].AudioOnly() {
return i
}
}
return -1
}
// newLevel builds a list.Model populated with the given items.
func (m Model) newLevel(title string, items []api.Item) list.Model {
rows := make([]list.Item, len(items))
for i, it := range items {
rows[i] = listItem{item: it}
}
w, h := m.listSize()
l := list.New(rows, newItemDelegate(m.st), w, h)
l.Title = title
l.Styles.Title = m.st.title // plain bold accent, no default colour block
l.SetShowStatusBar(false) // count/filter state lives in our footer instead
l.SetShowHelp(false) // footer renders the key hints
l.SetFilteringEnabled(true)
return l
}
func (m Model) listSize() (int, int) {
w := m.width
// Reserve rows for the header rule, a blank line, and the footer
// (rule + help), so the list never overflows the screen frame.
h := m.height - 5
if w <= 0 {
w = 80
}
if h <= 0 {
h = 18
}
return w, h
}
// Update is the BubbleTea reducer; it dispatches by screen.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
w, h := m.listSize()
for i := range m.stack {
m.stack[i].SetSize(w, h)
}
return m, nil
case discoveredMsg:
m.discovered = msg.servers
m.discIdx = 0
if len(msg.servers) == 0 {
if m.status == "Scanning the network for Jellyfin servers…" {
m.status = "No servers found — enter the URL manually."
}
return m, nil
}
// Pre-fill the URL only if the user hasn't typed one yet.
if m.inputs[0].Value() == "" {
m.inputs[0].SetValue(msg.servers[0].Address)
}
m.status = discoveryStatus(msg.servers, 0)
return m, nil
case loginResultMsg:
m.logging = false
if msg.err != nil {
m.status = "Login failed: " + msg.err.Error()
return m, nil
}
// Persist and switch to browsing.
m.cfg.ServerURL = m.inputs[0].Value()
m.cfg.Username = m.inputs[1].Value()
m.cfg.Token = msg.res.AccessToken
m.cfg.UserID = msg.res.User.ID
_ = m.cfg.Save()
m.client = api.New(m.cfg.ServerURL, m.cfg.Token, m.cfg.UserID, m.cfg.DeviceID)
m.screen = screenBrowse
m.status = ""
// Best-effort: mirror the Jellyfin credentials into cliamp's config so
// its Jellyfin music provider works without a second setup. Only when
// cliamp is present; never blocks or fails login.
if cliamp.Present() {
if err := cliamp.WriteJellyfin(cliamp.Creds{
URL: m.cfg.ServerURL,
Token: m.cfg.Token,
UserID: m.cfg.UserID,
}); err == nil {
m.status = "cliamp Jellyfin provider configured"
}
}
return m, m.loadViews()
case levelMsg:
if msg.err != nil {
m.status = "Load failed: " + msg.err.Error()
return m, nil
}
m.status = ""
m.stack = append(m.stack, m.newLevel(msg.title, msg.items))
return m, nil
case playStartedMsg:
if msg.err != nil {
m.status = "Playback failed: " + msg.err.Error()
return m, nil
}
m.plr = msg.plr
m.nowPlaying = msg.item
m.screen = screenPlaying
m.lastReport = time.Time{}
return m, tick()
case tickMsg:
if m.screen != screenPlaying || m.plr == nil {
return m, nil
}
snap := m.plr.Snapshot()
if snap.Done {
// Auto-advance to the next audio track in the queue (music only);
// anything else just ends and returns to the browser.
if m.nowPlaying.AudioOnly() {
if next := m.nextAudioIndex(m.queueIdx); next >= 0 {
return m.advanceTo(next)
}
}
cmd := m.endPlayback(snap)
return m, cmd
}
// Report progress to the server roughly every 5s, and persist the
// volume if the user has changed it (so the next track resumes at the
// same level). snap.Volume is only trusted once mpv has reported it
// (>0), which avoids saving a spurious 0 before the first observe.
if t := time.Time(msg); t.Sub(m.lastReport) > 5*time.Second {
m.lastReport = t
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
paused := snap.Paused
c := m.client
go func() { _ = c.ReportProgress(context.Background(), id, pos, paused) }()
if snap.Volume > 0 && snap.Volume != m.cfg.VolumeOr(-1) {
m.cfg.SetVolume(snap.Volume)
_ = m.cfg.Save()
}
}
return m, tick()
}
switch m.screen {
case screenLogin:
return m.updateLogin(msg)
case screenBrowse:
return m.updateBrowse(msg)
case screenPlaying:
return m.updatePlaying(msg)
}
return m, nil
}
// advanceTo stops the current track (reporting its stop) and starts the queue
// item at index next, keeping the now-playing screen up for a seamless album
// playthrough. It returns the updated model so the caller uses
// `return m.advanceTo(next)` — a value receiver is deliberate: the
// `return m, m.advanceTo(...)` form would evaluate the first `m` before this
// method mutates it, discarding the plr=nil/queueIdx changes and orphaning the
// old player (two tracks playing at once).
func (m Model) advanceTo(next int) (Model, tea.Cmd) {
if m.plr != nil {
snap := m.plr.Snapshot()
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
c := m.client
go func() { _ = c.ReportStop(context.Background(), id, pos) }()
m.plr.Stop()
m.plr = nil
}
m.queueIdx = next
return m, m.startPlayback(m.queue[next])
}
// endPlayback stops mpv, reports the final position, and returns to browsing.
func (m *Model) endPlayback(snap player.State) tea.Cmd {
id := m.nowPlaying.ID
pos := api.SecondsToTicks(snap.TimePos)
c := m.client
go func() { _ = c.ReportStop(context.Background(), id, pos) }()
if m.plr != nil {
m.plr.Stop()
m.plr = nil
}
m.screen = screenBrowse
return nil
}
// View renders the active screen.
func (m Model) View() string {
switch m.screen {
case screenLogin:
return m.viewLogin()
case screenPlaying:
return m.viewPlaying()
default:
return m.viewBrowse()
}
}