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) }