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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
220
Marks/Views/PodcastPlayerView.swift
Normal file
220
Marks/Views/PodcastPlayerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user