- 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>
99 lines
3.2 KiB
Swift
99 lines
3.2 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
struct HistoryEntry: Codable, Identifiable {
|
|
let id: UUID
|
|
let timestamp: Date
|
|
let itemId: String
|
|
let episodeId: String?
|
|
let itemTitle: String
|
|
let itemAuthor: String
|
|
let chapterTitle: String?
|
|
let position: Double
|
|
}
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class HistoryManager {
|
|
private(set) var entries: [HistoryEntry] = []
|
|
|
|
private static let maxEntries = 200
|
|
private var saveFile: URL {
|
|
AppPaths.supportDirectory.appendingPathComponent("history.json")
|
|
}
|
|
|
|
init() { load() }
|
|
|
|
func record(item: LibraryItem, position: Double, chapters: [Chapter]) {
|
|
guard position >= 0 else { return }
|
|
if let last = entries.first,
|
|
last.itemId == item.id,
|
|
last.episodeId == item.episodeId,
|
|
abs(last.position - position) < 15 { return }
|
|
let chapter = chapters.last { $0.start <= position }
|
|
let entry = HistoryEntry(
|
|
id: UUID(),
|
|
timestamp: Date(),
|
|
itemId: item.id,
|
|
episodeId: item.episodeId,
|
|
itemTitle: item.title,
|
|
itemAuthor: item.author,
|
|
chapterTitle: chapter?.title,
|
|
position: position
|
|
)
|
|
entries.insert(entry, at: 0)
|
|
if entries.count > Self.maxEntries { entries.removeLast() }
|
|
save()
|
|
}
|
|
|
|
func clear() {
|
|
entries.removeAll()
|
|
save()
|
|
}
|
|
|
|
func exportXML() -> URL? {
|
|
let formatter = ISO8601DateFormatter()
|
|
var xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
xml += "<history exported=\"\(formatter.string(from: Date()))\">\n"
|
|
for entry in entries {
|
|
xml += " <entry>\n"
|
|
xml += " <timestamp>\(formatter.string(from: entry.timestamp))</timestamp>\n"
|
|
xml += " <itemTitle>\(xmlEscape(entry.itemTitle))</itemTitle>\n"
|
|
xml += " <itemAuthor>\(xmlEscape(entry.itemAuthor))</itemAuthor>\n"
|
|
if let chapter = entry.chapterTitle {
|
|
xml += " <chapter>\(xmlEscape(chapter))</chapter>\n"
|
|
}
|
|
if let epId = entry.episodeId {
|
|
xml += " <episodeId>\(xmlEscape(epId))</episodeId>\n"
|
|
}
|
|
xml += " <position>\(entry.position)</position>\n"
|
|
xml += " </entry>\n"
|
|
}
|
|
xml += "</history>\n"
|
|
|
|
let tmpURL = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("hoerverlauf.xml")
|
|
try? xml.write(to: tmpURL, atomically: true, encoding: .utf8)
|
|
return tmpURL
|
|
}
|
|
|
|
private func xmlEscape(_ s: String) -> String {
|
|
s.replacingOccurrences(of: "&", with: "&")
|
|
.replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">")
|
|
}
|
|
|
|
private func save() {
|
|
try? FileManager.default.createDirectory(
|
|
at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
|
if let data = try? JSONEncoder().encode(entries) {
|
|
try? data.write(to: saveFile)
|
|
}
|
|
}
|
|
|
|
private func load() {
|
|
guard let data = try? Data(contentsOf: saveFile) else { return }
|
|
entries = (try? JSONDecoder().decode([HistoryEntry].self, from: data)) ?? []
|
|
}
|
|
}
|