- 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>
98 lines
4.1 KiB
Swift
98 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|