Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library - Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library - Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail - Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished - Add AirPlay picker in PlayerBar - Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup - Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s - Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id) - Bump CFBundleVersion to 8 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,7 @@ final class ABSClient {
|
||||
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) }
|
||||
return dto.libraries.map { Library(id: $0.id, name: Self.decodeHTMLEntities($0.name), mediaType: $0.mediaType) }
|
||||
}
|
||||
|
||||
func fetchItems(libraryId: String) async throws -> [LibraryItem] {
|
||||
@@ -84,6 +84,62 @@ final class ABSClient {
|
||||
return dto.results.map { Self.toLibraryItem(from: $0) }
|
||||
}
|
||||
|
||||
/// Audiobookshelf serves podcast titles straight from the RSS feed, which
|
||||
/// often contains HTML entities (`ü`, `€`, `€` etc.).
|
||||
/// SwiftUI renders those literally, so we decode them here once at the
|
||||
/// API boundary — every title/author/chapter/library name goes through
|
||||
/// this before reaching the UI.
|
||||
static func decodeHTMLEntities(_ s: String) -> String {
|
||||
guard s.contains("&") else { return s }
|
||||
var result = s
|
||||
let named: [String: String] = [
|
||||
"&": "&", "<": "<", ">": ">", """: "\"",
|
||||
"'": "'", " ": " ",
|
||||
"€": "€", "£": "£", "¥": "¥",
|
||||
"©": "©", "®": "®", "™": "™",
|
||||
"…": "…", "—": "—", "–": "–",
|
||||
"«": "«", "»": "»",
|
||||
"„": "„", "“": "\u{201C}", "”": "\u{201D}",
|
||||
"‘": "\u{2018}", "’": "\u{2019}",
|
||||
"ä": "ä", "Ä": "Ä",
|
||||
"ö": "ö", "Ö": "Ö",
|
||||
"ü": "ü", "Ü": "Ü",
|
||||
"ß": "ß",
|
||||
"é": "é", "É": "É",
|
||||
"è": "è", "È": "È",
|
||||
"à": "à", "À": "À",
|
||||
"â": "â", "Â": "Â",
|
||||
"ê": "ê", "Ê": "Ê",
|
||||
"î": "î", "Î": "Î",
|
||||
"ô": "ô", "Ô": "Ô",
|
||||
"û": "û", "Û": "Û",
|
||||
"ñ": "ñ", "Ñ": "Ñ",
|
||||
"ç": "ç", "Ç": "Ç",
|
||||
]
|
||||
for (entity, char) in named {
|
||||
result = result.replacingOccurrences(of: entity, with: char)
|
||||
}
|
||||
// Numeric refs: Ӓ (decimal) or ꯍ (hex)
|
||||
if result.contains("&#"),
|
||||
let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") {
|
||||
let nsRange = NSRange(result.startIndex..., in: result)
|
||||
// Reverse so prior replacements don't shift later ranges.
|
||||
let matches = regex.matches(in: result, range: nsRange).reversed()
|
||||
for match in matches {
|
||||
guard let full = Range(match.range, in: result),
|
||||
let hexFlag = Range(match.range(at: 1), in: result),
|
||||
let num = Range(match.range(at: 2), in: result) else { continue }
|
||||
let isHex = !result[hexFlag].isEmpty
|
||||
let numStr = String(result[num])
|
||||
let code = isHex ? UInt32(numStr, radix: 16) : UInt32(numStr)
|
||||
if let code, let scalar = Unicode.Scalar(code) {
|
||||
result.replaceSubrange(full, with: String(scalar))
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func toLibraryItem(from raw: LibraryItemDTO) -> LibraryItem {
|
||||
let meta = raw.media?.metadata
|
||||
let mediaType = raw.mediaType ?? "book"
|
||||
@@ -99,16 +155,16 @@ final class ABSClient {
|
||||
let authorString = meta?.authorName ?? meta?.author ?? (mediaType == "podcast" ? "Podcast" : "Unbekannter Autor")
|
||||
var item = LibraryItem(
|
||||
id: raw.id,
|
||||
title: meta?.title ?? "Unbekannt",
|
||||
author: authorString,
|
||||
title: decodeHTMLEntities(meta?.title ?? "Unbekannt"),
|
||||
author: decodeHTMLEntities(authorString),
|
||||
durationSeconds: raw.media?.duration ?? 0,
|
||||
audioFiles: files
|
||||
)
|
||||
item.mediaType = mediaType
|
||||
item.description = meta?.description
|
||||
item.description = meta?.description.map { decodeHTMLEntities($0) }
|
||||
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 Chapter(id: id, start: start, end: end, title: decodeHTMLEntities(title))
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -129,7 +185,7 @@ final class ABSClient {
|
||||
)
|
||||
return PodcastEpisode(
|
||||
id: ep.id,
|
||||
title: ep.title ?? "Folge",
|
||||
title: Self.decodeHTMLEntities(ep.title ?? "Folge"),
|
||||
pubDate: ep.pubDate,
|
||||
publishedAtMillis: ep.publishedAt,
|
||||
season: ep.season,
|
||||
@@ -167,7 +223,8 @@ final class ABSClient {
|
||||
currentTime: dto.currentTime ?? 0,
|
||||
duration: dto.duration ?? 0,
|
||||
isFinished: dto.isFinished ?? false,
|
||||
updatedAt: Self.parseLastUpdate(dto.lastUpdate)
|
||||
updatedAt: Self.parseLastUpdate(dto.lastUpdate),
|
||||
id: dto.id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -194,6 +251,26 @@ final class ABSClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a media-progress entry on the server. Audiobookshelf's
|
||||
/// `DELETE /api/me/progress/:id` matches by the progress entry's UUID,
|
||||
/// NOT by libraryItemId (which is what PATCH uses) — this is the source
|
||||
/// of the long-running "delete didn't stick" bug. 404 is treated as
|
||||
/// success (entry already gone).
|
||||
func deleteProgress(progressId: String) async throws {
|
||||
let req = try makeRequest(
|
||||
path: "/api/me/progress/\(progressId)",
|
||||
method: "DELETE"
|
||||
)
|
||||
let (_, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw ABSClientError.httpStatus(0)
|
||||
}
|
||||
if http.statusCode == 404 { return }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw ABSClientError.httpStatus(http.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAllProgress() async throws -> [PlaybackProgress] {
|
||||
let req = try makeRequest(path: "/api/me")
|
||||
let dto = try await perform(req, as: MeResponseDTO.self)
|
||||
@@ -205,7 +282,8 @@ final class ABSClient {
|
||||
currentTime: p.currentTime ?? 0,
|
||||
duration: p.duration ?? 0,
|
||||
isFinished: p.isFinished ?? false,
|
||||
updatedAt: Self.parseLastUpdate(p.lastUpdate)
|
||||
updatedAt: Self.parseLastUpdate(p.lastUpdate),
|
||||
id: p.id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ final class AppState {
|
||||
/// Used to show progress bars on covers in the library views.
|
||||
var progressCache: [String: PlaybackProgress] = [:]
|
||||
|
||||
/// Cached LibraryItem details (cover, title, author, chapters) per syncKey.
|
||||
/// Populated whenever the user actually plays an item, persisted to disk so
|
||||
/// the Home screen can show "recently played" cards on first launch without
|
||||
/// any network round-trip.
|
||||
var recentItemsCache: [String: LibraryItem] = [:]
|
||||
|
||||
/// Server-progress that's newer than local but we haven't applied yet because
|
||||
/// playback is active/paused. Offered via alert on next play; cleared on
|
||||
/// item change.
|
||||
@@ -70,6 +76,7 @@ final class AppState {
|
||||
await NotificationManager.shared.requestPermissionIfNeeded()
|
||||
#endif
|
||||
auth.restoreSession()
|
||||
loadRecentItemsCache()
|
||||
network.start { [weak self] online in
|
||||
guard let self else { return }
|
||||
if online {
|
||||
@@ -220,6 +227,10 @@ final class AppState {
|
||||
}
|
||||
|
||||
currentItem = workItem
|
||||
// Cache full item details for the Home screen — we paid the
|
||||
// fetchItemDetail cost above anyway, no reason to redo it later.
|
||||
recentItemsCache[workItem.syncKey] = workItem
|
||||
persistRecentItems()
|
||||
player.load(item: workItem, client: client, downloads: downloads, startAt: startAt)
|
||||
if player.errorMessage == nil {
|
||||
player.play()
|
||||
@@ -244,6 +255,52 @@ final class AppState {
|
||||
await play(item: item, overrideStartAt: entry.position)
|
||||
}
|
||||
|
||||
/// Removes a media-progress entry from the server AND the local caches
|
||||
/// (`progressCache`, `recentItemsCache`, pending sync queue). The Home
|
||||
/// screen reacts immediately because it reads from `progressCache`. If
|
||||
/// the server call fails we still clear the local cache — the next
|
||||
/// `refreshProgressCache()` will re-sync from authoritative state (and
|
||||
/// the entry would come back if the server delete didn't go through).
|
||||
func removeProgress(itemId: String, episodeId: String? = nil) async {
|
||||
let key: String
|
||||
if let episodeId { key = "\(itemId)|\(episodeId)" } else { key = itemId }
|
||||
|
||||
// Grab the server-assigned progress UUID BEFORE we wipe the cache —
|
||||
// Audiobookshelf's DELETE endpoint matches by this UUID, not by
|
||||
// libraryItemId, so we need it to actually hit the right entry.
|
||||
var progressId = progressCache[key]?.id
|
||||
|
||||
progressCache.removeValue(forKey: key)
|
||||
recentItemsCache.removeValue(forKey: key)
|
||||
persistRecentItems()
|
||||
|
||||
guard network.isOnline else { return }
|
||||
|
||||
// Cache might be from before the `id` field was added — fall back to
|
||||
// a fresh fetchProgress to obtain it.
|
||||
if progressId == nil {
|
||||
progressId = (try? await client.fetchProgress(itemId: itemId, episodeId: episodeId))?.id
|
||||
}
|
||||
guard let pid = progressId else { return }
|
||||
try? await client.deleteProgress(progressId: pid)
|
||||
}
|
||||
|
||||
/// Plays an item from the Home screen / recent-items cache. Books resume
|
||||
/// via the normal flow, but podcasts need an extra step: the cached
|
||||
/// LibraryItem carries an `episodeId` but no `audioFiles` (those live on
|
||||
/// the PodcastEpisode, not the podcast container). We fetch the episode
|
||||
/// list, locate the matching one, and hand off to `play(podcast:episode:)`.
|
||||
func playFromRecents(_ item: LibraryItem) async {
|
||||
guard item.isPodcastEpisode, let episodeId = item.episodeId else {
|
||||
await play(item: item)
|
||||
return
|
||||
}
|
||||
guard network.isOnline,
|
||||
let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: item.id),
|
||||
let episode = episodes.first(where: { $0.id == episodeId }) else { return }
|
||||
await play(podcast: podcast, episode: episode)
|
||||
}
|
||||
|
||||
/// Convenience for podcast episodes.
|
||||
func play(podcast: LibraryItem, episode: PodcastEpisode) async {
|
||||
var synthetic = LibraryItem(
|
||||
@@ -323,6 +380,105 @@ final class AppState {
|
||||
syncTimer = timer
|
||||
}
|
||||
|
||||
// MARK: - Recent items cache
|
||||
|
||||
private var recentItemsFile: URL {
|
||||
AppPaths.supportDirectory.appendingPathComponent("recent-items.json")
|
||||
}
|
||||
|
||||
func loadRecentItemsCache() {
|
||||
guard let data = try? Data(contentsOf: recentItemsFile),
|
||||
let decoded = try? JSONDecoder().decode([String: LibraryItem].self, from: data) else { return }
|
||||
recentItemsCache = decoded
|
||||
}
|
||||
|
||||
private func persistRecentItems() {
|
||||
// Cap at 100 most-recently-touched syncKeys (based on progressCache) so
|
||||
// the file doesn't grow unbounded for users with massive listening history.
|
||||
let keep = Set(progressCache.values
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.prefix(100)
|
||||
.map { $0.syncKey })
|
||||
let trimmed = recentItemsCache.filter { keep.contains($0.key) || recentItemsCache.count <= 100 }
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: AppPaths.supportDirectory, withIntermediateDirectories: true)
|
||||
let data = try JSONEncoder().encode(trimmed)
|
||||
try data.write(to: recentItemsFile, options: .atomic)
|
||||
recentItemsCache = trimmed
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills `recentItemsCache` for the top-N most recent progress entries that
|
||||
/// don't yet have details. Pulls from downloaded items first (no network),
|
||||
/// then `fetchItemDetail` for the rest. Called by HomeView on appear.
|
||||
func fillRecentItemsCache(limit: Int = 20) async {
|
||||
let topKeys = progressCache.values
|
||||
.filter { !$0.isFinished }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
.prefix(limit)
|
||||
.map { $0.syncKey }
|
||||
|
||||
var newlyAdded = false
|
||||
for key in topKeys {
|
||||
// Skip if already cached AND has audio files. A cached podcast
|
||||
// item with empty audioFiles is a stale entry from the old
|
||||
// fillRecentItemsCache path (fetchItemDetail returned a container,
|
||||
// not an episode) — re-fetch via fetchEpisodes below.
|
||||
if let existing = recentItemsCache[key], !existing.audioFiles.isEmpty { continue }
|
||||
// 1) Try downloaded items first
|
||||
if let downloaded = downloads.downloadedItems[key] {
|
||||
let files: [AudioFile] = downloaded.tracks.enumerated().map { idx, t in
|
||||
AudioFile(ino: t.ino, filename: t.filename, ext: "", durationSeconds: t.durationSeconds, index: idx)
|
||||
}
|
||||
var li = LibraryItem(
|
||||
id: downloaded.itemId,
|
||||
title: downloaded.title,
|
||||
author: downloaded.author,
|
||||
durationSeconds: downloaded.durationSeconds,
|
||||
audioFiles: files
|
||||
)
|
||||
if let episodeId = downloaded.episodeId {
|
||||
li.mediaType = "podcast"
|
||||
li.episodeId = episodeId
|
||||
}
|
||||
recentItemsCache[key] = li
|
||||
newlyAdded = true
|
||||
continue
|
||||
}
|
||||
// 2) Fetch from server
|
||||
guard network.isOnline,
|
||||
let progress = progressCache[key] else { continue }
|
||||
|
||||
if let episodeId = progress.episodeId {
|
||||
// Podcast episode: fetch episodes and synthesize a LibraryItem
|
||||
// that carries the episode's title + audioFile (analogous to
|
||||
// `play(podcast:episode:)`). Without this the cached item has
|
||||
// the podcast container's title, not the episode title.
|
||||
guard let (podcast, episodes) = try? await client.fetchEpisodes(podcastItemId: progress.itemId),
|
||||
let episode = episodes.first(where: { $0.id == episodeId }) else { continue }
|
||||
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
|
||||
recentItemsCache[key] = synthetic
|
||||
newlyAdded = true
|
||||
} else {
|
||||
// Audiobook
|
||||
guard let detail = try? await client.fetchItemDetail(itemId: progress.itemId) else { continue }
|
||||
recentItemsCache[key] = detail
|
||||
newlyAdded = true
|
||||
}
|
||||
}
|
||||
if newlyAdded { persistRecentItems() }
|
||||
}
|
||||
|
||||
private func reportProgress(force: Bool) {
|
||||
guard let item = currentItem else { return }
|
||||
let t = player.absoluteCurrentTime
|
||||
|
||||
@@ -146,20 +146,23 @@ final class DownloadManager: NSObject, @unchecked Sendable {
|
||||
let config = URLSessionConfiguration.background(withIdentifier: identifier)
|
||||
config.sessionSendsLaunchEvents = true
|
||||
config.isDiscretionary = false
|
||||
// Tells iOS this is interactive (vs. opportunistic bulk transfer) so
|
||||
// it scheduling gives us closer-to-foreground throughput. On `.background`
|
||||
// sessions this is mostly advisory but still respected for prioritization.
|
||||
config.networkServiceType = .responsiveData
|
||||
// Allow up to 6 parallel HTTPS connections to the ABS host — matches the
|
||||
// default for foreground sessions and helps multi-track books download
|
||||
// faster instead of being serialized to 1 connection.
|
||||
config.httpMaximumConnectionsPerHost = 6
|
||||
#else
|
||||
// macOS hat keinen Background-Session-Daemon mit Auto-Retry —
|
||||
// gleiche Pool-/Service-Settings wie iOS, sonst hängt sich ein
|
||||
// parallel laufender Download bei geteilter Bandbreite zu lange ohne
|
||||
// Bytes auf und failed mit Timeout.
|
||||
let config = URLSessionConfiguration.default
|
||||
config.networkServiceType = .responsiveData
|
||||
config.httpMaximumConnectionsPerHost = 6
|
||||
#endif
|
||||
config.allowsCellularAccess = true
|
||||
config.waitsForConnectivity = true
|
||||
config.timeoutIntervalForRequest = 60
|
||||
// 300s statt 60s: bei zwei parallelen großen Tracks kann eine Task
|
||||
// temporär für deutlich mehr als 60s ohne Bytes bleiben, besonders
|
||||
// auf macOS-Foreground-Sessions ohne iOS-Background-Retry-Logik.
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 60 * 60 * 24 // 24h — big audiobooks
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,13 @@ final class PlayerEngine {
|
||||
|
||||
let queue = AVQueuePlayer(items: trackPlayerItems)
|
||||
queue.rate = rate
|
||||
// Enable AirPlay / Bluetooth / CarPlay routing. Without this, AVPlayer
|
||||
// refuses to send audio to external devices even when the user picks
|
||||
// them in Control Center / the route picker.
|
||||
queue.allowsExternalPlayback = true
|
||||
#if os(iOS)
|
||||
queue.usesExternalPlaybackWhileExternalScreenIsActive = true
|
||||
#endif
|
||||
self.player = queue
|
||||
|
||||
let center = NotificationCenter.default
|
||||
|
||||
Reference in New Issue
Block a user