Commit bec872cc34 for ffmpeg

commit bec872cc3446be37774aedc9a8c0882903c5ad25
Author: Michael Niedermayer <michael@niedermayer.cc>
Date:   Wed Oct 14 00:30:45 2026 +0200

    ffplay: move the chapter list into its own file

    The list becomes a ListView in ffplay_list.c, which shows entries with
    title, artist, length and metadata and reports the entry the user
    chooses; ffplay builds the entries from the chapters and seeks to the
    chosen one. The sort order moves from globals into the ListView.

    Assisted-by: Claude

diff --git a/fftools/Makefile b/fftools/Makefile
index bf1a8f1e29..38a724b339 100644
--- a/fftools/Makefile
+++ b/fftools/Makefile
@@ -53,6 +53,7 @@ OBJS-ffprobe +=                       \

 OBJS-ffmpeg += $(COMPAT_OBJS:%=compat/%)
 OBJS-ffplay += fftools/ffplay_renderer.o
+OBJS-ffplay += fftools/ffplay_list.o

 define DOFFTOOL
 OBJS-$(1) += fftools/cmdutils.o fftools/opt_common.o fftools/$(1).o $(OBJS-$(1)-yes)
diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 3054820dc3..b30f1dc224 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -58,6 +58,7 @@
 #include <SDL_thread.h>

 #include "cmdutils.h"
+#include "ffplay_list.h"
 #include "ffplay_renderer.h"
 #include "opt_common.h"

@@ -106,46 +107,6 @@ const int program_birth_year = 2003;

 #define CURSOR_HIDE_DELAY 1000000

-#define CHAPTER_LIST_FADE_TIME   250000
-#define CHAPTER_LIST_HOLD_TIME   500000
-#define CHAPTER_LIST_ROWS        7
-
-enum {
-    CHAPTER_SORT_NUMBER,
-    CHAPTER_SORT_ARTIST,
-    CHAPTER_SORT_TITLE,
-    CHAPTER_SORT_LENGTH,
-    CHAPTER_SORT_SHUFFLE,
-    CHAPTER_SORT_NB
-};
-
-typedef struct ChapterSortKey {
-    int key;
-    int descending;
-} ChapterSortKey;
-
-typedef struct ChapterRow {
-    int index;
-    const char *artist;
-    const char *title;
-    int64_t length;
-} ChapterRow;
-
-/* what lies under a window position: the row and the sort button, -1 for none, and whether it is inside the list at all */
-typedef struct ChapterListHit {
-    int inside;
-    int row;
-    int button;
-    int detail;
-} ChapterListHit;
-
-/* a horizontal band of the chapter list bitmap, rendered on its own when its content changes */
-typedef struct ChapterStrip {
-    SDL_Rect rect;
-    const char *background;
-    char *script;
-} ChapterStrip;
-
 #define USE_ONEPASS_SUBTITLE_RENDER 1

 typedef struct MyAVPacketList {
@@ -332,28 +293,8 @@ typedef struct VideoState {
     SDL_Texture *vis_texture;
     SDL_Texture *sub_texture;
     SDL_Texture *vid_texture;
-    SDL_Texture *chapter_texture;
-    SDL_Rect chapter_dirty;
-    uint8_t *chapter_bitmap;
-    SDL_Rect chapter_rect;
-    AVFilterGraph *chapter_graph;
-    AVFilterContext *chapter_sink;
-    ChapterStrip *chapter_strips;
-    int nb_chapter_strips;
-    int chapter_first;
-    ChapterRow *chapter_rows;
-    int nb_chapter_rows;
-    char chapter_search[64];
-    int chapter_selected;
-    int chapter_playing;
-    int chapter_mouse_x, chapter_mouse_y;
-    ChapterListHit chapter_hover;
-    int chapter_render_pending;
-    int chapter_pinned;
+    ListView *chapter_list;
     int chapter_pin_requested;
-    int64_t chapter_fade_start;
-    int64_t chapter_last_input;
-    double chapter_drawn_alpha;

     int subtitle_stream;
     AVStream *subtitle_st;
@@ -420,10 +361,6 @@ static int loop = 1;
 static int framedrop = -1;
 static int infinite_buffer = -1;
 static enum ShowMode show_mode = SHOW_MODE_NONE;
-/* the columns the chapter list is sorted by, the most recently clicked first */
-static ChapterSortKey chapter_sort[CHAPTER_SORT_NB] = { { CHAPTER_SORT_NUMBER }, { CHAPTER_SORT_ARTIST }, { CHAPTER_SORT_TITLE },
-                                                        { CHAPTER_SORT_LENGTH }, { CHAPTER_SORT_SHUFFLE } };
-static int chapter_shuffle_seed;
 static const char *audio_codec_name;
 static const char *subtitle_codec_name;
 static const char *video_codec_name;
@@ -1096,687 +1033,38 @@ static void draw_video_background(VideoState *is)
 static int current_chapter(VideoState *is);
 static void seek_chapter(VideoState *is, int i);

-static double chapter_list_alpha(VideoState *is, int64_t now)
-{
-    double alpha = (now - is->chapter_fade_start) / (double)CHAPTER_LIST_FADE_TIME;
-
-    if (!is->chapter_bitmap)
-        return 0;
-    if (!is->chapter_pinned)
-        alpha = FFMIN(alpha, 1 - (now - is->chapter_last_input - CHAPTER_LIST_HOLD_TIME) / (double)CHAPTER_LIST_FADE_TIME);
-    return av_clipd(alpha, 0, 1);
-}
-
 static const char *chapter_tag(const AVChapter *chapter, const char *key)
 {
     const AVDictionaryEntry *tag = av_dict_get(chapter->metadata, key, NULL, 0);

-    return tag ? tag->value : "";
-}
-
-/* case-insensitive, with missing tags after all others */
-static int compare_tags(const char *a, const char *b)
-{
-    return !*a != !*b ? !*a - !*b : av_strcasecmp(a, b);
-}
-
-/* the rank of a chapter in the shuffled order for the current seed */
-static uint32_t shuffle_rank(int index)
-{
-    uint32_t rank = (index + 1) * 0x9E3779B1u ^ chapter_shuffle_seed * 0x85EBCA77u;
-
-    rank ^= rank >> 15;
-    rank *= 0x2C1B3C6Du;
-    return rank ^ rank >> 12;
-}
-
-static int compare_chapter_rows(const void *a, const void *b)
-{
-    const ChapterRow *ra = a, *rb = b;
-
-    for (int i = 0; i < CHAPTER_SORT_NB; i++) {
-        int cmp;
-
-        switch (chapter_sort[i].key) {
-        case CHAPTER_SORT_ARTIST: cmp = compare_tags(ra->artist, rb->artist); break;
-        case CHAPTER_SORT_TITLE:  cmp = compare_tags(ra->title, rb->title);   break;
-        case CHAPTER_SORT_LENGTH: cmp = FFDIFFSIGN(ra->length, rb->length);   break;
-        case CHAPTER_SORT_SHUFFLE: cmp = FFDIFFSIGN(shuffle_rank(ra->index), shuffle_rank(rb->index)); break;
-        default:                  cmp = ra->index - rb->index;                break;
-        }
-        if (cmp)
-            return chapter_sort[i].descending ? -cmp : cmp;
-    }
-    return 0;
-}
-
-/* a column clicked again flips its order, any other becomes the first key ahead of the previous ones;
- * the shuffle button instead steps its seed by the side it was clicked on */
-static void chapter_sort_by(int key, int seed_step)
-{
-    int i = 0;
-
-    while (chapter_sort[i].key != key)
-        i++;
-    if (key == CHAPTER_SORT_SHUFFLE)
-        chapter_shuffle_seed += seed_step;
-    else if (i == 0)
-        chapter_sort[0].descending ^= 1;
-    if (i == 0)
-        return;
-    memmove(&chapter_sort[1], &chapter_sort[0], i * sizeof(*chapter_sort));
-    chapter_sort[0] = (ChapterSortKey){ key, 0 };
-}
-
-/* rebuilds the rows matching the search in the current sort order, keeping the selected chapter selected */
-static int chapter_list_update_rows(VideoState *is)
-{
-    int selected = is->nb_chapter_rows ? is->chapter_rows[is->chapter_selected].index : 0;
-    ChapterRow *rows = av_realloc_array(is->chapter_rows, is->ic->nb_chapters, sizeof(*rows));
-
-    if (!rows)
-        return AVERROR(ENOMEM);
-    is->chapter_rows    = rows;
-    is->nb_chapter_rows = 0;
-    for (int i = 0; i < is->ic->nb_chapters; i++) {
-        AVChapter *chapter = is->ic->chapters[i];
-        ChapterRow row = { i, chapter_tag(chapter, "artist"), chapter_tag(chapter, "title"), -1 };
-
-        if (chapter->end != AV_NOPTS_VALUE && chapter->end > chapter->start &&
-            (uint64_t)chapter->end - chapter->start <= INT64_MAX)
-            row.length = av_rescale_q(chapter->end - chapter->start, chapter->time_base, AV_TIME_BASE_Q);
-        if (av_stristr(row.artist, is->chapter_search) || av_stristr(row.title, is->chapter_search))
-            is->chapter_rows[is->nb_chapter_rows++] = row;
-    }
-    qsort(is->chapter_rows, is->nb_chapter_rows, sizeof(*is->chapter_rows), compare_chapter_rows);
-    is->chapter_selected = 0;
-    for (int row = 0; row < is->nb_chapter_rows; row++)
-        if (is->chapter_rows[row].index == selected)
-            is->chapter_selected = row;
-    return 0;
-}
-
-static const struct {
-    const char *label;
-    int width;
-} sort_buttons[CHAPTER_SORT_NB] = {
-    [CHAPTER_SORT_NUMBER] = { "#",      5 },
-    [CHAPTER_SORT_ARTIST] = { "Artist", 8 },
-    [CHAPTER_SORT_TITLE]  = { "Title",  7 },
-    [CHAPTER_SORT_LENGTH] = { "Duration", 10 },
-    [CHAPTER_SORT_SHUFFLE] = { "Shuffle", 14 },
-};
-
-#define CHAPTER_LIST_EVENT "Dialogue: 0:00:00.00,9999:00:00.00,"
-
-static void bprint_chapter_box(AVBPrint *script, const char *style, int x, int y, int w, int h)
-{
-    av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\pos(%d,%d)\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}\n", style, x, y, w, w, h, h);
-}
-
-/* a text cell cut off at the right and bottom of its column, with the characters libass would read as tags or line breaks blanked */
-static void bprint_chapter_cell(AVBPrint *script, const char *style, int x, int y, int right, int bottom, const char *text)
-{
-    av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\pos(%d,%d)\\clip(0,0,%d,%d)}", style, x, y, right, bottom);
-    for (; *text; text++)
-        av_bprint_chars(script, strchr("{}\\\r\n", *text) ? ' ' : *text, 1);
-    av_bprint_chars(script, '\n', 1);
-}
-
-typedef struct ChapterListLayout {
-    int font, line, width, height, canvas_h, strip_h, nb_rows, first;
-    int button_x[CHAPTER_SORT_NB], button_y[CHAPTER_SORT_NB];
-    int search_x, search_y, rows_y, text_x, text_right, length_x, detail_x, canvas_w;
-} ChapterListLayout;
-
-/* button widths are in half font sizes, a quarter apart, wrapping onto further lines when the panel is too narrow */
-static ChapterListLayout chapter_list_layout(VideoState *is)
-{
-    ChapterListLayout l;
-    int x, y;
-
-    l.font       = FFMAX(is->height / 30, 12);
-    l.line       = l.font * 9 / 4;
-    l.width      = FFMIN(FFMAX(is->width * 3 / 5, l.font * 26), is->width - l.font * 2);
-    x = y = l.font / 2;
-    for (int b = 0; b < CHAPTER_SORT_NB; b++) {
-        int w = sort_buttons[b].width * l.font / 2;
-
-        if (x > l.font / 2 && x + w > l.width - l.font / 2) {
-            x  = l.font / 2;
-            y += l.font * 3 / 2;
-        }
-        l.button_x[b] = x;
-        l.button_y[b] = y;
-        x += w + l.font / 4;
-    }
-    /* the search field follows the buttons, or takes its own line when they leave it less than 8 em */
-    if (x + l.font * 8 > l.width - l.font / 2) {
-        x  = l.font / 2;
-        y += l.font * 3 / 2;
-    }
-    l.search_x   = x;
-    l.search_y   = y;
-    l.rows_y     = is->chapter_pinned ? y + l.font * 3 / 2 : l.button_y[CHAPTER_SORT_NB - 1] + l.font * 3 / 2;
-    l.canvas_h   = FFMIN(y + l.font * 2 + CHAPTER_LIST_ROWS * l.line, is->height - l.font * 2);
-    l.strip_h    = FFMAX(l.line, y + l.font * 3 / 2);
-    l.nb_rows    = FFMIN(is->nb_chapter_rows, FFMAX(1, (l.canvas_h - l.rows_y - l.font / 2) / l.line));
-    l.first      = av_clip(is->chapter_selected - l.nb_rows / 2, 0, is->nb_chapter_rows - l.nb_rows);
-    l.height     = l.rows_y + l.nb_rows * l.line + l.font / 2;
-    l.text_x     = l.font * 3;
-    l.text_right = l.width - l.font * 11 / 2;
-    l.length_x   = l.width - l.font * 5 / 2;
-    l.detail_x   = l.width - l.font * 5 / 4;
-    l.canvas_w   = is->width - l.font * 2;
-    return l;
-}
-
-static ChapterListHit chapter_list_hit(VideoState *is, const ChapterListLayout *l, int x, int y)
-{
-    ChapterListHit hit = { 0, -1, -1, 0 };
-    int line;
-
-    x -= l->font;
-    y -= l->font;
-    if (x < 0 || y < 0 || x >= l->width || y >= FFMIN(l->height, l->canvas_h))
-        return hit;
-    hit.inside = 1;
-    line = (y - l->rows_y) / l->line;
-    if (y >= l->rows_y && line < l->nb_rows) {
-        hit.row    = l->first + line;
-        hit.detail = x >= l->width - l->font * 2;
-    }
-    for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++)
-        if (x >= l->button_x[b] && x < l->button_x[b] + sort_buttons[b].width * l->font / 2 &&
-            y >= l->button_y[b] && y < l->button_y[b] + l->font * 5 / 4)
-            hit.button = b;
-    return hit;
-}
-
-/* a circle outline drawn as four bezier arcs around a center */
-static void bprint_chapter_ring(AVBPrint *script, int cx, int cy, int r)
-{
-    int k = r * 552 / 1000;
-
-    av_bprintf(script, CHAPTER_LIST_EVENT "Ring,{\\an5\\pos(%d,%d)\\p1}m 0 %d b 0 %d %d 0 %d 0 b %d 0 %d %d %d %d "
-               "b %d %d %d %d %d %d b %d %d 0 %d 0 %d{\\p0}\n",
-               cx, cy, r, r - k, r - k, r, r + k, 2 * r, r - k, 2 * r, r, 2 * r, r + k, r + k, 2 * r, r, 2 * r,
-               r - k, 2 * r, r + k, r);
-}
-
-/* strip backgrounds, with premultiplied alpha as the ass filter composites */
-static const char chapter_panel_color[] = "0x000000BF";
-static const char chapter_hover_color[] = "0x373737CD";
-static const char chapter_popup_color[] = "0x000000DF";
-
-static void bprint_chapter_styles(AVBPrint *script, const ChapterListLayout *l)
-{
-    av_bprintf(script,
-               "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 2\nYCbCr Matrix: None\n\n"
-               "[V4+ Styles]\n"
-               "Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Bold, Outline, Alignment\n"
-               "Style: Button,Sans,%d,&H80FFFFFF,&H80FFFFFF,0,0,7\n"
-               "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n"
-               "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n"
-               "Style: Artist,Sans,%d,&H00C0C0C0,&H00000000,0,%d,7\n"
-               "Style: ArtistSelected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n"
-               "Style: Playing,Sans,%d,&H00FFFFFF,&H00FFFFFF,0,0,7\n"
-               "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n"
-               "Style: ButtonHover,Sans,%d,&H50FFFFFF,&H50FFFFFF,0,0,7\n"
-               "Style: Ring,Sans,%d,&HFF000000,&H00A0A0A0,0,%d,7\n"
-               "Style: Detail,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n\n"
-               "[Events]\nFormat: Start, End, Style, Text\n",
-               l->canvas_w, l->strip_h, l->font, l->font, l->font / 16 + 1, l->font, l->font / 16 + 1,
-               l->font * 4 / 5, l->font / 16 + 1, l->font * 4 / 5, l->font / 16 + 1, l->font, l->font, l->font / 16 + 1,
-               l->font, l->font, l->font / 16 + 1, l->font * 4 / 5, l->font / 16 + 1);
-}
-
-static void bprint_chapter_header_strip(VideoState *is, AVBPrint *script, const ChapterListLayout *l)
-{
-    if (is->chapter_pinned)
-        bprint_chapter_cell(script, *is->chapter_search ? "Row" : "Hint", l->search_x, l->search_y, l->length_x,
-                            l->search_y + l->font * 3 / 2, *is->chapter_search ? is->chapter_search : "type to search");
-    for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) {
-        int x = l->button_x[b], y = l->button_y[b], w = sort_buttons[b].width * l->font / 2;
-
-        bprint_chapter_box(script, b == is->chapter_hover.button ? "ButtonHover" : "Button", x, y, w, l->font * 5 / 4);
-        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an5\\pos(%d,%d)}", chapter_sort[0].key == b ? "Selected" : "Row",
-                   x + w / 2, y + l->font * 5 / 8);
-        if (b == CHAPTER_SORT_SHUFFLE)
-            av_bprintf(script, "\xe2\x97\x82 %s %d \xe2\x96\xb8\n", sort_buttons[b].label, chapter_shuffle_seed);
-        else
-            av_bprintf(script, "%s%s\n", sort_buttons[b].label,
-                       chapter_sort[0].key != b ? "" : chapter_sort[0].descending ? " \xe2\x96\xbc" : " \xe2\x96\xb2");
-    }
-}
-
-static void bprint_chapter_row_strip(VideoState *is, AVBPrint *script, const ChapterListLayout *l, int r)
-{
-    const ChapterRow *row = &is->chapter_rows[r];
-    const char *style = r == is->chapter_selected ? "Selected" : "Row";
-    int64_t seconds;
-
-    if (row->index == is->chapter_playing)
-        bprint_chapter_box(script, "Playing", 0, 0, l->font / 6, l->line);
-    av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an6\\pos(%d,%d)}%d\n", style, l->font * 5 / 2, l->line / 2, row->index + 1);
-    bprint_chapter_cell(script, style, l->text_x, l->font / 8, l->text_right, l->line, row->title);
-    bprint_chapter_cell(script, r == is->chapter_selected ? "ArtistSelected" : "Artist", l->text_x, l->font * 5 / 4,
-                        l->text_right, l->line, row->artist);
-    bprint_chapter_ring(script, l->detail_x, l->line / 2, l->font * 9 / 20);
-    av_bprintf(script, CHAPTER_LIST_EVENT "Hint,{\\an5\\pos(%d,%d)}i\n", l->detail_x, l->line / 2);
-    if (row->length < 0)
-        return;
-    seconds = av_rescale_rnd(row->length, 1, AV_TIME_BASE, AV_ROUND_NEAR_INF);
-    av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an6\\pos(%d,%d)}", style, l->length_x, l->line / 2);
-    if (seconds >= 3600)
-        av_bprintf(script, "%"PRId64":%02d:%02d\n", seconds / 3600, (int)(seconds / 60 % 60), (int)(seconds % 60));
-    else
-        av_bprintf(script, "%d:%02d\n", (int)(seconds / 60), (int)(seconds % 60));
-}
-
-static int chapter_strip_add(ChapterStrip **strips, int *nb, SDL_Rect rect, const char *background, const AVBPrint *script)
-{
-    if (rect.w <= 0 || rect.h <= 0)
-        return 0;
-    if (!av_bprint_is_complete(script))
-        return AVERROR(ENOMEM);
-    if (!(*nb & (*nb - 1))) {
-        ChapterStrip *grown = av_realloc_array(*strips, *nb ? 2 * *nb : 1, sizeof(**strips));
-
-        if (!grown)
-            return AVERROR(ENOMEM);
-        *strips = grown;
-    }
-    (*strips)[*nb] = (ChapterStrip){ rect, background, av_strdup(script->str) };
-    if (!(*strips)[*nb].script)
-        return AVERROR(ENOMEM);
-    (*nb)++;
-    return 0;
+    return tag ? tag->value : NULL;
 }

-/* the strips the list consists of right now: the header, each visible row, the bottom padding and the lines of the metadata
- * popup, beside the list or over its rows when the window leaves no room there */
-static int chapter_list_strips(VideoState *is, const ChapterListLayout *l, ChapterStrip **strips, int *nb)
-{
-    AVBPrint script;
-    int ret = 0;
-
-    av_bprint_init(&script, 0, AV_BPRINT_SIZE_UNLIMITED);
-    is->chapter_playing = current_chapter(is);
-    for (int i = 0; ret >= 0 && i < l->nb_rows + 2; i++) {
-        int row = i && i <= l->nb_rows ? l->first + i - 1 : -1;
-        SDL_Rect rect = { 0, i ? l->rows_y + (i - 1) * l->line : 0, l->width, i ? l->line : l->rows_y };
-
-        if (i > l->nb_rows)
-            rect.h = l->font / 2;
-        rect.h = FFMIN(rect.h, l->canvas_h - rect.y);
-        av_bprint_clear(&script);
-        bprint_chapter_styles(&script, l);
-        if (!i)
-            bprint_chapter_header_strip(is, &script, l);
-        else if (row >= 0)
-            bprint_chapter_row_strip(is, &script, l, row);
-        ret = chapter_strip_add(strips, nb, rect, row >= 0 && row == is->chapter_hover.row ? chapter_hover_color : chapter_panel_color, &script);
-    }
-    if (ret >= 0 && is->chapter_hover.detail) {
-        const AVDictionary *metadata = is->ic->chapters[is->chapter_rows[is->chapter_hover.row].index]->metadata;
-        const AVDictionaryEntry *above = NULL, *tag = av_dict_iterate(metadata, NULL);
-        int beside = l->canvas_w - l->width >= l->font * 12;
-        int height = FFMIN(av_dict_count(metadata) * l->font + l->font / 2, l->canvas_h);
-        int x      = beside ? l->width + l->font / 2 : l->text_x;
-        int right  = beside ? l->canvas_w : l->length_x - l->font / 2;
-        int y      = av_clip(l->rows_y + (is->chapter_hover.row - l->first) * l->line, 0, l->canvas_h - height);
-
-        /* the descenders of a line reach into the line below, so each line's strip also draws the line above it */
-        for (int top = 0; ret >= 0 && top < height && (above || tag); top += l->font) {
-            const AVDictionaryEntry *lines[2] = { above, tag };
-            SDL_Rect rect = { x, y + top, right - x, FFMIN(l->font, height - top) };
-
-            av_bprint_clear(&script);
-            bprint_chapter_styles(&script, l);
-            for (int i = 0; i < 2; i++) {
-                if (!lines[i])
-                    continue;
-                bprint_chapter_cell(&script, "Detail", x + l->font / 2, l->font / 4 + (i - 1) * l->font, x + l->font * 7, rect.h,
-                                    lines[i]->key);
-                bprint_chapter_cell(&script, "Detail", x + l->font * 15 / 2, l->font / 4 + (i - 1) * l->font, right - l->font / 4,
-                                    rect.h, lines[i]->value);
-            }
-            ret   = chapter_strip_add(strips, nb, rect, chapter_popup_color, &script);
-            above = tag;
-            tag   = tag ? av_dict_iterate(metadata, tag) : NULL;
-        }
-    }
-    av_bprint_finalize(&script, NULL);
-    return ret;
-}
-
-static void chapter_strips_free(ChapterStrip **strips, int *nb)
-{
-    for (int i = 0; i < *nb; i++)
-        av_freep(&(*strips)[i].script);
-    av_freep(strips);
-    *nb = 0;
-}
-
-static void chapter_list_discard(VideoState *is)
-{
-    avfilter_graph_free(&is->chapter_graph);
-    av_freep(&is->chapter_bitmap);
-    chapter_strips_free(&is->chapter_strips, &is->nb_chapter_strips);
-    if (is->chapter_texture)
-        SDL_DestroyTexture(is->chapter_texture);
-    is->chapter_texture = NULL;
-}
-
-/* a graph rendering one strip of the bitmap at a time, the bitmap itself and the texture it is uploaded to */
-static int chapter_list_configure(VideoState *is, const ChapterListLayout *l, const SDL_Rect *canvas)
+/* shows the chapter list with the chapter selected, or with the selection kept when no row shows it */
+static int chapter_list_show(VideoState *is, int chapter)
 {
-    const AVFilter *ass_filter = avfilter_get_by_name("ass");
-    AVFilterContext *source, *ass;
-    char source_args[64];
+    ListEntry *entries = av_calloc(is->ic->nb_chapters, sizeof(*entries));
     int ret;

-    if (!ass_filter) {
-        static int reported;
-
-        if (!reported++)
-            av_log(NULL, AV_LOG_ERROR, "The chapter list needs the ass filter, which this build lacks\n");
-        return AVERROR_FILTER_NOT_FOUND;
-    }
-    chapter_list_discard(is);
-    is->chapter_rect = *canvas;
-    if (l->canvas_w <= 0 || l->canvas_h <= 0 || l->canvas_w > INT_MAX / 4 / l->canvas_h)
-        return AVERROR(EINVAL);
-    if (!(is->chapter_graph = avfilter_graph_alloc()) ||
-        !(is->chapter_bitmap = av_mallocz(l->canvas_w * l->canvas_h * 4)))
-        return AVERROR(ENOMEM);
-    if (realloc_texture(&is->chapter_texture, SDL_PIXELFORMAT_ARGB8888, l->canvas_w, l->canvas_h, SDL_BLENDMODE_BLEND, 0) < 0)
-        return AVERROR_EXTERNAL;
-    is->chapter_dirty = (SDL_Rect){ 0, 0, l->canvas_w, l->canvas_h };
-    snprintf(source_args, sizeof(source_args), "color=black@0:rate=1:size=%dx%d", l->canvas_w, l->strip_h);
-    ass              = avfilter_graph_alloc_filter(is->chapter_graph, ass_filter, "ass");
-    is->chapter_sink = avfilter_graph_alloc_filter(is->chapter_graph, avfilter_get_by_name("buffersink"), "sink");
-    if (!ass || !is->chapter_sink)
+    if (!entries)
         return AVERROR(ENOMEM);
-    if ((ret = avfilter_graph_create_filter(&source, avfilter_get_by_name("color"), "source", source_args, NULL, is->chapter_graph)) < 0 ||
-        (ret = av_opt_set(ass, "script", "[Script Info]\nScriptType: v4.00+\n", AV_OPT_SEARCH_CHILDREN)) < 0 ||
-        (ret = av_opt_set_int(ass, "alpha", 1, AV_OPT_SEARCH_CHILDREN)) < 0 ||
-        (ret = avfilter_init_str(ass, NULL)) < 0 ||
-        (ret = av_opt_set_array(is->chapter_sink, "pixel_formats", AV_OPT_SEARCH_CHILDREN, 0, 1, AV_OPT_TYPE_PIXEL_FMT,
-                                &(enum AVPixelFormat){ AV_PIX_FMT_RGB32 })) < 0 ||
-        (ret = avfilter_init_str(is->chapter_sink, NULL)) < 0 ||
-        (ret = avfilter_link(source, 0, ass, 0)) < 0 ||
-        (ret = avfilter_link(ass, 0, is->chapter_sink, 0)) < 0)
-        return ret;
-    return avfilter_graph_config(is->chapter_graph, NULL);
-}
-
-/* renders the strip, the ass filter composing it over its background, and writes it into the bitmap */
-static int chapter_strip_render(VideoState *is, const ChapterStrip *strip)
-{
-    AVFrame *frame = av_frame_alloc();
-    int alpha = av_pix_fmt_desc_get(AV_PIX_FMT_RGB32)->comp[3].offset;
-    uint32_t scale[256] = { 1 << 16 };
-    int ret;
+    for (int i = 0; i < is->ic->nb_chapters; i++) {
+        const AVChapter *ch = is->ic->chapters[i];

-    if (!frame)
-        return AVERROR(ENOMEM);
-    for (int a = 1; a < 256; a++)
-        scale[a] = ((255 << 16) + a - 1) / a;
-    if ((ret = avfilter_graph_send_command(is->chapter_graph, "source", "color", strip->background, NULL, 0, 0)) >= 0 &&
-        (ret = avfilter_graph_send_command(is->chapter_graph, "ass", "script", strip->script, NULL, 0, 0)) >= 0)
-        ret = av_buffersink_get_frame(is->chapter_sink, frame);
-    /* the ass filter composites with premultiplied alpha, SDL blends straight alpha */
-    for (int y = 0; ret >= 0 && y < strip->rect.h; y++) {
-        const uint8_t *src = frame->data[0] + y * frame->linesize[0] + strip->rect.x * 4;
-        uint8_t *dst = is->chapter_bitmap + ((strip->rect.y + y) * is->chapter_rect.w + strip->rect.x) * 4;
-
-        for (int x = 0; x < strip->rect.w * 4; x += 4)
-            for (int c = 0; c < 4; c++)
-                dst[x + c] = c == alpha ? src[x + c] : FFMIN(src[x + c] * scale[src[x + alpha]] >> 16, 255);
+        entries[i] = (ListEntry){ chapter_tag(ch, "title"), chapter_tag(ch, "artist"), -1, ch->metadata };
+        if (ch->end != AV_NOPTS_VALUE && ch->end > ch->start && (uint64_t)ch->end - ch->start <= INT64_MAX)
+            entries[i].length = av_rescale_q(ch->end - ch->start, ch->time_base, AV_TIME_BASE_Q);
     }
-    av_frame_free(&frame);
+    list_view_set_playing(is->chapter_list, current_chapter(is));
+    ret = list_view_show(is->chapter_list, entries, is->ic->nb_chapters, chapter);
+    av_free(entries);
     return ret;
 }

-static void chapter_bitmap_touched(VideoState *is, const SDL_Rect *rect)
-{
-    SDL_UnionRect(&is->chapter_dirty, rect, &is->chapter_dirty);
-    is->force_refresh = 1;
-}
-
-/* moves the pixels and strips of the rows that stay visible when the list scrolls, so that only the rows scrolling in are
- * rendered; rows the popup covered hold popup pixels and are rendered anew, as is that part of the popup */
-static void chapter_list_scroll(VideoState *is, const ChapterListLayout *l, const ChapterStrip *strips, int nb)
-{
-    int shift = is->chapter_first - l->first, kept = l->nb_rows - FFABS(shift);
-    SDL_Rect block = { 0, l->rows_y, l->width, l->nb_rows * l->line }, popup = { 0, 0, 0, 0 };
-
-    if (!shift || kept <= 0 || is->nb_chapter_strips <= l->nb_rows || nb <= l->nb_rows)
-        return;
-    ChapterStrip *rows = is->chapter_strips + 1;
-    for (int j = 0; j < l->nb_rows; j++) {
-        SDL_Rect slot = { 0, block.y + j * l->line, l->width, l->line };
-
-        if (!SDL_RectEquals(&rows[j].rect, &slot) || !SDL_RectEquals(&strips[j + 1].rect, &slot))
-            return;
-    }
-    for (int i = 0; i < is->nb_chapter_strips; i++) {
-        if (is->chapter_strips[i].background == chapter_popup_color && SDL_HasIntersection(&is->chapter_strips[i].rect, &block)) {
-            SDL_UnionRect(&popup, &is->chapter_strips[i].rect, &popup);
-            is->chapter_strips[i].background = NULL;
-        }
-    }
-    for (int j = 0; j < l->nb_rows; j++)
-        if (SDL_HasIntersection(&rows[j].rect, &popup))
-            rows[j].background = NULL;
-    for (int k = 0; k < kept * l->line; k++) {
-        int y = shift > 0 ? block.y + block.h - 1 - k : block.y + k;
-
-        memcpy(is->chapter_bitmap + y * is->chapter_rect.w * 4, is->chapter_bitmap + (y - shift * l->line) * is->chapter_rect.w * 4,
-               l->width * 4);
-    }
-    for (int j = 0; j < FFABS(shift); j++)
-        av_freep(&rows[shift > 0 ? kept + j : j].script);
-    memmove(rows + FFMAX(shift, 0), rows + FFMAX(-shift, 0), kept * sizeof(*rows));
-    for (int j = 0; j < FFABS(shift); j++)
-        rows[shift > 0 ? j : kept + j] = (ChapterStrip){ { 0 } };
-    for (int j = 0; j < l->nb_rows; j++)
-        rows[j].rect = strips[j + 1].rect;
-    chapter_bitmap_touched(is, &block);
-}
-
-/* brings the bitmap up to date, rendering only the strips whose content, place or background changed */
-static int chapter_list_render(VideoState *is)
+static void chapter_list_pin(VideoState *is)
 {
-    ChapterListLayout l = chapter_list_layout(is);
-    SDL_Rect canvas = { l.font, l.font, l.canvas_w, l.canvas_h }, redrawn = { 0, 0, 0, 0 };
-    ChapterStrip *strips = NULL;
-    int nb = 0, ret = 0;
-
-    is->chapter_render_pending = 0;
-    is->chapter_hover = chapter_list_hit(is, &l, is->chapter_mouse_x, is->chapter_mouse_y);
-    if (!is->chapter_graph || !SDL_RectEquals(&is->chapter_rect, &canvas))
-        ret = chapter_list_configure(is, &l, &canvas);
-    if (ret >= 0)
-        ret = chapter_list_strips(is, &l, &strips, &nb);
-    if (ret >= 0)
-        chapter_list_scroll(is, &l, strips, nb);
-    /* what the strips no longer cover becomes transparent again; a strip is drawn anew when it changed or lies on what was
-     * cleared or drawn before it */
-    for (int i = 0; ret >= 0 && i < is->nb_chapter_strips; i++) {
-        const SDL_Rect *old = &is->chapter_strips[i].rect;
-
-        if (i < nb && SDL_RectEquals(old, &strips[i].rect))
-            continue;
-        for (int y = 0; y < old->h; y++)
-            memset(is->chapter_bitmap + ((old->y + y) * is->chapter_rect.w + old->x) * 4, 0, old->w * 4);
-        SDL_UnionRect(&redrawn, old, &redrawn);
-        chapter_bitmap_touched(is, old);
-    }
-    for (int i = 0; ret >= 0 && i < nb; i++) {
-        const ChapterStrip *old = i < is->nb_chapter_strips ? &is->chapter_strips[i] : NULL;
-
-        if (old && SDL_RectEquals(&old->rect, &strips[i].rect) && old->background == strips[i].background &&
-            !strcmp(old->script, strips[i].script) && !SDL_HasIntersection(&strips[i].rect, &redrawn))
-            continue;
-        if ((ret = chapter_strip_render(is, &strips[i])) < 0)
-            break;
-        SDL_UnionRect(&redrawn, &strips[i].rect, &redrawn);
-        chapter_bitmap_touched(is, &strips[i].rect);
-    }
-    if (ret < 0) {
-        if (ret != AVERROR_FILTER_NOT_FOUND)
-            av_log(NULL, AV_LOG_ERROR, "Could not render the chapter list: %s\n", av_err2str(ret));
-        chapter_list_discard(is);
-        chapter_strips_free(&strips, &nb);
-        is->chapter_pinned = 0;
-        SDL_StopTextInput();
-    }
-    chapter_strips_free(&is->chapter_strips, &is->nb_chapter_strips);
-    is->chapter_strips    = strips;
-    is->nb_chapter_strips = nb;
-    is->chapter_first     = l.first;
-    return ret;
-}
-
-static void chapter_list_select(VideoState *is, int row)
-{
-    int64_t now = av_gettime_relative();
-
-    is->chapter_selected       = row;
-    is->chapter_fade_start     = now - chapter_list_alpha(is, now) * CHAPTER_LIST_FADE_TIME;
-    is->chapter_last_input     = now;
-    is->chapter_render_pending = 1;
-}
-
-/* shows the list with the chapter selected, or with the selection kept when no row shows it */
-static int chapter_list_show(VideoState *is, int chapter)
-{
-    int ret = renderer ? chapter_list_update_rows(is) : AVERROR(EINVAL);
-
-    if (ret < 0)
-        return ret;
-    for (int row = 0; row < is->nb_chapter_rows; row++)
-        if (is->chapter_rows[row].index == chapter)
-            is->chapter_selected = row;
-    chapter_list_select(is, is->chapter_selected);
-    return 0;
-}
-
-static void chapter_list_move(VideoState *is, int delta)
-{
-    if (is->nb_chapter_rows)
-        chapter_list_select(is, av_clip(is->chapter_selected + delta, 0, is->nb_chapter_rows - 1));
-}
-
-static int chapter_list_visible(VideoState *is)
-{
-    return is->chapter_pinned || chapter_list_alpha(is, av_gettime_relative()) > 0;
-}
-
-/* appends the typed text to the search, or erases its last character when nothing was typed */
-static void chapter_list_search(VideoState *is, const char *typed)
-{
-    int len = strlen(is->chapter_search);
-
-    if (typed) {
-        if (len + strlen(typed) < sizeof(is->chapter_search))
-            av_strlcat(is->chapter_search, typed, sizeof(is->chapter_search));
-    } else if (len) {
-        while (len > 0 && (is->chapter_search[--len] & 0xC0) == 0x80)
-            ;
-        is->chapter_search[len] = 0;
-    }
-    chapter_list_update_rows(is);
-    is->chapter_render_pending = 1;
-}
-
-static void chapter_list_pin(VideoState *is, int pinned)
-{
-    if (!renderer)
-        return;
-    is->chapter_pinned = pinned;
-    if (pinned) {
-        is->chapter_pinned = chapter_list_show(is, current_chapter(is)) >= 0;
-        if (is->chapter_pinned)
-            SDL_StartTextInput();
-    } else {
-        SDL_StopTextInput();
-        is->chapter_last_input = av_gettime_relative() - CHAPTER_LIST_HOLD_TIME;
-        is->chapter_search[0] = 0;
-        chapter_list_update_rows(is);
-        is->chapter_render_pending = 1;
-    }
-}
-
-/* keys while the list is pinned; typed characters arrive as text input instead */
-static void chapter_list_key(VideoState *is, SDL_Keycode key)
-{
-    switch (key) {
-    case SDLK_ESCAPE:    chapter_list_pin(is, 0); break;
-    case SDLK_UP:        chapter_list_move(is, -1); break;
-    case SDLK_DOWN:      chapter_list_move(is, 1); break;
-    case SDLK_PAGEUP:    seek_chapter(is, current_chapter(is) + 1); break;
-    case SDLK_PAGEDOWN:  seek_chapter(is, current_chapter(is) - 1); break;
-    case SDLK_BACKSPACE: chapter_list_search(is, NULL); break;
-    case SDLK_RETURN:
-    case SDLK_KP_ENTER:
-        if (is->nb_chapter_rows)
-            seek_chapter(is, is->chapter_rows[is->chapter_selected].index);
-        break;
-    }
-}
-
-/* keeps the mouse position for the next render and asks for one when what a visible list points at changed;
- * the render waits until the pending events are handled, so a sweep across the list renders once */
-static void chapter_list_hover(VideoState *is, int x, int y)
-{
-    ChapterListLayout l;
-    ChapterListHit hit;
-
-    is->chapter_mouse_x = x;
-    is->chapter_mouse_y = y;
-    if (!chapter_list_visible(is))
-        return;
-    l   = chapter_list_layout(is);
-    hit = chapter_list_hit(is, &l, x, y);
-    if (hit.row != is->chapter_hover.row || hit.button != is->chapter_hover.button || hit.detail != is->chapter_hover.detail)
-        is->chapter_render_pending = 1;
-}
-
-/* seeks to the entry under a click or sorts by the button under it, returns whether the click hit the list at all */
-static int chapter_list_click(VideoState *is, int x, int y)
-{
-    ChapterListLayout l = chapter_list_layout(is);
-    ChapterListHit hit = chapter_list_hit(is, &l, x, y);
-
-    if (hit.row >= 0 && !hit.detail)
-        seek_chapter(is, is->chapter_rows[hit.row].index);
-    if (hit.button >= 0) {
-        int button_w = sort_buttons[hit.button].width * l.font / 2;
-
-        chapter_sort_by(hit.button, (x - l.font - l.button_x[hit.button]) * 2 < button_w ? -1 : 1);
-        chapter_list_show(is, -1);
-    }
-    return hit.inside;
-}
-
-/* uploads what the texture lacks of the bitmap, at most once per refresh */
-static void chapter_list_draw(VideoState *is)
-{
-    double alpha = chapter_list_alpha(is, av_gettime_relative());
-    SDL_Rect *dirty = &is->chapter_dirty;
-
-    is->chapter_drawn_alpha = alpha;
-    if (alpha <= 0)
-        return;
-    if (dirty->w && !SDL_UpdateTexture(is->chapter_texture, dirty,
-                                       is->chapter_bitmap + (dirty->y * is->chapter_rect.w + dirty->x) * 4, is->chapter_rect.w * 4))
-        *dirty = (SDL_Rect){ 0, 0, 0, 0 };
-    SDL_SetTextureAlphaMod(is->chapter_texture, alpha * 255);
-    SDL_RenderCopy(renderer, is->chapter_texture, NULL, &is->chapter_rect);
+    if (chapter_list_show(is, current_chapter(is)) >= 0)
+        list_view_pin(is->chapter_list, 1);
 }

 static void video_image_display(VideoState *is)
@@ -2156,8 +1444,7 @@ static void stream_close(VideoState *is)
         SDL_DestroyTexture(is->vid_texture);
     if (is->sub_texture)
         SDL_DestroyTexture(is->sub_texture);
-    chapter_list_discard(is);
-    av_freep(&is->chapter_rows);
+    list_view_free(&is->chapter_list);
     av_free(is);
 }

@@ -2226,6 +1513,7 @@ static int video_open(VideoState *is)

     is->width  = w;
     is->height = h;
+    list_view_set_size(is->chapter_list, w, h);

     return 0;
 }
@@ -2242,7 +1530,7 @@ static void video_display(VideoState *is)
         video_audio_display(is);
     else if (is->video_st)
         video_image_display(is);
-    chapter_list_draw(is);
+    list_view_draw(is->chapter_list);
     SDL_RenderPresent(renderer);
 }

@@ -4290,7 +3578,8 @@ static VideoState *stream_open(const char *filename,
     is->last_audio_stream = is->audio_stream = -1;
     is->last_subtitle_stream = is->subtitle_stream = -1;
     is->filename = av_strdup(filename);
-    if (!is->filename)
+    is->chapter_list = list_view_alloc(renderer);
+    if (!is->filename || !is->chapter_list)
         goto fail;
     is->iformat = iformat;
     is->ytop    = 0;
@@ -4463,9 +3752,9 @@ static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {
                 continue;
         }
         remaining_time = REFRESH_RATE;
-        if (is->chapter_render_pending || (chapter_list_visible(is) && current_chapter(is) != is->chapter_playing))
-            chapter_list_render(is);
-        if (chapter_list_alpha(is, av_gettime_relative()) != is->chapter_drawn_alpha)
+        if (list_view_visible(is->chapter_list))
+            list_view_set_playing(is->chapter_list, current_chapter(is));
+        if (list_view_update(is->chapter_list))
             is->force_refresh = 1;
         if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))
             video_refresh(is, &remaining_time);
@@ -4504,13 +3793,14 @@ static void event_loop(VideoState *cur_stream)
 {
     SDL_Event event;
     double incr, pos, frac;
+    int chosen;

     for (;;) {
         double x;
         refresh_loop_wait_event(cur_stream, &event);
         switch (event.type) {
         case SDL_KEYDOWN:
-            if (exit_on_keydown || (!cur_stream->chapter_pinned &&
+            if (exit_on_keydown || (!list_view_pinned(cur_stream->chapter_list) &&
                                     (event.key.keysym.sym == SDLK_ESCAPE || event.key.keysym.sym == SDLK_q))) {
                 do_exit(cur_stream);
                 break;
@@ -4518,8 +3808,11 @@ static void event_loop(VideoState *cur_stream)
             // If we don't yet have a window, skip all key events, because read_thread might still be initializing...
             if (!cur_stream->width)
                 continue;
-            if (cur_stream->chapter_pinned) {
-                chapter_list_key(cur_stream, event.key.keysym.sym);
+            if (list_view_pinned(cur_stream->chapter_list)) {
+                if (event.key.keysym.sym == SDLK_PAGEUP || event.key.keysym.sym == SDLK_PAGEDOWN)
+                    seek_chapter(cur_stream, current_chapter(cur_stream) + (event.key.keysym.sym == SDLK_PAGEUP ? 1 : -1));
+                else if ((chosen = list_view_key(cur_stream->chapter_list, event.key.keysym.sym)) >= 0)
+                    seek_chapter(cur_stream, chosen);
                 break;
             }
             switch (event.key.keysym.sym) {
@@ -4564,8 +3857,8 @@ static void event_loop(VideoState *cur_stream)
                 break;
             case SDLK_RETURN:
             case SDLK_KP_ENTER:
-                if (chapter_list_visible(cur_stream) && cur_stream->nb_chapter_rows)
-                    seek_chapter(cur_stream, cur_stream->chapter_rows[cur_stream->chapter_selected].index);
+                if (list_view_visible(cur_stream->chapter_list) && (chosen = list_view_selected(cur_stream->chapter_list)) >= 0)
+                    seek_chapter(cur_stream, chosen);
                 break;
             case SDLK_w:
                 if (cur_stream->show_mode == SHOW_MODE_VIDEO && cur_stream->vfilter_idx < nb_vfilters - 1) {
@@ -4633,25 +3926,28 @@ static void event_loop(VideoState *cur_stream)
         case SDL_KEYUP:
             /* on the release of a press made while unpinned, so that the key's own text input does not start the search */
             if (event.key.keysym.sym == SDLK_l && cur_stream->chapter_pin_requested)
-                chapter_list_pin(cur_stream, 1);
+                chapter_list_pin(cur_stream);
             cur_stream->chapter_pin_requested = 0;
             break;
         case SDL_TEXTINPUT:
-            if (cur_stream->chapter_pinned)
-                chapter_list_search(cur_stream, event.text.text);
+            if (list_view_pinned(cur_stream->chapter_list))
+                list_view_text(cur_stream->chapter_list, event.text.text);
             break;
         case SDL_MOUSEWHEEL:
-            if (chapter_list_visible(cur_stream))
-                chapter_list_move(cur_stream, -event.wheel.y);
+            if (list_view_visible(cur_stream->chapter_list))
+                list_view_move(cur_stream->chapter_list, -event.wheel.y);
             break;
         case SDL_MOUSEBUTTONDOWN:
             if (exit_on_mousedown) {
                 do_exit(cur_stream);
                 break;
             }
-            if (event.button.button == SDL_BUTTON_LEFT && chapter_list_visible(cur_stream) &&
-                chapter_list_click(cur_stream, event.button.x, event.button.y))
+            if (event.button.button == SDL_BUTTON_LEFT && list_view_visible(cur_stream->chapter_list) &&
+                list_view_click(cur_stream->chapter_list, event.button.x, event.button.y, &chosen)) {
+                if (chosen >= 0)
+                    seek_chapter(cur_stream, chosen);
                 break;
+            }
             if (event.button.button == SDL_BUTTON_LEFT) {
                 static int64_t last_mouse_left_click = 0;
                 if (av_gettime_relative() - last_mouse_left_click <= 500000) {
@@ -4670,7 +3966,7 @@ static void event_loop(VideoState *cur_stream)
             }
             cursor_last_shown = av_gettime_relative();
             if (event.type == SDL_MOUSEMOTION)
-                chapter_list_hover(cur_stream, event.motion.x, event.motion.y);
+                list_view_hover(cur_stream->chapter_list, event.motion.x, event.motion.y);
             if (event.type == SDL_MOUSEBUTTONDOWN) {
                 if (event.button.button != SDL_BUTTON_RIGHT)
                     break;
@@ -4708,7 +4004,7 @@ static void event_loop(VideoState *cur_stream)
         case SDL_WINDOWEVENT:
             switch (event.window.event) {
                 case SDL_WINDOWEVENT_LEAVE:
-                    chapter_list_hover(cur_stream, -1, -1);
+                    list_view_hover(cur_stream->chapter_list, -1, -1);
                     break;
                 case SDL_WINDOWEVENT_SIZE_CHANGED:
                     screen_width  = cur_stream->width  = event.window.data1;
@@ -4717,8 +4013,7 @@ static void event_loop(VideoState *cur_stream)
                         SDL_DestroyTexture(cur_stream->vis_texture);
                         cur_stream->vis_texture = NULL;
                     }
-                    if (cur_stream->chapter_bitmap)
-                        cur_stream->chapter_render_pending = 1;
+                    list_view_set_size(cur_stream->chapter_list, screen_width, screen_height);
                     if (vk_renderer)
                         vk_renderer_resize(vk_renderer, screen_width, screen_height);
                     av_fallthrough;
diff --git a/fftools/ffplay_list.c b/fftools/ffplay_list.c
new file mode 100644
index 0000000000..ef379ff9da
--- /dev/null
+++ b/fftools/ffplay_list.c
@@ -0,0 +1,861 @@
+/*
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include <limits.h>
+#include <string.h>
+
+#include "libavutil/avstring.h"
+#include "libavutil/bprint.h"
+#include "libavutil/common.h"
+#include "libavutil/error.h"
+#include "libavutil/frame.h"
+#include "libavutil/log.h"
+#include "libavutil/mathematics.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+#include "libavutil/pixdesc.h"
+#include "libavutil/qsort.h"
+#include "libavutil/time.h"
+#include "libavfilter/avfilter.h"
+#include "libavfilter/buffersink.h"
+
+#include "ffplay_list.h"
+
+#define LIST_FADE_TIME 250000
+#define LIST_HOLD_TIME 500000
+#define LIST_ROWS      7
+
+enum {
+    LIST_SORT_NUMBER,
+    LIST_SORT_ARTIST,
+    LIST_SORT_TITLE,
+    LIST_SORT_LENGTH,
+    LIST_SORT_SHUFFLE,
+    LIST_SORT_NB
+};
+
+typedef struct ListSortKey {
+    int key;
+    int descending;
+} ListSortKey;
+
+/* what lies under a window position: the row and the sort button, -1 for none, and whether it is inside the list at all */
+typedef struct ListHit {
+    int inside;
+    int row;
+    int button;
+    int detail;
+} ListHit;
+
+/* a horizontal band of the list bitmap, rendered on its own when its content changes */
+typedef struct ListStrip {
+    SDL_Rect rect;
+    const char *background;
+    char *script;
+} ListStrip;
+
+struct ListView {
+    SDL_Renderer *renderer;
+    SDL_Texture *texture;
+    SDL_Rect dirty;
+    uint8_t *bitmap;
+    SDL_Rect rect;
+    AVFilterGraph *graph;
+    AVFilterContext *sink;
+    ListStrip *strips;
+    int nb_strips;
+    int first;
+    ListEntry *entries;
+    int nb_entries;
+    /* the listed entries in display order, and the scratch space sorting them needs */
+    int *rows, *sort_tmp;
+    int nb_rows;
+    char search[64];
+    int selected;
+    int playing;
+    int mouse_x, mouse_y;
+    ListHit hover;
+    int render_pending;
+    int changed;
+    int pinned;
+    int64_t fade_start;
+    int64_t last_input;
+    double drawn_alpha;
+    int width, height;
+    /* the columns the list is sorted by, the most recently clicked first */
+    ListSortKey sort[LIST_SORT_NB];
+    int shuffle_seed;
+    int filter_missing_reported;
+};
+
+static double list_alpha(const ListView *lv, int64_t now)
+{
+    double alpha = (now - lv->fade_start) / (double)LIST_FADE_TIME;
+
+    if (!lv->bitmap)
+        return 0;
+    if (!lv->pinned)
+        alpha = FFMIN(alpha, 1 - (now - lv->last_input - LIST_HOLD_TIME) / (double)LIST_FADE_TIME);
+    return av_clipd(alpha, 0, 1);
+}
+
+static const char *entry_tag(const char *tag)
+{
+    return tag ? tag : "";
+}
+
+/* case-insensitive, with missing tags after all others */
+static int compare_tags(const char *a, const char *b)
+{
+    a = entry_tag(a);
+    b = entry_tag(b);
+    return !*a != !*b ? !*a - !*b : av_strcasecmp(a, b);
+}
+
+/* the rank of an entry in the shuffled order for the current seed */
+static uint32_t shuffle_rank(const ListView *lv, int index)
+{
+    uint32_t rank = (index + 1) * 0x9E3779B1u ^ lv->shuffle_seed * 0x85EBCA77u;
+
+    rank ^= rank >> 15;
+    rank *= 0x2C1B3C6Du;
+    return rank ^ rank >> 12;
+}
+
+static int compare_rows(const ListView *lv, const int *a, const int *b)
+{
+    const ListEntry *ea = &lv->entries[*a], *eb = &lv->entries[*b];
+
+    for (int i = 0; i < LIST_SORT_NB; i++) {
+        int cmp;
+
+        switch (lv->sort[i].key) {
+        case LIST_SORT_ARTIST:  cmp = compare_tags(ea->artist, eb->artist); break;
+        case LIST_SORT_TITLE:   cmp = compare_tags(ea->title, eb->title);   break;
+        case LIST_SORT_LENGTH:  cmp = FFDIFFSIGN(ea->length, eb->length);   break;
+        case LIST_SORT_SHUFFLE: cmp = FFDIFFSIGN(shuffle_rank(lv, *a), shuffle_rank(lv, *b)); break;
+        default:                cmp = *a - *b;                              break;
+        }
+        if (cmp)
+            return lv->sort[i].descending ? -cmp : cmp;
+    }
+    return 0;
+}
+
+/* a column clicked again flips its order, any other becomes the first key ahead of the previous ones;
+ * the shuffle button instead steps its seed by the side it was clicked on */
+static void list_sort_by(ListView *lv, int key, int seed_step)
+{
+    int i = 0;
+
+    while (lv->sort[i].key != key)
+        i++;
+    if (key == LIST_SORT_SHUFFLE)
+        lv->shuffle_seed += seed_step;
+    else if (i == 0)
+        lv->sort[0].descending ^= 1;
+    if (i == 0)
+        return;
+    memmove(&lv->sort[1], &lv->sort[0], i * sizeof(*lv->sort));
+    lv->sort[0] = (ListSortKey){ key, 0 };
+}
+
+/* rebuilds the rows matching the search in the current sort order, keeping the selected entry selected */
+static void list_update_rows(ListView *lv)
+{
+    int selected = list_view_selected(lv);
+
+    lv->nb_rows = 0;
+    for (int i = 0; i < lv->nb_entries; i++)
+        if (av_stristr(entry_tag(lv->entries[i].artist), lv->search) || av_stristr(entry_tag(lv->entries[i].title), lv->search))
+            lv->rows[lv->nb_rows++] = i;
+#define CMP(a, b) compare_rows(lv, a, b)
+    AV_MSORT(lv->rows, lv->sort_tmp, lv->nb_rows, int, CMP);
+#undef CMP
+    lv->selected = 0;
+    for (int row = 0; row < lv->nb_rows; row++)
+        if (lv->rows[row] == selected)
+            lv->selected = row;
+}
+
+static const struct {
+    const char *label;
+    int width;
+} sort_buttons[LIST_SORT_NB] = {
+    [LIST_SORT_NUMBER] = { "#",      5 },
+    [LIST_SORT_ARTIST] = { "Artist", 8 },
+    [LIST_SORT_TITLE]  = { "Title",  7 },
+    [LIST_SORT_LENGTH] = { "Duration", 10 },
+    [LIST_SORT_SHUFFLE] = { "Shuffle", 14 },
+};
+
+#define LIST_EVENT "Dialogue: 0:00:00.00,9999:00:00.00,"
+
+static void bprint_box(AVBPrint *script, const char *style, int x, int y, int w, int h)
+{
+    av_bprintf(script, LIST_EVENT "%s,{\\pos(%d,%d)\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}\n", style, x, y, w, w, h, h);
+}
+
+/* a text cell cut off at the right and bottom of its column, with the characters libass would read as tags or line breaks blanked */
+static void bprint_cell(AVBPrint *script, const char *style, int x, int y, int right, int bottom, const char *text)
+{
+    av_bprintf(script, LIST_EVENT "%s,{\\pos(%d,%d)\\clip(0,0,%d,%d)}", style, x, y, right, bottom);
+    for (text = entry_tag(text); *text; text++)
+        av_bprint_chars(script, strchr("{}\\\r\n", *text) ? ' ' : *text, 1);
+    av_bprint_chars(script, '\n', 1);
+}
+
+typedef struct ListLayout {
+    int font, line, width, height, canvas_h, strip_h, nb_rows, first;
+    int button_x[LIST_SORT_NB], button_y[LIST_SORT_NB];
+    int search_x, search_y, rows_y, text_x, text_right, length_x, detail_x, canvas_w;
+} ListLayout;
+
+/* button widths are in half font sizes, a quarter apart, wrapping onto further lines when the panel is too narrow */
+static ListLayout list_layout(const ListView *lv)
+{
+    ListLayout l;
+    int x, y;
+
+    l.font       = FFMAX(lv->height / 30, 12);
+    l.line       = l.font * 9 / 4;
+    l.width      = FFMIN(FFMAX(lv->width * 3 / 5, l.font * 26), lv->width - l.font * 2);
+    x = y = l.font / 2;
+    for (int b = 0; b < LIST_SORT_NB; b++) {
+        int w = sort_buttons[b].width * l.font / 2;
+
+        if (x > l.font / 2 && x + w > l.width - l.font / 2) {
+            x  = l.font / 2;
+            y += l.font * 3 / 2;
+        }
+        l.button_x[b] = x;
+        l.button_y[b] = y;
+        x += w + l.font / 4;
+    }
+    /* the search field follows the buttons, or takes its own line when they leave it less than 8 em */
+    if (x + l.font * 8 > l.width - l.font / 2) {
+        x  = l.font / 2;
+        y += l.font * 3 / 2;
+    }
+    l.search_x   = x;
+    l.search_y   = y;
+    l.rows_y     = lv->pinned ? y + l.font * 3 / 2 : l.button_y[LIST_SORT_NB - 1] + l.font * 3 / 2;
+    l.canvas_h   = FFMIN(y + l.font * 2 + LIST_ROWS * l.line, lv->height - l.font * 2);
+    l.strip_h    = FFMAX(l.line, y + l.font * 3 / 2);
+    l.nb_rows    = FFMIN(lv->nb_rows, FFMAX(1, (l.canvas_h - l.rows_y - l.font / 2) / l.line));
+    l.first      = av_clip(lv->selected - l.nb_rows / 2, 0, lv->nb_rows - l.nb_rows);
+    l.height     = l.rows_y + l.nb_rows * l.line + l.font / 2;
+    l.text_x     = l.font * 3;
+    l.text_right = l.width - l.font * 11 / 2;
+    l.length_x   = l.width - l.font * 5 / 2;
+    l.detail_x   = l.width - l.font * 5 / 4;
+    l.canvas_w   = lv->width - l.font * 2;
+    return l;
+}
+
+static ListHit list_hit(const ListLayout *l, int x, int y)
+{
+    ListHit hit = { 0, -1, -1, 0 };
+    int line;
+
+    x -= l->font;
+    y -= l->font;
+    if (x < 0 || y < 0 || x >= l->width || y >= FFMIN(l->height, l->canvas_h))
+        return hit;
+    hit.inside = 1;
+    line = (y - l->rows_y) / l->line;
+    if (y >= l->rows_y && line < l->nb_rows) {
+        hit.row    = l->first + line;
+        hit.detail = x >= l->width - l->font * 2;
+    }
+    for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++)
+        if (x >= l->button_x[b] && x < l->button_x[b] + sort_buttons[b].width * l->font / 2 &&
+            y >= l->button_y[b] && y < l->button_y[b] + l->font * 5 / 4)
+            hit.button = b;
+    return hit;
+}
+
+/* a circle outline drawn as four bezier arcs around a center */
+static void bprint_ring(AVBPrint *script, int cx, int cy, int r)
+{
+    int k = r * 552 / 1000;
+
+    av_bprintf(script, LIST_EVENT "Ring,{\\an5\\pos(%d,%d)\\p1}m 0 %d b 0 %d %d 0 %d 0 b %d 0 %d %d %d %d "
+               "b %d %d %d %d %d %d b %d %d 0 %d 0 %d{\\p0}\n",
+               cx, cy, r, r - k, r - k, r, r + k, 2 * r, r - k, 2 * r, r, 2 * r, r + k, r + k, 2 * r, r, 2 * r,
+               r - k, 2 * r, r + k, r);
+}
+
+/* strip backgrounds, with premultiplied alpha as the ass filter composites */
+static const char panel_color[] = "0x000000BF";
+static const char hover_color[] = "0x373737CD";
+static const char popup_color[] = "0x000000DF";
+
+static void bprint_styles(AVBPrint *script, const ListLayout *l)
+{
+    av_bprintf(script,
+               "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: %d\nWrapStyle: 2\nYCbCr Matrix: None\n\n"
+               "[V4+ Styles]\n"
+               "Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, Bold, Outline, Alignment\n"
+               "Style: Button,Sans,%d,&H80FFFFFF,&H80FFFFFF,0,0,7\n"
+               "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n"
+               "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n"
+               "Style: Artist,Sans,%d,&H00C0C0C0,&H00000000,0,%d,7\n"
+               "Style: ArtistSelected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n"
+               "Style: Playing,Sans,%d,&H00FFFFFF,&H00FFFFFF,0,0,7\n"
+               "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n"
+               "Style: ButtonHover,Sans,%d,&H50FFFFFF,&H50FFFFFF,0,0,7\n"
+               "Style: Ring,Sans,%d,&HFF000000,&H00A0A0A0,0,%d,7\n"
+               "Style: Detail,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n\n"
+               "[Events]\nFormat: Start, End, Style, Text\n",
+               l->canvas_w, l->strip_h, l->font, l->font, l->font / 16 + 1, l->font, l->font / 16 + 1,
+               l->font * 4 / 5, l->font / 16 + 1, l->font * 4 / 5, l->font / 16 + 1, l->font, l->font, l->font / 16 + 1,
+               l->font, l->font, l->font / 16 + 1, l->font * 4 / 5, l->font / 16 + 1);
+}
+
+static void bprint_header_strip(const ListView *lv, AVBPrint *script, const ListLayout *l)
+{
+    if (lv->pinned)
+        bprint_cell(script, *lv->search ? "Row" : "Hint", l->search_x, l->search_y, l->length_x,
+                    l->search_y + l->font * 3 / 2, *lv->search ? lv->search : "type to search");
+    for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) {
+        int x = l->button_x[b], y = l->button_y[b], w = sort_buttons[b].width * l->font / 2;
+
+        bprint_box(script, b == lv->hover.button ? "ButtonHover" : "Button", x, y, w, l->font * 5 / 4);
+        av_bprintf(script, LIST_EVENT "%s,{\\an5\\pos(%d,%d)}", lv->sort[0].key == b ? "Selected" : "Row",
+                   x + w / 2, y + l->font * 5 / 8);
+        if (b == LIST_SORT_SHUFFLE)
+            av_bprintf(script, "\xe2\x97\x82 %s %d \xe2\x96\xb8\n", sort_buttons[b].label, lv->shuffle_seed);
+        else
+            av_bprintf(script, "%s%s\n", sort_buttons[b].label,
+                       lv->sort[0].key != b ? "" : lv->sort[0].descending ? " \xe2\x96\xbc" : " \xe2\x96\xb2");
+    }
+}
+
+static void bprint_row_strip(const ListView *lv, AVBPrint *script, const ListLayout *l, int r)
+{
+    int index = lv->rows[r];
+    const ListEntry *entry = &lv->entries[index];
+    const char *style = r == lv->selected ? "Selected" : "Row";
+    int64_t seconds;
+
+    if (index == lv->playing)
+        bprint_box(script, "Playing", 0, 0, l->font / 6, l->line);
+    av_bprintf(script, LIST_EVENT "%s,{\\an6\\pos(%d,%d)}%d\n", style, l->font * 5 / 2, l->line / 2, index + 1);
+    bprint_cell(script, style, l->text_x, l->font / 8, l->text_right, l->line, entry->title);
+    bprint_cell(script, r == lv->selected ? "ArtistSelected" : "Artist", l->text_x, l->font * 5 / 4,
+                l->text_right, l->line, entry->artist);
+    bprint_ring(script, l->detail_x, l->line / 2, l->font * 9 / 20);
+    av_bprintf(script, LIST_EVENT "Hint,{\\an5\\pos(%d,%d)}i\n", l->detail_x, l->line / 2);
+    if (entry->length < 0)
+        return;
+    seconds = av_rescale_rnd(entry->length, 1, AV_TIME_BASE, AV_ROUND_NEAR_INF);
+    av_bprintf(script, LIST_EVENT "%s,{\\an6\\pos(%d,%d)}", style, l->length_x, l->line / 2);
+    if (seconds >= 3600)
+        av_bprintf(script, "%"PRId64":%02d:%02d\n", seconds / 3600, (int)(seconds / 60 % 60), (int)(seconds % 60));
+    else
+        av_bprintf(script, "%d:%02d\n", (int)(seconds / 60), (int)(seconds % 60));
+}
+
+static int strip_add(ListStrip **strips, int *nb, SDL_Rect rect, const char *background, const AVBPrint *script)
+{
+    if (rect.w <= 0 || rect.h <= 0)
+        return 0;
+    if (!av_bprint_is_complete(script))
+        return AVERROR(ENOMEM);
+    if (!(*nb & (*nb - 1))) {
+        ListStrip *grown = av_realloc_array(*strips, *nb ? 2 * *nb : 1, sizeof(**strips));
+
+        if (!grown)
+            return AVERROR(ENOMEM);
+        *strips = grown;
+    }
+    (*strips)[*nb] = (ListStrip){ rect, background, av_strdup(script->str) };
+    if (!(*strips)[*nb].script)
+        return AVERROR(ENOMEM);
+    (*nb)++;
+    return 0;
+}
+
+/* the strips the list consists of right now: the header, each visible row, the bottom padding and the lines of the metadata
+ * popup, beside the list or over its rows when the window leaves no room there */
+static int list_strips(const ListView *lv, const ListLayout *l, ListStrip **strips, int *nb)
+{
+    AVBPrint script;
+    int ret = 0;
+
+    av_bprint_init(&script, 0, AV_BPRINT_SIZE_UNLIMITED);
+    for (int i = 0; ret >= 0 && i < l->nb_rows + 2; i++) {
+        int row = i && i <= l->nb_rows ? l->first + i - 1 : -1;
+        SDL_Rect rect = { 0, i ? l->rows_y + (i - 1) * l->line : 0, l->width, i ? l->line : l->rows_y };
+
+        if (i > l->nb_rows)
+            rect.h = l->font / 2;
+        rect.h = FFMIN(rect.h, l->canvas_h - rect.y);
+        av_bprint_clear(&script);
+        bprint_styles(&script, l);
+        if (!i)
+            bprint_header_strip(lv, &script, l);
+        else if (row >= 0)
+            bprint_row_strip(lv, &script, l, row);
+        ret = strip_add(strips, nb, rect, row >= 0 && row == lv->hover.row ? hover_color : panel_color, &script);
+    }
+    if (ret >= 0 && lv->hover.detail) {
+        const AVDictionary *metadata = lv->entries[lv->rows[lv->hover.row]].metadata;
+        const AVDictionaryEntry *above = NULL, *tag = av_dict_iterate(metadata, NULL);
+        int beside = l->canvas_w - l->width >= l->font * 12;
+        int height = FFMIN(av_dict_count(metadata) * l->font + l->font / 2, l->canvas_h);
+        int x      = beside ? l->width + l->font / 2 : l->text_x;
+        int right  = beside ? l->canvas_w : l->length_x - l->font / 2;
+        int y      = av_clip(l->rows_y + (lv->hover.row - l->first) * l->line, 0, l->canvas_h - height);
+
+        /* the descenders of a line reach into the line below, so each line's strip also draws the line above it */
+        for (int top = 0; ret >= 0 && top < height && (above || tag); top += l->font) {
+            const AVDictionaryEntry *lines[2] = { above, tag };
+            SDL_Rect rect = { x, y + top, right - x, FFMIN(l->font, height - top) };
+
+            av_bprint_clear(&script);
+            bprint_styles(&script, l);
+            for (int i = 0; i < 2; i++) {
+                if (!lines[i])
+                    continue;
+                bprint_cell(&script, "Detail", x + l->font / 2, l->font / 4 + (i - 1) * l->font, x + l->font * 7, rect.h,
+                            lines[i]->key);
+                bprint_cell(&script, "Detail", x + l->font * 15 / 2, l->font / 4 + (i - 1) * l->font, right - l->font / 4,
+                            rect.h, lines[i]->value);
+            }
+            ret   = strip_add(strips, nb, rect, popup_color, &script);
+            above = tag;
+            tag   = tag ? av_dict_iterate(metadata, tag) : NULL;
+        }
+    }
+    av_bprint_finalize(&script, NULL);
+    return ret;
+}
+
+static void strips_free(ListStrip **strips, int *nb)
+{
+    for (int i = 0; i < *nb; i++)
+        av_freep(&(*strips)[i].script);
+    av_freep(strips);
+    *nb = 0;
+}
+
+static void list_discard(ListView *lv)
+{
+    avfilter_graph_free(&lv->graph);
+    av_freep(&lv->bitmap);
+    strips_free(&lv->strips, &lv->nb_strips);
+    if (lv->texture)
+        SDL_DestroyTexture(lv->texture);
+    lv->texture = NULL;
+}
+
+/* a graph rendering one strip of the bitmap at a time, the bitmap itself and the texture it is uploaded to */
+static int list_configure(ListView *lv, const ListLayout *l, const SDL_Rect *canvas)
+{
+    const AVFilter *ass_filter = avfilter_get_by_name("ass");
+    AVFilterContext *source, *ass;
+    char source_args[64];
+    int ret;
+
+    if (!ass_filter) {
+        if (!lv->filter_missing_reported++)
+            av_log(NULL, AV_LOG_ERROR, "The chapter list needs the ass filter, which this build lacks\n");
+        return AVERROR_FILTER_NOT_FOUND;
+    }
+    list_discard(lv);
+    lv->rect = *canvas;
+    if (l->canvas_w <= 0 || l->canvas_h <= 0 || l->canvas_w > INT_MAX / 4 / l->canvas_h)
+        return AVERROR(EINVAL);
+    if (!(lv->graph = avfilter_graph_alloc()) ||
+        !(lv->bitmap = av_mallocz(l->canvas_w * l->canvas_h * 4)))
+        return AVERROR(ENOMEM);
+    if (!(lv->texture = SDL_CreateTexture(lv->renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING,
+                                          l->canvas_w, l->canvas_h)) ||
+        SDL_SetTextureBlendMode(lv->texture, SDL_BLENDMODE_BLEND) < 0)
+        return AVERROR_EXTERNAL;
+    lv->dirty = (SDL_Rect){ 0, 0, l->canvas_w, l->canvas_h };
+    snprintf(source_args, sizeof(source_args), "color=black@0:rate=1:size=%dx%d", l->canvas_w, l->strip_h);
+    ass      = avfilter_graph_alloc_filter(lv->graph, ass_filter, "ass");
+    lv->sink = avfilter_graph_alloc_filter(lv->graph, avfilter_get_by_name("buffersink"), "sink");
+    if (!ass || !lv->sink)
+        return AVERROR(ENOMEM);
+    if ((ret = avfilter_graph_create_filter(&source, avfilter_get_by_name("color"), "source", source_args, NULL, lv->graph)) < 0 ||
+        (ret = av_opt_set(ass, "script", "[Script Info]\nScriptType: v4.00+\n", AV_OPT_SEARCH_CHILDREN)) < 0 ||
+        (ret = av_opt_set_int(ass, "alpha", 1, AV_OPT_SEARCH_CHILDREN)) < 0 ||
+        (ret = avfilter_init_str(ass, NULL)) < 0 ||
+        (ret = av_opt_set_array(lv->sink, "pixel_formats", AV_OPT_SEARCH_CHILDREN, 0, 1, AV_OPT_TYPE_PIXEL_FMT,
+                                &(enum AVPixelFormat){ AV_PIX_FMT_RGB32 })) < 0 ||
+        (ret = avfilter_init_str(lv->sink, NULL)) < 0 ||
+        (ret = avfilter_link(source, 0, ass, 0)) < 0 ||
+        (ret = avfilter_link(ass, 0, lv->sink, 0)) < 0)
+        return ret;
+    return avfilter_graph_config(lv->graph, NULL);
+}
+
+/* renders the strip, the ass filter composing it over its background, and writes it into the bitmap */
+static int strip_render(ListView *lv, const ListStrip *strip)
+{
+    AVFrame *frame = av_frame_alloc();
+    int alpha = av_pix_fmt_desc_get(AV_PIX_FMT_RGB32)->comp[3].offset;
+    uint32_t scale[256] = { 1 << 16 };
+    int ret;
+
+    if (!frame)
+        return AVERROR(ENOMEM);
+    for (int a = 1; a < 256; a++)
+        scale[a] = ((255 << 16) + a - 1) / a;
+    if ((ret = avfilter_graph_send_command(lv->graph, "source", "color", strip->background, NULL, 0, 0)) >= 0 &&
+        (ret = avfilter_graph_send_command(lv->graph, "ass", "script", strip->script, NULL, 0, 0)) >= 0)
+        ret = av_buffersink_get_frame(lv->sink, frame);
+    /* the ass filter composites with premultiplied alpha, SDL blends straight alpha */
+    for (int y = 0; ret >= 0 && y < strip->rect.h; y++) {
+        const uint8_t *src = frame->data[0] + y * frame->linesize[0] + strip->rect.x * 4;
+        uint8_t *dst = lv->bitmap + ((strip->rect.y + y) * lv->rect.w + strip->rect.x) * 4;
+
+        for (int x = 0; x < strip->rect.w * 4; x += 4)
+            for (int c = 0; c < 4; c++)
+                dst[x + c] = c == alpha ? src[x + c] : FFMIN(src[x + c] * scale[src[x + alpha]] >> 16, 255);
+    }
+    av_frame_free(&frame);
+    return ret;
+}
+
+static void bitmap_touched(ListView *lv, const SDL_Rect *rect)
+{
+    SDL_UnionRect(&lv->dirty, rect, &lv->dirty);
+    lv->changed = 1;
+}
+
+/* moves the pixels and strips of the rows that stay visible when the list scrolls, so that only the rows scrolling in are
+ * rendered; rows the popup covered hold popup pixels and are rendered anew, as is that part of the popup */
+static void list_scroll(ListView *lv, const ListLayout *l, const ListStrip *strips, int nb)
+{
+    int shift = lv->first - l->first, kept = l->nb_rows - FFABS(shift);
+    SDL_Rect block = { 0, l->rows_y, l->width, l->nb_rows * l->line }, popup = { 0, 0, 0, 0 };
+
+    if (!shift || kept <= 0 || lv->nb_strips <= l->nb_rows || nb <= l->nb_rows)
+        return;
+    ListStrip *rows = lv->strips + 1;
+    for (int j = 0; j < l->nb_rows; j++) {
+        SDL_Rect slot = { 0, block.y + j * l->line, l->width, l->line };
+
+        if (!SDL_RectEquals(&rows[j].rect, &slot) || !SDL_RectEquals(&strips[j + 1].rect, &slot))
+            return;
+    }
+    for (int i = 0; i < lv->nb_strips; i++) {
+        if (lv->strips[i].background == popup_color && SDL_HasIntersection(&lv->strips[i].rect, &block)) {
+            SDL_UnionRect(&popup, &lv->strips[i].rect, &popup);
+            lv->strips[i].background = NULL;
+        }
+    }
+    for (int j = 0; j < l->nb_rows; j++)
+        if (SDL_HasIntersection(&rows[j].rect, &popup))
+            rows[j].background = NULL;
+    for (int k = 0; k < kept * l->line; k++) {
+        int y = shift > 0 ? block.y + block.h - 1 - k : block.y + k;
+
+        memcpy(lv->bitmap + y * lv->rect.w * 4, lv->bitmap + (y - shift * l->line) * lv->rect.w * 4, l->width * 4);
+    }
+    for (int j = 0; j < FFABS(shift); j++)
+        av_freep(&rows[shift > 0 ? kept + j : j].script);
+    memmove(rows + FFMAX(shift, 0), rows + FFMAX(-shift, 0), kept * sizeof(*rows));
+    for (int j = 0; j < FFABS(shift); j++)
+        rows[shift > 0 ? j : kept + j] = (ListStrip){ { 0 } };
+    for (int j = 0; j < l->nb_rows; j++)
+        rows[j].rect = strips[j + 1].rect;
+    bitmap_touched(lv, &block);
+}
+
+/* brings the bitmap up to date, rendering only the strips whose content, place or background changed */
+static int list_render(ListView *lv)
+{
+    ListLayout l = list_layout(lv);
+    SDL_Rect canvas = { l.font, l.font, l.canvas_w, l.canvas_h }, redrawn = { 0, 0, 0, 0 };
+    ListStrip *strips = NULL;
+    int nb = 0, ret = 0;
+
+    lv->render_pending = 0;
+    lv->hover = list_hit(&l, lv->mouse_x, lv->mouse_y);
+    if (!lv->graph || !SDL_RectEquals(&lv->rect, &canvas))
+        ret = list_configure(lv, &l, &canvas);
+    if (ret >= 0)
+        ret = list_strips(lv, &l, &strips, &nb);
+    if (ret >= 0)
+        list_scroll(lv, &l, strips, nb);
+    /* what the strips no longer cover becomes transparent again; a strip is drawn anew when it changed or lies on what was
+     * cleared or drawn before it */
+    for (int i = 0; ret >= 0 && i < lv->nb_strips; i++) {
+        const SDL_Rect *old = &lv->strips[i].rect;
+
+        if (i < nb && SDL_RectEquals(old, &strips[i].rect))
+            continue;
+        for (int y = 0; y < old->h; y++)
+            memset(lv->bitmap + ((old->y + y) * lv->rect.w + old->x) * 4, 0, old->w * 4);
+        SDL_UnionRect(&redrawn, old, &redrawn);
+        bitmap_touched(lv, old);
+    }
+    for (int i = 0; ret >= 0 && i < nb; i++) {
+        const ListStrip *old = i < lv->nb_strips ? &lv->strips[i] : NULL;
+
+        if (old && SDL_RectEquals(&old->rect, &strips[i].rect) && old->background == strips[i].background &&
+            !strcmp(old->script, strips[i].script) && !SDL_HasIntersection(&strips[i].rect, &redrawn))
+            continue;
+        if ((ret = strip_render(lv, &strips[i])) < 0)
+            break;
+        SDL_UnionRect(&redrawn, &strips[i].rect, &redrawn);
+        bitmap_touched(lv, &strips[i].rect);
+    }
+    if (ret < 0) {
+        if (ret != AVERROR_FILTER_NOT_FOUND)
+            av_log(NULL, AV_LOG_ERROR, "Could not render the chapter list: %s\n", av_err2str(ret));
+        list_discard(lv);
+        strips_free(&strips, &nb);
+        if (lv->pinned)
+            SDL_StopTextInput();
+        lv->pinned = 0;
+    }
+    strips_free(&lv->strips, &lv->nb_strips);
+    lv->strips    = strips;
+    lv->nb_strips = nb;
+    lv->first     = l.first;
+    return ret;
+}
+
+static void list_select(ListView *lv, int row)
+{
+    int64_t now = av_gettime_relative();
+
+    lv->selected       = row;
+    lv->fade_start     = now - list_alpha(lv, now) * LIST_FADE_TIME;
+    lv->last_input     = now;
+    lv->render_pending = 1;
+}
+
+/* appends the typed text to the search, or erases its last character when nothing was typed */
+static void list_search(ListView *lv, const char *typed)
+{
+    int len = strlen(lv->search);
+
+    if (typed) {
+        if (len + strlen(typed) < sizeof(lv->search))
+            av_strlcat(lv->search, typed, sizeof(lv->search));
+    } else if (len) {
+        while (len > 0 && (lv->search[--len] & 0xC0) == 0x80)
+            ;
+        lv->search[len] = 0;
+    }
+    list_update_rows(lv);
+    lv->render_pending = 1;
+}
+
+ListView *list_view_alloc(SDL_Renderer *renderer)
+{
+    ListView *lv = av_mallocz(sizeof(*lv));
+
+    if (!lv)
+        return NULL;
+    lv->renderer = renderer;
+    lv->playing  = -1;
+    lv->mouse_x  = lv->mouse_y = -1;
+    lv->hover    = (ListHit){ 0, -1, -1, 0 };
+    for (int i = 0; i < LIST_SORT_NB; i++)
+        lv->sort[i].key = i;
+    return lv;
+}
+
+void list_view_free(ListView **plv)
+{
+    ListView *lv = *plv;
+
+    if (!lv)
+        return;
+    list_discard(lv);
+    av_freep(&lv->entries);
+    av_freep(&lv->rows);
+    av_freep(&lv->sort_tmp);
+    av_freep(plv);
+}
+
+int list_view_show(ListView *lv, const ListEntry *entries, int nb_entries, int selected)
+{
+    ListEntry *copy;
+    int *rows, *sort_tmp, previous = list_view_selected(lv);
+
+    if (!lv->renderer)
+        return AVERROR(EINVAL);
+    copy     = av_malloc_array(nb_entries, sizeof(*copy));
+    rows     = av_malloc_array(nb_entries, sizeof(*rows));
+    sort_tmp = av_malloc_array(nb_entries, sizeof(*sort_tmp));
+    if (!copy || !rows || !sort_tmp) {
+        av_free(copy);
+        av_free(rows);
+        av_free(sort_tmp);
+        return AVERROR(ENOMEM);
+    }
+    if (nb_entries)
+        memcpy(copy, entries, nb_entries * sizeof(*entries));
+    av_free(lv->entries);
+    av_free(lv->rows);
+    av_free(lv->sort_tmp);
+    lv->entries    = copy;
+    lv->rows       = rows;
+    lv->sort_tmp   = sort_tmp;
+    lv->nb_entries = nb_entries;
+    lv->nb_rows    = 0;
+    list_update_rows(lv);
+    for (int row = 0; row < lv->nb_rows; row++)
+        if (lv->rows[row] == previous)
+            lv->selected = row;
+    for (int row = 0; row < lv->nb_rows; row++)
+        if (lv->rows[row] == selected)
+            lv->selected = row;
+    list_select(lv, lv->selected);
+    return 0;
+}
+
+void list_view_pin(ListView *lv, int pinned)
+{
+    lv->pinned = pinned;
+    if (pinned) {
+        SDL_StartTextInput();
+    } else {
+        SDL_StopTextInput();
+        lv->last_input = av_gettime_relative() - LIST_HOLD_TIME;
+        lv->search[0] = 0;
+        list_update_rows(lv);
+    }
+    lv->render_pending = 1;
+}
+
+int list_view_pinned(const ListView *lv)
+{
+    return lv->pinned;
+}
+
+int list_view_visible(const ListView *lv)
+{
+    return lv->pinned || list_alpha(lv, av_gettime_relative()) > 0;
+}
+
+int list_view_selected(const ListView *lv)
+{
+    return lv->nb_rows ? lv->rows[lv->selected] : -1;
+}
+
+void list_view_move(ListView *lv, int delta)
+{
+    if (lv->nb_rows)
+        list_select(lv, av_clip(lv->selected + delta, 0, lv->nb_rows - 1));
+}
+
+int list_view_key(ListView *lv, SDL_Keycode key)
+{
+    switch (key) {
+    case SDLK_ESCAPE:    list_view_pin(lv, 0); break;
+    case SDLK_UP:        list_view_move(lv, -1); break;
+    case SDLK_DOWN:      list_view_move(lv, 1); break;
+    case SDLK_BACKSPACE: list_search(lv, NULL); break;
+    case SDLK_RETURN:
+    case SDLK_KP_ENTER:  return list_view_selected(lv);
+    }
+    return -1;
+}
+
+void list_view_text(ListView *lv, const char *text)
+{
+    list_search(lv, text);
+}
+
+/* asks for a render when what a visible list points at changed; the render waits until the pending events are handled,
+ * so a sweep across the list renders once */
+void list_view_hover(ListView *lv, int x, int y)
+{
+    ListLayout l;
+    ListHit hit;
+
+    lv->mouse_x = x;
+    lv->mouse_y = y;
+    if (!list_view_visible(lv))
+        return;
+    l   = list_layout(lv);
+    hit = list_hit(&l, x, y);
+    if (hit.row != lv->hover.row || hit.button != lv->hover.button || hit.detail != lv->hover.detail)
+        lv->render_pending = 1;
+}
+
+int list_view_click(ListView *lv, int x, int y, int *chosen)
+{
+    ListLayout l = list_layout(lv);
+    ListHit hit = list_hit(&l, x, y);
+
+    *chosen = hit.row >= 0 && !hit.detail ? lv->rows[hit.row] : -1;
+    if (hit.button >= 0) {
+        int button_w = sort_buttons[hit.button].width * l.font / 2;
+
+        list_sort_by(lv, hit.button, (x - l.font - l.button_x[hit.button]) * 2 < button_w ? -1 : 1);
+        list_update_rows(lv);
+        list_select(lv, lv->selected);
+    }
+    return hit.inside;
+}
+
+void list_view_set_size(ListView *lv, int width, int height)
+{
+    if (lv->width == width && lv->height == height)
+        return;
+    lv->width  = width;
+    lv->height = height;
+    if (lv->bitmap)
+        lv->render_pending = 1;
+}
+
+void list_view_set_playing(ListView *lv, int entry)
+{
+    if (lv->playing == entry)
+        return;
+    lv->playing        = entry;
+    lv->render_pending = 1;
+}
+
+int list_view_update(ListView *lv)
+{
+    int changed;
+
+    if (lv->render_pending)
+        list_render(lv);
+    changed = lv->changed || list_alpha(lv, av_gettime_relative()) != lv->drawn_alpha;
+    lv->changed = 0;
+    return changed;
+}
+
+/* uploads what the texture lacks of the bitmap, at most once per refresh */
+void list_view_draw(ListView *lv)
+{
+    double alpha = list_alpha(lv, av_gettime_relative());
+    SDL_Rect *dirty = &lv->dirty;
+
+    lv->drawn_alpha = alpha;
+    if (alpha <= 0)
+        return;
+    if (dirty->w && !SDL_UpdateTexture(lv->texture, dirty, lv->bitmap + (dirty->y * lv->rect.w + dirty->x) * 4, lv->rect.w * 4))
+        *dirty = (SDL_Rect){ 0, 0, 0, 0 };
+    SDL_SetTextureAlphaMod(lv->texture, alpha * 255);
+    SDL_RenderCopy(lv->renderer, lv->texture, NULL, &lv->rect);
+}
diff --git a/fftools/ffplay_list.h b/fftools/ffplay_list.h
new file mode 100644
index 0000000000..7814a88b5f
--- /dev/null
+++ b/fftools/ffplay_list.h
@@ -0,0 +1,131 @@
+/*
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+/**
+ * @file
+ * A list overlay for ffplay: entries with title, artist, length and metadata that the user can sort, filter by typing,
+ * browse with keys, wheel and mouse, and pick one of.
+ *
+ * A ListView is used from the thread handling the SDL events and rendering; separate ListViews are independent.
+ */
+
+#ifndef FFTOOLS_FFPLAY_LIST_H
+#define FFTOOLS_FFPLAY_LIST_H
+
+#include <stdint.h>
+
+#include <SDL.h>
+
+#include "libavutil/dict.h"
+
+typedef struct ListEntry {
+    const char *title;
+    const char *artist;
+    int64_t length;                 ///< in AV_TIME_BASE units, negative when unknown
+    const AVDictionary *metadata;   ///< shown when the mouse is over the entry's symbol, may be NULL
+} ListEntry;
+
+typedef struct ListView ListView;
+
+/**
+ * @return the list, hidden, or NULL on allocation failure
+ */
+ListView *list_view_alloc(SDL_Renderer *renderer);
+
+void list_view_free(ListView **lv);
+
+/**
+ * Replaces the entries and shows the list, fading it in.
+ *
+ * @param entries  numbered from 1 in the list; their strings and dictionaries must stay valid until the next call or
+ *                 list_view_free()
+ * @param selected entry to select, the selection is kept when it is -1 or not listed
+ * @return 0 or a negative error code
+ */
+int list_view_show(ListView *lv, const ListEntry *entries, int nb_entries, int selected);
+
+/**
+ * Keeps the list on screen and lets it take the keyboard and text input, or lets it fade out again.
+ */
+void list_view_pin(ListView *lv, int pinned);
+
+int list_view_pinned(const ListView *lv);
+
+/**
+ * @return whether the list is on screen or about to be
+ */
+int list_view_visible(const ListView *lv);
+
+/**
+ * @return the selected entry, -1 when none is listed
+ */
+int list_view_selected(const ListView *lv);
+
+/**
+ * Moves the selection by delta rows.
+ */
+void list_view_move(ListView *lv, int delta);
+
+/**
+ * Handles a key while the list is pinned: escape unpins it, up/down move the selection, backspace erases from the search.
+ *
+ * @return the entry chosen by enter, -1 otherwise
+ */
+int list_view_key(ListView *lv, SDL_Keycode key);
+
+/**
+ * Appends text typed while the list is pinned to the search.
+ */
+void list_view_text(ListView *lv, const char *text);
+
+/**
+ * Keeps the mouse position, in window coordinates, -1 when the mouse left the window.
+ */
+void list_view_hover(ListView *lv, int x, int y);
+
+/**
+ * Handles a left click at window coordinates: a click on an entry chooses it, a click on a column button sorts by it.
+ *
+ * @param chosen set to the entry clicked, -1 when none was
+ * @return whether the click hit the list
+ */
+int list_view_click(ListView *lv, int x, int y, int *chosen);
+
+/**
+ * Sets the size of the window the list is drawn into.
+ */
+void list_view_set_size(ListView *lv, int width, int height);
+
+/**
+ * Marks the entry as playing, -1 for none.
+ */
+void list_view_set_playing(ListView *lv, int entry);
+
+/**
+ * Brings the list up to date for the next frame, once the pending input is handled.
+ *
+ * @return whether the window needs to be drawn again
+ */
+int list_view_update(ListView *lv);
+
+/**
+ * Draws the list onto the window, between its other content and the present.
+ */
+void list_view_draw(ListView *lv);
+
+#endif /* FFTOOLS_FFPLAY_LIST_H */