Files
linkding-ios/Marks/Views/BookmarksView.swift
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

245 lines
10 KiB
Swift

import SwiftUI
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))
.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
)
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")
}
}
}
if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.navigationTitle(viewModel.unreadFilter ? "Unread" : "Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Button {
Task { await viewModel.generateSmartCollections() }
showCollections = true
} label: {
Label("Smart Collections", systemImage: "sparkles")
}
Button {
Task { await viewModel.enrichAll() }
} label: {
Label("Enrich All with AI", systemImage: "wand.and.stars")
}
Button {
showPodcastLibrary = true
} label: {
Label("Podcasts", systemImage: "headphones")
}
} label: {
Image(systemName: "sparkles")
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button { showAddBookmark = true } label: {
Image(systemName: "plus")
}
Button {
Task { await viewModel.toggleUnreadFilter() }
} label: {
Image(systemName: viewModel.unreadFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
}
}
}
.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."))
}
}
.overlay(alignment: .bottom) {
VStack(spacing: 8) {
if !viewModel.podcastPlayer.currentArticleUrl.isEmpty {
MiniPlayerView(vm: viewModel.podcastPlayer) {
showFullPlayer = true
}
.transition(.move(edge: .bottom).combined(with: .opacity))
}
if viewModel.enrichmentProgress > 0 {
enrichmentBanner
}
}
.animation(.spring(duration: 0.3), value: !viewModel.podcastPlayer.currentArticleUrl.isEmpty)
.padding(.bottom, 8)
}
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
}
.sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel)
}
.sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel)
}
.sheet(item: $editingBookmark) { bookmark in
EditBookmarkView(viewModel: viewModel, bookmark: bookmark)
}
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
}
}
.onChange(of: browsingBookmark) { _, new in
if new == nil { readingProgress = ReadingProgress.all() }
}
.sheet(isPresented: $showFullPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
.sheet(isPresented: $showPodcastLibrary) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
.refreshable {
await viewModel.load()
}
.task {
if viewModel.bookmarks.isEmpty {
await viewModel.load()
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
private var enrichmentBanner: some View {
HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear)
.tint(.primary)
Text("Adding AI summaries…")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
}
private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
Task { await viewModel.loadMore() }
}
}