- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library - Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library - Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail - Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished - Add AirPlay picker in PlayerBar - Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup - Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s - Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id) - Bump CFBundleVersion to 8 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
752 lines
30 KiB
Swift
752 lines
30 KiB
Swift
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<String> = []
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|