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:
@@ -29,6 +29,7 @@ struct ContentView: View {
|
||||
LoginView()
|
||||
}
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: app.language))
|
||||
#if os(iOS)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
#else
|
||||
|
||||
97
ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift
Normal file
97
ABS Client/Audiobookshelf swift/Views/FullHistoryView.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import SwiftUI
|
||||
|
||||
struct FullHistoryView: View {
|
||||
@Environment(AppState.self) private var app
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(app.history.entries) { entry in
|
||||
let isCurrent = entry.itemId == app.currentItem?.id &&
|
||||
entry.episodeId == app.currentItem?.episodeId
|
||||
Button {
|
||||
dismiss()
|
||||
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(formatTime(entry.position))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(relativeTime(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)
|
||||
.navigationTitle(String(localized: "history.title"))
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(String(localized: "settings.done")) { dismiss() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.overlay {
|
||||
if app.history.entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
String(localized: "history.empty"),
|
||||
systemImage: "clock.arrow.circlepath",
|
||||
description: Text(String(localized: "history.empty_desc"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func formatTime(_ 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 relativeTime(_ 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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct LibraryGridView: View {
|
||||
let items: [LibraryItem]
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
var dimDownloading: Bool = false
|
||||
var onSelect: (LibraryItem) -> Void
|
||||
|
||||
@AppStorage("libraryCoverSize") private var coverSize: Double = Self.defaultCoverSize
|
||||
@@ -22,7 +23,7 @@ struct LibraryGridView: View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(items) { item in
|
||||
LibraryItemCell(item: item)
|
||||
LibraryItemCell(item: item, dimDownloading: dimDownloading)
|
||||
.onTapGesture { onSelect(item) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,22 @@ import SwiftUI
|
||||
struct LibraryItemCell: View {
|
||||
@Environment(AppState.self) private var app
|
||||
let item: LibraryItem
|
||||
var dimDownloading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ZStack(alignment: .bottom) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
cover
|
||||
downloadBadge.padding(4)
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
if !(dimDownloading && isActivelyDownloading) {
|
||||
downloadBadge.padding(4)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
|
||||
LargeDownloadOverlay(progress: p)
|
||||
}
|
||||
}
|
||||
CoverProgressBar(fraction: app.progressFraction(itemId: item.id, episodeId: item.episodeId))
|
||||
.padding(.horizontal, 3)
|
||||
@@ -23,16 +32,38 @@ struct LibraryItemCell: View {
|
||||
#endif
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
Text(item.author)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
HStack(spacing: 4) {
|
||||
Text(item.author)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if dimDownloading {
|
||||
let bytes = app.downloads.downloadedBytes(for: item.downloadKey)
|
||||
if bytes > 0 {
|
||||
Text("·")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(formatBytes(bytes))
|
||||
.font(.system(size: 9, weight: .medium).monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
}
|
||||
// Ensure the cell fills its full grid column width
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contextMenu { downloadMenuItems }
|
||||
}
|
||||
|
||||
private var isActivelyDownloading: Bool {
|
||||
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Cover
|
||||
|
||||
private var cover: some View {
|
||||
@@ -198,3 +229,40 @@ struct DownloadProgressRing: View {
|
||||
.shadow(color: .black.opacity(0.4), radius: 3, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeDownloadOverlay: View {
|
||||
let progress: Double
|
||||
var size: CGFloat = 64
|
||||
|
||||
private var lineWidth: CGFloat { size / 13 }
|
||||
private var padding: CGFloat { size / 7 }
|
||||
private var fontSize: CGFloat { max(9, size / 5) }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.black.opacity(0.65))
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.2), lineWidth: lineWidth)
|
||||
.padding(padding)
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.03, min(progress, 1)))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.padding(padding)
|
||||
.animation(.easeInOut(duration: 0.3), value: progress)
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.system(size: fontSize, weight: .semibold).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.shadow(color: .black.opacity(0.45), radius: 6)
|
||||
}
|
||||
}
|
||||
|
||||
func formatBytes(_ bytes: Int64) -> String {
|
||||
let mb = Double(bytes) / 1_048_576
|
||||
if mb >= 1024 { return String(format: "%.1f GB", mb / 1024) }
|
||||
if mb >= 1 { return String(format: "%.0f MB", mb) }
|
||||
return String(format: "%.0f KB", Double(bytes) / 1024)
|
||||
}
|
||||
|
||||
@@ -12,13 +12,14 @@ enum LibraryLayout: String, CaseIterable, Identifiable {
|
||||
struct LibraryListView: View {
|
||||
let items: [LibraryItem]
|
||||
var onRefresh: (() async -> Void)? = nil
|
||||
var dimDownloading: Bool = false
|
||||
let onSelect: (LibraryItem) -> Void
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
LibraryListRow(item: item)
|
||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(item) }
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
@@ -32,7 +33,7 @@ struct LibraryListView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||
LibraryListRow(item: item)
|
||||
LibraryListRow(item: item, dimDownloading: dimDownloading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onSelect(item) }
|
||||
if idx < items.count - 1 {
|
||||
@@ -49,10 +50,17 @@ struct LibraryListView: View {
|
||||
struct LibraryListRow: View {
|
||||
@Environment(AppState.self) private var app
|
||||
let item: LibraryItem
|
||||
var dimDownloading: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
cover
|
||||
ZStack {
|
||||
cover
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
if dimDownloading, case .downloading(let p) = app.downloads.state(for: item.downloadKey) {
|
||||
LargeDownloadOverlay(progress: p, size: 40)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
@@ -77,18 +85,27 @@ struct LibraryListRow: View {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
Spacer(minLength: 8)
|
||||
#if os(macOS)
|
||||
if item.durationSeconds > 0 {
|
||||
if dimDownloading, app.downloads.downloadedBytes(for: item.downloadKey) > 0 {
|
||||
Text(formatBytes(app.downloads.downloadedBytes(for: item.downloadKey)))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
} else if item.durationSeconds > 0 {
|
||||
Text(formatDuration(item.durationSeconds))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.opacity(dimDownloading && isActivelyDownloading ? 0.55 : 1.0)
|
||||
}
|
||||
#endif
|
||||
downloadStatus
|
||||
#if os(macOS)
|
||||
.frame(width: 28)
|
||||
#endif
|
||||
if !(dimDownloading && isActivelyDownloading) {
|
||||
downloadStatus
|
||||
#if os(macOS)
|
||||
.frame(width: 28)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.padding(.horizontal, 16)
|
||||
@@ -97,6 +114,11 @@ struct LibraryListRow: View {
|
||||
.contextMenu { downloadMenuItems }
|
||||
}
|
||||
|
||||
private var isActivelyDownloading: Bool {
|
||||
if case .downloading = app.downloads.state(for: item.downloadKey) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private var cover: some View {
|
||||
Group {
|
||||
if let url = app.client.coverURL(itemId: item.id) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
245
ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift
Normal file
245
ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlaybackDetailsView: View {
|
||||
@Environment(AppState.self) private var app
|
||||
enum Tab: String, CaseIterable {
|
||||
case chapters = "Kapitel"
|
||||
case bookmarks = "Lesezeichen"
|
||||
}
|
||||
|
||||
@State private var selectedTab: Tab = .chapters
|
||||
@State private var showAddBookmark: Bool = false
|
||||
@State private var newBookmarkTitle: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker(selection: $selectedTab) {
|
||||
ForEach(Tab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
} label: { EmptyView() }
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Divider()
|
||||
|
||||
switch selectedTab {
|
||||
case .chapters: chaptersTab
|
||||
case .bookmarks: bookmarksTab
|
||||
}
|
||||
}
|
||||
.navigationTitle(selectedTab.rawValue)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
.alert("Lesezeichen hinzufügen", isPresented: $showAddBookmark) {
|
||||
TextField("Name", text: $newBookmarkTitle)
|
||||
Button("Hinzufügen") {
|
||||
let title = newBookmarkTitle.trimmingCharacters(in: .whitespaces)
|
||||
app.addBookmark(title: title.isEmpty ? defaultBookmarkName : title)
|
||||
newBookmarkTitle = ""
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { newBookmarkTitle = "" }
|
||||
} message: {
|
||||
Text("Gib einen Namen für dieses Lesezeichen ein.")
|
||||
}
|
||||
#if os(iOS)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
#else
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Chapters tab
|
||||
|
||||
private var chaptersTab: some View {
|
||||
let chapters = app.currentItem?.chapters ?? []
|
||||
let current = app.player.currentChapter
|
||||
return ScrollViewReader { proxy in
|
||||
List {
|
||||
if !chapters.isEmpty {
|
||||
Section {
|
||||
chapterNavigationBar
|
||||
}
|
||||
}
|
||||
ForEach(chapters) { chapter in
|
||||
Button {
|
||||
app.seekAbsolute(chapter.start)
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
if chapter.id == current?.id {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 14)
|
||||
} else {
|
||||
Spacer().frame(width: 14)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(chapter.title)
|
||||
.font(chapter.id == current?.id ? .body.bold() : .body)
|
||||
.foregroundStyle(chapter.id == current?.id ? Color.accentColor : Color.primary)
|
||||
Text(formatTime(chapter.start))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(formatDuration(chapter.end - chapter.start))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.id(chapter.id)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.onAppear {
|
||||
guard let id = current?.id else { return }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
proxy.scrollTo(id, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chapterNavigationBar: some View {
|
||||
let chapters = app.currentItem?.chapters ?? []
|
||||
let current = app.player.currentChapter
|
||||
let currentIdx = chapters.firstIndex { $0.id == current?.id }
|
||||
let hasPrev = (currentIdx ?? 0) > 0
|
||||
let hasNext = (currentIdx.map { $0 < chapters.count - 1 }) ?? false
|
||||
|
||||
return HStack(spacing: 0) {
|
||||
Spacer()
|
||||
navButton(systemImage: "chevron.backward.to.line",
|
||||
help: "Kapitelanfang",
|
||||
disabled: current == nil) {
|
||||
if let ch = current { app.seekAbsolute(ch.start) }
|
||||
}
|
||||
Spacer()
|
||||
navButton(systemImage: "chevron.backward",
|
||||
help: "Vorheriges Kapitel",
|
||||
disabled: !hasPrev) {
|
||||
if let idx = currentIdx { app.seekAbsolute(chapters[idx - 1].start) }
|
||||
}
|
||||
Spacer()
|
||||
navButton(systemImage: "chevron.forward",
|
||||
help: "Nächstes Kapitel",
|
||||
disabled: !hasNext) {
|
||||
if let idx = currentIdx { app.seekAbsolute(chapters[idx + 1].start) }
|
||||
}
|
||||
Spacer()
|
||||
navButton(systemImage: "chevron.forward.to.line",
|
||||
help: "Kapitelende",
|
||||
disabled: current == nil) {
|
||||
if let ch = current { app.seekAbsolute(max(ch.start, ch.end - 0.5)) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func navButton(systemImage: String, help: String, disabled: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title3)
|
||||
.frame(minWidth: 44, minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(disabled)
|
||||
.foregroundStyle(disabled ? Color.secondary : Color.accentColor)
|
||||
.help(help)
|
||||
}
|
||||
|
||||
// MARK: - Bookmarks tab
|
||||
|
||||
private var bookmarksTab: some View {
|
||||
let item = app.currentItem
|
||||
let itemBookmarks = item.map { app.bookmarks.bookmarks(for: $0) } ?? []
|
||||
return List {
|
||||
Section {
|
||||
Button {
|
||||
newBookmarkTitle = defaultBookmarkName
|
||||
showAddBookmark = true
|
||||
} label: {
|
||||
Label("Lesezeichen hinzufügen", systemImage: "bookmark.fill")
|
||||
}
|
||||
.disabled(item == nil || !app.player.isReady)
|
||||
}
|
||||
|
||||
ForEach(itemBookmarks) { bm in
|
||||
Button {
|
||||
app.seekAbsolute(bm.time)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(bm.title)
|
||||
.font(.subheadline.bold())
|
||||
HStack(spacing: 4) {
|
||||
if let ch = bm.chapterTitle {
|
||||
Text(ch).font(.caption).foregroundStyle(.secondary)
|
||||
Text("·").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(formatTime(bm.time))
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.onDelete { offsets in
|
||||
for idx in offsets {
|
||||
app.deleteBookmark(itemBookmarks[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if itemBookmarks.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Keine Lesezeichen",
|
||||
systemImage: "bookmark",
|
||||
description: Text(String(localized: "details.no_bookmarks_desc"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var defaultBookmarkName: String {
|
||||
let t = app.player.absoluteCurrentTime
|
||||
if let ch = app.player.currentChapter {
|
||||
return "\(ch.title) · \(formatTime(t))"
|
||||
}
|
||||
return formatTime(t)
|
||||
}
|
||||
|
||||
private func formatTime(_ 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 formatDuration(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||
let total = Int(seconds)
|
||||
let h = total / 3600, m = (total % 3600) / 60, s = total % 60
|
||||
if h > 0 { return String(format: "%dh %02dm", h, m) }
|
||||
if m > 0 { return String(format: "%dm %02ds", m, s) }
|
||||
return String(format: "%ds", s)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,8 +3,11 @@ import SwiftUI
|
||||
struct PlayerBar: View {
|
||||
@Environment(AppState.self) private var app
|
||||
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
||||
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||
@State private var scrubbing: Bool = false
|
||||
@State private var scrubValue: Double = 0
|
||||
@State private var showDetails: Bool = false
|
||||
@State private var showFullHistory: Bool = false
|
||||
|
||||
var body: some View {
|
||||
if let item = app.currentItem {
|
||||
@@ -21,6 +24,14 @@ struct PlayerBar: View {
|
||||
.background(.bar)
|
||||
}
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.sheet(isPresented: $showDetails) {
|
||||
PlaybackDetailsView()
|
||||
.environment(app)
|
||||
}
|
||||
.sheet(isPresented: $showFullHistory) {
|
||||
FullHistoryView()
|
||||
.environment(app)
|
||||
}
|
||||
} else if app.isPreparingPlayback {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
@@ -77,6 +88,17 @@ struct PlayerBar: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if historyEnabled {
|
||||
historyQuickMenu
|
||||
}
|
||||
|
||||
Button { showDetails = true } label: {
|
||||
Image(systemName: "list.bullet.indent")
|
||||
.font(.system(size: 22))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!app.player.isReady)
|
||||
|
||||
sleepMenu
|
||||
|
||||
rateMenu
|
||||
@@ -119,6 +141,17 @@ struct PlayerBar: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if historyEnabled {
|
||||
historyQuickMenu
|
||||
}
|
||||
|
||||
Button { showDetails = true } label: {
|
||||
Image(systemName: "list.bullet.indent")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!app.player.isReady)
|
||||
.help("Kapitel & Lesezeichen")
|
||||
|
||||
statusIndicator
|
||||
|
||||
Button {
|
||||
@@ -235,6 +268,51 @@ struct PlayerBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var detailsButtonVisible: Bool {
|
||||
app.player.isReady
|
||||
}
|
||||
|
||||
private var historyQuickMenu: some View {
|
||||
Menu {
|
||||
let recent = Array(app.history.entries
|
||||
.filter { $0.itemId == app.currentItem?.id && $0.episodeId == app.currentItem?.episodeId }
|
||||
.prefix(5))
|
||||
if recent.isEmpty {
|
||||
Text(String(localized: "history.empty"))
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(recent) { entry in
|
||||
Button {
|
||||
app.seekAbsolute(entry.position)
|
||||
} label: {
|
||||
let timeStr = formatTime(entry.position)
|
||||
let label = entry.chapterTitle.map { "\($0) · \(timeStr)" } ?? timeStr
|
||||
Label(label, systemImage: "clock")
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button {
|
||||
showFullHistory = true
|
||||
} label: {
|
||||
Label(String(localized: "player.history_all"), systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
#if os(iOS)
|
||||
.font(.system(size: 22))
|
||||
#else
|
||||
.font(.system(size: 16))
|
||||
#endif
|
||||
}
|
||||
#if os(macOS)
|
||||
.menuStyle(.borderlessButton)
|
||||
.fixedSize()
|
||||
#endif
|
||||
.menuIndicator(.hidden)
|
||||
.help(String(localized: "player.history_recent"))
|
||||
}
|
||||
|
||||
private var sleepMenu: some View {
|
||||
Menu {
|
||||
sleepOption(title: "Aus", mode: .off)
|
||||
@@ -244,6 +322,9 @@ struct PlayerBar: View {
|
||||
sleepOption(title: "30 Minuten", mode: .minutes(30))
|
||||
sleepOption(title: "1 Stunde", mode: .minutes(60))
|
||||
sleepOption(title: endOfPlaybackLabel, mode: .endOfBook)
|
||||
if !(app.currentItem?.chapters.isEmpty ?? true) {
|
||||
sleepOption(title: "Bis Ende des Kapitels", mode: .endOfChapter)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: app.player.sleepTimer == .off ? "moon.zzz" : "moon.zzz.fill")
|
||||
#if os(iOS)
|
||||
|
||||
@@ -9,8 +9,11 @@ struct SettingsView: View {
|
||||
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
||||
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
||||
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
||||
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
||||
|
||||
@State private var showLogoutConfirm: Bool = false
|
||||
@State private var showHistoryDisableConfirm: Bool = false
|
||||
@State private var showHistoryExport: Bool = false
|
||||
|
||||
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
|
||||
|
||||
@@ -20,6 +23,7 @@ struct SettingsView: View {
|
||||
Form {
|
||||
connectionSection
|
||||
playbackSection
|
||||
historySection
|
||||
appearanceSection
|
||||
downloadsSection
|
||||
aboutSection
|
||||
@@ -45,6 +49,24 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Hörverlauf deaktivieren?",
|
||||
isPresented: $showHistoryDisableConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Verlauf löschen & deaktivieren", role: .destructive) {
|
||||
app.history.clear()
|
||||
historyEnabled = false
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.")
|
||||
}
|
||||
.sheet(isPresented: $showHistoryExport) {
|
||||
if let url = app.history.exportXML() {
|
||||
ShareSheet(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
#else
|
||||
TabView {
|
||||
@@ -54,6 +76,9 @@ struct SettingsView: View {
|
||||
playbackPane
|
||||
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
|
||||
|
||||
historyPane
|
||||
.tabItem { Label("Verlauf", systemImage: "clock.arrow.circlepath") }
|
||||
|
||||
appearancePane
|
||||
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
|
||||
|
||||
@@ -61,7 +86,7 @@ struct SettingsView: View {
|
||||
.tabItem { Label("Über", systemImage: "info.circle") }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 480, height: 320)
|
||||
.frame(width: 480, height: 360)
|
||||
.confirmationDialog(
|
||||
"Mit Server abmelden?",
|
||||
isPresented: $showLogoutConfirm,
|
||||
@@ -75,6 +100,19 @@ struct SettingsView: View {
|
||||
} message: {
|
||||
Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Hörverlauf deaktivieren?",
|
||||
isPresented: $showHistoryDisableConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Verlauf löschen & deaktivieren", role: .destructive) {
|
||||
app.history.clear()
|
||||
historyEnabled = false
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -130,6 +168,41 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var historySection: some View {
|
||||
Section {
|
||||
Toggle(isOn: Binding(
|
||||
get: { historyEnabled },
|
||||
set: { newVal in
|
||||
if !newVal && historyEnabled {
|
||||
showHistoryDisableConfirm = true
|
||||
} else {
|
||||
historyEnabled = newVal
|
||||
}
|
||||
}
|
||||
)) {
|
||||
Label("Hörverlauf aktivieren", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
if historyEnabled {
|
||||
LabeledContent("Einträge") {
|
||||
Text("\(app.history.entries.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Button {
|
||||
showHistoryExport = true
|
||||
} label: {
|
||||
Label("Verlauf als XML exportieren", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(app.history.entries.isEmpty)
|
||||
}
|
||||
} header: {
|
||||
Text("Hörverlauf")
|
||||
} footer: {
|
||||
Text(historyEnabled
|
||||
? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge). Daten verbleiben lokal auf diesem Gerät."
|
||||
: "Protokolliert, wo du vor einem Sprung warst, damit du zurücknavigieren kannst. Standardmäßig deaktiviert.")
|
||||
}
|
||||
}
|
||||
|
||||
private var appearanceSection: some View {
|
||||
Section {
|
||||
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
||||
@@ -138,6 +211,10 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
||||
Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) {
|
||||
Text("Deutsch").tag("de")
|
||||
Text("English").tag("en")
|
||||
}
|
||||
} header: {
|
||||
Text("Darstellung")
|
||||
}
|
||||
@@ -219,6 +296,38 @@ struct SettingsView: View {
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var historyPane: some View {
|
||||
Form {
|
||||
Toggle(isOn: Binding(
|
||||
get: { historyEnabled },
|
||||
set: { newVal in
|
||||
if !newVal && historyEnabled { showHistoryDisableConfirm = true }
|
||||
else { historyEnabled = newVal }
|
||||
}
|
||||
)) {
|
||||
Text("Hörverlauf aktivieren")
|
||||
}
|
||||
if historyEnabled {
|
||||
LabeledContent("Einträge", value: "\(app.history.entries.count)")
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Verlauf als XML exportieren") {
|
||||
if let url = app.history.exportXML() {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
}
|
||||
}
|
||||
.disabled(app.history.entries.isEmpty)
|
||||
}
|
||||
}
|
||||
Text(historyEnabled
|
||||
? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge)."
|
||||
: "Protokolliert Positionen vor Sprüngen, damit du zurücknavigieren kannst.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
|
||||
private var appearancePane: some View {
|
||||
Form {
|
||||
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
||||
@@ -227,6 +336,10 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
||||
Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) {
|
||||
Text("Deutsch").tag("de")
|
||||
Text("English").tag("en")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
@@ -248,3 +361,15 @@ struct SettingsView: View {
|
||||
return "\(v) (\(b))"
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let url: URL
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user