Compare commits

..

5 Commits

Author SHA1 Message Date
Krishna Kumar
1f49a7dcbe feat(podcasts): #8 follow-ups — AI summary + in-app Open Bookmark
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s
Resolves the two deviations from the episode detail sheet:

- AI summary now shows in the detail sheet. Added a URL-keyed AISummaryStore
  that BookmarksViewModel populates as it enriches/loads bookmarks, so the
  decoupled Podcasts tab can surface a summary from just the episode URL —
  without recoupling to BookmarksViewModel.
- "Open Bookmark" opens the in-app BrowserView as a nested sheet (generator
  threaded through PodcastLibraryView / EpisodePickerView), alongside the
  existing "Open in Safari" and Share.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:16:16 -05:00
Krishna Kumar
a155c15e17 feat(podcasts): #8 episode detail sheet (source, played state, actions)
All checks were successful
CI / build-and-deploy (push) Successful in 17s
CI / build-and-deploy (pull_request) Successful in 17s
- New EpisodeDetailView: title, source domain, created date, played badge,
  and per-episode actions — Play/Pause, Mark Played/Unplayed, Open Source,
  Share, Delete — all routed through the PodcastLibrary store so the badge
  stays in sync.
- Reachable by tapping a row (or "Episode Details" in the context menu) in the
  Podcasts tab, and by tapping a row in the episode picker.

Note: AI summary is intentionally omitted for now — it's keyed by bookmark id
(not URL), so surfacing it would recouple the deliberately-decoupled Podcasts
tab to BookmarksViewModel. Open Source uses Safari rather than the in-app
browser to avoid sheet-stacking. Both are noted as follow-ups on issue #8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:11:46 -05:00
Krishna Kumar
d2ccbc94b5 feat(podcasts): #6 quick wins — tab badge, remember speed, sleep timer, persist queue
All checks were successful
CI / build-and-deploy (push) Successful in 17s
CI / build-and-deploy (pull_request) Successful in 18s
- Unread badge on the Podcasts tab via a new observable PodcastLibrary store
  that mirrors PodcastIndex; all played/remove/generation mutations route
  through it so the count stays live.
- Persist playback speed across sessions (UserDefaults), applied on setup.
- Sleep timer (15/30/45 min or end-of-episode) with a moon menu in the full
  player; end-of-episode stops instead of auto-advancing.
- Persist the current episode + Up Next queue and restore a *paused* session on
  launch; save position when backgrounding. Audio session is only claimed on
  actual playback so a restored session never interrupts other audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 13:07:31 -05:00
Krishna Kumar
5e69d99996 feat(podcasts): surface mark-played in context menu, full player, and row badge
All checks were successful
CI / build-and-deploy (push) Successful in 18s
CI / build-and-deploy (pull_request) Successful in 18s
- Add a long-press context menu on each library row (Play/Pause,
  Mark as Played/Unplayed, Delete).
- Add a Mark as Played/Unplayed control next to Share Episode in the full
  player, tracking the loaded episode across queue advances.
- Show a green checkmark on played rows, on top of the existing Played
  section + dimmed title, so played state reads at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 12:01:57 -05:00
Krishna Kumar
af3112530e feat(podcasts): played/unplayed queue, background generation, share-sheet audio
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
Turns the Podcasts tab into a proper podcast-app experience across three
features that compose on a shared background-generation service.

Background generation (#2)
- New PodcastGenerationManager runs generate→poll→download off the player,
  so producing a new episode never stops current playback.
- Headphone taps never interrupt: idle → generate-and-play in the foreground
  (unchanged); already playing → generate in the background, episode lands in
  the library. A "Generating…" banner surfaces active jobs.

Played/unplayed + queue (#1)
- PodcastEntry gains playedAt (old index.json decodes as unplayed).
- Podcasts tab splits into Up Next / Played with a Play All queue that
  auto-advances; finishing marks played; swipe to toggle played state.

Share-extension audio (#3)
- "Create podcast" toggle on the save card, plus configurable Auto-Podcast
  Tags in Settings. The extension enqueues a request into the App Group; the
  main app drains it on launch/foreground and generates in the background.

Closes #1
Closes #2
Closes #3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 11:37:42 -05:00
17 changed files with 896 additions and 92 deletions

View File

@@ -12,6 +12,7 @@
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; }; 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; }; 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2860AAA865515225FB9FC65 /* AISummaryStore.swift */; };
212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; }; 212F713DCC289C48087B79AE /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB7728D15C17219ABFF3EFFE /* Log.swift */; };
22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; }; 22C814FD55D29B88D227C987 /* SpotlightBookmarkSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */; };
3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 3528AF5CB690BBCCF337581B /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
@@ -20,11 +21,13 @@
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; }; 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 217E6702DE1210AC38ED16D1 /* AskView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; }; 706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */; };
778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; }; 778B82E075D4DAF5E8446D6F /* BookmarkOnscreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; }; 8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; }; 8227B9E3B5EFF6427702F376 /* BookmarkAssistant.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */; };
@@ -33,6 +36,7 @@
852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; }; 852A3E18661C190622FF5A85 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F9803BA80BEAE9A3AE4AF5E7 /* AppIntents.framework */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; }; 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; }; 905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; }; 927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; }; 94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
@@ -56,6 +60,7 @@
DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; }; DE32F3DC24D606926A559C06 /* IntentSnippetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D8E2E470C9336209B7E8543 /* IntentSnippetViews.swift */; };
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; }; E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E9DEC5CD89FF346E23A14F /* MarksAppIntents.swift */; };
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */; };
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; }; E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */; };
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; }; EFF8E4CD63CAE1342CE3A4F0 /* IntentSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACABF0CA940312B4195456 /* IntentSupport.swift */; };
@@ -110,6 +115,8 @@
093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 093402014727A338745A06DA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; }; 0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; }; 0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastLibrary.swift; sourceTree = "<group>"; };
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastGenerationManager.swift; sourceTree = "<group>"; };
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; }; 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotlightIndexer.swift; sourceTree = "<group>"; };
217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; }; 217E6702DE1210AC38ED16D1 /* AskView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AskView.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
@@ -149,6 +156,8 @@
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; }; CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; }; CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListRow.swift; sourceTree = "<group>"; };
D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; }; D1EEDFAE9FCA24192640CA7A /* BookmarkOnscreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkOnscreen.swift; sourceTree = "<group>"; };
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastRequests.swift; sourceTree = "<group>"; };
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AISummaryStore.swift; sourceTree = "<group>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; }; D6ACABF0CA940312B4195456 /* IntentSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntentSupport.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
@@ -244,6 +253,7 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = { 7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D2860AAA865515225FB9FC65 /* AISummaryStore.swift */,
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */, 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */, E6379451D7FD7090A9F01A01 /* BookmarkAssistant.swift */,
C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */, C5A99F666A536D569171B55F /* BookmarkSearchTool.swift */,
@@ -251,7 +261,10 @@
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */, 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
FB7728D15C17219ABFF3EFFE /* Log.swift */, FB7728D15C17219ABFF3EFFE /* Log.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */, F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
171EF75BF9BE4592DFA2C716 /* PodcastGenerationManager.swift */,
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */, F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
11DC9EA7BF3AD8FE3D536319 /* PodcastLibrary.swift */,
D27A97922BAEBDC9C5A7385C /* PodcastRequests.swift */,
41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */, 41DDBB04346F3BF06DE233D2 /* SpotlightBookmarkSearch.swift */,
1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */, 1A5FEE76168FA5AB1E047FEC /* SpotlightIndexer.swift */,
); );
@@ -481,6 +494,7 @@
1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */, 1095DC18A31055ED40CD9323 /* LinkdingAPI.swift in Sources */,
B085CDBFC47F0357D1A28911 /* Log.swift in Sources */, B085CDBFC47F0357D1A28911 /* Log.swift in Sources */,
B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */, B7AF3F940FEE7B8AC32628B6 /* MarksAuth.swift in Sources */,
8F4BFD6C1322B508E8F52297 /* PodcastRequests.swift in Sources */,
F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */, F04066805A33E1A305C11F4F /* ServerConfig.swift in Sources */,
8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */, 8456E89CFE607C2EC57BF466 /* ShareView.swift in Sources */,
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */, 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
@@ -504,6 +518,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
1B04368962246251639D9590 /* AISummaryStore.swift in Sources */,
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */, AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */, B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */, 68BDDFF472DDF1854D08A9ED /* AskView.swift in Sources */,
@@ -528,8 +543,11 @@
E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */, E10B0B4EC9580342830EC0D2 /* MarksAppIntents.swift in Sources */,
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */, 8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */,
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
773C174263D08416248D0675 /* PodcastGenerationManager.swift in Sources */,
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */, 0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */,
E3C3E48E6DE9CA0CD00AEE48 /* PodcastLibrary.swift in Sources */,
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */,
66D5D90A5FAF842BCA0FE72D /* PodcastRequests.swift in Sources */,
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,

View File

@@ -38,6 +38,7 @@ struct MainContainer: View {
@State private var showDeepLinkPlayer = false @State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks @State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared @State private var router = IntentRouter.shared
@State private var library = PodcastLibrary.shared
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) { init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
@@ -56,8 +57,9 @@ struct MainContainer: View {
TagsView(viewModel: viewModel) TagsView(viewModel: viewModel)
} }
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) { Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude) PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
} }
.badge(library.unplayedCount)
Tab(value: AppTab.search, role: .search) { Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel) SearchView(viewModel: viewModel)
} }
@@ -65,18 +67,28 @@ struct MainContainer: View {
.onOpenURL { url in .onOpenURL { url in
handleDeepLink(url) handleDeepLink(url)
} }
.task { applyPendingIntent() } .task {
applyPendingIntent()
viewModel.processPendingPodcastRequests()
viewModel.podcastPlayer.restoreSession(claude: viewModel.claude)
}
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() } .onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() } .onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in .onChange(of: scenePhase) { old, new in
// Refresh when returning to the foreground (cold launch is already // Refresh when returning to the foreground (cold launch is already
// covered by BookmarksView's .task, so only react to a real re-entry). // covered by BookmarksView's .task, so only react to a real re-entry).
if new == .active && old != .active { if new == .active && old != .active {
viewModel.processPendingPodcastRequests()
Task { await viewModel.load() } Task { await viewModel.load() }
} }
// Persist playback position + session when leaving the foreground so a
// relaunch can resume where you left off.
if new == .background {
viewModel.podcastPlayer.saveProgress()
}
} }
.sheet(item: $deepLinkBrowser) { item in .sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
} }
.sheet(isPresented: $showDeepLinkPlayer) { .sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView( PodcastPlayerView(

View File

@@ -0,0 +1,27 @@
import Foundation
/// URL-keyed cache of AI summaries so views that only know a URL e.g. the
/// deliberately-decoupled Podcasts tab can show a bookmark's summary without
/// holding the full `Bookmark`. Written by `BookmarksViewModel` as it enriches
/// and loads bookmarks; read by the episode detail sheet.
enum AISummaryStore {
private static let key = "aiSummaryByURL"
static func summary(for url: String) -> String? {
let store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
let value = store[url]
return (value?.isEmpty ?? true) ? nil : value
}
static func set(_ summary: String?, for url: String) {
var store = UserDefaults.standard.dictionary(forKey: key) as? [String: String] ?? [:]
if let summary, !summary.isEmpty {
guard store[url] != summary else { return } // no-op if unchanged
store[url] = summary
} else {
guard store[url] != nil else { return }
store.removeValue(forKey: url)
}
UserDefaults.standard.set(store, forKey: key)
}
}

View File

@@ -0,0 +1,120 @@
import Foundation
import Observation
/// Runs podcast generation (generate poll download) **off** the player, so a
/// new episode can be produced in the background while another one keeps playing.
/// The finished audio is written to the on-disk cache and recorded in
/// `PodcastIndex`; the optional `onReady` callback lets a caller start playback
/// only when nothing else is playing.
@Observable
@MainActor
final class PodcastGenerationManager {
struct Job: Identifiable {
let id: String // articleUrl
var articleUrl: String
var title: String
var progress: Double // 01
var label: String
var failed: Bool = false
var error: String?
}
private(set) var jobs: [Job] = []
private var tasks: [String: Task<Void, Never>] = [:]
private let claude = ClaudeService()
/// Jobs still working (not failed). Drives the "Generating" banner.
var activeJobs: [Job] { jobs.filter { !$0.failed } }
var hasActive: Bool { !activeJobs.isEmpty }
func isGenerating(_ articleUrl: String) -> Bool { tasks[articleUrl] != nil }
func job(for articleUrl: String) -> Job? { jobs.first { $0.articleUrl == articleUrl } }
/// Enqueue a background generation for `articleUrl`. Idempotent per URL.
/// If audio is already cached, `onReady` fires immediately and nothing runs.
/// `onReady` is invoked on the main actor once audio is available.
func enqueue(articleUrl: String,
title: String,
parentBookmarkUrl: String? = nil,
onReady: ((URL, String) -> Void)? = nil) {
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
onReady?(cached, title)
return
}
if tasks[articleUrl] != nil { return } // already in flight
jobs.insert(Job(id: articleUrl, articleUrl: articleUrl, title: title,
progress: 0, label: "Starting…"), at: 0)
Analytics.track("podcast.bg_started", ["url": articleUrl])
let task = Task { [weak self] in
guard let self else { return }
do {
let jobId = try await claude.generatePodcast(url: articleUrl)
while !Task.isCancelled {
let status = try await claude.podcastStatus(jobId: jobId)
update(articleUrl) {
$0.progress = Double(status.progress) / 100
$0.label = PodcastPlayerViewModel.statusLabel(status.status)
}
if status.isDone {
update(articleUrl) { $0.label = "Preparing audio…"; $0.progress = 1 }
let url = try await claude.downloadPodcastAudio(
jobId: jobId, articleUrl: articleUrl,
title: status.title, parentBookmarkUrl: parentBookmarkUrl)
let finalTitle = status.title ?? title
finish(articleUrl)
PodcastLibrary.shared.reload() // new episode refresh list + badge
Analytics.track("podcast.bg_completed", ["url": articleUrl])
onReady?(url, finalTitle)
return
}
if status.isFailed {
fail(articleUrl, status.error ?? "Generation failed")
return
}
try await Task.sleep(for: .seconds(3))
}
} catch is CancellationError {
finish(articleUrl)
} catch {
fail(articleUrl, error.localizedDescription)
}
}
tasks[articleUrl] = task
}
/// Cancel an in-flight job and drop it from the list.
func cancel(_ articleUrl: String) {
tasks[articleUrl]?.cancel()
tasks[articleUrl] = nil
jobs.removeAll { $0.articleUrl == articleUrl }
}
func dismiss(_ articleUrl: String) { jobs.removeAll { $0.articleUrl == articleUrl } }
// MARK: - Mutations
private func update(_ url: String, _ mutate: (inout Job) -> Void) {
guard let i = jobs.firstIndex(where: { $0.articleUrl == url }) else { return }
mutate(&jobs[i])
}
private func finish(_ url: String) {
tasks[url] = nil
jobs.removeAll { $0.articleUrl == url }
}
private func fail(_ url: String, _ message: String) {
tasks[url] = nil
update(url) { $0.failed = true; $0.error = message; $0.label = message }
Analytics.track("podcast.bg_failed", ["url": url, "error": message])
// Failed jobs linger briefly so the user sees them, then self-clear.
Task { [weak self] in
try? await Task.sleep(for: .seconds(8))
self?.dismiss(url)
}
}
}

View File

@@ -7,6 +7,11 @@ struct PodcastEntry: Codable, Identifiable {
var title: String? var title: String?
let createdAt: Date let createdAt: Date
var parentBookmarkUrl: String? var parentBookmarkUrl: String?
/// When the episode was finished. `nil` means unplayed/new. Absent in
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
var playedAt: Date? = nil
var isPlayed: Bool { playedAt != nil }
} }
enum PodcastIndex { enum PodcastIndex {
@@ -49,6 +54,18 @@ enum PodcastIndex {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl } all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
} }
static func isPlayed(articleUrl: String) -> Bool {
all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false
}
/// Mark an episode played (default) or back to unplayed. No-op if unknown.
static func setPlayed(articleUrl: String, _ played: Bool = true) {
var entries = all()
guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return }
entries[i].playedAt = played ? Date() : nil
save(entries)
}
static func remove(articleUrl: String) { static func remove(articleUrl: String) {
var entries = all() var entries = all()
entries.removeAll { $0.articleUrl == articleUrl } entries.removeAll { $0.articleUrl == articleUrl }

View File

@@ -0,0 +1,31 @@
import Foundation
import Observation
/// Observable, in-memory mirror of `PodcastIndex` so SwiftUI updates immediately
/// on changes the Podcasts tab badge and the library list both read from here.
/// Route podcast mutations through this store so the unplayed badge stays in sync.
@Observable
@MainActor
final class PodcastLibrary {
static let shared = PodcastLibrary()
private(set) var entries: [PodcastEntry] = []
var unplayed: [PodcastEntry] { entries.filter { !$0.isPlayed } }
var played: [PodcastEntry] { entries.filter { $0.isPlayed } }
var unplayedCount: Int { entries.reduce(0) { $0 + ($1.isPlayed ? 0 : 1) } }
private init() { entries = PodcastIndex.all() }
func reload() { entries = PodcastIndex.all() }
func setPlayed(_ articleUrl: String, _ played: Bool) {
PodcastIndex.setPlayed(articleUrl: articleUrl, played)
reload()
}
func remove(_ articleUrl: String) {
PodcastIndex.remove(articleUrl: articleUrl)
reload()
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
/// Cross-process (main app share extension) queue of podcast-generation
/// requests, plus the user's "auto-generate for these tags" rule. Lives in the
/// shared App Group so the share extension can enqueue work the main app runs
/// the extension itself is too short-lived to generate audio.
enum PodcastRequests {
private static let appGroupId = "group.com.magicive.marks"
private static let pendingKey = "pendingPodcastRequests"
private static let autoTagsKey = "podcastAutoTags"
private static var defaults: UserDefaults? { UserDefaults(suiteName: appGroupId) }
struct Request: Codable {
let url: String
let title: String
}
// MARK: - Pending queue
/// Enqueue a request. De-duplicates on URL so repeated saves don't pile up.
static func enqueue(url: String, title: String) {
var list = pending()
guard !list.contains(where: { $0.url == url }) else { return }
list.append(Request(url: url, title: title))
save(list)
}
static func pending() -> [Request] {
guard let data = defaults?.data(forKey: pendingKey),
let list = try? JSONDecoder().decode([Request].self, from: data) else { return [] }
return list
}
/// Return everything pending and clear the queue.
static func drain() -> [Request] {
let list = pending()
defaults?.removeObject(forKey: pendingKey)
return list
}
private static func save(_ list: [Request]) {
guard let data = try? JSONEncoder().encode(list) else { return }
defaults?.set(data, forKey: pendingKey)
}
// MARK: - Auto-tag rule
/// Tags that auto-trigger podcast generation when a bookmark is saved.
static var autoTags: [String] {
get { defaults?.stringArray(forKey: autoTagsKey) ?? [] }
set { defaults?.set(newValue, forKey: autoTagsKey) }
}
/// Whether saving a bookmark with `tags` should auto-generate a podcast.
static func matchesAutoTag(_ tags: [String]) -> Bool {
let auto = Set(autoTags.map { $0.lowercased() })
guard !auto.isEmpty else { return false }
return tags.contains { auto.contains($0.lowercased()) }
}
}

View File

@@ -85,7 +85,7 @@ struct BookmarkListRow: View {
) )
} }
.sheet(item: $episodePickerBookmark) { b in .sheet(item: $episodePickerBookmark) { b in
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude) EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator)
} }
} }
@@ -100,12 +100,7 @@ struct BookmarkListRow: View {
claude: viewModel.claude claude: viewModel.claude
) )
showFullPlayer = true showFullPlayer = true
} else { } else if viewModel.playOrGeneratePodcast(articleUrl: bookmark.url, title: bookmark.displayTitle) {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true showFullPlayer = true
} }
} }

View File

@@ -146,6 +146,10 @@ struct BookmarksView: View {
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
VStack(spacing: 8) { VStack(spacing: 8) {
if viewModel.podcastGenerator.hasActive {
podcastGeneratingBanner
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty { if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) { MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true showFullPlayer = true
@@ -157,6 +161,7 @@ struct BookmarksView: View {
} }
} }
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty) .animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.animation(.spring(duration: 0.3), value: viewModel.podcastGenerator.hasActive)
.padding(.bottom, 8) .padding(.bottom, 8)
} }
.sensoryFeedback(.selection, trigger: browsingBookmark?.id) .sensoryFeedback(.selection, trigger: browsingBookmark?.id)
@@ -178,7 +183,7 @@ struct BookmarksView: View {
} }
.sheet(item: $browsingBookmark) { bookmark in .sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) { if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) { BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator) {
await viewModel.archive(bookmark) await viewModel.archive(bookmark)
} }
.bookmarkOnscreen(bookmark) .bookmarkOnscreen(bookmark)
@@ -230,6 +235,40 @@ struct BookmarksView: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
private var podcastGeneratingBanner: some View {
let jobs = viewModel.podcastGenerator.activeJobs
return HStack(spacing: 10) {
Image(systemName: "waveform")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(alignment: .leading, spacing: 2) {
Text(jobs.count == 1 ? "Generating podcast…" : "Generating \(jobs.count) podcasts…")
.font(.system(size: 13, weight: .medium))
if let first = jobs.first {
Text(first.title.isEmpty ? first.label : first.title)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if jobs.count == 1, let progress = jobs.first?.progress, progress > 0 {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
.frame(width: 44)
} else {
ProgressView()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
}
private func maybeLoadMore(_ bookmark: Bookmark) { private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id, guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return } viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }

View File

@@ -18,6 +18,7 @@ final class BookmarksViewModel {
private let api: LinkdingAPI private let api: LinkdingAPI
let claude = ClaudeService() let claude = ClaudeService()
let podcastPlayer = PodcastPlayerViewModel() let podcastPlayer = PodcastPlayerViewModel()
let podcastGenerator = PodcastGenerationManager()
private var enrichTask: Task<Void, Never>? private var enrichTask: Task<Void, Never>?
private let cacheFileUrl: URL private let cacheFileUrl: URL
@@ -29,6 +30,32 @@ final class BookmarksViewModel {
self.cacheFileUrl = dir.appendingPathComponent("\(name).json") self.cacheFileUrl = dir.appendingPathComponent("\(name).json")
} }
/// True while the player is showing an episode (playing or generating one).
var isPlayerBusy: Bool { !podcastPlayer.currentArticleUrl.isEmpty }
/// Handle a headphone tap without ever interrupting current playback.
/// If the player is idle, generate-and-play in the foreground and return
/// `true` (caller should present the full player). If the player is busy,
/// generate in the background the finished episode lands in the library
/// and return `false`.
@discardableResult
func playOrGeneratePodcast(articleUrl: String, title: String, parentBookmarkUrl: String? = nil) -> Bool {
if isPlayerBusy {
podcastGenerator.enqueue(articleUrl: articleUrl, title: title, parentBookmarkUrl: parentBookmarkUrl)
return false
}
podcastPlayer.start(articleUrl: articleUrl, articleTitle: title, claude: claude, parentBookmarkUrl: parentBookmarkUrl)
return true
}
/// Drain podcast requests queued by the share extension and start generating
/// them in the background. Safe to call repeatedly (the queue is cleared).
func processPendingPodcastRequests() {
for req in PodcastRequests.drain() {
podcastGenerator.enqueue(articleUrl: req.url, title: req.title)
}
}
func load(useCache: Bool = true) async { func load(useCache: Bool = true) async {
if useCache { loadFromCache() } if useCache { loadFromCache() }
isLoading = true isLoading = true
@@ -272,6 +299,8 @@ final class BookmarksViewModel {
"tags": bookmark.aiTags ?? [] "tags": bookmark.aiTags ?? []
] ]
UserDefaults.standard.set(store, forKey: "aiData") UserDefaults.standard.set(store, forKey: "aiData")
// Mirror by URL so the decoupled Podcasts tab can show summaries.
AISummaryStore.set(bookmark.aiSummary, for: bookmark.url)
} }
private func restoreAIData() { private func restoreAIData() {
@@ -284,6 +313,8 @@ final class BookmarksViewModel {
if let d = store["\(list[i].id)"] { if let d = store["\(list[i].id)"] {
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 } list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
list[i].aiTags = d["tags"] as? [String] list[i].aiTags = d["tags"] as? [String]
// Keep the URL-keyed summary cache populated for existing bookmarks.
if let summary = list[i].aiSummary { AISummaryStore.set(summary, for: list[i].url) }
} }
} }
} }

View File

@@ -220,6 +220,7 @@ struct BrowserView: View {
let initialTitle: String let initialTitle: String
let claude: ClaudeService let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -232,11 +233,12 @@ struct BrowserView: View {
@State private var navForwardCount = 0 @State private var navForwardCount = 0
@State private var toolbarHidden = false @State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) { init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
self.initialURL = url self.initialURL = url
self.initialTitle = title self.initialTitle = title
self.claude = claude self.claude = claude
self.podcastPlayer = podcastPlayer self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url)) self._state = State(initialValue: BrowserState(url: url))
} }
@@ -375,6 +377,9 @@ struct BrowserView: View {
Button { Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
// Never interrupt current playback: play-and-show only when idle,
// otherwise generate in the background.
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start( podcastPlayer.start(
articleUrl: currentUrl, articleUrl: currentUrl,
articleTitle: currentTitle, articleTitle: currentTitle,
@@ -382,6 +387,13 @@ struct BrowserView: View {
parentBookmarkUrl: initialURL.absoluteString parentBookmarkUrl: initialURL.absoluteString
) )
showPodcast = true showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: { } label: {
Image(systemName: "headphones") Image(systemName: "headphones")
.frame(width: 44, height: 44) .frame(width: 44, height: 44)

View File

@@ -42,10 +42,21 @@ final class PodcastPlayerViewModel {
var duration: Double = 1 var duration: Double = 1
var currentArticleUrl: String = "" var currentArticleUrl: String = ""
var currentArticleTitle: String = "" var currentArticleTitle: String = ""
var playbackSpeed: Float = 1.0 var playbackSpeed: Float = PodcastPlayerViewModel.savedSpeed
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0] static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
/// Sleep-timer selection. `.off` when disabled.
enum SleepTimer: Hashable { case off, minutes(Int), endOfEpisode }
var sleepTimer: SleepTimer = .off
private static let speedKey = "podcastPlaybackSpeed"
private static let sessionKey = "podcastSession"
static var savedSpeed: Float {
let v = UserDefaults.standard.double(forKey: speedKey)
return v > 0 ? Float(v) : 1.0
}
var isGenerating: Bool { var isGenerating: Bool {
switch phase { switch phase {
case .generating, .downloading: return true case .generating, .downloading: return true
@@ -53,7 +64,13 @@ final class PodcastPlayerViewModel {
} }
} }
/// Remaining episodes to auto-play after the current one finishes.
private(set) var queue: [PodcastEntry] = []
/// Retained so queued episodes can be started from the end-of-item observer.
private var lastClaude: ClaudeService?
private var pollingTask: Task<Void, Never>? private var pollingTask: Task<Void, Never>?
private var sleepTask: Task<Void, Never>?
private var timeObserver: Any? private var timeObserver: Any?
private var endObserver: Any? private var endObserver: Any?
private var interruptionObserver: Any? private var interruptionObserver: Any?
@@ -66,6 +83,7 @@ final class PodcastPlayerViewModel {
// Teardown previous episode (saves its position) // Teardown previous episode (saves its position)
if player != nil { stop() } if player != nil { stop() }
lastClaude = claude
currentArticleUrl = articleUrl currentArticleUrl = articleUrl
currentArticleTitle = articleTitle currentArticleTitle = articleTitle
@@ -112,7 +130,20 @@ final class PodcastPlayerViewModel {
} }
} }
/// Play `entries` in order, auto-advancing through the queue as each finishes.
func playQueue(_ entries: [PodcastEntry], claude: ClaudeService) {
guard let first = entries.first else { return }
// `start` calls `stop`, which clears the queue set it afterwards.
start(articleUrl: first.articleUrl, articleTitle: first.title ?? "", claude: claude)
queue = Array(entries.dropFirst())
persistSession()
}
func stop() { func stop() {
queue = []
clearSession()
sleepTask?.cancel(); sleepTask = nil
sleepTimer = .off
if !currentArticleUrl.isEmpty && currentTime > 10 { if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration) PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
} }
@@ -146,6 +177,7 @@ final class PodcastPlayerViewModel {
if isPlaying { if isPlaying {
player.pause() player.pause()
} else { } else {
try? AVAudioSession.sharedInstance().setActive(true) // may be a restored, paused session
player.rate = playbackSpeed player.rate = playbackSpeed
} }
isPlaying.toggle() isPlaying.toggle()
@@ -168,16 +200,90 @@ final class PodcastPlayerViewModel {
func setPlaybackSpeed(_ speed: Float) { func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed playbackSpeed = speed
UserDefaults.standard.set(Double(speed), forKey: Self.speedKey)
if isPlaying { player?.rate = speed } if isPlaying { player?.rate = speed }
updateNowPlaying() updateNowPlaying()
} }
private func setupPlayer(url: URL, title: String) { // MARK: - Sleep timer
func setSleepTimer(_ timer: SleepTimer) {
sleepTimer = timer
sleepTask?.cancel(); sleepTask = nil
guard case .minutes(let m) = timer else { return } // .endOfEpisode handled at finish
sleepTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Double(m) * 60))
guard !Task.isCancelled else { return }
self?.fireSleepTimer()
}
}
private func fireSleepTimer() {
player?.pause()
isPlaying = false
sleepTimer = .off
sleepTask = nil
updateNowPlaying()
}
// MARK: - Session persistence (survives app relaunch)
private struct SavedSession: Codable {
let currentUrl: String
let currentTitle: String
let podcastTitle: String
let queue: [PodcastEntry]
}
/// Persist the current episode + queue so relaunch can restore a paused session.
func persistSession() {
guard !currentArticleUrl.isEmpty else { clearSession(); return }
let session = SavedSession(currentUrl: currentArticleUrl,
currentTitle: currentArticleTitle,
podcastTitle: currentPodcastTitle,
queue: queue)
if let data = try? JSONEncoder().encode(session) {
UserDefaults.standard.set(data, forKey: Self.sessionKey)
}
}
private func clearSession() {
UserDefaults.standard.removeObject(forKey: Self.sessionKey)
}
/// Save the current playback position + session (call when backgrounding).
func saveProgress() {
if !currentArticleUrl.isEmpty && currentTime > 10 {
PodcastProgress.set(url: currentArticleUrl, time: currentTime, duration: duration)
}
persistSession()
}
/// Restore a previously-playing episode into a **paused** player on launch,
/// with its queue intact. No-op if nothing is saved or the audio is gone.
func restoreSession(claude: ClaudeService) {
guard currentArticleUrl.isEmpty, player == nil else { return }
guard let data = UserDefaults.standard.data(forKey: Self.sessionKey),
let session = try? JSONDecoder().decode(SavedSession.self, from: data) else { return }
let cached = ClaudeService.cachedPodcastURL(for: session.currentUrl)
guard FileManager.default.fileExists(atPath: cached.path) else { clearSession(); return }
lastClaude = claude
currentArticleUrl = session.currentUrl
currentArticleTitle = session.currentTitle
queue = session.queue
setupPlayer(url: cached, title: session.podcastTitle, autoPlay: false)
}
private func setupPlayer(url: URL, title: String, autoPlay: Bool = true) {
currentPodcastTitle = title currentPodcastTitle = title
// A freshly generated episode may have just landed in the index.
PodcastLibrary.shared.reload()
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio,
options: [.allowBluetoothA2DP]) options: [.allowBluetoothA2DP])
try? AVAudioSession.sharedInstance().setActive(true) // Only claim the audio session when actually playing a restored, paused
// session on launch must not interrupt the user's other audio.
if autoPlay { try? AVAudioSession.sharedInstance().setActive(true) }
let item = AVPlayerItem(url: url) let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item) let p = AVPlayer(playerItem: item)
@@ -196,16 +302,36 @@ final class PodcastPlayerViewModel {
} }
} }
// Reset to start when playback finishes, like standard media players // On finish: mark the episode played, then auto-advance to the next
// queued episode, or reset to the start like a standard media player.
endObserver = NotificationCenter.default.addObserver( endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in ) { [weak self] _ in
guard let self else { return } guard let self else { return }
let finished = self.currentArticleUrl
PodcastIndex.setPlayed(articleUrl: finished, true)
PodcastProgress.set(url: finished, time: 0, duration: self.duration)
PodcastLibrary.shared.reload()
// Sleep timer set to "end of episode" stops here no auto-advance.
let stopForSleep = self.sleepTimer == .endOfEpisode
if stopForSleep { self.sleepTimer = .off }
if !stopForSleep, !self.queue.isEmpty {
let next = self.queue.removeFirst()
let remaining = self.queue
let claude = self.lastClaude ?? ClaudeService()
self.start(articleUrl: next.articleUrl,
articleTitle: next.title ?? "", claude: claude)
self.queue = remaining // `start` cleared it via `stop`
self.persistSession()
} else {
self.isPlaying = false self.isPlaying = false
self.currentTime = 0 self.currentTime = 0
self.player?.seek(to: .zero) self.player?.seek(to: .zero)
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
self.updateNowPlaying() self.updateNowPlaying()
self.persistSession()
}
} }
// Pause on phone calls / Siri; resume when interruption ends // Pause on phone calls / Siri; resume when interruption ends
@@ -245,14 +371,20 @@ final class PodcastPlayerViewModel {
setupRemoteCommands() setupRemoteCommands()
phase = .ready(title: title) phase = .ready(title: title)
updateNowPlaying() updateNowPlaying()
if autoPlay {
p.rate = playbackSpeed p.rate = playbackSpeed
isPlaying = true isPlaying = true
} else {
isPlaying = false
}
persistSession()
} }
private func setupRemoteCommands() { private func setupRemoteCommands() {
let c = MPRemoteCommandCenter.shared() let c = MPRemoteCommandCenter.shared()
c.playCommand.isEnabled = true c.playCommand.isEnabled = true
c.playCommand.addTarget { [weak self] _ in c.playCommand.addTarget { [weak self] _ in
try? AVAudioSession.sharedInstance().setActive(true)
self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success self?.player?.play(); self?.isPlaying = true; self?.updateNowPlaying(); return .success
} }
c.pauseCommand.isEnabled = true c.pauseCommand.isEnabled = true
@@ -308,7 +440,7 @@ final class PodcastPlayerViewModel {
return MPMediaItemArtwork(boundsSize: size) { _ in image } return MPMediaItemArtwork(boundsSize: size) { _ in image }
}() }()
private static func statusLabel(_ status: String) -> String { static func statusLabel(_ status: String) -> String {
switch status { switch status {
case "queued": return "Queued…" case "queued": return "Queued…"
case "fetching": return "Fetching article…" case "fetching": return "Fetching article…"
@@ -330,6 +462,33 @@ struct PodcastPlayerView: View {
var stopOnDismiss: Bool = false var stopOnDismiss: Bool = false
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var isPlayed = false
/// The episode currently loaded in the player (follows queue advances),
/// falling back to the URL this sheet was opened with.
private var activeUrl: String {
vm.currentArticleUrl.isEmpty ? articleUrl : vm.currentArticleUrl
}
private var sleepActive: Bool { vm.sleepTimer != .off }
private var sleepLabel: String {
switch vm.sleepTimer {
case .off: return "Sleep"
case .minutes(let m): return "\(m) min"
case .endOfEpisode: return "End"
}
}
@ViewBuilder
private func sleepButton(_ title: String, _ option: PodcastPlayerViewModel.SleepTimer) -> some View {
Button { vm.setSleepTimer(option) } label: {
if vm.sleepTimer == option {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -357,6 +516,10 @@ struct PodcastPlayerView: View {
} }
.onAppear { .onAppear {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude) vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
}
.onChange(of: vm.currentArticleUrl) { _, _ in
isPlayed = PodcastIndex.isPlayed(articleUrl: activeUrl)
} }
.onDisappear { .onDisappear {
if stopOnDismiss { vm.stop() } if stopOnDismiss { vm.stop() }
@@ -445,6 +608,7 @@ struct PodcastPlayerView: View {
} }
} }
HStack(spacing: 12) {
Menu { Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button { Button {
@@ -467,6 +631,35 @@ struct PodcastPlayerView: View {
.glassEffect(in: Capsule()) .glassEffect(in: Capsule())
} }
Menu {
sleepButton("Off", .off)
sleepButton("15 Minutes", .minutes(15))
sleepButton("30 Minutes", .minutes(30))
sleepButton("45 Minutes", .minutes(45))
sleepButton("End of Episode", .endOfEpisode)
} label: {
Label(sleepLabel, systemImage: sleepActive ? "moon.fill" : "moon")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(sleepActive ? Color.blue : .secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.glassEffect(in: Capsule())
}
}
HStack(spacing: 28) {
Button {
isPlayed.toggle()
PodcastLibrary.shared.setPlayed(activeUrl, isPlayed)
} label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(isPlayed ? Color.green : .secondary)
}
.buttonStyle(.plain)
.contentTransition(.symbolEffect(.replace))
if let url = URL(string: articleUrl) { if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) { ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up") Label("Share Episode", systemImage: "square.and.arrow.up")
@@ -476,6 +669,7 @@ struct PodcastPlayerView: View {
} }
} }
} }
}
private func failedView(message: String) -> some View { private func failedView(message: String) -> some View {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -603,14 +797,16 @@ struct MiniPlayerView: View {
struct PodcastLibraryView: View { struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var entries: [PodcastEntry] = [] @State private var library = PodcastLibrary.shared
@State private var showFullPlayer = false @State private var showFullPlayer = false
@State private var detailEntry: PodcastEntry?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { Group {
if entries.isEmpty { if library.entries.isEmpty {
ContentUnavailableView( ContentUnavailableView(
"No Podcasts", "No Podcasts",
systemImage: "headphones", systemImage: "headphones",
@@ -618,47 +814,30 @@ struct PodcastLibraryView: View {
) )
} else { } else {
List { List {
ForEach(entries) { entry in if !unplayed.isEmpty {
HStack(spacing: 12) { Section("Up Next") {
VStack(alignment: .leading, spacing: 4) { ForEach(unplayed) { row(for: $0) }
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
} }
Spacer()
Button {
play(entry)
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
} }
.buttonStyle(.plain) if !played.isEmpty {
} Section("Played") {
.padding(.vertical, 6) ForEach(played) { row(for: $0) }
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
} }
} }
} }
.onDelete { indexSet in .listStyle(.insetGrouped)
indexSet.forEach { deleteEntry(entries[$0]) }
}
}
.listStyle(.plain)
} }
} }
.navigationTitle("Podcasts") .navigationTitle("Podcasts")
.toolbar { .toolbar {
if !entries.isEmpty { if !unplayed.isEmpty {
ToolbarItem(placement: .topBarTrailing) { EditButton() } ToolbarItem(placement: .topBarLeading) {
Button {
vm.playQueue(unplayed, claude: claude)
} label: {
Label("Play All", systemImage: "play.fill")
}
}
} }
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
@@ -679,8 +858,102 @@ struct PodcastLibraryView: View {
stopOnDismiss: false stopOnDismiss: false
) )
} }
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
} }
.onAppear { entries = PodcastIndex.all() } }
.onAppear { library.reload() }
// When the current episode changes (finished, advanced, or stopped),
// re-read the index so played items move into the Played section.
.onChange(of: vm.currentArticleUrl) { _, _ in library.reload() }
}
private var unplayed: [PodcastEntry] { library.unplayed }
private var played: [PodcastEntry] { library.played }
@ViewBuilder
private func row(for entry: PodcastEntry) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
.foregroundStyle(entry.isPlayed ? .secondary : .primary)
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
if entry.isPlayed {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 15))
.foregroundStyle(.green)
.accessibilityLabel("Played")
}
Button {
play(entry)
} label: {
Image(systemName: isCurrent(entry) && vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .leading) {
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Unplayed", systemImage: "circle")
} else {
Label("Played", systemImage: "checkmark.circle")
}
}
.tint(entry.isPlayed ? .gray : .green)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
.contextMenu {
Button {
play(entry)
} label: {
Label(isCurrent(entry) && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent(entry) && vm.isPlaying ? "pause" : "play")
}
Button {
detailEntry = entry
} label: {
Label("Episode Details", systemImage: "info.circle")
}
Button {
setPlayed(entry, !entry.isPlayed)
} label: {
if entry.isPlayed {
Label("Mark as Unplayed", systemImage: "circle")
} else {
Label("Mark as Played", systemImage: "checkmark.circle")
}
}
Divider()
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
private func setPlayed(_ entry: PodcastEntry, _ played: Bool) {
library.setPlayed(entry.articleUrl, played)
} }
private func isCurrent(_ entry: PodcastEntry) -> Bool { private func isCurrent(_ entry: PodcastEntry) -> Bool {
@@ -696,8 +969,7 @@ struct PodcastLibraryView: View {
} }
private func deleteEntry(_ entry: PodcastEntry) { private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl) library.remove(entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }
@@ -708,8 +980,10 @@ struct EpisodePickerView: View {
let bookmark: Bookmark let bookmark: Bookmark
let vm: PodcastPlayerViewModel let vm: PodcastPlayerViewModel
let claude: ClaudeService let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@State private var entries: [PodcastEntry] = [] @State private var entries: [PodcastEntry] = []
@State private var detailEntry: PodcastEntry?
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -745,6 +1019,8 @@ struct EpisodePickerView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { detailEntry = entry }
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: { Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
@@ -760,6 +1036,9 @@ struct EpisodePickerView: View {
Button("Done") { dismiss() } Button("Done") { dismiss() }
} }
} }
.sheet(item: $detailEntry) { entry in
EpisodeDetailView(entry: entry, vm: vm, claude: claude, podcastGenerator: podcastGenerator)
}
} }
.onAppear { entries = PodcastIndex.find(for: bookmark.url) } .onAppear { entries = PodcastIndex.find(for: bookmark.url) }
} }
@@ -770,3 +1049,131 @@ struct EpisodePickerView: View {
if vm.currentArticleUrl == entry.articleUrl { vm.stop() } if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
} }
} }
// MARK: - Episode detail
/// Detail sheet for a single episode: reconnects the podcast to its source
/// bookmark and gathers per-episode actions (play, mark played, open source,
/// share, delete) in one place.
struct EpisodeDetailView: View {
let entry: PodcastEntry
let vm: PodcastPlayerViewModel
let claude: ClaudeService
let podcastGenerator: PodcastGenerationManager
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var library = PodcastLibrary.shared
@State private var showBrowser = false
private var isCurrent: Bool { vm.currentArticleUrl == entry.articleUrl }
private var isPlayed: Bool {
library.entries.first { $0.articleUrl == entry.articleUrl }?.isPlayed ?? entry.isPlayed
}
private var sourceURL: URL? { URL(string: entry.parentBookmarkUrl ?? entry.articleUrl) }
private var domain: String { URL(string: entry.articleUrl)?.host ?? entry.articleUrl }
private var summary: String? {
AISummaryStore.summary(for: entry.parentBookmarkUrl ?? entry.articleUrl)
?? AISummaryStore.summary(for: entry.articleUrl)
}
var body: some View {
NavigationStack {
List {
Section {
VStack(alignment: .leading, spacing: 8) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 20, weight: .semibold))
Label(domain, systemImage: "link")
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 12) {
Label(entry.createdAt.formatted(date: .abbreviated, time: .omitted),
systemImage: "calendar")
if isPlayed {
Label("Played", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
}
}
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
if let summary {
Section("Summary") {
Text(summary)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Section {
Button {
if isCurrent {
vm.togglePlayPause()
} else {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? "", claude: claude)
}
} label: {
Label(isCurrent && vm.isPlaying ? "Pause" : "Play",
systemImage: isCurrent && vm.isPlaying ? "pause.fill" : "play.fill")
}
Button {
library.setPlayed(entry.articleUrl, !isPlayed)
} label: {
Label(isPlayed ? "Mark as Unplayed" : "Mark as Played",
systemImage: isPlayed ? "circle" : "checkmark.circle")
}
}
if let sourceURL {
Section {
Button {
showBrowser = true
} label: {
Label("Open Bookmark", systemImage: "book")
}
Button {
openURL(sourceURL)
} label: {
Label("Open in Safari", systemImage: "safari")
}
ShareLink(item: sourceURL) {
Label("Share Episode", systemImage: "square.and.arrow.up")
}
}
}
Section {
Button(role: .destructive) {
library.remove(entry.articleUrl)
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
dismiss()
} label: {
Label("Delete Episode", systemImage: "trash")
}
}
}
.navigationTitle("Episode")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
.sheet(isPresented: $showBrowser) {
if let sourceURL {
BrowserView(url: sourceURL,
title: entry.title ?? domain,
claude: claude,
podcastPlayer: vm,
podcastGenerator: podcastGenerator)
}
}
}
}
}

View File

@@ -48,7 +48,8 @@ struct SearchView: View {
url: url, url: url,
title: bookmark.displayTitle, title: bookmark.displayTitle,
claude: viewModel.claude, claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) { ) {
await viewModel.archive(bookmark) await viewModel.archive(bookmark)
} }

View File

@@ -6,6 +6,8 @@ struct SettingsView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var autoTagText = PodcastRequests.autoTags.joined(separator: " ")
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
@@ -15,6 +17,21 @@ struct SettingsView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section {
TextField("e.g. listen podcast", text: $autoTagText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onChange(of: autoTagText) { _, new in
PodcastRequests.autoTags = new
.split(whereSeparator: { $0 == " " || $0 == "," })
.map(String.init)
}
} header: {
Text("Auto-Podcast Tags")
} footer: {
Text("Saving a bookmark with any of these tags automatically generates a podcast for it.")
}
Section("Server") { Section("Server") {
Button(role: .destructive) { Button(role: .destructive) {
dismiss() dismiss()

View File

@@ -76,7 +76,8 @@ struct TagBookmarksView: View {
url: url, url: url,
title: bookmark.displayTitle, title: bookmark.displayTitle,
claude: viewModel.claude, claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer podcastPlayer: viewModel.podcastPlayer,
podcastGenerator: viewModel.podcastGenerator
) { ) {
await viewModel.archive(bookmark) await viewModel.archive(bookmark)
} }

View File

@@ -15,6 +15,7 @@ struct ShareView: View {
@State private var tagText = "" @State private var tagText = ""
@State private var notes = "" @State private var notes = ""
@State private var readLater = false @State private var readLater = false
@State private var createPodcast = false
@State private var suggestedTags: [String] = [] // linkding auto_tags @State private var suggestedTags: [String] = [] // linkding auto_tags
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased) @State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary) @State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
@@ -151,6 +152,11 @@ struct ShareView: View {
Toggle("Read later", isOn: $readLater) Toggle("Read later", isOn: $readLater)
.font(.subheadline) .font(.subheadline)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(.subheadline)
} }
} }
@@ -236,6 +242,8 @@ struct ShareView: View {
title = result.metadata?.title ?? "" title = result.metadata?.title ?? ""
suggestedTags = result.autoTags ?? [] suggestedTags = result.autoTags ?? []
} }
// Pre-enable the toggle when the current tags match the auto-rule.
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
phase = .editing phase = .editing
await loadSuggestions() await loadSuggestions()
} catch { } catch {
@@ -294,6 +302,11 @@ struct ShareView: View {
) )
_ = try await api.createBookmark(create) _ = try await api.createBookmark(create)
} }
// Hand off podcast generation to the main app (the extension is too
// short-lived to generate audio itself).
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
}
phase = .done phase = .done
try? await Task.sleep(nanoseconds: 700_000_000) try? await Task.sleep(nanoseconds: 700_000_000)
onClose(true) onClose(true)

View File

@@ -90,6 +90,8 @@ targets:
- path: Marks/Services/LinkdingAPI.swift - path: Marks/Services/LinkdingAPI.swift
- path: Marks/Services/MarksAuth.swift - path: Marks/Services/MarksAuth.swift
- path: Marks/Services/Log.swift - path: Marks/Services/Log.swift
# Cross-process podcast request queue: the extension enqueues, the app runs it.
- path: Marks/Services/PodcastRequests.swift
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.ShareExtension