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 #if os(iOS) @State private var showSettings: Bool = false #endif private var layout: LibraryLayout { LibraryLayout(rawValue: layoutRaw) ?? .grid } var body: some View { // Modifiers like .task and .onChange cannot chain after a #if/#endif block // in a @ViewBuilder — wrap the conditional nav in a separate property instead. navigationRoot .task { await loadAll() } .onChange(of: vm.selection) { _, _ in navPath.removeAll() Task { await loadAll() } } .safeAreaInset(edge: .bottom, spacing: 0) { PlayerBar() .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id) .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback) } } @ViewBuilder private var navigationRoot: some View { #if os(iOS) NavigationStack(path: $navPath) { detail .navigationTitle("") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { libraryMenu } ToolbarItem(placement: .topBarTrailing) { Menu { Picker("Ansicht", selection: $layoutRaw) { ForEach(LibraryLayout.allCases) { l in Label(l.label, systemImage: l.systemImage).tag(l.rawValue) } } Divider() Button { showSettings = true } label: { Label("Einstellungen", systemImage: "gearshape") } Divider() statusMenuSection } label: { Image(systemName: "ellipsis.circle") } } } .navigationDestination(for: LibraryItem.self) { podcast in PodcastDetailView(podcast: podcast) } } .sheet(isPresented: $showSettings) { SettingsView() .environment(app) } #else NavigationSplitView { sidebar } detail: { NavigationStack(path: $navPath) { detail .navigationDestination(for: LibraryItem.self) { podcast in PodcastDetailView(podcast: podcast) } } } #endif } private func loadAll() async { await vm.loadLibraries(client: app.client) await vm.loadItems(client: app.client, downloads: app.downloads) await app.refreshProgressCache() } private func handleSelect(_ item: LibraryItem) { if item.isPodcastContainer { navPath.append(item) } else { Task { await app.play(item: item) } } } // MARK: - macOS sidebar #if os(macOS) 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) } #endif // MARK: - Detail content (shared) @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, onRefresh: loadAll) { handleSelect($0) } case .list: LibraryListView(items: vm.items, onRefresh: loadAll) { handleSelect($0) } } } #if os(macOS) .navigationTitle(currentTitle) .toolbar { ToolbarItem(placement: .primaryAction) { Button { Task { await loadAll() } } 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") } } #endif } } // MARK: - iOS-only helpers #if os(iOS) private var libraryMenu: some View { Menu { Picker("Bibliothek", selection: Binding( get: { vm.selection ?? .library("") }, set: { vm.selection = $0 } )) { 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) } } } label: { HStack(spacing: 4) { Image(systemName: selectionIcon) Text(currentTitle) .lineLimit(1) .font(.headline) Image(systemName: "chevron.down") .font(.caption) } .foregroundStyle(.primary) } } @ViewBuilder private var statusMenuSection: some View { Section(app.auth.username.isEmpty ? "Status" : "Angemeldet als \(app.auth.username)") { Label(app.network.isOnline ? "Online" : "Offline", systemImage: app.network.isOnline ? "wifi" : "wifi.slash") if app.sync.queuedCount > 0 { Label("\(app.sync.queuedCount) Synchronisationen wartend", systemImage: "arrow.triangle.2.circlepath") } } } private var selectionIcon: String { switch vm.selection { case .downloaded: return "arrow.down.circle.fill" default: return "books.vertical" } } #endif // MARK: - Shared helpers 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" } } }