- Add a long-press context menu on each library row (Play/Pause, Mark as Played/Unplayed, Delete). - Add a Mark as Played/Unplayed control next to Share Episode in the full player, tracking the loaded episode across queue advances. - Show a green checkmark on played rows, on top of the existing Played section + dimmed title, so played state reads at a glance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
2.9 KiB
Swift
83 lines
2.9 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 }
|
|
}
|
|
|
|
static func isPlayed(articleUrl: String) -> Bool {
|
|
all().first { $0.articleUrl == articleUrl }?.isPlayed ?? false
|
|
}
|
|
|
|
/// 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)
|
|
})
|
|
}
|
|
}
|