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 }