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:
Scarriffle
2026-05-25 18:40:52 +02:00
parent 15d8e71d09
commit fa47cae664
21 changed files with 1751 additions and 119 deletions

View File

@@ -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?
}