import SwiftUI struct LibraryItemCell: View { @Environment(AppState.self) private var app let item: LibraryItem var body: some View { VStack(alignment: .leading, spacing: 2) { ZStack(alignment: .bottom) { ZStack(alignment: .topTrailing) { cover downloadBadge.padding(4) } 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) Text(item.author) .font(.system(size: 9)) .foregroundStyle(.secondary) .lineLimit(1) } // Ensure the cell fills its full grid column width .frame(maxWidth: .infinity, alignment: .leading) .contextMenu { downloadMenuItems } } // 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) } }