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>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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)) ?? []
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user