Merge iOS and Mac app into one
This commit is contained in:
228
ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Normal file
228
ABS Client/Audiobookshelf swift/Services/ABSClient.swift
Normal file
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
|
||||
enum ABSClientError: LocalizedError {
|
||||
case noAuth
|
||||
case invalidURL
|
||||
case httpStatus(Int)
|
||||
case decoding(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noAuth: return "Nicht angemeldet."
|
||||
case .invalidURL: return "Ungültige URL."
|
||||
case .httpStatus(let code): return "HTTP-Status \(code)."
|
||||
case .decoding(let err): return "Antwort konnte nicht gelesen werden: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Accepts any server certificate so that private servers with self-signed or
|
||||
// custom-CA certificates work without needing the CA installed on-device.
|
||||
// Acceptable because the user explicitly configures the server URL themselves.
|
||||
private final class AnyServerTrustDelegate: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ABSClient {
|
||||
private let auth: AuthStore
|
||||
private let sessionDelegate = AnyServerTrustDelegate()
|
||||
private(set) var session: URLSession = URLSession.shared
|
||||
|
||||
init(auth: AuthStore) {
|
||||
self.auth = auth
|
||||
let config = URLSessionConfiguration.default
|
||||
config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
config.waitsForConnectivity = false
|
||||
self.session = URLSession(configuration: config, delegate: sessionDelegate, delegateQueue: nil)
|
||||
}
|
||||
|
||||
private func makeRequest(path: String, method: String = "GET", body: Data? = nil) throws -> URLRequest {
|
||||
guard !auth.token.isEmpty, !auth.serverURL.isEmpty else { throw ABSClientError.noAuth }
|
||||
guard let url = URL(string: auth.serverURL + path) else { throw ABSClientError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("Bearer \(auth.token)", forHTTPHeaderField: "Authorization")
|
||||
if let body {
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
private func perform<T: Decodable>(_ req: URLRequest, as: T.Type) async throws -> T {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else { throw ABSClientError.httpStatus(0) }
|
||||
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
|
||||
do {
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
} catch {
|
||||
throw ABSClientError.decoding(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchLibraries() async throws -> [Library] {
|
||||
let req = try makeRequest(path: "/api/libraries")
|
||||
let dto = try await perform(req, as: LibrariesResponseDTO.self)
|
||||
return dto.libraries.map { Library(id: $0.id, name: $0.name, mediaType: $0.mediaType) }
|
||||
}
|
||||
|
||||
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
||||
let req = try makeRequest(path: "/api/libraries/\(libraryId)/items?limit=500&sort=media.metadata.title")
|
||||
let dto = try await perform(req, as: LibraryItemsResponseDTO.self)
|
||||
return dto.results.map { Self.toLibraryItem(from: $0) }
|
||||
}
|
||||
|
||||
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
||||
let meta = raw.media?.metadata
|
||||
let mediaType = raw.mediaType ?? "book"
|
||||
let files: [AudioFile] = (raw.media?.audioFiles ?? []).enumerated().map { idx, f in
|
||||
AudioFile(
|
||||
ino: f.ino,
|
||||
filename: f.metadata?.filename ?? "track-\(idx).mp3",
|
||||
ext: (f.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: ".")),
|
||||
durationSeconds: f.duration ?? 0,
|
||||
index: f.index ?? idx
|
||||
)
|
||||
}.sorted { $0.index < $1.index }
|
||||
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
||||
var item = LibraryItem(
|
||||
id: raw.id,
|
||||
title: meta?.title ?? "Unbekannt",
|
||||
author: authorString,
|
||||
durationSeconds: raw.media?.duration ?? 0,
|
||||
audioFiles: files
|
||||
)
|
||||
item.mediaType = mediaType
|
||||
item.description = meta?.description
|
||||
return item
|
||||
}
|
||||
|
||||
func fetchEpisodes(podcastItemId: String) async throws -> (LibraryItem, [PodcastEpisode]) {
|
||||
let req = try makeRequest(path: "/api/items/\(podcastItemId)?expanded=1")
|
||||
let raw = try await perform(req, as: LibraryItemDTO.self)
|
||||
let item = Self.toLibraryItem(from: raw)
|
||||
let episodes: [PodcastEpisode] = (raw.media?.episodes ?? []).compactMap { ep in
|
||||
guard let af = ep.audioFile else { return nil }
|
||||
let ext = (af.metadata?.ext ?? "mp3").trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let audioFile = AudioFile(
|
||||
ino: af.ino,
|
||||
filename: af.metadata?.filename ?? "episode.\(ext)",
|
||||
ext: ext,
|
||||
durationSeconds: af.duration ?? ep.duration ?? 0,
|
||||
index: 0
|
||||
)
|
||||
return PodcastEpisode(
|
||||
id: ep.id,
|
||||
title: ep.title ?? "Folge",
|
||||
pubDate: ep.pubDate,
|
||||
publishedAtMillis: ep.publishedAt,
|
||||
season: ep.season,
|
||||
episode: ep.episode,
|
||||
durationSeconds: ep.duration ?? af.duration ?? 0,
|
||||
audioFile: audioFile
|
||||
)
|
||||
}.sorted { lhs, rhs in
|
||||
(lhs.publishedAtMillis ?? 0) > (rhs.publishedAtMillis ?? 0)
|
||||
}
|
||||
return (item, episodes)
|
||||
}
|
||||
|
||||
func fetchItemDetail(itemId: String) async throws -> LibraryItem {
|
||||
let req = try makeRequest(path: "/api/items/\(itemId)?expanded=1")
|
||||
let raw = try await perform(req, as: LibraryItemDTO.self)
|
||||
return Self.toLibraryItem(from: raw)
|
||||
}
|
||||
|
||||
private func progressPath(itemId: String, episodeId: String?) -> String {
|
||||
if let episodeId { return "/api/me/progress/\(itemId)/\(episodeId)" }
|
||||
return "/api/me/progress/\(itemId)"
|
||||
}
|
||||
|
||||
func fetchProgress(itemId: String, episodeId: String? = nil) async throws -> PlaybackProgress? {
|
||||
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 nil }
|
||||
if http.statusCode == 404 { return nil }
|
||||
guard (200..<300).contains(http.statusCode) else { throw ABSClientError.httpStatus(http.statusCode) }
|
||||
let dto = try JSONDecoder().decode(ProgressResponseDTO.self, from: data)
|
||||
return PlaybackProgress(
|
||||
itemId: itemId,
|
||||
episodeId: episodeId,
|
||||
currentTime: dto.currentTime ?? 0,
|
||||
duration: dto.duration ?? 0,
|
||||
isFinished: dto.isFinished ?? false,
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
func saveProgress(_ progress: PlaybackProgress) async throws {
|
||||
let body: [String: Any] = [
|
||||
"currentTime": progress.currentTime,
|
||||
"duration": progress.duration,
|
||||
"isFinished": progress.isFinished,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: body)
|
||||
let req = try makeRequest(
|
||||
path: progressPath(itemId: progress.itemId, episodeId: progress.episodeId),
|
||||
method: "PATCH",
|
||||
body: data
|
||||
)
|
||||
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 fetchAllProgress() async throws -> [PlaybackProgress] {
|
||||
let req = try makeRequest(path: "/api/me")
|
||||
let dto = try await perform(req, as: MeResponseDTO.self)
|
||||
return (dto.mediaProgress ?? []).compactMap { p in
|
||||
guard let itemId = p.libraryItemId else { return nil }
|
||||
return PlaybackProgress(
|
||||
itemId: itemId,
|
||||
episodeId: p.episodeId,
|
||||
currentTime: p.currentTime ?? 0,
|
||||
duration: p.duration ?? 0,
|
||||
isFinished: p.isFinished ?? false,
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validateToken() async -> Bool {
|
||||
guard let req = try? makeRequest(path: "/api/me") else { return false }
|
||||
do {
|
||||
let (_, response) = try await session.data(for: req)
|
||||
return ((response as? HTTPURLResponse)?.statusCode ?? 0) < 400
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func coverURL(itemId: String) -> URL? {
|
||||
guard !auth.serverURL.isEmpty else { return nil }
|
||||
var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/cover")
|
||||
comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
|
||||
return comps?.url
|
||||
}
|
||||
|
||||
func audioFileURL(itemId: String, ino: String) -> URL? {
|
||||
var comps = URLComponents(string: auth.serverURL + "/api/items/\(itemId)/file/\(ino)")
|
||||
comps?.queryItems = [URLQueryItem(name: "token", value: auth.token)]
|
||||
return comps?.url
|
||||
}
|
||||
|
||||
var bearerHeader: [String: String] { ["Authorization": "Bearer \(auth.token)"] }
|
||||
}
|
||||
224
ABS Client/Audiobookshelf swift/Services/AppState.swift
Normal file
224
ABS Client/Audiobookshelf swift/Services/AppState.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
let auth: AuthStore
|
||||
let client: ABSClient
|
||||
let network: NetworkMonitor
|
||||
let downloads: DownloadManager
|
||||
let sync: ProgressSyncManager
|
||||
let player: PlayerEngine
|
||||
|
||||
var currentItem: LibraryItem?
|
||||
var isPreparingPlayback: Bool = false
|
||||
|
||||
/// Map: PlaybackProgress.syncKey -> PlaybackProgress (server-known progress).
|
||||
/// Used to show progress bars on covers in the library views.
|
||||
var progressCache: [String: PlaybackProgress] = [:]
|
||||
|
||||
private var syncTimer: Timer?
|
||||
private var lastReportedSecond: Double = -10
|
||||
|
||||
init() {
|
||||
let auth = AuthStore()
|
||||
let client = ABSClient(auth: auth)
|
||||
self.auth = auth
|
||||
self.client = client
|
||||
self.network = NetworkMonitor()
|
||||
self.downloads = DownloadManager(client: client)
|
||||
self.sync = ProgressSyncManager(client: client)
|
||||
self.player = PlayerEngine()
|
||||
}
|
||||
|
||||
func bootstrap() async {
|
||||
auth.restoreSession()
|
||||
network.start { [weak self] online in
|
||||
guard let self else { return }
|
||||
if online {
|
||||
Task { [weak self] in
|
||||
await self?.sync.drain()
|
||||
await self?.refreshProgressCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
if auth.isLoggedIn {
|
||||
let ok = await client.validateToken()
|
||||
if !ok {
|
||||
auth.logout()
|
||||
} else {
|
||||
await sync.drain()
|
||||
await refreshProgressCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the entire progress map from the server (via /api/me).
|
||||
func refreshProgressCache() async {
|
||||
guard network.isOnline, auth.isLoggedIn else { return }
|
||||
do {
|
||||
let all = try await client.fetchAllProgress()
|
||||
progressCache = Dictionary(all.map { ($0.syncKey, $0) }, uniquingKeysWith: { _, new in new })
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/// Local update for the cache while we're actively playing.
|
||||
func cacheProgress(itemId: String, episodeId: String?, currentTime: Double, duration: Double, isFinished: Bool) {
|
||||
let p = PlaybackProgress(
|
||||
itemId: itemId, episodeId: episodeId,
|
||||
currentTime: currentTime, duration: duration,
|
||||
isFinished: isFinished, updatedAt: Date()
|
||||
)
|
||||
progressCache[p.syncKey] = p
|
||||
}
|
||||
|
||||
func progress(for item: LibraryItem) -> PlaybackProgress? {
|
||||
progressCache[item.syncKey]
|
||||
}
|
||||
|
||||
func progressFraction(itemId: String, episodeId: String? = nil) -> Double {
|
||||
let key = episodeId.map { "\(itemId)|\($0)" } ?? itemId
|
||||
guard let p = progressCache[key], p.duration > 0 else { return 0 }
|
||||
if p.isFinished { return 1.0 }
|
||||
return min(1, max(0, p.currentTime / p.duration))
|
||||
}
|
||||
|
||||
func play(item: LibraryItem) async {
|
||||
if currentItem?.id == item.id, currentItem?.episodeId == item.episodeId, player.isReady {
|
||||
player.play()
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
let nearEnd = p.duration > 0 && p.currentTime >= p.duration - 10
|
||||
if !p.isFinished && !nearEnd {
|
||||
startAt = p.currentTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentItem = workItem
|
||||
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
||||
if player.errorMessage == nil {
|
||||
player.play()
|
||||
startSyncTimer()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for podcast episodes.
|
||||
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
||||
var synthetic = LibraryItem(
|
||||
id: podcast.id,
|
||||
title: episode.title,
|
||||
author: podcast.title,
|
||||
durationSeconds: episode.durationSeconds > 0 ? episode.durationSeconds : episode.audioFile.durationSeconds,
|
||||
audioFiles: [episode.audioFile]
|
||||
)
|
||||
synthetic.mediaType = "podcast"
|
||||
synthetic.episodeId = episode.id
|
||||
await play(item: synthetic)
|
||||
}
|
||||
|
||||
func stopPlayback(reportFinal: Bool = true) {
|
||||
if reportFinal { reportProgress(force: true) }
|
||||
syncTimer?.invalidate()
|
||||
syncTimer = nil
|
||||
player.teardown()
|
||||
currentItem = nil
|
||||
lastReportedSecond = -10
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
guard currentItem != nil else { return }
|
||||
player.togglePlay()
|
||||
if !player.isPlaying { reportProgress(force: true) }
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
guard currentItem != nil else { return }
|
||||
player.skip(by: seconds)
|
||||
reportProgress(force: true)
|
||||
}
|
||||
|
||||
func seekAbsolute(_ target: Double) {
|
||||
guard currentItem != nil else { return }
|
||||
player.seekAbsolute(target)
|
||||
reportProgress(force: true)
|
||||
}
|
||||
|
||||
func setRate(_ newRate: Float) {
|
||||
player.setRate(newRate)
|
||||
}
|
||||
|
||||
private func startSyncTimer() {
|
||||
syncTimer?.invalidate()
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.reportProgress(force: false)
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
syncTimer = timer
|
||||
}
|
||||
|
||||
private func reportProgress(force: Bool) {
|
||||
guard let item = currentItem else { return }
|
||||
let t = player.absoluteCurrentTime
|
||||
let d = player.totalDuration
|
||||
guard d > 0 else { return }
|
||||
if !force && abs(t - lastReportedSecond) < 3 { return }
|
||||
lastReportedSecond = t
|
||||
let finished = (d - t) < 30
|
||||
|
||||
cacheProgress(
|
||||
itemId: item.id,
|
||||
episodeId: item.episodeId,
|
||||
currentTime: t,
|
||||
duration: d,
|
||||
isFinished: finished
|
||||
)
|
||||
|
||||
Task {
|
||||
await sync.report(
|
||||
itemId: item.id,
|
||||
episodeId: item.episodeId,
|
||||
currentTime: t,
|
||||
duration: d,
|
||||
isFinished: finished,
|
||||
isOnline: network.isOnline
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LibraryItem {
|
||||
/// Matches PlaybackProgress.syncKey for cache lookups.
|
||||
var syncKey: String {
|
||||
if let episodeId { return "\(id)|\(episodeId)" }
|
||||
return id
|
||||
}
|
||||
|
||||
/// The DownloadManager keys downloads by this composite identifier,
|
||||
/// allowing the same podcast item to host multiple per-episode downloads.
|
||||
var downloadKey: String { syncKey }
|
||||
}
|
||||
94
ABS Client/Audiobookshelf swift/Services/AuthStore.swift
Normal file
94
ABS Client/Audiobookshelf swift/Services/AuthStore.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
enum AuthError: LocalizedError {
|
||||
case invalidURL
|
||||
case badResponse(Int)
|
||||
case noToken
|
||||
case unknown(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Ungültige Server-URL."
|
||||
case .badResponse(let code): return "Server antwortete mit Status \(code)."
|
||||
case .noToken: return "Login fehlgeschlagen: kein Token erhalten."
|
||||
case .unknown(let msg): return msg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AuthStore {
|
||||
var isLoggedIn: Bool = false
|
||||
var serverURL: String = ""
|
||||
var username: String = ""
|
||||
var token: String = ""
|
||||
var errorMessage: String?
|
||||
|
||||
func restoreSession() {
|
||||
guard let creds = KeychainStore.load() else { return }
|
||||
self.serverURL = creds.serverURL
|
||||
self.username = creds.username
|
||||
self.token = creds.token
|
||||
self.isLoggedIn = true
|
||||
}
|
||||
|
||||
func login(serverURL rawURL: String, username: String, password: String, remember: Bool) async {
|
||||
errorMessage = nil
|
||||
let normalized = Self.normalizeURL(rawURL)
|
||||
guard let url = URL(string: normalized + "/login") else {
|
||||
errorMessage = AuthError.invalidURL.errorDescription
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let body = ["username": username, "password": password]
|
||||
request.httpBody = try? JSONEncoder().encode(body)
|
||||
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
errorMessage = "Keine HTTP-Antwort vom Server."
|
||||
return
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
errorMessage = AuthError.badResponse(http.statusCode).errorDescription
|
||||
return
|
||||
}
|
||||
let decoded = try JSONDecoder().decode(LoginResponseDTO.self, from: data)
|
||||
self.serverURL = normalized
|
||||
self.username = decoded.user.username ?? username
|
||||
self.token = decoded.user.token
|
||||
self.isLoggedIn = true
|
||||
|
||||
if remember {
|
||||
try? KeychainStore.save(StoredCredentials(
|
||||
serverURL: normalized,
|
||||
username: self.username,
|
||||
token: self.token
|
||||
))
|
||||
} else {
|
||||
KeychainStore.delete()
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Login fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func logout() {
|
||||
KeychainStore.delete()
|
||||
token = ""
|
||||
isLoggedIn = false
|
||||
}
|
||||
|
||||
static func normalizeURL(_ raw: String) -> String {
|
||||
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while s.hasSuffix("/") { s.removeLast() }
|
||||
if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") {
|
||||
s = "https://" + s
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
294
ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
Normal file
294
ABS Client/Audiobookshelf swift/Services/DownloadManager.swift
Normal file
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DownloadedTrack: Codable, Hashable {
|
||||
let ino: String
|
||||
let filename: String
|
||||
let localPath: String // relative to AppPaths.downloadsDirectory
|
||||
let durationSeconds: Double
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case ino, filename, localPath, durationSeconds
|
||||
}
|
||||
|
||||
init(ino: String, filename: String, localPath: String, durationSeconds: Double) {
|
||||
self.ino = ino
|
||||
self.filename = filename
|
||||
self.localPath = localPath
|
||||
self.durationSeconds = durationSeconds
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
ino = try c.decode(String.self, forKey: .ino)
|
||||
filename = try c.decode(String.self, forKey: .filename)
|
||||
localPath = try c.decode(String.self, forKey: .localPath)
|
||||
durationSeconds = try c.decodeIfPresent(Double.self, forKey: .durationSeconds) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadedItem: Codable, Hashable {
|
||||
let itemId: String
|
||||
var episodeId: String?
|
||||
let title: String
|
||||
let author: String
|
||||
let durationSeconds: Double
|
||||
let tracks: [DownloadedTrack]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case itemId, episodeId, title, author, durationSeconds, tracks
|
||||
}
|
||||
|
||||
init(itemId: String, episodeId: String? = nil, title: String, author: String, durationSeconds: Double, tracks: [DownloadedTrack]) {
|
||||
self.itemId = itemId
|
||||
self.episodeId = episodeId
|
||||
self.title = title
|
||||
self.author = author
|
||||
self.durationSeconds = durationSeconds
|
||||
self.tracks = tracks
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
itemId = try c.decode(String.self, forKey: .itemId)
|
||||
episodeId = try c.decodeIfPresent(String.self, forKey: .episodeId)
|
||||
title = try c.decode(String.self, forKey: .title)
|
||||
author = try c.decode(String.self, forKey: .author)
|
||||
durationSeconds = try c.decode(Double.self, forKey: .durationSeconds)
|
||||
tracks = try c.decode([DownloadedTrack].self, forKey: .tracks)
|
||||
}
|
||||
|
||||
var downloadKey: String {
|
||||
if let episodeId { return "\(itemId)|\(episodeId)" }
|
||||
return itemId
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DownloadManager {
|
||||
private let client: ABSClient
|
||||
/// Keyed by downloadKey (itemId or "itemId|episodeId").
|
||||
private(set) var states: [String: DownloadState] = [:]
|
||||
private(set) var downloadedItems: [String: DownloadedItem] = [:]
|
||||
|
||||
private var indexFile: URL { AppPaths.supportDirectory.appendingPathComponent("downloads-index.json") }
|
||||
private var activeTasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
init(client: ABSClient) {
|
||||
self.client = client
|
||||
try? FileManager.default.createDirectory(at: AppPaths.downloadsDirectory, withIntermediateDirectories: true)
|
||||
loadIndex()
|
||||
}
|
||||
|
||||
func state(for downloadKey: String) -> DownloadState {
|
||||
states[downloadKey] ?? .notDownloaded
|
||||
}
|
||||
|
||||
func isDownloaded(downloadKey: String) -> Bool {
|
||||
if case .downloaded = state(for: downloadKey) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func localTrackURLs(for downloadKey: String) -> [URL]? {
|
||||
guard let item = downloadedItems[downloadKey] else { return nil }
|
||||
return item.tracks.map { AppPaths.downloadsDirectory.appendingPathComponent($0.localPath) }
|
||||
}
|
||||
|
||||
/// Downloads a book (whole audioFiles list) or a podcast episode (single audioFile).
|
||||
func startDownload(item: LibraryItem) {
|
||||
let key = item.downloadKey
|
||||
guard activeTasks[key] == nil else { return }
|
||||
states[key] = .downloading(progress: 0)
|
||||
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var workItem = item
|
||||
|
||||
if !workItem.isPodcast && workItem.audioFiles.isEmpty {
|
||||
do {
|
||||
workItem = try await self.client.fetchItemDetail(itemId: item.id)
|
||||
} catch {
|
||||
self.states[key] = .failed(message: "Detail konnte nicht geladen werden: \(error.localizedDescription)")
|
||||
self.activeTasks[key] = nil
|
||||
return
|
||||
}
|
||||
}
|
||||
if workItem.audioFiles.isEmpty {
|
||||
self.states[key] = .failed(message: "Keine herunterladbaren Audiodateien gefunden.")
|
||||
self.activeTasks[key] = nil
|
||||
return
|
||||
}
|
||||
await self.performDownload(workItem: workItem, downloadKey: key)
|
||||
self.activeTasks[key] = nil
|
||||
}
|
||||
activeTasks[key] = task
|
||||
}
|
||||
|
||||
func cancel(downloadKey: String) {
|
||||
activeTasks[downloadKey]?.cancel()
|
||||
activeTasks[downloadKey] = nil
|
||||
states[downloadKey] = .notDownloaded
|
||||
}
|
||||
|
||||
func delete(downloadKey: String) {
|
||||
cancel(downloadKey: downloadKey)
|
||||
if let item = downloadedItems[downloadKey] {
|
||||
let dir = directoryURL(itemId: item.itemId, episodeId: item.episodeId)
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
if item.episodeId != nil {
|
||||
let parent = AppPaths.downloadsDirectory.appendingPathComponent(item.itemId)
|
||||
if let contents = try? FileManager.default.contentsOfDirectory(atPath: parent.path), contents.isEmpty {
|
||||
try? FileManager.default.removeItem(at: parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadedItems.removeValue(forKey: downloadKey)
|
||||
states[downloadKey] = .notDownloaded
|
||||
persistIndex()
|
||||
}
|
||||
|
||||
private func directoryURL(itemId: String, episodeId: String?) -> URL {
|
||||
var dir = AppPaths.downloadsDirectory.appendingPathComponent(itemId, isDirectory: true)
|
||||
if let episodeId {
|
||||
dir = dir.appendingPathComponent(episodeId, isDirectory: true)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
private func relativePath(itemId: String, episodeId: String?, fileName: String) -> String {
|
||||
if let episodeId { return "\(itemId)/\(episodeId)/\(fileName)" }
|
||||
return "\(itemId)/\(fileName)"
|
||||
}
|
||||
|
||||
private func performDownload(workItem: LibraryItem, downloadKey: String) async {
|
||||
let itemDir = directoryURL(itemId: workItem.id, episodeId: workItem.episodeId)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: itemDir, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
var tracks: [DownloadedTrack] = []
|
||||
let total = max(workItem.audioFiles.count, 1)
|
||||
|
||||
for (idx, file) in workItem.audioFiles.enumerated() {
|
||||
if Task.isCancelled {
|
||||
states[downloadKey] = .notDownloaded
|
||||
return
|
||||
}
|
||||
guard let url = client.audioFileURL(itemId: workItem.id, ino: file.ino) else { continue }
|
||||
var request = URLRequest(url: url)
|
||||
for (k, v) in client.bearerHeader { request.setValue(v, forHTTPHeaderField: k) }
|
||||
|
||||
let tempURL: URL
|
||||
do {
|
||||
tempURL = try await downloadWithRetry(request: request, filename: file.filename)
|
||||
} catch is CancellationError {
|
||||
states[downloadKey] = .notDownloaded
|
||||
return
|
||||
} catch {
|
||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
let ext = file.ext.isEmpty ? "mp3" : file.ext
|
||||
let destName = "\(String(format: "%03d", idx))-\(file.ino).\(ext)"
|
||||
let dest = itemDir.appendingPathComponent(destName)
|
||||
do {
|
||||
try? FileManager.default.removeItem(at: dest)
|
||||
try FileManager.default.moveItem(at: tempURL, to: dest)
|
||||
} catch {
|
||||
states[downloadKey] = .failed(message: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
tracks.append(DownloadedTrack(
|
||||
ino: file.ino,
|
||||
filename: file.filename,
|
||||
localPath: relativePath(itemId: workItem.id, episodeId: workItem.episodeId, fileName: destName),
|
||||
durationSeconds: file.durationSeconds
|
||||
))
|
||||
states[downloadKey] = .downloading(progress: Double(idx + 1) / Double(total))
|
||||
}
|
||||
|
||||
let downloaded = DownloadedItem(
|
||||
itemId: workItem.id,
|
||||
episodeId: workItem.episodeId,
|
||||
title: workItem.title,
|
||||
author: workItem.author,
|
||||
durationSeconds: workItem.durationSeconds,
|
||||
tracks: tracks
|
||||
)
|
||||
downloadedItems[downloadKey] = downloaded
|
||||
states[downloadKey] = .downloaded
|
||||
persistIndex()
|
||||
}
|
||||
|
||||
/// 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
|
||||
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)
|
||||
}
|
||||
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
return tempURL
|
||||
} catch is CancellationError {
|
||||
throw CancellationError()
|
||||
} catch let error as NSError {
|
||||
resumeData = error.userInfo[NSURLSessionDownloadTaskResumeData] as? Data
|
||||
lastError = error
|
||||
if attempt < maxAttempts - 1 {
|
||||
// Exponential backoff: 1 s, 2 s, 4 s, 8 s …
|
||||
let delay = UInt64(min(pow(2.0, Double(attempt)), 30)) * 1_000_000_000
|
||||
try await Task.sleep(nanoseconds: delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private func loadIndex() {
|
||||
guard let data = try? Data(contentsOf: indexFile),
|
||||
let decoded = try? JSONDecoder().decode([String: DownloadedItem].self, from: data) else { return }
|
||||
var rekeyed: [String: DownloadedItem] = [:]
|
||||
for (_, item) in decoded {
|
||||
if item.tracks.isEmpty { continue }
|
||||
rekeyed[item.downloadKey] = item
|
||||
}
|
||||
downloadedItems = rekeyed
|
||||
for k in rekeyed.keys {
|
||||
states[k] = .downloaded
|
||||
}
|
||||
for (oldKey, item) in decoded where item.tracks.isEmpty {
|
||||
let dir = AppPaths.downloadsDirectory.appendingPathComponent(oldKey)
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
if rekeyed.count != decoded.count {
|
||||
persistIndex()
|
||||
}
|
||||
}
|
||||
|
||||
private func persistIndex() {
|
||||
do {
|
||||
let data = try JSONEncoder().encode(downloadedItems)
|
||||
try data.write(to: indexFile, options: .atomic)
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
59
ABS Client/Audiobookshelf swift/Services/KeychainStore.swift
Normal file
59
ABS Client/Audiobookshelf swift/Services/KeychainStore.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct StoredCredentials: Codable {
|
||||
let serverURL: String
|
||||
let username: String
|
||||
let token: String
|
||||
}
|
||||
|
||||
enum KeychainError: Error {
|
||||
case osStatus(OSStatus)
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum KeychainStore {
|
||||
private static let service = "com.local.Audiobookshelf-swift.auth"
|
||||
private static let account = "primary"
|
||||
|
||||
static func save(_ creds: StoredCredentials) throws {
|
||||
let data = try JSONEncoder().encode(creds)
|
||||
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
|
||||
let status = SecItemAdd(attributes as CFDictionary, nil)
|
||||
guard status == errSecSuccess else { throw KeychainError.osStatus(status) }
|
||||
}
|
||||
|
||||
static func load() -> StoredCredentials? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
guard status == errSecSuccess, let data = item as? Data else { return nil }
|
||||
return try? JSONDecoder().decode(StoredCredentials.self, from: data)
|
||||
}
|
||||
|
||||
static func delete() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NetworkMonitor {
|
||||
var isOnline: Bool = true
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
func start(onChange: @escaping @MainActor (Bool) -> Void) {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
let online = path.status == .satisfied
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let previous = self.isOnline
|
||||
self.isOnline = online
|
||||
if previous != online {
|
||||
onChange(online)
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
424
ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
424
ABS Client/Audiobookshelf swift/Services/PlayerEngine.swift
Normal file
@@ -0,0 +1,424 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import MediaPlayer
|
||||
import Observation
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
private typealias PlayerArtworkImage = UIImage
|
||||
#else
|
||||
import AppKit
|
||||
private typealias PlayerArtworkImage = NSImage
|
||||
#endif
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PlayerEngine {
|
||||
var isPlaying: Bool = false
|
||||
var absoluteCurrentTime: Double = 0
|
||||
var totalDuration: Double = 0
|
||||
var rate: Float = 1.0
|
||||
var isReady: Bool = false
|
||||
var errorMessage: String?
|
||||
|
||||
private var player: AVQueuePlayer?
|
||||
private var trackDurations: [Double] = []
|
||||
private var trackPlayerItems: [AVPlayerItem] = []
|
||||
private var currentTrackIndex: Int = 0
|
||||
private var timeObserver: Any?
|
||||
private var endObservers: [NSObjectProtocol] = []
|
||||
private var isSeeking: Bool = false
|
||||
|
||||
var itemId: String?
|
||||
|
||||
private var currentTitle: String = ""
|
||||
private var currentAuthor: String = ""
|
||||
private var currentCoverURL: URL?
|
||||
private var remoteCommandsConfigured: Bool = false
|
||||
private var artworkSession: URLSession?
|
||||
|
||||
#if os(iOS)
|
||||
private var audioSessionObserversConfigured: Bool = false
|
||||
private var wasPlayingBeforeInterruption: Bool = false
|
||||
#endif
|
||||
|
||||
nonisolated init() {}
|
||||
|
||||
func load(item: LibraryItem, client: ABSClient, downloads: DownloadManager, startAt absoluteTime: Double) {
|
||||
teardown()
|
||||
self.itemId = item.id
|
||||
self.errorMessage = nil
|
||||
self.artworkSession = client.session
|
||||
|
||||
let useLocal = downloads.isDownloaded(downloadKey: item.downloadKey)
|
||||
let urls: [URL]
|
||||
|
||||
if useLocal, let localURLs = downloads.localTrackURLs(for: item.downloadKey), !localURLs.isEmpty {
|
||||
urls = localURLs
|
||||
trackDurations = (0..<localURLs.count).map { idx in
|
||||
idx < item.audioFiles.count ? item.audioFiles[idx].durationSeconds : 0
|
||||
}
|
||||
} else {
|
||||
guard !item.audioFiles.isEmpty else {
|
||||
errorMessage = "Dieses Hörbuch enthält keine abspielbaren Audiodateien."
|
||||
return
|
||||
}
|
||||
urls = item.audioFiles.compactMap { client.audioFileURL(itemId: item.id, ino: $0.ino) }
|
||||
trackDurations = item.audioFiles.map { $0.durationSeconds }
|
||||
}
|
||||
|
||||
// When audio files carry no duration (e.g. some podcast episodes or
|
||||
// freshly-scanned items), fall back to the item's reported total.
|
||||
// Distribute equally across all tracks so that trackDurations.count
|
||||
// always matches trackPlayerItems.count.
|
||||
if trackDurations.allSatisfy({ $0 <= 0 }) && item.durationSeconds > 0 {
|
||||
let count = max(1, trackDurations.count)
|
||||
let perTrack = item.durationSeconds / Double(count)
|
||||
trackDurations = Array(repeating: perTrack, count: count)
|
||||
}
|
||||
|
||||
totalDuration = trackDurations.reduce(0, +)
|
||||
|
||||
trackPlayerItems = urls.map { AVPlayerItem(url: $0) }
|
||||
|
||||
let queue = AVQueuePlayer(items: trackPlayerItems)
|
||||
queue.rate = rate
|
||||
self.player = queue
|
||||
|
||||
let center = NotificationCenter.default
|
||||
for (idx, playerItem) in trackPlayerItems.enumerated() {
|
||||
let token = center.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: playerItem, queue: .main) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleTrackEnd(finishedIndex: idx)
|
||||
}
|
||||
}
|
||||
endObservers.append(token)
|
||||
}
|
||||
|
||||
seekAbsolute(absoluteTime)
|
||||
|
||||
timeObserver = queue.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.refreshAbsoluteTime()
|
||||
}
|
||||
}
|
||||
|
||||
currentTitle = item.title
|
||||
currentAuthor = item.author
|
||||
currentCoverURL = client.coverURL(itemId: item.id)
|
||||
|
||||
#if os(iOS)
|
||||
configureAudioSessionObserversIfNeeded()
|
||||
#endif
|
||||
configureRemoteCommandsIfNeeded()
|
||||
updateNowPlayingInfo()
|
||||
fetchAndAttachArtwork()
|
||||
|
||||
isReady = true
|
||||
}
|
||||
|
||||
func play() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
player?.play()
|
||||
player?.rate = rate
|
||||
isPlaying = true
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func setRate(_ newRate: Float) {
|
||||
rate = newRate
|
||||
if isPlaying { player?.rate = newRate }
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
|
||||
func skip(by seconds: Double) {
|
||||
seekAbsolute(absoluteCurrentTime + seconds)
|
||||
}
|
||||
|
||||
func seekAbsolute(_ target: Double) {
|
||||
let clamped = max(0, min(target, max(totalDuration - 0.5, 0)))
|
||||
var remaining = clamped
|
||||
var trackIndex = 0
|
||||
for (idx, dur) in trackDurations.enumerated() {
|
||||
if remaining <= dur || idx == trackDurations.count - 1 {
|
||||
trackIndex = idx
|
||||
break
|
||||
}
|
||||
remaining -= dur
|
||||
}
|
||||
switchToTrack(index: trackIndex)
|
||||
absoluteCurrentTime = clamped
|
||||
guard let currentItem = player?.currentItem else {
|
||||
// No item to seek: don't leave isSeeking stuck, which would freeze the scrubber.
|
||||
return
|
||||
}
|
||||
isSeeking = true
|
||||
let cmTime = CMTime(seconds: max(0, remaining), preferredTimescale: 600)
|
||||
// Small tolerance so seeking succeeds on VBR MP3s without a Xing header.
|
||||
// Zero-tolerance seeks fail silently on such files, snapping the slider back.
|
||||
let tolerance = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||
currentItem.seek(to: cmTime, toleranceBefore: tolerance, toleranceAfter: tolerance) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isSeeking = false
|
||||
self?.refreshAbsoluteTime()
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToTrack(index: Int) {
|
||||
guard index < trackPlayerItems.count, let player else { return }
|
||||
if index == currentTrackIndex, player.currentItem === trackPlayerItems[index] { return }
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
player.removeAllItems()
|
||||
for i in index..<trackPlayerItems.count {
|
||||
let it = trackPlayerItems[i]
|
||||
it.seek(to: .zero, completionHandler: nil)
|
||||
if player.canInsert(it, after: nil) {
|
||||
player.insert(it, after: nil)
|
||||
}
|
||||
}
|
||||
currentTrackIndex = index
|
||||
if wasPlaying { player.play(); player.rate = rate }
|
||||
}
|
||||
|
||||
private func handleTrackEnd(finishedIndex: Int) {
|
||||
if finishedIndex < trackDurations.count - 1 {
|
||||
currentTrackIndex = finishedIndex + 1
|
||||
} else {
|
||||
isPlaying = false
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAbsoluteTime() {
|
||||
guard let player, let current = player.currentItem else { return }
|
||||
if isSeeking { return }
|
||||
if let idx = trackPlayerItems.firstIndex(where: { $0 === current }) {
|
||||
currentTrackIndex = idx
|
||||
}
|
||||
let trackTime = current.currentTime().seconds
|
||||
let prior = trackDurations.prefix(currentTrackIndex).reduce(0, +)
|
||||
let absolute = prior + (trackTime.isFinite ? trackTime : 0)
|
||||
let cap = totalDuration > 0 ? totalDuration : absolute
|
||||
absoluteCurrentTime = max(0, min(absolute, cap))
|
||||
let wasPlaying = isPlaying
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
if wasPlaying != isPlaying { updateNowPlayingInfo() }
|
||||
}
|
||||
|
||||
func teardown() {
|
||||
if let token = timeObserver { player?.removeTimeObserver(token) }
|
||||
timeObserver = nil
|
||||
for obs in endObservers { NotificationCenter.default.removeObserver(obs) }
|
||||
endObservers.removeAll()
|
||||
player?.pause()
|
||||
player?.removeAllItems()
|
||||
player = nil
|
||||
trackPlayerItems.removeAll()
|
||||
trackDurations.removeAll()
|
||||
isPlaying = false
|
||||
isReady = false
|
||||
absoluteCurrentTime = 0
|
||||
totalDuration = 0
|
||||
currentTrackIndex = 0
|
||||
itemId = nil
|
||||
errorMessage = nil
|
||||
isSeeking = false
|
||||
currentTitle = ""
|
||||
currentAuthor = ""
|
||||
currentCoverURL = nil
|
||||
artworkSession = nil
|
||||
clearNowPlayingInfo()
|
||||
}
|
||||
|
||||
// MARK: - Now-playing / remote commands
|
||||
|
||||
private func configureRemoteCommandsIfNeeded() {
|
||||
guard !remoteCommandsConfigured else { return }
|
||||
remoteCommandsConfigured = true
|
||||
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
|
||||
center.playCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.play() }
|
||||
return .success
|
||||
}
|
||||
center.pauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.pause() }
|
||||
return .success
|
||||
}
|
||||
center.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.togglePlay() }
|
||||
return .success
|
||||
}
|
||||
applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
||||
center.skipForwardCommand.addTarget { [weak self] _ in
|
||||
let s = Double(Self.currentSkipSeconds())
|
||||
Task { @MainActor in self?.skip(by: s) }
|
||||
return .success
|
||||
}
|
||||
center.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
let s = Double(Self.currentSkipSeconds())
|
||||
Task { @MainActor in self?.skip(by: -s) }
|
||||
return .success
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.applyRemoteSkipInterval(seconds: Self.currentSkipSeconds())
|
||||
}
|
||||
}
|
||||
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let posEvent = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .commandFailed
|
||||
}
|
||||
let target = posEvent.positionTime
|
||||
Task { @MainActor in self?.seekAbsolute(target) }
|
||||
return .success
|
||||
}
|
||||
center.changePlaybackRateCommand.supportedPlaybackRates = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
center.changePlaybackRateCommand.addTarget { [weak self] event in
|
||||
guard let rateEvent = event as? MPChangePlaybackRateCommandEvent else { return .commandFailed }
|
||||
Task { @MainActor in self?.setRate(rateEvent.playbackRate) }
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNowPlayingInfo() {
|
||||
guard itemId != nil else {
|
||||
clearNowPlayingInfo()
|
||||
return
|
||||
}
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: currentTitle,
|
||||
MPMediaItemPropertyArtist: currentAuthor,
|
||||
MPMediaItemPropertyPlaybackDuration: totalDuration,
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: absoluteCurrentTime,
|
||||
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? Double(rate) : 0.0,
|
||||
MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
|
||||
MPNowPlayingInfoPropertyMediaType: MPNowPlayingInfoMediaType.audio.rawValue,
|
||||
]
|
||||
if let existing = MPNowPlayingInfoCenter.default().nowPlayingInfo,
|
||||
let art = existing[MPMediaItemPropertyArtwork] {
|
||||
info[MPMediaItemPropertyArtwork] = art
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
|
||||
private func fetchAndAttachArtwork() {
|
||||
guard let url = currentCoverURL else { return }
|
||||
let session = artworkSession ?? URLSession.shared
|
||||
Task.detached {
|
||||
do {
|
||||
let (data, _) = try await session.data(from: url)
|
||||
guard let img = PlayerArtworkImage(data: data) else { return }
|
||||
let artwork = MPMediaItemArtwork(boundsSize: img.size) { _ in img }
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, self.currentCoverURL == url else { return }
|
||||
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private func clearNowPlayingInfo() {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
}
|
||||
|
||||
private func applyRemoteSkipInterval(seconds: Int) {
|
||||
let center = MPRemoteCommandCenter.shared()
|
||||
center.skipForwardCommand.preferredIntervals = [NSNumber(value: seconds)]
|
||||
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: seconds)]
|
||||
}
|
||||
|
||||
static func currentSkipSeconds() -> Int {
|
||||
let raw = UserDefaults.standard.integer(forKey: "skipDurationSeconds")
|
||||
return raw > 0 ? raw : 30
|
||||
}
|
||||
|
||||
// MARK: - iOS audio session observers
|
||||
|
||||
#if os(iOS)
|
||||
private func configureAudioSessionObserversIfNeeded() {
|
||||
guard !audioSessionObserversConfigured else { return }
|
||||
audioSessionObserversConfigured = true
|
||||
|
||||
let center = NotificationCenter.default
|
||||
|
||||
center.addObserver(
|
||||
forName: AVAudioSession.interruptionNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleAudioInterruption(notification: notification)
|
||||
}
|
||||
}
|
||||
|
||||
center.addObserver(
|
||||
forName: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance(),
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleRouteChange(notification: notification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAudioInterruption(notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let typeRaw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeRaw) else { return }
|
||||
|
||||
switch type {
|
||||
case .began:
|
||||
wasPlayingBeforeInterruption = isPlaying
|
||||
if isPlaying {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
updateNowPlayingInfo()
|
||||
}
|
||||
case .ended:
|
||||
let optionsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
|
||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsRaw)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
if wasPlayingBeforeInterruption && options.contains(.shouldResume) {
|
||||
play()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRouteChange(notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let reasonRaw = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonRaw) else { return }
|
||||
// Pause when headphones are unplugged (Apple's recommended behavior).
|
||||
if reason == .oldDeviceUnavailable {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ProgressSyncManager {
|
||||
private let client: ABSClient
|
||||
private(set) var queuedCount: Int = 0
|
||||
private(set) var lastSyncError: String?
|
||||
|
||||
/// Latest progress per itemId, persisted to disk.
|
||||
private var queue: [String: PlaybackProgress] = [:]
|
||||
|
||||
private let queueFile: URL
|
||||
|
||||
init(client: ABSClient) {
|
||||
self.client = client
|
||||
let dir = AppPaths.supportDirectory
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
self.queueFile = dir.appendingPathComponent("progress-queue.json")
|
||||
loadQueue()
|
||||
}
|
||||
|
||||
func report(itemId: String, episodeId: String? = nil, currentTime: Double, duration: Double, isFinished: Bool, isOnline: Bool) async {
|
||||
let progress = PlaybackProgress(
|
||||
itemId: itemId,
|
||||
episodeId: episodeId,
|
||||
currentTime: currentTime,
|
||||
duration: duration,
|
||||
isFinished: isFinished,
|
||||
updatedAt: Date()
|
||||
)
|
||||
let key = progress.syncKey
|
||||
|
||||
if isOnline {
|
||||
do {
|
||||
try await client.saveProgress(progress)
|
||||
queue.removeValue(forKey: key)
|
||||
persist()
|
||||
lastSyncError = nil
|
||||
return
|
||||
} catch {
|
||||
lastSyncError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
queue[key] = progress
|
||||
persist()
|
||||
}
|
||||
|
||||
func drain() async {
|
||||
guard !queue.isEmpty else { return }
|
||||
let snapshot = queue
|
||||
for (id, progress) in snapshot {
|
||||
do {
|
||||
try await client.saveProgress(progress)
|
||||
queue.removeValue(forKey: id)
|
||||
} catch {
|
||||
lastSyncError = error.localizedDescription
|
||||
break
|
||||
}
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
private func loadQueue() {
|
||||
guard let data = try? Data(contentsOf: queueFile),
|
||||
let decoded = try? JSONDecoder().decode([String: PlaybackProgress].self, from: data) else { return }
|
||||
queue = decoded
|
||||
queuedCount = decoded.count
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
queuedCount = queue.count
|
||||
do {
|
||||
let data = try JSONEncoder().encode(queue)
|
||||
try data.write(to: queueFile, options: .atomic)
|
||||
} catch {
|
||||
lastSyncError = "Queue konnte nicht gespeichert werden: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AppPaths {
|
||||
static var supportDirectory: URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
|
||||
return base.appendingPathComponent("AudiobookshelfClient", isDirectory: true)
|
||||
}
|
||||
|
||||
static var downloadsDirectory: URL {
|
||||
supportDirectory.appendingPathComponent("downloads", isDirectory: true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user