280 lines
8.5 KiB
Go
280 lines
8.5 KiB
Go
package ui
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"image"
|
||
"image/color"
|
||
_ "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
|
||
flatCellError = 600 // squared RGB distance below which a cell is drawn flat
|
||
)
|
||
|
||
// 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 quadrant block characters: every cell shows
|
||
// 2×2 pixels in two colors (the best split of the four pixels into a
|
||
// foreground and a background group), which doubles the horizontal detail of
|
||
// plain half blocks. The glyphs are all from the Block Elements range that
|
||
// every terminal font has, and it works 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 }
|
||
|
||
// coverCells returns how many columns and rows a poster of the given aspect
|
||
// needs inside w×h cells. A cell is about twice as tall as it is wide.
|
||
func coverCells(imgW, imgH, w, h int) (cols, rows int) {
|
||
cols = w
|
||
rows = cols * imgH / imgW / 2
|
||
if rows > h {
|
||
rows = h
|
||
cols = rows * 2 * imgW / imgH
|
||
}
|
||
return max(cols, 0), max(rows, 0)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
b := c.img.Bounds()
|
||
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
|
||
if cols < 1 || rows < 1 {
|
||
return
|
||
}
|
||
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows {
|
||
c.scaled = boxScale(c.img, 2*cols, 2*rows)
|
||
}
|
||
px := func(x, y int) color.RGBA {
|
||
i := c.scaled.PixOffset(x, y)
|
||
return color.RGBA{c.scaled.Pix[i], c.scaled.Pix[i+1], c.scaled.Pix[i+2], 255}
|
||
}
|
||
for row := 0; row < rows; row++ {
|
||
for col := 0; col < cols; col++ {
|
||
ch, fg, bg := quadCell([4]color.RGBA{
|
||
px(2*col, 2*row), px(2*col+1, 2*row), // top left, top right
|
||
px(2*col, 2*row+1), px(2*col+1, 2*row+1), // bottom left, bottom right
|
||
})
|
||
style := tcell.StyleDefault.Foreground(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))).
|
||
Background(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))
|
||
screen.SetContent(x+col, y+row, ch, nil, style)
|
||
}
|
||
}
|
||
}
|
||
|
||
// quadrants[mask] is the glyph that fills the quadrants set in mask
|
||
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
|
||
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
|
||
|
||
// quadCell picks the glyph and colors that best reproduce four pixels (top
|
||
// left, top right, bottom left, bottom right) with one foreground and one
|
||
// background color.
|
||
func quadCell(p [4]color.RGBA) (ch rune, fg, bg color.RGBA) {
|
||
mean := func(mask int, want bool) (c [3]float64, n int) {
|
||
for i := 0; i < 4; i++ {
|
||
if (mask>>i&1 == 1) == want {
|
||
c[0], c[1], c[2] = c[0]+float64(p[i].R), c[1]+float64(p[i].G), c[2]+float64(p[i].B)
|
||
n++
|
||
}
|
||
}
|
||
for k := range c {
|
||
if n > 0 {
|
||
c[k] /= float64(n)
|
||
}
|
||
}
|
||
return c, n
|
||
}
|
||
// err is the squared distance of the pixels in one group to the group's mean.
|
||
errOf := func(mask int, want bool, m [3]float64) (e float64) {
|
||
for i := 0; i < 4; i++ {
|
||
if (mask>>i&1 == 1) == want {
|
||
dr, dg, db := float64(p[i].R)-m[0], float64(p[i].G)-m[1], float64(p[i].B)-m[2]
|
||
e += dr*dr + dg*dg + db*db
|
||
}
|
||
}
|
||
return e
|
||
}
|
||
toRGBA := func(m [3]float64) color.RGBA {
|
||
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
|
||
}
|
||
|
||
// Mask 0 is a single flat color. A split must clearly beat it, and nearly
|
||
// flat cells (smooth gradients, noise) stay flat. Only masks without the
|
||
// bottom right quadrant are tried: a mask and its complement draw the same
|
||
// picture with swapped colors.
|
||
flat, _ := mean(0, false)
|
||
flatErr := errOf(0, false, flat)
|
||
best, bestErr := 0, flatErr*0.6
|
||
if flatErr >= flatCellError {
|
||
for mask := 1; mask < 8; mask++ {
|
||
fm, _ := mean(mask, true)
|
||
bm, _ := mean(mask, false)
|
||
if e := errOf(mask, true, fm) + errOf(mask, false, bm); e < bestErr {
|
||
best, bestErr = mask, e
|
||
}
|
||
}
|
||
}
|
||
if best == 0 {
|
||
c := toRGBA(flat)
|
||
return ' ', c, c
|
||
}
|
||
fm, _ := mean(best, true)
|
||
bm, _ := mean(best, false)
|
||
return quadrants[best], toRGBA(fm), toRGBA(bm)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
})
|
||
}()
|
||
}
|