Skip to content

fix(player): keep playing while you browse, with one player that owns the media - #401

Merged
chhoumann merged 69 commits into
masterfrom
feat/player-controller
Oct 12, 2026
Merged

chhoumann merged 69 commits into
masterfrom
feat/player-controller

Conversation

@chhoumann

@chhoumann chhoumann commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Summary

Playback no longer depends on which PodNotes view is open. One PlayerController (src/player/PlayerController.ts) owns the single media element in the main window from plugin load to unload, and the player view only renders its state and calls its intents.

  • Audio keeps playing while you browse the podcast grid, an episode list or settings, with the PodNotes view closed, and after a popout holding the view closes. Fixes Playback stops when browsing away from the player #355.
  • After a restart the last episode shows paused at its saved position. Opening the player no longer starts it. Fixes Opening the restored player after an Obsidian restart starts audio automatically #377.
  • Playing another episode before the restored one never overwrites its saved position. Only a loaded episode's position is ever saved. Fixes Saved playback position resets to 0 after a restart and an episode switch #353.
  • Commands, timestamp links, context menus, "Play with PodNotes" and the public API work with the view closed. The headphone previous-track capture is unchanged.
  • A link to another show's same-titled episode plays that show's episode. The loaded-episode shortcut now compares the feed (or the vault file) as well as the title.
  • A data.json change synced from another device never loads, seeks, pauses or advances the queue. It also no longer gets this device's loaded episode written back over the other device's selection: only an explicit play, or the queue advancing at a natural end, writes currentEpisode. While nothing is loaded, a sync refreshes the restored episode and position.
  • A queued snapshot plays its own source. Its own vault file plays that file; a registered download plays only when it is a copy of the same media URL; otherwise the stream plays. A missing file is an error, never other audio. A download that finishes mid-play no longer swaps the source.
  • time and endTime in links parse strictly (12abc is refused), api.currentTime = NaN or undefined is ignored, and a seek past the end stops 0.25 s short and pauses instead of finishing the episode.
  • After a media error, a retry starts where the user last put the position (a seek or a link's time) until that position is saved, and otherwise at the saved progress, including progress a sync brought in meanwhile.
  • A link whose feed is still loading never overrides a newer intent: a play, resume, pause, seek, Start or Stop command, media key or OS play/pause, or another link that plays, all win over it, and a stale link shows no error notice. A link refused before its fetch cancels nothing, including a link to a saved private feed whose URL is not on this device.
  • Legacy title-only progress keeps working: a record saved without a podcast name resumes the episode, and another show's same-titled record is ignored (PB-06).
  • Queue reordering takes the episode, not an index.
  • The playback-rate slider shows the real rate. It showed 1.50 at 1x because the slider set its value before its limits.

Every playback store is gone: currentEpisode, currentTime, duration, isPaused, playbackRate, volume, requestedPlaybackTime, activePlaybackSegment, queue.playNext, subscribeQueueToCurrentEpisode, the currentEpisode persistence binding and the republish after a sync. Episode identity stays the released getEpisodeKey (podcast and title) and the Episode snapshot.

Verification

Obsidian 1.13.7 on headless Linux (Xvfb), isolated worktree vault, Node v22.23.2. Feeds are the e2e fixture server and the verify skill's PodNotes Fixture (http://127.0.0.1.nip.io:<port>/feed.xml, 60 s WAV enclosures); video checks used a 60 s WebM made with ffmpeg and "Play as video with PodNotes".

Before (master f7400ba) and after, same journeys. The new journeys in tests/e2e/journey-playback.test.ts fail on master and pass on this branch:

Journey master this branch
#355 audio keeps playing on the grid, an episode list and settings fails: paused on the grid passes
#353 a restored position survives switching episodes before the player opens fails: saved {0, 0} over 30 s passes
Commands, the API and a timestamp link with the view closed fails: reports playing, nothing advances passes
A link to another show's same-titled episode fails: resumes the loaded show's episode passes
A remote data.json change during playback fails: local currentEpisode written back over the remote one passes
#355 an episode that ends while browsing plays the queued one fails: playback stopped passes
#377 J-PLAY-11 restart keeps the saved position without autoplay known-failure marker plain assertions, passes

The journey "Start playing while a slow link to another show loads keeps playing the paused episode" reproduces V-401's finding: run against the pre-fix controller (head 58b77ee) it fails with the other show's episode playing after the delayed feed arrives; it passes on this head.

Recordings with an on-screen probe of api.isPlaying and api.currentTime:

Also checked in Obsidian: headset previous-track capture (existing e2e, with the view closed), a popout holding the view closes while audio keeps playing and data.json stays intact, a reload right after (#370), hiding the main window saves the position at once, a video moved to a popout keeps playing in the main window while the popout shows the artwork, and api.start(); api.stop() in one call leaves the media paused.

Commands on the head commit:

npm run lint            exit 0
npm run format:check    All matched files use the correct format.
npm run typecheck       exit 0
npm run build           exit 0
npm run test            92 files, 1558 tests passed; svelte-check 0 errors, 0 warnings
npm run docs:build      15 pages, internal links valid
npm run size            main.js raw 317,826 (master 315,027, +2,799; budget 330,000)
                        main.js gzip 99,975 (master 99,505, +470; budget 100,000)
npm run test:e2e        11 files, 68 tests passed

git grep -nE "currentTime\.set|isPaused|requestedPlaybackTime|activePlaybackSegment|subscribeQueueToCurrentEpisode|playNext|currentEpisode\.(set|subscribe)|bind:currentTime|bind:paused|<audio|<video" -- src prints nothing: the view-owned media element and its playback store wiring are gone.

Reviewed by an adversarial panel (Claude Opus 5.5 and GPT-6 Astra) over eight rounds on the full diff and each fix diff, and verified independently (V-401 and V-401b, whose findings this head fixes); every accepted finding has a test that fails without its fix.

Release impact

None for storage: no data.json shape change and no migration. settings.currentEpisode is now written only by an explicit selection, so after a sync it keeps the other device's selection instead of this device's loaded episode. The public API (src/API/IAPI.ts) and obsidian://podnotes parameters are unchanged. Behavior differences a user can notice: closing the PodNotes view or a popout no longer stops audio (use Stop or Toggle playback); a seek past the end pauses just before it; isPlaying is also true while an episode is loading.

Fixes #355
Fixes #353
Fixes #377

Note

Replace playback stores with a single PlayerController that keeps playing while browsing

  • Adds PlayerController in PlayerController.ts, which owns one main-window media element and one reactive player state. Playback now continues while browsing views, changing settings, or closing the player view
  • Removes the player-state stores (current episode, time, duration, paused, volume, rate, segment) from index.ts. All callers — API, URI handler, context menus, EpisodePlayer, settings import — now route through the controller
  • Adds selectSource in source.ts, which picks the episode's vault snapshot first, then a matching download, then the stream URL; a missing vault file stays unplayable
  • URI handling now distinguishes same-titled episodes by link target, rejects non-numeric timestamps, and cancels stale async feed results
  • Docs, e2e journeys, and unit tests are rewritten around controller state and a new FakeMedia mock
  • Behavioral Change: startup restores the last episode paused at its saved position; API.isPlaying is true while media loads; forward skips are capped 0.25s before the end and keep playing; importing settings updates the live player's volume and rate

Macroscope summarized 8c9ea1a.

…iew closed, and across sync

Before the player controller these journeys fail on master: browsing stops
audio (#355), switching before the restored player opens zeroes its position
(#353), commands and the API cannot play with the view closed, a link to a
same-titled episode of another show resumes the loaded one, and a remote
data.json change gets the local currentEpisode written back over it.
Plays the snapshot it is given: its own vault file, else a downloaded copy
of the same media, else the stream. A missing file is an empty source,
never other audio.
…achine

The controller owns the single media element in the main window and exposes
one read-only state plus intents. Only an explicit play selects an episode;
restore, resume and progress never write settings.currentEpisode. Progress is
saved only once the current load's metadata set the start position.
A range input snaps its value to the step from its minimum. Setting the
value under the default step of 1 and then min 0.5 turned a playback rate
of 1 into 1.5 on the rate slider.
…ores

Every caller now plays, pauses, seeks and reads playback through the
controller: the API, commands, URIs, menus, Play with PodNotes, the view and
settings import. The player view only renders the controller's state, so
audio keeps playing on other tabs, with the view closed and after a popout
closes. Restoring at startup no longer autoplays or loads media, and playing
another episode first no longer overwrites the restored one's position.

The queue's reorder moves now name the episode instead of an index. A link
to the loaded episode must name its feed or file as well as its title, and a
link whose feed resolves after a newer play does not play.
…playback contract

The journeys find the one media element by its class in the main window.
J-PLAY-11 asserts the restored episode stays paused and then resumes from its
saved position. The runtime helpers that faked the element are gone: links
play real media and are checked within a tolerance. Closing a popout keeps
the audio playing, and a reload right after keeps data.json intact. A synced
selection now becomes this device's selection while what plays stays.
…rules

The player keeps playing off its tab and with the view closed, and restores
the last episode paused. The API page covers isPlaying while loading, length
before metadata and seek validation; the URI page covers strict time parsing.
The verify skill's player gotchas match the new behavior.
A video stage carries the media element into the popout's document, and the
browser reloads a media element that changes documents: position 0, paused.
The controller now reads and saves the element's position only while it
holds the current load's media, and on an emptied it did not cause it parks
the element back in the main window and starts it again where it was,
playing if it played.
Closing the view no longer stops playback, so a playback checkpoint, or the
position saved when PodNotes is disabled, could write data.json while the
harness rewrote it, and the two writes interleaved into invalid JSON.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 8c9ea1a
Status: ✅  Deploy successful!
Preview URL: https://9a6c59dd.podnotes.pages.dev
Branch Preview URL: https://feat-player-controller.podnotes.pages.dev

View logs

@chhoumann
chhoumann marked this pull request as ready for review October 11, 2026 12:34
Comment thread src/URIHandler.ts
// Taken once the link passed every check, so a refused link cancels nothing,
// and a newer link or play wins.
const request = ++latestRequest;
play = player.deferPlay();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/URIHandler.ts:214

A feed request that fails after the user pauses, seeks, or starts another playback still shows “Could not load the podcast feed”; a title miss likewise shows “Episode not found” even though that player intent superseded the request. Those intents invalidate PlayerController’s deferred-play request, not latestRequest, and both notices are handled before play(...) checks that cancellation. Check the controller’s cancellation token before reporting fetch errors or title misses.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/URIHandler.ts around line 214:

A feed request that fails after the user pauses, seeks, or starts another playback still shows “Could not load the podcast feed”; a title miss likewise shows “Episode not found” even though that player intent superseded the request. Those intents invalidate `PlayerController`’s deferred-play request, not `latestRequest`, and both notices are handled before `play(...)` checks that cancellation. Check the controller’s cancellation token before reporting fetch errors or title misses.

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

Labels

None yet

Projects

None yet

1 participant