Skip to content

fix: behavioral-audit logic and robustness fixes (back-end, 1/2) - #213

Merged
chhoumann merged 9 commits into
masterfrom
chhoumann/audit-logic-fixes
Jun 22, 2026
Merged

chhoumann merged 9 commits into
masterfrom
chhoumann/audit-logic-fixes

Conversation

@chhoumann

Copy link
Copy Markdown
Owner

Summary

Part 1 of 2 from a full behavioral audit of the plugin: every feature was written up as a user story, adversarially tested against the code, fixed, then re-verified (unit + real Obsidian). This PR is the back-end / logic half, where changes are unit-covered and carry low visual risk. The UI/interaction half (player, chapters, playlists, context menu, podcast view) follows in a second PR after real-Obsidian verification of each change.

No audit tooling or spreadsheets are included; only code, tests, and doc fixes.

What's fixed

Settings and playback controls

  • Repair persisted skip lengths on load (a cleared field stored NaN/null and corrupted the playback position when skipping); guard the skip API the same way.
  • Self-heal an out-of-range persisted defaultPlaybackRate; reject non-finite api.volume writes.
  • Backfill a partial/legacy feedNote so a missing template can't crash note creation.
  • Make Skip available while paused (drop the isPlaying gate); clamp a forward skip just short of the end so it doesn't auto-advance.
  • Make "Capture Timestamp" go inert when capture is impossible; keep "Transcribe" offered without a key so the missing-key Notice is reachable.
  • Surface failures: Reload PodNotes errors and media-session timestamp-capture errors now show a Notice.
  • Timestamp preview shows a hint when no episode is loaded; the settings-import confirmation is honest about replacing existing feeds/playlists.

Downloads, links, local files

  • Dismiss the download Notice on every terminal state (success and failure).
  • Reject non-media content types (text/*, json, xml) served at a media URL instead of writing them as fake media.
  • Detect ISO-BMFF (ftyp) so real m4a/mp4 files are recognised.
  • Strip only a trailing extension from the download-path template (no longer corrupts folder names that contain the extension string).
  • Warn on a local-file basename collision (signalled by the store, raised by the caller so the store stays side-effect free).
  • Resolve finished local files by composite key so they can be replayed; resolve moved local-file deep links by name instead of failing as a feed.
  • URI handler rejects negative timestamps; "Copy universal link" awaits the clipboard write, guards a missing clipboard, and degrades an empty-title resume link to "".

Templates, formatting, feeds

  • Parse an empty tag argument ({{date:}}) as the tag default instead of a spurious "invalid tag" Notice.
  • Format time in a single pass with backslash escaping, so custom formats containing literal letters are no longer corrupted.
  • Treat a whitespace-only search query as empty (restore the full list).
  • Resolve feed artwork from the channel's direct children so an episode image can't become the feed artwork.
  • Guard the Latest Episodes sort against invalid pubDates.

Import/export, transcription, docs

  • Escape XML attributes and fix the OPML declaration so exports round-trip; detect invalid XML via parsererror (works in Obsidian's Chromium); import case-insensitive xmlUrl.
  • Don't let an imported empty secret clobber a configured API key; validate imported playlist shapes before applying.
  • Fail transcription (write no file) when the transcript body is empty.
  • Align the OpenAI chunk-size wording with the actual 20 MB cap; fix the diarization chunk-index off-by-one in the failure log; document getPodcastTimeFormatted's offsetSeconds.

Deferred (intentionally not here)

  • Typing api.podcast as Episode | undefined (runtime already returns undefined and all call sites guard; would force non-null assertions everywhere).
  • Removing the inert podcastsUpdated store (dead code, no user impact).
  • Graceful empty-value degradation of the default note template (needs template-engine conditional syntax it lacks).

Verification

  • lint, format:check, typecheck, build: clean.
  • check:a11y (svelte-check): 0 errors / 0 warnings.
  • Vitest: 718 passing, including new regression tests for the fixes above.
  • Real Obsidian (isolated e2e vault + dev vault): verified load-time settings migrations (null/negative/out-of-range repaired, feedNote backfilled), skip NaN guard and paused availability, the URI negative-time guard, download content-type rejection, and the {{date:}} template fix end to end.

Follow-up (part 2/2)

UI/interaction fixes: chapter highlight reactivity, progress-bar aria, video fullscreen, keyboard/mobile context menu, playlist name validation and visible names, dropdown reset, reorder touch targets, search/feed loading states. Each will be driven through the dev vault before that PR opens.

- repair persisted skip lengths (cleared field -> NaN/null) on load and
  guard the skip API so a bad value can't corrupt the playback position
- self-heal an out-of-range persisted defaultPlaybackRate; guard api.volume
  against non-finite writes
- backfill a partial/legacy feedNote so a missing template can't crash
  createFeedNote
- make Skip available while paused (drop the isPlaying gate) and clamp a
  forward skip just short of the end so it doesn't auto-advance
- gate "Capture Timestamp" (editorCheckCallback) and keep "Transcribe"
  offered without a key so the missing-key Notice is reachable
- handle Reload PodNotes failure with a Notice; report media-session
  timestamp-capture failures
- show a hint in the timestamp preview when no episode is loaded; make the
  settings-import warning honest about replacing existing feeds/playlists
- dismiss the download Notice on every terminal state (success/failure)
- reject known non-media content types (text/*, json, xml) served at a
  media URL instead of writing them as fake media
- detect ISO-BMFF (ftyp) so real m4a/mp4 files are recognised
- strip only a trailing extension from the download path template (no longer
  corrupts folder names containing the extension string)
- warn on a local-file basename collision (signalled by the store, raised by
  the caller, keeping the store pure)
- resolve finished local files by composite key so they can be replayed
- reject negative timestamps in the URI handler; resolve moved local-file
  deep links by name instead of failing as a feed; normalise local title match
- copy universal link: await the clipboard write, guard missing clipboard,
  and degrade an empty-title resume link to ""
- parse an empty tag argument ({{date:}}) as the tag default instead of a
  spurious "invalid tag" Notice
- format time in a single pass with backslash escaping so custom formats with
  literal letters are no longer corrupted
- treat a whitespace-only search query as empty (restore the full list)
- resolve feed artwork from the channel's direct children so an item image
  can't become the feed artwork
- guard the Latest Episodes sort against invalid pubDates (NaN)
- escape XML attributes and fix the OPML declaration (utf-8) so exports
  round-trip; detect invalid XML via parsererror (works in Chromium)
- import case-insensitive xmlUrl attributes
- don't let an imported empty secret clobber a configured API key; validate
  imported playlist shapes before applying
- fail transcription (no file written) when the transcript body is empty
- align the OpenAI chunk-size wording with the actual 20 MB cap and fix the
  diarization chunk-index off-by-one in the failure log
- document getPodcastTimeFormatted's offsetSeconds parameter
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4085c57
Status: ✅  Deploy successful!
Preview URL: https://45ddc4fa.podnotes.pages.dev
Branch Preview URL: https://chhoumann-audit-logic-fixes.podnotes.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c48aae031

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/commands.ts Outdated
…possible

Revert "Capture Timestamp" to an editorCallback so it stays addable to the
mobile editor toolbar / command picker before an episode is loaded (an
editorCheckCallback that returns false is filtered out, and an e2e test pins
the editorCallback shape). Surface a Notice when capture isn't possible instead
of silently no-opping, preserving the TS-01 feedback intent. Addresses the
Codex review on #213.
@chhoumann

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 988ff298ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/downloadEpisode.ts
…rands

A generic ISO-BMFF major brand (mp42/isom/M4B...) resolves to "mp4" from the
byte signature, which short-circuited inferFileExtensionFromDownload before the
audio content-type fallback, so an audio podcast served as audio/mp4 was saved
as *.mp4 and later treated as an ambiguous audio/video container. Apply the
audio remap (mp4 -> m4a) at the signature step using the content-type/episode
audio hint. Addresses the Codex review on #213.
@chhoumann

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 788f542ed7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/getUniversalPodcastLink.ts Outdated
…okup

Resolve the iTunes/saved-feed collection by a normalized feed-URL match first,
falling back to title only when no URL match exists. Duplicate or rehosted
podcast titles could otherwise return a same-title-but-different-feed result and
resolve the wrong collection. Addresses the Codex review on #213.
@chhoumann

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17d3f58ba9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/API/API.ts Outdated
Comment thread src/downloadEpisode.ts Outdated
…d types

- Skip Forward no longer rewinds when already within the last 0.25s: the
  end-clamp keeps at least the current position instead of moving backward.
- isKnownNonMediaContentType also rejects +xml/+json families (rss+xml,
  atom+xml, problem+json, ...), so an expired feed served at a media URL isn't
  written as audio. Addresses the Codex review on #213.
@chhoumann

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d29664a75b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/getUniversalPodcastLink.ts Outdated
Do the normalized feed-URL lookup before falling back to the podcastName-keyed
entry, so a same-name saved feed pointing at a different feed can't win over the
entry whose URL actually matches the playing episode. Addresses the follow-up
Codex review on #213.
@chhoumann

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: 4085c57e4e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chhoumann
chhoumann merged commit 4e2845d into master Jun 22, 2026
2 checks passed
@chhoumann
chhoumann deleted the chhoumann/audit-logic-fixes branch June 22, 2026 05:39
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 📦🚀

chhoumann added a commit that referenced this pull request Oct 13, 2026
…s pages (#447)

* test(verify): capture the v3 pages at the design's widths and themes

screens.mjs places the view at sidebar 320, pane 820 and phone 390,
switches theme, seeds a scene and shoots the five UI1a states, with a
contact sheet beside the prototype's screen. fixtures.mjs gains a
library manifest of eight podcasts, one whose feed answers 404.

* refactor(ui): add library actions, episode row model and menus as data

The v3 pages change the library only through ShellServices.actions
(src/services/library.ts), which carries the rules PodcastQueryGrid and
spawnEpisodeContextMenu kept to themselves: private-feed interning,
namesake refusal, setting membership instead of toggling it. Episode rows
render one pure model (episodeRow.ts), and the episode and podcast menus
are built as data that one renderer shows (menus.ts), anchored in the
pointer's own document. Head.libraryActions becomes Head.action, so the
wide Library gets Refresh all and a podcast's page its menu button.

The legacy components still run beside these until the pages replace them.

size: 100097 gzip (+1337 before the legacy deletions)

* feat(ui): first run and Add podcast pages in the view

First run is the empty library's page: the mark, "Add your first
podcast", a focused "Search or paste a link" field, and quiet buttons to
play a vault file or import OPML. Its first keystroke opens Add podcast,
whose field sits in the head with the text and the caret kept. Results
name each show's author and episode count; a pasted RSS URL or Apple
Podcasts link resolves to one result with a chip naming its source. A
tap adds the show and opens its episodes; an added show shows a check
and opens. Failures read as fixed lines, never an error's text, and only
the newest input may publish.

Search podcasts leaves the settings tab: PodcastQueryGrid and
PodcastResultCard are deleted, their rules live in LibraryActions and
PodcastSearch, and their tests moved to AddPodcastPage.test.ts and
library.test.ts. The settings tab and First run share pickFile, and the
file menu and the vault media picker share playLocalFile.

size: 98908 gzip (-1189 against the contracts commit)

* feat(ui): Library grid with title tiles, error badges and the podcast menu

The Library's grid is the shell's .pn-grid, sized by the view's own
layout classes: 84 px tiles compact, 108 px wide, three columns on a
phone, with no square backdrop behind rounded art. A tile whose artwork
is missing or fails shows the podcast's title on a plain tile instead of
initials or a blank square, and a podcast whose feed did not load wears
a badge and says so in its label. Right-click or a long press opens the
podcast menu. The wide Library's head refreshes every feed.

PodcastGrid and PodcastGridCard are deleted; the Playlists page uses the
same grid.

size: 98703 gzip (-205)

* feat(ui): podcast episodes page and one episode row for every list

A podcast's page is headed by its artwork, title and author. A feed that
did not load shows a "Feed didn't load" banner naming its server, never
the address or the error, and keeps the cached episodes under it; Retry
reloads, and a private feed whose address is not on this device offers
"Set feed address" instead. The filter bar searches, hides played
episodes and refreshes. Rows show played, in progress with the time
left, playing or paused, unavailable, downloaded and has note, and say
it in their label. Titles clamp at two lines and the More button sits
beside them, never over them (#371). Tapping a row plays it and opens
the player; the current episode only opens the player. More, right-click
and long press open the episode menu.

Latest, Queue, Local files, Played and a playlist render through the
same page and row. The player's queue uses the row and the menus too.
EpisodeList, EpisodeListItem, EpisodeListHeader, spawnEpisodeContextMenu
and ImageLoader are deleted, with the shell's legacy scroller overrides;
the libraryContext guard now allows only MainView.

size: 98171 gzip (-532)

* feat: open PodNotes once when the mobile app enables it for the first time

A new install, a start that found no data.json, opens the view in the
mobile app as it already does on desktop, so the empty view's search is
the first thing a new user meets (#346). PodNotes writes data.json on
that start, so later mobile starts stay dormant as before.

size: 98200 gzip (+29)

* refactor: drop the download check and unavailable reason no list reads

The episode row decides "downloaded" from the library it is given, and
an unavailable Played record says so in its row's label, so
downloadedEpisodes.isEpisodeDownloaded and EpisodeListEntry's
unavailableReason have no reader left.

size: 98167 gzip (-33)

* refactor(ui): share formatMinutes with the mini player

The mini player and episode rows say time left the same way, from one
function. Two comments that restated their code go.

size: 98177 gzip (+10)

* refactor(ui): play a vault file through the shared PickerModal

The merge brought PickerModal from #442, so "Play a vault file" offers
the vault's audio and video files through it instead of a second
FuzzySuggestModal subclass.

size: 99115 gzip (-38 against the merge, 99153; master e3243c4 is 99768)

* perf(notes): read the vault once for a list's episode notes

Episode rows ask whether their episode has a note, and since #442 that
lookup reads every note's frontmatter for its guid. A podcast's page asks
for all its rows at once, which read the vault once per episode. The
notes with a guid are now read once per task and read again in the next,
so a note created or synced since is still found.

size: 99155 gzip (+40)

* fix(ui): wrap long episode, result and card titles inside their buttons

Obsidian's button style sets white-space: nowrap, which episode rows,
search results and grid cards inherited, so a long title stayed on one
clipped line instead of taking the two-line clamp. Live at 320 px the
#213 Field Notes title now takes two lines ending in an ellipsis beside
More options.

npm run size: 99,109 gzip (master e3243c4: 99,768).

* fix(ui): report a podcast's feed failure only in its page's banner

The banner names the failure, keeps the cached episodes and offers Retry
or Set feed address, so the podcast page no longer also raises the old
"Could not load episodes" and private-address Notices.

* fix(ui): keep Back on the wide Add podcast page reached from First run

From the rail the page has nothing before it and stays without Back.

* fix(ui): leave Copy episode link out when its target carries a feed secret

An episode opened through an obsidian://podnotes URI for a private feed
that is not subscribed carries the feed's own credential-bearing
address, which the episode link embeds. The menu now offers no link for
such an episode. A subscribed private feed's link keeps its placeholder,
and the URI format is unchanged.

npm run size: 99,118 gzip (master e3243c4: 99,768).

* fix(ui): count an episode as current only when its media is the player's

Two episodes of one feed that share a title share a key, so after one
played, tapping the other only opened the player and both rows showed
as playing. A row, Play and Open player now count an episode as current
only when it has the player's key and the same media, as the player's
own reuse rule does. The queue keeps its key identity.

npm run size: 99,135 gzip (master e3243c4: 99,768).

* fix(ui): offer only Mark as played for a Played record that left every feed

The placeholder for such a record has no media or link, so Create note
and Add to Favorites made a note and a favorite of nothing playable. As
on master, its menu now holds only Mark as played or unplayed. Leaving
early for it also drops the menu's other availability checks.

npm run size: 99,135 gzip (master e3243c4: 99,768).

* fix(ui): keep the row note icon current, and cheap enough for every row

Each row asked getPodcastNote, whose title-overlap scan reads the whole
notes folder for every row without a note, on mount and on each filter
keystroke, and the icon never changed after a note was created or
deleted. Rows now ask findExactPodcastNote, the guid index and the
template's paths only, through a lookup the view replaces when a note
is created, deleted or renamed. The view registers those vault events
once the layout is ready, since the vault reports every file as created
while it loads, and Obsidian removes them when the view closes. Edits
do not refresh rows. The menu and opening a note keep the full lookup.

npm run size: 99,219 gzip (master e3243c4: 99,768).

* test(e2e): drive the journeys through the new Library, Add podcast and podcast pages

Podcasts are added on the view's Add podcast page and removed from the
podcast menu, Set feed address lives in the podcast page's banner and
menu, and the episode menu uses the new labels. A journey that waited on
the old Updating banner waits on feed status instead, the offline
failure is asserted through the banner, the Library scroll journey has
enough podcasts to scroll the denser grid, and the restored current
episode's row opens the player, where play resumes it.

* test(e2e): first run to playing, pasted feed and Apple links, and a feed error with Retry

The fixture server can fail a feed until it is restored, and the iTunes
stub answers lookups, so an Apple Podcasts link resolves offline.

* test(e2e): the episode menu's order, playlist submenu, Mark as played and Open player

* test(e2e): a new install on mobile opens PodNotes once, on First run

J-ONB-02 splits into a true new install, with no data.json and no
PodNotes leaf, and a later mobile start, which stays closed.

* fix(ui): size the text cover's padding against the cover, not its row

A percentage padding resolves against the containing block, so a 44 px
result cover in a 500 px row took about 48 px of padding a side, grew to
97 px and lost its title. The padding is now 10cqw on the title, the
cover being its inline-size container, and the title sits left as in
the prototype. Rendered in headless Chrome with Obsidian 1.14.4's
app.css at a 500 px view: grid card 94x94, result row 44x44, hero
64x64 and episode row 40x40, each showing its title (before: 97x97,
100x100 and 95x95 with a 0x0 title). The title is now 12% of the cover,
near the prototype's 12.5cqw, so a grid card's reads 11.2 px, not 9 px.

npm run size: 99,221 gzip (master e3243c4: 99,768).

* docs(verify): drive feeds through the view's pages and add a new-install recipe

Adding a podcast happens on the view's Add podcast page, removing it in
the podcast menu, and Set feed address in the podcast page's banner or
menu. The New install recipe disables PodNotes, deletes its data.json
and enables it again, optionally under mobile emulation.

* fix(ui): list a Played search's best match first

The Played page filtered its own order by the search's results, so a
search for "reading" listed a newer "Reading slowly, again and again"
above "Reading". It now lists the search's results in their order, as
master's filterPlayedEntries did.

npm run size: 99,237 gzip (master e3243c4: 99,768).

* fix(ui): leave played episodes out of the player's queue while hidden

Master's queue in the full player followed the hide played preference,
as every list did. The rows that replaced it listed every queued
episode. The queue now reads the preference from the library context.

npm run size: 99,257 gzip (master e3243c4: 99,768).

* test(ui): move a queued episode up from the player's queue menu

The test rendered the player over untyped services with no actions and
only looked for the item's title, so it passed with a reorder that did
nothing. It now uses the test services, chooses Move up in queue and
checks the queue's order.

npm run size: 99,257 gzip (master e3243c4: 99,768); tests only.

* fix(services): say a private feed failed to load without its error's text

Set feed address showed "Could not load feed: " and the error's message,
which can carry the address being set, a secret. It now shows one fixed
line, "Could not load the feed at that address."

npm run size: 99,240 gzip (master e3243c4: 99,768).

* fix(main): log a failure to open the view on a new install's first mobile start

The first mobile enable opened the view with no rejection handler, so a
failure became an unhandled rejection. It is now logged as the desktop
startup path logs it. A new test starts on mobile with no data.json,
then starts again on the data.json that first start wrote, and proves
the view opens only the first time.

npm run size: 99,244 gzip (master e3243c4: 99,768).

* fix(ui): start First run over on Back, and open Add podcast only on text

Back from Add podcast to First run showed the old query, and a search
still in flight could publish its results. First run now clears the
search when it opens. Spaces alone on First run no longer open an idle
Add podcast page.

npm run size: 99,260 gzip (master e3243c4: 99,768).

* fix(ui): list nothing on a podcast or playlist page whose list is gone

A podcast page whose feed was removed, or a playlist page whose playlist
was, fell back to Latest episodes until the shell left the page. Each
page now lists only its own episodes, and none once they are gone.

npm run size: 99,269 gzip (master e3243c4: 99,768).

* fix(ui): keep the full player's artwork square on mobile

The artwork is the full player's play and pause button. It was a square
made of padding-bottom, which Obsidian's mobile button padding overrides,
so on a tablet or phone it collapsed to an 8 px strip. An aspect-ratio
keeps it square whatever the button's padding.

npm run size: 99,111 gzip (master e3243c4: 99,768).

* fix(ui): refresh a restored view's note icons at layout ready, once per burst

A view restored at startup lists its rows before Obsidian has indexed the
vault, and the rows only looked their notes up again on a later vault event,
so a note found by its GUID kept a missing icon for a quiet session. The view
now registers its vault listeners when it mounts, so closing it removes them
even before the layout is ready, ignores the files Obsidian reports while it
loads the vault, refreshes the rows once at layout ready, and collapses a
burst of changes into one lookup after 200 ms of quiet.

The test mock's Component and ItemView follow Obsidian's lifecycle: open
loads before onOpen, close unloads before onClose, and a callback registered
after unload never runs.

Size: main.js gzip 99,302 bytes (was 99,271), raw 312,785.

* docs(verify): wait for the Add result that a pasted feed shows

* test(e2e): J-LIST-04 waits for the Loading episodes line to leave both pages

* test(e2e): J-FEED-05 removes a podcast on another device while Latest Episodes stays open

* test(e2e): Open player on the playing episode keeps it playing and advancing

* test(ui): iterate the fake vault's listeners without copying them

* refactor: drop comments that restate the code they sit on

Keeps only the whys the code cannot show: security, races, platform
behavior and interface contracts.

* fix(ui): drop a pending note lookup when the view closes, and name the result's episode count

size: 99,317 gzip

* test(ui): a same-titled episode without a downloaded copy shows as not downloaded

* fix(ui): call an episode downloaded only when playback would play its copy

A row and its menu matched a download by podcast and title, so a same-titled episode of the same podcast showed the other's download, and its Remove download would delete that file, while playback streamed it. The row, the menu and playback now share one matcher, downloadedCopy.

* test(ui): listed episodes look their notes up again when the note path setting changes

* fix(ui): show a listed episode's note once the note path setting points at it

Rows looked their notes up again only for vault changes, so after a change to Note creation file path a row kept its old state while its menu offered Open note. The field now announces the change through the plugin store, as the queue toggle does, and the view looks again when the note path changes. The deferred-view test now opens and closes its view through load and unload, as Obsidian does.

* test(ui): the search takes focus when its view first shows, not while it is hidden

* fix(ui): focus the search field once its view is shown

On a new mobile install the view mounted inside a hidden leaf, so the field's focus call failed and typing did nothing until a tap. An autofocus field now takes focus the first time it has a width, once.

* test(ui): build the note-path test's note as Obsidian's TFile type allows

* test(e2e): onboarding types into First run without tapping the field first

* test(e2e): onboarding waits for the search field's focus before typing

Typing came right after First run showed, so it could reach the window before the field took focus on the next frame. The journeys now wait until the field has focus, as a user waits for the caret, and fail by name when it never does.

* test(e2e): write the mobile new-install eval's plugin values with jsLiteral

* test(e2e): find a Back button by its label anywhere in the view

The head's Back is .pn-back, or .pn-iconbtn on the Add podcast page, and
the harness probe sits outside the head; the label alone names it.
chhoumann added a commit that referenced this pull request Oct 14, 2026
…466)

* test(verify): shoot the player states for the v3 pixel pass

screens.mjs gains ten player states: now-playing, mini-player, capture,
chapters, up-next, up-next-empty, no-chapters, video, local-player and
media-error, and `set player <label>` runs them. A player state plays the
scene's episode until its media loads, pauses it at a fixed second, saves
it there and opens the full player. capture, chapters and the two up-next
states press the full player's button by its exact text, so they fail at
once on a player without it. `set player` and `set all` go on past a
failed state and fail at the end. seed deletes episode notes, so a
capture cannot change a later state's shots.

fixtures.mjs serves each library episode as silence as long as its feed
says, streamed so an hour costs no memory. #213 carries the prototype's
eight JSON chapters, and Signal & Noise gains a 10 s H.264 test pattern
that ffmpeg makes on first request and an episode whose media answers 404.

ui.mjs gains --exact, so "Chapters" does not also match "Chapters (8)".

* test(verify): wait for PodNotes' unload save before seeding

disablePlugin resolves before the save PodNotes starts on unload, and
that save merges its unsaved edits into data.json. seed wrote the next
scene in that window, so a late save could carry the previous state's
feeds or progress into it. seed now awaits flushSaves() on the disabled
instance, which resolves once the unload save is done.

* fix(player): keep quarter playback rates and share the skip length rule

normalizePlaybackRate rounded to tenths, so 0.75, 1.25 and 1.75 became
0.8, 1.3 and 1.8. It now rounds to hundredths; the rate commands still
step by 0.1. normalizeSkipLength moves from the API to src/utility so the
player can label its skip buttons with the same rule.

size: 98,241 gzip (+2 vs master 98,239)

* refactor(ui): make the Queue page's list a shared QueueList

QueuePage becomes QueueList with an empty label, so the player's Up next
panel can render the same keyed rows and the same one-write drag. A drag
now cancels on Escape through a scope it pushes for its lifetime: inside
the full player, the player's scope takes Escape before a window listener
could hear it. The grip carries data-ignore-swipe, so on a phone a
downward drag starts none of Obsidian's swipe gestures.

size: 98,298 gzip (+59 vs master 98,239)

* refactor(ui): give menus their trigger, a rate menu and a note entry

A menu now knows the control that opened it. Focus goes back there before
a picked item runs, so an item that opens a note keeps the focus it sets,
or when the menu closes with nothing picked, so the next Escape reaches
the view. rateMenu offers 0.75 to 2 with the live rate checked, and
noteEntry is the episode menu's Open note or Create note, for the player's
capture bar to share. The Menu mock keeps its items and can pick or close.

size: 98,347 gzip (+108 vs master 98,239)

* fix(ui): show seconds when less than a minute is left

Rows, the mini player and list totals rounded anything under a minute up
to "1 min", so a nearly finished episode read "1 min left" with seconds
of audio remaining. Under a minute they now show seconds, such as
"45 s left".

* feat(ui): replace the legacy player with the v3 now playing screen

The full player is one Player component: a head with collapse and the
episode menu; a stage with the artwork, a file tile for a local file or
artwork that fails to load (#394), the video with fullscreen, the media
error with Retry, or a panel at compact widths; and a deck with the
title, the podcast or folder link, a chaptered Scrubber, the current
chapter, a rate pill menu, skip buttons labeled with the configured
lengths, play and pause, a volume popover, Timestamp and Create or Open
note (#347), and the Chapters and Up next switch. Wide views put the
panels in a right column with tabs. Up next is the shared QueueList, so
it reorders in place with one write per move. Escape closes the volume
popover before the player, and Space plays and pauses off controls.

EpisodePlayer, PlayerOverlay, ChapterList, Loading, the obsidian Button,
Icon and Slider wrappers, Progressbar, Image and their helpers go. The
e2e targets and the verify skill's player notes follow the new markup.

size: 97,980 gzip (-259 vs master 98,239)

* feat(capture): show every capture in a Notice with an Undo that removes exactly what it inserted

CaptureService now confirms each successful capture, whatever started it (the
player, the mini player, the commands, the headset or lock screen), with an
Undo notice: "Timestamp added to {note}", or "Created {note}" when the
capture created the episode note.

Undo takes back exactly what the capture inserted, and only while it is
still exactly where it was put:
- in an editor, while the same tab still shows the same note in Source or
  Live Preview and the inserted range is untouched; text typed after it
  stays, and a selection the capture replaced comes back;
- in the episode note, while the note still ends with the appended text,
  through the note's open editor when one edits it, else through a
  vault.process that aborts when the end changed. A note the capture created
  is kept; only the timestamp goes.
When the text was edited since, Undo leaves it and says so. The Undo is
disarmed when the plugin unloads (dismissUndoNotices in onunload).

The full player's Timestamp button reads "Added" with a check for 1.2 s
after a capture. The player and mini player no longer show their own
"Timestamp added to" Notice.

size: 98,396 gzip (+157 vs master 98,239)

* test(player): keep quarter and one-decimal playback rates through the API and the saved default

1.25 round-trips through the IAPI playbackRate setter and through the saved
default speed, and a 2.21.1 library's one-decimal default speed (1.3) loads
and saves unchanged with no normalization warning.

size: 98,396 gzip (+157 vs master 98,239)

* refactor(ui): fold the current chapter label's rule into the podcast link's

The tightening pass over the new player measured three candidates against
98,396 gzip:
- folding ChaptersPanel into Player saves 93 bytes, under the 100-byte bar,
  so it stays a component;
- one shared reset for the player's bare buttons costs 3 bytes more than the
  duplicates it removes, so the per-group resets stay;
- the current chapter label's own rule (min-width 0, font-size inherit) merges
  into the podcast link rule it already shares, with no visual change.

size: 98,386 gzip (+147 vs master 98,239)

* fix(ui): keep focus on a menu's control when Escape closes the menu

Obsidian's menu closes on Escape but leaves the key unhandled, and Obsidian
then sends focus from a sidebar view to the main area's most recent leaf.
With the player in the right sidebar and a note open, Escape on the episode
menu moved focus into the note, so the next Escape went to the note instead
of closing the volume slider or the player. The menu now marks the Escape
that closes it as handled, so focus stays on the control that opened it.

size: 98,419 gzip (+180 vs master 98,239)

* test(e2e): drive capture with Undo, the video stage, Up next writes, local artwork and the Escape order

In real Obsidian 1.14.4:
- J-TS-12: the full player's Timestamp goes into a Live Preview note at the
  caret, reads "Added", and Undo through the Notice leaves the note byte for
  byte as before; with no editor open it creates the episode note, Undo keeps
  the note without the line, and a second capture's Undo leaves it as before.
- J-TS-13: the same from the mini player, into a note and into an episode
  note the user has, whose missing final newline Undo takes back too.
- J-TS-14: Capture Timestamp with nothing loaded shows "Play an episode to
  capture" and writes nothing.
- A video episode with 30 queued keeps its 16:9 frame at 320 px, after Up
  next covered the stage, and at 820 px beside Up next.
- Up next reorders by a slow drag and by an arrow key, writing data.json
  once per completed move.
- #394: a local file shows its file tile, never an image, in the player and
  the mini player.
- With a note open in the main area, Escape closes the episode menu, then
  the volume slider, then the player, and focus stays in the view.

size: 98,419 gzip (+180 vs master 98,239)

* docs(verify): describe the capture Notice and its Undo

size: 98,419 gzip (+180 vs master 98,239)

* fix(ui): left-align chapter rows and keep Undo notices to two lines

Obsidian centers a button's content, so chapter rows centered their time
and title instead of starting each at the left as the design shows. A long
note or podcast name made an Undo notice four or five lines tall; it now
stops at two, with Undo beside it.

size: 98,566 gzip (+242 vs master f295af1 98,324)

* test(verify): keep the capture notice in the capture shots

Capture feedback is an Obsidian notice, so the capture state keeps notices
in its shots, fails unless one says "Timestamp added to", and clears older
notices before it presses Timestamp.

* fix(ui): refetch chapters after a failed load and keep a Chapters choice with its episode

A chapters fetch that failed, as offline, was memoized as an empty list, so
Chapters stayed hidden for the session. Only loaded chapters are kept now, and
a reopened player takes them on its first frame instead of showing Up next
until a promise settles. A panel choice resets when the chapters change, so a
Chapters choice no longer comes back on its own on a later episode.

size: 98,605 gzip (+281 vs master f295af1 98,324)

* fix(capture): offer no Undo for a capture that ends after the plugin unloaded

CaptureService awaits the note write before it offers Undo, so a capture
still running when the plugin unloaded showed an armed Undo after
dismissUndoNotices had run. onunload now disposes the service, and a
disposed service offers no Undo.

size: 98,623 gzip (+299 vs master f295af1 98,324)

* fix(ui): give the video frame its own play and pause, and show a media error over an open panel

Fullscreen shows only the video frame, whose one control was Fullscreen. The
frame now carries a play/pause button over the video, showing its icon while
paused or under the pointer, as the replaced player's overlay did. A media
error now takes the stage before a compact panel, so its message and Retry
show when Up next or Chapters was open.

size: 98,770 gzip (+446 vs master f295af1 98,324)

* fix(ui): name the Timestamp button by its text and link the player's tabs to their panel

The Timestamp button's accessible name now follows what it shows, Timestamp
and then Added. The wide player's tabs carry ids and aria-controls, and the
tab panel is labelled by the selected tab. The scrubber shows an empty track,
not width NaN%, before the duration is known, and the menu's Escape comment
says why it holds on desktop and does nothing on a phone.

size: 98,825 gzip (+501 vs master f295af1 98,324)

* test(verify): retry a test pattern whose ffmpeg run failed

A failed run stayed cached as a rejected promise, so every later request for
the video answered 500 until the fixture server restarted.

size: 98,825 gzip (+501 vs master f295af1 98,324)

* test(e2e): drive Space, a refused Undo and the podcast link in real Obsidian

The player journey presses Space with the focus on the player's title and
checks that api.isPlaying flips both ways, then opens the podcast's page from
the link under the title. J-TS-15 writes a line after a capture into the
episode note and checks that Undo leaves both lines and says so. A unit test
covers the link for a saved and an unsaved podcast.

size: 98,825 gzip (+501 vs master f295af1 98,324)

* test(e2e): read the Undo notice's message and the new player's elapsed time

The Undo notice now wraps its message in a span, so dismissNotice found no
text to click and J-FEED-05 and the shared-feed Remove journey timed out.
The synced-progress journeys from master read the replaced player's
.episode-player status line; they read the new player's elapsed time now.

size: 98,825 gzip (+501 vs master f295af1 98,324)

* refactor: drop doc comments that restate their function's name

* fix(ui): keep the latest episode's chapters and switch panels off during a media error

A slower chapters load for an earlier episode could replace the cached
chapters of the episode on screen, so reopening the player fetched again and
showed Up next first. At compact widths a media error takes the stage, so the
panel switches now read off and are disabled until the media loads.

size: 98,854 gzip (+530 vs master f295af1 98,324)

* fix(ui): keep every episode's loaded chapters and keep panel controls off during a media error

A one-entry chapters cache let a slower or earlier load evict the chapters
of the episode on screen, also after a return to that episode. Chapters now
stay per address for the session, so no load order can evict them. During a
media error at compact widths the current chapter label no longer opens a
panel, and disabled switches no longer take the hover style.

size: 98,859 gzip (+535 vs master f295af1 98,324)

* fix(capture): bind Undo to where the capture put its text

Undo found an episode note capture by the note's ending, so after the user
wrote the same line again, an older Undo removed the later line and kept
its own. Undo also refused once the note's tab closed or switched to
Reading view, though the note had not changed.

Each capture now keeps where its text went: the offset, the text and the
selection it replaced. Undo takes the text out of an editor open on the
note, else out of the saved file in one vault.process, and only while that
exact range still holds exactly the inserted text. Text written after the
capture stays. Otherwise Undo leaves the note and says so.

size: 98,943 gzip (+607 vs master b14edf1 98,336)

* test(e2e): drive an older Undo, Undo after the tab closed, and the transport during a media error

* fix(capture): undo only while the note is exactly as the capture left it

Matching the inserted text at its old offset was not identity: a copy of
the capture pasted before it sat at that offset, so Undo deleted the copy.
Undo also refused while a closing tab's save was still pending, with a
message that said the note had changed.

A capture now keeps the note's text before and after it. Undo restores the
text before only while the note, in its open editor or else its saved file,
equals the text after. Any other state refuses, including an edit anywhere
and a save still pending, with a message that holds in every case. In an
editor, Undo replaces only the changed stretch, so the caret and the
editor's own undo history stay.

size: 98,927 gzip (+591 vs master b14edf1 98,336)

* test: refuse Undo after a pasted copy and while a closing tab's save is pending

* test: refuse Undo when the note changed on disk before Obsidian's cache saw it

The unit vault fake now has cachedRead, read, modify and process, with a
cache that a change on disk does not update, as Obsidian's. A unit test and
J-TS-19 change the episode note behind that cache and click Undo: it must
refuse and keep the newer text. Both fail when Undo reads with cachedRead
and writes with modify instead of one vault.process.
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.

1 participant