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>
This commit is contained in:
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
enum LibraryFilter: Hashable {
|
||||
case library(String)
|
||||
case downloaded
|
||||
case history
|
||||
}
|
||||
|
||||
@Observable
|
||||
@@ -15,8 +16,6 @@ final class LibraryViewModel {
|
||||
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 {
|
||||
@@ -29,8 +28,6 @@ final class LibraryViewModel {
|
||||
|
||||
func loadItems(client: ABSClient, downloads: DownloadManager) async {
|
||||
guard let selection else { return }
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
switch selection {
|
||||
case .library(let id):
|
||||
do {
|
||||
@@ -39,8 +36,11 @@ final class LibraryViewModel {
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
case .history:
|
||||
items = []
|
||||
errorMessage = nil
|
||||
case .downloaded:
|
||||
items = downloads.downloadedItems.values.map { di in
|
||||
let completed = downloads.downloadedItems.values.map { di -> LibraryItem in
|
||||
let files: [AudioFile] = di.tracks.enumerated().map { idx, t in
|
||||
AudioFile(
|
||||
ino: t.ino,
|
||||
@@ -62,7 +62,12 @@ final class LibraryViewModel {
|
||||
li.episodeId = episodeId
|
||||
}
|
||||
return li
|
||||
}.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
}
|
||||
let inProgress = downloads.pendingItems.values.filter {
|
||||
downloads.downloadedItems[$0.downloadKey] == nil
|
||||
}
|
||||
items = (completed + Array(inProgress))
|
||||
.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
@@ -73,6 +78,8 @@ struct MainView: View {
|
||||
@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
|
||||
@@ -90,11 +97,25 @@ struct MainView: View {
|
||||
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
|
||||
@@ -115,6 +136,14 @@ struct MainView: View {
|
||||
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
|
||||
@@ -151,8 +180,14 @@ struct MainView: View {
|
||||
}
|
||||
|
||||
private func loadAll() async {
|
||||
vm.items = []
|
||||
vm.isLoading = true
|
||||
defer { vm.isLoading = false }
|
||||
|
||||
await vm.loadLibraries(client: app.client)
|
||||
await vm.loadItems(client: app.client, downloads: app.downloads)
|
||||
if vm.selection != .history {
|
||||
await vm.loadItems(client: app.client, downloads: app.downloads)
|
||||
}
|
||||
await app.refreshProgressCache()
|
||||
}
|
||||
|
||||
@@ -169,106 +204,232 @@ struct MainView: View {
|
||||
#if os(macOS)
|
||||
private var sidebar: some View {
|
||||
List(selection: $vm.selection) {
|
||||
Section("Bibliotheken") {
|
||||
Section(String(localized: "sidebar.libraries")) {
|
||||
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")
|
||||
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("ABS Client")
|
||||
.navigationTitle(String(localized: "sidebar.app_title"))
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
sidebarFooter
|
||||
}
|
||||
}
|
||||
|
||||
private var sidebarFooter: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
VStack(spacing: 0) {
|
||||
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)
|
||||
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"))
|
||||
}
|
||||
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(.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)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
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 (shared)
|
||||
// MARK: - Detail content
|
||||
|
||||
@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."))
|
||||
#if os(macOS)
|
||||
if vm.selection == .history {
|
||||
historyDetailContent
|
||||
.navigationTitle(String(localized: "sidebar.history"))
|
||||
} 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
|
||||
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
|
||||
@@ -319,7 +480,8 @@ struct MainView: View {
|
||||
private var selectionIcon: String {
|
||||
switch vm.selection {
|
||||
case .downloaded: return "arrow.down.circle.fill"
|
||||
default: return "books.vertical"
|
||||
case .history: return "clock.arrow.circlepath"
|
||||
default: return "books.vertical"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -332,6 +494,8 @@ struct MainView: View {
|
||||
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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user