import SwiftUI // MARK: - Shared bookmark actions // // The context menu and the podcast launch path are identical whether a bookmark // is presented as a list row or as a library card. They live here so the two // presentations can't drift apart — the menu is a @ViewBuilder rather than a // ViewModifier because each host already owns the sheets it needs to present, // and a modifier would have forced a second copy of that state. /// Every action a bookmark offers, in the order they appear in the menu. /// /// `@MainActor` because the callbacks are plain (non-Sendable) UI closures — /// without it, Swift 6 treats handing them to this function as sending them /// across isolation domains. @MainActor @ViewBuilder func bookmarkMenuItems( bookmark: Bookmark, viewModel: BookmarksViewModel, openURL: OpenURLAction, onOpen: @escaping () -> Void, onEdit: (() -> Void)?, onPodcast: @escaping () -> Void ) -> some View { 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 { onPodcast() } 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") } } /// Resolve what a "convert to podcast" tap should do. Several cached episodes /// means the user picks; one means play it; none means generate — and /// generating only takes over the player when it isn't already busy. /// /// The caller owns the sheet state because the presenting view has to. @MainActor func launchPodcast( for bookmark: Bookmark, viewModel: BookmarksViewModel, showFullPlayer: Binding, episodePicker: Binding ) { let episodes = PodcastIndex.find(for: bookmark.url) if episodes.count >= 2 { episodePicker.wrappedValue = bookmark } else if let ep = episodes.first { viewModel.podcastPlayer.start( articleUrl: ep.articleUrl, articleTitle: ep.title ?? bookmark.displayTitle, claude: viewModel.claude ) showFullPlayer.wrappedValue = true } else if viewModel.playOrGeneratePodcast( articleUrl: bookmark.url, title: bookmark.displayTitle ) { showFullPlayer.wrappedValue = true } } /// The two sheets any bookmark presentation needs once it offers podcasts. struct PodcastSheets: ViewModifier { let viewModel: BookmarksViewModel @Binding var showFullPlayer: Bool @Binding var episodePicker: Bookmark? func body(content: Content) -> some View { content .sheet(isPresented: $showFullPlayer) { PodcastPlayerView( vm: viewModel.podcastPlayer, articleUrl: viewModel.podcastPlayer.currentArticleUrl, articleTitle: viewModel.podcastPlayer.currentArticleTitle, claude: viewModel.claude, stopOnDismiss: false ) } .sheet(item: $episodePicker) { b in EpisodePickerView( bookmark: b, vm: viewModel.podcastPlayer, claude: viewModel.claude, podcastGenerator: viewModel.podcastGenerator ) } } } extension View { func podcastSheets( viewModel: BookmarksViewModel, showFullPlayer: Binding, episodePicker: Binding ) -> some View { modifier(PodcastSheets( viewModel: viewModel, showFullPlayer: showFullPlayer, episodePicker: episodePicker )) } }