- 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>
99 lines
2.8 KiB
Swift
99 lines
2.8 KiB
Swift
import Foundation
|
|
|
|
struct Chapter: Codable, Identifiable, Hashable {
|
|
let id: Int
|
|
let start: Double
|
|
let end: Double
|
|
let title: String
|
|
}
|
|
|
|
struct Library: Codable, Identifiable, Hashable {
|
|
let id: String
|
|
let name: String
|
|
let mediaType: String?
|
|
}
|
|
|
|
/// Wichtig für `ForEach`: Synthetische Podcast-Episoden-Items (z.B. aus der
|
|
/// library-weiten Folgensuche) teilen sich denselben `id` (Podcast-Container-ID)
|
|
/// und unterscheiden sich nur über `episodeId`. Default-`Identifiable` würde
|
|
/// hier kollidieren. **Immer** `ForEach(items, id: \.syncKey)` benutzen wenn
|
|
/// die Liste sowohl Container als auch Episoden-Items enthalten kann.
|
|
struct LibraryItem: Codable, Identifiable, Hashable {
|
|
let id: String
|
|
let title: String
|
|
let author: String
|
|
let durationSeconds: Double
|
|
let audioFiles: [AudioFile]
|
|
var mediaType: String = "book"
|
|
var episodeId: String? = nil
|
|
var description: String? = nil
|
|
var chapters: [Chapter] = []
|
|
|
|
var isPodcast: Bool { mediaType == "podcast" }
|
|
var isPodcastContainer: Bool { isPodcast && episodeId == nil }
|
|
var isPodcastEpisode: Bool { isPodcast && episodeId != nil }
|
|
|
|
static func == (lhs: LibraryItem, rhs: LibraryItem) -> Bool {
|
|
lhs.id == rhs.id && lhs.episodeId == rhs.episodeId
|
|
}
|
|
func hash(into hasher: inout Hasher) {
|
|
hasher.combine(id)
|
|
hasher.combine(episodeId)
|
|
}
|
|
}
|
|
|
|
struct PodcastEpisode: Identifiable, Hashable, Codable {
|
|
let id: String
|
|
let title: String
|
|
let pubDate: String?
|
|
let publishedAtMillis: Double?
|
|
let season: String?
|
|
let episode: String?
|
|
let durationSeconds: Double
|
|
let audioFile: AudioFile
|
|
|
|
var formattedDate: String? {
|
|
if let ms = publishedAtMillis, ms > 0 {
|
|
let date = Date(timeIntervalSince1970: ms / 1000.0)
|
|
let df = DateFormatter()
|
|
df.dateStyle = .medium
|
|
df.timeStyle = .none
|
|
return df.string(from: date)
|
|
}
|
|
return pubDate
|
|
}
|
|
}
|
|
|
|
struct AudioFile: Codable, Hashable {
|
|
let ino: String
|
|
let filename: String
|
|
let ext: String
|
|
let durationSeconds: Double
|
|
let index: Int
|
|
}
|
|
|
|
struct PlaybackProgress: Codable, Hashable {
|
|
let itemId: String
|
|
var episodeId: String?
|
|
var currentTime: Double
|
|
var duration: Double
|
|
var isFinished: Bool
|
|
var updatedAt: Date
|
|
/// Server-assigned UUID for this progress entry. Required to delete it —
|
|
/// Audiobookshelf's `DELETE /api/me/progress/:id` matches by this id, NOT
|
|
/// by libraryItemId. Optional so older persisted caches still decode.
|
|
var id: String?
|
|
|
|
var syncKey: String {
|
|
if let episodeId { return "\(itemId)|\(episodeId)" }
|
|
return itemId
|
|
}
|
|
}
|
|
|
|
enum DownloadState: Equatable {
|
|
case notDownloaded
|
|
case downloading(progress: Double)
|
|
case downloaded
|
|
case failed(message: String)
|
|
}
|