Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/MainView.swift
Scarriffle fa47cae664 Add chapters, history, bookmarks, live download progress, and i18n
- Chapter navigation with auto-scroll to current chapter and end-of-chapter sleep timer
- Opt-in listening history (local-only) with XML export and per-item quick menu
- Bookmarks with server sync via Audiobookshelf API
- Live MB counter during downloads via URLSessionDownloadTask delegate
- In-progress downloads shown in "Heruntergeladen" with dimmed cover + ring overlay
- Cover image cache (50 MB memory / 500 MB disk URLCache)
- German/English localization (de.lproj, en.lproj)
- Loading spinner now triggers immediately on view switch instead of waiting for the network

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:43:16 +02:00

504 lines
19 KiB
Swift

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"
}
}
}