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

@@ -1,95 +1,306 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct SplashView: View {
@State private var appeared = false
// MARK: - App-Icon-Loading (cross-platform, kein Extra-Asset)
extension Image {
/// Lädt das echte AppIcon zur Laufzeit:
/// iOS via `CFBundleIcons` aus der Info.plist, macOS via
/// `NSApp.applicationIconImage`. Fällt auf ein SF-Symbol zurück wenn die
/// Plattform-spezifische Auflösung leer ist (z.B. Preview ohne Bundle).
static var appIcon: Image {
#if os(iOS)
if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any],
let primary = icons["CFBundlePrimaryIcon"] as? [String: Any],
let files = primary["CFBundleIconFiles"] as? [String],
let name = files.last,
let ui = UIImage(named: name) {
return Image(uiImage: ui)
}
return Image(systemName: "books.vertical.fill")
#else
if let ns = NSApp?.applicationIconImage {
return Image(nsImage: ns)
}
return Image(systemName: "books.vertical.fill")
#endif
}
}
// MARK: - Color-Helper für hellere/dunklere Akzent-Töne
private extension Color {
func lighter(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: amount) }
func darker(by amount: CGFloat = 0.2) -> Color { adjustBrightness(by: -amount) }
private func adjustBrightness(by amount: CGFloat) -> Color {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
#if os(iOS)
UIColor(self).getHue(&h, saturation: &s, brightness: &b, alpha: &a)
#else
(NSColor(self).usingColorSpace(.sRGB) ?? NSColor(self))
.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
#endif
return Color(hue: Double(h),
saturation: Double(s),
brightness: Double(min(1.0, max(0.0, b + amount))),
opacity: Double(a))
}
}
// MARK: - Partikel-Konfiguration
private struct ParticleConfig: Identifiable {
let id: Int
let startAngle: Double // 02π Position auf der Umlaufbahn bei t=0
let orbitRadius: CGFloat // 80150pt Radius der Umlaufbahn
let size: CGFloat // 4 / 7 / 10pt
let appearDelay: Double // 00.9s Stagger-Fade-In
let angularVelocity: Double // rad/s Umlaufgeschwindigkeit, Vorzeichen = Richtung
let radialWobble: CGFloat // 05pt sanftes Atmen des Radius
let wobbleFrequency: Double // 0.40.8 Hz
let wobblePhase: Double // 02π
let shade: ParticleShade
}
private enum ParticleShade {
case light, accent, dark
func color(base: Color) -> Color {
switch self {
case .light: return base.lighter(by: 0.25)
case .accent: return base
case .dark: return base.darker(by: 0.20)
}
}
}
/// Acht Partikel auf unterschiedlichen Umlaufbahnen. Reproduzierbar bei
/// jedem App-Start (kein Random). Drei Bahnen-Schichten (innen/mitte/außen)
/// plus gemischte Drehrichtungen (Vorzeichen von `angularVelocity`) damit
/// das Bild nicht wie ein gleichförmiges Karussell wirkt.
private let particleConfigs: [ParticleConfig] = [
.init(id: 0, startAngle: 0.45, orbitRadius: 88, size: 7, appearDelay: 0.05, angularVelocity: 0.32, radialWobble: 3, wobbleFrequency: 0.7, wobblePhase: 0.3, shade: .light),
.init(id: 1, startAngle: 1.20, orbitRadius: 130, size: 4, appearDelay: 0.30, angularVelocity: -0.22, radialWobble: 4, wobbleFrequency: 0.5, wobblePhase: 1.1, shade: .accent),
.init(id: 2, startAngle: 2.00, orbitRadius: 85, size: 10, appearDelay: 0.55, angularVelocity: 0.18, radialWobble: 2, wobbleFrequency: 0.4, wobblePhase: 2.4, shade: .dark),
.init(id: 3, startAngle: 2.80, orbitRadius: 115, size: 7, appearDelay: 0.20, angularVelocity: 0.28, radialWobble: 5, wobbleFrequency: 0.6, wobblePhase: 0.8, shade: .accent),
.init(id: 4, startAngle: 3.65, orbitRadius: 95, size: 4, appearDelay: 0.70, angularVelocity: -0.35, radialWobble: 3, wobbleFrequency: 0.55, wobblePhase: 3.2, shade: .light),
.init(id: 5, startAngle: 4.40, orbitRadius: 140, size: 10, appearDelay: 0.10, angularVelocity: -0.16, radialWobble: 4, wobbleFrequency: 0.35, wobblePhase: 4.0, shade: .dark),
.init(id: 6, startAngle: 5.20, orbitRadius: 100, size: 7, appearDelay: 0.45, angularVelocity: 0.24, radialWobble: 3, wobbleFrequency: 0.65, wobblePhase: 5.1, shade: .light),
.init(id: 7, startAngle: 5.80, orbitRadius: 120, size: 4, appearDelay: 0.85, angularVelocity: -0.30, radialWobble: 5, wobbleFrequency: 0.45, wobblePhase: 1.6, shade: .accent),
]
// MARK: - SplashScreenView
/// Vierphasiger Splash:
/// 1. Partikel erscheinen gestaffelt um das Icon (~1.2s)
/// 2. Partikel driften organisch in Loop bis `isReady` true wird (min 0.5s)
/// 3. Alle Partikel werden ins Icon gesogen (0.6s) mit kleinem Icon-Wobble
/// 4. Flash-Ring + Icon-Scale-Pulse (0.4s, iOS auch Haptic)
/// 5. Fade-Out (0.4s) `onComplete()`
struct SplashScreenView: View {
let isReady: Bool
let onComplete: () -> Void
private enum Phase { case appearing, idle, attracting, flash, fading }
@State private var phase: Phase = .appearing
@State private var particlesShown: Bool = false
@State private var particlesAttracted: Bool = false
@State private var iconScale: CGFloat = 1.0
@State private var iconWobble: CGFloat = 1.0
@State private var iconBrightness: Double = 0
@State private var ringScale: CGFloat = 1.0
@State private var ringOpacity: Double = 0
@State private var splashOpacity: Double = 1.0
/// Spiegel von `isReady` mit `@State`-Storage. Nötig weil die `.task`-
/// Closure die View-Struct beim ersten Erscheinen capturet und das
/// `let isReady` aus dem alten Snapshot liest das bleibt forever false
/// auch wenn der Parent neu rendert. `@State`-Storage dagegen ist
/// shared über Reconstructions hinweg, also sieht der laufende Task die
/// Aktualisierung sobald `onChange` sie hier reinpiped.
@State private var readySignaled: Bool = false
/// Einmalig festgelegt Basis für die TimelineView-Zeit. Als `@State`,
/// damit der Wert bei View-Re-Renders aus ContentView nicht versehentlich
/// auf jetzt" zurückgesetzt wird und die Drift-Bewegung dadurch springt.
@State private var referenceDate: Date = .init()
var body: some View {
ZStack {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
VStack(spacing: 36) {
// Animated icon
ZStack {
// Outer glow pulse
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 180, height: 180)
.scaleEffect(appeared ? 1.0 : 0.2)
.blur(radius: appeared ? 12 : 40)
.animation(.easeOut(duration: 1.1), value: appeared)
// Ring border
Circle()
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
.frame(width: 130, height: 130)
.scaleEffect(appeared ? 1.0 : 0.4)
.opacity(appeared ? 1 : 0)
.animation(.easeOut(duration: 0.8).delay(0.1), value: appeared)
// Book icon springs into place
Image(systemName: "books.vertical.fill")
.font(.system(size: 58, weight: .regular))
.foregroundStyle(Color.accentColor)
.scaleEffect(appeared ? 1.0 : 0.1)
.opacity(appeared ? 1 : 0)
.animation(.spring(duration: 0.65, bounce: 0.55), value: appeared)
.symbolEffect(.pulse.byLayer,
options: .speed(0.5).repeating,
value: appeared)
}
// Text
VStack(spacing: 6) {
Text("ABS Client")
.font(.title2.bold())
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 18)
.animation(.easeOut(duration: 0.5).delay(0.28), value: appeared)
Text("Audiobookshelf")
.font(.subheadline)
.foregroundStyle(.secondary)
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 10)
.animation(.easeOut(duration: 0.45).delay(0.42), value: appeared)
}
}
// Loading dots at bottom
VStack {
Spacer()
LoadingDots()
.opacity(appeared ? 1 : 0)
.animation(.easeIn(duration: 0.3).delay(0.65), value: appeared)
.padding(.bottom, 60)
}
}
.onAppear { appeared = true }
}
}
private struct LoadingDots: View {
@State private var phase: Int = 0
var body: some View {
HStack(spacing: 7) {
ForEach(0..<3, id: \.self) { i in
Circle()
.fill(Color.accentColor.opacity(phase == i ? 0.9 : 0.3))
.frame(width: 7, height: 7)
.scaleEffect(phase == i ? 1.25 : 1.0)
.animation(.easeInOut(duration: 0.35), value: phase)
background
ZStack {
particleLayer
iconLayer
flashRing
}
.frame(width: 320, height: 320)
}
.opacity(splashOpacity)
.task { await runSplashAnimation() }
.onAppear {
Timer.scheduledTimer(withTimeInterval: 0.38, repeats: true) { _ in
phase = (phase + 1) % 3
// Falls der Parent isReady bereits beim ersten Render gesetzt hat
// (z.B. weil bootstrap super-schnell durchlief), spiegeln wir den
// Anfangswert direkt ohne dass eine onChange-Flanke nötig wäre.
if isReady { readySignaled = true }
}
.onChange(of: isReady) { _, newValue in
if newValue { readySignaled = true }
}
}
// MARK: Background
@ViewBuilder
private var background: some View {
#if os(iOS)
Color(.systemBackground).ignoresSafeArea()
#else
Color(NSColor.windowBackgroundColor).ignoresSafeArea()
#endif
}
// MARK: Icon
private var iconLayer: some View {
Image.appIcon
.resizable()
.frame(width: 96, height: 96)
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
.shadow(color: .black.opacity(0.15), radius: 8, y: 3)
.scaleEffect(iconScale * iconWobble)
.brightness(iconBrightness)
}
// MARK: Flash-Ring
private var flashRing: some View {
Circle()
.stroke(Color.white, lineWidth: 4)
.frame(width: 96, height: 96)
.scaleEffect(ringScale)
.opacity(ringOpacity)
.allowsHitTesting(false)
}
// MARK: Partikel
@ViewBuilder
private var particleLayer: some View {
// TimelineView läuft während `.appearing` und `.idle`, damit die
// Partikel schon beim Stagger-Fade-In leicht driften (sonst gäb's
// einen sichtbaren Positions-Sprung beim Übergang zu `.idle`).
// Pausiert ab `.attracting` die gefrorene Position dient als
// Startpunkt für die Attract-Animation Richtung .zero.
let paused = (phase == .attracting || phase == .flash || phase == .fading)
TimelineView(.animation(paused: paused)) { context in
let t = context.date.timeIntervalSince(referenceDate)
ZStack {
ForEach(particleConfigs) { cfg in
particleView(cfg: cfg, t: t)
}
}
}
}
@ViewBuilder
private func particleView(cfg: ParticleConfig, t: TimeInterval) -> some View {
let drifted = idlePosition(for: cfg, t: t)
let position = particlesAttracted ? CGPoint.zero : drifted
let scale: CGFloat = particlesAttracted ? 0.0 : 1.0
// Wichtig: NICHT `Color.accentColor` (semantisch konvertiert via
// UIColor unzuverlässig zum echten Asset-Wert, gibt manchmal den
// System-Tint = blau zurück). Direkt aus dem Asset-Catalog lesen.
Circle()
.fill(cfg.shade.color(base: Color("AccentColor")))
.frame(width: cfg.size, height: cfg.size)
.opacity(particlesShown ? 1.0 : 0.0)
.scaleEffect(scale)
.offset(x: position.x, y: position.y)
// Stagger-Fade-In: jeder Partikel mit eigener Verzögerung
.animation(.easeOut(duration: 0.5).delay(cfg.appearDelay), value: particlesShown)
// Attract: 0.6s easeIn ins Zentrum, gleichzeitig auf scale=0
.animation(.easeIn(duration: 0.6), value: particlesAttracted)
}
private func idlePosition(for cfg: ParticleConfig, t: TimeInterval) -> CGPoint {
// Echte Umlaufbahn: Winkel wächst mit t (Vorzeichen = Drehrichtung).
// Radius "atmet" leicht über sinusförmiges radialWobble damit die
// Bahn nicht maschinell perfekt-kreisförmig wirkt.
let angle = cfg.startAngle + t * cfg.angularVelocity
let wobble = sin(t * cfg.wobbleFrequency + cfg.wobblePhase) * Double(cfg.radialWobble)
let radius = Double(cfg.orbitRadius) + wobble
return CGPoint(x: cos(angle) * radius, y: sin(angle) * radius)
}
// MARK: Animation-Orchestrierung
private func runSplashAnimation() async {
// Phase 1a Partikel erscheinen gestaffelt
particlesShown = true
// Warten bis alle Partikel sichtbar sind (max appearDelay 0.85 + fade 0.5)
try? await Task.sleep(for: .milliseconds(1400))
if Task.isCancelled { return }
phase = .idle
// Phase 1b Drift-Loop bis readySignaled UND min 500ms in idle.
// Wichtig: NICHT `isReady` lesen siehe Doc-Kommentar an readySignaled.
let idleStart = Date()
let minIdleDuration: TimeInterval = 0.5
while !readySignaled || Date().timeIntervalSince(idleStart) < minIdleDuration {
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
}
// Phase 2 Sog ins Icon-Zentrum
phase = .attracting
withAnimation(.easeIn(duration: 0.6)) {
particlesAttracted = true
}
// Bei ~400ms in die Attract-Phase ein kleiner Icon-Wobble als die
// Partikel ankommen fühlt sich an wie der Impact.
try? await Task.sleep(for: .milliseconds(400))
if Task.isCancelled { return }
withAnimation(.easeOut(duration: 0.1)) { iconWobble = 1.06 }
try? await Task.sleep(for: .milliseconds(100))
withAnimation(.easeIn(duration: 0.1)) { iconWobble = 1.0 }
try? await Task.sleep(for: .milliseconds(100))
if Task.isCancelled { return }
// Phase 3 Flash + Scale-Pulse + Haptic
phase = .flash
#if os(iOS)
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
#endif
// Ring: 1.0 2.5×, opacity 1 0 (0.4s easeOut)
ringScale = 1.0
ringOpacity = 1.0
withAnimation(.easeOut(duration: 0.4)) {
ringScale = 2.5
ringOpacity = 0
}
// Icon-Brightness: schnell hoch, langsamer runter
withAnimation(.easeOut(duration: 0.15)) { iconBrightness = 0.6 }
// Parallel: Icon-Scale-Pulse 1.0 1.15 1.0
withAnimation(.easeOut(duration: 0.2)) { iconScale = 1.15 }
try? await Task.sleep(for: .milliseconds(200))
if Task.isCancelled { return }
withAnimation(.easeIn(duration: 0.25)) { iconBrightness = 0 }
withAnimation(.easeIn(duration: 0.2)) { iconScale = 1.0 }
try? await Task.sleep(for: .milliseconds(200))
if Task.isCancelled { return }
// Phase 4 Fade
phase = .fading
withAnimation(.easeOut(duration: 0.4)) {
splashOpacity = 0
}
try? await Task.sleep(for: .milliseconds(420))
onComplete()
}
}