340 lines
12 KiB
Swift
340 lines
12 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|