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) { cover .opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0) .overlay(alignment: .topTrailing) { if isListened { listenedBadge.padding(4) } } .overlay(alignment: .bottomTrailing) { if !(dimDownloading && isActivelyDownloading) { downloadBadge.padding(.trailing, 4).padding(.bottom, 10) } } .overlay { if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) { LargeDownloadOverlay(progress: p) } } if !isListened { 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 { mediaItemContextMenu(for: item, app: app) } } private var isActivelyDownloading: Bool { if case .downloading = app.downloads.state(for: item.downloadKey) { return true } return false } /// "Already listened" — server's authoritative finished flag. The /// Audiobookshelf server flips this once `markAsFinishedPercentComplete` /// (set per library by the admin) is crossed, so we don't need our own /// threshold here. private var isListened: Bool { app.progressCache[item.syncKey]?.isFinished ?? 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: - Badges /// Top-right corner: green checkmark if the server has marked this item /// as finished. Surfaces the "I've listened to this" status that used to /// be tangled with the download checkmark. @ViewBuilder private var listenedBadge: some View { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.white, .green) .font(.title3) .shadow(radius: 2) } /// Bottom-right corner: download status. Was previously a checkmark in the /// top-right; the checkmark now signals "listened", and the download icon /// is a downward arrow so the two states are visually unambiguous. @ViewBuilder private var downloadBadge: some View { let state = app.downloads.state(for: item.downloadKey) switch state { case .downloaded: Image(systemName: "arrow.down.circle.fill") .foregroundStyle(.white, .green) .font(.title3) .shadow(radius: 2) case .downloading(let p): DownloadProgressRing(progress: p) #if os(iOS) .frame(width: 22, height: 22) #else .frame(width: 26, height: 26) #endif case .failed: Image(systemName: "exclamationmark.circle.fill") .foregroundStyle(.white, .red) .font(.title3) .shadow(radius: 2) case .notDownloaded: EmptyView() } } } // 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) }