tsplay/internal/tui/delegate.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

197 lines
5.3 KiB
Go

// itemDelegate is a compact single-line list renderer for media items: a
// media-type icon, the title, and a right-aligned meta column (year · duration ·
// watched). It replaces bubbles' two-line default delegate so the browser reads
// like a tidy table.
package tui
import (
"fmt"
"io"
"strings"
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"tuistream-play/internal/api"
)
type itemDelegate struct{ st styles }
func newItemDelegate(st styles) itemDelegate { return itemDelegate{st: st} }
func (d itemDelegate) Height() int { return 1 }
func (d itemDelegate) Spacing() int { return 0 }
func (d itemDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (d itemDelegate) Render(w io.Writer, m list.Model, index int, raw list.Item) {
it, ok := raw.(listItem)
if !ok {
return
}
selected := index == m.Index()
icon := cell(mediaIcon(it.item), 2)
title := rowTitle(it.item)
meta := rowMeta(it.item)
cursor := " "
if selected {
cursor = d.st.accent.Render("▸ ")
}
width := m.Width()
if width <= 0 {
width = 80
}
// Layout: cursor(2) + icon(2) + " " + title + gap + meta + 1 right margin.
fixed := 2 + lipgloss.Width(icon) + 1 + lipgloss.Width(meta) + 1
titleBudget := width - fixed
if lipgloss.Width(title) > titleBudget {
title = truncate(title, titleBudget)
}
gap := width - fixed - lipgloss.Width(title)
if gap < 0 {
gap = 0
}
titleR := title
if selected {
titleR = d.st.accent.Render(title)
}
row := cursor + icon + " " + titleR + strings.Repeat(" ", gap) + d.st.muted.Render(meta)
fmt.Fprint(w, row)
}
// Nerd Font Material Design icon glyphs (nf-md-*), matching the family Omarchy
// uses in waybar/walker. Defined by codepoint so the source bytes can't be
// mangled by an editor or terminal. These are single display-width.
const (
iconMovie = "\U000f07de" // nf-md-movie_open
iconTV = "\U000f082b" // nf-md-television_classic
iconFolder = "\U000f024b" // nf-md-folder
iconEpisode = "\U000f0fce" // nf-md-movie_play
iconMusic = "\U000f075a" // nf-md-music
iconAlbum = "\U000f0025" // nf-md-album
iconArtist = "\U000f0803" // nf-md-account_music
iconMusicBox = "\U000f0223" // nf-md-music_box (music library)
iconLibrary = "\U000f0253" // nf-md-folder_multiple (generic library)
iconFile = "\U000f0224" // nf-md-file (plain leaf)
)
// IconLegend returns the (label, glyph) pairs tsplay uses, for the `--icons`
// debug command so the user can confirm their terminal font renders them.
func IconLegend() [][2]string {
return [][2]string{
{"movie", iconMovie},
{"tv / series", iconTV},
{"season / folder", iconFolder},
{"episode", iconEpisode},
{"audio track", iconMusic},
{"album", iconAlbum},
{"artist", iconArtist},
{"music library", iconMusicBox},
{"generic library", iconLibrary},
{"file", iconFile},
}
}
// mediaIcon picks a Nerd Font glyph for an item's type (or its library's
// collection type, or a generic folder/leaf glyph).
func mediaIcon(it api.Item) string {
switch it.Type {
case "Movie":
return iconMovie
case "Series":
return iconTV
case "Season":
return iconFolder
case "Episode":
return iconEpisode
case "Audio":
return iconMusic
case "MusicAlbum":
return iconAlbum
case "MusicArtist":
return iconArtist
case "CollectionFolder", "UserView":
switch it.CollectionType {
case "movies":
return iconMovie
case "tvshows":
return iconTV
case "music":
return iconMusicBox
default:
return iconLibrary
}
}
if it.IsFolder {
return iconFolder
}
return iconFile
}
// rowTitle is the left text for a row: episode/track numbering where it helps,
// otherwise the bare name (year lives in the meta column).
func rowTitle(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)
}
}
return it.Name
}
// rowMeta is the right-aligned column: "(year) 1h 23m ✓".
func rowMeta(it api.Item) string {
var parts []string
if it.ProductionYear > 0 && (it.Type == "Movie" || it.Type == "Series") {
parts = append(parts, fmt.Sprintf("(%d)", it.ProductionYear))
}
if d := formatDuration(it.RunTimeTicks); d != "" {
parts = append(parts, d)
}
if it.UserData.Played {
parts = append(parts, "✓")
} else if it.UserData.PlayedPercentage > 1 {
parts = append(parts, fmt.Sprintf("%.0f%%", it.UserData.PlayedPercentage))
}
return strings.Join(parts, " ")
}
// cell pads s on the right so it occupies exactly w display columns. This keeps
// the title column aligned even when a terminal measures an emoji as one cell
// instead of two.
func cell(s string, w int) string {
if gap := w - lipgloss.Width(s); gap > 0 {
return s + strings.Repeat(" ", gap)
}
return s
}
// truncate shortens s to at most max display columns, appending an ellipsis.
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
if lipgloss.Width(s) <= max {
return s
}
var b strings.Builder
for _, r := range s {
if lipgloss.Width(b.String()+string(r))+1 > max {
break
}
b.WriteRune(r)
}
return b.String() + "…"
}