Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/PlaybackDetailsView.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

246 lines
9.1 KiB
Swift

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