Repository navigation
fix: behavioral-audit logic and robustness fixes (back-end, 1/2) - #213
Conversation
- 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
Deploying podnotes with
|
| Latest commit: |
4085c57
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://45ddc4fa.podnotes.pages.dev |
| Branch Preview URL: | https://chhoumann-audit-logic-fixes.podnotes.pages.dev |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
…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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
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.
|
@codex review |
|
Codex Review: Didn't find any major issues. Swish! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
# [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))
|
🎉 This PR is included in version 2.17.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
…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.
…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.
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
defaultPlaybackRate; reject non-finiteapi.volumewrites.feedNoteso a missing template can't crash note creation.isPlayinggate); clamp a forward skip just short of the end so it doesn't auto-advance.Downloads, links, local files
ftyp) so real m4a/mp4 files are recognised.Templates, formatting, feeds
{{date:}}) as the tag default instead of a spurious "invalid tag" Notice.Import/export, transcription, docs
parsererror(works in Obsidian's Chromium); import case-insensitivexmlUrl.getPodcastTimeFormatted'soffsetSeconds.Deferred (intentionally not here)
api.podcastasEpisode | undefined(runtime already returns undefined and all call sites guard; would force non-null assertions everywhere).podcastsUpdatedstore (dead code, no user impact).Verification
lint,format:check,typecheck,build: clean.check:a11y(svelte-check): 0 errors / 0 warnings.{{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.