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,13 +3,22 @@ import SwiftUI
struct LibraryItemCell: View {
@Environment(AppState.self) private var app
let item: LibraryItem
var dimDownloading: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
ZStack(alignment: .bottom) {
ZStack(alignment: .topTrailing) {
cover
downloadBadge.padding(4)
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
if !(dimDownloading && isActivelyDownloading) {
downloadBadge.padding(4)
}
}
.overlay {
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
LargeDownloadOverlay(progress: p)
}
}
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
.padding(.horizontal, 3)
@@ -23,16 +32,38 @@ struct LibraryItemCell: View {
#endif
.lineLimit(2)
.multilineTextAlignment(.leading)
Text(item.author)
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
HStack(spacing: 4) {
Text(item.author)
.font(.system(size: 9))
.foregroundStyle(.secondary)
.lineLimit(1)
if dimDownloading {
let bytes = app.downloads.downloadedBytes(for: item.downloadKey)
if bytes > 0 {
Text("·")
.font(.system(size: 9))
.foregroundStyle(.secondary)
Text(formatBytes(bytes))
.font(.system(size: 9, weight: .medium).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize()
}
}
}
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
}
// Ensure the cell fills its full grid column width
.frame(maxWidth: .infinity, alignment: .leading)
.contextMenu { downloadMenuItems }
}
private var isActivelyDownloading: Bool {
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
return false
}
// MARK: - Cover
private var cover: some View {
@@ -198,3 +229,40 @@ struct DownloadProgressRing: View {
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
}
}
struct LargeDownloadOverlay: View {
let progress: Double
var size: CGFloat = 64
private var lineWidth: CGFloat { size / 13 }
private var padding: CGFloat { size / 7 }
private var fontSize: CGFloat { max(9, size / 5) }
var body: some View {
ZStack {
Circle()
.fill(Color.black.opacity(0.65))
Circle()
.stroke(Color.white.opacity(0.2), lineWidth: lineWidth)
.padding(padding)
Circle()
.trim(from: 0, to: max(0.03, min(progress, 1)))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(padding)
.animation(.easeInOut(duration: 0.3), value: progress)
Text("\(Int(progress * 100))%")
.font(.system(size: fontSize, weight: .semibold).monospacedDigit())
.foregroundStyle(.white)
}
.frame(width: size, height: size)
.shadow(color: .black.opacity(0.45), radius: 6)
}
}
func formatBytes(_ bytes: Int64) -> String {
let mb = Double(bytes) / 1_048_576
if mb >= 1024 { return String(format: "%.1f GB", mb / 1024) }
if mb >= 1 { return String(format: "%.0f MB", mb) }
return String(format: "%.0f KB", Double(bytes) / 1024)
}