// 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() } }