Skip to content

fix(playback): persist listened time during playback (#33) - #190

Merged
chhoumann merged 2 commits into
masterfrom
chhoumann/33-keep-listened-time
Jun 15, 2026
Merged

chhoumann merged 2 commits into
masterfrom
chhoumann/33-keep-listened-time

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fixes #33 — podcast playback no longer restarts from the beginning after you leave and reopen the app.

The listened position was persisted only on teardown events — the player's onDestroy, an episode switch, or the episode finishing. On mobile the OS kills the backgrounded process without firing onDestroy, so every second listened since the last teardown was lost and the episode resumed from 0. (Also reported "after closing Obsidian" on macOS, where a hard quit can beat the async settings write.)

This PR persists the position during playback, so progress is durable no matter how the app terminates.

Root cause (verified on current master)

EpisodePlayer.svelte only wrote the position via onDestroy / currentEpisode.set / markAsPlayed. There was no timeupdate/interval save during playback. Reproduced in a real isolated Obsidian vault:

  • Played an episode to 137s, waited 3s while "playing" → settings.playedEpisodes stayed {}.
  • On-disk data.json had nothing; a simulated cold restart restored 0 → 137s lost.

The fix (src/ui/PodcastView/EpisodePlayer.svelte)

  • Periodic save while playing — throttled on:timeupdate (SAVE_POSITION_THROTTLE_MS = 5000, leading-edge). An abrupt kill loses at most ~5s.
  • Immediate save on pause — on:pause (the "stop, then leave" path) writes the exact position, bypassing the throttle.
  • Immediate save on background — a visibilitychange → hidden listener captures the exact moment the app is backgrounded / window hidden, before the OS can suspend or kill the process. This is the headline mobile scenario.
  • Teardown save kept — onDestroy still saves, now via the shared persistPlaybackPosition() helper.
  • No clobber before restore — persistPlaybackPosition() skips writes while isLoading is true, so it never overwrites the stored position with the pre-restore 0. (This also repairs a latent bug where onDestroy before metadata load wrote finished: true.)
  • Re-armed on episode switch — isLoading / throttle are reset in the currentEpisode subscription, so an in-player switch (queue click, auto-advance) cannot clobber the next episode's saved resume position. <EpisodePlayer/> has no {#key}, so the instance is reused across switches and the guard would otherwise stay inert.

Writes flow through the existing setEpisodeTime → EpisodeStatusController → saveSettings chain, which already coalesces, so the cadence stays cheap.

Tests (EpisodePlayer.test.ts)

7 tests, mounting the real component and firing real DOM events:

  • throttled periodic save (incl. the exact-5000ms boundary);
  • pause saves immediately and bypasses the throttle;
  • no clobber before metadata loads;
  • episode-switch re-arm cannot clobber the next episode's saved position;
  • visibilitychange → hidden persists immediately.

Runtime verification (real Obsidian, isolated e2e vault)

With a real local audio file driven through the player (no teardown):

  • position persisted to data.json during playback (e.g. time: 27.16 after ~6s) — previously stayed {};
  • on:pause wrote the exact position (423);
  • visibilitychange → hidden wrote the exact position (480) to disk.

Gates

npm run lint, format:check, typecheck, check:a11y, build ✅ — full npm run test passes for this change.

Note: scripts/provision-obsidian-e2e-vault.test.ts fails on master independently of this PR (pre-existing e2e-seed vs DEFAULT_SETTINGS drift); confirmed by stashing this branch's changes. Out of scope here.

Release / migration impact

None. No settings shape, storage, API, or URI changes — only more frequent writes to the existing playedEpisodes map.


Open in Devin Review

CI fix (separate commit)

CI's Test check was red due to a pre-existing failure on master, not this change: scripts/provision-obsidian-e2e-vault.test.ts asserts the e2e seed DEFAULT_PODNOTES_DATA mirrors DEFAULT_SETTINGS, and recent merges (#108 autoQueue, #163 feedNote, #183 download.path) drifted the seed. Filed as #191 and fixed here in a dedicated fix(devx) commit (Closes #191) so this PR's CI can go green — easily separable if you'd prefer it on its own.

Playback position was persisted only on teardown — the player's onDestroy,
an episode switch, or the episode finishing. On mobile the OS kills the
backgrounded process without firing onDestroy, so all progress since the
last teardown was lost and the episode restarted from 0 on reopen (also
reported after closing Obsidian on macOS).

Persist the position periodically while audio plays (5s leading-edge
throttle), immediately on pause, and immediately on visibilitychange→hidden
(app backgrounded / window hidden) — keeping the existing teardown save — so
an abrupt process kill loses at most ~5s. A guard skips writes before the
saved position has been restored (which would clobber it with a pre-restore
0); the guard and throttle are re-armed on every in-player episode switch so
a switch cannot clobber the next episode's saved resume position.

Verified end-to-end in real Obsidian (isolated e2e vault): position now
reaches data.json during playback, on pause, and on background — with no
teardown — and restores on reload.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 15, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 044f2a3
Status: ✅  Deploy successful!
Preview URL: https://c72de8f6.podnotes.pages.dev
Branch Preview URL: https://chhoumann-33-keep-listened-t.podnotes.pages.dev

View logs

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

DEFAULT_PODNOTES_DATA in the e2e provisioning script is a hand-maintained
mirror of DEFAULT_SETTINGS. Recently-merged PRs changed the defaults without
updating the mirror, so the "seeds a data.json that mirrors the real
DEFAULT_SETTINGS" test fails on master — turning the Test CI check red for
every open PR.

Re-sync the three drifted fields:
- add autoQueue: true (#108)
- add the feedNote default (#163)
- set download.path to the per-episode default (#183)

Closes #191.
@chhoumann
chhoumann merged commit e3433c2 into master Jun 15, 2026
2 checks passed
@chhoumann
chhoumann deleted the chhoumann/33-keep-listened-time branch June 16, 2026 06:17
github-actions Bot pushed a commit that referenced this pull request Jun 22, 2026
# [2.17.0](2.16.0...2.17.0) (2026-06-22)

### Bug Fixes

* behavioral-audit logic and robustness fixes (back-end, 1/2) ([#213](#213)) ([4e2845d](4e2845d))
* **download:** default per-episode download path and migrate empty default ([#183](#183)) ([#186](#186)) ([46a6486](46a6486))
* **download:** prevent Android crash and create missing folders on download ([#178](#178)) ([ecd09d5](ecd09d5)), closes [#113](#113) [#86](#86) [#113](#113) [#86](#86)
* **lifecycle:** defer mobile podcast view startup ([#208](#208)) ([8bf9ce4](8bf9ce4))
* **notes:** cap note path length and harden folder creation ([#22](#22), [#87](#87)) ([#192](#192)) ([858e280](858e280)), closes [#87-class](#87)
* **playback:** persist listened time during playback ([#33](#33)) ([#190](#190)) ([e3433c2](e3433c2)), closes [#191](#191) [#108](#108) [#163](#163) [#183](#183)
* **playback:** play local files and downloads on iOS via resource path ([#100](#100)) ([#184](#184)) ([12c503a](12c503a))
* **player:** clear progress on episode switch to stop end-of-playback glitch ([#94](#94)) ([#194](#194)) ([59dccb3](59dccb3))
* **player:** reveal PodNotes view on Play with PodNotes so local files play ([#84](#84)) ([#198](#198)) ([5953625](5953625))
* **settings:** show labelled Add/Remove buttons in podcast search ([#109](#109)) ([#195](#195)) ([33399f5](33399f5))
* show downloaded episodes in the Local Files playlist ([#176](#176)) ([#177](#177)) ([184188c](184188c))
* **timestamps:** capture into the cursor's table cell without breaking the row ([#165](#165)) ([#203](#203)) ([964e342](964e342))
* **transcription:** always transcribe the currently playing episode ([#182](#182)) ([62b488a](62b488a)), closes [#107](#107)
* **uri:** preserve '+' in episode titles and paths for timestamp links ([#181](#181)) ([8ad7aa5](8ad7aa5)), closes [#164](#164)
* **view:** reliably reveal PodNotes view via command + ribbon icon ([#55](#55)) ([#199](#199)) ([fa1d708](fa1d708))

### Features

* add podcast segment links ([#205](#205)) ([d97e59e](d97e59e))
* **api:** expose generated episode transcripts ([e264465](e264465)), closes [#105](#105)
* behavioral-audit UI and interaction fixes (front-end, 2/2) ([#215](#215)) ([894d93d](894d93d))
* **commands:** add playback rate and media timestamp controls ([#206](#206)) ([a8bb44a](a8bb44a))
* **devx:** isolated per-worktree Obsidian E2E vault wrapper ([#188](#188)) ([a8b7a4a](a8b7a4a))
* **episodes:** add a setting to control the Latest Episodes list length ([#114](#114)) ([#200](#200)) ([7b3e3c6](7b3e3c6))
* **notes:** add {{episodelink}} template tag to resume an episode from its note ([#35](#35)) ([#193](#193)) ([8c1ddd6](8c1ddd6))
* **notes:** add podcast feed-level notes ([#163](#163)) ([#187](#187)) ([db0de47](db0de47)), closes [#161](#161) [#160](#160)
* **notes:** ship a Bases-friendly default episode note template ([#160](#160)) ([#201](#201)) ([209431d](209431d)), closes [#163](#163) [#183](#183)
* **player:** scale episode title font size to its length ([#81](#81)) ([#202](#202)) ([659b6b8](659b6b8))
* **player:** support video episode playback ([#209](#209)) ([f92a91f](f92a91f))
* **queue:** add setting to disable queue auto-population and auto-advance ([#108](#108)) ([#185](#185)) ([cf3d73c](cf3d73c))
* **queue:** allow reordering the playback queue ([#80](#80)) ([#179](#179)) ([d994d61](d994d61)), closes [#173](#173)
* **settings:** import/export settings & templates ([#180](#180)) ([a27d23d](a27d23d)), closes [#162](#162) [#162](#162)
* **templates:** add {{currentDate}}, {{episodeNumber}}, {{duration}} template variables ([#189](#189)) ([ec573eb](ec573eb)), closes [#75](#75) [#34](#34) [#88](#88) [163/#186](#186)
* **templates:** add episode chapters tag ([#207](#207)) ([9c98863](9c98863))
* **transcripts:** opt-in speaker diarization for transcripts ([#168](#168)) ([#204](#204)) ([a96e12f](a96e12f))
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Podcast episode not keeping the listened time

1 participant