Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/LibraryListView.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

212 lines
7.5 KiB
Swift

import SwiftUI
enum LibraryLayout: String, CaseIterable, Identifiable {
case grid
case list
var id: String { rawValue }
var label: String { self == .grid ? "Kachelansicht" : "Listenansicht" }
var systemImage: String { self == .grid ? "square.grid.2x2" : "list.bullet" }
}
struct LibraryListView: View {
let items: [LibraryItem]
var onRefresh: (() async -> Void)? = nil
var dimDownloading: Bool = false
let onSelect: (LibraryItem) -> Void
var body: some View {
#if os(iOS)
List {
ForEach(items) { item in
LibraryListRow(item: item, dimDownloading: dimDownloading)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
}
.listStyle(.plain)
.refreshable {
await onRefresh?()
}
#else
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
LibraryListRow(item: item, dimDownloading: dimDownloading)
.contentShape(Rectangle())
.onTapGesture { onSelect(item) }
if idx < items.count - 1 {
Divider().padding(.leading, 76)
}
}
}
.padding(.vertical, 4)
}
#endif
}
}
struct LibraryListRow: View {
@Environment(AppState.self) private var app
let item: LibraryItem
var dimDownloading: Bool = false
var body: some View {
HStack(spacing: 12) {
ZStack {
cover
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
LargeDownloadOverlay(progress: p, size: 40)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.headline)
.lineLimit(1)
Text(item.author)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
if fraction > 0 {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
RoundedRectangle(cornerRadius: 1.5).fill(Color.green)
.frame(width: max(2, geo.size.width * fraction))
}
}
.frame(height: 3)
.padding(.top, 2)
#if os(macOS)
.padding(.trailing, 40)
#endif
}
}
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
Spacer(minLength: 8)
#if os(macOS)
if dimDownloading, app.downloads.downloadedBytes(for: item.downloadKey) > 0 {
Text(formatBytes(app.downloads.downloadedBytes(for: item.downloadKey)))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
} else if item.durationSeconds > 0 {
Text(formatDuration(item.durationSeconds))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
}
#endif
if !(dimDownloading && isActivelyDownloading) {
downloadStatus
#if os(macOS)
.frame(width: 28)
#endif
}
}
#if os(macOS)
.padding(.horizontal, 16)
.padding(.vertical, 8)
#endif
.contextMenu { downloadMenuItems }
}
private var isActivelyDownloading: Bool {
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
return false
}
private var cover: some View {
Group {
if let url = app.client.coverURL(itemId: item.id) {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
Rectangle().fill(.quaternary)
case .success(let img):
img.resizable().aspectRatio(contentMode: .fill)
case .failure:
Rectangle().fill(.quaternary)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
@unknown default:
Rectangle().fill(.quaternary)
}
}
} else {
Rectangle().fill(.quaternary)
}
}
#if os(iOS)
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 6))
#else
.frame(width: 48, height: 48)
.clipShape(RoundedRectangle(cornerRadius: 4))
#endif
}
@ViewBuilder
private var downloadStatus: some View {
let state = app.downloads.state(for: item.downloadKey)
switch state {
case .downloaded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.white, .green)
.font(.title3)
case .downloading(let p):
DownloadProgressRing(progress: p)
#if os(iOS)
.frame(width: 22, height: 22)
#else
.frame(width: 24, height: 24)
#endif
case .failed:
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.title3)
case .notDownloaded:
EmptyView()
}
}
@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")
}
}
}
}
#if os(macOS)
private func formatDuration(_ seconds: Double) -> String {
guard seconds.isFinite, seconds > 0 else { return "" }
let total = Int(seconds)
let h = total / 3600
let m = (total % 3600) / 60
if h > 0 { return "\(h) h \(m) min" }
return "\(m) min"
}
#endif
}