Files
ABS-Client/ABS Client Mac/Audiobookshelf swift/Views/MainView.swift
2026-05-17 08:45:37 +02:00

237 lines
8.1 KiB
Swift

import SwiftUI
enum LibraryFilter: Hashable {
case library(String)
case downloaded
}
@Observable
@MainActor
final class LibraryViewModel {
var libraries: [Library] = []
var items: [LibraryItem] = []
var isLoading: Bool = false
var errorMessage: String?
var selection: LibraryFilter?
func loadLibraries(client: ABSClient) async {
isLoading = true
defer { isLoading = false }
do {
libraries = try await client.fetchLibraries()
if selection == nil, let first = libraries.first {
selection = .library(first.id)
}
} catch {
errorMessage = error.localizedDescription
}
}
func loadItems(client: ABSClient, downloads: DownloadManager) async {
guard let selection else { return }
isLoading = true
defer { isLoading = false }
switch selection {
case .library(let id):
do {
items = try await client.fetchItems(libraryId: id)
errorMessage = nil
} catch {
errorMessage = error.localizedDescription
}
case .downloaded:
items = downloads.downloadedItems.values.map { di in
let files: [AudioFile] = di.tracks.enumerated().map { idx, t in
AudioFile(
ino: t.ino,
filename: t.filename,
ext: "",
durationSeconds: t.durationSeconds,
index: idx
)
}
var li = LibraryItem(
id: di.itemId,
title: di.title,
author: di.author,
durationSeconds: di.durationSeconds,
audioFiles: files
)
if let episodeId = di.episodeId {
li.mediaType = "podcast"
li.episodeId = episodeId
}
return li
}.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
errorMessage = nil
}
}
}
struct MainView: View {
@Environment(AppState.self) private var app
@State private var vm = LibraryViewModel()
@State private var navPath: [LibraryItem] = []
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
private var layout: LibraryLayout {
LibraryLayout(rawValue: layoutRaw) ?? .grid
}
var body: some View {
NavigationSplitView {
sidebar
} detail: {
NavigationStack(path: $navPath) {
detail
.navigationDestination(for: LibraryItem.self) { podcast in
PodcastDetailView(podcast: podcast)
}
}
}
.task {
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
.onChange(of: vm.selection) { _, _ in
navPath.removeAll()
Task {
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
PlayerBar()
.animation(.easeInOut(duration: 0.2), value: app.currentItem?.id)
.animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback)
}
}
private func handleSelect(_ item: LibraryItem) {
if item.isPodcastContainer {
navPath.append(item)
} else {
Task { await app.play(item: item) }
}
}
private var sidebar: some View {
List(selection: $vm.selection) {
Section("Bibliotheken") {
ForEach(vm.libraries) { lib in
Label(lib.name, systemImage: "books.vertical")
.tag(LibraryFilter.library(lib.id))
}
}
Section("Offline") {
Label("Heruntergeladen", systemImage: "arrow.down.circle.fill")
.tag(LibraryFilter.downloaded)
}
}
.listStyle(.sidebar)
.navigationTitle("ABS Client")
.safeAreaInset(edge: .bottom) {
sidebarFooter
}
}
private var sidebarFooter: some View {
VStack(alignment: .leading, spacing: 6) {
Divider()
HStack(spacing: 8) {
Circle()
.fill(app.network.isOnline ? .green : .orange)
.frame(width: 8, height: 8)
Text(app.network.isOnline ? "Online" : "Offline")
.font(.caption)
if app.sync.queuedCount > 0 {
Text("(\(app.sync.queuedCount) wartend)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
HStack {
Text(app.auth.username).font(.caption).foregroundStyle(.secondary)
Spacer()
Button("Abmelden") {
app.stopPlayback()
app.auth.logout()
}
.buttonStyle(.borderless)
.font(.caption)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
@ViewBuilder
private var detail: some View {
if vm.isLoading && vm.items.isEmpty {
ProgressView("Lade Bibliothek …")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let err = vm.errorMessage, vm.items.isEmpty {
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
} else if vm.items.isEmpty {
ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher."))
} else {
Group {
switch layout {
case .grid:
LibraryGridView(items: vm.items) { item in
handleSelect(item)
}
case .list:
LibraryListView(items: vm.items) { item in
handleSelect(item)
}
}
}
.navigationTitle(currentTitle)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task {
await vm.loadLibraries(client: app.client)
await vm.loadItems(client: app.client, downloads: app.downloads)
await app.refreshProgressCache()
}
} label: {
if vm.isLoading {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.clockwise")
}
}
.help("Bibliothek, Cover und Hörfortschritte neu laden")
.disabled(vm.isLoading)
}
ToolbarItem(placement: .primaryAction) {
Picker("Ansicht", selection: $layoutRaw) {
ForEach(LibraryLayout.allCases) { l in
Image(systemName: l.systemImage)
.help(l.label)
.tag(l.rawValue)
}
}
.pickerStyle(.segmented)
.help("Zwischen Kachel- und Listenansicht wechseln")
}
}
}
}
private var currentTitle: String {
switch vm.selection {
case .library(let id):
return vm.libraries.first(where: { $0.id == id })?.name ?? "Bibliothek"
case .downloaded:
return "Heruntergeladen"
case .none:
return "Bibliothek"
}
}
}