Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Scarriffle fa47cae664 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>
2026-05-25 18:43:16 +02:00

269 lines
9.4 KiB
Swift

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
.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)
.padding(.bottom, 3)
}
Text(item.title)
#if os(iOS)
.font(.system(size: 11, weight: .bold))
#else
.font(.headline)
#endif
.lineLimit(2)
.multilineTextAlignment(.leading)
.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 {
#if os(iOS)
iOSCover
#else
macosCover
#endif
}
#if os(iOS)
private var iOSCover: some View {
coverContainer(background: AnyShapeStyle(Color(.systemGray6)))
}
#endif
#if os(macOS)
private var macosCover: some View {
coverContainer(background: AnyShapeStyle(.quaternary))
}
#endif
/// Quadratischer Cover-Container: füllt die Spaltenbreite, behält 1:1-Aspektverhältnis
/// und zeigt das Bild **ohne Verzerrung** (`.scaledToFit`). Nicht-quadratische Cover
/// bekommen Letterbox-/Pillarbox-Ränder im neutralen Hintergrund.
private func coverContainer(background: AnyShapeStyle) -> some View {
Rectangle()
.fill(background)
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.overlay {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let img):
img.resizable().scaledToFit()
case .empty:
ProgressView()
#if os(macOS)
.controlSize(.small)
#else
.tint(.accentColor)
#endif
case .failure:
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
@unknown default:
EmptyView()
}
}
} else {
Image(systemName: "book.closed")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
// MARK: - Download badge
@ViewBuilder
private var downloadBadge: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
.shadow(radius: 2)
case .downloading(let p):
DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 26, height: 26)
#else
.frame(width: 32, height: 32)
#endif
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
.shadow(radius: 2)
case .notDownloaded:
EmptyView()
}
}
// MARK: - Context menu
@ViewBuilder
private var downloadMenuItems: some View {
let key = item.downloadKey
let state = app.downloads.state(for: key)
if item.isPodcastContainer {
Text("Episoden zum Download in der Podcast-Ansicht auswählen")
} else {
switch state {
case .notDownloaded, .failed:
Button {
app.downloads.startDownload(item: item)
} label: {
Label("Für Offline herunterladen", systemImage: "arrow.down.circle")
}
case .downloading:
Button {
app.downloads.cancel(downloadKey: key)
} label: {
Label("Download abbrechen", systemImage: "xmark.circle")
}
case .downloaded:
Button(role: .destructive) {
app.downloads.delete(downloadKey: key)
} label: {
Label("Heruntergeladene Dateien löschen", systemImage: "trash")
}
}
}
}
}
// MARK: - Shared components
struct CoverProgressBar: View {
let fraction: Double
var body: some View {
if fraction > 0 {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.black.opacity(0.55))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Color.accentColor)
.frame(width: max(2, geo.size.width * fraction), height: 4)
}
}
.frame(height: 4)
.shadow(color: .black.opacity(0.35), radius: 1, y: 1)
}
}
}
struct DownloadProgressRing: View {
let progress: Double
var body: some View {
ZStack {
Circle()
.fill(Color.black.opacity(0.75))
Circle()
.stroke(Color.white.opacity(0.25), lineWidth: 3)
.padding(4)
Circle()
.trim(from: 0, to: max(0.03, min(progress, 1)))
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.rotationEffect(.degrees(-90))
.padding(4)
.animation(.easeInOut(duration: 0.25), value: progress)
Image(systemName: "arrow.down")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
}
.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)
}