Files
arr-tui/internal/ui/cover.go
T

280 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
})
}()
}