tsplay/internal/tui/browse.go
28allday b3bf49df05 Release v1.0.0: server switching, Walker launcher, curl install, user guide
- Switch servers from the browser (s) with auto-discovery; auto-fallback to
  discovery when a saved server is unreachable, so you're never locked to a
  rebuilt box. cliamp's Jellyfin provider is re-synced on every (re)login.
- Ignore late LAN-discovery results once off the login screen (footer/input
  clobber fix).
- install.sh: curl|bash one-liner that downloads the per-arch release binary,
  still builds from a clone when Go is present; adds a floating Walker entry
  (TUI.float app-id) + icon on Omarchy desktops.
- Docs: USER_GUIDE.md plain-English walkthrough; README install one-liner,
  accurate keybindings, features section.
- Bump version to 1.0.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:48:03 +01:00

163 lines
3.9 KiB
Go

package tui
import (
"fmt"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tsplay/internal/api"
)
func (m Model) updateBrowse(msg tea.Msg) (tea.Model, tea.Cmd) {
// Nothing loaded yet (initial Views fetch in flight).
if len(m.stack) == 0 {
if key, ok := msg.(tea.KeyMsg); ok && (key.String() == "ctrl+c" || key.String() == "q") {
return m, tea.Quit
}
return m, nil
}
cur := &m.stack[len(m.stack)-1]
filtering := cur.FilterState() == list.Filtering
if key, ok := msg.(tea.KeyMsg); ok && !filtering {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
case "esc", "backspace", "h", "left":
if len(m.stack) > 1 {
m.stack = m.stack[:len(m.stack)-1]
}
return m, nil
case "c":
return m, m.loadResume()
case "s":
// Switch to a different Jellyfin server: back to discovery/login.
return m.toLogin("")
case "enter", "l", "right":
sel, ok := cur.SelectedItem().(listItem)
if !ok {
return m, nil
}
it := sel.item
if it.Playable() {
m.status = "Starting mpv…"
// Capture the sibling list as the play queue so audio can
// auto-advance. Built from the current level's items in their
// displayed order.
m.queue, m.queueIdx = currentItems(*cur), cur.Index()
return m, m.startPlayback(it)
}
return m, m.loadChildren(displayTitle(it), it.ID)
}
}
var cmd tea.Cmd
m.stack[len(m.stack)-1], cmd = cur.Update(msg)
return m, cmd
}
func (m Model) viewBrowse() string {
if len(m.stack) == 0 {
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(), "", m.st.muted.Render(" Loading libraries…"))
}
cur := m.stack[len(m.stack)-1]
help := "enter play/open · c continue · / filter · s server · esc back · q quit"
return lipgloss.JoinVertical(lipgloss.Left,
m.headerBar(),
"",
cur.View(),
m.footerBar(help, m.status),
)
}
// currentItems extracts the api.Items from a list, in display order, so the
// playback queue mirrors exactly what the user sees.
func currentItems(l list.Model) []api.Item {
rows := l.Items()
out := make([]api.Item, 0, len(rows))
for _, r := range rows {
if li, ok := r.(listItem); ok {
out = append(out, li.item)
}
}
return out
}
// ---- display helpers ----
func displayTitle(it api.Item) string {
switch it.Type {
case "Episode":
if it.ParentIndexNumber > 0 && it.IndexNumber > 0 {
return fmt.Sprintf("S%02dE%02d %s", it.ParentIndexNumber, it.IndexNumber, it.Name)
}
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
case "Audio":
if it.IndexNumber > 0 {
return fmt.Sprintf("%d. %s", it.IndexNumber, it.Name)
}
case "Movie", "Series":
if it.ProductionYear > 0 {
return fmt.Sprintf("%s (%d)", it.Name, it.ProductionYear)
}
}
return it.Name
}
func displayDesc(it api.Item) string {
var parts []string
switch it.Type {
case "CollectionFolder", "UserView":
if it.CollectionType != "" {
parts = append(parts, it.CollectionType)
} else {
parts = append(parts, "library")
}
case "Episode":
if it.SeriesName != "" {
parts = append(parts, it.SeriesName)
}
case "Series", "Season":
parts = append(parts, "folder")
default:
if it.IsFolder {
parts = append(parts, "folder")
}
}
if d := formatDuration(it.RunTimeTicks); d != "" {
parts = append(parts, d)
}
if it.UserData.Played {
parts = append(parts, "✓ watched")
} else if it.UserData.PlayedPercentage > 1 {
parts = append(parts, fmt.Sprintf("%.0f%%", it.UserData.PlayedPercentage))
}
if len(parts) == 0 {
return " "
}
out := parts[0]
for _, p := range parts[1:] {
out += " · " + p
}
return out
}
// formatDuration turns RunTimeTicks (100ns units) into "1h 23m" / "12m".
func formatDuration(ticks int64) string {
if ticks <= 0 {
return ""
}
secs := ticks / 1e7
h := secs / 3600
mn := (secs % 3600) / 60
if h > 0 {
return fmt.Sprintf("%dh %02dm", h, mn)
}
return fmt.Sprintf("%dm", mn)
}