Show poster thumbnails in the detail panes

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 21:47:50 +02:00
co-authored by Claude Sonnet 5.5
parent ff204655d3
commit a4303ed3ca
15 changed files with 724 additions and 22 deletions
+204
View File
@@ -0,0 +1,204 @@
package ui
import (
"bytes"
"context"
"fmt"
"image"
_ "image/jpeg" // poster formats
_ "image/png"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"git.stinnesbeck.com/nils/arr-tui/internal/arr"
)
const (
maxCoverPixels = 25_000_000 // refuse decompression bombs
storedCoverHeight = 160 // a small copy is plenty for a terminal thumbnail
coverFailureTTL = 2 * time.Minute
maxCoverCache = 300
coverFetchLimit = 4
)
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
func decodeCover(data []byte) (*image.RGBA, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("not a supported image: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxCoverPixels {
return nil, fmt.Errorf("image dimensions %dx%d not supported", cfg.Width, cfg.Height)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
b := img.Bounds()
if b.Dy() <= storedCoverHeight {
return boxScale(img, b.Dx(), b.Dy()), nil
}
return boxScale(img, max(1, b.Dx()*storedCoverHeight/b.Dy()), storedCoverHeight), nil
}
// boxScale resizes src to w×h by averaging the source pixels under every
// target pixel (good for shrinking; upscaling degrades to nearest neighbour).
func boxScale(src image.Image, w, h int) *image.RGBA {
b := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
y0 := b.Min.Y + y*b.Dy()/h
y1 := max(b.Min.Y+(y+1)*b.Dy()/h, y0+1)
for x := 0; x < w; x++ {
x0 := b.Min.X + x*b.Dx()/w
x1 := max(b.Min.X+(x+1)*b.Dx()/w, x0+1)
var r, g, bl, n uint64
for sy := y0; sy < y1; sy++ {
for sx := x0; sx < x1; sx++ {
pr, pg, pb, _ := src.At(sx, sy).RGBA()
r, g, bl, n = r+uint64(pr), g+uint64(pg), bl+uint64(pb), n+1
}
}
i := dst.PixOffset(x, y)
dst.Pix[i], dst.Pix[i+1], dst.Pix[i+2], dst.Pix[i+3] = uint8(r/n>>8), uint8(g/n>>8), uint8(bl/n>>8), 255
}
}
return dst
}
// coverView draws a poster with half-block characters: every cell shows two
// vertically stacked pixels (foreground = upper, background = lower), so it
// works in any terminal with colors, also over SSH.
type coverView struct {
*tview.Box
img *image.RGBA
note string // shown instead of the image: "loading…", "no cover"
scaled *image.RGBA // cache of img at the last drawn size
}
func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil }
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
func (c *coverView) Draw(screen tcell.Screen) {
c.Box.DrawForSubclass(screen, c)
x, y, w, h := c.GetInnerRect()
if w < 1 || h < 1 {
return
}
if c.img == nil {
if c.note != "" {
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, tcell.ColorGray)
}
return
}
// Fit the poster into w columns by 2h pixel rows, keeping its aspect.
b := c.img.Bounds()
tw := w
th := tw * b.Dy() / b.Dx()
if th > 2*h {
th = 2 * h
tw = th * b.Dx() / b.Dy()
}
th &^= 1
if tw < 1 || th < 2 {
return
}
if c.scaled == nil || c.scaled.Bounds().Dx() != tw || c.scaled.Bounds().Dy() != th {
c.scaled = boxScale(c.img, tw, th)
}
px := func(px, py int) tcell.Color {
i := c.scaled.PixOffset(px, py)
return tcell.NewRGBColor(int32(c.scaled.Pix[i]), int32(c.scaled.Pix[i+1]), int32(c.scaled.Pix[i+2]))
}
for row := 0; row < th/2; row++ {
for col := 0; col < tw; col++ {
style := tcell.StyleDefault.Foreground(px(col, 2*row)).Background(px(col, 2*row+1))
screen.SetContent(x+col, y+row, '▀', nil, style)
}
}
}
// coverRef says where a poster comes from. The zero value means "no poster".
type coverRef struct {
key string // identifies the poster in the cache
fetch func(ctx context.Context) ([]byte, error)
}
// coverFor builds the poster reference of a title from its image list.
func coverFor(c *arr.Client, images []arr.Image, inLibrary bool) coverRef {
key, fetch, ok := c.Poster(images, inLibrary)
if !ok {
return coverRef{}
}
return coverRef{key: key, fetch: fetch}
}
// coverCache holds decoded posters and the downloads in flight. UI goroutine only.
type coverCache struct {
imgs map[string]*image.RGBA
failed map[string]time.Time
waiting map[string][]func(*image.RGBA, error)
slots chan struct{}
}
func newCoverCache() *coverCache {
return &coverCache{
imgs: map[string]*image.RGBA{},
failed: map[string]time.Time{},
waiting: map[string][]func(*image.RGBA, error){},
slots: make(chan struct{}, coverFetchLimit),
}
}
// loadCover delivers the poster to done on the UI goroutine, downloading it in
// the background when it is not cached. Failures are remembered for a while so
// an unreachable image host is not retried on every selection.
func (a *App) loadCover(ref coverRef, done func(*image.RGBA, error)) {
c := a.coverCache
if img, ok := c.imgs[ref.key]; ok {
done(img, nil)
return
}
if t, ok := c.failed[ref.key]; ok && time.Since(t) < coverFailureTTL {
done(nil, fmt.Errorf("no cover"))
return
}
if _, inflight := c.waiting[ref.key]; inflight {
c.waiting[ref.key] = append(c.waiting[ref.key], done)
return
}
c.waiting[ref.key] = []func(*image.RGBA, error){done}
go func() {
c.slots <- struct{}{}
defer func() { <-c.slots }()
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
var img *image.RGBA
data, err := ref.fetch(ctx)
if err == nil {
img, err = decodeCover(data)
}
a.post(func() {
waiters := c.waiting[ref.key]
delete(c.waiting, ref.key)
if err != nil {
c.failed[ref.key] = time.Now()
} else {
if len(c.imgs) >= maxCoverCache {
c.imgs = map[string]*image.RGBA{} // simple bound; posters are cheap to fetch again
}
c.imgs[ref.key] = img
}
for _, w := range waiters {
w(img, err)
}
})
}()
}