Migrate AI features to backend, add podcast generation

- ClaudeService: replace direct OpenRouter calls with backend endpoints
  (/v1/marks/enrich, /v1/marks/search, /v1/marks/collections)
- ClaudeService: add podcast methods (generate, status, downloadAudio)
- SettingsView: replace OpenRouter key field with backend URL + API key
- PodcastPlayerView: new sheet — generate → poll → AVPlayer playback
- BrowserView: headphones toolbar button triggers podcast for current URL
- BookmarksView: "Convert to Podcast" context menu item + sheet

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-21 18:39:15 -05:00
parent ac2f9cf85d
commit bc16ee986d
6 changed files with 390 additions and 127 deletions

View File

@@ -10,6 +10,7 @@ struct BookmarksView: View {
@State private var showAddBookmark = false
@State private var editingBookmark: Bookmark?
@State private var browsingBookmark: Bookmark?
@State private var podcastBookmark: Bookmark?
var body: some View {
NavigationStack {
@@ -59,6 +60,13 @@ struct BookmarksView: View {
} label: {
Label("Open in Safari", systemImage: "safari")
}
if viewModel.claude != nil {
Button {
podcastBookmark = bookmark
} label: {
Label("Convert to Podcast", systemImage: "headphones")
}
}
Divider()
Button {
Task { await viewModel.archive(bookmark) }
@@ -147,7 +155,12 @@ struct BookmarksView: View {
}
.sheet(item: $browsingBookmark) { bookmark in
if let url = URL(string: bookmark.url) {
BrowserView(url: url, title: bookmark.displayTitle)
BrowserView(url: url, title: bookmark.displayTitle, claude: viewModel.claude)
}
}
.sheet(item: $podcastBookmark) { bookmark in
if let claude = viewModel.claude {
PodcastPlayerView(articleUrl: bookmark.url, claude: claude)
}
}
.refreshable {

View File

@@ -115,15 +115,18 @@ private struct WebViewRepresentable: UIViewRepresentable {
struct BrowserView: View {
let initialURL: URL
let initialTitle: String
let claude: ClaudeService?
@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
init(url: URL, title: String) {
init(url: URL, title: String, claude: ClaudeService? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self._state = State(initialValue: BrowserState(url: url))
}
@@ -187,11 +190,28 @@ struct BrowserView: View {
Spacer()
if claude != nil {
Button {
showPodcast = true
} label: {
Image(systemName: "headphones")
}
.disabled(state.isLoading)
Spacer()
}
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
}
}
}
}
.sheet(isPresented: $showPodcast) {
if let claude {
let url = (state.currentURL ?? initialURL).absoluteString
PodcastPlayerView(articleUrl: url, claude: claude)
}
}
}
}

View File

@@ -0,0 +1,220 @@
import SwiftUI
import AVFoundation
// MARK: - ViewModel
@Observable
@MainActor
final class PodcastPlayerViewModel {
enum Phase {
case generating(progress: Double, label: String)
case downloading
case ready(title: String)
case failed(String)
}
var phase: Phase = .generating(progress: 0, label: "Starting…")
var player: AVPlayer?
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 1
private var pollingTask: Task<Void, Never>?
private var timeObserver: Any?
func start(articleUrl: String, claude: ClaudeService) {
pollingTask = Task {
do {
let jobId = try await claude.generatePodcast(url: articleUrl)
while !Task.isCancelled {
let job = try await claude.podcastStatus(jobId: jobId)
phase = .generating(
progress: Double(job.progress) / 100,
label: Self.statusLabel(job.status)
)
if job.isDone {
phase = .downloading
let audioUrl = try await claude.downloadPodcastAudio(jobId: jobId)
setupPlayer(url: audioUrl, title: job.title ?? "Podcast")
return
}
if job.isFailed {
phase = .failed(job.error ?? "Generation failed")
return
}
try await Task.sleep(for: .seconds(3))
}
} catch is CancellationError {
// sheet dismissed clean exit
} catch {
phase = .failed(error.localizedDescription)
}
}
}
func togglePlayPause() {
guard let player else { return }
if isPlaying { player.pause() } else { player.play() }
isPlaying.toggle()
}
func seek(to time: Double) {
player?.seek(to: CMTime(seconds: time, preferredTimescale: 600))
currentTime = time
}
func teardown() {
pollingTask?.cancel()
player?.pause()
if let obs = timeObserver { player?.removeTimeObserver(obs) }
player = nil
}
private func setupPlayer(url: URL, title: String) {
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
player = p
Task {
if let d = try? await item.asset.load(.duration), d.isNumeric {
duration = max(d.seconds, 1)
}
}
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
self?.currentTime = t.seconds
}
phase = .ready(title: title)
}
private static func statusLabel(_ status: String) -> String {
switch status {
case "queued": return "Queued…"
case "fetching": return "Fetching article…"
case "scripting": return "Writing dialogue…"
case "recording": return "Recording voices…"
case "mixing": return "Mixing audio…"
default: return "Generating…"
}
}
}
// MARK: - View
struct PodcastPlayerView: View {
let articleUrl: String
let claude: ClaudeService
@Environment(\.dismiss) private var dismiss
@State private var vm = PodcastPlayerViewModel()
var body: some View {
NavigationStack {
VStack {
Spacer()
phaseContent
Spacer()
}
.padding(.horizontal, 32)
.navigationTitle("Podcast")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Done") { dismiss() }
}
}
}
.onAppear { vm.start(articleUrl: articleUrl, claude: claude) }
.onDisappear { vm.teardown() }
}
@ViewBuilder
private var phaseContent: some View {
switch vm.phase {
case .generating(let progress, let label):
generatingView(progress: progress, label: label)
case .downloading:
generatingView(progress: 1.0, label: "Preparing audio…")
case .ready(let title):
playerView(title: title)
case .failed(let message):
failedView(message: message)
}
}
private func generatingView(progress: Double, label: String) -> some View {
VStack(spacing: 28) {
Image(systemName: "waveform")
.font(.system(size: 52))
.foregroundStyle(.secondary)
.symbolEffect(.variableColor.iterative, isActive: true)
VStack(spacing: 10) {
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.blue)
Text(label)
.font(.system(size: 14))
.foregroundStyle(.secondary)
}
}
}
private func playerView(title: String) -> some View {
VStack(spacing: 28) {
RoundedRectangle(cornerRadius: 20)
.fill(Color(.systemGray6))
.frame(width: 220, height: 220)
.overlay {
Image(systemName: "waveform.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.blue)
}
Text(title)
.font(.system(size: 18, weight: .semibold))
.multilineTextAlignment(.center)
.lineLimit(3)
VStack(spacing: 6) {
Slider(
value: Binding(get: { vm.currentTime }, set: { vm.seek(to: $0) }),
in: 0...vm.duration
)
.tint(.primary)
HStack {
Text(formatTime(vm.currentTime))
Spacer()
Text(formatTime(vm.duration))
}
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.tertiary)
}
Button { vm.togglePlayPause() } label: {
Image(systemName: vm.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.primary)
}
}
}
private func failedView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 44))
.foregroundStyle(.red)
Text("Generation Failed")
.font(.headline)
Text(message)
.font(.system(size: 14))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
private func formatTime(_ s: Double) -> String {
let total = Int(max(s, 0))
return String(format: "%d:%02d", total / 60, total % 60)
}
}

View File

@@ -4,25 +4,36 @@ struct SettingsView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
@State private var backendUrl = UserDefaults.standard.string(forKey: ClaudeService.urlKey) ?? ""
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.apiKeyKey) ?? ""
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section("AI Features") {
Section {
VStack(alignment: .leading, spacing: 6) {
Text("OpenRouter API Key")
Text("Backend URL")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
SecureField("sk-or-…", text: $claudeKey)
TextField("https://api.yourserver.com", text: $backendUrl)
.textContentType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.onChange(of: backendUrl) { _, _ in reloadClaude() }
}
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 6) {
Text("API Key")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
SecureField("Bearer token", text: $claudeKey)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: claudeKey) { _, new in
ClaudeService.save(apiKey: new)
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
}
.onChange(of: claudeKey) { _, _ in reloadClaude() }
}
.padding(.vertical, 4)
@@ -31,10 +42,12 @@ struct SettingsView: View {
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
Text("Enter your backend URL and API key to enable AI features.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
} header: {
Text("AI Features")
}
Section("Server") {
@@ -55,4 +68,12 @@ struct SettingsView: View {
}
}
}
private func reloadClaude() {
ClaudeService.save(backendUrl: backendUrl, apiKey: claudeKey)
let service = backendUrl.isEmpty || claudeKey.isEmpty
? nil
: ClaudeService(backendUrl: backendUrl, apiKey: claudeKey)
viewModel.updateClaude(service)
}
}