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

@@ -15,6 +15,7 @@ enum SleepTimerMode: Equatable, Hashable {
case off
case minutes(Int)
case endOfBook
case endOfChapter
}
@Observable
@@ -29,8 +30,13 @@ final class PlayerEngine {
var sleepTimer: SleepTimerMode = .off
/// Verbleibende Wallclock-Sekunden bis der Sleep-Timer auslöst (0 wenn off).
/// Pausiert mit der Wiedergabe; bei `.endOfBook` rate-skaliert aus der Restspielzeit.
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
var sleepRemainingSeconds: Double = 0
var chapters: [Chapter] = []
var currentChapter: Chapter? {
chapters.last { $0.start <= absoluteCurrentTime }
}
private var player: AVQueuePlayer?
private var trackDurations: [Double] = []
@@ -118,6 +124,7 @@ final class PlayerEngine {
}
}
self.chapters = item.chapters
currentTitle = item.title
currentAuthor = item.author
currentCoverURL = client.coverURL(itemId: item.id)
@@ -159,8 +166,10 @@ final class PlayerEngine {
func setRate(_ newRate: Float) {
rate = newRate
if isPlaying { player?.rate = newRate }
if case .endOfBook = sleepTimer {
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
switch sleepTimer {
case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
case .endOfChapter: sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
default: break
}
updateNowPlayingInfo()
}
@@ -241,6 +250,7 @@ final class PlayerEngine {
isPlaying = player.timeControlStatus == .playing
if wasPlaying != isPlaying { updateNowPlayingInfo() }
updateEndOfBookSleep()
updateEndOfChapterSleep()
}
func teardown() {
@@ -264,6 +274,7 @@ final class PlayerEngine {
itemId = nil
errorMessage = nil
isSeeking = false
chapters = []
currentTitle = ""
currentAuthor = ""
currentCoverURL = nil
@@ -284,6 +295,8 @@ final class PlayerEngine {
if isPlaying { startSleepTickTask() }
case .endOfBook:
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
case .endOfChapter:
sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
}
}
@@ -322,6 +335,22 @@ final class PlayerEngine {
return playback / r
}
private func updateEndOfChapterSleep() {
guard case .endOfChapter = sleepTimer else { return }
sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
if sleepRemainingSeconds <= 0 {
sleepTimer = .off
pause()
}
}
private func wallclockRemainingUntilEndOfChapter() -> Double {
let chapterEnd = currentChapter?.end ?? totalDuration
let playback = max(0, chapterEnd - absoluteCurrentTime)
let r = max(0.1, Double(rate))
return playback / r
}
// MARK: - Now-playing / remote commands
private func configureRemoteCommandsIfNeeded() {