PR #24589 opened by michaelni
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24589
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/24589.patch

avformat/concatdec: add an option creating a chapter per file
ffplay: highlight the chapter list entry and button under the mouse
ffplay: factor the chapter list hit test out of the click handler
ffplay: filter the chapter list by typing while it is pinned
ffplay: sort the chapter list by clickable column buttons
ffplay: seek to a chapter list entry by clicking it
ffplay: show a list of the chapters on screen
ffplay: make seek_chapter take the chapter index
ffplay: factor the current chapter lookup out of seek_chapter
ffplay: seek to the previous chapter from the last one as well
avfilter/vf_subtitles: replace the ass filter's script at runtime
avfilter/vf_subtitles: let the ass filter take its script from an option



>From 3d2bc3dd03b5aaf0b01b98ca2d5bdbf66ad56a14 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:04:14 +0200
Subject: [PATCH 01/12] avfilter/vf_subtitles: let the ass filter take its
 script from an option

The new "script" option holds the ASS script inline, as an alternative
to reading it from "filename", so an application can render text it
generated itself without writing a temporary file first.

Assisted-by: Claude
---
 doc/filters.texi           |  6 +++++-
 libavfilter/vf_subtitles.c | 27 +++++++++++++++++----------
 2 files changed, 22 insertions(+), 11 deletions(-)

diff --git a/doc/filters.texi b/doc/filters.texi
index 0407c46917..7257668544 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -8629,9 +8629,13 @@ Substation Alpha) subtitles files.
 
 This filter accepts @option{filename}/@option{f}, @option{original_size},
 @option{fontsdir}, and @option{alpha} from the @ref{subtitles} filter, plus the
-following option:
+following options:
 
 @table @option
+@item script
+The ASS script to render, given inline. Exactly one of @option{filename} and
+@option{script} must be set.
+
 @item shaping
 Set the shaping engine.
 
diff --git a/libavfilter/vf_subtitles.c b/libavfilter/vf_subtitles.c
index 72c621a807..be4118ab53 100644
--- a/libavfilter/vf_subtitles.c
+++ b/libavfilter/vf_subtitles.c
@@ -53,6 +53,7 @@ typedef struct AssContext {
     ASS_Renderer *renderer;
     ASS_Track    *track;
     char *filename;
+    char *script;
     char *fontsdir;
     char *charenc;
     char *force_style;
@@ -142,11 +143,6 @@ static av_cold int init(AVFilterContext *ctx)
 {
     AssContext *ass = ctx->priv;
 
-    if (!ass->filename) {
-        av_log(ctx, AV_LOG_ERROR, "No filename provided!\n");
-        return AVERROR(EINVAL);
-    }
-
     ass->library = ass_library_init();
     if (!ass->library) {
         av_log(ctx, AV_LOG_ERROR, "Could not initialize libass.\n");
@@ -269,6 +265,7 @@ static const AVFilterPad ass_inputs[] = {
 static const AVOption ass_options[] = {
     COMMON_OPTIONS
     SHAPING_OPTIONS
+    {"script", "set the ASS script to render instead of reading a file", 
OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS },
     {NULL},
 };
 
@@ -277,19 +274,24 @@ AVFILTER_DEFINE_CLASS(ass);
 static av_cold int init_ass(AVFilterContext *ctx)
 {
     AssContext *ass = ctx->priv;
-    int ret = init(ctx);
+    int ret;
 
+    if (!ass->filename == !ass->script) {
+        av_log(ctx, AV_LOG_ERROR, "Exactly one of filename and script must be 
set\n");
+        return AVERROR(EINVAL);
+    }
+    ret = init(ctx);
     if (ret < 0)
         return ret;
 
     /* Initialize fonts */
     ass_set_fonts(ass->renderer, NULL, NULL, 1, NULL, 1);
 
-    ass->track = ass_read_file(ass->library, ass->filename, NULL);
+    ass->track = ass->script ? ass_read_memory(ass->library, ass->script, 
strlen(ass->script), NULL)
+                             : ass_read_file(ass->library, ass->filename, 
NULL);
     if (!ass->track) {
-        av_log(ctx, AV_LOG_ERROR,
-               "Could not create a libass track when reading file '%s'\n",
-               ass->filename);
+        av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n",
+               ass->filename ? ass->filename : "the script");
         return AVERROR(EINVAL);
     }
     return 0;
@@ -368,6 +370,11 @@ static av_cold int init_subtitles(AVFilterContext *ctx)
     AVPacket pkt;
     AssContext *ass = ctx->priv;
 
+    if (!ass->filename) {
+        av_log(ctx, AV_LOG_ERROR, "No filename provided!\n");
+        return AVERROR(EINVAL);
+    }
+
     /* Init libass */
     ret = init(ctx);
     if (ret < 0)
-- 
2.52.0


>From b8cbbc25ed6d024a909b6912502fc9e4e8587071 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:04:56 +0200
Subject: [PATCH 02/12] avfilter/vf_subtitles: replace the ass filter's script
 at runtime

The "script" option becomes a command, so an application can update the
rendered text while the graph keeps running instead of rebuilding it,
which would reload the fonts every time.

Assisted-by: Claude
---
 doc/filters.texi           |  5 +++++
 libavfilter/vf_subtitles.c | 39 +++++++++++++++++++++++++++++---------
 2 files changed, 35 insertions(+), 9 deletions(-)

diff --git a/doc/filters.texi b/doc/filters.texi
index 7257668544..1a9bbeff7e 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -8654,6 +8654,11 @@ and Thai. Requires libass to be built with HarfBuzz.
 The default is @code{auto}.
 @end table
 
+@subsection Commands
+
+This filter supports the @option{script} option as @ref{commands}; the new
+script replaces the one being rendered.
+
 @section atadenoise
 Apply an Adaptive Temporal Averaging Denoiser to the video input.
 
diff --git a/libavfilter/vf_subtitles.c b/libavfilter/vf_subtitles.c
index be4118ab53..f90ac40e22 100644
--- a/libavfilter/vf_subtitles.c
+++ b/libavfilter/vf_subtitles.c
@@ -265,12 +265,29 @@ static const AVFilterPad ass_inputs[] = {
 static const AVOption ass_options[] = {
     COMMON_OPTIONS
     SHAPING_OPTIONS
-    {"script", "set the ASS script to render instead of reading a file", 
OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS },
+    {"script", "set the ASS script to render instead of reading a file", 
OFFSET(script), AV_OPT_TYPE_STRING, {.str = NULL}, 0, 0, FLAGS | 
AV_OPT_FLAG_RUNTIME_PARAM },
     {NULL},
 };
 
 AVFILTER_DEFINE_CLASS(ass);
 
+static int read_track(AVFilterContext *ctx)
+{
+    AssContext *ass = ctx->priv;
+    ASS_Track *track = ass->script ? ass_read_memory(ass->library, 
ass->script, strlen(ass->script), NULL)
+                                   : ass_read_file(ass->library, 
ass->filename, NULL);
+
+    if (!track) {
+        av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n",
+               ass->filename ? ass->filename : "the script");
+        return AVERROR(EINVAL);
+    }
+    if (ass->track)
+        ass_free_track(ass->track);
+    ass->track = track;
+    return 0;
+}
+
 static av_cold int init_ass(AVFilterContext *ctx)
 {
     AssContext *ass = ctx->priv;
@@ -287,14 +304,17 @@ static av_cold int init_ass(AVFilterContext *ctx)
     /* Initialize fonts */
     ass_set_fonts(ass->renderer, NULL, NULL, 1, NULL, 1);
 
-    ass->track = ass->script ? ass_read_memory(ass->library, ass->script, 
strlen(ass->script), NULL)
-                             : ass_read_file(ass->library, ass->filename, 
NULL);
-    if (!ass->track) {
-        av_log(ctx, AV_LOG_ERROR, "Could not create a libass track from %s\n",
-               ass->filename ? ass->filename : "the script");
-        return AVERROR(EINVAL);
-    }
-    return 0;
+    return read_track(ctx);
+}
+
+static int process_command(AVFilterContext *ctx, const char *cmd, const char 
*arg,
+                           char *res, int res_len, int flags)
+{
+    int ret = ff_filter_process_command(ctx, cmd, arg, res, res_len, flags);
+
+    if (ret < 0 || (ret = read_track(ctx)) < 0)
+        return ret;
+    return config_input(ctx->inputs[0]);
 }
 
 const FFFilter ff_vf_ass = {
@@ -307,6 +327,7 @@ const FFFilter ff_vf_ass = {
     FILTER_INPUTS(ass_inputs),
     FILTER_OUTPUTS(ff_video_default_filterpad),
     FILTER_QUERY_FUNC2(query_formats),
+    .process_command = process_command,
 };
 #endif
 
-- 
2.52.0


>From d1ab8e96b9f9ff919395cecb2ba76c44e5af7406 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:08:21 +0200
Subject: [PATCH 03/12] ffplay: seek to the previous chapter from the last one
 as well

The chapter lookup left the index one past the end when the position
lay inside the last chapter, so page down restarted that chapter
instead of moving to the previous one like it does everywhere else.

Assisted-by: Claude
---
 fftools/ffplay.c | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index b9d9641582..3330f09cd6 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -3438,13 +3438,11 @@ static void seek_chapter(VideoState *is, int incr)
     /* find the current chapter */
     for (i = 0; i < is->ic->nb_chapters; i++) {
         AVChapter *ch = is->ic->chapters[i];
-        if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0) {
-            i--;
+        if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0)
             break;
-        }
     }
 
-    i += incr;
+    i += incr - 1;
     i = FFMAX(i, 0);
     if (i >= is->ic->nb_chapters)
         return;
-- 
2.52.0


>From 3413c6a141d2e870e230b6783150c4bff2261c4e Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:08:33 +0200
Subject: [PATCH 04/12] ffplay: factor the current chapter lookup out of
 seek_chapter

Assisted-by: Claude
---
 fftools/ffplay.c | 19 ++++++++++++-------
 1 file changed, 12 insertions(+), 7 deletions(-)

diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 3330f09cd6..c4692c83e4 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -3427,23 +3427,28 @@ static void refresh_loop_wait_event(VideoState *is, 
SDL_Event *event) {
     }
 }
 
-static void seek_chapter(VideoState *is, int incr)
+/* index of the chapter containing the current playback position, -1 before 
the first one */
+static int current_chapter(VideoState *is)
 {
     int64_t pos = get_master_clock(is) * AV_TIME_BASE;
     int i;
 
-    if (!is->ic->nb_chapters)
-        return;
-
-    /* find the current chapter */
     for (i = 0; i < is->ic->nb_chapters; i++) {
         AVChapter *ch = is->ic->chapters[i];
         if (av_compare_ts(pos, AV_TIME_BASE_Q, ch->start, ch->time_base) < 0)
             break;
     }
+    return i - 1;
+}
 
-    i += incr - 1;
-    i = FFMAX(i, 0);
+static void seek_chapter(VideoState *is, int incr)
+{
+    int i;
+
+    if (!is->ic->nb_chapters)
+        return;
+
+    i = FFMAX(current_chapter(is) + incr, 0);
     if (i >= is->ic->nb_chapters)
         return;
 
-- 
2.52.0


>From 651d6da6e258b132d2956df9e7c4564fd513c448 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:10:53 +0200
Subject: [PATCH 05/12] ffplay: make seek_chapter take the chapter index

The callers pass the index relative to the current chapter themselves,
so the function can serve seeks to an arbitrary chapter too.

Assisted-by: Claude
---
 fftools/ffplay.c | 13 ++++---------
 1 file changed, 4 insertions(+), 9 deletions(-)

diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index c4692c83e4..ae93c5b8d0 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -3441,14 +3441,9 @@ static int current_chapter(VideoState *is)
     return i - 1;
 }
 
-static void seek_chapter(VideoState *is, int incr)
+static void seek_chapter(VideoState *is, int i)
 {
-    int i;
-
-    if (!is->ic->nb_chapters)
-        return;
-
-    i = FFMAX(current_chapter(is) + incr, 0);
+    i = FFMAX(i, 0);
     if (i >= is->ic->nb_chapters)
         return;
 
@@ -3526,14 +3521,14 @@ static void event_loop(VideoState *cur_stream)
                     incr = 600.0;
                     goto do_seek;
                 }
-                seek_chapter(cur_stream, 1);
+                seek_chapter(cur_stream, current_chapter(cur_stream) + 1);
                 break;
             case SDLK_PAGEDOWN:
                 if (cur_stream->ic->nb_chapters <= 1) {
                     incr = -600.0;
                     goto do_seek;
                 }
-                seek_chapter(cur_stream, -1);
+                seek_chapter(cur_stream, current_chapter(cur_stream) - 1);
                 break;
             case SDLK_LEFT:
                 incr = seek_interval ? -seek_interval : -10.0;
-- 
2.52.0


>From c793be9289d701395d05b5fec6a8eea791338f4a Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:30:29 +0200
Subject: [PATCH 06/12] ffplay: show a list of the chapters on screen

Page up/down fade in a list of the chapters around the current one and
fade it out again half a second after the last key press, "l" keeps it
on screen, and while it is kept down/up move the selection. Enter seeks
to the selected chapter and the mouse wheel moves the selection whenever
the list is visible. Each entry shows the chapter's artist and title
tags in their own columns, clipped to the column, and its length.

The ass filter renders the list onto a transparent canvas the size of
the panel, only when its content changes, and SDL composites the result
as a texture whose alpha modulation does the fading. The video path is
untouched and nothing is drawn while the list is hidden. The list is not
available with the Vulkan renderer, which bypasses SDL rendering.

Assisted-by: Claude
---
 configure        |   2 +-
 doc/ffplay.texi  |  11 +++
 fftools/ffplay.c | 246 +++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 258 insertions(+), 1 deletion(-)

diff --git a/configure b/configure
index 035d4d090a..415d8b69d2 100755
--- a/configure
+++ b/configure
@@ -4367,7 +4367,7 @@ ffmpeg_select="aformat_filter anull_filter atrim_filter 
crop_filter
 ffmpeg_suggest="ole32 psapi shell32"
 ffplay_deps="avcodec avformat avfilter swscale swresample sdl2"
 ffplay_select="crop_filter transpose_filter hflip_filter vflip_filter 
rotate_filter"
-ffplay_suggest="shell32 libplacebo vulkan"
+ffplay_suggest="shell32 libplacebo vulkan ass_filter"
 ffprobe_deps="avcodec avformat"
 ffprobe_suggest="shell32"
 
diff --git a/doc/ffplay.texi b/doc/ffplay.texi
index 5273c158bd..008633d1a1 100644
--- a/doc/ffplay.texi
+++ b/doc/ffplay.texi
@@ -266,6 +266,17 @@ Seek backward/forward 1 minute.
 
 @item page down/page up
 Seek to previous/next chapter or backward/forward 10 minutes if no chapters.
+The chapter list is shown briefly.
+
+@item l
+Keep the chapter list on screen, or let it fade out again. While it is kept,
+down/up move its selection.
+
+@item enter
+Seek to the chapter selected in the chapter list while it is shown.
+
+@item mouse wheel
+Move the chapter list selection while it is shown.
 
 @item right mouse click
 Seek to percentage in file corresponding to fraction of width.
diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index ae93c5b8d0..298be3e251 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -105,6 +105,10 @@ 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        12
+
 #define USE_ONEPASS_SUBTITLE_RENDER 1
 
 typedef struct MyAVPacketList {
@@ -273,6 +277,15 @@ typedef struct VideoState {
     SDL_Texture *vis_texture;
     SDL_Texture *sub_texture;
     SDL_Texture *vid_texture;
+    SDL_Texture *chapter_texture;
+    SDL_Rect chapter_rect;
+    AVFilterGraph *chapter_graph;
+    AVFilterContext *chapter_sink;
+    int chapter_selected;
+    int chapter_pinned;
+    int64_t chapter_fade_start;
+    int64_t chapter_last_input;
+    double chapter_drawn_alpha;
 
     int subtitle_stream;
     AVStream *subtitle_st;
@@ -1007,6 +1020,200 @@ static void draw_video_background(VideoState *is)
     }
 }
 
+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_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 : "";
+}
+
+#define CHAPTER_LIST_EVENT "Dialogue: 0:00:00.00,9999:00:00.00,"
+
+/* a text cell clipped to 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(%d,%d,%d,%d)}", style, x, y, 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, nb_rows, first;
+    int artist_x, title_x, length_x;
+} ChapterListLayout;
+
+static ChapterListLayout chapter_list_layout(VideoState *is)
+{
+    ChapterListLayout l;
+
+    l.font     = FFMAX(is->height / 30, 12);
+    l.line     = l.font * 3 / 2;
+    l.width    = is->width * 3 / 5;
+    l.nb_rows  = FFMIN(is->ic->nb_chapters, CHAPTER_LIST_ROWS);
+    l.first    = av_clip(is->chapter_selected - l.nb_rows / 2, 0, 
is->ic->nb_chapters - l.nb_rows);
+    l.artist_x = l.font * 3;
+    l.length_x = l.width - l.font / 2;
+    l.title_x  = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5;
+    return l;
+}
+
+static void chapter_list_script(VideoState *is, AVBPrint *script)
+{
+    ChapterListLayout l = chapter_list_layout(is);
+
+    is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, l.nb_rows * l.line 
+ l.font };
+    av_bprintf(script,
+               "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: 
%d\nWrapStyle: 2\n\n"
+               "[V4+ Styles]\n"
+               "Format: Name, Fontname, Fontsize, PrimaryColour, 
OutlineColour, Bold, Outline, Alignment\n"
+               "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\n"
+               "Style: Row,Sans,%d,&H00FFFFFF,&H00000000,0,%d,7\n"
+               "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n\n"
+               "[Events]\nFormat: Start, End, Style, Text\n"
+               CHAPTER_LIST_EVENT "Panel,{\\pos(0,0)\\p1}m 0 0 l %d 0 %d %d 0 
%d{\\p0}\n",
+               is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font 
/ 16 + 1, l.font, l.font / 16 + 1,
+               is->chapter_rect.w, is->chapter_rect.w, is->chapter_rect.h, 
is->chapter_rect.h);
+    for (int i = l.first; i < l.first + l.nb_rows; i++) {
+        AVChapter *chapter = is->ic->chapters[i];
+        const char *style = i == is->chapter_selected ? "Selected" : "Row";
+        int64_t length = av_rescale_q(chapter->end - chapter->start, 
chapter->time_base, av_make_q(1, 1));
+        int y = l.font / 2 + (i - l.first) * l.line;
+
+        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", 
style, l.font * 5 / 2, y, i + 1);
+        bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 
2, y + l.line, chapter_tag(chapter, "artist"));
+        bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 
4, y + l.line, chapter_tag(chapter, "title"));
+        if (chapter->end == AV_NOPTS_VALUE || length < 0)
+            continue;
+        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}", style, 
l.length_x, y);
+        if (length >= 3600)
+            av_bprintf(script, "%d:%02d:%02d\n", (int)(length / 3600), 
(int)(length / 60 % 60), (int)(length % 60));
+        else
+            av_bprintf(script, "%d:%02d\n", (int)(length / 60), (int)(length % 
60));
+    }
+}
+
+static int chapter_list_configure(VideoState *is, const char *script)
+{
+    const AVFilter *ass_filter = avfilter_get_by_name("ass");
+    AVFilterContext *source, *ass;
+    char source_args[64];
+    int ret;
+
+    if (!ass_filter) {
+        av_log(NULL, AV_LOG_ERROR, "The chapter list needs the ass filter, 
which this build lacks\n");
+        return AVERROR_FILTER_NOT_FOUND;
+    }
+    avfilter_graph_free(&is->chapter_graph);
+    if (!(is->chapter_graph = avfilter_graph_alloc()))
+        return AVERROR(ENOMEM);
+    snprintf(source_args, sizeof(source_args), 
"color=black@0:rate=1:size=%dx%d", is->chapter_rect.w, is->chapter_rect.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)
+        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, 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);
+}
+
+static int chapter_list_render(VideoState *is)
+{
+    AVBPrint script;
+    AVFrame *frame = av_frame_alloc();
+    void *pixels;
+    int pitch, ret;
+
+    if (!frame)
+        return AVERROR(ENOMEM);
+    av_bprint_init(&script, 0, AV_BPRINT_SIZE_UNLIMITED);
+    chapter_list_script(is, &script);
+    if (!av_bprint_is_complete(&script))
+        ret = AVERROR(ENOMEM);
+    else if (is->chapter_graph)
+        ret = avfilter_graph_send_command(is->chapter_graph, "ass", "script", 
script.str, NULL, 0, 0);
+    else
+        ret = chapter_list_configure(is, script.str);
+    if (ret >= 0)
+        ret = av_buffersink_get_frame(is->chapter_sink, frame);
+    if (ret >= 0 && (realloc_texture(&is->chapter_texture, 
SDL_PIXELFORMAT_ARGB8888, frame->width, frame->height, SDL_BLENDMODE_BLEND, 0) 
< 0 ||
+                     SDL_LockTexture(is->chapter_texture, NULL, &pixels, 
&pitch) < 0))
+        ret = AVERROR_EXTERNAL;
+    if (ret >= 0) {
+        /* the ass filter composites onto the transparent canvas with 
premultiplied alpha, SDL blends straight alpha */
+        for (int y = 0; y < frame->height; y++) {
+            const uint8_t *src = frame->data[0] + y * frame->linesize[0];
+            uint8_t *dst = (uint8_t *)pixels + y * pitch;
+
+            for (int x = 0; x < frame->width * 4; x += 4) {
+                for (int c = 0; c < 3; c++)
+                    dst[x + c] = src[x + 3] ? FFMIN(src[x + c] * 255 / src[x + 
3], 255) : 0;
+                dst[x + 3] = src[x + 3];
+            }
+        }
+        SDL_UnlockTexture(is->chapter_texture);
+    }
+    if (ret < 0) {
+        av_log(NULL, AV_LOG_ERROR, "Could not render the chapter list: %s\n", 
av_err2str(ret));
+        avfilter_graph_free(&is->chapter_graph);
+    }
+    av_frame_free(&frame);
+    av_bprint_finalize(&script, NULL);
+    return ret;
+}
+
+static void chapter_list_show(VideoState *is, int selected)
+{
+    int64_t now = av_gettime_relative();
+
+    if (!renderer)
+        return;
+    is->chapter_fade_start = now - chapter_list_alpha(is, now) * 
CHAPTER_LIST_FADE_TIME;
+    is->chapter_last_input = now;
+    if (selected != is->chapter_selected || !is->chapter_texture) {
+        is->chapter_selected = selected;
+        chapter_list_render(is);
+    }
+}
+
+static void chapter_list_move(VideoState *is, int delta)
+{
+    chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, 
is->ic->nb_chapters - 1));
+}
+
+static int chapter_list_visible(VideoState *is)
+{
+    return chapter_list_alpha(is, av_gettime_relative()) > 0;
+}
+
+static void chapter_list_draw(VideoState *is)
+{
+    double alpha = chapter_list_alpha(is, av_gettime_relative());
+
+    is->chapter_drawn_alpha = alpha;
+    if (alpha > 0 && is->chapter_texture) {
+        SDL_SetTextureAlphaMod(is->chapter_texture, alpha * 255);
+        SDL_RenderCopy(renderer, is->chapter_texture, NULL, &is->chapter_rect);
+    }
+}
+
 static void video_image_display(VideoState *is)
 {
     Frame *vp;
@@ -1345,6 +1552,9 @@ static void stream_close(VideoState *is)
         SDL_DestroyTexture(is->vid_texture);
     if (is->sub_texture)
         SDL_DestroyTexture(is->sub_texture);
+    if (is->chapter_texture)
+        SDL_DestroyTexture(is->chapter_texture);
+    avfilter_graph_free(&is->chapter_graph);
     av_free(is);
 }
 
@@ -1429,6 +1639,7 @@ static void video_display(VideoState *is)
         video_audio_display(is);
     else if (is->video_st)
         video_image_display(is);
+    chapter_list_draw(is);
     SDL_RenderPresent(renderer);
 }
 
@@ -3421,6 +3632,8 @@ static void refresh_loop_wait_event(VideoState *is, 
SDL_Event *event) {
         if (remaining_time > 0.0)
             av_usleep((int64_t)(remaining_time * 1000000.0));
         remaining_time = REFRESH_RATE;
+        if (chapter_list_alpha(is, av_gettime_relative()) != 
is->chapter_drawn_alpha)
+            is->force_refresh = 1;
         if (is->show_mode != SHOW_MODE_NONE && (!is->paused || 
is->force_refresh))
             video_refresh(is, &remaining_time);
         SDL_PumpEvents();
@@ -3447,6 +3660,7 @@ static void seek_chapter(VideoState *is, int i)
     if (i >= is->ic->nb_chapters)
         return;
 
+    chapter_list_show(is, i);
     av_log(NULL, AV_LOG_VERBOSE, "Seeking to chapter %d.\n", i);
     stream_seek(is, av_rescale_q(is->ic->chapters[i]->start, 
is->ic->chapters[i]->time_base,
                                  AV_TIME_BASE_Q), 0, 0);
@@ -3507,6 +3721,20 @@ static void event_loop(VideoState *cur_stream)
             case SDLK_t:
                 stream_cycle_channel(cur_stream, AVMEDIA_TYPE_SUBTITLE);
                 break;
+            case SDLK_l:
+                if (!cur_stream->ic->nb_chapters)
+                    break;
+                cur_stream->chapter_pinned = !cur_stream->chapter_pinned;
+                if (cur_stream->chapter_pinned)
+                    chapter_list_show(cur_stream, 
FFMAX(current_chapter(cur_stream), 0));
+                else
+                    cur_stream->chapter_last_input = av_gettime_relative() - 
CHAPTER_LIST_HOLD_TIME;
+                break;
+            case SDLK_RETURN:
+            case SDLK_KP_ENTER:
+                if (chapter_list_visible(cur_stream))
+                    seek_chapter(cur_stream, cur_stream->chapter_selected);
+                break;
             case SDLK_w:
                 if (cur_stream->show_mode == SHOW_MODE_VIDEO && 
cur_stream->vfilter_idx < nb_vfilters - 1) {
                     if (++cur_stream->vfilter_idx >= nb_vfilters)
@@ -3537,9 +3765,17 @@ static void event_loop(VideoState *cur_stream)
                 incr = seek_interval ? seek_interval : 10.0;
                 goto do_seek;
             case SDLK_UP:
+                if (cur_stream->chapter_pinned) {
+                    chapter_list_move(cur_stream, -1);
+                    break;
+                }
                 incr = 60.0;
                 goto do_seek;
             case SDLK_DOWN:
+                if (cur_stream->chapter_pinned) {
+                    chapter_list_move(cur_stream, 1);
+                    break;
+                }
                 incr = -60.0;
             do_seek:
                     if (seek_by_bytes) {
@@ -3570,6 +3806,10 @@ static void event_loop(VideoState *cur_stream)
                 break;
             }
             break;
+        case SDL_MOUSEWHEEL:
+            if (chapter_list_visible(cur_stream))
+                chapter_list_move(cur_stream, -event.wheel.y);
+            break;
         case SDL_MOUSEBUTTONDOWN:
             if (exit_on_mousedown) {
                 do_exit(cur_stream);
@@ -3635,6 +3875,9 @@ static void event_loop(VideoState *cur_stream)
                         SDL_DestroyTexture(cur_stream->vis_texture);
                         cur_stream->vis_texture = NULL;
                     }
+                    avfilter_graph_free(&cur_stream->chapter_graph);
+                    if (cur_stream->chapter_texture)
+                        chapter_list_render(cur_stream);
                     if (vk_renderer)
                         vk_renderer_resize(vk_renderer, screen_width, 
screen_height);
                     av_fallthrough;
@@ -3849,6 +4092,9 @@ void show_help_default(const char *opt, const char *arg)
            "left/right          seek backward/forward by 10 seconds or a 
custom interval if -seek_interval is set\n"
            "down/up             seek backward/forward 1 minute\n"
            "page down/page up   seek to previous/next chapter or 
backward/forward 10 minutes if no chapters\n"
+           "l                   keep the chapter list on screen, down/up then 
move its selection\n"
+           "enter               seek to the chapter selected in the chapter 
list while it is shown\n"
+           "mouse wheel         move the chapter list selection while it is 
shown\n"
            "right mouse click   seek to percentage in file corresponding to 
fraction of width\n"
            "left double-click   toggle full screen\n"
            );
-- 
2.52.0


>From 8eb07c0311c1b8dd8c8fcd5e53e36252a2444fad Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 21:01:47 +0200
Subject: [PATCH 07/12] ffplay: seek to a chapter list entry by clicking it

Assisted-by: Claude
---
 doc/ffplay.texi  |  3 +++
 fftools/ffplay.c | 22 ++++++++++++++++++++++
 2 files changed, 25 insertions(+)

diff --git a/doc/ffplay.texi b/doc/ffplay.texi
index 008633d1a1..8360f5b995 100644
--- a/doc/ffplay.texi
+++ b/doc/ffplay.texi
@@ -278,6 +278,9 @@ Seek to the chapter selected in the chapter list while it 
is shown.
 @item mouse wheel
 Move the chapter list selection while it is shown.
 
+@item left mouse click
+Seek to the clicked chapter list entry.
+
 @item right mouse click
 Seek to percentage in file corresponding to fraction of width.
 
diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 298be3e251..43d43b87eb 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -1203,6 +1203,24 @@ static int chapter_list_visible(VideoState *is)
     return chapter_list_alpha(is, av_gettime_relative()) > 0;
 }
 
+static void seek_chapter(VideoState *is, int i);
+
+/* seeks to the entry under a click, 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);
+    int row;
+
+    x -= is->chapter_rect.x;
+    y -= is->chapter_rect.y;
+    if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h)
+        return 0;
+    row = (y - l.font / 2) / l.line;
+    if (row < l.nb_rows)
+        seek_chapter(is, l.first + row);
+    return 1;
+}
+
 static void chapter_list_draw(VideoState *is)
 {
     double alpha = chapter_list_alpha(is, av_gettime_relative());
@@ -3815,6 +3833,9 @@ static void event_loop(VideoState *cur_stream)
                 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))
+                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) {
@@ -4095,6 +4116,7 @@ void show_help_default(const char *opt, const char *arg)
            "l                   keep the chapter list on screen, down/up then 
move its selection\n"
            "enter               seek to the chapter selected in the chapter 
list while it is shown\n"
            "mouse wheel         move the chapter list selection while it is 
shown\n"
+           "left click          seek to the clicked chapter list entry\n"
            "right mouse click   seek to percentage in file corresponding to 
fraction of width\n"
            "left double-click   toggle full screen\n"
            );
-- 
2.52.0


>From 4e0255282dc1863287552763bebcae44e5e97b72 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 21:05:12 +0200
Subject: [PATCH 08/12] ffplay: sort the chapter list by clickable column
 buttons

Buttons above the list order it by chapter number, artist, title or
duration. Clicking a column makes it the first sort key ahead of the
ones clicked before, which still resolve ties, so # then Title orders
by title with equal titles in chapter order. Clicking the first key
again flips its order, shown by an arrow on its button.

The rows become an array in display order, built when the input opens,
so the selection can survive a re-sort, and the length of each chapter
is kept with its row for the display and the sort to share.

Assisted-by: Claude
---
 doc/ffplay.texi  |   4 +-
 fftools/ffplay.c | 195 ++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 171 insertions(+), 28 deletions(-)

diff --git a/doc/ffplay.texi b/doc/ffplay.texi
index 8360f5b995..070c3ae42f 100644
--- a/doc/ffplay.texi
+++ b/doc/ffplay.texi
@@ -279,7 +279,9 @@ Seek to the chapter selected in the chapter list while it 
is shown.
 Move the chapter list selection while it is shown.
 
 @item left mouse click
-Seek to the clicked chapter list entry.
+Seek to the clicked chapter list entry, or sort the list by the clicked column
+button. Clicking the same button again flips the order, and ties are resolved
+by the columns clicked before.
 
 @item right mouse click
 Seek to percentage in file corresponding to fraction of width.
diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 43d43b87eb..4c693c12df 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -109,6 +109,26 @@ const int program_birth_year = 2003;
 #define CHAPTER_LIST_HOLD_TIME   500000
 #define CHAPTER_LIST_ROWS        12
 
+enum {
+    CHAPTER_SORT_NUMBER,
+    CHAPTER_SORT_ARTIST,
+    CHAPTER_SORT_TITLE,
+    CHAPTER_SORT_LENGTH,
+    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;
+
 #define USE_ONEPASS_SUBTITLE_RENDER 1
 
 typedef struct MyAVPacketList {
@@ -281,6 +301,8 @@ typedef struct VideoState {
     SDL_Rect chapter_rect;
     AVFilterGraph *chapter_graph;
     AVFilterContext *chapter_sink;
+    ChapterRow *chapter_rows;
+    int nb_chapter_rows;
     int chapter_selected;
     int chapter_pinned;
     int64_t chapter_fade_start;
@@ -352,6 +374,8 @@ 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 } };
 static const char *audio_codec_name;
 static const char *subtitle_codec_name;
 static const char *video_codec_name;
@@ -1036,8 +1060,106 @@ static const char *chapter_tag(const AVChapter 
*chapter, const char *key)
     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);
+}
+
+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;
+        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 */
+static void chapter_sort_by(int key)
+{
+    int i = 0;
+
+    while (chapter_sort[i].key != key)
+        i++;
+    if (i == 0) {
+        chapter_sort[0].descending ^= 1;
+        return;
+    }
+    memmove(&chapter_sort[1], &chapter_sort[0], i * sizeof(*chapter_sort));
+    chapter_sort[0] = (ChapterSortKey){ key, 0 };
+}
+
+/* rebuilds the rows 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;
+
+    if (!is->chapter_rows && !(is->chapter_rows = 
av_calloc(is->ic->nb_chapters, sizeof(*is->chapter_rows))))
+        return AVERROR(ENOMEM);
+    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)
+            row.length = av_rescale_q(chapter->end - chapter->start, 
chapter->time_base, av_make_q(1, 1));
+        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;
+}
+
+/* the row showing the chapter, or the selected row if none does */
+static int chapter_row(VideoState *is, int chapter)
+{
+    for (int row = 0; row < is->nb_chapter_rows; row++)
+        if (is->chapter_rows[row].index == chapter)
+            return row;
+    return is->chapter_selected;
+}
+
+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 },
+};
+
+/* button widths are in half font sizes, a quarter apart */
+static int sort_button_x(int font, int button)
+{
+    int x = font / 2;
+
+    for (int i = 0; i < button; i++)
+        x += sort_buttons[i].width * font / 2 + font / 4;
+    return x;
+}
+
 #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 clipped to 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)
 {
@@ -1059,8 +1181,8 @@ static ChapterListLayout chapter_list_layout(VideoState 
*is)
     l.font     = FFMAX(is->height / 30, 12);
     l.line     = l.font * 3 / 2;
     l.width    = is->width * 3 / 5;
-    l.nb_rows  = FFMIN(is->ic->nb_chapters, CHAPTER_LIST_ROWS);
-    l.first    = av_clip(is->chapter_selected - l.nb_rows / 2, 0, 
is->ic->nb_chapters - l.nb_rows);
+    l.nb_rows  = FFMIN(is->nb_chapter_rows, CHAPTER_LIST_ROWS);
+    l.first    = av_clip(is->chapter_selected - l.nb_rows / 2, 0, 
is->nb_chapter_rows - l.nb_rows);
     l.artist_x = l.font * 3;
     l.length_x = l.width - l.font / 2;
     l.title_x  = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5;
@@ -1071,34 +1193,41 @@ static void chapter_list_script(VideoState *is, 
AVBPrint *script)
 {
     ChapterListLayout l = chapter_list_layout(is);
 
-    is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, l.nb_rows * l.line 
+ l.font };
+    is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (l.nb_rows + 1) * 
l.line + l.font };
     av_bprintf(script,
                "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: 
%d\nWrapStyle: 2\n\n"
                "[V4+ Styles]\n"
                "Format: Name, Fontname, Fontsize, PrimaryColour, 
OutlineColour, Bold, Outline, Alignment\n"
                "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\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\n"
-               "[Events]\nFormat: Start, End, Style, Text\n"
-               CHAPTER_LIST_EVENT "Panel,{\\pos(0,0)\\p1}m 0 0 l %d 0 %d %d 0 
%d{\\p0}\n",
-               is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font 
/ 16 + 1, l.font, l.font / 16 + 1,
-               is->chapter_rect.w, is->chapter_rect.w, is->chapter_rect.h, 
is->chapter_rect.h);
-    for (int i = l.first; i < l.first + l.nb_rows; i++) {
-        AVChapter *chapter = is->ic->chapters[i];
-        const char *style = i == is->chapter_selected ? "Selected" : "Row";
-        int64_t length = av_rescale_q(chapter->end - chapter->start, 
chapter->time_base, av_make_q(1, 1));
-        int y = l.font / 2 + (i - l.first) * l.line;
+               "[Events]\nFormat: Start, End, Style, Text\n",
+               is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, 
l.font / 16 + 1, l.font, l.font / 16 + 1);
+    bprint_chapter_box(script, "Panel", 0, 0, is->chapter_rect.w, 
is->chapter_rect.h);
+    for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) {
+        int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 
2;
 
-        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", 
style, l.font * 5 / 2, y, i + 1);
-        bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 
2, y + l.line, chapter_tag(chapter, "artist"));
-        bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 
4, y + l.line, chapter_tag(chapter, "title"));
-        if (chapter->end == AV_NOPTS_VALUE || length < 0)
+        bprint_chapter_box(script, "Button", x, l.font / 2, w, l.font * 5 / 4);
+        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an5\\pos(%d,%d)}%s%s\n", 
chapter_sort[0].key == b ? "Selected" : "Row",
+                   x + w / 2, l.font * 9 / 8, sort_buttons[b].label,
+                   chapter_sort[0].key != b ? "" : chapter_sort[0].descending 
? " \xe2\x96\xbc" : " \xe2\x96\xb2");
+    }
+    for (int r = l.first; r < l.first + l.nb_rows; r++) {
+        const ChapterRow *row = &is->chapter_rows[r];
+        const char *style = r == is->chapter_selected ? "Selected" : "Row";
+        int y = l.font / 2 + (r - l.first + 1) * l.line;
+
+        av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", 
style, l.font * 5 / 2, y, row->index + 1);
+        bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 
2, y + l.line, row->artist);
+        bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 
4, y + l.line, row->title);
+        if (row->length < 0)
             continue;
         av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}", style, 
l.length_x, y);
-        if (length >= 3600)
-            av_bprintf(script, "%d:%02d:%02d\n", (int)(length / 3600), 
(int)(length / 60 % 60), (int)(length % 60));
+        if (row->length >= 3600)
+            av_bprintf(script, "%d:%02d:%02d\n", (int)(row->length / 3600), 
(int)(row->length / 60 % 60), (int)(row->length % 60));
         else
-            av_bprintf(script, "%d:%02d\n", (int)(length / 60), (int)(length % 
60));
+            av_bprintf(script, "%d:%02d\n", (int)(row->length / 60), 
(int)(row->length % 60));
     }
 }
 
@@ -1195,7 +1324,7 @@ static void chapter_list_show(VideoState *is, int 
selected)
 
 static void chapter_list_move(VideoState *is, int delta)
 {
-    chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, 
is->ic->nb_chapters - 1));
+    chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, 
is->nb_chapter_rows - 1));
 }
 
 static int chapter_list_visible(VideoState *is)
@@ -1215,9 +1344,17 @@ static int chapter_list_click(VideoState *is, int x, int 
y)
     y -= is->chapter_rect.y;
     if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h)
         return 0;
-    row = (y - l.font / 2) / l.line;
-    if (row < l.nb_rows)
-        seek_chapter(is, l.first + row);
+    row = (y - l.font / 2) / l.line - 1;
+    if (row >= 0 && row < l.nb_rows)
+        seek_chapter(is, is->chapter_rows[l.first + row].index);
+    for (int b = 0; row < 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++) {
+        if (x < sort_button_x(l.font, b) || x >= sort_button_x(l.font, b + 1))
+            continue;
+        chapter_sort_by(b);
+        chapter_list_update_rows(is);
+        chapter_list_show(is, is->chapter_selected);
+        chapter_list_render(is);
+    }
     return 1;
 }
 
@@ -1573,6 +1710,7 @@ static void stream_close(VideoState *is)
     if (is->chapter_texture)
         SDL_DestroyTexture(is->chapter_texture);
     avfilter_graph_free(&is->chapter_graph);
+    av_freep(&is->chapter_rows);
     av_free(is);
 }
 
@@ -3200,6 +3338,9 @@ static int read_thread(void *arg)
     if (ic->pb)
         ic->pb->eof_reached = 0; // FIXME hack, ffplay maybe should not use 
avio_feof() to test for the end
 
+    if (ic->nb_chapters && (ret = chapter_list_update_rows(is)) < 0)
+        goto fail;
+
     if (seek_by_bytes < 0)
         seek_by_bytes = !(ic->iformat->flags & AVFMT_NO_BYTE_SEEK) &&
                         !!(ic->iformat->flags & AVFMT_TS_DISCONT) &&
@@ -3678,7 +3819,7 @@ static void seek_chapter(VideoState *is, int i)
     if (i >= is->ic->nb_chapters)
         return;
 
-    chapter_list_show(is, i);
+    chapter_list_show(is, chapter_row(is, i));
     av_log(NULL, AV_LOG_VERBOSE, "Seeking to chapter %d.\n", i);
     stream_seek(is, av_rescale_q(is->ic->chapters[i]->start, 
is->ic->chapters[i]->time_base,
                                  AV_TIME_BASE_Q), 0, 0);
@@ -3744,14 +3885,14 @@ static void event_loop(VideoState *cur_stream)
                     break;
                 cur_stream->chapter_pinned = !cur_stream->chapter_pinned;
                 if (cur_stream->chapter_pinned)
-                    chapter_list_show(cur_stream, 
FFMAX(current_chapter(cur_stream), 0));
+                    chapter_list_show(cur_stream, chapter_row(cur_stream, 
current_chapter(cur_stream)));
                 else
                     cur_stream->chapter_last_input = av_gettime_relative() - 
CHAPTER_LIST_HOLD_TIME;
                 break;
             case SDLK_RETURN:
             case SDLK_KP_ENTER:
                 if (chapter_list_visible(cur_stream))
-                    seek_chapter(cur_stream, cur_stream->chapter_selected);
+                    seek_chapter(cur_stream, 
cur_stream->chapter_rows[cur_stream->chapter_selected].index);
                 break;
             case SDLK_w:
                 if (cur_stream->show_mode == SHOW_MODE_VIDEO && 
cur_stream->vfilter_idx < nb_vfilters - 1) {
@@ -4116,7 +4257,7 @@ void show_help_default(const char *opt, const char *arg)
            "l                   keep the chapter list on screen, down/up then 
move its selection\n"
            "enter               seek to the chapter selected in the chapter 
list while it is shown\n"
            "mouse wheel         move the chapter list selection while it is 
shown\n"
-           "left click          seek to the clicked chapter list entry\n"
+           "left click          seek to the clicked chapter list entry, or 
sort the list by the clicked column, again to flip it\n"
            "right mouse click   seek to percentage in file corresponding to 
fraction of width\n"
            "left double-click   toggle full screen\n"
            );
-- 
2.52.0


>From 93b4b2917983eb976a9b9644735ca414def949d3 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 21:10:39 +0200
Subject: [PATCH 09/12] ffplay: filter the chapter list by typing while it is
 pinned

While the list is pinned it owns the keyboard: typed text narrows the
entries to those whose artist or title contains it, backspace erases,
escape closes the list. The list is pinned on the release of its key so
that the key's own text input does not start the search, and the
canvas keeps its full size so a changing row count does not alter the
script resolution libass scales to.

Assisted-by: Claude
---
 doc/ffplay.texi  |   4 +-
 fftools/ffplay.c | 114 +++++++++++++++++++++++++++++++++++------------
 2 files changed, 87 insertions(+), 31 deletions(-)

diff --git a/doc/ffplay.texi b/doc/ffplay.texi
index 070c3ae42f..e6b4b357ca 100644
--- a/doc/ffplay.texi
+++ b/doc/ffplay.texi
@@ -269,8 +269,8 @@ Seek to previous/next chapter or backward/forward 10 
minutes if no chapters.
 The chapter list is shown briefly.
 
 @item l
-Keep the chapter list on screen, or let it fade out again. While it is kept,
-down/up move its selection.
+Keep the chapter list on screen. While it is kept, typing filters the list by
+artist and title, down/up move its selection and escape closes it.
 
 @item enter
 Seek to the chapter selected in the chapter list while it is shown.
diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 4c693c12df..9c86647a4e 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -303,6 +303,7 @@ typedef struct VideoState {
     AVFilterContext *chapter_sink;
     ChapterRow *chapter_rows;
     int nb_chapter_rows;
+    char chapter_search[64];
     int chapter_selected;
     int chapter_pinned;
     int64_t chapter_fade_start;
@@ -1044,6 +1045,9 @@ 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;
@@ -1100,7 +1104,7 @@ static void chapter_sort_by(int key)
     chapter_sort[0] = (ChapterSortKey){ key, 0 };
 }
 
-/* rebuilds the rows in the current sort order, keeping the selected chapter 
selected */
+/* 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;
@@ -1114,7 +1118,8 @@ static int chapter_list_update_rows(VideoState *is)
 
         if (chapter->end != AV_NOPTS_VALUE && chapter->end > chapter->start)
             row.length = av_rescale_q(chapter->end - chapter->start, 
chapter->time_base, av_make_q(1, 1));
-        is->chapter_rows[is->nb_chapter_rows++] = row;
+        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;
@@ -1170,7 +1175,7 @@ static void bprint_chapter_cell(AVBPrint *script, const 
char *style, int x, int
 }
 
 typedef struct ChapterListLayout {
-    int font, line, width, nb_rows, first;
+    int font, line, width, height, nb_rows, first;
     int artist_x, title_x, length_x;
 } ChapterListLayout;
 
@@ -1183,6 +1188,7 @@ static ChapterListLayout chapter_list_layout(VideoState 
*is)
     l.width    = is->width * 3 / 5;
     l.nb_rows  = FFMIN(is->nb_chapter_rows, CHAPTER_LIST_ROWS);
     l.first    = av_clip(is->chapter_selected - l.nb_rows / 2, 0, 
is->nb_chapter_rows - l.nb_rows);
+    l.height   = (l.nb_rows + 1) * l.line + l.font;
     l.artist_x = l.font * 3;
     l.length_x = l.width - l.font / 2;
     l.title_x  = l.artist_x + (l.length_x - l.font * 4 - l.artist_x) * 2 / 5;
@@ -1193,7 +1199,7 @@ static void chapter_list_script(VideoState *is, AVBPrint 
*script)
 {
     ChapterListLayout l = chapter_list_layout(is);
 
-    is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (l.nb_rows + 1) * 
l.line + l.font };
+    is->chapter_rect = (SDL_Rect){ l.font, l.font, l.width, (CHAPTER_LIST_ROWS 
+ 1) * l.line + l.font };
     av_bprintf(script,
                "[Script Info]\nScriptType: v4.00+\nPlayResX: %d\nPlayResY: 
%d\nWrapStyle: 2\n\n"
                "[V4+ Styles]\n"
@@ -1201,10 +1207,15 @@ static void chapter_list_script(VideoState *is, 
AVBPrint *script)
                "Style: Panel,Sans,%d,&H40000000,&H40000000,0,0,7\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\n"
+               "Style: Selected,Sans,%d,&H0080D0FF,&H00000000,-1,%d,7\n"
+               "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n\n"
                "[Events]\nFormat: Start, End, Style, Text\n",
-               is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, 
l.font / 16 + 1, l.font, l.font / 16 + 1);
-    bprint_chapter_box(script, "Panel", 0, 0, is->chapter_rect.w, 
is->chapter_rect.h);
+               is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, 
l.font / 16 + 1, l.font, l.font / 16 + 1,
+               l.font, l.font / 16 + 1);
+    bprint_chapter_box(script, "Panel", 0, 0, l.width, l.height);
+    if (is->chapter_pinned)
+        bprint_chapter_cell(script, *is->chapter_search ? "Row" : "Hint", 
sort_button_x(l.font, FF_ARRAY_ELEMS(sort_buttons)),
+                            l.font / 2, l.length_x, l.font / 2 + l.line, 
*is->chapter_search ? is->chapter_search : "type to search");
     for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) {
         int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 
2;
 
@@ -1324,7 +1335,8 @@ static void chapter_list_show(VideoState *is, int 
selected)
 
 static void chapter_list_move(VideoState *is, int delta)
 {
-    chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, 
is->nb_chapter_rows - 1));
+    if (is->nb_chapter_rows)
+        chapter_list_show(is, av_clip(is->chapter_selected + delta, 0, 
is->nb_chapter_rows - 1));
 }
 
 static int chapter_list_visible(VideoState *is)
@@ -1332,7 +1344,54 @@ static int chapter_list_visible(VideoState *is)
     return chapter_list_alpha(is, av_gettime_relative()) > 0;
 }
 
-static void seek_chapter(VideoState *is, int i);
+/* 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) {
+        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);
+    chapter_list_render(is);
+}
+
+static void chapter_list_pin(VideoState *is, int pinned)
+{
+    is->chapter_pinned = pinned;
+    if (pinned) {
+        SDL_StartTextInput();
+        chapter_list_show(is, chapter_row(is, current_chapter(is)));
+    } else {
+        SDL_StopTextInput();
+        is->chapter_last_input = av_gettime_relative() - 
CHAPTER_LIST_HOLD_TIME;
+        is->chapter_search[0] = 0;
+        chapter_list_update_rows(is);
+        chapter_list_render(is);
+    }
+}
+
+/* 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;
+    }
+}
 
 /* seeks to the entry under a click, returns whether the click hit the list at 
all */
 static int chapter_list_click(VideoState *is, int x, int y)
@@ -1342,7 +1401,7 @@ static int chapter_list_click(VideoState *is, int x, int 
y)
 
     x -= is->chapter_rect.x;
     y -= is->chapter_rect.y;
-    if (x < 0 || y < 0 || x >= is->chapter_rect.w || y >= is->chapter_rect.h)
+    if (x < 0 || y < 0 || x >= l.width || y >= l.height)
         return 0;
     row = (y - l.font / 2) / l.line - 1;
     if (row >= 0 && row < l.nb_rows)
@@ -3836,13 +3895,18 @@ static void event_loop(VideoState *cur_stream)
         refresh_loop_wait_event(cur_stream, &event);
         switch (event.type) {
         case SDL_KEYDOWN:
-            if (exit_on_keydown || event.key.keysym.sym == SDLK_ESCAPE || 
event.key.keysym.sym == SDLK_q) {
+            if (exit_on_keydown || (!cur_stream->chapter_pinned &&
+                                    (event.key.keysym.sym == SDLK_ESCAPE || 
event.key.keysym.sym == SDLK_q))) {
                 do_exit(cur_stream);
                 break;
             }
             // 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);
+                break;
+            }
             switch (event.key.keysym.sym) {
             case SDLK_f:
                 toggle_full_screen(cur_stream);
@@ -3880,15 +3944,6 @@ static void event_loop(VideoState *cur_stream)
             case SDLK_t:
                 stream_cycle_channel(cur_stream, AVMEDIA_TYPE_SUBTITLE);
                 break;
-            case SDLK_l:
-                if (!cur_stream->ic->nb_chapters)
-                    break;
-                cur_stream->chapter_pinned = !cur_stream->chapter_pinned;
-                if (cur_stream->chapter_pinned)
-                    chapter_list_show(cur_stream, chapter_row(cur_stream, 
current_chapter(cur_stream)));
-                else
-                    cur_stream->chapter_last_input = av_gettime_relative() - 
CHAPTER_LIST_HOLD_TIME;
-                break;
             case SDLK_RETURN:
             case SDLK_KP_ENTER:
                 if (chapter_list_visible(cur_stream))
@@ -3924,17 +3979,9 @@ static void event_loop(VideoState *cur_stream)
                 incr = seek_interval ? seek_interval : 10.0;
                 goto do_seek;
             case SDLK_UP:
-                if (cur_stream->chapter_pinned) {
-                    chapter_list_move(cur_stream, -1);
-                    break;
-                }
                 incr = 60.0;
                 goto do_seek;
             case SDLK_DOWN:
-                if (cur_stream->chapter_pinned) {
-                    chapter_list_move(cur_stream, 1);
-                    break;
-                }
                 incr = -60.0;
             do_seek:
                     if (seek_by_bytes) {
@@ -3965,6 +4012,15 @@ static void event_loop(VideoState *cur_stream)
                 break;
             }
             break;
+        case SDL_KEYUP:
+            /* on the release, so that the key's own text input does not start 
the search */
+            if (event.key.keysym.sym == SDLK_l && cur_stream->width && 
!cur_stream->chapter_pinned && cur_stream->ic->nb_chapters)
+                chapter_list_pin(cur_stream, 1);
+            break;
+        case SDL_TEXTINPUT:
+            if (cur_stream->chapter_pinned)
+                chapter_list_search(cur_stream, event.text.text);
+            break;
         case SDL_MOUSEWHEEL:
             if (chapter_list_visible(cur_stream))
                 chapter_list_move(cur_stream, -event.wheel.y);
@@ -4254,7 +4310,7 @@ void show_help_default(const char *opt, const char *arg)
            "left/right          seek backward/forward by 10 seconds or a 
custom interval if -seek_interval is set\n"
            "down/up             seek backward/forward 1 minute\n"
            "page down/page up   seek to previous/next chapter or 
backward/forward 10 minutes if no chapters\n"
-           "l                   keep the chapter list on screen, down/up then 
move its selection\n"
+           "l                   keep the chapter list on screen; typing then 
filters it, down/up move its selection and escape closes it\n"
            "enter               seek to the chapter selected in the chapter 
list while it is shown\n"
            "mouse wheel         move the chapter list selection while it is 
shown\n"
            "left click          seek to the clicked chapter list entry, or 
sort the list by the clicked column, again to flip it\n"
-- 
2.52.0


>From d5ee86088eace87a86039e79259762befe6ee4c8 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 21:51:07 +0200
Subject: [PATCH 10/12] ffplay: factor the chapter list hit test out of the
 click handler

Assisted-by: Claude
---
 fftools/ffplay.c | 40 ++++++++++++++++++++++++++--------------
 1 file changed, 26 insertions(+), 14 deletions(-)

diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index 9c86647a4e..cca0ded1dc 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -1393,28 +1393,40 @@ static void chapter_list_key(VideoState *is, 
SDL_Keycode key)
     }
 }
 
-/* seeks to the entry under a click, returns whether the click hit the list at 
all */
+/* the row and the sort button under a window position, -1 for none; returns 
whether it is inside the list */
+static int chapter_list_hit(VideoState *is, const ChapterListLayout *l, int x, 
int y, int *row, int *button)
+{
+    int line;
+
+    *row = *button = -1;
+    x -= is->chapter_rect.x;
+    y -= is->chapter_rect.y;
+    if (x < 0 || y < 0 || x >= l->width || y >= l->height)
+        return 0;
+    line = (y - l->font / 2) / l->line;
+    if (line > 0 && line <= l->nb_rows)
+        *row = l->first + line - 1;
+    for (int b = 0; line == 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++)
+        if (x >= sort_button_x(l->font, b) && x < sort_button_x(l->font, b + 
1))
+            *button = b;
+    return 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);
-    int row;
+    int row, button, inside = chapter_list_hit(is, &l, x, y, &row, &button);
 
-    x -= is->chapter_rect.x;
-    y -= is->chapter_rect.y;
-    if (x < 0 || y < 0 || x >= l.width || y >= l.height)
-        return 0;
-    row = (y - l.font / 2) / l.line - 1;
-    if (row >= 0 && row < l.nb_rows)
-        seek_chapter(is, is->chapter_rows[l.first + row].index);
-    for (int b = 0; row < 0 && b < FF_ARRAY_ELEMS(sort_buttons); b++) {
-        if (x < sort_button_x(l.font, b) || x >= sort_button_x(l.font, b + 1))
-            continue;
-        chapter_sort_by(b);
+    if (row >= 0)
+        seek_chapter(is, is->chapter_rows[row].index);
+    if (button >= 0) {
+        chapter_sort_by(button);
         chapter_list_update_rows(is);
         chapter_list_show(is, is->chapter_selected);
         chapter_list_render(is);
     }
-    return 1;
+    return inside;
 }
 
 static void chapter_list_draw(VideoState *is)
-- 
2.52.0


>From 16de3a08f5105fa8e034e09eba784a0fffb2b169 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 21:52:28 +0200
Subject: [PATCH 11/12] ffplay: highlight the chapter list entry and button
 under the mouse

Mouse motion over the visible list re-renders it only when the hovered
row or button changes, and the highlight is dropped when the list comes
back after having been hidden.

Assisted-by: Claude
---
 fftools/ffplay.c | 30 +++++++++++++++++++++++++++---
 1 file changed, 27 insertions(+), 3 deletions(-)

diff --git a/fftools/ffplay.c b/fftools/ffplay.c
index cca0ded1dc..f47996a133 100644
--- a/fftools/ffplay.c
+++ b/fftools/ffplay.c
@@ -305,6 +305,8 @@ typedef struct VideoState {
     int nb_chapter_rows;
     char chapter_search[64];
     int chapter_selected;
+    int chapter_hover_row;
+    int chapter_hover_button;
     int chapter_pinned;
     int64_t chapter_fade_start;
     int64_t chapter_last_input;
@@ -1208,10 +1210,12 @@ static void chapter_list_script(VideoState *is, 
AVBPrint *script)
                "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: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n\n"
+               "Style: Hint,Sans,%d,&H00A0A0A0,&H00000000,0,%d,7\n"
+               "Style: Hover,Sans,%d,&HC8FFFFFF,&HC8FFFFFF,0,0,7\n"
+               "Style: ButtonHover,Sans,%d,&H50FFFFFF,&H50FFFFFF,0,0,7\n\n"
                "[Events]\nFormat: Start, End, Style, Text\n",
                is->chapter_rect.w, is->chapter_rect.h, l.font, l.font, l.font, 
l.font / 16 + 1, l.font, l.font / 16 + 1,
-               l.font, l.font / 16 + 1);
+               l.font, l.font / 16 + 1, l.font, l.font);
     bprint_chapter_box(script, "Panel", 0, 0, l.width, l.height);
     if (is->chapter_pinned)
         bprint_chapter_cell(script, *is->chapter_search ? "Row" : "Hint", 
sort_button_x(l.font, FF_ARRAY_ELEMS(sort_buttons)),
@@ -1219,7 +1223,7 @@ static void chapter_list_script(VideoState *is, AVBPrint 
*script)
     for (int b = 0; b < FF_ARRAY_ELEMS(sort_buttons); b++) {
         int x = sort_button_x(l.font, b), w = sort_buttons[b].width * l.font / 
2;
 
-        bprint_chapter_box(script, "Button", x, l.font / 2, w, l.font * 5 / 4);
+        bprint_chapter_box(script, b == is->chapter_hover_button ? 
"ButtonHover" : "Button", x, l.font / 2, w, l.font * 5 / 4);
         av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an5\\pos(%d,%d)}%s%s\n", 
chapter_sort[0].key == b ? "Selected" : "Row",
                    x + w / 2, l.font * 9 / 8, sort_buttons[b].label,
                    chapter_sort[0].key != b ? "" : chapter_sort[0].descending 
? " \xe2\x96\xbc" : " \xe2\x96\xb2");
@@ -1229,6 +1233,8 @@ static void chapter_list_script(VideoState *is, AVBPrint 
*script)
         const char *style = r == is->chapter_selected ? "Selected" : "Row";
         int y = l.font / 2 + (r - l.first + 1) * l.line;
 
+        if (r == is->chapter_hover_row)
+            bprint_chapter_box(script, "Hover", 0, y - l.font / 4, l.width, 
l.line);
         av_bprintf(script, CHAPTER_LIST_EVENT "%s,{\\an9\\pos(%d,%d)}%d\n", 
style, l.font * 5 / 2, y, row->index + 1);
         bprint_chapter_cell(script, style, l.artist_x, y, l.title_x - l.font / 
2, y + l.line, row->artist);
         bprint_chapter_cell(script, style, l.title_x, y, l.length_x - l.font * 
4, y + l.line, row->title);
@@ -1325,6 +1331,8 @@ static void chapter_list_show(VideoState *is, int 
selected)
 
     if (!renderer)
         return;
+    if (!chapter_list_alpha(is, now))
+        is->chapter_hover_row = is->chapter_hover_button = -1;
     is->chapter_fade_start = now - chapter_list_alpha(is, now) * 
CHAPTER_LIST_FADE_TIME;
     is->chapter_last_input = now;
     if (selected != is->chapter_selected || !is->chapter_texture) {
@@ -1412,6 +1420,19 @@ static int chapter_list_hit(VideoState *is, const 
ChapterListLayout *l, int x, i
     return 1;
 }
 
+static void chapter_list_hover(VideoState *is, int x, int y)
+{
+    ChapterListLayout l = chapter_list_layout(is);
+    int row, button;
+
+    chapter_list_hit(is, &l, x, y, &row, &button);
+    if (row == is->chapter_hover_row && button == is->chapter_hover_button)
+        return;
+    is->chapter_hover_row    = row;
+    is->chapter_hover_button = button;
+    chapter_list_render(is);
+}
+
 /* 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)
 {
@@ -3689,6 +3710,7 @@ static VideoState *stream_open(const char *filename,
     is = av_mallocz(sizeof(VideoState));
     if (!is)
         return NULL;
+    is->chapter_hover_row = is->chapter_hover_button = -1;
     is->last_video_stream = is->video_stream = -1;
     is->last_audio_stream = is->audio_stream = -1;
     is->last_subtitle_stream = is->subtitle_stream = -1;
@@ -4062,6 +4084,8 @@ static void event_loop(VideoState *cur_stream)
                 cursor_hidden = 0;
             }
             cursor_last_shown = av_gettime_relative();
+            if (event.type == SDL_MOUSEMOTION && 
chapter_list_visible(cur_stream))
+                chapter_list_hover(cur_stream, event.motion.x, event.motion.y);
             if (event.type == SDL_MOUSEBUTTONDOWN) {
                 if (event.button.button != SDL_BUTTON_RIGHT)
                     break;
-- 
2.52.0


>From 308a5e1a0db151f91bd4fc732dfcdd97b7e28461 Mon Sep 17 00:00:00 2001
From: Michael Niedermayer <[email protected]>
Date: Sun, 20 Sep 2026 18:34:51 +0200
Subject: [PATCH 12/12] avformat/concatdec: add an option creating a chapter
 per file

With chapter_per_file set, every file becomes a chapter named after its
title tag or, failing that, its file name, so a player can present a
playlist as a browsable list. Finding the chapter boundaries means
opening every file up front, which also makes the total duration known
and the output seekable.

Assisted-by: Claude
---
 doc/demuxers.texi       |  7 +++++++
 libavformat/concatdec.c | 30 ++++++++++++++++++++++++++++++
 2 files changed, 37 insertions(+)

diff --git a/doc/demuxers.texi b/doc/demuxers.texi
index 82c36a4594..a1f316aa86 100644
--- a/doc/demuxers.texi
+++ b/doc/demuxers.texi
@@ -242,6 +242,13 @@ expressed in microseconds. The duration metadata is only 
set if it is known
 based on the concat file.
 The default is 0.
 
+@item chapter_per_file
+If set to 1, add a chapter for each file, named after the @code{title}
+metadata of the file or, failing that, its file name. Every file is opened
+when the script is read to learn its duration, which also makes the total
+duration known and the output seekable.
+The default is 0.
+
 @end table
 
 @subsection Examples
diff --git a/libavformat/concatdec.c b/libavformat/concatdec.c
index c57d1b649a..5960d00611 100644
--- a/libavformat/concatdec.c
+++ b/libavformat/concatdec.c
@@ -73,6 +73,7 @@ typedef struct {
     ConcatMatchMode stream_match_mode;
     unsigned auto_convert;
     int segment_time_metadata;
+    int chapter_per_file;
 } ConcatContext;
 
 static int concat_probe(const AVProbeData *probe)
@@ -658,6 +659,31 @@ fail:
     return ret == AVERROR_EOF ? 0 : ret;
 }
 
+static int add_file_chapters(AVFormatContext *avf)
+{
+    ConcatContext *cat = avf->priv_data;
+
+    for (unsigned i = 0; i < cat->nb_files; i++) {
+        ConcatFile *file = &cat->files[i];
+        AVDictionaryEntry *title;
+        int ret = open_file(avf, i);
+
+        if (ret < 0)
+            return ret;
+        file->user_duration = file->duration;
+        title = av_dict_get(cat->avf->metadata, "title", NULL, 0);
+        if (!avpriv_new_chapter(avf, i, AV_TIME_BASE_Q, file->start_time,
+                                file->duration == AV_NOPTS_VALUE ? 
AV_NOPTS_VALUE : file->start_time + file->duration,
+                                title ? title->value : av_basename(file->url)))
+            return AVERROR(ENOMEM);
+        if (file->duration == AV_NOPTS_VALUE) {
+            av_log(avf, AV_LOG_WARNING, "Duration of '%s' unknown, no chapters 
for the files after it\n", file->url);
+            break;
+        }
+    }
+    return 0;
+}
+
 static int concat_read_header(AVFormatContext *avf)
 {
     ConcatContext *cat = avf->priv_data;
@@ -672,6 +698,8 @@ static int concat_read_header(AVFormatContext *avf)
         av_log(avf, AV_LOG_ERROR, "No files to concat\n");
         return AVERROR_INVALIDDATA;
     }
+    if (cat->chapter_per_file && (ret = add_file_chapters(avf)) < 0)
+        return ret;
 
     for (i = 0; i < cat->nb_files; i++) {
         if (cat->files[i].start_time == AV_NOPTS_VALUE)
@@ -940,6 +968,8 @@ static const AVOption options[] = {
       OFFSET(auto_convert), AV_OPT_TYPE_BOOL, {.i64 = 1}, 0, 1, DEC },
     { "segment_time_metadata", "output file segment start time and duration as 
packet metadata",
       OFFSET(segment_time_metadata), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, DEC },
+    { "chapter_per_file", "add a chapter for each file, opening them all up 
front to learn their durations",
+      OFFSET(chapter_per_file), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, DEC },
     { NULL }
 };
 
-- 
2.52.0

_______________________________________________
ffmpeg-devel mailing list -- [email protected]
To unsubscribe send an email to [email protected]

Reply via email to