Files
MobileMusicAssistant/Mobile Music Assistant/ViewsLibraryArtistDetailView.swift
T
2026-04-05 19:44:30 +02:00

305 lines
10 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.
//
// ArtistDetailView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 26.03.26.
//
import SwiftUI
struct ArtistDetailView: View {
@Environment(MAService.self) private var service
let artist: MAArtist
@State private var albums: [MAAlbum] = []
@State private var biography: String?
@State private var isLoading = true
@State private var errorMessage: String?
@State private var showError = false
@State private var kenBurnsScale: CGFloat = 1.0
@State private var isBiographyExpanded = false
var body: some View {
ZStack {
// Blurred Background with Ken Burns Effect
backgroundArtwork
// Content
ScrollView {
VStack(spacing: 24) {
// Artist Header
artistHeader
// Biography Section
if let biography {
biographySection(biography)
}
Divider()
.background(Color.white.opacity(0.3))
// Albums Section
if isLoading {
ProgressView()
.padding()
.tint(.white)
} else if albums.isEmpty {
Text("No albums found")
.foregroundStyle(.white.opacity(0.7))
.padding()
} else {
albumGrid
}
}
}
}
.navigationTitle(artist.name)
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.task {
async let albumsLoad: () = loadAlbums()
async let detailLoad: () = loadArtistDetail()
_ = await (albumsLoad, detailLoad)
// Start Ken Burns animation
withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) {
kenBurnsScale = 1.15
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage {
Text(errorMessage)
}
}
}
// MARK: - Background Artwork
@ViewBuilder
private var backgroundArtwork: some View {
GeometryReader { geometry in
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: geometry.size.width, height: geometry.size.height)
.scaleEffect(kenBurnsScale)
.blur(radius: 50)
.overlay {
// Dark gradient overlay for better text contrast
LinearGradient(
colors: [
Color.black.opacity(0.7),
Color.black.opacity(0.5),
Color.black.opacity(0.7)
],
startPoint: .top,
endPoint: .bottom
)
}
.clipped()
} placeholder: {
Rectangle()
.fill(
LinearGradient(
colors: [Color(.systemGray6), Color(.systemGray5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
Color.black.opacity(0.6)
}
}
}
.ignoresSafeArea()
}
// MARK: - Artist Header
@ViewBuilder
private var artistHeader: some View {
VStack(spacing: 16) {
CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 512)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.white.opacity(0.1))
.overlay {
Image(systemName: "music.mic")
.font(.system(size: 60))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(width: 200, height: 200)
.clipShape(Circle())
.shadow(color: .black.opacity(0.5), radius: 20, y: 10)
if !albums.isEmpty {
Text("\(albums.count) albums")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.white.opacity(0.8))
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
}
}
.padding(.top)
}
// MARK: - Album Grid
@ViewBuilder
private var albumGrid: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Albums")
.font(.title2.bold())
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
.padding(.horizontal)
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 160), spacing: 16)],
spacing: 16
) {
ForEach(albums) { album in
NavigationLink(value: album) {
ArtistAlbumCard(album: album, service: service)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.bottom, 24)
}
}
// MARK: - Biography Section
@ViewBuilder
private func biographySection(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("About")
.font(.title2.bold())
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1)
Text(verbatim: text)
.font(.body)
.foregroundStyle(.white.opacity(0.85))
.lineLimit(isBiographyExpanded ? nil : 4)
.lineSpacing(3)
Button {
withAnimation(.easeInOut(duration: 0.25)) {
isBiographyExpanded.toggle()
}
} label: {
Text(isBiographyExpanded ? "Show less" : "Show more")
.font(.subheadline.bold())
.foregroundStyle(.white.opacity(0.6))
}
}
.padding(.horizontal)
.frame(maxWidth: .infinity, alignment: .leading)
}
// MARK: - Actions
private func loadAlbums() async {
print("🔵 ArtistDetailView: Loading albums for artist: \(artist.name)")
print("🔵 ArtistDetailView: Artist URI: \(artist.uri)")
isLoading = true
errorMessage = nil
do {
albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri)
print("✅ ArtistDetailView: Loaded \(albums.count) albums")
isLoading = false
} catch {
print("❌ ArtistDetailView: Failed to load albums: \(error)")
errorMessage = error.localizedDescription
showError = true
isLoading = false
}
}
private func loadArtistDetail() async {
do {
let detail = try await service.getArtistDetail(artistUri: artist.uri)
if let desc = detail.metadata?.description, !desc.isEmpty {
biography = desc
}
} catch {
// Biography is optional silently ignore if unavailable
print("️ ArtistDetailView: Could not load artist detail: \(error)")
}
}
}
// MARK: - Artist Album Card
private struct ArtistAlbumCard: View {
let album: MAAlbum
let service: MAService
var body: some View {
VStack(alignment: .leading, spacing: 8) {
CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 256)) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 10)
.fill(Color.white.opacity(0.1))
.overlay {
Image(systemName: "opticaldisc")
.font(.system(size: 36))
.foregroundStyle(.white.opacity(0.5))
}
}
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(color: .black.opacity(0.4), radius: 8, y: 4)
Text(album.name)
.font(.caption.bold())
.lineLimit(2)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1)
if let year = album.year {
Text(String(year))
.font(.caption2)
.foregroundStyle(.white.opacity(0.7))
.shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1)
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.black.opacity(0.3))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
)
}
}
#Preview {
NavigationStack {
ArtistDetailView(
artist: MAArtist(
uri: "library://artist/1",
name: "Test Artist",
imageUrl: nil,
sortName: nil,
musicbrainzId: nil
)
)
.environment(MAService())
}
}