Files
ABS-Client/ABS Client/Audiobookshelf swift/Views/SplashView.swift
Scarriffle a17a61ad9a 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>
2026-06-23 08:54:43 +02:00

307 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
// 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 {
background
ZStack {
particleLayer
iconLayer
flashRing
}
.frame(width: 320, height: 320)
}
.opacity(splashOpacity)
.task { await runSplashAnimation() }
.onAppear {
// 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()
}
}