From bc16ee986d54def8456448ae92be4012807789ad Mon Sep 17 00:00:00 2001 From: Krishna Kumar Date: Thu, 21 May 2026 18:39:15 -0500 Subject: [PATCH] Migrate AI features to backend, add podcast generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Marks.xcodeproj/project.pbxproj | 12 +- Marks/Services/ClaudeService.swift | 209 ++++++++++++-------------- Marks/Views/BookmarksView.swift | 15 +- Marks/Views/BrowserView.swift | 22 ++- Marks/Views/PodcastPlayerView.swift | 220 ++++++++++++++++++++++++++++ Marks/Views/SettingsView.swift | 39 +++-- 6 files changed, 390 insertions(+), 127 deletions(-) create mode 100644 Marks/Views/PodcastPlayerView.swift diff --git a/Marks.xcodeproj/project.pbxproj b/Marks.xcodeproj/project.pbxproj index d4ba9e8..cd86d0a 100644 --- a/Marks.xcodeproj/project.pbxproj +++ b/Marks.xcodeproj/project.pbxproj @@ -12,7 +12,9 @@ 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; }; 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; }; 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; }; + 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */; }; 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; }; + 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629C41E0BC28EB6359D50CFD /* BrowserView.swift */; }; 76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; }; 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; }; 8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; }; @@ -26,7 +28,6 @@ BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; }; C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */; }; EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; }; - FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -61,11 +62,13 @@ 57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = ""; }; + 629C41E0BC28EB6359D50CFD /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = ""; }; 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = ""; }; 759BA3FCF8BEE1D4EA1CDC17 /* String+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Helpers.swift"; sourceTree = ""; }; 86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = ""; }; 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastPlayerView.swift; sourceTree = ""; }; AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = ""; }; AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; }; ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; @@ -76,7 +79,6 @@ CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = ""; }; D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = ""; }; D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = ""; }; - FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserView.swift; sourceTree = ""; }; E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -131,11 +133,12 @@ AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */, B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */, CBE3C5E420F078D499B2D926 /* BookmarksView.swift */, - FA1B2C3D4E5F6A7B8C9D0E10 /* BrowserView.swift */, CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */, + 629C41E0BC28EB6359D50CFD /* BrowserView.swift */, FE19F7619214271A8C12EEEB /* CollectionsView.swift */, D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */, 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */, + A4EB8C63735A267B81030CB5 /* PodcastPlayerView.swift */, BCC3BB2525F0F63445D419B9 /* SearchView.swift */, 5C29CB878BC334639E6194E2 /* SettingsView.swift */, B13B9F2D890C7953531AC0D2 /* TagsView.swift */, @@ -268,14 +271,15 @@ 457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */, 96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */, A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */, - FA1B2C3D4E5F6A7B8C9D0E1F /* BrowserView.swift in Sources */, 5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */, + 706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */, BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */, 6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */, C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */, 15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */, 41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */, 14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */, + 5ED7F0AB24549BA01757A39C /* PodcastPlayerView.swift in Sources */, EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */, 969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */, 849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */, diff --git a/Marks/Services/ClaudeService.swift b/Marks/Services/ClaudeService.swift index deb20ab..b972539 100644 --- a/Marks/Services/ClaudeService.swift +++ b/Marks/Services/ClaudeService.swift @@ -1,147 +1,132 @@ import Foundation +struct PodcastStatus: Decodable { + let status: String + let progress: Int + let title: String? + let error: String? + + var isDone: Bool { status == "done" } + var isFailed: Bool { status == "error" } +} + struct ClaudeService: Sendable { + let backendUrl: String let apiKey: String - static let defaultsKey = "openRouterApiKey" - // Cheap, fast model — change to any OpenRouter model slug - private static let model = "google/gemini-2.0-flash-lite-001" + + static let urlKey = "marksBackendUrl" + static let apiKeyKey = "marksApiKey" static func load() -> ClaudeService? { - guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil } - return ClaudeService(apiKey: key) + guard let url = UserDefaults.standard.string(forKey: urlKey), !url.isEmpty, + let key = UserDefaults.standard.string(forKey: apiKeyKey), !key.isEmpty + else { return nil } + return ClaudeService(backendUrl: url, apiKey: key) } - static func save(apiKey: String) { - UserDefaults.standard.set(apiKey, forKey: defaultsKey) + static func save(backendUrl: String, apiKey: String) { + UserDefaults.standard.set(backendUrl, forKey: urlKey) + UserDefaults.standard.set(apiKey, forKey: apiKeyKey) } + // MARK: - Bookmark AI + func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) { - let prompt = """ - Analyze this bookmark: - URL: \(bookmark.url) - Title: \(bookmark.displayTitle) - Existing tags: \(bookmark.tagNames.joined(separator: ", ")) - - Respond with ONLY valid JSON, no markdown: - {"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]} - - Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated. - """ - let text = try await callModel(prompt: prompt, maxTokens: 256) - return parseEnrichResponse(text) + struct Response: Decodable { let summary: String; let tags: [String] } + let body: [String: Any] = [ + "url": bookmark.url, + "title": bookmark.displayTitle, + "tags": bookmark.tagNames, + ] + let data = try await post("/v1/marks/enrich", body: body) + let decoded = try JSONDecoder().decode(Response.self, from: data) + return (decoded.summary, decoded.tags) } func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] { guard !bookmarks.isEmpty else { return [] } - let list = bookmarks.enumerated().map { i, b in - "[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")" - }.joined(separator: "\n") - let prompt = """ - Search query: "\(query)" - - Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7] - Return [] if nothing matches. - - Bookmarks: - \(list) - """ - let text = try await callModel(prompt: prompt, maxTokens: 256) - let indices = parseIntArray(text) - return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil } + struct Response: Decodable { let indices: [Int] } + let list: [[String: Any]] = bookmarks.map { b in + var d: [String: Any] = ["id": b.id, "title": b.displayTitle, "domain": b.domain] + if let s = b.aiSummary { d["summary"] = s } + return d + } + let data = try await post("/v1/marks/search", body: ["query": query, "bookmarks": list]) + let decoded = try JSONDecoder().decode(Response.self, from: data) + return decoded.indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil } } func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] { guard !bookmarks.isEmpty else { return [] } - let list = bookmarks.prefix(150).enumerated().map { i, b in - "[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]" - }.joined(separator: "\n") - let prompt = """ - Group these bookmarks into 3-6 meaningful themed collections. - Respond with ONLY valid JSON, no markdown: - [{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}] - - Bookmarks: - \(list) - """ - let text = try await callModel(prompt: prompt, maxTokens: 1024) - return parseCollections(text, bookmarks: Array(bookmarks.prefix(150))) + struct Item: Decodable { let name: String; let description: String; let bookmarkIds: [Int] } + struct Response: Decodable { let collections: [Item] } + let capped = Array(bookmarks.prefix(150)) + let list: [[String: Any]] = capped.map { b in + ["id": b.id, "title": b.displayTitle, "tags": b.tagNames] + } + let data = try await post("/v1/marks/collections", body: ["bookmarks": list]) + let decoded = try JSONDecoder().decode(Response.self, from: data) + return decoded.collections.map { + SmartCollection(name: $0.name, description: $0.description, bookmarkIds: $0.bookmarkIds) + } } - private func callModel(prompt: String, maxTokens: Int) async throws -> String { - let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")! + // MARK: - Podcast + + func generatePodcast(url: String) async throws -> String { + struct Response: Decodable { let job_id: String } + let data = try await post("/v1/podcast/generate", body: ["url": url]) + return try JSONDecoder().decode(Response.self, from: data).job_id + } + + func podcastStatus(jobId: String) async throws -> PodcastStatus { + let data = try await get("/v1/podcast/status/\(jobId)") + return try JSONDecoder().decode(PodcastStatus.self, from: data) + } + + func downloadPodcastAudio(jobId: String) async throws -> URL { + let data = try await get("/v1/podcast/audio/\(jobId)") + let dest = FileManager.default.temporaryDirectory + .appendingPathComponent("podcast-\(jobId).mp3") + try data.write(to: dest, options: .atomic) + return dest + } + + // MARK: - HTTP primitives + + private var base: String { + backendUrl.hasSuffix("/") ? String(backendUrl.dropLast()) : backendUrl + } + + private func post(_ path: String, body: [String: Any]) async throws -> Data { + guard let url = URL(string: base + path) else { throw APIError.invalidUrl } var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer") - request.setValue("Marks", forHTTPHeaderField: "X-Title") - - let body: [String: Any] = [ - "model": Self.model, - "max_tokens": maxTokens, - "messages": [["role": "user", "content": prompt]] - ] request.httpBody = try JSONSerialization.data(withJSONObject: body) - - print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)") + print("[AI] POST \(path)") let (data, response) = try await URLSession.shared.data(for: request) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - print("[AI] callModel: status=\(status) bytes=\(data.count)") - guard status == 200 else { - let body = String(data: data, encoding: .utf8) ?? "" - print("[AI] callModel: error body=\(body.prefix(200))") + print("[AI] POST \(path) → \(status)") + guard (200...299).contains(status) else { + print("[AI] error: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "")") throw APIError.badStatus(status) } - - struct Choice: Codable { - struct Message: Codable { let content: String } - let message: Message - } - struct Response: Codable { let choices: [Choice] } - let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? "" - print("[AI] callModel: response=\(content.prefix(100))") - return content + return data } - private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) { - let cleaned = extractJSON(from: text) - guard let data = cleaned.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let summary = json["summary"] as? String, - let tags = json["tags"] as? [String] else { return (text, []) } - return (summary, tags) - } - - private func parseIntArray(_ text: String) -> [Int] { - let cleaned = extractJSON(from: text) - guard let data = cleaned.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] } - return arr - } - - private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] { - let cleaned = extractJSON(from: text) - guard let data = cleaned.data(using: .utf8), - let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } - return arr.compactMap { dict in - guard let name = dict["name"] as? String, - let indices = dict["indices"] as? [Int] else { return nil } - let desc = dict["description"] as? String ?? "" - let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil } - return SmartCollection(name: name, description: desc, bookmarkIds: ids) + private func get(_ path: String) async throws -> Data { + guard let url = URL(string: base + path) else { throw APIError.invalidUrl } + var request = URLRequest(url: url) + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + print("[AI] GET \(path)") + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + print("[AI] GET \(path) → \(status)") + guard (200...299).contains(status) else { + throw APIError.badStatus(status) } - } - - private func extractJSON(from text: String) -> String { - if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") { - return String(text[start.upperBound..? + 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) + } +} diff --git a/Marks/Views/SettingsView.swift b/Marks/Views/SettingsView.swift index 208b542..609e992 100644 --- a/Marks/Views/SettingsView.swift +++ b/Marks/Views/SettingsView.swift @@ -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) + } }