- 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>
267 lines
9.6 KiB
Swift
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)
|
|
}
|