import SwiftUI enum LibraryFilter: Hashable { case home case library(String) case downloaded case history } /// One indexed (podcast, episode) pair used to make episodes searchable from the /// library overview — without having to dive into each show first. struct PodcastEpisodeMatch: Hashable { let podcast: LibraryItem let episode: PodcastEpisode var syncKey: String { "\(podcast.id)|\(episode.id)" } /// Synthesizes a `LibraryItem` so the cell/list views can render this /// episode the same way they render any other item: episode title up top, /// podcast name as the subtitle, podcast cover as the artwork. var asLibraryItem: LibraryItem { var item = LibraryItem( id: podcast.id, title: episode.title, author: podcast.title, durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds, audioFiles: [episode.audioFile] ) item.mediaType = "podcast" item.episodeId = episode.id return item } } @Observable @MainActor final class LibraryViewModel { var libraries: [Library] = [] var items: [LibraryItem] = [] var isLoading: Bool = false var errorMessage: String? var selection: LibraryFilter? /// Episode index per library, populated lazily the first time the user /// types in the search bar of a podcast library. Episodes aren't part of /// `items` (those only hold podcast containers) so we have to load them /// explicitly. Kept in memory only — cleared whenever the library reloads. var episodeIndex: [String: [PodcastEpisodeMatch]] = [:] /// Library IDs currently being indexed. Prevents duplicate in-flight loads /// when the user types rapidly while the first request is still running. var episodeIndexLoading: Set = [] var isLoadingEpisodeIndex: Bool { !episodeIndexLoading.isEmpty } func loadLibraries(client: ABSClient) async { do { libraries = try await client.fetchLibraries() // Home is the new default landing screen — first library is only // used as fallback when there are zero libraries. if selection == nil { selection = .home } } catch { errorMessage = error.localizedDescription } } /// Fetches every podcast's episodes in the given library and caches them /// flat so the search field can match against episode titles too. No-ops /// when the index is already populated for this library. func loadEpisodeIndex(libraryId: String, client: ABSClient) async { guard episodeIndex[libraryId] == nil, !episodeIndexLoading.contains(libraryId) else { return } let podcasts = items.filter { $0.isPodcastContainer } guard !podcasts.isEmpty else { episodeIndex[libraryId] = [] return } episodeIndexLoading.insert(libraryId) defer { episodeIndexLoading.remove(libraryId) } var collected: [PodcastEpisodeMatch] = [] var anySuccess = false for p in podcasts { // Bail out if the user already navigated away — keeps a slow scan // from blocking later searches. guard selection == .library(libraryId) else { return } do { let (_, eps) = try await client.fetchEpisodes(podcastItemId: p.id) anySuccess = true for ep in eps { collected.append(PodcastEpisodeMatch(podcast: p, episode: ep)) } } catch { // Individual podcast fetch errors are ignored — the rest of // the library is still searchable. } } // Bei totalem Offline-Failure (kein einziger Fetch erfolgreich) NICHT // den Cache mit `[]` setzen — sonst wäre der Index "geladen aber leer" // und ein erneuter Suchversuch würde nichts mehr tun. Nil-lassen // erlaubt Retry beim nächsten Tippen. if anySuccess { episodeIndex[libraryId] = collected } } func episodeMatch(syncKey: String, in libraryId: String) -> PodcastEpisodeMatch? { episodeIndex[libraryId]?.first(where: { $0.syncKey == syncKey }) } func loadItems(client: ABSClient, downloads: DownloadManager) async { guard let selection else { return } switch selection { case .home: // HomeView reads progressCache + recentItemsCache directly; no // library items to load into vm.items. items = [] errorMessage = nil case .library(let id): // Reload of items invalidates the episode index — episodes are // tied to the previous snapshot of `items`. Auch das Loading-Flag // löschen damit ein nach-dem-Refresh neu getippter Suchbegriff // sofort einen Neu-Aufbau triggern kann (eine ggf. noch laufende // alte loadEpisodeIndex-Schleife läuft zu Ende und das Resultat // wird vom nächsten Trigger ohnehin überschrieben). episodeIndex.removeValue(forKey: id) episodeIndexLoading.remove(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] = [] @State private var librarySearchText: String = "" @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() librarySearchText = "" Task { await loadAll() } } .onChange(of: librarySearchText) { _, newValue in // First keystroke in a podcast library kicks off the episode // index load (cached after that, so subsequent searches are // instant). Skipped for audiobook libraries — those don't // have episodes to scan. guard !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, currentLibraryIsPodcast, let libId = currentLibraryId else { return } Task { await vm.loadEpisodeIndex(libraryId: libId, client: app.client) } } .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) } #if os(iOS) .alert( "Download fehlgeschlagen", isPresented: Binding( get: { app.downloads.downloadFailureAlertTitle != nil }, set: { if !$0 { app.downloads.downloadFailureAlertTitle = nil } } ) ) { Button("OK") { app.downloads.downloadFailureAlertTitle = nil } } message: { if let title = app.downloads.downloadFailureAlertTitle { Text("\u{201E}\(title)\u{201C} konnte nicht heruntergeladen werden.") } } #endif } @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.isPodcastEpisode { // Synthetic episode result from library-wide podcast search. // Prefer the in-memory index (no extra round-trip); fall back to // re-fetching via the Home/Recents path if the index expired. if let libId = currentLibraryId, let match = vm.episodeMatch(syncKey: item.syncKey, in: libId) { Task { await app.play(podcast: match.podcast, episode: match.episode) } } else { Task { await app.playFromRecents(item) } } } else if item.isPodcastContainer { navPath.append(item) } else { Task { await app.play(item: item) } } } private var currentLibraryId: String? { if case .library(let id) = vm.selection { return id } return nil } private var currentLibraryIsPodcast: Bool { guard let id = currentLibraryId else { return false } if let mt = vm.libraries.first(where: { $0.id == id })?.mediaType { return mt == "podcast" } // Fallback: `vm.libraries` ist noch leer (User tippt direkt nach // App-Start, bevor `loadLibraries()` durch ist). Wir leiten den Typ // aus den bereits geladenen Items ab — sobald die Library Items // hat die Podcast-Container sind, ist es eine Podcast-Library. return vm.items.contains { $0.isPodcastContainer } } // MARK: - macOS sidebar #if os(macOS) private var sidebar: some View { List(selection: $vm.selection) { Section { Label("Home", systemImage: "house.fill") .tag(LibraryFilter.home) } 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 == .home { HomeView(libraries: vm.libraries) .navigationTitle("Home") .toolbar { // Same Kachel/Liste picker the library view has, so the // shared `libraryLayout` setting can be toggled from Home // too. Otherwise the user would land here with no UI // affordance to switch into Listenansicht. 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") } } } else if vm.selection == .history { historyDetailContent .navigationTitle(String(localized: "sidebar.history")) } else { libraryContent } #else if vm.selection == .home { HomeView(libraries: vm.libraries) } else { libraryContent } #endif } private var filteredItems: [LibraryItem] { let q = librarySearchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !q.isEmpty else { return vm.items } let containerMatches = vm.items.filter { item in item.title.localizedCaseInsensitiveContains(q) || item.author.localizedCaseInsensitiveContains(q) } // For podcast libraries, also surface matching episodes from across // the entire library — the user shouldn't have to dive into each show // first. Episode rows render with the show name as their subtitle. guard currentLibraryIsPodcast, let libId = currentLibraryId else { return containerMatches } let episodeMatches = (vm.episodeIndex[libId] ?? []) .filter { $0.episode.title.localizedCaseInsensitiveContains(q) } .map { $0.asLibraryItem } return containerMatches + episodeMatches } 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 if filteredItems.isEmpty { if vm.isLoadingEpisodeIndex { VStack(spacing: 10) { ProgressView() Text("Folgen werden durchsucht …") .font(.callout) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView.search(text: librarySearchText) .transition(.opacity) } } else { VStack(spacing: 0) { if vm.isLoadingEpisodeIndex { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Folgen werden durchsucht …") .font(.caption) .foregroundStyle(.secondary) Spacer() } .padding(.horizontal, 16) .padding(.vertical, 6) } libraryGridOrList } .transition(.opacity) } } .animation(.easeInOut(duration: 0.2), value: vm.isLoading) .animation(.easeInOut(duration: 0.2), value: vm.items.isEmpty) .animation(.easeInOut(duration: 0.15), value: filteredItems.isEmpty) .animation(.easeInOut(duration: 0.15), value: vm.isLoadingEpisodeIndex) .searchable(text: $librarySearchText, placement: .automatic, prompt: searchPrompt) } private var searchPrompt: String { switch vm.selection { case .library(let id): let mediaType = vm.libraries.first(where: { $0.id == id })?.mediaType return mediaType == "podcast" ? "Podcasts & Folgen durchsuchen" : "Hörbücher durchsuchen" case .downloaded: return "Downloads durchsuchen" default: return "Suchen" } } @ViewBuilder private var libraryGridOrList: some View { Group { let isDownloaded = vm.selection == .downloaded switch layout { case .grid: LibraryGridView(items: filteredItems, onRefresh: loadAll, dimDownloading: isDownloaded) { handleSelect($0) } case .list: LibraryListView(items: filteredItems, 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 ?? .home }, set: { vm.selection = $0 } )) { Label("Home", systemImage: "house.fill") .tag(LibraryFilter.home) 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 .home: return "house.fill" 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 .home: return "Home" 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" } } }