// // ArtistsView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI import UIKit struct ArtistsView: View { @Environment(MAService.self) private var service @State private var errorMessage: String? @State private var showError = false private var artists: [MAArtist] { service.libraryManager.artists } private var isLoading: Bool { service.libraryManager.isLoadingArtists } private let columns = [ GridItem(.adaptive(minimum: 80), spacing: 8) ] /// Artists grouped by first letter; non-alphabetic names go under "#" private var artistsByLetter: [(String, [MAArtist])] { let grouped = Dictionary(grouping: artists) { artist -> String in let first = artist.name.prefix(1).uppercased() return first.first?.isLetter == true ? String(first) : "#" } return grouped.sorted { if $0.key == "#" { return false } if $1.key == "#" { return true } return $0.key < $1.key } } private var availableLetters: [String] { artistsByLetter.map { $0.0 } } var body: some View { ScrollViewReader { proxy in ZStack(alignment: .trailing) { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { ForEach(artistsByLetter, id: \.0) { letter, letterArtists in // Section header Text(letter) .font(.headline) .fontWeight(.bold) .foregroundStyle(.secondary) .padding(.horizontal, 12) .padding(.top, 10) .padding(.bottom, 4) .id(letter) // Grid of artists in this section LazyVGrid(columns: columns, spacing: 8) { ForEach(letterArtists) { artist in NavigationLink(value: artist) { ArtistGridItem(artist: artist) } .buttonStyle(.plain) .task { await loadMoreIfNeeded(currentItem: artist) } } } .padding(.horizontal, 12) .padding(.bottom, 4) } if isLoading { ProgressView() .frame(maxWidth: .infinity) .padding() } } // Right padding leaves room for the alphabet index .padding(.trailing, 24) } // Floating alphabet index on the right edge if !availableLetters.isEmpty { AlphabetIndexView(letters: availableLetters) { letter in proxy.scrollTo(letter, anchor: .top) } .padding(.trailing, 2) } } } .refreshable { await loadArtists(refresh: true) } .task { await loadArtists(refresh: !artists.isEmpty) } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let errorMessage { Text(errorMessage) } } .overlay { if artists.isEmpty && !isLoading { ContentUnavailableView( "No Artists", systemImage: "music.mic", description: Text("Your library doesn't contain any artists yet") ) } } } private func loadArtists(refresh: Bool) async { do { try await service.libraryManager.loadArtists(refresh: refresh) } catch { errorMessage = error.localizedDescription showError = true } } private func loadMoreIfNeeded(currentItem: MAArtist) async { do { try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem) } catch { errorMessage = error.localizedDescription showError = true } } } // MARK: - Alphabet Index struct AlphabetIndexView: View { let letters: [String] let onSelect: (String) -> Void @State private var activeLetter: String? var body: some View { GeometryReader { geometry in let itemHeight = geometry.size.height / CGFloat(letters.count) ZStack { // Touch-responsive column VStack(spacing: 0) { ForEach(letters, id: \.self) { letter in Text(letter) .font(.system(size: 11, weight: .bold)) .frame(width: 20, height: itemHeight) .foregroundStyle(activeLetter == letter ? .white : .accentColor) .background { if activeLetter == letter { Circle() .fill(Color.accentColor) .frame(width: 18, height: 18) } } } } .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let index = Int(value.location.y / itemHeight) let clamped = max(0, min(letters.count - 1, index)) let letter = letters[clamped] if activeLetter != letter { activeLetter = letter onSelect(letter) UISelectionFeedbackGenerator().selectionChanged() } } .onEnded { _ in activeLetter = nil } ) } } .frame(width: 20) } } // MARK: - Artist Grid Item struct ArtistGridItem: View { @Environment(MAService.self) private var service let artist: MAArtist var body: some View { VStack(spacing: 4) { CachedAsyncImage(url: service.imageProxyURL(path: artist.imageUrl, provider: artist.imageProvider, size: 128)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Circle() .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: "music.mic") .font(.system(size: 22)) .foregroundStyle(.secondary) } } .frame(width: 76, height: 76) .clipShape(Circle()) Text(artist.name) .font(.caption) .fontWeight(.medium) .lineLimit(1) .multilineTextAlignment(.center) .foregroundStyle(.primary) } } } #Preview { NavigationStack { ArtistsView() .environment(MAService()) } }