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:
Scarriffle
2026-06-23 08:54:43 +02:00
parent 1dbd133b75
commit a17a61ad9a
20 changed files with 1423 additions and 267 deletions

View File

@@ -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 (`&uuml;`, `&euro;`, `&#8364;` 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] = [
"&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": "\"",
"&apos;": "'", "&nbsp;": " ",
"&euro;": "", "&pound;": "£", "&yen;": "¥",
"&copy;": "©", "&reg;": "®", "&trade;": "",
"&hellip;": "", "&mdash;": "", "&ndash;": "",
"&laquo;": "«", "&raquo;": "»",
"&bdquo;": "", "&ldquo;": "\u{201C}", "&rdquo;": "\u{201D}",
"&lsquo;": "\u{2018}", "&rsquo;": "\u{2019}",
"&auml;": "ä", "&Auml;": "Ä",
"&ouml;": "ö", "&Ouml;": "Ö",
"&uuml;": "ü", "&Uuml;": "Ü",
"&szlig;": "ß",
"&eacute;": "é", "&Eacute;": "É",
"&egrave;": "è", "&Egrave;": "È",
"&agrave;": "à", "&Agrave;": "À",
"&acirc;": "â", "&Acirc;": "Â",
"&ecirc;": "ê", "&Ecirc;": "Ê",
"&icirc;": "î", "&Icirc;": "Î",
"&ocirc;": "ô", "&Ocirc;": "Ô",
"&ucirc;": "û", "&Ucirc;": "Û",
"&ntilde;": "ñ", "&Ntilde;": "Ñ",
"&ccedil;": "ç", "&Ccedil;": "Ç",
]
for (entity, char) in named {
result = result.replacingOccurrences(of: entity, with: char)
}
// Numeric refs: &#1234; (decimal) or &#xABCD; (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
)
}
}

View File

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

View File

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

View File

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