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