Files
ABS-Client/ABS Client/Audiobookshelf swift/Services/HistoryManager.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

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: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
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)) ?? []
}
}