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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user