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