import SwiftUI enum LibraryFilter: Hashable { case library(String) case downloaded case history } @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 { 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 } switch selection { case .library(let id): do { items = try await client.fetchItems(libraryId: id) errorMessage = nil } catch { errorMessage = error.localizedDescription } case .history: items = [] errorMessage = nil case .downloaded: let completed = downloads.downloadedItems.values.map { di -> LibraryItem 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 } let inProgress = downloads.pendingItems.values.filter { downloads.downloadedItems[$0.downloadKey] == nil } items = (completed + Array(inProgress)) .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 @AppStorage("historyEnabled") private var historyEnabled: Bool = false @State private var showFullHistory: Bool = false #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() } } .onChange(of: app.downloads.pendingItems.count) { _, _ in if vm.selection == .downloaded { Task { await vm.loadItems(client: app.client, downloads: app.downloads) } } } .onChange(of: app.downloads.downloadedItems.count) { _, _ in if vm.selection == .downloaded { Task { await vm.loadItems(client: app.client, downloads: app.downloads) } } } .safeAreaInset(edge: .bottom, spacing: 0) { PlayerBar() .animation(.easeInOut(duration: 0.2), value: app.currentItem?.id) .animation(.easeInOut(duration: 0.2), value: app.isPreparingPlayback) } .sheet(isPresented: $showFullHistory) { FullHistoryView() .environment(app) } } @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) } } if historyEnabled { Divider() Button { showFullHistory = true } label: { Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath") } } 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 { vm.items = [] vm.isLoading = true defer { vm.isLoading = false } await vm.loadLibraries(client: app.client) if vm.selection != .history { 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(String(localized: "sidebar.libraries")) { ForEach(vm.libraries) { lib in Label(lib.name, systemImage: "books.vertical") .tag(LibraryFilter.library(lib.id)) } } Section(String(localized: "sidebar.offline")) { Label(String(localized: "nav.downloaded"), systemImage: "arrow.down.circle.fill") .tag(LibraryFilter.downloaded) } if historyEnabled { Section(String(localized: "nav.history")) { Label(String(localized: "sidebar.history"), systemImage: "clock.arrow.circlepath") .tag(LibraryFilter.history) } } } .listStyle(.sidebar) .navigationTitle(String(localized: "sidebar.app_title")) .safeAreaInset(edge: .bottom) { sidebarFooter } } private var sidebarFooter: some View { VStack(spacing: 0) { Divider() VStack(spacing: 0) { HStack(spacing: 6) { Circle() .fill(app.network.isOnline ? Color.green : Color.orange) .frame(width: 6, height: 6) Text(app.network.isOnline ? String(localized: "sidebar.status_online") : String(localized: "sidebar.status_offline")) .font(.caption2) .foregroundStyle(.tertiary) if app.sync.queuedCount > 0 { Text("· \(app.sync.queuedCount)") .font(.caption2) .foregroundStyle(.tertiary) } Spacer() } .padding(.bottom, 4) HStack(spacing: 6) { Image(systemName: "person.circle") .font(.caption2) .foregroundStyle(.secondary) Text(app.auth.username) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) Spacer() Button { app.stopPlayback() app.auth.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") .font(.caption2) .foregroundStyle(.secondary) } .buttonStyle(.borderless) .help(String(localized: "settings.logout")) } } .padding(.horizontal, 12) .padding(.top, 8) .padding(.bottom, 10) // Reserve space for PlayerBar — macOS safeAreaInset doesn't propagate into // nested safeAreaInset overlays, so we add explicit spacing here. if app.currentItem != nil || app.isPreparingPlayback { Color.clear.frame(height: 78) } } } private var historyDetailContent: some View { List { ForEach(app.history.entries) { entry in let isCurrent = entry.itemId == app.currentItem?.id && entry.episodeId == app.currentItem?.episodeId Button { Task { await app.playFromHistory(entry) } } label: { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 3) { Text(entry.itemTitle) .font(.subheadline.bold()) HStack(spacing: 4) { if let ch = entry.chapterTitle { Text(ch).font(.caption).foregroundStyle(.secondary) Text("·").font(.caption).foregroundStyle(.secondary) } Text(historyFormatTime(entry.position)) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } Text(historyRelativeTime(entry.timestamp)) .font(.caption2).foregroundStyle(.tertiary) } Spacer() if !isCurrent { Text(String(localized: "history.other_item")) .font(.caption2).foregroundStyle(.tertiary) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } if !app.history.entries.isEmpty { Section { Button(role: .destructive) { app.history.clear() } label: { Text(String(localized: "history.clear")) .frame(maxWidth: .infinity, alignment: .center) } } } } .listStyle(.plain) .overlay { if app.history.entries.isEmpty { ContentUnavailableView( String(localized: "history.empty"), systemImage: "clock.arrow.circlepath", description: Text(String(localized: "history.empty_desc")) ) } } } private func historyFormatTime(_ seconds: Double) -> String { guard seconds.isFinite, seconds >= 0 else { return "0:00" } let total = Int(seconds) let h = total / 3600, m = (total % 3600) / 60, s = total % 60 return h > 0 ? String(format: "%d:%02d:%02d", h, m, s) : String(format: "%d:%02d", m, s) } private func historyRelativeTime(_ date: Date) -> String { let diff = Int(-date.timeIntervalSinceNow) if diff < 60 { return String(localized: "history.just_now") } if diff < 3600 { return String(format: String(localized: "history.minutes_ago"), diff / 60) } if diff < 86400 { return String(format: String(localized: "history.hours_ago"), diff / 3600) } return String(format: String(localized: "history.days_ago"), diff / 86400) } #endif // MARK: - Detail content @ViewBuilder private var detail: some View { #if os(macOS) if vm.selection == .history { historyDetailContent .navigationTitle(String(localized: "sidebar.history")) } else { libraryContent } #else libraryContent #endif } private var libraryContent: some View { ZStack { if vm.isLoading && vm.items.isEmpty { ProgressView("Lade Bibliothek …") .frame(maxWidth: .infinity, maxHeight: .infinity) .transition(.opacity) } else if let err = vm.errorMessage, vm.items.isEmpty { ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err)) .transition(.opacity) } else if vm.items.isEmpty { ContentUnavailableView("Keine Hörbücher", systemImage: "books.vertical", description: Text("Diese Auswahl enthält noch keine Hörbücher.")) .transition(.opacity) } else { libraryGridOrList.transition(.opacity) } } .animation(.easeInOut(duration: 0.2), value: vm.isLoading) .animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty) } @ViewBuilder private var libraryGridOrList: some View { Group { let isDownloaded = vm.selection == .downloaded switch layout { case .grid: LibraryGridView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } case .list: LibraryListView(items: vm.items, onRefresh: loadAll, dimDownloading: isDownloaded) { 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" case .history: return "clock.arrow.circlepath" 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 .history: return String(localized: "sidebar.history") case .none: return "Bibliothek" } } }