Merge iOS and Mac app into one
This commit is contained in:
189
ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
Normal file
189
ABS Client/Audiobookshelf swift/Views/LibraryListView.swift
Normal file
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
enum LibraryLayout: String, CaseIterable, Identifiable {
|
||||
case grid
|
||||
case list
|
||||
|
||||
var id: String { rawValue }
|
||||
var label: String { self == .grid ? "Kachelansicht" : "Listenansicht" }
|
||||
var systemImage: String { self == .grid ? "square.grid.2x2" : "list.bullet" }
|
||||
}
|
||||
|
||||
struct LibraryListView: View {
|
||||
let items: [LibraryItem]
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
let onSelect: (LibraryItem) -> Void
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
LibraryListRow(item: item)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(item) }
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable {
|
||||
await onRefresh?()
|
||||
}
|
||||
#else
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
LibraryListRow(item: item)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(item) }
|
||||
if idx < items.count - 1 {
|
||||
Divider().padding(.leading, 76)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
struct LibraryListRow: View {
|
||||
@Environment(AppState.self) private var app
|
||||
let item: LibraryItem
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
cover
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Text(item.author)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
let fraction = app.progressFraction(itemId: item.id, episodeId: item.episodeId)
|
||||
if fraction > 0 {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.green)
|
||||
.frame(width: max(2, geo.size.width * fraction))
|
||||
}
|
||||
}
|
||||
.frame(height: 3)
|
||||
.padding(.top, 2)
|
||||
#if os(macOS)
|
||||
.padding(.trailing, 40)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
#if os(macOS)
|
||||
if item.durationSeconds > 0 {
|
||||
Text(formatDuration(item.durationSeconds))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
downloadStatus
|
||||
#if os(macOS)
|
||||
.frame(width: 28)
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
#endif
|
||||
.contextMenu { downloadMenuItems }
|
||||
}
|
||||
|
||||
private var cover: some View {
|
||||
Group {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
AsyncImage(url: url) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
Rectangle().fill(.quaternary)
|
||||
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)
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.frame(width: 52, height: 52)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
#else
|
||||
.frame(width: 48, height: 48)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var downloadStatus: some View {
|
||||
let state = app.downloads.state(for: item.downloadKey)
|
||||
switch state {
|
||||
case .downloaded:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.title3)
|
||||
case .downloading(let p):
|
||||
DownloadProgressRing(progress: p)
|
||||
#if os(iOS)
|
||||
.frame(width: 22, height: 22)
|
||||
#else
|
||||
.frame(width: 24, height: 24)
|
||||
#endif
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.white, .red)
|
||||
.font(.title3)
|
||||
case .notDownloaded:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func formatDuration(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||
let total = Int(seconds)
|
||||
let h = total / 3600
|
||||
let m = (total % 3600) / 60
|
||||
if h > 0 { return "\(h) h \(m) min" }
|
||||
return "\(m) min"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user