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:
@@ -106,6 +106,10 @@ final class ABSClient {
|
||||
)
|
||||
item.mediaType = mediaType
|
||||
item.description = meta?.description
|
||||
item.chapters = (raw.media?.chapters ?? []).compactMap { c in
|
||||
guard let id = c.id, let start = c.start, let end = c.end, let title = c.title else { return nil }
|
||||
return Chapter(id: id, start: start, end: end, title: title)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
@@ -225,4 +229,43 @@ final class ABSClient {
|
||||
}
|
||||
|
||||
var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }
|
||||
|
||||
// MARK: - Bookmarks
|
||||
|
||||
func fetchBookmarks(itemId: String, episodeId: String? = nil) async throws -> [ServerBookmark] {
|
||||
let req = try makeRequest(path: progressPath(itemId: itemId, episodeId: episodeId))
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { return [] }
|
||||
if http.statusCode == 404 { return [] }
|
||||
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
|
||||
let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
|
||||
return (dto.bookmarks ?? []).compactMap { b in
|
||||
guard let title = b.title, let time = b.time else { return nil }
|
||||
return ServerBookmark(title: title, time: time, createdAt: b.createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func createBookmark(itemId: String, time: Double, title: String) async throws {
|
||||
let body = try JSONSerialization.data(withJSONObject: ["title": title, "time": time])
|
||||
let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark", method: "POST", body: body)
|
||||
let (_, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBookmark(itemId: String, time: Double) async throws {
|
||||
let timeInt = Int(time)
|
||||
let req = try makeRequest(path: "/api/me/item/\(itemId)/bookmark/\(timeInt)", method: "DELETE")
|
||||
let (_, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
|
||||
throw ABSClientError.httpStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerBookmark {
|
||||
let title: String
|
||||
let time: Double
|
||||
let createdAt: Double?
|
||||
}
|
||||
|
||||
@@ -10,9 +10,14 @@ final class AppState {
|
||||
let downloads: DownloadManager
|
||||
let sync: ProgressSyncManager
|
||||
let player: PlayerEngine
|
||||
let history: HistoryManager
|
||||
let bookmarks: BookmarkManager
|
||||
|
||||
var currentItem: LibraryItem?
|
||||
var isPreparingPlayback: Bool = false
|
||||
var language: String = UserDefaults.standard.string(forKey: "appLanguage") ?? "de" {
|
||||
didSet { UserDefaults.standard.set(language, forKey: "appLanguage") }
|
||||
}
|
||||
|
||||
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
|
||||
/// Used to show progress bars on covers in the library views.
|
||||
@@ -20,6 +25,7 @@ final class AppState {
|
||||
|
||||
private var syncTimer: Timer?
|
||||
private var lastReportedSecond: Double = -10
|
||||
private var lastTrackedChapterId: Int?
|
||||
|
||||
init() {
|
||||
let auth = AuthStore()
|
||||
@@ -30,6 +36,8 @@ final class AppState {
|
||||
self.downloads = DownloadManager(client: client)
|
||||
self.sync = ProgressSyncManager(client: client)
|
||||
self.player = PlayerEngine()
|
||||
self.history = HistoryManager()
|
||||
self.bookmarks = BookmarkManager()
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
@@ -86,27 +94,31 @@ final class AppState {
|
||||
return min(1, max(0, p.currentTime / p.duration))
|
||||
}
|
||||
|
||||
func play(item: LibraryItem) async {
|
||||
func play(item: LibraryItem, overrideStartAt: Double? = nil) async {
|
||||
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
|
||||
player.play()
|
||||
if let pos = overrideStartAt { seekAbsolute(pos) } else { player.play() }
|
||||
return
|
||||
}
|
||||
// Record position before switching to a new item
|
||||
if let current = currentItem, player.absoluteCurrentTime > 5,
|
||||
UserDefaults.standard.bool(forKey: "historyEnabled") {
|
||||
history.record(item: current, position: player.absoluteCurrentTime, chapters: current.chapters)
|
||||
}
|
||||
stopPlayback(reportFinal: true)
|
||||
isPreparingPlayback = true
|
||||
defer { isPreparingPlayback = false }
|
||||
|
||||
var workItem = item
|
||||
// Only fetch detail for books with empty audioFiles (podcast episodes
|
||||
// arrive with their single audioFile already populated by the caller).
|
||||
if !workItem.isPodcast && workItem.audioFiles.isEmpty && network.isOnline {
|
||||
// Always fetch detail for books to get chapters; skip if downloaded offline.
|
||||
if !workItem.isPodcast && network.isOnline {
|
||||
let alreadyDownloaded = downloads.isDownloaded(downloadKey: item.downloadKey)
|
||||
if !alreadyDownloaded, let detail = try? await client.fetchItemDetail(itemId: item.id) {
|
||||
workItem = detail
|
||||
}
|
||||
}
|
||||
|
||||
var startAt: Double = 0
|
||||
if network.isOnline {
|
||||
var startAt: Double = overrideStartAt ?? 0
|
||||
if overrideStartAt == nil && network.isOnline {
|
||||
if let p = try? await client.fetchProgress(itemId: item.id, episodeId: workItem.episodeId) {
|
||||
// Replaying a finished item (or one with progress essentially at the end)
|
||||
// should start from the beginning, not drop the user at the last few seconds.
|
||||
@@ -116,15 +128,40 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if network.isOnline {
|
||||
// Load bookmarks from server for this item
|
||||
if let serverBMs = try? await client.fetchBookmarks(itemId: workItem.id, episodeId: workItem.episodeId) {
|
||||
bookmarks.mergeFromServer(serverBMs, for: workItem)
|
||||
}
|
||||
}
|
||||
|
||||
currentItem = workItem
|
||||
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
||||
if player.errorMessage == nil {
|
||||
player.play()
|
||||
startSyncTimer()
|
||||
let startingChapter = workItem.chapters.last { $0.start <= startAt }
|
||||
lastTrackedChapterId = startingChapter?.id
|
||||
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
||||
history.record(item: workItem, position: startAt, chapters: workItem.chapters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func playFromHistory(_ entry: HistoryEntry) async {
|
||||
if let current = currentItem,
|
||||
current.id == entry.itemId,
|
||||
current.episodeId == entry.episodeId {
|
||||
seekAbsolute(entry.position)
|
||||
return
|
||||
}
|
||||
guard network.isOnline,
|
||||
let detail = try? await client.fetchItemDetail(itemId: entry.itemId) else { return }
|
||||
var item = detail
|
||||
item.episodeId = entry.episodeId
|
||||
await play(item: item, overrideStartAt: entry.position)
|
||||
}
|
||||
|
||||
/// Convenience for podcast episodes.
|
||||
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
||||
var synthetic = LibraryItem(
|
||||
@@ -146,6 +183,7 @@ final class AppState {
|
||||
player.teardown()
|
||||
currentItem = nil
|
||||
lastReportedSecond = -10
|
||||
lastTrackedChapterId = nil
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
@@ -161,7 +199,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
func seekAbsolute(_ target: Double) {
|
||||
guard currentItem != nil else { return }
|
||||
guard let item = currentItem else { return }
|
||||
if UserDefaults.standard.bool(forKey: "historyEnabled") {
|
||||
history.record(item: item, position: player.absoluteCurrentTime, chapters: item.chapters)
|
||||
}
|
||||
player.seekAbsolute(target)
|
||||
reportProgress(force: true)
|
||||
}
|
||||
@@ -170,6 +211,22 @@ final class AppState {
|
||||
player.setRate(newRate)
|
||||
}
|
||||
|
||||
func addBookmark(title: String) {
|
||||
guard let item = currentItem else { return }
|
||||
let t = player.absoluteCurrentTime
|
||||
bookmarks.add(item: item, time: t, title: title, chapters: item.chapters)
|
||||
if network.isOnline {
|
||||
Task { try? await client.createBookmark(itemId: item.id, time: t, title: title) }
|
||||
}
|
||||
}
|
||||
|
||||
func deleteBookmark(_ bookmark: Bookmark) {
|
||||
bookmarks.delete(bookmark)
|
||||
if network.isOnline {
|
||||
Task { try? await client.deleteBookmark(itemId: bookmark.itemId, time: bookmark.time) }
|
||||
}
|
||||
}
|
||||
|
||||
private func startSyncTimer() {
|
||||
syncTimer?.invalidate()
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
||||
@@ -181,10 +238,21 @@ final class AppState {
|
||||
syncTimer = timer
|
||||
}
|
||||
|
||||
private func detectChapterChange(item: LibraryItem) {
|
||||
let currentId = player.currentChapter?.id
|
||||
guard currentId != lastTrackedChapterId else { return }
|
||||
defer { lastTrackedChapterId = currentId }
|
||||
guard lastTrackedChapterId != nil,
|
||||
let chapter = player.currentChapter,
|
||||
UserDefaults.standard.bool(forKey: "historyEnabled") else { return }
|
||||
history.record(item: item, position: chapter.start, chapters: item.chapters)
|
||||
}
|
||||
|
||||
private func reportProgress(force: Bool) {
|
||||
guard let item = currentItem else { return }
|
||||
let t = player.absoluteCurrentTime
|
||||
let d = player.totalDuration
|
||||
detectChapterChange(item: item)
|
||||
guard d > 0 else { return }
|
||||
if !force && abs(t - lastReportedSecond) < 3 { return }
|
||||
lastReportedSecond = t
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct Bookmark: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let createdAt: Date
|
||||
let itemId: String
|
||||
let episodeId: String?
|
||||
let itemTitle: String
|
||||
var title: String
|
||||
let chapterTitle: String?
|
||||
let time: Double
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class BookmarkManager {
|
||||
private(set) var bookmarks: [Bookmark] = []
|
||||
|
||||
private var saveFile: URL {
|
||||
AppPaths.supportDirectory.appendingPathComponent("bookmarks.json")
|
||||
}
|
||||
|
||||
init() { load() }
|
||||
|
||||
func add(item: LibraryItem, time: Double, title: String, chapters: [Chapter]) {
|
||||
let chapter = chapters.last { $0.start <= time }
|
||||
let bm = Bookmark(
|
||||
id: UUID(),
|
||||
createdAt: Date(),
|
||||
itemId: item.id,
|
||||
episodeId: item.episodeId,
|
||||
itemTitle: item.title,
|
||||
title: title,
|
||||
chapterTitle: chapter?.title,
|
||||
time: time
|
||||
)
|
||||
bookmarks.insert(bm, at: 0)
|
||||
save()
|
||||
}
|
||||
|
||||
func delete(_ bookmark: Bookmark) {
|
||||
bookmarks.removeAll { $0.id == bookmark.id }
|
||||
save()
|
||||
}
|
||||
|
||||
func bookmarks(for item: LibraryItem) -> [Bookmark] {
|
||||
bookmarks.filter { $0.itemId == item.id && $0.episodeId == item.episodeId }
|
||||
.sorted { $0.time < $1.time }
|
||||
}
|
||||
|
||||
/// Replaces bookmarks for an item with the server-loaded list (keeps bookmarks for other items).
|
||||
func mergeFromServer(_ serverBookmarks: [ServerBookmark], for item: LibraryItem) {
|
||||
bookmarks.removeAll { $0.itemId == item.id && $0.episodeId == item.episodeId }
|
||||
let chapters = item.chapters
|
||||
for sb in serverBookmarks {
|
||||
let chapter = chapters.last { $0.start <= sb.time }
|
||||
let bm = Bookmark(
|
||||
id: UUID(),
|
||||
createdAt: sb.createdAt.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date(),
|
||||
itemId: item.id,
|
||||
episodeId: item.episodeId,
|
||||
itemTitle: item.title,
|
||||
title: sb.title,
|
||||
chapterTitle: chapter?.title,
|
||||
time: sb.time
|
||||
)
|
||||
bookmarks.append(bm)
|
||||
}
|
||||
bookmarks.sort { $0.createdAt > $1.createdAt }
|
||||
save()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
try? FileManager.default.createDirectory(
|
||||
at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
try? data.write(to: saveFile)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = try? Data(contentsOf: saveFile) else { return }
|
||||
bookmarks = (try? JSONDecoder().decode([Bookmark].self, from: data)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -66,11 +66,16 @@ struct DownloadedItem: Codable, Hashable {
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DownloadManager {
|
||||
final class DownloadManager: @unchecked Sendable {
|
||||
private let client: ABSClient
|
||||
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
||||
private(set) var states: [String: DownloadState] = [:]
|
||||
private(set) var downloadedItems: [String: DownloadedItem] = [:]
|
||||
/// Items currently being downloaded (removed on completion or cancellation).
|
||||
private(set) var pendingItems: [String: LibraryItem] = [:]
|
||||
/// Bytes received for the currently in-flight track per downloadKey.
|
||||
/// Reset between tracks; cleared when the download finishes/cancels.
|
||||
private(set) var inFlightBytes: [String: Int64] = [:]
|
||||
|
||||
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
|
||||
private var activeTasks: [String: Task<Void, Never>] = [:]
|
||||
@@ -90,6 +95,36 @@ final class DownloadManager {
|
||||
return false
|
||||
}
|
||||
|
||||
func downloadedBytes(for downloadKey: String) -> Int64 {
|
||||
if let item = downloadedItems[downloadKey] {
|
||||
return item.tracks.reduce(Int64(0)) { sum, track in
|
||||
let url = AppPaths.downloadsDirectory.appendingPathComponent(track.localPath)
|
||||
return sum + fileSize(at: url)
|
||||
}
|
||||
}
|
||||
if let pending = pendingItems[downloadKey] {
|
||||
let onDisk = folderSize(at: AppPaths.downloadsDirectory.appendingPathComponent(pending.id))
|
||||
let inFlight = inFlightBytes[downloadKey] ?? 0
|
||||
return onDisk + inFlight
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/// Called from the URL session delegate (any queue) to update in-flight bytes
|
||||
/// for a currently downloading track.
|
||||
nonisolated func _updateInFlightBytes(_ bytes: Int64, for key: String) {
|
||||
Task { @MainActor [self] in
|
||||
self.inFlightBytes[key] = bytes
|
||||
}
|
||||
}
|
||||
|
||||
private func fileSize(at url: URL) -> Int64 {
|
||||
guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) else { return 0 }
|
||||
if let n = attrs[.size] as? NSNumber { return n.int64Value }
|
||||
if let i = attrs[.size] as? Int { return Int64(i) }
|
||||
return 0
|
||||
}
|
||||
|
||||
func localTrackURLs(for downloadKey: String) -> [URL]? {
|
||||
guard let item = downloadedItems[downloadKey] else { return nil }
|
||||
return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
|
||||
@@ -100,6 +135,7 @@ final class DownloadManager {
|
||||
let key = item.downloadKey
|
||||
guard activeTasks[key] == nil else { return }
|
||||
states[key] = .downloading(progress: 0)
|
||||
pendingItems[key] = item
|
||||
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -110,12 +146,14 @@ final class DownloadManager {
|
||||
workItem = try await self.client.fetchItemDetail(itemId: item.id)
|
||||
} catch {
|
||||
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
||||
self.pendingItems.removeValue(forKey: key)
|
||||
self.activeTasks[key] = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
if workItem.audioFiles.isEmpty {
|
||||
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
||||
self.pendingItems.removeValue(forKey: key)
|
||||
self.activeTasks[key] = nil
|
||||
return
|
||||
}
|
||||
@@ -129,9 +167,12 @@ final class DownloadManager {
|
||||
activeTasks[downloadKey]?.cancel()
|
||||
activeTasks[downloadKey] = nil
|
||||
states[downloadKey] = .notDownloaded
|
||||
pendingItems.removeValue(forKey: downloadKey)
|
||||
inFlightBytes.removeValue(forKey: downloadKey)
|
||||
}
|
||||
|
||||
func delete(downloadKey: String) {
|
||||
pendingItems.removeValue(forKey: downloadKey)
|
||||
cancel(downloadKey: downloadKey)
|
||||
if let item = downloadedItems[downloadKey] {
|
||||
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
|
||||
@@ -148,6 +189,18 @@ final class DownloadManager {
|
||||
persistIndex()
|
||||
}
|
||||
|
||||
private func folderSize(at url: URL) -> Int64 {
|
||||
guard let enumerator = FileManager.default.enumerator(
|
||||
atPath: url.path
|
||||
) else { return 0 }
|
||||
var total: Int64 = 0
|
||||
for case let relPath as String in enumerator {
|
||||
let fileURL = url.appendingPathComponent(relPath)
|
||||
total += fileSize(at: fileURL)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
private func directoryURL(itemId: String, episodeId: String?) -> URL {
|
||||
var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
|
||||
if let episodeId {
|
||||
@@ -162,6 +215,10 @@ final class DownloadManager {
|
||||
}
|
||||
|
||||
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
||||
defer {
|
||||
pendingItems.removeValue(forKey: downloadKey)
|
||||
inFlightBytes.removeValue(forKey: downloadKey)
|
||||
}
|
||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
||||
@@ -184,7 +241,7 @@ final class DownloadManager {
|
||||
|
||||
let tempURL: URL
|
||||
do {
|
||||
tempURL = try await downloadWithRetry(request: request, filename: file.filename)
|
||||
tempURL = try await downloadWithRetry(request: request, filename: file.filename, downloadKey: downloadKey)
|
||||
} catch is CancellationError {
|
||||
states[downloadKey] = .notDownloaded
|
||||
return
|
||||
@@ -210,6 +267,9 @@ final class DownloadManager {
|
||||
durationSeconds: file.durationSeconds
|
||||
))
|
||||
states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
|
||||
// Track is on disk now (folderSize picks it up). Clear the in-flight
|
||||
// counter so the next track's bytes don't double-count.
|
||||
inFlightBytes[downloadKey] = 0
|
||||
}
|
||||
|
||||
let downloaded = DownloadedItem(
|
||||
@@ -226,22 +286,22 @@ final class DownloadManager {
|
||||
}
|
||||
|
||||
/// Downloads with up to `maxAttempts` retries and resume-data support so a brief
|
||||
/// network dropout picks up where it left off. Uses the client session so that
|
||||
/// self-signed server certificates are accepted.
|
||||
private func downloadWithRetry(request: URLRequest, filename: String, maxAttempts: Int = 5) async throws -> URL {
|
||||
let session = client.session
|
||||
/// network dropout picks up where it left off. Uses a classic URLSessionDownloadTask
|
||||
/// with explicit delegate (wrapped in a continuation) so we reliably get
|
||||
/// `didWriteData` progress callbacks — the async `download(for:delegate:)` API
|
||||
/// often doesn't fire them.
|
||||
private func downloadWithRetry(request: URLRequest, filename: String, downloadKey: String, maxAttempts: Int = 5) async throws -> URL {
|
||||
var resumeData: Data? = nil
|
||||
var lastError: Error = URLError(.unknown)
|
||||
|
||||
for attempt in 0..<maxAttempts {
|
||||
try Task.checkCancellation()
|
||||
do {
|
||||
let (tempURL, response): (URL, URLResponse)
|
||||
if let resume = resumeData {
|
||||
(tempURL, response) = try await session.download(resumeFrom: resume)
|
||||
} else {
|
||||
(tempURL, response) = try await session.download(for: request)
|
||||
}
|
||||
let (tempURL, response) = try await streamingDownload(
|
||||
request: request,
|
||||
resumeData: resumeData,
|
||||
downloadKey: downloadKey
|
||||
)
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw URLError(.badServerResponse)
|
||||
@@ -262,6 +322,32 @@ final class DownloadManager {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/// Runs a single download attempt via URLSessionDownloadTask, reporting byte
|
||||
/// progress to `inFlightBytes` and moving the system-temp file to a stable
|
||||
/// location before returning.
|
||||
private func streamingDownload(
|
||||
request: URLRequest,
|
||||
resumeData: Data?,
|
||||
downloadKey: String
|
||||
) async throws -> (URL, URLResponse) {
|
||||
let session = client.session
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let delegate = DownloadProgressDelegate(
|
||||
manager: self,
|
||||
downloadKey: downloadKey,
|
||||
continuation: continuation
|
||||
)
|
||||
let task: URLSessionDownloadTask
|
||||
if let resumeData {
|
||||
task = session.downloadTask(withResumeData: resumeData)
|
||||
} else {
|
||||
task = session.downloadTask(with: request)
|
||||
}
|
||||
task.delegate = delegate
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
private func loadIndex() {
|
||||
guard let data = try? Data(contentsOf: indexFile),
|
||||
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
|
||||
@@ -292,3 +378,78 @@ final class DownloadManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-task delegate for a single `URLSessionDownloadTask`. Forwards live byte
|
||||
/// progress to the manager and bridges the delegate callbacks back to async/await
|
||||
/// via a checked continuation. The system deletes the `didFinishDownloadingTo`
|
||||
/// temp URL immediately after the callback returns, so we move it to a stable
|
||||
/// location before resuming.
|
||||
private final class DownloadProgressDelegate: NSObject, URLSessionDownloadDelegate, @unchecked Sendable {
|
||||
private let manager: DownloadManager
|
||||
private let downloadKey: String
|
||||
private let continuation: CheckedContinuation<(URL, URLResponse), Error>
|
||||
private var stableTempURL: URL?
|
||||
private var didResume = false
|
||||
private let lock = NSLock()
|
||||
|
||||
init(
|
||||
manager: DownloadManager,
|
||||
downloadKey: String,
|
||||
continuation: CheckedContinuation<(URL, URLResponse), Error>
|
||||
) {
|
||||
self.manager = manager
|
||||
self.downloadKey = downloadKey
|
||||
self.continuation = continuation
|
||||
super.init()
|
||||
}
|
||||
|
||||
private func resumeOnce(with result: Result<(URL, URLResponse), Error>) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
guard !didResume else { return }
|
||||
didResume = true
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didWriteData bytesWritten: Int64,
|
||||
totalBytesWritten: Int64,
|
||||
totalBytesExpectedToWrite: Int64
|
||||
) {
|
||||
manager._updateInFlightBytes(totalBytesWritten, for: downloadKey)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
downloadTask: URLSessionDownloadTask,
|
||||
didFinishDownloadingTo location: URL
|
||||
) {
|
||||
// Move out of the system-temp folder before this delegate method returns
|
||||
// (otherwise the OS deletes it).
|
||||
let target = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString + ".tmp")
|
||||
do {
|
||||
try FileManager.default.moveItem(at: location, to: target)
|
||||
stableTempURL = target
|
||||
} catch {
|
||||
stableTempURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
didCompleteWithError error: Error?
|
||||
) {
|
||||
if let error {
|
||||
resumeOnce(with: .failure(error))
|
||||
return
|
||||
}
|
||||
guard let url = stableTempURL, let response = task.response else {
|
||||
resumeOnce(with: .failure(URLError(.cannotCreateFile)))
|
||||
return
|
||||
}
|
||||
resumeOnce(with: .success((url, response)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) ?? []
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ enum SleepTimerMode: Equatable, Hashable {
|
||||
case off
|
||||
case minutes(Int)
|
||||
case endOfBook
|
||||
case endOfChapter
|
||||
}
|
||||
|
||||
@Observable
|
||||
@@ -29,8 +30,13 @@ final class PlayerEngine {
|
||||
|
||||
var sleepTimer: SleepTimerMode = .off
|
||||
/// Verbleibende Wallclock-Sekunden bis der Sleep-Timer auslöst (0 wenn off).
|
||||
/// Pausiert mit der Wiedergabe; bei `.endOfBook` rate-skaliert aus der Restspielzeit.
|
||||
/// Pausiert mit der Wiedergabe; bei `.endOfBook`/`.endOfChapter` rate-skaliert aus der Restspielzeit.
|
||||
var sleepRemainingSeconds: Double = 0
|
||||
var chapters: [Chapter] = []
|
||||
|
||||
var currentChapter: Chapter? {
|
||||
chapters.last { $0.start <= absoluteCurrentTime }
|
||||
}
|
||||
|
||||
private var player: AVQueuePlayer?
|
||||
private var trackDurations: [Double] = []
|
||||
@@ -118,6 +124,7 @@ final class PlayerEngine {
|
||||
}
|
||||
}
|
||||
|
||||
self.chapters = item.chapters
|
||||
currentTitle = item.title
|
||||
currentAuthor = item.author
|
||||
currentCoverURL = client.coverURL(itemId: item.id)
|
||||
@@ -159,8 +166,10 @@ final class PlayerEngine {
|
||||
func setRate(_ newRate: Float) {
|
||||
rate = newRate
|
||||
if isPlaying { player?.rate = newRate }
|
||||
if case .endOfBook = sleepTimer {
|
||||
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||
switch sleepTimer {
|
||||
case .endOfBook: sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||
case .endOfChapter: sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
|
||||
default: break
|
||||
}
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
@@ -241,6 +250,7 @@ final class PlayerEngine {
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
||||
updateEndOfBookSleep()
|
||||
updateEndOfChapterSleep()
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
@@ -264,6 +274,7 @@ final class PlayerEngine {
|
||||
itemId = nil
|
||||
errorMessage = nil
|
||||
isSeeking = false
|
||||
chapters = []
|
||||
currentTitle = ""
|
||||
currentAuthor = ""
|
||||
currentCoverURL = nil
|
||||
@@ -284,6 +295,8 @@ final class PlayerEngine {
|
||||
if isPlaying { startSleepTickTask() }
|
||||
case .endOfBook:
|
||||
sleepRemainingSeconds = wallclockRemainingUntilEndOfBook()
|
||||
case .endOfChapter:
|
||||
sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +335,22 @@ final class PlayerEngine {
|
||||
return playback / r
|
||||
}
|
||||
|
||||
private func updateEndOfChapterSleep() {
|
||||
guard case .endOfChapter = sleepTimer else { return }
|
||||
sleepRemainingSeconds = wallclockRemainingUntilEndOfChapter()
|
||||
if sleepRemainingSeconds <= 0 {
|
||||
sleepTimer = .off
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
private func wallclockRemainingUntilEndOfChapter() -> Double {
|
||||
let chapterEnd = currentChapter?.end ?? totalDuration
|
||||
let playback = max(0, chapterEnd - absoluteCurrentTime)
|
||||
let r = max(0.1, Double(rate))
|
||||
return playback / r
|
||||
}
|
||||
|
||||
// MARK: - Now-playing / remote commands
|
||||
|
||||
private func configureRemoteCommandsIfNeeded() {
|
||||
|
||||
Reference in New Issue
Block a user