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