Collapse the project/module/directory name to match the binary, so there is a single name everywhere (tuistream-play -> tsplay). The binary, config dir and Jellyfin client identity were already tsplay. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
426 lines
11 KiB
Go
426 lines
11 KiB
Go
// 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"
|
||
|
||
"tsplay/internal/api"
|
||
"tsplay/internal/cliamp"
|
||
"tsplay/internal/config"
|
||
"tsplay/internal/discovery"
|
||
"tsplay/internal/player"
|
||
"tsplay/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()
|
||
}
|
||
}
|