Skip to content

Commit 445e290

Browse files
fhirschmannclaude
andcommitted
feat(audio): restart audiobooks from the start after a long idle gap
Position-saving audiobook cards (AUDIOBOOK / _LOOP / _RECURSIVE) now start over instead of resuming mid-chapter if they have not been played for a configurable span. Default 24 h, 0 = disabled, via the new "Restart audiobook after (hrs)" general setting (NVS key freshAfterHrs). Handy for kids who, after a day's break, no longer remember where they were. A per-card last-seen timestamp lives in its own NVS namespace (rfidLastSeen), mirroring the per-card play counter. It is only consulted when the system clock is valid (NTP/RTC), so a pre-sync clock never triggers a spurious restart; the stamp is cleared when a card is deleted. Web interface, all three locales (de/en/fr) and the GitHub-Pages demo mock are updated; README documents the feature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27f754c commit 445e290

12 files changed

Lines changed: 89 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
241241
| OLED display support (SH1106/SSD1306 128×64 over I2C): boot splash, idle screen with IP + READY, now-playing title (up to 3 lines, scrolling) with battery/time/wifi status bar, and a volume bar | [`8ce8104`](../../commit/8ce8104) |
242242
| Web-configurable OLED (new **OLED** settings tab): enable/disable the display, pick the startup animation (none / boot terminal / login splash / full), set the idle header text, toggle the battery/time/wifi/volume fields, and flip the panel 180°. Also a `CMD_TOGGLE_OLED` command for keybindings to blank the screen (e.g. at night) | [`7d86c3d`](../../commit/7d86c3d) |
243243
| Audiobook resume fade-in: continuing a saved position briefly stutters in the first ~2 s (file open + MP3 header decode + seek-flush saturate the 1-bit SD/CPU while I2S already plays). The resume now rewinds a few seconds and fades the volume up over that span, so the glitch lands on already-heard audio — no content lost, clean sound from where you stopped. Tunable / disable via `RESUME_FADEIN_DURATION_MS` and `RESUME_FADEIN_REWIND_S` in `settings.h` | [`583225f`](../../commit/583225f) |
244+
| Audiobooks restart from the beginning after a long pause: if a position-saving audiobook card has not been played for a configurable span (new **"Restart audiobook after (hrs)"** general setting, default 24 h, 0 = off), the next tap starts the book over instead of resuming mid-chapter — handy for kids who no longer remember where they were. The per-card last-seen timestamp lives in its own NVS namespace and is only consulted when the clock is valid (NTP/RTC) | [`8997e71`](../../commit/8997e71) |
244245

245246
### Virtual RFID cards
246247

html/locales/de.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@
692692
"savePlayPosRfidChangeExp": "Beim Wechsel zu einem neuen RFID-Tag wird für Hörbücher die Abspielposition gespeichert. Betrifft keine Modifikations-Tags. Mehr Infos <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>hier</a> unter <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
693693
"savePlayPosPeriodic": "Hörbuch-Position regelmäßig sichern",
694694
"savePlayPosPeriodicExp": "Während ein Hörbuch läuft, wird die Position alle ~30 s im Flash gesichert. So gehen bei einem unerwarteten Stromausfall (leerer Akku, harter Aus-Schalter) nur die letzten Sekunden verloren statt bis zum Anfang des Tracks zurückzuspringen. Fork-Funktion.",
695+
"restartFreshHrs": "Hörbuch von vorn nach (Std.)",
696+
"restartFreshHrsExp": "Wird ein Hörbuch länger als die angegebene Stundenzahl nicht abgespielt, startet es beim nächsten Auflegen wieder von vorn statt an der gespeicherten Stelle. Praktisch für Kinder, die sich nach einem Tag Pause nicht mehr erinnern, wo sie waren. 0 = aus (immer fortsetzen). Standard: 24. Benötigt eine gültige Uhrzeit (NTP/RTC). Fork-Funktion.",
695697
"pauseIfRfidRemoved": "Pause wenn RFID-Tag entfernt",
696698
"pauseIfRfidRemovedExp": "Pausieren wenn RFID-Tag entfernt wird. Mehr Infos <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>hier</a> unter <code>PAUSE_WHEN_RFID_REMOVED</code>.",
697699
"dontAcceptRfidTwice": "Denselben RFID-Tag nicht erneut akzeptieren",

html/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@
692692
"savePlayPosRfidChangeExp": "When switching to a new RFID tag, the playback position is saved for audio books. Does not affect modification tags. More information <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>here</a> under <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
693693
"savePlayPosPeriodic": "Checkpoint audiobook position periodically",
694694
"savePlayPosPeriodicExp": "While an audio book plays, the position is saved to flash every ~30 s. This way an unexpected power loss (dead battery, hard power-off) only loses the last few seconds instead of falling back to the start of the track. Fork feature.",
695+
"restartFreshHrs": "Restart audiobook after (hrs)",
696+
"restartFreshHrsExp": "If an audiobook has not been played for more than the given number of hours, it starts over from the beginning the next time the card is placed, instead of resuming the saved position. Handy for kids who, after a day's break, no longer remember where they were. 0 = off (always resume). Default: 24. Requires a valid clock (NTP/RTC). Fork feature.",
695697
"pauseIfRfidRemoved": "Pause when RFID tag is removed",
696698
"pauseIfRfidRemovedExp": "Playback is paused when the RFID tag is removed. More information <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>here</a> under <code>PAUSE_IF_RFID_REMOVED</code>.",
697699
"dontAcceptRfidTwice": "Don't accept the same RFID twice",

html/locales/fr.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@
681681
"savePlayPosRfidChangeExp": "Lors du passage à une nouvelle balise RFID, la position de lecture est enregistrée pour les livres audio. Plus d'informations <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>ici</a> sous <code>SAVE_PLAYPOS_WHEN_RFID_CHANGE</code>.",
682682
"savePlayPosPeriodic": "Sauvegarder régulièrement la position du livre audio",
683683
"savePlayPosPeriodicExp": "Pendant la lecture d'un livre audio, la position est enregistrée dans la mémoire flash toutes les ~30 s. Ainsi, une coupure de courant inattendue (batterie vide, arrêt brutal) ne perd que les dernières secondes au lieu de revenir au début de la piste. Fonction du fork.",
684+
"restartFreshHrs": "Redémarrer le livre audio après (h)",
685+
"restartFreshHrsExp": "Si un livre audio n'a pas été lu depuis plus que le nombre d'heures indiqué, il recommence depuis le début lors de la prochaine pose de la carte, au lieu de reprendre à la position enregistrée. Pratique pour les enfants qui, après une journée de pause, ne se souviennent plus où ils en étaient. 0 = désactivé (toujours reprendre). Par défaut : 24. Nécessite une horloge valide (NTP/RTC). Fonction du fork.",
684686
"pauseIfRfidRemoved": "Mettre en pause si la balise RFID est retirée",
685687
"pauseIfRfidRemovedExp": "Mettre en pause la lecture si la balise RFID est retirée. Plus d'informations <a href='https://forum.espuino.de/t/welche-optionen-beim-kompilieren-gibt-es/120' target='_blank'>ici</a> sous <code>PAUSE_IF_RFID_REMOVED</code>.",
686688
"dontAcceptRfidTwice": "Ne pas accepter la balise RFID deux fois",

html/management.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2602,6 +2602,14 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
26022602
data-i18n="[data-bs-content]general.options.savePlayPosPeriodicExp"
26032603
tabindex="0"><i class="fas fa-circle-question"></i></a>
26042604
</div>
2605+
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
2606+
<label for="restartFreshHrs" data-i18n="general.options.restartFreshHrs"></label>
2607+
<input type="number" class="form-control" id="restartFreshHrs" name="restartFreshHrs"
2608+
min="0" max="8760" step="1" style="max-width:7em">
2609+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2610+
data-i18n="[data-bs-content]general.options.restartFreshHrsExp" tabindex="0"><i
2611+
class="fas fa-circle-question"></i></a>
2612+
</div>
26052613
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
26062614
<input type="checkbox" id="playLastRfidOnReboot" name="playLastRfidOnReboot"
26072615
value="false">
@@ -5319,6 +5327,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
53195327
$('#savePlayPosShutdown').prop('checked', genSettings.savePosShutdown);
53205328
$('#savePlayPosRfidChange').prop('checked', genSettings.savePosRfidChge);
53215329
$('#savePlayPosPeriodic').prop('checked', genSettings.savePosPeriodic);
5330+
if (genSettings.restartFreshHrs !== undefined) {
5331+
$('#restartFreshHrs').val(genSettings.restartFreshHrs);
5332+
}
53225333
$('#playLastRfidOnReboot').prop('checked', genSettings.playLastRfidOnReboot);
53235334
$('#pauseIfRfidRemoved').prop('checked', genSettings.pauseIfRfidRemoved);
53245335
$('#dontAcceptRfidTwice').prop('checked', genSettings.dontAcceptRfidTwice);
@@ -5983,6 +5994,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
59835994
savePosShutdown: $('#savePlayPosShutdown').prop('checked'),
59845995
savePosRfidChge: $('#savePlayPosRfidChange').prop('checked'),
59855996
savePosPeriodic: $('#savePlayPosPeriodic').prop('checked'),
5997+
restartFreshHrs: Number($('#restartFreshHrs').val()),
59865998
playLastRfidOnReboot: $('#playLastRfidOnReboot').prop('checked'),
59875999
pauseIfRfidRemoved: $('#pauseIfRfidRemoved').prop('checked'),
59886000
dontAcceptRfidTwice: $('#dontAcceptRfidTwice').prop('checked'),

src/Playstats.cpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,48 @@ void Playstats_ClearCardPlays(const char *tagId) {
4646
gPrefsCardCnt.remove(tagId);
4747
}
4848

49+
// Per-card "last seen" timestamps (epoch seconds) live in their own NVS namespace, keyed by the
50+
// 12-digit tag id. Used to restart an audiobook from the start after a long pause.
51+
static constexpr uint32_t PLAYSTATS_MIN_VALID_EPOCH = 1577836800UL; // 2020-01-01, guards against pre-NTP clock
52+
static Preferences gPrefsCardSeen;
53+
static bool gCardSeenReady = false;
54+
static void Playstats_CardSeenInit(void) {
55+
if (!gCardSeenReady) {
56+
gPrefsCardSeen.begin("rfidLastSeen");
57+
gCardSeenReady = true;
58+
}
59+
}
60+
void Playstats_NoteCardSeen(const char *tagId) {
61+
if (!tagId || !tagId[0]) {
62+
return;
63+
}
64+
const time_t now = time(nullptr);
65+
if ((uint32_t) now < PLAYSTATS_MIN_VALID_EPOCH) {
66+
return; // clock not valid yet -> don't store a bogus timestamp
67+
}
68+
Playstats_CardSeenInit();
69+
gPrefsCardSeen.putULong(tagId, (uint32_t) now);
70+
}
71+
bool Playstats_CardSeenAgoExceeds(const char *tagId, uint32_t seconds) {
72+
if (!tagId || !tagId[0]) {
73+
return false;
74+
}
75+
const time_t now = time(nullptr);
76+
if ((uint32_t) now < PLAYSTATS_MIN_VALID_EPOCH) {
77+
return false; // can't reason about elapsed time without a valid clock
78+
}
79+
Playstats_CardSeenInit();
80+
const uint32_t lastSeen = gPrefsCardSeen.getULong(tagId, 0);
81+
if (lastSeen == 0 || (uint32_t) now < lastSeen) {
82+
return false; // never seen, or clock went backwards -> play it safe, resume as usual
83+
}
84+
return ((uint32_t) now - lastSeen) > seconds;
85+
}
86+
void Playstats_ClearCardSeen(const char *tagId) {
87+
Playstats_CardSeenInit();
88+
gPrefsCardSeen.remove(tagId);
89+
}
90+
4991
// Days since 1970-01-01 for a (proleptic Gregorian) calendar date (Howard Hinnant's algorithm).
5092
// Avoids needing tm_gmtoff (not available in this newlib build): we feed it the local Y/M/D.
5193
static long Playstats_DaysFromCivil(int y, unsigned m, unsigned d) {

src/Playstats.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ void Playstats_NoteCardPlay(const char *tagId);
2020
uint32_t Playstats_GetCardPlays(const char *tagId);
2121
void Playstats_ClearCardPlays(const char *tagId); // drop a card's play counter (e.g. when deleted)
2222

23+
// Per-card "last seen" timestamp (epoch seconds), used to restart audiobooks from the beginning
24+
// after a long pause. Stored only when the system clock is valid (NTP/RTC). NoteCardSeen() stamps
25+
// the current time; CardSeenAgoExceeds() returns true only if the clock is valid, a stamp exists,
26+
// and more than <seconds> have elapsed since it.
27+
void Playstats_NoteCardSeen(const char *tagId);
28+
bool Playstats_CardSeenAgoExceeds(const char *tagId, uint32_t seconds);
29+
void Playstats_ClearCardSeen(const char *tagId); // drop a card's last-seen stamp (e.g. when deleted)
30+
2331
// Raw ring-buffer access for backup/restore (JSON is built/parsed by the caller).
2432
uint16_t Playstats_GetRingSize(void); // number of day-slots (365)
2533
uint32_t Playstats_GetRingLastDay(void); // local day number of the most recent tracked day

src/RfidCommon.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,19 @@ void Rfid_PreferenceLookupHandler(void) {
9292
}
9393
#endif
9494

95+
// Restart an audiobook from the beginning if it has not been played for a configured
96+
// span (default 24 h; 0 = disabled). After such a gap the listener rarely remembers
97+
// where they were, so resuming mid-chapter is more annoying than starting over. Only
98+
// the position-saving audiobook modes carry a resume point worth discarding.
99+
const uint32_t freshAfterHrs = gPrefsSettings.getUInt("freshAfterHrs", 24);
100+
const bool isAudiobook = (_playMode == AUDIOBOOK || _playMode == AUDIOBOOK_LOOP || _playMode == AUDIOBOOK_RECURSIVE);
101+
if (freshAfterHrs > 0 && isAudiobook && (_lastPlayPos > 0 || _trackLastPlayed > 0) && Playstats_CardSeenAgoExceeds(gCurrentRfidTagId, freshAfterHrs * 3600UL)) {
102+
Log_Printf(LOGLEVEL_NOTICE, "Audiobook idle > %u h -> restarting from the beginning", freshAfterHrs);
103+
_lastPlayPos = 0;
104+
_trackLastPlayed = 0;
105+
}
106+
Playstats_NoteCardSeen(gCurrentRfidTagId); // remember when this card was last started
107+
95108
Playstats_NoteCardPlay(gCurrentRfidTagId); // count plays per music card (most-played stats)
96109
AudioPlayer_SetPlaylist(_file, _lastPlayPos, _playMode, _trackLastPlayed);
97110
}

src/Web.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,9 @@ WebsocketCodeType JSONToSettings(JsonObject doc) {
10781078
success = success && (gPrefsSettings.putBool("savePosShutdown", generalObj["savePosShutdown"].as<bool>()) != 0);
10791079
success = success && (gPrefsSettings.putBool("savePosRfidChge", generalObj["savePosRfidChge"].as<bool>()) != 0);
10801080
success = success && (gPrefsSettings.putBool("savePosPeriodic", generalObj["savePosPeriodic"].as<bool>()) != 0);
1081+
if (!generalObj["restartFreshHrs"].isNull()) { // restart audiobooks from start after this idle gap (0 = off)
1082+
gPrefsSettings.putUInt("freshAfterHrs", generalObj["restartFreshHrs"].as<uint32_t>());
1083+
}
10811084
success = success && (gPrefsSettings.putBool("playStartupSnd", generalObj["playStartupSnd"].as<bool>()) != 0);
10821085
success = success && (gPrefsSettings.putBool("playLastOnBoot", generalObj["playLastRfidOnReboot"].as<bool>()) != 0);
10831086
success = success && (gPrefsSettings.putBool("pauseRfidRem", generalObj["pauseIfRfidRemoved"].as<bool>()) != 0);

src/WebRfid.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ void handleDeleteRFIDRequest(AsyncWebServerRequest *request) {
267267
if (gPrefsRfid.remove(tagId.c_str())) {
268268
RfidSync_OnDelete(tagId.c_str()); // record tombstone + propagate the deletion to server/peers
269269
Playstats_ClearCardPlays(tagId.c_str()); // drop the card's play counter too
270+
Playstats_ClearCardSeen(tagId.c_str()); // and its last-seen timestamp
270271
Log_Printf(LOGLEVEL_INFO, "/rfid (DELETE): tag %s removed successfuly", tagId);
271272
request->send(200, "text/plain; charset=utf-8", tagId + " removed successfuly");
272273
} else {

0 commit comments

Comments
 (0)