Compare commits

...

20 Commits

Author SHA1 Message Date
Krishna Kumar
199fc2d043 Fix ShareExtension "Open Marks first" + add widget + BookmarkListRow polish
All checks were successful
CI / build-and-deploy (push) Successful in 41s
- Save serverConfig to App Group on every app launch so ShareExtension
  can always find credentials without requiring a disconnect/reconnect flow
- Add MarksWidget target with RandomBookmarkWidget (random saved bookmark,
  reloads hourly) using file-based App Group storage to avoid CFPreferences issues
- Extract BookmarkListRow as shared component with editorial typography tweaks
  (17pt semibold title, 13pt domain, relative date, 32×32 favicon, italic AI summary,
  20pt podcast icon, 16pt horizontal insets)
- Add MarksWidget and ShareExtension Xcode schemes for direct run/debug

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 13:11:05 -05:00
Krishna Kumar
d87500a7dc experiment(BookmarksView): row horizontal insets 20pt → 16pt for tighter editorial margins 2026-05-23 01:14:18 -05:00
Krishna Kumar
3de93ac61d experiment(BookmarkRow): podcast icon 26pt → 20pt, less visual noise in list 2026-05-23 01:13:15 -05:00
Krishna Kumar
ee9a7c018e experiment(BookmarkRow): show dateAdded relative time inline with domain — 'domain · 2 days ago' 2026-05-23 01:12:48 -05:00
Krishna Kumar
89bcecebab experiment(BookmarkRow): AI summary 15pt → 14pt italic for editorial reading-app feel 2026-05-23 01:12:23 -05:00
Krishna Kumar
baadaf77a3 experiment(BookmarkRow): favicon 26×26 → 32×32, corner radius 5→7, align offsets to 44 2026-05-23 01:11:54 -05:00
Krishna Kumar
20f06c8440 experiment(BookmarkRow): tag pills blue fill → neutral systemGray6, secondary text 2026-05-23 01:11:03 -05:00
Krishna Kumar
18f1564d3b experiment(BookmarkRow): domain font 16pt → 13pt, tighter metadata line 2026-05-23 01:10:45 -05:00
Krishna Kumar
7c5ffbf69b experiment(BookmarkRow): title font 22pt medium → 17pt semibold for reading app scale 2026-05-23 01:10:24 -05:00
Krishna Kumar
15e2801b87 chore: gitignore autoresearch-results.tsv 2026-05-23 01:10:05 -05:00
Krishna Kumar
5c52693d4d experiment(PodcastPlayerView): add ShareLink button to full player for sharing episode URL 2026-05-23 00:56:48 -05:00
Krishna Kumar
2a0cb2d648 experiment(PodcastPlayerView): add MPMediaItemArtwork to Now Playing — waveform rendered image 2026-05-23 00:55:54 -05:00
Krishna Kumar
f61179e568 experiment(PodcastPlayerView): add swipe-to-delete in PodcastLibraryView, stops player if active 2026-05-23 00:55:04 -05:00
Krishna Kumar
09430e8e66 experiment(PodcastPlayerView): handle AVAudioSession interruptions — pause on call, resume after 2026-05-23 00:54:12 -05:00
Krishna Kumar
140c761850 experiment(PodcastPlayerView): add Retry button to failedView, clear pollingTask on failure 2026-05-23 00:52:55 -05:00
Krishna Kumar
f7d5c129fc experiment(PodcastPlayerView): add playbackSpeed control (0.75×/1×/1.25×/1.5×/2×) with menu 2026-05-23 00:51:23 -05:00
Krishna Kumar
be4a4c3a5f experiment(PodcastPlayerView): add lock screen skipBackwardCommand/skipForwardCommand (15s) 2026-05-23 00:50:07 -05:00
Krishna Kumar
fc0f98f45a experiment(PodcastPlayerView): add skip ±15s buttons to full player controls 2026-05-23 00:48:46 -05:00
Krishna Kumar
ea8a425c21 experiment(BookmarkRow): add sensoryFeedback haptic on podcast trigger button 2026-05-23 00:47:40 -05:00
Krishna Kumar
ab72611d7b Podcast UX baseline: mini player during generation, end-of-track reset, library view
- Mini player shows as soon as generation starts (not just when audio ready)
- Idempotency: tapping headphones again during generation is a no-op
- Playback resets to position 0 on completion (standard player behavior)
- Stop button in full player visible during generation
- PodcastLibraryView lists all generated podcasts
- Sparkles menu gains Podcasts entry
- Fix AnalyticsService Sendable error (serialize before Task.detached boundary)
2026-05-23 00:46:46 -05:00
26 changed files with 1741 additions and 214 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ Pods/
# VS Code
.vscode/
autoresearch-results.tsv

View File

@@ -7,8 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; };
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
@@ -16,24 +16,42 @@
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; };
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */; };
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; };
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 093402014727A338745A06DA /* SwiftUI.framework */; };
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
8BD3FA025C082654D55374A2 /* MarksAuth.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78AA3450BDFAC24591EE407 /* MarksAuth.swift */; };
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */; };
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */; };
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0552C13335034219DECF4F62 /* WidgetKit.framework */; };
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */; };
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */; };
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; };
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */; };
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */; };
E056C1B84C267D7554F4D115 /* 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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
proxyType = 1;
remoteGlobalIDString = 61046BF39C48393DD96687A0;
remoteInfo = MarksWidget;
};
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
@@ -51,6 +69,7 @@
dstSubfolderSpec = 13;
files = (
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
0360FE41EA05B1A6CAC53E5E /* MarksWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
@@ -59,9 +78,17 @@
/* Begin PBXFileReference section */
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
0552C13335034219DECF4F62 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
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>"; };
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = "<group>"; };
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = MarksWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentPodcastsWidget.swift; sourceTree = "<group>"; };
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentBookmarksWidget.swift; sourceTree = "<group>"; };
55D369E6D2B8B036685BBD13 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
@@ -71,6 +98,8 @@
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MarksWidget.entitlements; sourceTree = "<group>"; };
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RandomBookmarkWidget.swift; sourceTree = "<group>"; };
A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = "<group>"; };
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -80,15 +109,36 @@
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.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>"; };
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksWidgetBundle.swift; sourceTree = "<group>"; };
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
6B2547BA0E17C2564677B34A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A396A5DC6ED590D0CDB1024B /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A1E820C4B9604F8F05055D3E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
AFB08279F8A146B56D7D7250 /* WidgetKit.framework in Frameworks */,
8140EEEFBB7A3510A73D1A93 /* SwiftUI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
3952A081972B444674BEB51B /* Marks */ = {
isa = PBXGroup;
@@ -127,7 +177,7 @@
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */,
49685B8F3FEC72E8CF75843E /* AnalyticsService.swift */,
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
@@ -136,10 +186,25 @@
path = Services;
sourceTree = "<group>";
};
85E717A515682EBF67DD199A /* MarksWidget */ = {
isa = PBXGroup;
children = (
55D369E6D2B8B036685BBD13 /* Info.plist */,
969B364D1E9E6D2E1C610D21 /* MarksWidget.entitlements */,
DE73381C52297CDB30AACCFB /* MarksWidgetBundle.swift */,
96F73C2A0B8AB36C6F80D5FE /* RandomBookmarkWidget.swift */,
49C6260D1530C5C4F1AD063E /* RecentBookmarksWidget.swift */,
47CB3AAED5B64809B06A9650 /* RecentPodcastsWidget.swift */,
0EDC639E0783581A733F4C99 /* WidgetDataStore.swift */,
);
path = MarksWidget;
sourceTree = "<group>";
};
AF9CABF75E77168EF1D60F29 /* Views */ = {
isa = PBXGroup;
children = (
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
CC6B10FBB227F426A2B597C8 /* BookmarkListRow.swift */,
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
@@ -159,7 +224,9 @@
isa = PBXGroup;
children = (
3952A081972B444674BEB51B /* Marks */,
85E717A515682EBF67DD199A /* MarksWidget */,
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
F5B3C85424137FA0CE2467B1 /* Frameworks */,
C6978BB2E0A71E9A349D5EB6 /* Products */,
);
sourceTree = "<group>";
@@ -168,26 +235,56 @@
isa = PBXGroup;
children = (
AB6C53AB14A38FCD4CC7628D /* Marks.app */,
23F172EC9977CD5C51B228B9 /* MarksWidget.appex */,
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
F5B3C85424137FA0CE2467B1 /* Frameworks */ = {
isa = PBXGroup;
children = (
093402014727A338745A06DA /* SwiftUI.framework */,
0552C13335034219DECF4F62 /* WidgetKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
61046BF39C48393DD96687A0 /* MarksWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = 9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */;
buildPhases = (
2B3C202ACE4B7248E24601BF /* Sources */,
A1E820C4B9604F8F05055D3E /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = MarksWidget;
packageProductDependencies = (
);
productName = MarksWidget;
productReference = 23F172EC9977CD5C51B228B9 /* MarksWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
76C272776D8EBCCD3FB6715A /* Marks */ = {
isa = PBXNativeTarget;
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
buildPhases = (
81AF93AC59791A6311B9055B /* Sources */,
D56AC7DEA48961F856A6CEF6 /* Resources */,
6B2547BA0E17C2564677B34A /* Frameworks */,
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */,
);
name = Marks;
packageProductDependencies = (
@@ -222,6 +319,10 @@
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1630;
TargetAttributes = {
61046BF39C48393DD96687A0 = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
};
76C272776D8EBCCD3FB6715A = {
DevelopmentTeam = AE5DZKJHGN;
ProvisioningStyle = Automatic;
@@ -247,6 +348,7 @@
projectRoot = "";
targets = (
76C272776D8EBCCD3FB6715A /* Marks */,
61046BF39C48393DD96687A0 /* MarksWidget */,
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
);
};
@@ -272,17 +374,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
2B3C202ACE4B7248E24601BF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
DA914D93102B9F872DDC2032 /* MarksWidgetBundle.swift in Sources */,
706642006800745A0A0188D0 /* RandomBookmarkWidget.swift in Sources */,
94CEF815D51433054412CB20 /* RecentBookmarksWidget.swift in Sources */,
905FBD9468F488ACE4B81726 /* RecentPodcastsWidget.swift in Sources */,
E056C1B84C267D7554F4D115 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
81AF93AC59791A6311B9055B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
B5EC36EF81525C8FCD2D6C0A /* AnalyticsService.swift in Sources */,
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
CD3013ED0FD018091D18F9FE /* BookmarkListRow.swift in Sources */,
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */,
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
@@ -297,12 +412,18 @@
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
B424D50BE9E6623A4DA15FDC /* String+Helpers.swift in Sources */,
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
E5A8DE01BEB3BC7DBB7FB720 /* WidgetDataStore.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
93598905CCCCA7BBFC2C0716 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 61046BF39C48393DD96687A0 /* MarksWidget */;
targetProxy = 22E9426D1CE4E3C473C5CD5D /* PBXContainerItemProxy */;
};
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
@@ -422,6 +543,23 @@
};
name = Release;
};
6CDF13C33FDD1141CFE677AD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
7AD80830605CAD3492E03039 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -506,6 +644,23 @@
};
name = Debug;
};
D8AB62E7173CC10C6EB15373 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = MarksWidget/MarksWidget.entitlements;
INFOPLIST_FILE = MarksWidget/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.MarksWidget;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -536,6 +691,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
9575EACAD2FDC865F3752A0F /* Build configuration list for PBXNativeTarget "MarksWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6CDF13C33FDD1141CFE677AD /* Debug */,
D8AB62E7173CC10C6EB15373 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;

View File

@@ -21,6 +21,20 @@
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "61046BF39C48393DD96687A0"
BuildableName = "MarksWidget.appex"
BlueprintName = "MarksWidget"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
BuildableName = "ShareExtension.appex"
BlueprintName = "ShareExtension"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</MacroExpansion>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
BuildableName = "Marks.app"
BlueprintName = "Marks"
ReferencedContainer = "container:Marks.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -28,5 +28,14 @@
<array>
<string>audio</string>
</array>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>marks</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@@ -18,16 +18,24 @@ struct MarksApp: App {
defaultConfig.save()
serverConfig = defaultConfig
}
.task { serverConfig.save() }
}
}
}
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders
struct MainContainer: View {
let config: ServerConfig
let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config
@@ -48,5 +56,45 @@ struct MainContainer: View {
SearchView(viewModel: viewModel)
}
}
.onOpenURL { url in
handleDeepLink(url)
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
}
private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
else { return }
switch url.host {
case "bookmark":
if let browserURL = URL(string: articleUrl) {
deepLinkBrowser = IdentifiableURL(url: browserURL)
}
case "podcast":
let podcasts = WidgetDataStore.loadPodcasts()
let found = podcasts.first { $0.articleUrl == articleUrl }
viewModel.podcastPlayer.start(
articleUrl: articleUrl,
articleTitle: found?.title ?? "",
claude: viewModel.claude
)
showDeepLinkPlayer = true
default:
break
}
}
}

View File

@@ -2,6 +2,10 @@ import Foundation
enum Analytics {
static func track(_ event: String, _ properties: [String: Any] = [:]) {
// Serialize before crossing the task boundary Data is Sendable, [String: Any] is not
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
guard let payload = try? JSONSerialization.data(withJSONObject: body) else { return }
Task.detached {
do {
let token = try await MarksAuth.validToken()
@@ -10,9 +14,7 @@ enum Analytics {
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["event": event]
if !properties.isEmpty { body["properties"] = properties }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.httpBody = payload
let (_, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
if status < 200 || status > 299 {

View File

@@ -67,13 +67,13 @@ struct ClaudeService: Sendable {
return try JSONDecoder().decode(PodcastStatus.self, from: data)
}
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil) async throws -> URL {
func downloadPodcastAudio(jobId: String, articleUrl: String, title: String? = nil, parentBookmarkUrl: String? = nil) async throws -> URL {
let dest = ClaudeService.cachedPodcastURL(for: articleUrl)
try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
withIntermediateDirectories: true)
let data = try await get("/v1/podcast/audio/\(jobId)")
try data.write(to: dest, options: .atomic)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title)
PodcastIndex.upsert(articleUrl: articleUrl, filename: dest.lastPathComponent, title: title, parentBookmarkUrl: parentBookmarkUrl)
return dest
}

View File

@@ -6,6 +6,7 @@ struct PodcastEntry: Codable, Identifiable {
let filename: String
var title: String?
let createdAt: Date
var parentBookmarkUrl: String?
}
enum PodcastIndex {
@@ -35,14 +36,19 @@ enum PodcastIndex {
return entries.sorted { $0.createdAt > $1.createdAt }
}
static func upsert(articleUrl: String, filename: String, title: String?) {
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
title: title, createdAt: Date()), at: 0)
title: title, createdAt: Date(),
parentBookmarkUrl: parentBookmarkUrl), at: 0)
save(entries)
}
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
}
static func remove(articleUrl: String) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }
@@ -52,5 +58,8 @@ enum PodcastIndex {
private static func save(_ entries: [PodcastEntry]) {
guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: indexURL, options: .atomic)
WidgetDataStore.savePodcasts(entries.prefix(10).map {
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
})
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
/// Full-featured list row used by BookmarksView, TagBookmarksView, and SearchView.
/// Owns podcast and episode-picker sheet state; parent owns BrowserView sheet.
struct BookmarkListRow: View {
let bookmark: Bookmark
let viewModel: BookmarksViewModel
var readingProgress: Double = 0
let onOpen: () -> Void
var onEdit: (() -> Void)? = nil
@Environment(\.openURL) private var openURL
@State private var showFullPlayer = false
@State private var episodePickerBookmark: Bookmark?
var body: some View {
Button { onOpen() } label: {
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress,
onPodcast: handlePodcast
)
}
.buttonStyle(RowPressStyle())
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
}
.contextMenu {
if let onEdit {
Button { onEdit() } label: {
Label("Edit", systemImage: "pencil")
}
}
Button { onOpen() } label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button { handlePodcast() } label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.sheet(item: $episodePickerBookmark) { b in
EpisodePickerView(bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
}
private func handlePodcast() {
let episodes = PodcastIndex.find(for: bookmark.url)
if episodes.count >= 2 {
episodePickerBookmark = bookmark
} else if let ep = episodes.first {
viewModel.podcastPlayer.start(
articleUrl: ep.articleUrl,
articleTitle: ep.title ?? bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
} else {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
}
}

View File

@@ -5,6 +5,8 @@ struct BookmarkRow: View {
var readingProgress: Double = 0
var onPodcast: (() -> Void)? = nil
@State private var podcastTapCount = 0
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) {
@@ -21,35 +23,44 @@ struct BookmarkRow: View {
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle)
.font(.system(size: 22, weight: .medium))
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 4) {
Text(bookmark.domain)
.font(.system(size: 16))
Text("·")
Text(bookmark.dateAdded, style: .relative)
}
.font(.system(size: 13))
.foregroundStyle(.tertiary)
}
Spacer()
if let onPodcast {
Button(action: onPodcast) {
Button {
podcastTapCount += 1
onPodcast()
} label: {
Image(systemName: podcastCached ? "headphones.circle.fill" : "headphones.circle")
.font(.system(size: 26))
.font(.system(size: 20))
.foregroundStyle(podcastCached ? .blue : Color(.systemGray3))
}
.buttonStyle(.plain)
.padding(.top, 1)
.sensoryFeedback(.impact(weight: .medium), trigger: podcastTapCount)
}
}
if let summary = bookmark.aiSummary {
Text(summary)
.font(.system(size: 15))
.font(.system(size: 14))
.italic()
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 40)
.padding(.leading, 44)
}
if !effectiveTags.isEmpty {
@@ -57,16 +68,16 @@ struct BookmarkRow: View {
HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.blue)
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Color(.systemGray6))
.clipShape(Capsule())
}
}
}
.padding(.leading, 40)
.padding(.leading, 44)
}
// Reading progress indicator
@@ -82,7 +93,7 @@ struct BookmarkRow: View {
}
.frame(height: 2)
.clipShape(Capsule())
.padding(.leading, 40)
.padding(.leading, 44)
}
}
.padding(.vertical, 14)
@@ -119,16 +130,16 @@ struct FaviconView: View {
placeholder
}
}
.frame(width: 26, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 5))
.frame(width: 32, height: 32)
.clipShape(RoundedRectangle(cornerRadius: 7))
}
private var placeholder: some View {
RoundedRectangle(cornerRadius: 4)
RoundedRectangle(cornerRadius: 6)
.fill(Color(.systemGray5))
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 9, weight: .medium))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
}

View File

@@ -1,98 +1,85 @@
import SwiftUI
// MARK: - Skeleton loading row
private struct SkeletonRow: View {
var delay: Double = 0
@State private var dimmed = false
var body: some View {
HStack(alignment: .top, spacing: 11) {
RoundedRectangle(cornerRadius: 7)
.fill(Color(.systemGray5))
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 7) {
Capsule()
.fill(Color(.systemGray5))
.frame(maxWidth: .infinity)
.frame(height: 13)
Capsule()
.fill(Color(.systemGray6))
.frame(width: 140, height: 10)
}
.padding(.top, 4)
Spacer()
}
.padding(.vertical, 14)
.opacity(dimmed ? 0.45 : 1)
.onAppear {
withAnimation(
.easeInOut(duration: 0.9)
.repeatForever(autoreverses: true)
.delay(delay)
) { dimmed = true }
}
}
}
// MARK: - Row press ButtonStyle
struct RowPressStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.opacity(configuration.isPressed ? 0.75 : 1)
.animation(.spring(duration: 0.18, bounce: 0), value: configuration.isPressed)
}
}
// MARK: - BookmarksView
struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@Environment(\.openURL) private var openURL
@State private var showSettings = false
@State private var showCollections = false
@State private var showAddBookmark = false
@State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark?
@State private var showFullPlayer = false
@State private var showPodcastLibrary = false
@State private var readingProgress: [String: Double] = ReadingProgress.all()
var body: some View {
NavigationStack {
List {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkRow(
bookmark: bookmark,
readingProgress: readingProgress[bookmark.url] ?? 0,
onPodcast: {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
)
showFullPlayer = true
}
)
.onTapGesture {
browsingBookmark = bookmark
}
.onAppear { maybeLoadMore(bookmark) }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ForEach(0..<3, id: \.self) { i in
SkeletonRow(delay: Double(i) * 0.13)
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(.blue)
}
.contextMenu {
Button {
editingBookmark = bookmark
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
browsingBookmark = bookmark
} label: {
Label("Open", systemImage: "globe")
}
Button {
if let url = URL(string: bookmark.url) { openURL(url) }
} label: {
Label("Open in Safari", systemImage: "safari")
}
Button {
viewModel.podcastPlayer.start(
articleUrl: bookmark.url,
articleTitle: bookmark.displayTitle,
claude: viewModel.claude
} else {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
readingProgress: readingProgress[bookmark.url] ?? 0,
onOpen: { browsingBookmark = bookmark },
onEdit: { editingBookmark = bookmark }
)
showFullPlayer = true
} label: {
Label("Convert to Podcast", systemImage: "headphones")
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
.onAppear { maybeLoadMore(bookmark) }
}
}
@@ -102,6 +89,7 @@ struct BookmarksView: View {
}
}
.listStyle(.plain)
.animation(.spring(duration: 0.35), value: viewModel.bookmarks.isEmpty)
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
@@ -118,6 +106,11 @@ struct BookmarksView: View {
} label: {
Label("Enrich All with AI", systemImage: "wand.and.stars")
}
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: {
Image(systemName: "sparkles")
}
@@ -133,6 +126,7 @@ struct BookmarksView: View {
Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.contentTransition(.symbolEffect(.replace))
}
Button { showSettings = true } label: {
Image(systemName: "gearshape")
@@ -141,16 +135,18 @@ struct BookmarksView: View {
}
}
.overlay {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView()
}
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark",
description: Text("Bookmarks you save will appear here.")
)
.transition(.opacity)
}
}
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
if viewModel.podcastPlayer.player != nil {
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true
}
@@ -160,9 +156,10 @@ struct BookmarksView: View {
enrichmentBanner
}
}
.animation(.spring(duration: 0.3), value: viewModel.podcastPlayer.player != nil)
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.padding(.bottom, 8)
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
@@ -178,7 +175,9 @@ struct BookmarksView: View {
}
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer) {
await viewModel.archive(bookmark)
}
}
}
.onChange(of: browsingBookmark) { _, new in
@@ -193,6 +192,9 @@ struct BookmarksView: View {
stopOnDismiss: false
)
}
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable {
await viewModel.load()
}

View File

@@ -39,6 +39,9 @@ final class BookmarksViewModel {
nextPageUrl = response.next
restoreAIData()
saveToCache(bookmarks)
WidgetDataStore.saveBookmarks(bookmarks.prefix(20).map {
WidgetBookmark(url: $0.url, title: $0.displayTitle, domain: $0.domain, faviconUrl: $0.faviconUrl)
})
} catch {
self.error = error.localizedDescription
}

View File

@@ -25,6 +25,29 @@ enum ReadingProgress {
}
}
// MARK: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// MARK: - BrowserState
@Observable
@@ -196,18 +219,25 @@ struct BrowserView: View {
let initialURL: URL
let initialTitle: String
let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
var onArchive: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var state: BrowserState
@State private var readerMode = false
@State private var showPodcast = false
@State private var podcastVM = PodcastPlayerViewModel()
@State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService) {
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url))
}
@@ -221,16 +251,17 @@ struct BrowserView: View {
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.7))
.fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress)
}
// Page loading sweep bar (replaces center spinner)
if state.isLoading {
ProgressView()
.padding(.top, 8)
PageLoadBar()
.transition(.opacity)
}
}
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
@@ -247,42 +278,26 @@ struct BrowserView: View {
Image(systemName: "safari")
}
}
ToolbarItemGroup(placement: .bottomBar) {
Button { state.webView.goBack() } label: {
Image(systemName: "chevron.left")
}
.disabled(!state.canGoBack)
Spacer()
Button { state.webView.goForward() } label: {
Image(systemName: "chevron.right")
}
.disabled(!state.canGoForward)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
}
.disabled(state.isLoading)
Spacer()
Button { showPodcast = true } label: {
Image(systemName: "headphones")
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomBar
.offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
}
.onChange(of: state.readingProgress) { old, new in
let delta = new - old
withAnimation(.spring(duration: 0.3, bounce: 0)) {
if delta > 0.012 && new > 0.07 {
toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
toolbarHidden = false
}
}
}
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear {
@@ -292,12 +307,101 @@ struct BrowserView: View {
}
.sheet(isPresented: $showPodcast) {
PodcastPlayerView(
vm: podcastVM,
vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude,
stopOnDismiss: true
stopOnDismiss: false
)
}
}
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.overlay(alignment: .top) { Divider() }
}
}

View File

@@ -42,14 +42,26 @@ final class PodcastPlayerViewModel {
var duration: Double = 1
var currentArticleUrl: String = ""
var currentArticleTitle: String = ""
var playbackSpeed: Float = 1.0
static let availableSpeeds: [Float] = [0.75, 1.0, 1.25, 1.5, 2.0]
var isGenerating: Bool {
switch phase {
case .generating, .downloading: return true
default: return false
}
}
private var pollingTask: Task<Void, Never>?
private var timeObserver: Any?
private var endObserver: Any?
private var interruptionObserver: Any?
private var currentPodcastTitle: String = "Marks Podcast"
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService) {
// Idempotent don't restart the same article if already playing
if currentArticleUrl == articleUrl && player != nil { return }
func start(articleUrl: String, articleTitle: String = "", claude: ClaudeService, parentBookmarkUrl: String? = nil) {
// Idempotent don't restart if already generating or playing this article
if currentArticleUrl == articleUrl && (player != nil || pollingTask != nil) { return }
// Teardown previous episode (saves its position)
if player != nil { stop() }
@@ -78,7 +90,7 @@ final class PodcastPlayerViewModel {
)
if job.isDone {
phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title)
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId, articleUrl: articleUrl, title: job.title, parentBookmarkUrl: parentBookmarkUrl)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
@@ -86,6 +98,7 @@ final class PodcastPlayerViewModel {
let errMsg = job.error ?? "Generation failed"
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
phase = .failed(errMsg)
pollingTask = nil
return
}
try await Task.sleep(for: .seconds(3))
@@ -94,6 +107,7 @@ final class PodcastPlayerViewModel {
} catch {
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
phase = .failed(error.localizedDescription)
pollingTask = nil
}
}
}
@@ -105,6 +119,10 @@ final class PodcastPlayerViewModel {
pollingTask?.cancel()
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
if let obs = endObserver { NotificationCenter.default.removeObserver(obs) }
endObserver = nil
if let obs = interruptionObserver { NotificationCenter.default.removeObserver(obs) }
interruptionObserver = nil
player = nil
isPlaying = false
currentTime = 0
@@ -118,12 +136,18 @@ final class PodcastPlayerViewModel {
c.playCommand.removeTarget(nil)
c.pauseCommand.removeTarget(nil)
c.changePlaybackPositionCommand.removeTarget(nil)
c.skipBackwardCommand.removeTarget(nil)
c.skipForwardCommand.removeTarget(nil)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func togglePlayPause() {
guard let player else { return }
if isPlaying { player.pause() } else { player.play() }
if isPlaying {
player.pause()
} else {
player.rate = playbackSpeed
}
isPlaying.toggle()
updateNowPlaying()
}
@@ -134,6 +158,20 @@ final class PodcastPlayerViewModel {
updateNowPlaying()
}
func skipForward15() {
seek(to: min(currentTime + 15, duration))
}
func skipBackward15() {
seek(to: max(currentTime - 15, 0))
}
func setPlaybackSpeed(_ speed: Float) {
playbackSpeed = speed
if isPlaying { player?.rate = speed }
updateNowPlaying()
}
private func setupPlayer(url: URL, title: String) {
currentPodcastTitle = title
@@ -158,6 +196,40 @@ final class PodcastPlayerViewModel {
}
}
// Reset to start when playback finishes, like standard media players
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main
) { [weak self] _ in
guard let self else { return }
self.isPlaying = false
self.currentTime = 0
self.player?.seek(to: .zero)
PodcastProgress.set(url: self.currentArticleUrl, time: 0, duration: self.duration)
self.updateNowPlaying()
}
// Pause on phone calls / Siri; resume when interruption ends
interruptionObserver = NotificationCenter.default.addObserver(
forName: AVAudioSession.interruptionNotification, object: nil, queue: .main
) { [weak self] notification in
guard let self,
let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
if type == .began {
self.player?.pause()
self.isPlaying = false
self.updateNowPlaying()
} else if type == .ended {
let optionsValue = notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
if options.contains(.shouldResume) {
self.player?.rate = self.playbackSpeed
self.isPlaying = true
self.updateNowPlaying()
}
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
guard let self else { return }
@@ -173,7 +245,7 @@ final class PodcastPlayerViewModel {
setupRemoteCommands()
phase = .ready(title: title)
updateNowPlaying()
p.play()
p.rate = playbackSpeed
isPlaying = true
}
@@ -194,18 +266,48 @@ final class PodcastPlayerViewModel {
self?.currentTime = e.positionTime
return .success
}
c.skipBackwardCommand.isEnabled = true
c.skipBackwardCommand.preferredIntervals = [15]
c.skipBackwardCommand.addTarget { [weak self] _ in
self?.skipBackward15(); return .success
}
c.skipForwardCommand.isEnabled = true
c.skipForwardCommand.preferredIntervals = [15]
c.skipForwardCommand.addTarget { [weak self] _ in
self?.skipForward15(); return .success
}
}
private func updateNowPlaying() {
var info: [String: Any] = [:]
info[MPMediaItemPropertyTitle] = currentPodcastTitle
info[MPMediaItemPropertyArtist] = currentArticleTitle.isEmpty ? "Marks" : currentArticleTitle
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? Double(playbackSpeed) : 0.0
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
if duration > 1 { info[MPMediaItemPropertyPlaybackDuration] = duration }
if let artwork = Self.nowPlayingArtwork {
info[MPMediaItemPropertyArtwork] = artwork
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
private static let nowPlayingArtwork: MPMediaItemArtwork? = {
let size = CGSize(width: 300, height: 300)
let renderer = UIGraphicsImageRenderer(size: size)
let image = renderer.image { ctx in
UIColor.systemBlue.withAlphaComponent(0.15).setFill()
ctx.fill(CGRect(origin: .zero, size: size))
let config = UIImage.SymbolConfiguration(pointSize: 140, weight: .regular)
if let symbol = UIImage(systemName: "waveform.circle.fill", withConfiguration: config) {
let tinted = symbol.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
let origin = CGPoint(x: (size.width - tinted.size.width) / 2,
y: (size.height - tinted.size.height) / 2)
tinted.draw(at: origin)
}
}
return MPMediaItemArtwork(boundsSize: size) { _ in image }
}()
private static func statusLabel(_ status: String) -> String {
switch status {
case "queued": return "Queued…"
@@ -243,7 +345,7 @@ struct PodcastPlayerView: View {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
if vm.player != nil {
if !vm.currentArticleUrl.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button { vm.stop(); dismiss() } label: {
Image(systemName: "stop.circle")
@@ -325,11 +427,54 @@ struct PodcastPlayerView: View {
.foregroundStyle(.tertiary)
}
HStack(spacing: 36) {
Button { vm.skipBackward15() } label: {
Image(systemName: "gobackward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
Button { vm.skipForward15() } label: {
Image(systemName: "goforward.15")
.font(.system(size: 32))
.foregroundStyle(.primary)
}
}
Menu {
ForEach(PodcastPlayerViewModel.availableSpeeds, id: \.self) { speed in
Button {
vm.setPlaybackSpeed(speed)
} label: {
let label = speed == 1.0 ? "1× (Normal)" : "\(String(format: "%g", speed))×"
if speed == vm.playbackSpeed {
Label(label, systemImage: "checkmark")
} else {
Text(label)
}
}
}
} label: {
Text(vm.playbackSpeed == 1.0 ? "1× Speed" : "\(String(format: "%g", vm.playbackSpeed))×")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(Color(.systemGray5))
.clipShape(Capsule())
}
if let url = URL(string: articleUrl) {
ShareLink(item: url, subject: Text(vm.currentArticleTitle.isEmpty ? "Marks Podcast" : vm.currentArticleTitle)) {
Label("Share Episode", systemImage: "square.and.arrow.up")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
}
}
}
}
@@ -344,6 +489,18 @@ struct PodcastPlayerView: View {
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button {
vm.start(articleUrl: articleUrl, articleTitle: articleTitle, claude: claude)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color.blue)
.clipShape(Capsule())
}
.padding(.top, 4)
}
}
@@ -365,7 +522,7 @@ struct MiniPlayerView: View {
Image(systemName: "waveform")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.blue)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying)
.symbolEffect(.variableColor.iterative, isActive: vm.isPlaying || vm.isGenerating)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
@@ -380,6 +537,10 @@ struct MiniPlayerView: View {
Spacer()
if vm.isGenerating {
ProgressView()
.frame(width: 36, height: 36)
} else {
Button {
vm.togglePlayPause()
} label: {
@@ -389,6 +550,7 @@ struct MiniPlayerView: View {
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
}
Button {
vm.stop()
@@ -435,3 +597,149 @@ struct MiniPlayerView: View {
return mins > 0 ? "\(mins) min left" : "Almost done"
}
}
// MARK: - Podcast library
struct PodcastLibraryView: View {
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if entries.isEmpty {
ContentUnavailableView(
"No Podcasts",
systemImage: "headphones",
description: Text("Podcasts you generate will appear here.")
)
} else {
List {
ForEach(entries) { entry in
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
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 {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? "",
claude: claude)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
deleteEntry(entry)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
.onDelete { indexSet in
indexSet.forEach { deleteEntry(entries[$0]) }
}
}
.listStyle(.plain)
}
}
.navigationTitle("Podcasts")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.all() }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}
// MARK: - Episode picker (multiple episodes per bookmark)
struct EpisodePickerView: View {
let bookmark: Bookmark
let vm: PodcastPlayerViewModel
let claude: ClaudeService
@State private var entries: [PodcastEntry] = []
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
ForEach(entries) { entry in
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(entry.title ?? entry.articleUrl)
.font(.system(size: 15, weight: .medium))
.lineLimit(2)
if entry.articleUrl != bookmark.url {
Text(entry.articleUrl)
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
Text(entry.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Spacer()
Button {
vm.start(articleUrl: entry.articleUrl,
articleTitle: entry.title ?? bookmark.displayTitle,
claude: claude)
dismiss()
} label: {
Image(systemName: "play.circle.fill")
.font(.system(size: 36))
.foregroundStyle(.blue)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) { deleteEntry(entry) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Episodes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.onAppear { entries = PodcastIndex.find(for: bookmark.url) }
}
private func deleteEntry(_ entry: PodcastEntry) {
PodcastIndex.remove(articleUrl: entry.articleUrl)
entries.removeAll { $0.articleUrl == entry.articleUrl }
if vm.currentArticleUrl == entry.articleUrl { vm.stop() }
}
}

View File

@@ -2,30 +2,22 @@ import SwiftUI
struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = ""
@State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>?
@State private var browsingBookmark: Bookmark?
var body: some View {
NavigationStack {
List {
ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
onOpen: { browsingBookmark = bookmark }
)
}
}
.listStyle(.plain)
@@ -49,6 +41,19 @@ struct SearchView: View {
ProgressView()
}
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer
) {
await viewModel.archive(bookmark)
}
}
}
}
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() }

View File

@@ -48,32 +48,17 @@ struct TagsView: View {
struct TagBookmarksView: View {
let tag: String
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var browsingBookmark: Bookmark?
var body: some View {
List {
ForEach(filteredBookmarks) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
BookmarkListRow(
bookmark: bookmark,
viewModel: viewModel,
onOpen: { browsingBookmark = bookmark }
)
}
}
.listStyle(.plain)
@@ -84,6 +69,19 @@ struct TagBookmarksView: View {
ContentUnavailableView("No Bookmarks", systemImage: "tag")
}
}
.sensoryFeedback(.selection, trigger: browsingBookmark?.id)
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(
url: url,
title: bookmark.displayTitle,
claude: viewModel.claude,
podcastPlayer: viewModel.podcastPlayer
) {
await viewModel.archive(bookmark)
}
}
}
}
private var filteredBookmarks: [Bookmark] {

25
MarksWidget/Info.plist Normal file
View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>MarksWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.magicive.marks</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,11 @@
import WidgetKit
import SwiftUI
@main
struct MarksWidgetBundle: WidgetBundle {
var body: some Widget {
RecentBookmarksWidget()
RandomBookmarkWidget()
RecentPodcastsWidget()
}
}

View File

@@ -0,0 +1,98 @@
import WidgetKit
import SwiftUI
struct RandomBookmarkEntry: TimelineEntry {
let date: Date
let bookmark: WidgetBookmark?
static let placeholder = RandomBookmarkEntry(
date: .now,
bookmark: WidgetBookmark(url: "https://example.com", title: "A Saved Article Worth Revisiting", domain: "example.com", faviconUrl: nil)
)
}
struct RandomBookmarkProvider: TimelineProvider {
func placeholder(in context: Context) -> RandomBookmarkEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RandomBookmarkEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RandomBookmarkEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RandomBookmarkEntry(date: .now, bookmark: bookmarks.randomElement())
let next = Calendar.current.date(byAdding: .hour, value: 1, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RandomBookmarkWidgetView: View {
let entry: RandomBookmarkEntry
var body: some View {
Group {
if let bookmark = entry.bookmark {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Rediscover")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "sparkles")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
Spacer()
RoundedRectangle(cornerRadius: 7)
.fill(Color.blue.opacity(0.1))
.frame(width: 38, height: 38)
.overlay {
Image(systemName: "bookmark.fill")
.font(.system(size: 15))
.foregroundStyle(.blue)
}
Spacer().frame(height: 10)
Text(bookmark.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
.lineLimit(3)
.fixedSize(horizontal: false, vertical: true)
Text(bookmark.domain)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.widgetURL(.marksBookmark(bookmark.url))
} else {
VStack(spacing: 8) {
Image(systemName: "bookmark")
.font(.system(size: 28))
.foregroundStyle(.secondary)
Text("Open Marks to\nload bookmarks.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct RandomBookmarkWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RandomBookmark",
provider: RandomBookmarkProvider()
) { entry in
RandomBookmarkWidgetView(entry: entry)
}
.configurationDisplayName("Rediscover")
.description("A random saved bookmark to revisit.")
.supportedFamilies([.systemSmall])
}
}

View File

@@ -0,0 +1,108 @@
import WidgetKit
import SwiftUI
struct RecentBookmarksEntry: TimelineEntry {
let date: Date
let bookmarks: [WidgetBookmark]
static let placeholder = RecentBookmarksEntry(date: .now, bookmarks: [
WidgetBookmark(url: "https://example.com/a", title: "The Future of AI in 2025", domain: "example.com", faviconUrl: nil),
WidgetBookmark(url: "https://news.com/b", title: "Why Swift Concurrency Matters", domain: "news.com", faviconUrl: nil),
WidgetBookmark(url: "https://blog.dev/c", title: "Building Great iOS Apps", domain: "blog.dev", faviconUrl: nil),
])
}
struct RecentBookmarksProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentBookmarksEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentBookmarksEntry) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
completion(RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentBookmarksEntry>) -> Void) {
let bookmarks = WidgetDataStore.loadBookmarks()
let entry = RecentBookmarksEntry(date: .now, bookmarks: Array(bookmarks.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentBookmarksWidgetView: View {
let entry: RecentBookmarksEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Recent")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "bookmark.fill")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.bookmarks.isEmpty {
Spacer()
Text("Save bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.bookmarks.enumerated()), id: \.element.id) { idx, bookmark in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksBookmark(bookmark.url)) {
WidgetBookmarkRow(bookmark: bookmark)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetBookmarkRow: View {
let bookmark: WidgetBookmark
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color(.systemGray5))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
VStack(alignment: .leading, spacing: 1) {
Text(bookmark.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(bookmark.domain)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
}
struct RecentBookmarksWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentBookmarks",
provider: RecentBookmarksProvider()
) { entry in
RecentBookmarksWidgetView(entry: entry)
}
.configurationDisplayName("Recent Bookmarks")
.description("Your latest saved bookmarks.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,111 @@
import WidgetKit
import SwiftUI
struct RecentPodcastsEntry: TimelineEntry {
let date: Date
let podcasts: [WidgetPodcast]
static let placeholder = RecentPodcastsEntry(date: .now, podcasts: [
WidgetPodcast(articleUrl: "https://example.com/a", title: "The Future of AI", createdAt: .now),
WidgetPodcast(articleUrl: "https://news.com/b", title: "Swift Concurrency Deep Dive", createdAt: .now.addingTimeInterval(-86400)),
WidgetPodcast(articleUrl: "https://blog.dev/c", title: "Building Great iOS Apps", createdAt: .now.addingTimeInterval(-172800)),
])
}
struct RecentPodcastsProvider: TimelineProvider {
func placeholder(in context: Context) -> RecentPodcastsEntry { .placeholder }
func getSnapshot(in context: Context, completion: @escaping (RecentPodcastsEntry) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
completion(RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3))))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<RecentPodcastsEntry>) -> Void) {
let podcasts = WidgetDataStore.loadPodcasts()
let entry = RecentPodcastsEntry(date: .now, podcasts: Array(podcasts.prefix(3)))
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
completion(Timeline(entries: [entry], policy: .after(next)))
}
}
struct RecentPodcastsWidgetView: View {
let entry: RecentPodcastsEntry
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Podcasts")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Image(systemName: "headphones")
.font(.system(size: 11))
.foregroundStyle(.blue)
}
.padding(.bottom, 8)
if entry.podcasts.isEmpty {
Spacer()
Text("Generate podcasts from your bookmarks to see them here.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
Spacer()
} else {
ForEach(Array(entry.podcasts.enumerated()), id: \.element.id) { idx, podcast in
if idx > 0 { Divider().padding(.vertical, 5) }
Link(destination: .marksPodcast(podcast.articleUrl)) {
WidgetPodcastRow(podcast: podcast)
}
}
Spacer(minLength: 0)
}
}
.padding(14)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.containerBackground(.fill.tertiary, for: .widget)
}
}
struct WidgetPodcastRow: View {
let podcast: WidgetPodcast
var body: some View {
HStack(spacing: 8) {
RoundedRectangle(cornerRadius: 5)
.fill(Color.blue.opacity(0.1))
.frame(width: 26, height: 26)
.overlay {
Image(systemName: "waveform")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.blue)
}
VStack(alignment: .leading, spacing: 1) {
Text(podcast.title)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Text(podcast.createdAt, style: .relative)
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "play.circle.fill")
.font(.system(size: 18))
.foregroundStyle(.blue.opacity(0.8))
}
}
}
struct RecentPodcastsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(
kind: "com.magicive.marks.RecentPodcasts",
provider: RecentPodcastsProvider()
) { entry in
RecentPodcastsWidgetView(entry: entry)
}
.configurationDisplayName("Recent Podcasts")
.description("Tap to play recently generated podcasts.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import OSLog
#if canImport(WidgetKit)
import WidgetKit
#endif
private let log = Logger(subsystem: "com.magicive.marks", category: "WidgetDataStore")
struct WidgetBookmark: Codable, Identifiable, Sendable {
var id: String { url }
let url: String
let title: String
let domain: String
let faviconUrl: String?
}
struct WidgetPodcast: Codable, Identifiable, Sendable {
var id: String { articleUrl }
let articleUrl: String
let title: String
let createdAt: Date
}
enum WidgetDataStore {
static let groupID = "group.com.magicive.marks"
private static var containerURL: URL? {
let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID)
if url == nil { log.error("App Group container not found — group ID: \(groupID)") }
return url
}
private static func fileURL(_ name: String) -> URL? {
containerURL?.appendingPathComponent(name)
}
private static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()
private static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
// MARK: - Bookmarks
static func saveBookmarks(_ bookmarks: [WidgetBookmark]) {
guard let url = fileURL("widget_bookmarks.json"),
let data = try? encoder.encode(bookmarks) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadBookmarks() -> [WidgetBookmark] {
guard let url = fileURL("widget_bookmarks.json") else {
log.error("loadBookmarks: no container URL")
return []
}
guard let data = try? Data(contentsOf: url) else {
log.warning("loadBookmarks: file not found at \(url.path)")
return []
}
guard let items = try? decoder.decode([WidgetBookmark].self, from: data) else {
log.error("loadBookmarks: decode failed")
return []
}
log.info("loadBookmarks: loaded \(items.count) bookmarks")
return items
}
// MARK: - Podcasts
static func savePodcasts(_ podcasts: [WidgetPodcast]) {
guard let url = fileURL("widget_podcasts.json"),
let data = try? encoder.encode(podcasts) else { return }
try? data.write(to: url, options: .atomic)
reloadTimelines()
}
static func loadPodcasts() -> [WidgetPodcast] {
guard let url = fileURL("widget_podcasts.json"),
let data = try? Data(contentsOf: url),
let items = try? decoder.decode([WidgetPodcast].self, from: data)
else { return [] }
return items
}
private static func reloadTimelines() {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
}
}
// MARK: - Deep link URL helpers
extension URL {
static func marksBookmark(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "bookmark"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://bookmark")!
}
static func marksPodcast(_ url: String) -> URL {
var c = URLComponents()
c.scheme = "marks"
c.host = "podcast"
c.queryItems = [URLQueryItem(name: "url", value: url)]
return c.url ?? URL(string: "marks://podcast")!
}
}

View File

@@ -21,6 +21,7 @@ schemes:
targets:
Marks: all
ShareExtension: all
MarksWidget: all
run:
config: Debug
test:
@@ -32,12 +33,37 @@ schemes:
archive:
config: Release
MarksWidget:
build:
targets:
Marks: all
MarksWidget: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
ShareExtension:
build:
targets:
Marks: all
ShareExtension: all
run:
config: Debug
analyze:
config: Debug
archive:
config: Release
targets:
Marks:
type: application
platform: iOS
sources:
- path: Marks
- path: MarksWidget/WidgetDataStore.swift
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks
@@ -46,6 +72,8 @@ targets:
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
dependencies:
- target: ShareExtension
- target: MarksWidget
- sdk: WidgetKit.framework
ShareExtension:
type: app-extension
@@ -59,3 +87,18 @@ targets:
CODE_SIGN_ENTITLEMENTS: ShareExtension/ShareExtension.entitlements
SKIP_INSTALL: YES
APPLICATION_EXTENSION_API_ONLY: YES
MarksWidget:
type: app-extension
platform: iOS
sources:
- path: MarksWidget
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.magicive.marks.MarksWidget
INFOPLIST_FILE: MarksWidget/Info.plist
CODE_SIGN_ENTITLEMENTS: MarksWidget/MarksWidget.entitlements
SKIP_INSTALL: YES
dependencies:
- sdk: WidgetKit.framework
- sdk: SwiftUI.framework