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:
@@ -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 // 0…2π — Position auf der Umlaufbahn bei t=0
|
||||
let orbitRadius: CGFloat // 80…150pt — Radius der Umlaufbahn
|
||||
let size: CGFloat // 4 / 7 / 10pt
|
||||
let appearDelay: Double // 0…0.9s — Stagger-Fade-In
|
||||
let angularVelocity: Double // rad/s — Umlaufgeschwindigkeit, Vorzeichen = Richtung
|
||||
let radialWobble: CGFloat // 0…5pt — sanftes Atmen des Radius
|
||||
let wobbleFrequency: Double // 0.4…0.8 Hz
|
||||
let wobblePhase: Double // 0…2π
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user