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) } }) }() }