Render covers with quadrant blocks for twice the horizontal detail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ First release.
|
|||||||
- **Series drill-down:** seasons and episodes with monitoring, automatic search and release search.
|
- **Series drill-down:** seasons and episodes with monitoring, automatic search and release search.
|
||||||
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
|
- **Downloads:** SABnzbd queue and history plus the Radarr and Sonarr queues; pause/resume, delete,
|
||||||
retry, remove and blocklist.
|
retry, remove and blocklist.
|
||||||
- **Posters:** thumbnails in the detail panes (half-block rendering, any color terminal).
|
- **Posters:** thumbnails in the detail panes (quadrant block rendering, any color terminal).
|
||||||
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history.
|
- **Log (F5) and popups:** feedback appears as fading popups; the log keeps the history.
|
||||||
- Connection indicators for all three services, context-sensitive key hints, `?` help.
|
- Connection indicators for all three services, context-sensitive key hints, `?` help.
|
||||||
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
|
- JSON config with environment overrides (`ARR_TUI_*`), `--version`, cross-platform builds
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
|
|||||||
optionally start the automatic search.
|
optionally start the automatic search.
|
||||||
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
|
- **Interactive search**: list the releases Radarr/Sonarr find (approved or rejected, with reasons),
|
||||||
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
|
sort and filter them (by approval, quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), and grab one. Series: pick a season or a single episode first.
|
||||||
- **Posters**: a small poster thumbnail in the detail panes, drawn with colored half-block
|
- **Posters**: a small poster thumbnail in the detail panes, drawn with colored quadrant
|
||||||
characters (needs a terminal with colors; works over SSH). Library items use the copy stored in
|
block characters (needs a terminal with colors; works over SSH). Library items use the copy stored in
|
||||||
Radarr/Sonarr; search results are downloaded from the metadata provider's image host
|
Radarr/Sonarr; search results are downloaded from the metadata provider's image host
|
||||||
(TMDb, TheTVDB). Turn it off with `"ui": {"covers": false}`.
|
(TMDb, TheTVDB). Turn it off with `"ui": {"covers": false}`.
|
||||||
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
|
- **Library**: browse movies (top) and series (below) with a details pane on the right, filter by status (missing / downloaded / monitored) and by file properties (quality, x265 / x264 / AV1, HDR, Dolby Vision, audio format, language), toggle
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Notes:
|
|||||||
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
|
- Radarr and Sonarr queues (status, warnings, import errors), so stuck imports are visible.
|
||||||
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
- Global speed / pause state in the status bar. Pause/resume and delete a SABnzbd job.
|
||||||
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
- **Status bar / help:** the bottom bar lists the keys that work in the current view or panel; the top right shows the connection state of each service; `?` opens the full key reference.
|
||||||
- **Posters:** the detail panes show a poster thumbnail above the text, rendered as half-block characters with 24-bit colors (each cell is two pixels; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
- **Posters:** the detail panes show a poster thumbnail above the text, rendered with quadrant block characters in 24-bit colors (each cell shows 2×2 pixels in the best two-color split, from the Block Elements range every font has; JPEG/PNG only, decoded and downscaled in the process, so no terminal graphics protocol is needed). Library items fetch `/MediaCover/<id>/poster-250.jpg` from their Radarr/Sonarr instance with the API key; search results fall back to the provider's `remoteUrl` (fetched without credentials). Posters are cached in memory, failed downloads are not retried for two minutes, and the poster shrinks or disappears when the pane is too short. `ui.covers: false` disables it.
|
||||||
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
- **Notifications and log (F5):** feedback about actions (added, deleted, grabbed, failures) appears as popups in the bottom right that fade away (errors stay longer). Everything is also kept in the Log view; only connection changes of the background polling are logged, not every failed poll.
|
||||||
|
|
||||||
### 4.2 v0.2 (done, M8)
|
### 4.2 v0.2 (done, M8)
|
||||||
|
|||||||
+96
-21
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
"image/color"
|
||||||
_ "image/jpeg" // poster formats
|
_ "image/jpeg" // poster formats
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
coverFailureTTL = 2 * time.Minute
|
coverFailureTTL = 2 * time.Minute
|
||||||
maxCoverCache = 300
|
maxCoverCache = 300
|
||||||
coverFetchLimit = 4
|
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.
|
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
|
||||||
@@ -68,9 +70,11 @@ func boxScale(src image.Image, w, h int) *image.RGBA {
|
|||||||
return dst
|
return dst
|
||||||
}
|
}
|
||||||
|
|
||||||
// coverView draws a poster with half-block characters: every cell shows two
|
// coverView draws a poster with quadrant block characters: every cell shows
|
||||||
// vertically stacked pixels (foreground = upper, background = lower), so it
|
// 2×2 pixels in two colors (the best split of the four pixels into a
|
||||||
// works in any terminal with colors, also over SSH.
|
// 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 {
|
type coverView struct {
|
||||||
*tview.Box
|
*tview.Box
|
||||||
img *image.RGBA
|
img *image.RGBA
|
||||||
@@ -84,6 +88,18 @@ 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) 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) 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) {
|
func (c *coverView) Draw(screen tcell.Screen) {
|
||||||
c.Box.DrawForSubclass(screen, c)
|
c.Box.DrawForSubclass(screen, c)
|
||||||
x, y, w, h := c.GetInnerRect()
|
x, y, w, h := c.GetInnerRect()
|
||||||
@@ -97,33 +113,92 @@ func (c *coverView) Draw(screen tcell.Screen) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fit the poster into w columns by 2h pixel rows, keeping its aspect.
|
|
||||||
b := c.img.Bounds()
|
b := c.img.Bounds()
|
||||||
tw := w
|
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
|
||||||
th := tw * b.Dy() / b.Dx()
|
if cols < 1 || rows < 1 {
|
||||||
if th > 2*h {
|
|
||||||
th = 2 * h
|
|
||||||
tw = th * b.Dx() / b.Dy()
|
|
||||||
}
|
|
||||||
th &^= 1
|
|
||||||
if tw < 1 || th < 2 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if c.scaled == nil || c.scaled.Bounds().Dx() != tw || c.scaled.Bounds().Dy() != th {
|
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows {
|
||||||
c.scaled = boxScale(c.img, tw, th)
|
c.scaled = boxScale(c.img, 2*cols, 2*rows)
|
||||||
}
|
}
|
||||||
px := func(px, py int) tcell.Color {
|
px := func(x, y int) color.RGBA {
|
||||||
i := c.scaled.PixOffset(px, py)
|
i := c.scaled.PixOffset(x, y)
|
||||||
return tcell.NewRGBColor(int32(c.scaled.Pix[i]), int32(c.scaled.Pix[i+1]), int32(c.scaled.Pix[i+2]))
|
return color.RGBA{c.scaled.Pix[i], c.scaled.Pix[i+1], c.scaled.Pix[i+2], 255}
|
||||||
}
|
}
|
||||||
for row := 0; row < th/2; row++ {
|
for row := 0; row < rows; row++ {
|
||||||
for col := 0; col < tw; col++ {
|
for col := 0; col < cols; col++ {
|
||||||
style := tcell.StyleDefault.Foreground(px(col, 2*row)).Background(px(col, 2*row+1))
|
ch, fg, bg := quadCell([4]color.RGBA{
|
||||||
screen.SetContent(x+col, y+row, '▀', nil, style)
|
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".
|
// coverRef says where a poster comes from. The zero value means "no poster".
|
||||||
type coverRef struct {
|
type coverRef struct {
|
||||||
key string // identifies the poster in the cache
|
key string // identifies the poster in the cache
|
||||||
|
|||||||
+60
-19
@@ -77,39 +77,80 @@ func TestDecodeCover(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCoverViewDrawsHalfBlocks(t *testing.T) {
|
func TestQuadCell(t *testing.T) {
|
||||||
|
red, blue, black := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}, color.RGBA{0, 0, 0, 255}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
px [4]color.RGBA
|
||||||
|
wantCh rune
|
||||||
|
wantFg color.RGBA
|
||||||
|
wantBg color.RGBA
|
||||||
|
sameBoth bool
|
||||||
|
}{
|
||||||
|
{"flat", [4]color.RGBA{red, red, red, red}, ' ', red, red, true},
|
||||||
|
{"top half", [4]color.RGBA{red, red, blue, blue}, '▀', red, blue, false},
|
||||||
|
{"bottom half", [4]color.RGBA{blue, blue, red, red}, '▀', blue, red, false}, // same picture, canonical form
|
||||||
|
{"left half", [4]color.RGBA{red, blue, red, blue}, '▌', red, blue, false},
|
||||||
|
{"one corner", [4]color.RGBA{red, blue, blue, blue}, '▘', red, blue, false},
|
||||||
|
{"diagonal", [4]color.RGBA{red, black, black, red}, '▞', black, red, false},
|
||||||
|
} {
|
||||||
|
ch, fg, bg := quadCell(tc.px)
|
||||||
|
if ch != tc.wantCh || fg != tc.wantFg || bg != tc.wantBg {
|
||||||
|
t.Errorf("%s: %q fg=%v bg=%v, want %q fg=%v bg=%v", tc.name, ch, fg, bg, tc.wantCh, tc.wantFg, tc.wantBg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nearly flat noise stays a flat cell instead of a distracting pattern.
|
||||||
|
n := color.RGBA{100, 100, 100, 255}
|
||||||
|
m := color.RGBA{102, 100, 101, 255}
|
||||||
|
if ch, _, _ := quadCell([4]color.RGBA{n, m, n, n}); ch != ' ' {
|
||||||
|
t.Errorf("noise rendered as %q", ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverCells(t *testing.T) {
|
||||||
|
// A 2:3 poster in a roomy box is limited by the height: 10 rows are 20
|
||||||
|
// width units tall, so 13 columns.
|
||||||
|
if c, r := coverCells(40, 60, 30, 10); c != 13 || r != 10 {
|
||||||
|
t.Errorf("tall limit: %dx%d", c, r)
|
||||||
|
}
|
||||||
|
// In a narrow box the width limits it.
|
||||||
|
if c, r := coverCells(40, 60, 10, 20); c != 10 || r != 7 {
|
||||||
|
t.Errorf("narrow limit: %dx%d", c, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoverViewDraws(t *testing.T) {
|
||||||
screen := tcell.NewSimulationScreen("")
|
screen := tcell.NewSimulationScreen("")
|
||||||
if err := screen.Init(); err != nil {
|
if err := screen.Init(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer screen.Fini()
|
defer screen.Fini()
|
||||||
screen.SetSize(20, 10)
|
screen.SetSize(30, 10)
|
||||||
|
|
||||||
c := newCoverView()
|
c := newCoverView()
|
||||||
c.setImage(stripes(40, 60)) // 2:3 poster
|
c.setImage(stripes(40, 60)) // red above blue
|
||||||
c.SetRect(0, 0, 20, 10) // 20 columns × 20 pixel rows: a 13×20 pixel poster
|
c.SetRect(0, 0, 30, 10) // fits 13 columns × 10 rows
|
||||||
c.Draw(screen)
|
|
||||||
|
|
||||||
// Row 0 holds pixel rows 0 (top) and 1: both red. The middle cell row
|
c.Draw(screen)
|
||||||
// (index 4/5) crosses into blue; the last row is blue on both halves.
|
|
||||||
rgba := func(c tcell.Color) color.RGBA {
|
rgba := func(c tcell.Color) color.RGBA {
|
||||||
r, g, b := c.RGB()
|
r, g, b := c.RGB()
|
||||||
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
|
return color.RGBA{uint8(r), uint8(g), uint8(b), 255}
|
||||||
}
|
}
|
||||||
check := func(row int, wantFg, wantBg color.RGBA) {
|
cell := func(x, y int) (rune, color.RGBA, color.RGBA) {
|
||||||
t.Helper()
|
ch, _, st, _ := screen.GetContent(x, y)
|
||||||
ch, _, style, _ := screen.GetContent(0, row)
|
fg, bg, _ := st.Decompose()
|
||||||
fg, bg, _ := style.Decompose()
|
return ch, rgba(fg), rgba(bg)
|
||||||
if ch != '▀' || rgba(fg) != wantFg || rgba(bg) != wantBg {
|
|
||||||
t.Errorf("row %d: %q fg=%v bg=%v", row, ch, rgba(fg), rgba(bg))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
red, blue := color.RGBA{255, 0, 0, 255}, color.RGBA{0, 0, 255, 255}
|
||||||
check(0, red, red)
|
if _, fg, bg := cell(0, 0); fg != red || bg != red {
|
||||||
check(9, blue, blue)
|
t.Errorf("top row = fg %v bg %v, want red", fg, bg)
|
||||||
// Beyond the poster's width nothing is drawn.
|
}
|
||||||
if ch, _, _, _ := screen.GetContent(19, 0); ch == '▀' {
|
if _, fg, bg := cell(12, 9); fg != blue || bg != blue {
|
||||||
t.Error("poster stretched beyond its aspect ratio")
|
t.Errorf("bottom right = fg %v bg %v, want blue", fg, bg)
|
||||||
|
}
|
||||||
|
// Outside the poster's footprint nothing is drawn.
|
||||||
|
if ch, _, _, _ := screen.GetContent(13, 0); ch != ' ' {
|
||||||
|
t.Errorf("poster stretched beyond its aspect ratio: %q", ch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
coverRows = 18 // poster height in terminal rows (36 pixels)
|
coverRows = 20 // poster height in terminal rows
|
||||||
minCoverRows = 6 // a smaller poster is not worth showing
|
minCoverRows = 6 // a smaller poster is not worth showing
|
||||||
minTextRows = 10 // the poster never takes these rows from the text
|
minTextRows = 10 // the poster never takes these rows from the text
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user