Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/AppState.swift
Scarriffle a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- 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>
2026-06-23 08:54:43 +02:00

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 }
}