NavidromeApp/iOS/Views/Library/ArtistDetailView.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

292 lines
11 KiB
Swift

import SwiftUI
// MARK: - Artist Detail View
struct ArtistDetailView: View {
@EnvironmentObject var serverManager: ServerManager
let artistId: String
@State private var artist: ArtistWithAlbums?
@State private var isLoading = true
@State private var loadFailed = false
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
private let cache = LibraryCache.shared
var body: some View {
ScrollView {
if let artist = artist {
VStack(spacing: 0) {
// Artist header
VStack(spacing: 12) {
AsyncCoverArt(
coverArtId: artist.coverArt,
size: 200
)
.frame(width: 160, height: 160)
.clipShape(Circle())
.shadow(color: .black.opacity(0.4), radius: 12)
.padding(.top, 20)
Text(artist.name)
.font(.system(size: 24, weight: .bold))
.foregroundColor(.white)
Text("\(artist.albumCount ?? artist.album?.count ?? 0) Albums")
.font(.system(size: 14))
.foregroundColor(.gray)
}
.padding(.bottom, 24)
// Albums
LazyVStack(spacing: 0) {
ForEach(artist.album ?? []) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
HStack(spacing: 14) {
AsyncCoverArt(
coverArtId: album.coverArt,
size: 60
)
.frame(width: 56, height: 56)
.cornerRadius(4)
VStack(alignment: .leading, spacing: 3) {
Text(album.name)
.font(.system(size: 16, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
HStack(spacing: 4) {
if let year = album.year {
Text(String(year))
}
if let count = album.songCount {
Text("\(count) songs")
}
}
.font(.system(size: 12))
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12))
.foregroundColor(.gray)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
Divider()
.background(Color.white.opacity(0.08))
.padding(.leading, 86)
}
}
Color.clear.frame(height: 120)
}
} else if loadFailed {
// Connection failed show retry
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load artist")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadArtist() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(accentPink)
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(accentPink)
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationBarTitleDisplayMode(.inline)
.task { await loadArtist() }
}
private func loadArtist() async {
loadFailed = false
// 1. Load from cache instantly no spinner if we have data
if artist == nil, let cached = cache.loadArtistDetail(id: artistId) {
artist = cached
isLoading = false
}
// 2. Fetch from server in background
do {
if let fetched = try await serverManager.client.getArtist(id: artistId) {
cache.cacheArtistDetail(fetched)
await MainActor.run {
self.artist = fetched
self.isLoading = false
}
} else if artist == nil {
// Server returned nil and no cache wait for connection and retry
await retryAfterConnection()
} else {
isLoading = false
}
} catch {
if artist == nil {
// No cache, fetch failed try waiting for connection
await retryAfterConnection()
} else {
// We have cache, just stop loading
isLoading = false
}
}
}
/// Wait for server connection to establish, then retry once
private func retryAfterConnection() async {
// If server is still connecting, wait up to 5 seconds
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
// One more attempt
do {
if let fetched = try await serverManager.client.getArtist(id: artistId) {
cache.cacheArtistDetail(fetched)
await MainActor.run {
self.artist = fetched
self.isLoading = false
}
return
}
} catch { }
// Truly failed
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
}
// MARK: - Genre Detail View
struct GenreDetailView: View {
@EnvironmentObject var serverManager: ServerManager
let genre: Genre
@State private var albums: [Album] = []
@State private var isLoading = true
@State private var loadFailed = false
private let columns = [
GridItem(.adaptive(minimum: 160), spacing: 14)
]
var body: some View {
ScrollView {
if !albums.isEmpty {
LazyVGrid(columns: columns, spacing: 18) {
ForEach(albums) { album in
NavigationLink(destination: AlbumDetailView(albumId: album.id)) {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverArt(coverArtId: album.coverArt, size: 180)
.frame(height: 160)
.cornerRadius(4)
Text(album.name)
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Text(album.artist ?? "")
.font(.system(size: 11))
.foregroundColor(.gray)
.lineLimit(1)
}
}
}
}
.padding(16)
Color.clear.frame(height: 120)
} else if loadFailed {
VStack(spacing: 16) {
Image(systemName: "wifi.slash")
.font(.system(size: 32))
.foregroundColor(.gray)
Text("Couldn't load genre")
.font(.system(size: 15))
.foregroundColor(.gray)
Button(action: { Task { await loadGenre() } }) {
Text("Retry")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color(red: 1.0, green: 0.176, blue: 0.333))
}
}
.padding(.top, 100)
} else if isLoading {
ProgressView()
.tint(Color(red: 1.0, green: 0.176, blue: 0.333))
.padding(.top, 100)
}
}
.background(Color(white: 0.06))
.navigationTitle(genre.value)
.task { await loadGenre() }
}
private func loadGenre() async {
let cache = LibraryCache.shared
loadFailed = false
// Cache first
if albums.isEmpty, let cached = cache.load([Album].self, key: "genre_\(genre.value)") {
albums = cached
isLoading = false
}
// Server fetch
do {
let result = try await serverManager.client.getAlbumList2(
type: "byGenre", size: 100, genre: genre.value
)
cache.save(result, key: "genre_\(genre.value)")
await MainActor.run {
self.albums = result
self.isLoading = false
}
} catch {
if albums.isEmpty {
// Wait for connection
for _ in 0..<10 {
if serverManager.connectionState == .connected { break }
try? await Task.sleep(for: .milliseconds(500))
}
do {
let result = try await serverManager.client.getAlbumList2(
type: "byGenre", size: 100, genre: genre.value
)
cache.save(result, key: "genre_\(genre.value)")
await MainActor.run {
self.albums = result
self.isLoading = false
}
} catch {
await MainActor.run {
self.isLoading = false
self.loadFailed = true
}
}
} else {
await MainActor.run { self.isLoading = false }
}
}
}
}