Skip to content

Commit 7cacec7

Browse files
fhirschmannclaude
andcommitted
feat(audio): smarter audiobook resume when the RFID card is pulled
Two related refinements to what resume point an audiobook persists when its card leaves the reader: - Don't resume a barely-started book: pulling the card within the first `minResumeSec` seconds (new General -> options setting, NVS key `minResumeSec`, default 20 s, 0 = off) resets the resume point to the very start instead of saving the couple of seconds played, so a briefly-sampled card starts cleanly from the beginning next time. The clamp lives in AudioPlayer_NvsRfidWriteWrapper and is gated to track 0 of the position-saving audiobook modes, so genuine mid-book progress is never discarded. - Stop-on-removal now saves the exact position: the STOP path persists the audiobook resume point (mirroring the pause-save, skipping TTS) just before stopSong(). So "stop instead of pause on removal" returns to the idle animation AND continues exactly where you pulled the card on re-apply, instead of relying on the up-to-30 s periodic checkpoint. Web UI (new number field + load/save), en/de/fr locales (incl. an updated stop-on-removal tooltip), the GitHub Pages demo mock and README are all updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 67dffd9 commit 7cacec7

10 files changed

Lines changed: 58 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
247247
| Change | Commit |
248248
| --- | --- |
249249
| Tag removal detected via consecutive-miss counter instead of a wall-clock timeout: pause after ~0.5 s, immune to phantom dropouts and task starvation | [`1fad9cd`](../../commit/1fad9cd) |
250-
| **Stop instead of pause when the tag is removed** (General → options, sub-option of "Pause when RFID tag is removed"): pulling the card fully stops playback (clears the playlist) rather than just pausing; re-applying the card starts it again from the beginning. Persisted in NVS (`stopRfidRem`) and applied live without a reboot | [`dd3b4d7`](../../commit/dd3b4d7) |
250+
| **Stop instead of pause when the tag is removed** (General → options, sub-option of "Pause when RFID tag is removed"): pulling the card fully stops playback (clears the playlist, so the LEDs return to the idle animation) rather than just pausing. The audiobook resume point is now saved at the **exact** moment of removal — not just the last ~30 s periodic checkpoint — so re-applying the card continues right where you pulled it (non-position-saving content still starts over). Persisted in NVS (`stopRfidRem`) and applied live without a reboot | [`dd3b4d7`](../../commit/dd3b4d7) |
251251
| Vendored PN5180 library with fast no-card detection: read attempts on an empty field take ~25 ms instead of ~230 ms (no more 200 ms timeout) | [`8762784`](../../commit/8762784) |
252252
| SLIX2 password support for protected ICODE-SLIX2 tags | [`d3cc69c`](../../commit/d3cc69c) |
253253
| Configurable idle LED and progress bar colors | [`bdc54e5`](../../commit/bdc54e5) |
@@ -261,6 +261,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
261261
| 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) |
262262
| 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) |
263263
| 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) | [`445e290`](../../commit/445e290) |
264+
| Don't resume a barely-started audiobook: if the card is pulled within the first few seconds (new **"Don't resume if pulled within (sec.)"** general setting, NVS key `minResumeSec`, default 20 s, 0 = off), the resume point is reset to the very start instead of saving the couple of seconds played — so a briefly-sampled card starts cleanly from the beginning next time rather than a few seconds in. Gated to the very first track of the position-saving audiobook modes, so genuine mid-book progress is never discarded | [`PENDING`](../../commit/PENDING) |
264265
| **Smart forward/backward seek with press-coalescing** (`CMD_SMART_FORWARDS` / `CMD_SMART_BACKWARDS`, action codes 190/191; also track-control codes 10/11 for MQTT): on an audiobook that is a single long file these commands seek *inside* the file; on a playlist of several files they fall back to next/previous track. Rapid presses are bundled into one jump so the decoder re-syncs once instead of once per press (the old per-press seek felt sluggish on FLAC and dropped presses during the multi-second resync). A forward jump past the end of a single file **clamps to a few seconds before the end and keeps playing** instead of ending the book (`setAudioPlayTime` instead of `setTimeOffset`'s `stopSong`). The step is configurable in the web UI (General → options → **"Smart seek step (sec.)"**, NVS key `seekStep`, default 300 s = 5 min). Bind them to physical buttons in the **Buttons** settings tab. The plain `CMD_SEEK_FORWARDS`/`BACKWARDS` 30 s jump (`jumpOffset`) and the upstream next/previous-track behavior are both unchanged | [`ba3628e`](../../commit/ba3628e) |
265266

266267
### Virtual RFID cards

‎html/locales/de.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,12 +699,14 @@
699699
"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.",
700700
"restartFreshHrs": "Hörbuch von vorn nach (Std.)",
701701
"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.",
702+
"minResumeSec": "Nicht fortsetzen bei Abziehen in (Sek.)",
703+
"minResumeSecExp": "Wird die Karte in den ersten Sekunden eines Hörbuchs wieder abgezogen, wird der Fortsetzungspunkt auf den Anfang zurückgesetzt, statt die paar gespielten Sekunden zu speichern. Eine nur kurz angetippte Karte startet beim nächsten Mal sauber von vorn statt ein paar Sekunden mittendrin. Betrifft nur den allerersten Titel der positions-speichernden Hörbuch-Modi, echter Fortschritt mitten im Hörbuch geht also nie verloren. 0 = aus. Standard: 20. Fork-Funktion.",
702704
"seekStep": "Smart-Spulen Schritt (Sek.)",
703705
"seekStepExp": "Sprungweite in Sekunden für die Befehle „Smart vor/zurück\" (CMD_SMART_FORWARDS/BACKWARDS). Bei einem Hörbuch, das aus einer einzigen langen Datei besteht, spulen diese Tasten innerhalb der Datei; bei einer Playlist aus mehreren Dateien wechseln sie stattdessen den Track. Mehrere schnelle Tastendrücke werden zu einem Sprung gebündelt (nur ein Neu-Puffern statt vieler). Standard: 300 (5 Min.). Fork-Funktion.",
704706
"pauseIfRfidRemoved": "Pause wenn RFID-Tag entfernt",
705707
"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>.",
706708
"stopIfRfidRemoved": "Bei Entfernen stoppen statt pausieren",
707-
"stopIfRfidRemovedExp": "Wenn aktiv (nur in Kombination mit „Pause wenn RFID-Tag entfernt“): Beim Abziehen der Karte wird die Wiedergabe vollständig gestoppt statt nur pausiert. Erneutes Auflegen startet die Karte von vorn.",
709+
"stopIfRfidRemovedExp": "Wenn aktiv (nur in Kombination mit „Pause wenn RFID-Tag entfernt“): Beim Abziehen der Karte wird die Wiedergabe vollständig gestoppt statt nur pausiert, die LEDs kehren also zur Idle-Animation zurück. Bei Hörbüchern wird die genaue Position im Moment des Abziehens gespeichert, sodass erneutes Auflegen genau dort weitermacht, wo du aufgehört hast; anderer Inhalt startet von vorn.",
708710
"dontAcceptRfidTwice": "Denselben RFID-Tag nicht erneut akzeptieren",
709711
"resumeOnSameRfidExp": "Wenn die Wiedergabe pausiert ist und derselbe RFID-Tag erneut aufgelegt wird, wird die Wiedergabe fortgesetzt. Funktioniert nur, wenn <em>Denselben RFID-Tag nicht erneut akzeptieren</em> aktiv ist.",
710712
"playLastRfidOnReboot": "Letzten RFID-Tag nach Neustart abspielen",

‎html/locales/en.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,12 +699,14 @@
699699
"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.",
700700
"restartFreshHrs": "Restart audiobook after (hrs)",
701701
"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.",
702+
"minResumeSec": "Don't resume if pulled within (sec.)",
703+
"minResumeSecExp": "If the card is removed within the first few seconds of an audiobook, the resume point is reset to the very beginning instead of saving the couple of seconds played. So a card that was only briefly sampled starts cleanly from the start next time rather than a few seconds in. Only affects the very first track of position-saving audiobook modes, so real mid-book progress is never discarded. 0 = off. Default: 20. Fork feature.",
702704
"seekStep": "Smart seek step (sec.)",
703705
"seekStepExp": "Jump distance in seconds for the \"smart forward/backward\" commands (CMD_SMART_FORWARDS/BACKWARDS). On an audiobook that is a single long file these buttons seek within the file; on a playlist of several files they change tracks instead. Several quick presses are coalesced into one jump (a single re-buffer instead of many). Default: 300 (5 min). Fork feature.",
704706
"pauseIfRfidRemoved": "Pause when RFID tag is removed",
705707
"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>.",
706708
"stopIfRfidRemoved": "Stop instead of pause on removal",
707-
"stopIfRfidRemovedExp": "When enabled (only together with “Pause when RFID tag is removed”): removing the tag fully stops playback instead of just pausing. Re-applying the tag starts it again from the beginning.",
709+
"stopIfRfidRemovedExp": "When enabled (only together with “Pause when RFID tag is removed”): removing the tag fully stops playback instead of just pausing, so the LEDs return to the idle animation. For audiobooks the exact position is saved the moment the tag is removed, so re-applying it continues right where you left off; other content starts over.",
708710
"dontAcceptRfidTwice": "Don't accept the same RFID twice",
709711
"dontAcceptRfidTwiceExp": "RFID tags are not accepted twice in a row. Cannot be used together with <em>Pause when RFID tag is removed</em>.",
710712
"playLastRfidOnReboot": "Play last RFID tag on restart",

‎html/locales/fr.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,12 +688,14 @@
688688
"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.",
689689
"restartFreshHrs": "Redémarrer le livre audio après (h)",
690690
"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.",
691+
"minResumeSec": "Ne pas reprendre si retirée en (s)",
692+
"minResumeSecExp": "Si la carte est retirée pendant les premières secondes d'un livre audio, le point de reprise est remis au tout début au lieu d'enregistrer les quelques secondes lues. Une carte juste effleurée redémarre proprement depuis le début la prochaine fois, plutôt que quelques secondes plus loin. N'affecte que la toute première piste des modes livre audio qui enregistrent la position ; la vraie progression au milieu du livre n'est donc jamais perdue. 0 = désactivé. Par défaut : 20. Fonction du fork.",
691693
"seekStep": "Pas d'avance rapide intelligente (s)",
692694
"seekStepExp": "Distance de saut en secondes pour les commandes « avance/recul intelligent » (CMD_SMART_FORWARDS/BACKWARDS). Pour un livre audio constitué d'un seul fichier long, ces touches avancent à l'intérieur du fichier ; pour une liste de plusieurs fichiers, elles changent de piste. Plusieurs appuis rapides sont regroupés en un seul saut (une seule remise en mémoire tampon au lieu de plusieurs). Par défaut : 300 (5 min). Fonction du fork.",
693695
"pauseIfRfidRemoved": "Mettre en pause si la balise RFID est retirée",
694696
"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>.",
695697
"stopIfRfidRemoved": "Arrêter au lieu de mettre en pause",
696-
"stopIfRfidRemovedExp": "Si activé (uniquement avec « Mettre en pause si la balise RFID est retirée ») : retirer la balise arrête complètement la lecture au lieu de la mettre en pause. Reposer la balise redémarre depuis le début.",
698+
"stopIfRfidRemovedExp": "Si activé (uniquement avec « Mettre en pause si la balise RFID est retirée ») : retirer la balise arrête complètement la lecture au lieu de la mettre en pause, les LED reviennent donc à l'animation de veille. Pour les livres audio, la position exacte est enregistrée au moment du retrait, de sorte que reposer la balise reprend précisément où vous vous étiez arrêté ; les autres contenus recommencent depuis le début.",
697699
"dontAcceptRfidTwice": "Ne pas accepter la balise RFID deux fois",
698700
"dontAcceptRfidTwiceExp": "Ne pas accepter la même balise RFID deux fois. Ne peut pas être utilisé avec <em>Mettre en pause si la balise RFID est retirée</em>.",
699701
"playLastRfidOnReboot": "Lire la dernière balise RFID au redémarrage",

‎html/management.html‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2725,6 +2725,14 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
27252725
data-i18n="[data-bs-content]general.options.restartFreshHrsExp" tabindex="0"><i
27262726
class="fas fa-circle-question"></i></a>
27272727
</div>
2728+
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
2729+
<label for="minResumeSec" data-i18n="general.options.minResumeSec"></label>
2730+
<input type="number" class="form-control" id="minResumeSec" name="minResumeSec"
2731+
min="0" max="3600" step="1" style="max-width:7em">
2732+
<a href="#" class="link-secondary" data-bs-toggle="popover"
2733+
data-i18n="[data-bs-content]general.options.minResumeSecExp" tabindex="0"><i
2734+
class="fas fa-circle-question"></i></a>
2735+
</div>
27282736
<div class="col-12 col-md-6 d-flex gap-2 align-items-center mb-1">
27292737
<label for="seekStep" data-i18n="general.options.seekStep"></label>
27302738
<input type="number" class="form-control" id="seekStep" name="seekStep"
@@ -5511,6 +5519,9 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
55115519
if (genSettings.restartFreshHrs !== undefined) {
55125520
$('#restartFreshHrs').val(genSettings.restartFreshHrs);
55135521
}
5522+
if (genSettings.minResumeSec !== undefined) {
5523+
$('#minResumeSec').val(genSettings.minResumeSec);
5524+
}
55145525
if (genSettings.seekStep !== undefined) {
55155526
$('#seekStep').val(genSettings.seekStep);
55165527
}
@@ -6179,6 +6190,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
61796190
savePosRfidChge: $('#savePlayPosRfidChange').prop('checked'),
61806191
savePosPeriodic: $('#savePlayPosPeriodic').prop('checked'),
61816192
restartFreshHrs: Number($('#restartFreshHrs').val()),
6193+
minResumeSec: Number($('#minResumeSec').val()),
61826194
seekStep: Number($('#seekStep').val()),
61836195
playLastRfidOnReboot: $('#playLastRfidOnReboot').prop('checked'),
61846196
pauseIfRfidRemoved: $('#pauseIfRfidRemoved').prop('checked'),

‎src/AudioPlayer.cpp‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,17 @@ void AudioPlayer_Loop() {
872872
(stop, start, next track, prev. track, last track, first track...) */
873873
switch (trackCommand) {
874874
case STOP:
875+
// Persist the audiobook resume point *before* stopping (e.g. the card was pulled in
876+
// stop-on-removal mode) so the next play continues exactly where it left off instead of
877+
// from the last ~30 s periodic checkpoint or the track start. Mirrors the pause-save guard:
878+
// only the position-saving audiobook modes carry a resume point (saveLastPlayPosition), and
879+
// a running TTS announcement is skipped (its ~0 s position would clobber the book's slot).
880+
// getAudioCurrentTime() must be read before stopSong() resets it. A pull within the first
881+
// few seconds is reset to the very start by AudioPlayer_NvsRfidWriteWrapper (minResumeSec).
882+
if (gPlayProperties.saveLastPlayPosition && !gPlayProperties.currentSpeechActive && audio && audio->isRunning()) {
883+
Log_Printf(LOGLEVEL_INFO, trackPausedAtPos, audio->getAudioCurrentTime(), audio->getAudioFileDuration());
884+
AudioPlayer_NvsRfidWriteWrapper(gPlayProperties.playRfidTag, audio->getAudioCurrentTime(), gPlayProperties.playMode, gPlayProperties.currentTrackNumber);
885+
}
875886
audio->stopSong();
876887
trackCommand = NO_ACTION;
877888
Log_Println(cmndStop, LOGLEVEL_INFO);
@@ -1757,6 +1768,20 @@ size_t AudioPlayer_NvsRfidWriteWrapper(const char *_rfidCardId, const uint32_t _
17571768
char firstPart[275] = {0};
17581769
char prefBuf[275];
17591770

1771+
// Don't create a "resume a few seconds in" point when an audiobook is only briefly sampled: if the
1772+
// card is pulled within the first <minResumeSec> seconds of the very first track, persist position 0
1773+
// so the next play starts cleanly from the beginning instead of a couple of seconds in. Gated to the
1774+
// position-saving audiobook modes and track 0, so genuine mid-book progress is never discarded.
1775+
// 0 = off. Default 20 s.
1776+
uint32_t playPosition = _playPosition;
1777+
if (_trackLastPlayed == 0 && _playPosition > 0 && (_playMode == AUDIOBOOK || _playMode == AUDIOBOOK_LOOP || _playMode == AUDIOBOOK_RECURSIVE)) {
1778+
const uint32_t minResumeSec = gPrefsSettings.getUInt("minResumeSec", 20);
1779+
if (minResumeSec > 0 && _playPosition < minResumeSec) {
1780+
Log_Printf(LOGLEVEL_NOTICE, "Audiobook pulled within first %u s -> resetting resume point to start", minResumeSec);
1781+
playPosition = 0;
1782+
}
1783+
}
1784+
17601785
gPrefsRfid.getString(_rfidCardId, firstPart, sizeof(firstPart)); // read back previous value from NVS
17611786

17621787
// Remove everything after the first part (after the first stringDelimiter)
@@ -1766,7 +1791,7 @@ size_t AudioPlayer_NvsRfidWriteWrapper(const char *_rfidCardId, const uint32_t _
17661791
}
17671792

17681793
// Build the new string with the preserved first part (which already contains the track)
1769-
snprintf(prefBuf, sizeof(prefBuf), "%s%s%" PRIu32 "%s%d%s%" PRIu16, firstPart, stringDelimiter, _playPosition, stringDelimiter, _playMode, stringDelimiter, _trackLastPlayed);
1794+
snprintf(prefBuf, sizeof(prefBuf), "%s%s%" PRIu32 "%s%d%s%" PRIu16, firstPart, stringDelimiter, playPosition, stringDelimiter, _playMode, stringDelimiter, _trackLastPlayed);
17701795

17711796
Log_Printf(LOGLEVEL_INFO, wroteLastTrackToNvs, prefBuf, _rfidCardId, _playMode, _trackLastPlayed);
17721797
Log_Println(prefBuf, LOGLEVEL_INFO);

‎src/RfidCommon.cpp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,9 @@ void Rfid_HandleCardDetected(const byte *uid, byte *lastValidcardId, const char
206206

207207
if (gPlayProperties.pauseIfRfidRemoved) {
208208
if (gPlayProperties.stopIfRfidRemoved) {
209-
// stop-mode: removal fully stops playback, so any (re)application restarts the card from the beginning
209+
// stop-mode: removal fully stops playback (back to the idle animation). The resume point is
210+
// saved on removal (see the STOP case in AudioPlayer_Loop), so re-applying the card continues
211+
// an audiobook where it left off; non-position-saving content simply starts over.
210212
xQueueSend(gRfidCardQueue, cardIdString, 0);
211213
} else {
212214
#ifdef ACCEPT_SAME_RFID_AFTER_TRACK_END

0 commit comments

Comments
 (0)