// // 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 @State private var kenBurnsStarted = 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) .toolbar { ToolbarItem(placement: .topBarTrailing) { FavoriteButton(uri: artist.uri, size: 22, showInLight: true) } } .task(id: "albums-\(artist.uri)") { await loadAlbums() } .task(id: "detail-\(artist.uri)") { await loadArtistDetail() } .onAppear { guard !kenBurnsStarted else { return } kenBurnsStarted = true 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 /// All distinct music providers for this artist. /// Uses the authoritative provider_mappings field from MA when available, /// and falls back to scanning loaded album metadata. private var artistProviders: [MusicProvider] { var seen = Set() var result = [MusicProvider]() // Primary: provider_mappings from the artist object (available immediately) for mapping in artist.providerMappings { if let p = MusicProvider.from(providerKey: mapping.providerDomain), !seen.contains(p) { seen.insert(p) result.append(p) } } // Fallback: scan loaded albums when providerMappings is empty if result.isEmpty { for album in albums { if let scheme = album.uri.components(separatedBy: "://").first, let p = MusicProvider.from(scheme: scheme), p != .library, !seen.contains(p) { seen.insert(p) result.append(p) } for key in album.metadata?.images?.compactMap({ $0.provider }) ?? [] { if let p = MusicProvider.from(providerKey: key), !seen.contains(p) { seen.insert(p) result.append(p) } } } } return result } @ViewBuilder private var artistHeader: some View { VStack(spacing: 12) { 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) // One badge per distinct source if !artistProviders.isEmpty { HStack(spacing: 4) { ForEach(artistProviders, id: \.self) { provider in Image(systemName: provider.icon) .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .frame(width: 20, height: 20) .background(.black.opacity(0.55)) .clipShape(Circle()) } } } 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 { isLoading = true errorMessage = nil do { albums = try await service.libraryManager.getArtistAlbums(artistUri: artist.uri) isLoading = false } catch is CancellationError { return } catch { 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 is CancellationError { return } catch { // Biography is optional — silently ignore if unavailable } } } // 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()) } }