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
This commit is contained in:
Adam N. Burke 2026-06-12 20:27:33 -04:00 committed by William Wilgus
parent 02f7dabc67
commit a824085057
10 changed files with 233 additions and 1 deletions

View file

@ -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)
{

View file

@ -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:

View file

@ -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:
{

View file

@ -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);

View file

@ -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);

View file

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

View file

@ -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;

View file

@ -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),

View file

@ -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,

View file

@ -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\\