skin: add %pX tag for time-based playlist progress

rockbox-gerrit-noreply--- via rockbox-cvs <[email protected]> Sat, 27 Jun 2026 12:29:32 -0400
Newsgroups gmane.comp.systems.archos.rockbox.cvs
Message-ID <[email protected]>
commit a8240850574d1fbcd24f13fb89f6368158021aa7
Author: Adam N. Burke <[email protected]>
Date:   Fri Jun 12 20:27:33 2026 -0400

    skin: add %pX tag for time-based playlist progress
    
    %pP reports playlist progress by position index, which treats every
    track as equally long. For playlists with tracks of unequal length --
    audiobooks with chapters anywhere from two minutes to an hour are the
    motivating case -- position is a poor proxy for listening progress.
    
    %pX reports the played percentage of the whole playlist by time: the
    summed length of all preceding tracks plus the elapsed time in the
    current one, relative to the playlist's total duration. It can be
    used as a value, in a conditional, with %if(), or as a bar tag like
    %pb.
    
    If a playlist contains more than 500 tracks or the scan is taking too
    long and the user aborts the tag will fallback to the behavior of %pP
    except the progress through the current track will be included in the
    returned percentage
    
    --------------------------------------
    Computing this needs every track's length, and reading metadata for
    every track is too slow and disk-heavy for a tag that refreshes on the
    WPS. Instead each track's length is estimated from its file size: the
    skin engine scans the playlist in the background, a batch of files each
    skin refresh, opening each file only to read its size (directory
    metadata, no header parse). Size is turned into time by calibrating one
    file of each type -- the first file of each extension is parsed once
    with get_metadata to learn its bytes-per-second, and every later file
    of that type reuses it. A single-format playlist, the usual audiobook
    case, parses exactly one file and stat's the rest.
    
    The result is an estimate -- bitrate varies within a type, especially
    for VBR -- but it is cheap and accurate enough for a progress
    indicator, and the playing track always contributes its exact elapsed
    time. Per-track lengths are stored as two bytes of minutes each in a
    movable buffer sized to the track count and allocated only while the
    tag is in use; the cache is keyed on the track count and a crc of the
    first, middle and last filenames, so a playlist swap or reshuffle is
    caught. If the buffer cannot be allocated (a very large playlist on a
    low-memory target) the tag falls back to position-based progress.
    
    The buffer is allocated when playback starts (and when the now-playing
    screen is opened with playback already active) rather than lazily on the
    first WPS refresh, so the one-time allocation happens at a playback
    boundary instead of during steady-state playback; it falls back to
    allocating on first use if that point is missed.
    
    Until the scan finishes the tag does not blank: it returns an instant
    equal-weight estimate -- (completed tracks + fraction through the
    current one) / track count -- which sharpens into the size-based value
    as the scan fills in. So a theme can just use %pX and it is correct
    from the first frame, and %?pX is true whenever a playlist is loaded.
    
    Tested on a Sansa Clip Zip and in the simulator. On a 216-track,
    ~745 MB single-format audiobook playlist the length scan dropped from
    ~4150 ms (get_metadata on every track) to ~174 ms (one parse plus 215
    file-size stats), roughly 24x lighter.
    
    Change-Id: I6e572e78a10444bd513ddc77e30da04aa5153ef2

diff --git a/apps/gui/skin_engine/skin_display.c b/apps/gui/skin_engine/skin_display.c
index d45a3c8364..c2c19b2b8c 100644
--- a/apps/gui/skin_engine/skin_display.c
+++ b/apps/gui/skin_engine/skin_display.c
@@ -210,6 +210,23 @@ void draw_progressbar(struct gui_wps *gwps, struct skin_viewport* skin_viewport,
         length = 100;
         end = battery_level();
     }
+    else if (pb->type == SKIN_TOKEN_PLAYLIST_PROGRESSBAR)
+    {
+        unsigned long pl_elapsed, pl_total;
+        if (id3 && wps_get_playlist_percent(id3,
+                       id3->elapsed + state->ff_rewind_count,
+                       &pl_elapsed, &pl_total)
+            && pl_total > 0)
+        {
+            length = pl_total;
+            end = pl_elapsed;
+        }
+        else
+        {
+            length = 1;
+            end = 0;
+        }
+    }
     else if (pb->type == SKIN_TOKEN_PEAKMETER_LEFTBAR ||
              pb->type == SKIN_TOKEN_PEAKMETER_RIGHTBAR)
     {
diff --git a/apps/gui/skin_engine/skin_parser.c b/apps/gui/skin_engine/skin_parser.c
index f501061a76..54b36ae31e 100644
--- a/apps/gui/skin_engine/skin_parser.c
+++ b/apps/gui/skin_engine/skin_parser.c
@@ -1234,6 +1234,11 @@ static int parse_progressbar_tag(struct skin_element* element,
         token->type = SKIN_TOKEN_VOLUMEBAR;
     else if (token->type == SKIN_TOKEN_BATTERY_PERCENT)
         token->type = SKIN_TOKEN_BATTERY_PERCENTBAR;
+    else if (token->type == SKIN_TOKEN_PLAYLIST_ELAPSED_PERCENT)
+    {
+        wps_playlist_percent_enable(); /* enable scanning track lengths */
+        token->type = SKIN_TOKEN_PLAYLIST_PROGRESSBAR;
+    }
     else if (token->type == SKIN_TOKEN_TUNER_RSSI)
         token->type = SKIN_TOKEN_TUNER_RSSI_BAR;
     else if (token->type == SKIN_TOKEN_PEAKMETER_LEFT)
@@ -2416,6 +2421,7 @@ static int skin_element_callback(struct skin_element* element, void* data)
                 case SKIN_TOKEN_PROGRESSBAR:
                 case SKIN_TOKEN_VOLUME:
                 case SKIN_TOKEN_BATTERY_PERCENT:
+                case SKIN_TOKEN_PLAYLIST_ELAPSED_PERCENT:
                 case SKIN_TOKEN_PLAYER_PROGRESSBAR:
                 case SKIN_TOKEN_PEAKMETER_LEFT:
                 case SKIN_TOKEN_PEAKMETER_RIGHT:
diff --git a/apps/gui/skin_engine/skin_render.c b/apps/gui/skin_engine/skin_render.c
index 0d626df15a..5c731e0676 100644
--- a/apps/gui/skin_engine/skin_render.c
+++ b/apps/gui/skin_engine/skin_render.c
@@ -227,6 +227,7 @@ static bool do_non_text_tags(struct gui_wps *gwps, struct skin_draw_info *info,
         case SKIN_TOKEN_PLAYLIST_PERCENTBAR:
         case SKIN_TOKEN_SETTINGBAR:
         case SKIN_TOKEN_PROGRESSBAR:
+        case SKIN_TOKEN_PLAYLIST_PROGRESSBAR:
         case SKIN_TOKEN_TUNER_RSSI_BAR:
         case SKIN_TOKEN_LIST_SCROLLBAR:
         {
diff --git a/apps/gui/skin_engine/skin_tokens.c b/apps/gui/skin_engine/skin_tokens.c
index e09c0ed224..3da1a52139 100644
--- a/apps/gui/skin_engine/skin_tokens.c
+++ b/apps/gui/skin_engine/skin_tokens.c
@@ -41,10 +41,15 @@
 #include "misc.h"
 #include "led.h"
 #include "peakmeter.h"
+#include "splash.h"
 /* Image stuff */
 #include "albumart.h"
 #include "playlist.h"
 #include "playback.h"
+#include "metadata.h"
+#include "crc32.h"
+#include "file.h"
+#include "core_alloc.h"
 #include "tdspeed.h"
 #include "viewport.h"
 #include "tagcache.h"
@@ -198,6 +203,167 @@ const char *get_cuesheetid3_token(struct wps_token *token, struct mp3entry *id3,
     return NULL;
 }
 
+/* Whole-playlist progress by time (%pX). if track count exceeds MAX_TRACKS
+ * the tag falls back to position-based progress.
+ * The cache is keyed on the track count and a crc of the
+ * first, middle and last filenames, so a playlist change, same-length swap,
+ * or reshuffle causes a reload of the length data. */
+#define PLPCT_MAX_TRACKS 500
+
+static struct {
+    uint32_t sig;           /* signature of the playlist to detect changed playlist or reshuffle*/
+    int amount;
+    bool nomem;             /* allocation failed -> position fallback */
+    bool enabled;           /* tag was parsed */
+    uint16_t track_mins[PLPCT_MAX_TRACKS];
+    unsigned long total_min;
+} plpct = { .amount = -1, .enabled = false };
+
+static uint32_t plpct_sig(void)
+{
+    struct playlist_info *pl = playlist_get_current();
+    /* changes on any playlist swap or reshuffle */
+    return pl->seed ^ pl->first_index ^ pl->amount ^ pl->last_insert_pos ^ pl->dirlen
+           ^ pl->created_tick ^ pl->max_playlist_size ^ pl->last_shuffled_start;
+}
+
+/* Equal-weight estimate (every track the same length)
+     scaled by 1000 as %pX is percent-only. */
+static void plpct_estimate(const struct mp3entry *id3, unsigned long elapsed_ms,
+                           int index, int amount,
+                           unsigned long *elapsed_s, unsigned long *total_s)
+{
+    unsigned long frac = 0;  /* 0..1000 through the current track */
+    if (id3->length)
+        frac = MIN(1000UL, 1000UL * elapsed_ms / id3->length);
+    *elapsed_s = (unsigned long)(index - 1) * 1000 + frac;
+    *total_s   = (unsigned long)amount * 1000;
+}
+
+void wps_playlist_percent_enable(void)
+{
+    plpct.enabled = true;
+}
+
+void wps_playlist_percent_prepare(void)
+{
+    if (!plpct.enabled)
+        return;
+
+    plpct.nomem = true;
+
+    int amount = playlist_amount();
+    plpct.sig = plpct_sig();
+    plpct.amount = amount;
+    if (amount <= 0 || amount > PLPCT_MAX_TRACKS) /* too many tracks abort */
+    {
+        return;
+    }
+
+    struct playlist_track_info info;
+
+    struct mp3entry *tmp = get_temp_mp3entry(NULL); /* shared scratch */
+
+    if (!tmp)
+        return;
+#if (CONFIG_STORAGE & STORAGE_ATA) /* Harddrive estimate length based on filesize */
+    unsigned int last_afmt = AFMT_UNKNOWN;
+    uint32_t last_bps = 0;
+#endif
+    for (int n = 0; n < amount; n++)
+    {
+        unsigned long secs = 0;
+        uint16_t mins = 0;
+        if (playlist_get_track_info(NULL, n, &info) < 0) /* couldn't get track filename abort */
+            goto abort;
+
+        int slot = info.display_index - 1;
+        if (slot < 0 || slot >= amount)
+            goto abort;
+
+#if (CONFIG_STORAGE & STORAGE_ATA) /* Harddrive */
+        int fd = open(info.filename, O_RDONLY);
+        if (fd >= 0)
+        {
+            unsigned int afmt = probe_file_format(info.filename);
+            off_t size = filesize(fd);
+
+            if (size > 0)
+            {
+                if (afmt != last_afmt || last_bps == 0
+                    || amount <= 50 || (amount <= 250 && ata_disk_isssd()) // TODO tune this for harddisk devices
+                {
+                    if (get_metadata_ex(tmp, fd, info.filename,
+                        METADATA_EXCLUDE_ID3_PATH | METADATA_EXCLUDE_NORMALIZE))
+                    {
+                        secs = tmp->length / 1000;
+                        last_bps = (uint32_t)((uint64_t)size * 1000 / tmp->length);
+                        last_afmt = afmt;
+                    }
+                }
+                else /* file size only -- no per-track metadata parse */
+                {
+                    secs = (unsigned long)((uint64_t)size / last_bps);
+                }
+                mins = MIN(MAX(1, (secs + 30) / 60), 65535ul);/* minutes, rounded to nearest */
+            }
+            close(fd);
+        }
+#else
+        if (get_metadata_ex(tmp, -1, info.filename,
+            METADATA_EXCLUDE_ID3_PATH | METADATA_EXCLUDE_NORMALIZE))
+        {
+            secs = tmp->length / 1000;
+            mins = MIN(MAX(1, (secs + 30) / 60), 65535ul);/* minutes, rounded to nearest */
+        }
+#endif
+        /* unreadable tracks count as zero length */
+        plpct.track_mins[slot] = mins;
+    }
+
+    plpct.total_min = 0;
+    for (int t = 0; t < plpct.amount; t++)
+        plpct.total_min += plpct.track_mins[t];
+    plpct.nomem = false;
+abort:
+    get_temp_mp3entry(tmp); /* release scratch */
+}
+
+bool wps_get_playlist_percent(struct mp3entry *id3, unsigned long elapsed_ms,
+                              unsigned long *elapsed_s, unsigned long *total_s)
+{
+    int amount = playlist_amount();
+    int index = playlist_get_display_index();  /* 1-based */
+
+    if (!id3 || amount <= 0 || index < 1 || index > amount)
+        return false;
+
+    uint32_t sig = plpct_sig();
+    if (amount != plpct.amount || sig != plpct.sig)
+    {
+        splashf(0, ID2P(LANG_WAIT));
+        wps_playlist_percent_enable();
+        wps_playlist_percent_prepare();
+    }
+    /* error parsing playlist or more than MAX_TRACKS fall back to
+       position-based progress rather than blank */
+    if (plpct.nomem)
+    {
+        plpct_estimate(id3, elapsed_ms, index, amount, elapsed_s, total_s);
+        return true;
+    }
+
+    unsigned long before_min = 0;
+    for (int t = 0; t < index - 1; t++)
+        before_min += plpct.track_mins[t];
+
+    *elapsed_s = before_min * 60 + elapsed_ms / 1000;
+    *total_s = plpct.total_min * 60;
+    if (*elapsed_s > *total_s)
+        *elapsed_s = *total_s;
+    return true;
+}
+
 static const char* get_filename_token(struct wps_token *token, char* filename,
                                char *buf, int buf_size)
 {
@@ -333,6 +499,24 @@ const char *get_id3_token(struct wps_token *token, struct mp3entry *id3,
                 snprintf(buf, buf_size, "%lu", 100 * elapsed / length);
                 return buf;
 
+            case SKIN_TOKEN_PLAYLIST_ELAPSED_PERCENT:
+            {
+                unsigned long pl_elapsed, pl_total;
+                if (!wps_get_playlist_percent(id3, elapsed,
+                                              &pl_elapsed, &pl_total)
+                    || pl_total == 0)
+                    return NULL;
+
+                /* %pX always yields a value for a valid playlist now (the
+                   equal-weight estimate until the scan finishes, then the exact
+                   time-weighted figure), so it behaves like any percent tag;
+                   %?pX<...> is true whenever the tag applies (a sized playlist). */
+                if (intval && limit == TOKEN_VALUE_ONLY)
+                    *intval = 100 * pl_elapsed / pl_total;
+                snprintf(buf, buf_size, "%lu", 100 * pl_elapsed / pl_total);
+                return buf;
+            }
+
             case SKIN_TOKEN_TRACK_STARTING:
                 {
                     unsigned long time = token->value.i * (HZ/TIMEOUT_UNIT);
diff --git a/apps/gui/skin_engine/wps_internals.h b/apps/gui/skin_engine/wps_internals.h
index deb95dd4fb..e39358b80d 100644
--- a/apps/gui/skin_engine/wps_internals.h
+++ b/apps/gui/skin_engine/wps_internals.h
@@ -428,6 +428,12 @@ const char *get_cuesheetid3_token(struct wps_token *token, struct mp3entry *id3,
                                   int offset_tracks, char *buf, int buf_size);
 const char *get_id3_token(struct wps_token *token, struct mp3entry *id3,
                           char *filename, char *buf, int buf_size, int limit, int *intval);
+/* whole-playlist progress backing %pX (number and bar forms) */
+bool wps_get_playlist_percent(struct mp3entry *id3, unsigned long elapsed_ms,
+                              unsigned long *elapsed_s, unsigned long *total_s);
+void wps_playlist_percent_enable(void);
+void wps_playlist_percent_prepare(void);
+
 #if CONFIG_TUNER
 const char *get_radio_token(struct wps_token *token, int preset_offset,
                             char *buf, int buf_size, int limit, int *intval);
diff --git a/apps/playlist.c b/apps/playlist.c
index a94a51a356..9140d25a8c 100644
--- a/apps/playlist.c
+++ b/apps/playlist.c
@@ -178,6 +178,9 @@
 static struct playlist_info current_playlist;
 static struct playlist_info on_disk_playlist;
 
+/* playlist elapsed percent function from skin_tokens.c */
+extern void wps_playlist_percent_prepare(void);
+
 /* REPEAT_ONE support function from playback.c */
 extern bool audio_pending_track_skip_is_manual(void);
 static inline bool is_manual_skip(void)
@@ -491,7 +494,7 @@ static void empty_playlist_unlocked(struct playlist_info* playlist, bool resume)
     playlist->first_index = 0;
     playlist->amount = 0;
     playlist->last_insert_pos = -1;
-
+    playlist->created_tick = current_tick;
     playlist->started = false;
 
     if (!resume && playlist == &current_playlist)
@@ -3755,6 +3758,8 @@ void playlist_start(int start_index, unsigned long elapsed,
 
     playlist_write_unlock(playlist);
 
+    wps_playlist_percent_prepare();
+
     audio_play(elapsed, offset);
     audio_resume();
 }
diff --git a/apps/playlist.h b/apps/playlist.h
index 4582f45a70..a906221664 100644
--- a/apps/playlist.h
+++ b/apps/playlist.h
@@ -85,6 +85,7 @@ struct playlist_info
     int last_shuffled_start; /* number of tracks when insert last
                                     shuffled command start */
     int  seed;           /* shuffle seed                            */
+    unsigned long created_tick;
     struct mutex mutex; /* mutex for control file access    */
 #ifdef HAVE_DIRCACHE
     int dcfrefs_handle;
diff --git a/lib/skin_parser/tag_table.c b/lib/skin_parser/tag_table.c
index 1e504cd6bd..a13e58a316 100644
--- a/lib/skin_parser/tag_table.c
+++ b/lib/skin_parser/tag_table.c
@@ -166,6 +166,7 @@ static const struct tag_info legal_tags[] =
     TAG(SKIN_TOKEN_PLAYLIST_ENTRIES,      "pe", "", SKIN_REFRESH_STATIC),
     TAG(SKIN_TOKEN_PLAYLIST_NAME,         "pn", "", SKIN_REFRESH_STATIC),
     TAG(SKIN_TOKEN_PLAYLIST_SHUFFLE,      "ps", "", SKIN_REFRESH_DYNAMIC),
+    TAG(SKIN_TOKEN_PLAYLIST_ELAPSED_PERCENT, "pX", BAR_PARAMS, SKIN_REFRESH_DYNAMIC),
 
     TAG(SKIN_TOKEN_DATABASE_PLAYCOUNT,    "rp", "", SKIN_REFRESH_DYNAMIC),
     TAG(SKIN_TOKEN_DATABASE_RATING,       "rr", "", SKIN_REFRESH_DYNAMIC),
diff --git a/lib/skin_parser/tag_table.h b/lib/skin_parser/tag_table.h
index e7a321dd99..eb2d409487 100644
--- a/lib/skin_parser/tag_table.h
+++ b/lib/skin_parser/tag_table.h
@@ -213,6 +213,8 @@ enum skin_token_type {
     SKIN_TOKEN_PLAYLIST_PERCENT,
     SKIN_TOKEN_PLAYLIST_PERCENTBAR,
     SKIN_TOKEN_PLAYLIST_SHUFFLE,
+    SKIN_TOKEN_PLAYLIST_ELAPSED_PERCENT,
+    SKIN_TOKEN_PLAYLIST_PROGRESSBAR,
 
 
     SKIN_TOKEN_ENABLE_THEME,
diff --git a/manual/appendix/wps_tags.tex b/manual/appendix/wps_tags.tex
index fbf7d042de..bb27840f41 100644
--- a/manual/appendix/wps_tags.tex
+++ b/manual/appendix/wps_tags.tex
@@ -176,6 +176,15 @@ or \config{\%D(2)}), produce the information for the next file to be played.
     \config{\%pp} & Playlist position as index\\
     \config{\%pP} & Playlist position as percentage. Can be used as a value, %
             a conditional tag or a bar tag.\\
+    \config{\%pX} & Percentage of the whole playlist played by time. Unlike
+         \config{\%pP} this reflects tracks of differing length when 500 or less, 
+         such as audiobook chapters. Othewise acts like \config{\%pP} except with
+         also including progress through the current track in the percentage.
+    \opt{HAVE_DISK_STORAGE}{
+         Track lengths are estimated from file size to reduce disk use, so
+         the value may be approximate.
+         }
+         Can be used as a value, a conditional tag or a bar tag.\\
     \config{\%pr} & Remaining time in song\\
     \config{\%ps} & ``s'' if shuffle mode is enabled\\
     \config{\%pt} & Total track time\\
-- 
rockbox-cvs mailing list
[email protected]
https://lists.haxx.se/mailman/listinfo/rockbox-cvs