Files
linkding-ios/Marks/Views/BrowserView.swift
Krishna Kumar af3112530e
All checks were successful
CI / build-and-deploy (push) Successful in 24s
CI / build-and-deploy (pull_request) Successful in 15s
feat(podcasts): played/unplayed queue, background generation, share-sheet audio
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>
2026-07-01 11:37:42 -05:00

420 lines
16 KiB
Swift

import SwiftUI
import WebKit
// MARK: - Reading progress persistence
enum ReadingProgress {
private static let key = "readingProgress"
static func get(url: String) -> Double {
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0
}
static func set(url: String, progress: Double) {
var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
if progress >= 0.99 {
store.removeValue(forKey: url) // fully read clean up
} else {
store[url] = progress
}
UserDefaults.standard.set(store, forKey: key)
}
static func all() -> [String: Double] {
(UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
}
}
// MARK: - Indeterminate page load bar
private struct PageLoadBar: View {
@State private var phase: CGFloat = 0
var body: some View {
GeometryReader { geo in
let barWidth = geo.size.width * 0.38
Capsule()
.fill(.blue.opacity(0.75))
.frame(width: barWidth, height: 3)
.offset(x: (phase * (geo.size.width + barWidth)) - barWidth)
}
.frame(height: 3)
.clipped()
.onAppear {
withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
}
// MARK: - BrowserState
@Observable
final class BrowserState: NSObject, WKNavigationDelegate, WKUIDelegate {
let webView: WKWebView
var isLoading = false
var canGoBack = false
var canGoForward = false
var pageTitle = ""
var currentURL: URL?
var readingProgress: Double = 0
// Weak wrapper breaks WKUserContentController's strong retain of the handler
private final class ScrollHandler: NSObject, WKScriptMessageHandler {
weak var state: BrowserState?
init(_ state: BrowserState) { self.state = state }
func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) {
if let p = msg.body as? Double { state?.readingProgress = p }
}
}
private var scrollHandler: ScrollHandler?
init(url: URL) {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
webView = WKWebView(frame: .zero, configuration: config)
super.init()
let handler = ScrollHandler(self)
scrollHandler = handler
webView.configuration.userContentController.add(handler, name: "scrollProgress")
webView.navigationDelegate = self
webView.uiDelegate = self
webView.load(URLRequest(url: url))
}
deinit {
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) {
isLoading = true
}
func webView(_ webView: WKWebView, didCommit _: WKNavigation!) {
currentURL = webView.url
canGoBack = webView.canGoBack
canGoForward = webView.canGoForward
}
func webView(_ webView: WKWebView, didFinish _: WKNavigation!) {
isLoading = false
canGoBack = webView.canGoBack
canGoForward = webView.canGoForward
pageTitle = webView.title ?? ""
currentURL = webView.url
// Restore persisted progress, then start tracking scroll
if let urlStr = webView.url?.absoluteString {
let saved = ReadingProgress.get(url: urlStr)
if saved > 0 {
let pct = saved * 100
webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil)
}
}
webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil)
}
func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false }
func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false }
// target="_blank" and window.open() load in the same webview instead of opening Safari
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = action.request.url {
webView.load(URLRequest(url: url))
}
return nil
}
func enterReaderMode(completion: @escaping () -> Void) {
webView.evaluateJavaScript(readerModeJS) { _, _ in
DispatchQueue.main.async {
completion()
self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil)
}
}
}
func exitReaderMode() { webView.reload() }
private var scrollTrackingJS: String {
"""
(function(){
function report(){
var h=document.body.scrollHeight-window.innerHeight;
var p=h>0?window.scrollY/h:0;
window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p)));
}
window.removeEventListener('scroll',window._marksScrollFn);
window._marksScrollFn=report;
window.addEventListener('scroll',report,{passive:true});
report();
})();
"""
}
private var readerModeJS: String {
"""
(function() {
var title = document.title;
var selectors = ['article','[role="article"]','main','[role="main"]',
'.post-content','.article-content','.entry-content',
'.post-body','.article-body','.story-body','.content','#content'];
var content = null;
for (var i = 0; i < selectors.length; i++) {
var el = document.querySelector(selectors[i]);
if (el && (el.innerText||'').trim().length > 300) { content = el; break; }
}
if (!content) {
var best = { el: null, len: 0 };
Array.from(document.querySelectorAll('div')).forEach(function(d) {
var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0);
if (len > best.len) best = { el: d, len: len };
});
content = best.el || document.body;
}
var clone = content.cloneNode(true);
['script','style','nav','header','footer','aside'].forEach(function(tag){
clone.querySelectorAll(tag).forEach(function(el){ el.remove(); });
});
var html = clone.innerHTML;
var css = [
'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;',
'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}',
'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}',
'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}',
'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}',
'a{color:#007aff;text-decoration:none}',
'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}',
'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}',
'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}',
'@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}',
'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}'
].join('');
document.open();
document.write('<!DOCTYPE html><html><head>' +
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
'<style>' + css + '</style>' +
'<title>' + title + '</title></head><body>' +
'<h1>' + title + '</h1>' + html + '</body></html>');
document.close();
return 'ok';
})()
"""
}
}
// MARK: - WebView representable
private struct WebViewRepresentable: UIViewRepresentable {
let webView: WKWebView
func makeUIView(context: Context) -> WKWebView { webView }
func updateUIView(_ uiView: WKWebView, context: Context) {}
}
// MARK: - BrowserView
struct BrowserView: View {
let initialURL: URL
let initialTitle: String
let claude: ClaudeService
let podcastPlayer: PodcastPlayerViewModel
let podcastGenerator: PodcastGenerationManager
var onArchive: (() async -> Void)? = nil
@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
@State private var archiveTapCount = 0
@State private var navBackCount = 0
@State private var navForwardCount = 0
@State private var toolbarHidden = false
init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, podcastGenerator: PodcastGenerationManager, onArchive: (() async -> Void)? = nil) {
self.initialURL = url
self.initialTitle = title
self.claude = claude
self.podcastPlayer = podcastPlayer
self.podcastGenerator = podcastGenerator
self.onArchive = onArchive
self._state = State(initialValue: BrowserState(url: url))
}
var body: some View {
NavigationStack {
ZStack(alignment: .top) {
WebViewRepresentable(webView: state.webView)
.ignoresSafeArea(edges: .bottom)
// Reading progress bar
if state.readingProgress > 0.01 && state.readingProgress < 0.99 {
GeometryReader { geo in
Rectangle()
.fill(Color.blue.opacity(0.55))
.frame(width: geo.size.width * state.readingProgress, height: 3)
}
.frame(height: 3)
.animation(.linear(duration: 0.1), value: state.readingProgress)
}
// Page loading sweep bar (replaces center spinner)
if state.isLoading {
PageLoadBar()
.transition(.opacity)
}
}
.navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.fontWeight(.semibold)
}
}
ToolbarItem(placement: .topBarTrailing) {
Button { openURL(state.currentURL ?? initialURL) } label: {
Image(systemName: "safari")
}
}
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomBar
.offset(y: toolbarHidden ? 88 : 0)
.animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden)
}
.onChange(of: state.readingProgress) { old, new in
let delta = new - old
withAnimation(.spring(duration: 0.3, bounce: 0)) {
if delta > 0.012 && new > 0.07 {
toolbarHidden = true
} else if delta < -0.005 || new < 0.05 {
toolbarHidden = false
}
}
}
.onChange(of: state.isLoading) { _, loading in
if loading {
withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false }
}
}
.onDisappear {
let urlStr = (state.currentURL ?? initialURL).absoluteString
let p = state.readingProgress
if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) }
}
.sheet(isPresented: $showPodcast) {
PodcastPlayerView(
vm: podcastPlayer,
articleUrl: (state.currentURL ?? initialURL).absoluteString,
articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle,
claude: claude,
stopOnDismiss: false
)
}
}
private var bottomBar: some View {
HStack {
Button {
navBackCount += 1
state.webView.goBack()
} label: {
Image(systemName: "chevron.left")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoBack)
.sensoryFeedback(.impact(weight: .light), trigger: navBackCount)
Spacer()
Button {
navForwardCount += 1
state.webView.goForward()
} label: {
Image(systemName: "chevron.right")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(!state.canGoForward)
.sensoryFeedback(.impact(weight: .light), trigger: navForwardCount)
Spacer()
Button {
if readerMode { state.exitReaderMode(); readerMode = false }
else { state.enterReaderMode { readerMode = true } }
} label: {
Image(systemName: readerMode ? "doc.text.fill" : "doc.text")
.contentTransition(.symbolEffect(.replace))
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
if onArchive != nil { Spacer() }
if let onArchive {
Button {
archiveTapCount += 1
Task { await onArchive() }
dismiss()
} label: {
Image(systemName: "archivebox")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount)
}
Spacer()
Button {
let currentUrl = (state.currentURL ?? initialURL).absoluteString
let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle
// Never interrupt current playback: play-and-show only when idle,
// otherwise generate in the background.
if podcastPlayer.currentArticleUrl.isEmpty {
podcastPlayer.start(
articleUrl: currentUrl,
articleTitle: currentTitle,
claude: claude,
parentBookmarkUrl: initialURL.absoluteString
)
showPodcast = true
} else {
podcastGenerator.enqueue(
articleUrl: currentUrl,
title: currentTitle,
parentBookmarkUrl: initialURL.absoluteString
)
}
} label: {
Image(systemName: "headphones")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.disabled(state.isLoading)
Spacer()
ShareLink(item: state.currentURL ?? initialURL) {
Image(systemName: "square.and.arrow.up")
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
}
.font(.system(size: 17))
.foregroundStyle(.primary)
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(.bar)
.overlay(alignment: .top) { Divider() }
}
}