Skip to content

fix(playback): play local files and downloads on iOS via resource path (#100) - #184

Merged
chhoumann merged 3 commits into
masterfrom
chhoumann/100-local-files-ios
Jun 15, 2026
Merged

chhoumann merged 3 commits into
masterfrom
chhoumann/100-local-files-ios

Conversation

@chhoumann

@chhoumann chhoumann commented Jun 15, 2026 •

Copy link
Copy Markdown
Owner

Summary

Local audio files and downloaded episodes could not be played on iOS — the player never started — while streaming worked (issue #100, filed Jul 2024).

Root cause. Both local files and downloaded episodes were played from in-memory blob: URLs (URL.createObjectURL), while streaming used the raw https:// URL. iOS's WKWebView <audio> element loads media out-of-process and cannot read an in-memory blob: URL, so playback silently failed on iOS. Desktop (Electron/Chromium) plays blob: audio fine, which is why the bug was iOS-only and never reproduced on desktop.

The player code path is identical for streaming vs. local/downloaded except for the value bound to <audio src> — getSrc() returns a real URL for streaming (works on iOS) vs. a blob for local/downloaded (fails). That isolates the blob URL as the cause.

Fix. Resolve vault files with app.vault.getResourcePath(file) — the same native-served, byte-range-capable URL Obsidian uses for its own ![[media]] embeds (which play on iOS). The change lives in the shared createMediaUrlObjectFromFilePath helper, so both call sites (getContextMenuHandler and EpisodePlayer.getSrc) are fixed with no player-UI changes. It also stops reading the entire audio file into memory as an ArrayBuffer just to play it.

Changes

  • src/utility/createMediaUrlObjectFromFilePath.ts — blob URL → getResourcePath; removed the now-dead BlobUrlManager.
  • src/main.ts — removed the blobUrlManager import + revokeAll() unload wiring (its only consumer).
  • src/getContextMenuHandler.ts — fixed the "Play with PodNotes" extension check: the inline regex /mp3|…|/ had a trailing empty alternative that matched every file, so the menu item appeared on all files. Replaced with an explicit PLAYABLE_EXTENSIONS set.
  • src/utility/createMediaUrlObjectFromFilePath.test.ts — new unit tests (forwards resource URL; "" for missing/non-file; non-mp3 extensions).
  • docs/docs/local_files.md — note that local/downloaded files play on all platforms.

Verification

  • Desktop (real dev vault, Obsidian CLI eval): both the blob URL and getResourcePath play (loadedmetadata, duration: 3, readyState: 4) — confirming desktop cannot reproduce the iOS-only failure and that the resource URL is a valid, playable source.
  • Gates (Node 22, npm): lint, format:check, typecheck, build, test (248 passed / 27 files, incl. EpisodePlayer.test.ts) all green. docs:build not run locally (mkdocs unavailable in the environment; change is one prose line, no nav/reference changes).

⚠️ Pre-merge verification needed (iOS device)

This fix is reasoned and desktop-validated but not device-proven. The exact URL getResourcePath returns on iOS could not be confirmed here (the typings document resourcePathPrefix as file:/// on mobile). It is the same API behind Obsidian's own iOS-working media embeds, so it is very likely correct — but before relying on it, please confirm on a real iPhone: log app.vault.getResourcePath(tfile) for a local audio file and verify <audio> fires loadedmetadata (readyState >= 2).

Closes #100


Open in Devin Review

#100)

Local files and downloaded episodes were played from in-memory blob: URLs,
which iOS WKWebView cannot play: its media element loads audio out-of-process
and cannot read an in-memory blob, so playback silently failed on iOS while
streaming (a real https URL) worked. Desktop (Electron/Chromium) plays blob
audio fine, which is why the bug was iOS-only.

Use app.vault.getResourcePath() — the same native-served, byte-range-capable
URL Obsidian uses for its own media embeds — for both local and downloaded
playback. This also stops reading the entire audio file into memory as an
ArrayBuffer just to play it.

- Replace blob URL with getResourcePath in createMediaUrlObjectFromFilePath;
  remove the now-dead BlobUrlManager and its unload wiring in main.ts
- Fix the context-menu extension check that matched every file (the inline
  regex had a trailing empty alternative)
- Add unit tests for the resource-path helper
- Note cross-platform playback in the local files docs

Closes #100
@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying podnotes with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0ffda18
Status: ✅  Deploy successful!
Preview URL: https://77f2011c.podnotes.pages.dev
Branch Preview URL: https://chhoumann-100-local-files-io.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: 941a7983a9

ℹ️ 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 on lines +17 to +26
const PLAYABLE_EXTENSIONS = new Set([
"mp3",
"mp4",
"wma",
"aac",
"wav",
"webm",
"flac",
"m4a",
]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include all supported local audio extensions

For local files with extensions that the rest of PodNotes already treats as audio, such as .ogg and .amr (detectAudioFileExtension and getExtensionFromContentType both return those extensions), this new allow-list suppresses the Play with PodNotes menu item entirely. The old regex was overly broad, but replacing it with this incomplete set regresses users who right-click an existing OGG/AMR audio file in the vault; those files can no longer be added to the Local Files playlist or played through the local-file flow.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 1-line follow-up: PLAYABLE_EXTENSIONS now includes ogg and amr (and stays in sync with detectAudioFileExtension / getExtensionFromContentType), so right-clicking those files offers "Play with PodNotes" again while still suppressing the menu on non-audio files.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

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

Open in Devin Review

…files docs

Review follow-up (PR #184). The helper no longer does any async work after the
blob->getResourcePath change, so drop the now-misleading `async`/Promise wrapping
and return a plain string; the awaiting call site is simplified accordingly. Also
reword the local-files docs to describe the native resource-path mechanism rather
than asserting unverified cross-platform coverage.
@chhoumann

Copy link
Copy Markdown
Owner Author

Review summary — adversarial + ultracode

Ran two independent review tracks on this implementation:

  • 3 adversarial reviewers on the opposing model (Codex) — Skeptic / Architect / Minimalist lenses: all PASS (no high-severity findings). They independently confirmed a repo-wide grep finds zero remaining createObjectURL / revokeObjectURL / blobUrlManager references, so the BlobUrlManager removal is fully contained.
  • Ultracode review (multi-lens + adversarial verification of each finding): ready to merge, no confirmed blockers/majors. The lone "major" (helper declared async with no await) was downgraded to minor on verification. getResourcePath is correctly called with a TFile behind the instanceof guard, so the right overload is used and path traversal is structurally prevented; the streaming path is untouched.

Addressed in 7163070

  • Helper made synchronous — it no longer does any async work after the blob→getResourcePath change, so dropped the misleading async/Promise wrapping (returns string); simplified the awaiting call site. (Most-cited finding across both tracks.)
  • Docs reworded — describes the native resource-path mechanism instead of asserting unverified "all platforms" coverage.
  • Test — added a direct getAbstractFileByPath call assertion.

Deferred as follow-ups (pre-existing / out of scope for this contained fix)

These predate this PR (the blob path had the same behavior) and would expand the change beyond the iOS playback fix:

  • A local episode's streamUrl holds a playback URL (now app://… instead of a blob: that was invalid after reload) which is persisted and surfaced via {{stream}}. Cleaner design: keep a canonical path on the episode and resolve to a resource URL only at the <audio src> boundary. Playback is unaffected (the player resolves downloaded/local episodes via filePath, not streamUrl).
  • downloadEpisodeWithNotice fetches episode.streamUrl with no isLocalFile guard (fails identically before/after this change for local files).
  • Missing/desynced vault file degrades to a silent empty <audio src=""> with no notice.
  • ogg is absent from the playable-extension set despite the download pipeline recognizing it (note: Ogg/Opus aren't decodable in iOS WebKit, so I left it out of an iOS-focused change).
  • Rename createMediaUrlObjectFromFilePath (it no longer creates an object URL).

⚠️ Still needs one on-device check before release

The fix is desktop-validated and unit-tested, but unit tests use mocks and the root cause is platform-specific WKWebView behavior. Before release, please confirm on a real iPhone: log app.vault.getResourcePath(tfile) for a local audio file and verify <audio> fires loadedmetadata (readyState >= 2).

The new extension allow-list was stricter than the (buggy match-everything)
regex it replaced, so right-clicking an .ogg or .amr file no longer offered
"Play with PodNotes" — a regression for formats PodNotes already recognizes as
audio via detectAudioFileExtension and getExtensionFromContentType. Align the
set with those sources.
@chhoumann
chhoumann merged commit 12c503a into master Jun 15, 2026
2 checks passed
@chhoumann
chhoumann deleted the chhoumann/100-local-files-ios branch June 16, 2026 06:16
github-actions Bot pushed a commit that referenced this pull request Jun 22, 2026
# [2.17.0](2.16.0...2.17.0) (2026-06-22)

### Bug Fixes

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

### Features

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

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 2.17.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can't play local files on iOS

1 participant