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>
197 lines
5.3 KiB
Go
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"
|
|
|
|
"tsplay/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() + "…"
|
|
}
|