Turns the Podcasts tab into a proper podcast-app experience across three features that compose on a shared background-generation service. Background generation (#2) - New PodcastGenerationManager runs generate→poll→download off the player, so producing a new episode never stops current playback. - Headphone taps never interrupt: idle → generate-and-play in the foreground (unchanged); already playing → generate in the background, episode lands in the library. A "Generating…" banner surfaces active jobs. Played/unplayed + queue (#1) - PodcastEntry gains playedAt (old index.json decodes as unplayed). - Podcasts tab splits into Up Next / Played with a Play All queue that auto-advances; finishing marks played; swipe to toggle played state. Share-extension audio (#3) - "Create podcast" toggle on the save card, plus configurable Auto-Podcast Tags in Settings. The extension enqueues a request into the App Group; the main app drains it on launch/foreground and generates in the background. Closes #1 Closes #2 Closes #3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
321 lines
12 KiB
Swift
321 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
/// Interactive save card shown by the share extension. On open it checks whether
|
|
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
|
|
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
|
|
struct ShareView: View {
|
|
let url: String
|
|
/// Called when the extension should dismiss. `saved` is true after a successful
|
|
/// write, false on cancel.
|
|
let onClose: (_ saved: Bool) -> Void
|
|
|
|
@State private var phase: Phase = .loading
|
|
@State private var existing: Bookmark?
|
|
@State private var title = ""
|
|
@State private var tagText = ""
|
|
@State private var notes = ""
|
|
@State private var readLater = false
|
|
@State private var createPodcast = false
|
|
@State private var suggestedTags: [String] = [] // linkding auto_tags
|
|
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
|
|
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
|
|
@State private var suggesting = false
|
|
@State private var errorMessage = ""
|
|
|
|
@FocusState private var tagsFocused: Bool
|
|
|
|
private let api: LinkdingAPI?
|
|
|
|
init(url: String, onClose: @escaping (_ saved: Bool) -> Void) {
|
|
self.url = url
|
|
self.onClose = onClose
|
|
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
|
|
}
|
|
|
|
private enum Phase { case loading, editing, saving, done, failed }
|
|
|
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
|
let f = RelativeDateTimeFormatter()
|
|
f.unitsStyle = .abbreviated
|
|
f.dateTimeStyle = .named
|
|
return f
|
|
}()
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Spacer()
|
|
card
|
|
.frame(maxWidth: 460)
|
|
.padding(.horizontal, 20)
|
|
Spacer()
|
|
}
|
|
.task { await check() }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var card: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
switch phase {
|
|
case .loading:
|
|
HStack(spacing: 12) {
|
|
ProgressView()
|
|
Text("Checking…").foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 24)
|
|
|
|
case .editing, .saving:
|
|
header
|
|
editor
|
|
actions
|
|
|
|
case .done:
|
|
statusCard(icon: "checkmark.circle.fill", tint: .green,
|
|
text: existing != nil ? "Updated" : "Saved")
|
|
|
|
case .failed:
|
|
statusCard(icon: "xmark.circle.fill", tint: .red,
|
|
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let existing {
|
|
Label {
|
|
Text(existing.dateAdded == .distantPast
|
|
? "Already saved"
|
|
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
|
|
} icon: {
|
|
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
|
}
|
|
.font(.subheadline.weight(.semibold))
|
|
} else {
|
|
Text("Save bookmark").font(.headline)
|
|
}
|
|
|
|
Text(title.isEmpty ? url : title)
|
|
.font(.subheadline.weight(.medium))
|
|
.lineLimit(2)
|
|
Text(domain)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var editor: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
TextField("Tags (space separated)", text: $tagText)
|
|
.textInputAutocapitalization(.never)
|
|
.autocorrectionDisabled()
|
|
.focused($tagsFocused)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
if suggesting && aiSuggestions.isEmpty {
|
|
HStack(spacing: 6) {
|
|
ProgressView().controlSize(.small)
|
|
Text("Suggesting tags…")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
if !suggestionChips.isEmpty {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 6) {
|
|
ForEach(suggestionChips, id: \.tag) { item in
|
|
Button { addTag(item.tag) } label: {
|
|
Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)")
|
|
.font(.caption.weight(.medium))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background((item.isAI ? Color.purple : .blue).opacity(0.12), in: Capsule())
|
|
.foregroundStyle(item.isAI ? Color.purple : .blue)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
TextField("Notes", text: $notes, axis: .vertical)
|
|
.lineLimit(1...3)
|
|
.textFieldStyle(.roundedBorder)
|
|
|
|
Toggle("Read later", isOn: $readLater)
|
|
.font(.subheadline)
|
|
|
|
Toggle(isOn: $createPodcast) {
|
|
Label("Create podcast", systemImage: "headphones")
|
|
}
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
|
|
private var actions: some View {
|
|
HStack(spacing: 12) {
|
|
Button("Cancel") { onClose(false) }
|
|
.buttonStyle(.bordered)
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Button {
|
|
Task { await commit() }
|
|
} label: {
|
|
Group {
|
|
if phase == .saving { ProgressView().tint(.white) }
|
|
else { Text(existing != nil ? "Update" : "Save") }
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(phase == .saving)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
private func statusCard(icon: String, tint: Color, text: String) -> some View {
|
|
HStack(spacing: 10) {
|
|
Image(systemName: icon).font(.title3).foregroundStyle(tint)
|
|
Text(text).font(.headline)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 16)
|
|
}
|
|
|
|
// MARK: - Derived
|
|
|
|
private var domain: String { URL(string: url)?.host ?? url }
|
|
|
|
/// Tappable suggestion chips: net-new AI ideas (✨) followed by linkding's
|
|
/// auto_tags (+), excluding anything already in the field, de-duplicated.
|
|
private var suggestionChips: [(tag: String, isAI: Bool)] {
|
|
let current = Set(parsedTags.map { $0.lowercased() })
|
|
var seen = Set<String>()
|
|
var out: [(tag: String, isAI: Bool)] = []
|
|
for tag in aiSuggestions + suggestedTags {
|
|
let key = tag.lowercased()
|
|
let isAI = aiSuggestions.contains { $0.lowercased() == key }
|
|
if !current.contains(key) && seen.insert(key).inserted {
|
|
out.append((tag, isAI))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
private var parsedTags: [String] {
|
|
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
|
|
.map { String($0) }
|
|
.filter { !$0.isEmpty }
|
|
}
|
|
|
|
private func addTag(_ tag: String) {
|
|
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
|
|
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
|
|
tagText += " "
|
|
}
|
|
|
|
// MARK: - Networking
|
|
|
|
private func check() async {
|
|
guard let api else {
|
|
errorMessage = "Open Marks first"
|
|
phase = .failed
|
|
return
|
|
}
|
|
do {
|
|
let result = try await api.checkBookmark(url: url)
|
|
if let bookmark = result.bookmark {
|
|
existing = bookmark
|
|
title = bookmark.displayTitle
|
|
tagText = bookmark.tagNames.joined(separator: " ")
|
|
notes = bookmark.description
|
|
readLater = bookmark.unread
|
|
} else {
|
|
title = result.metadata?.title ?? ""
|
|
suggestedTags = result.autoTags ?? []
|
|
}
|
|
// Pre-enable the toggle when the current tags match the auto-rule.
|
|
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
|
|
phase = .editing
|
|
await loadSuggestions()
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
phase = .failed
|
|
}
|
|
}
|
|
|
|
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
|
|
/// then applies the hybrid behavior: auto-fill suggestions that match the
|
|
/// existing vocabulary (new bookmarks only), surface the rest as chips.
|
|
/// Best-effort — never throws, never blocks saving.
|
|
private func loadSuggestions() async {
|
|
guard let api else { return }
|
|
suggesting = true
|
|
defer { suggesting = false }
|
|
|
|
async let vocabTask = api.fetchTags()
|
|
let ai = await TagSuggester.suggest(url: url, title: title)
|
|
let vocab = (try? await vocabTask) ?? []
|
|
knownTags = Set(vocab.map { $0.lowercased() })
|
|
|
|
if existing == nil {
|
|
// Auto-fill AI tags the user already uses; high confidence, low surprise.
|
|
let current = Set(parsedTags.map { $0.lowercased() })
|
|
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
|
|
addTag(tag)
|
|
}
|
|
}
|
|
// Everything not in the vocabulary becomes a tappable ✨ chip.
|
|
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
|
|
}
|
|
|
|
private func commit() async {
|
|
guard let api else { return }
|
|
phase = .saving
|
|
do {
|
|
if let existing {
|
|
let update = BookmarkUpdate(
|
|
url: existing.url,
|
|
title: existing.title,
|
|
description: notes,
|
|
tagNames: parsedTags,
|
|
unread: readLater,
|
|
shared: existing.shared
|
|
)
|
|
_ = try await api.updateBookmark(id: existing.id, update: update)
|
|
} else {
|
|
let create = BookmarkCreate(
|
|
url: url,
|
|
title: "",
|
|
tagNames: parsedTags,
|
|
isArchived: false,
|
|
unread: readLater,
|
|
shared: false
|
|
)
|
|
_ = try await api.createBookmark(create)
|
|
}
|
|
// Hand off podcast generation to the main app (the extension is too
|
|
// short-lived to generate audio itself).
|
|
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
|
|
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
|
|
}
|
|
phase = .done
|
|
try? await Task.sleep(nanoseconds: 700_000_000)
|
|
onClose(true)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
phase = .failed
|
|
try? await Task.sleep(nanoseconds: 1_600_000_000)
|
|
onClose(false)
|
|
}
|
|
}
|
|
}
|