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

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