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>
This commit is contained in:
Scarriffle
2026-05-25 18:40:52 +02:00
parent 15d8e71d09
commit fa47cae664
21 changed files with 1751 additions and 119 deletions

View File

@@ -10,9 +10,14 @@ final class AppState {
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.
@@ -20,6 +25,7 @@ final class AppState {
private var syncTimer: Timer?
private var lastReportedSecond: Double = -10
private var lastTrackedChapterId: Int?
init() {
let auth = AuthStore()
@@ -30,6 +36,8 @@ final class AppState {
self.downloads = DownloadManager(client: client)
self.sync = ProgressSyncManager(client: client)
self.player = PlayerEngine()
self.history = HistoryManager()
self.bookmarks = BookmarkManager()
}
func bootstrap() async {
@@ -86,27 +94,31 @@ final class AppState {
return min(1, max(0, p.currentTime / p.duration))
}
func play(item: LibraryItem) async {
func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
player.play()
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
// Only fetch detail for books with empty audioFiles (podcast episodes
// arrive with their single audioFile already populated by the caller).
if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
// 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 = 0
if network.isOnline {
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.
@@ -116,15 +128,40 @@ final class AppState {
}
}
}
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(
@@ -146,6 +183,7 @@ final class AppState {
player.teardown()
currentItem = nil
lastReportedSecond = -10
lastTrackedChapterId = nil
}
func togglePlay() {
@@ -161,7 +199,10 @@ final class AppState {
}
func seekAbsolute(_ target: Double) {
guard currentItem != nil else { return }
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)
}
@@ -170,6 +211,22 @@ final class AppState {
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
@@ -181,10 +238,21 @@ final class AppState {
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