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>
160 lines
3.8 KiB
Go
160 lines
3.8 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 "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 · 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)
|
|
}
|