- 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>
246 lines
9.1 KiB
Swift
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)
|
|
}
|
|
|
|
}
|