Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Scarriffle a17a61ad9a Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library
- Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library
- Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail
- Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished
- Add AirPlay picker in PlayerBar
- Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup
- Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s
- Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id)
- Bump CFBundleVersion to 8

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 08:54:43 +02:00

267 lines
9.6 KiB
Swift

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