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

@@ -3,8 +3,11 @@ import SwiftUI
struct PlayerBar: View {
@Environment(AppState.self) private var app
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
@State private var scrubbing: Bool = false
@State private var scrubValue: Double = 0
@State private var showDetails: Bool = false
@State private var showFullHistory: Bool = false
var body: some View {
if let item = app.currentItem {
@@ -21,6 +24,14 @@ struct PlayerBar: View {
.background(.bar)
}
.transition(.move(edge: .bottom).combined(with: .opacity))
.sheet(isPresented: $showDetails) {
PlaybackDetailsView()
.environment(app)
}
.sheet(isPresented: $showFullHistory) {
FullHistoryView()
.environment(app)
}
} else if app.isPreparingPlayback {
VStack(spacing: 0) {
Divider()
@@ -77,6 +88,17 @@ struct PlayerBar: View {
Spacer()
if historyEnabled {
historyQuickMenu
}
Button { showDetails = true } label: {
Image(systemName: "list.bullet.indent")
.font(.system(size: 22))
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
sleepMenu
rateMenu
@@ -119,6 +141,17 @@ struct PlayerBar: View {
Spacer(minLength: 0)
if historyEnabled {
historyQuickMenu
}
Button { showDetails = true } label: {
Image(systemName: "list.bullet.indent")
}
.buttonStyle(.plain)
.disabled(!app.player.isReady)
.help("Kapitel & Lesezeichen")
statusIndicator
Button {
@@ -235,6 +268,51 @@ struct PlayerBar: View {
}
}
private var detailsButtonVisible: Bool {
app.player.isReady
}
private var historyQuickMenu: some View {
Menu {
let recent = Array(app.history.entries
.filter { $0.itemId == app.currentItem?.id && $0.episodeId == app.currentItem?.episodeId }
.prefix(5))
if recent.isEmpty {
Text(String(localized: "history.empty"))
.foregroundStyle(.secondary)
} else {
ForEach(recent) { entry in
Button {
app.seekAbsolute(entry.position)
} label: {
let timeStr = formatTime(entry.position)
let label = entry.chapterTitle.map { "\($0) · \(timeStr)" } ?? timeStr
Label(label, systemImage: "clock")
}
}
Divider()
}
Button {
showFullHistory = true
} label: {
Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath")
}
} label: {
Image(systemName: "clock.arrow.circlepath")
#if os(iOS)
.font(.system(size: 22))
#else
.font(.system(size: 16))
#endif
}
#if os(macOS)
.menuStyle(.borderlessButton)
.fixedSize()
#endif
.menuIndicator(.hidden)
.help(String(localized: "player.history_recent"))
}
private var sleepMenu: some View {
Menu {
sleepOption(title: "Aus", mode: .off)
@@ -244,6 +322,9 @@ struct PlayerBar: View {
sleepOption(title: "30 Minuten", mode: .minutes(30))
sleepOption(title: "1 Stunde", mode: .minutes(60))
sleepOption(title: endOfPlaybackLabel, mode: .endOfBook)
if !(app.currentItem?.chapters.isEmpty ?? true) {
sleepOption(title: "Bis Ende des Kapitels", mode: .endOfChapter)
}
} label: {
Image(systemName: app.player.sleepTimer == .off ? "moon.zzz" : "moon.zzz.fill")
#if os(iOS)