- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library - Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library - Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail - Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished - Add AirPlay picker in PlayerBar - Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup - Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s - Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id) - Bump CFBundleVersion to 8 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
524 lines
21 KiB
Swift
524 lines
21 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class AppState {
|
|
let auth: AuthStore
|
|
let client: ABSClient
|
|
let network: NetworkMonitor
|
|
let downloads: DownloadManager
|
|
let sync: ProgressSyncManager
|
|
let player: PlayerEngine
|
|
let history: HistoryManager
|
|
let bookmarks: BookmarkManager
|
|
|
|
var currentItem: LibraryItem?
|
|
var isPreparingPlayback: Bool = false
|
|
var language: String = UserDefaults.standard.string(forKey: "appLanguage") ?? "de" {
|
|
didSet { UserDefaults.standard.set(language, forKey: "appLanguage") }
|
|
}
|
|
|
|
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
|
|
/// Used to show progress bars on covers in the library views.
|
|
var progressCache: [String: PlaybackProgress] = [:]
|
|
|
|
/// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
|
|
/// Populated whenever the user actually plays an item, persisted to disk so
|
|
/// the Home screen can show "recently played" cards on first launch without
|
|
/// any network round-trip.
|
|
var recentItemsCache: [String: LibraryItem] = [:]
|
|
|
|
/// Server-progress that's newer than local but we haven't applied yet because
|
|
/// playback is active/paused. Offered via alert on next play; cleared on
|
|
/// item change.
|
|
var pendingServerProgress: PlaybackProgress?
|
|
|
|
private var syncTimer: Timer?
|
|
private var pullTimer: Timer?
|
|
private var lastReportedSecond: Double = -10
|
|
private var lastPushedAt: Date = .distantPast
|
|
|
|
init() {
|
|
let auth = AuthStore()
|
|
let client = ABSClient(auth: auth)
|
|
self.auth = auth
|
|
self.client = client
|
|
self.network = NetworkMonitor()
|
|
self.downloads = DownloadManager(client: client)
|
|
self.sync = ProgressSyncManager(client: client)
|
|
self.player = PlayerEngine()
|
|
self.history = HistoryManager()
|
|
self.bookmarks = BookmarkManager()
|
|
// Route lockscreen/Control-Center seeks through AppState so history is
|
|
// recorded — otherwise remote skips bypass history entirely.
|
|
self.player.onRemoteSkip = { [weak self] seconds in
|
|
self?.skip(by: seconds)
|
|
}
|
|
self.player.onRemoteSeek = { [weak self] target in
|
|
self?.seekAbsolute(target)
|
|
}
|
|
// PlayerEngine reports chapter transitions from the AVPlayer time
|
|
// observer — fires reliably in background/locked, unlike the 5s Timer.
|
|
self.player.onChapterChanged = { [weak self] chapter in
|
|
self?.recordChapterEntry(chapter)
|
|
}
|
|
}
|
|
|
|
private func recordChapterEntry(_ chapter: Chapter) {
|
|
guard let item = currentItem,
|
|
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
|
|
history.record(item: item, position: chapter.start, chapters: item.chapters)
|
|
}
|
|
|
|
func bootstrap() async {
|
|
#if os(iOS)
|
|
await NotificationManager.shared.requestPermissionIfNeeded()
|
|
#endif
|
|
auth.restoreSession()
|
|
loadRecentItemsCache()
|
|
network.start { [weak self] online in
|
|
guard let self else { return }
|
|
if online {
|
|
Task { [weak self] in
|
|
await self?.sync.drain()
|
|
await self?.refreshProgressCache()
|
|
}
|
|
}
|
|
}
|
|
if auth.isLoggedIn {
|
|
let ok = await client.validateToken()
|
|
if !ok {
|
|
auth.logout()
|
|
} else {
|
|
await sync.drain()
|
|
await refreshProgressCache()
|
|
}
|
|
}
|
|
startPullTimer()
|
|
}
|
|
|
|
/// Called by ContentView on scenePhase == .active. Immediate pull so we
|
|
/// notice updates from other devices the moment the app comes forward.
|
|
func onScenePhaseActive() {
|
|
Task { await pullAndReconcile() }
|
|
}
|
|
|
|
private func startPullTimer() {
|
|
pullTimer?.invalidate()
|
|
let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { _ in
|
|
Task { @MainActor [weak self] in await self?.pullAndReconcile() }
|
|
}
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
pullTimer = timer
|
|
}
|
|
|
|
/// Pulls server progress and reconciles against local state:
|
|
/// - currentItem == nil: just refresh the cache (library covers).
|
|
/// - server is newer than local: stash for the resume-prompt.
|
|
/// - server is older than local: push our state immediately.
|
|
func pullAndReconcile() async {
|
|
guard network.isOnline, auth.isLoggedIn else { return }
|
|
await refreshProgressCache()
|
|
|
|
guard let current = currentItem else { return }
|
|
guard let server = progressCache[current.syncKey] else { return }
|
|
|
|
let local = player.absoluteCurrentTime
|
|
let positionDelta = abs(server.currentTime - local)
|
|
// Treat <8 s delta as identical to absorb own-update echoes, clock skew,
|
|
// and reporting granularity.
|
|
guard positionDelta > 8 else { return }
|
|
|
|
let serverIsNewer = server.updatedAt > lastPushedAt.addingTimeInterval(5)
|
|
if serverIsNewer {
|
|
pendingServerProgress = server
|
|
} else {
|
|
reportProgress(force: true)
|
|
}
|
|
}
|
|
|
|
func acceptPendingServerProgress() {
|
|
guard let p = pendingServerProgress else { return }
|
|
pendingServerProgress = nil
|
|
player.seekAbsolute(p.currentTime)
|
|
player.play()
|
|
}
|
|
|
|
func dismissPendingServerProgress() {
|
|
pendingServerProgress = nil
|
|
player.play()
|
|
}
|
|
|
|
/// Pulls the entire progress map from the server (via /api/me).
|
|
func refreshProgressCache() async {
|
|
guard network.isOnline, auth.isLoggedIn else { return }
|
|
do {
|
|
let all = try await client.fetchAllProgress()
|
|
progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
/// Local update for the cache while we're actively playing.
|
|
func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
|
|
let p = PlaybackProgress(
|
|
itemId: itemId, episodeId: episodeId,
|
|
currentTime: currentTime, duration: duration,
|
|
isFinished: isFinished, updatedAt: Date()
|
|
)
|
|
progressCache[p.syncKey] = p
|
|
}
|
|
|
|
func progress(for item: LibraryItem) -> PlaybackProgress? {
|
|
progressCache[item.syncKey]
|
|
}
|
|
|
|
func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
|
|
let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
|
|
guard let p = progressCache[key], p.duration > 0 else { return 0 }
|
|
if p.isFinished { return 1.0 }
|
|
return min(1, max(0, p.currentTime / p.duration))
|
|
}
|
|
|
|
func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
|
|
// Clear any stash from a previous item — only carry stashes per-item.
|
|
pendingServerProgress = nil
|
|
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
|
|
if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
|
|
return
|
|
}
|
|
// Record position before switching to a new item
|
|
if let current = currentItem, player.absoluteCurrentTime > 5,
|
|
UserDefaults.standard.bool(forKey: "historyEnabled") {
|
|
history.record(item: current, position: player.absoluteCurrentTime, chapters: current.chapters)
|
|
}
|
|
stopPlayback(reportFinal: true)
|
|
isPreparingPlayback = true
|
|
defer { isPreparingPlayback = false }
|
|
|
|
var workItem = item
|
|
// Always fetch detail when online so chapters are loaded — also for
|
|
// already-downloaded items (the persisted DownloadedItem doesn't store
|
|
// chapter metadata, so streaming the detail is the only source).
|
|
if !workItem.isPodcast && network.isOnline {
|
|
if let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
|
workItem = detail
|
|
}
|
|
}
|
|
|
|
var startAt: Double = overrideStartAt ?? 0
|
|
if overrideStartAt == nil && network.isOnline {
|
|
if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
|
|
// Replaying a finished item (or one with progress essentially at the end)
|
|
// should start from the beginning, not drop the user at the last few seconds.
|
|
let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
|
|
if !p.isFinished && !nearEnd {
|
|
startAt = p.currentTime
|
|
}
|
|
}
|
|
}
|
|
if network.isOnline {
|
|
// Load bookmarks from server for this item
|
|
if let serverBMs = try? await client.fetchBookmarks(itemId: workItem.id, episodeId: workItem.episodeId) {
|
|
bookmarks.mergeFromServer(serverBMs, for: workItem)
|
|
}
|
|
}
|
|
|
|
currentItem = workItem
|
|
// Cache full item details for the Home screen — we paid the
|
|
// fetchItemDetail cost above anyway, no reason to redo it later.
|
|
recentItemsCache[workItem.syncKey] = workItem
|
|
persistRecentItems()
|
|
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
|
if player.errorMessage == nil {
|
|
player.play()
|
|
startSyncTimer()
|
|
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
|
history.record(item: workItem, position: startAt, chapters: workItem.chapters)
|
|
}
|
|
}
|
|
}
|
|
|
|
func playFromHistory(_ entry: HistoryEntry) async {
|
|
if let current = currentItem,
|
|
current.id == entry.itemId,
|
|
current.episodeId == entry.episodeId {
|
|
seekAbsolute(entry.position)
|
|
return
|
|
}
|
|
guard network.isOnline,
|
|
let detail = try? await client.fetchItemDetail(itemId: entry.itemId) else { return }
|
|
var item = detail
|
|
item.episodeId = entry.episodeId
|
|
await play(item: item, overrideStartAt: entry.position)
|
|
}
|
|
|
|
/// Removes a media-progress entry from the server AND the local caches
|
|
/// (`progressCache`, `recentItemsCache`, pending sync queue). The Home
|
|
/// screen reacts immediately because it reads from `progressCache`. If
|
|
/// the server call fails we still clear the local cache — the next
|
|
/// `refreshProgressCache()` will re-sync from authoritative state (and
|
|
/// the entry would come back if the server delete didn't go through).
|
|
func removeProgress(itemId: String, episodeId: String? = nil) async {
|
|
let key: String
|
|
if let episodeId { key = "\(itemId)|\(episodeId)" } else { key = itemId }
|
|
|
|
// Grab the server-assigned progress UUID BEFORE we wipe the cache —
|
|
// Audiobookshelf's DELETE endpoint matches by this UUID, not by
|
|
// libraryItemId, so we need it to actually hit the right entry.
|
|
var progressId = progressCache[key]?.id
|
|
|
|
progressCache.removeValue(forKey: key)
|
|
recentItemsCache.removeValue(forKey: key)
|
|
persistRecentItems()
|
|
|
|
guard network.isOnline else { return }
|
|
|
|
// Cache might be from before the `id` field was added — fall back to
|
|
// a fresh fetchProgress to obtain it.
|
|
if progressId == nil {
|
|
progressId = (try? await client.fetchProgress(itemId: itemId, episodeId: episodeId))?.id
|
|
}
|
|
guard let pid = progressId else { return }
|
|
try? await client.deleteProgress(progressId: pid)
|
|
}
|
|
|
|
/// Plays an item from the Home screen / recent-items cache. Books resume
|
|
/// via the normal flow, but podcasts need an extra step: the cached
|
|
/// LibraryItem carries an `episodeId` but no `audioFiles` (those live on
|
|
/// the PodcastEpisode, not the podcast container). We fetch the episode
|
|
/// list, locate the matching one, and hand off to `play(podcast:episode:)`.
|
|
func playFromRecents(_ item: LibraryItem) async {
|
|
guard item.isPodcastEpisode, let episodeId = item.episodeId else {
|
|
await play(item: item)
|
|
return
|
|
}
|
|
guard network.isOnline,
|
|
let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: item.id),
|
|
let episode = episodes.first(where: { $0.id == episodeId }) else { return }
|
|
await play(podcast: podcast, episode: episode)
|
|
}
|
|
|
|
/// Convenience for podcast episodes.
|
|
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
|
var synthetic = LibraryItem(
|
|
id: podcast.id,
|
|
title: episode.title,
|
|
author: podcast.title,
|
|
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
|
|
audioFiles: [episode.audioFile]
|
|
)
|
|
synthetic.mediaType = "podcast"
|
|
synthetic.episodeId = episode.id
|
|
await play(item: synthetic)
|
|
}
|
|
|
|
func stopPlayback(reportFinal: Bool = true) {
|
|
if reportFinal { reportProgress(force: true) }
|
|
syncTimer?.invalidate()
|
|
syncTimer = nil
|
|
player.teardown()
|
|
currentItem = nil
|
|
pendingServerProgress = nil
|
|
lastReportedSecond = -10
|
|
}
|
|
|
|
func togglePlay() {
|
|
guard currentItem != nil else { return }
|
|
player.togglePlay()
|
|
if !player.isPlaying { reportProgress(force: true) }
|
|
}
|
|
|
|
func skip(by seconds: Double) {
|
|
guard let item = currentItem else { return }
|
|
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
|
history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
|
|
}
|
|
player.skip(by: seconds)
|
|
reportProgress(force: true)
|
|
}
|
|
|
|
func seekAbsolute(_ target: Double) {
|
|
guard let item = currentItem else { return }
|
|
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
|
history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
|
|
}
|
|
player.seekAbsolute(target)
|
|
reportProgress(force: true)
|
|
}
|
|
|
|
func setRate(_ newRate: Float) {
|
|
player.setRate(newRate)
|
|
}
|
|
|
|
func addBookmark(title: String) {
|
|
guard let item = currentItem else { return }
|
|
let t = player.absoluteCurrentTime
|
|
bookmarks.add(item: item, time: t, title: title, chapters: item.chapters)
|
|
if network.isOnline {
|
|
Task { try? await client.createBookmark(itemId: item.id, time: t, title: title) }
|
|
}
|
|
}
|
|
|
|
func deleteBookmark(_ bookmark: Bookmark) {
|
|
bookmarks.delete(bookmark)
|
|
if network.isOnline {
|
|
Task { try? await client.deleteBookmark(itemId: bookmark.itemId, time: bookmark.time) }
|
|
}
|
|
}
|
|
|
|
private func startSyncTimer() {
|
|
syncTimer?.invalidate()
|
|
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.reportProgress(force: false)
|
|
}
|
|
}
|
|
RunLoop.main.add(timer, forMode: .common)
|
|
syncTimer = timer
|
|
}
|
|
|
|
// MARK: - Recent items cache
|
|
|
|
private var recentItemsFile: URL {
|
|
AppPaths.supportDirectory.appendingPathComponent("recent-items.json")
|
|
}
|
|
|
|
func loadRecentItemsCache() {
|
|
guard let data = try? Data(contentsOf: recentItemsFile),
|
|
let decoded = try? JSONDecoder().decode([String: LibraryItem].self, from: data) else { return }
|
|
recentItemsCache = decoded
|
|
}
|
|
|
|
private func persistRecentItems() {
|
|
// Cap at 100 most-recently-touched syncKeys (based on progressCache) so
|
|
// the file doesn't grow unbounded for users with massive listening history.
|
|
let keep = Set(progressCache.values
|
|
.sorted { $0.updatedAt > $1.updatedAt }
|
|
.prefix(100)
|
|
.map { $0.syncKey })
|
|
let trimmed = recentItemsCache.filter { keep.contains($0.key) || recentItemsCache.count <= 100 }
|
|
do {
|
|
try FileManager.default.createDirectory(at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
|
let data = try JSONEncoder().encode(trimmed)
|
|
try data.write(to: recentItemsFile, options: .atomic)
|
|
recentItemsCache = trimmed
|
|
} catch {
|
|
// non-fatal
|
|
}
|
|
}
|
|
|
|
/// Fills `recentItemsCache` for the top-N most recent progress entries that
|
|
/// don't yet have details. Pulls from downloaded items first (no network),
|
|
/// then `fetchItemDetail` for the rest. Called by HomeView on appear.
|
|
func fillRecentItemsCache(limit: Int = 20) async {
|
|
let topKeys = progressCache.values
|
|
.filter { !$0.isFinished }
|
|
.sorted { $0.updatedAt > $1.updatedAt }
|
|
.prefix(limit)
|
|
.map { $0.syncKey }
|
|
|
|
var newlyAdded = false
|
|
for key in topKeys {
|
|
// Skip if already cached AND has audio files. A cached podcast
|
|
// item with empty audioFiles is a stale entry from the old
|
|
// fillRecentItemsCache path (fetchItemDetail returned a container,
|
|
// not an episode) — re-fetch via fetchEpisodes below.
|
|
if let existing = recentItemsCache[key], !existing.audioFiles.isEmpty { continue }
|
|
// 1) Try downloaded items first
|
|
if let downloaded = downloads.downloadedItems[key] {
|
|
let files: [AudioFile] = downloaded.tracks.enumerated().map { idx, t in
|
|
AudioFile(ino: t.ino, filename: t.filename, ext: "", durationSeconds: t.durationSeconds, index: idx)
|
|
}
|
|
var li = LibraryItem(
|
|
id: downloaded.itemId,
|
|
title: downloaded.title,
|
|
author: downloaded.author,
|
|
durationSeconds: downloaded.durationSeconds,
|
|
audioFiles: files
|
|
)
|
|
if let episodeId = downloaded.episodeId {
|
|
li.mediaType = "podcast"
|
|
li.episodeId = episodeId
|
|
}
|
|
recentItemsCache[key] = li
|
|
newlyAdded = true
|
|
continue
|
|
}
|
|
// 2) Fetch from server
|
|
guard network.isOnline,
|
|
let progress = progressCache[key] else { continue }
|
|
|
|
if let episodeId = progress.episodeId {
|
|
// Podcast episode: fetch episodes and synthesize a LibraryItem
|
|
// that carries the episode's title + audioFile (analogous to
|
|
// `play(podcast:episode:)`). Without this the cached item has
|
|
// the podcast container's title, not the episode title.
|
|
guard let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: progress.itemId),
|
|
let episode = episodes.first(where: { $0.id == episodeId }) else { continue }
|
|
var synthetic = LibraryItem(
|
|
id: podcast.id,
|
|
title: episode.title,
|
|
author: podcast.title,
|
|
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
|
|
audioFiles: [episode.audioFile]
|
|
)
|
|
synthetic.mediaType = "podcast"
|
|
synthetic.episodeId = episode.id
|
|
recentItemsCache[key] = synthetic
|
|
newlyAdded = true
|
|
} else {
|
|
// Audiobook
|
|
guard let detail = try? await client.fetchItemDetail(itemId: progress.itemId) else { continue }
|
|
recentItemsCache[key] = detail
|
|
newlyAdded = true
|
|
}
|
|
}
|
|
if newlyAdded { persistRecentItems() }
|
|
}
|
|
|
|
private func reportProgress(force: Bool) {
|
|
guard let item = currentItem else { return }
|
|
let t = player.absoluteCurrentTime
|
|
let d = player.totalDuration
|
|
guard d > 0 else { return }
|
|
if !force && abs(t - lastReportedSecond) < 3 { return }
|
|
lastReportedSecond = t
|
|
let finished = (d - t) < 30
|
|
|
|
cacheProgress(
|
|
itemId: item.id,
|
|
episodeId: item.episodeId,
|
|
currentTime: t,
|
|
duration: d,
|
|
isFinished: finished
|
|
)
|
|
lastPushedAt = Date()
|
|
|
|
Task {
|
|
await sync.report(
|
|
itemId: item.id,
|
|
episodeId: item.episodeId,
|
|
currentTime: t,
|
|
duration: d,
|
|
isFinished: finished,
|
|
isOnline: network.isOnline
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
extension LibraryItem {
|
|
/// Matches PlaybackProgress.syncKey for cache lookups.
|
|
var syncKey: String {
|
|
if let episodeId { return "\(id)|\(episodeId)" }
|
|
return id
|
|
}
|
|
|
|
/// The DownloadManager keys downloads by this composite identifier,
|
|
/// allowing the same podcast item to host multiple per-episode downloads.
|
|
var downloadKey: String { syncKey }
|
|
}
|