Files
linkding-ios/Marks/MarksApp.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

139 lines
5.0 KiB
Swift

import SwiftUI
private let defaultConfig = ServerConfig(
host: "linkding-production-f7e0.up.railway.app",
port: nil,
path: "",
token: "04c3388f543a6f4401ae41958b4a459a0125c4bf",
useHttps: true
)
@main
struct MarksApp: App {
@State private var serverConfig: ServerConfig = ServerConfig.load() ?? defaultConfig
var body: some Scene {
WindowGroup {
MainContainer(config: serverConfig) {
defaultConfig.save()
serverConfig = defaultConfig
}
.task { serverConfig.save() }
}
}
}
private struct IdentifiableURL: Identifiable {
let id = UUID()
let url: URL
}
// Holds the API + ViewModel so they survive re-renders
struct MainContainer: View {
let config: ServerConfig
let onDisconnect: () -> Void
@State private var viewModel: BookmarksViewModel
@State private var deepLinkBrowser: IdentifiableURL?
@State private var showDeepLinkPlayer = false
@State private var selectedTab: AppTab = .bookmarks
@State private var router = IntentRouter.shared
@Environment(\.scenePhase) private var scenePhase
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
self.config = config
self.onDisconnect = onDisconnect
let api = LinkdingAPI(config: config)
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, cacheKey: config.host))
}
var body: some View {
TabView(selection: $selectedTab) {
Tab("Bookmarks", systemImage: "bookmark", value: AppTab.bookmarks) {
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
}
Tab("Tags", systemImage: "tag", value: AppTab.tags) {
TagsView(viewModel: viewModel)
}
Tab("Podcasts", systemImage: "headphones", value: AppTab.podcasts) {
PodcastLibraryView(vm: viewModel.podcastPlayer, claude: viewModel.claude)
}
Tab(value: AppTab.search, role: .search) {
SearchView(viewModel: viewModel)
}
}
.onOpenURL { url in
handleDeepLink(url)
}
.task { applyPendingIntent(); viewModel.processPendingPodcastRequests() }
.onChange(of: router.openBookmarkURL) { _, _ in applyPendingIntent() }
.onChange(of: router.searchRequest) { _, _ in applyPendingIntent() }
.onChange(of: scenePhase) { old, new in
// Refresh when returning to the foreground (cold launch is already
// covered by BookmarksView's .task, so only react to a real re-entry).
if new == .active && old != .active {
viewModel.processPendingPodcastRequests()
Task { await viewModel.load() }
}
}
.sheet(item: $deepLinkBrowser) { item in
BrowserView(url: item.url, title: item.url.host ?? "", claude: viewModel.claude, podcastPlayer: viewModel.podcastPlayer, podcastGenerator: viewModel.podcastGenerator)
}
.sheet(isPresented: $showDeepLinkPlayer) {
PodcastPlayerView(
vm: viewModel.podcastPlayer,
articleUrl: viewModel.podcastPlayer.currentArticleUrl,
articleTitle: viewModel.podcastPlayer.currentArticleTitle,
claude: viewModel.claude,
stopOnDismiss: false
)
}
}
/// Reacts to requests an App Intent placed on `IntentRouter` (opening a
/// bookmark, or searching). Also runs once at launch so cold-starts via an
/// intent are honored.
private func applyPendingIntent() {
if let urlString = router.openBookmarkURL {
router.openBookmarkURL = nil
if let url = URL(string: urlString) {
deepLinkBrowser = IdentifiableURL(url: url)
}
}
if let query = router.searchRequest {
selectedTab = .search
Task {
viewModel.searchQuery = query
await viewModel.search()
}
// SearchView consumes the text from the router on appear; leave it
// set until then, then SearchView clears it.
}
}
private func handleDeepLink(_ url: URL) {
guard url.scheme == "marks",
let comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let articleUrl = comps.queryItems?.first(where: { $0.name == "url" })?.value
else { return }
switch url.host {
case "bookmark":
if let browserURL = URL(string: articleUrl) {
deepLinkBrowser = IdentifiableURL(url: browserURL)
}
case "podcast":
let podcasts = WidgetDataStore.loadPodcasts()
let found = podcasts.first { $0.articleUrl == articleUrl }
viewModel.podcastPlayer.start(
articleUrl: articleUrl,
articleTitle: found?.title ?? "",
claude: viewModel.claude
)
showDeepLinkPlayer = true
default:
break
}
}
}