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

79 lines
2.8 KiB
Swift

import Foundation
struct PodcastEntry: Codable, Identifiable {
var id: String { articleUrl }
let articleUrl: String
let filename: String
var title: String?
let createdAt: Date
var parentBookmarkUrl: String?
/// When the episode was finished. `nil` means unplayed/new. Absent in
/// pre-existing index files, which decode as `nil` (i.e. unplayed).
var playedAt: Date? = nil
var isPlayed: Bool { playedAt != nil }
}
enum PodcastIndex {
private static let indexURL: URL = {
let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("podcasts")
return dir.appendingPathComponent("index.json")
}()
private static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
e.outputFormatting = .prettyPrinted
return e
}()
private static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
static func all() -> [PodcastEntry] {
guard let data = try? Data(contentsOf: indexURL),
let entries = try? decoder.decode([PodcastEntry].self, from: data)
else { return [] }
return entries.sorted { $0.createdAt > $1.createdAt }
}
static func upsert(articleUrl: String, filename: String, title: String?, parentBookmarkUrl: String? = nil) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }
entries.insert(PodcastEntry(articleUrl: articleUrl, filename: filename,
title: title, createdAt: Date(),
parentBookmarkUrl: parentBookmarkUrl), at: 0)
save(entries)
}
static func find(for bookmarkUrl: String) -> [PodcastEntry] {
all().filter { $0.parentBookmarkUrl == bookmarkUrl || $0.articleUrl == bookmarkUrl }
}
/// Mark an episode played (default) or back to unplayed. No-op if unknown.
static func setPlayed(articleUrl: String, _ played: Bool = true) {
var entries = all()
guard let i = entries.firstIndex(where: { $0.articleUrl == articleUrl }) else { return }
entries[i].playedAt = played ? Date() : nil
save(entries)
}
static func remove(articleUrl: String) {
var entries = all()
entries.removeAll { $0.articleUrl == articleUrl }
save(entries)
}
private static func save(_ entries: [PodcastEntry]) {
guard let data = try? encoder.encode(entries) else { return }
try? data.write(to: indexURL, options: .atomic)
WidgetDataStore.savePodcasts(entries.prefix(10).map {
WidgetPodcast(articleUrl: $0.articleUrl, title: $0.title ?? $0.articleUrl, createdAt: $0.createdAt)
})
}
}