Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/AppState.swift
Scarriffle fa47cae664 Add chapters, history, bookmarks, live download progress, and i18n
- Chapter navigation with auto-scroll to current chapter and end-of-chapter sleep timer
- Opt-in listening history (local-only) with XML export and per-item quick menu
- Bookmarks with server sync via Audiobookshelf API
- Live MB counter during downloads via URLSessionDownloadTask delegate
- In-progress downloads shown in "Heruntergeladen" with dimmed cover + ring overlay
- Cover image cache (50 MB memory / 500 MB disk URLCache)
- German/English localization (de.lproj, en.lproj)
- Loading spinner now triggers immediately on view switch instead of waiting for the network

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:43:16 +02:00

293 lines
10 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] = [:]
private var syncTimer: Timer?
private var lastReportedSecond: Double = -10
private var lastTrackedChapterId: Int?
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()
}
func bootstrap() async {
auth.restoreSession()
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()
}
}
}
/// 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 {
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 for books to get chapters; skip if downloaded offline.
if !workItem.isPodcast && network.isOnline {
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
if !alreadyDownloaded, 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
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
if player.errorMessage == nil {
player.play()
startSyncTimer()
let startingChapter = workItem.chapters.last { $0.start <= startAt }
lastTrackedChapterId = startingChapter?.id
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)
}
/// 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
lastReportedSecond = -10
lastTrackedChapterId = nil
}
func togglePlay() {
guard currentItem != nil else { return }
player.togglePlay()
if !player.isPlaying { reportProgress(force: true) }
}
func skip(by seconds: Double) {
guard currentItem != nil else { return }
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
}
private func detectChapterChange(item: LibraryItem) {
let currentId = player.currentChapter?.id
guard currentId != lastTrackedChapterId else { return }
defer { lastTrackedChapterId = currentId }
guard lastTrackedChapterId != nil,
let chapter = player.currentChapter,
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
history.record(item: item, position: chapter.start, chapters: item.chapters)
}
private func reportProgress(force: Bool) {
guard let item = currentItem else { return }
let t = player.absoluteCurrentTime
let d = player.totalDuration
detectChapterChange(item: item)
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
)
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 }
}