294 lines
12 KiB
Swift
294 lines
12 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
|
|
@ObservedObject private var libraryCache = 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)
|
|
.opacity(libraryCache.isServerAvailable ? 1.0 : 0.5)
|
|
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(album.name)
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(libraryCache.isServerAvailable ? .white : .gray.opacity(0.5))
|
|
.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(libraryCache.isServerAvailable ? .gray : .gray.opacity(0.4))
|
|
}
|
|
|
|
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: 80)
|
|
}
|
|
} 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: 80)
|
|
} 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 }
|
|
}
|
|
}
|
|
}
|
|
}
|