Merge iOS and Mac app into one
This commit is contained in:
201
ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Normal file
201
ABS Client/Audiobookshelf swift/Views/LibraryItemCell.swift
Normal file
@@ -0,0 +1,201 @@
|
||||
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 {
|
||||
Color(.systemGray6) // neutral bg for PNG transparent areas
|
||||
.frame(maxWidth: .infinity) // explicitly fill the column width
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
AsyncImage(url: url) { image in
|
||||
image.resizable().scaledToFill()
|
||||
} placeholder: {
|
||||
ProgressView().tint(.accentColor)
|
||||
}
|
||||
.clipped() // clip image overflow before rounding
|
||||
} else {
|
||||
Image(systemName: "book.closed")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private var macosCover: some View {
|
||||
Group {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
Rectangle().fill(.quaternary)
|
||||
.overlay(ProgressView().controlSize(.small))
|
||||
case .success(let img):
|
||||
img.resizable().aspectRatio(contentMode: .fill)
|
||||
case .failure:
|
||||
Rectangle().fill(.quaternary)
|
||||
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
|
||||
@unknown default:
|
||||
Rectangle().fill(.quaternary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Rectangle().fill(.quaternary)
|
||||
}
|
||||
}
|
||||
.frame(width: 180, height: 180)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
#endif
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user