// // 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 var albumArtistsOnly: Bool = false @State private var errorMessage: String? @State private var showError = false @State private var scrollPosition: String? private var artists: [MAArtist] { albumArtistsOnly ? service.libraryManager.albumArtists : service.libraryManager.artists } private var isLoading: Bool { albumArtistsOnly ? service.libraryManager.isLoadingAlbumArtists : service.libraryManager.isLoadingArtists } private let columns = [ GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8), GridItem(.flexible(), 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 } } // Always show A–Z plus # so the sidebar has a consistent full height private let allLetters: [String] = (65...90).map { String(UnicodeScalar($0)!) } + ["#"] var body: some View { ScrollViewReader { proxy in ScrollView { VStack(alignment: .leading, spacing: 0) { ForEach(artistsByLetter, id: \.0) { letter, letterArtists in // Section header — scroll target Text(letter) .font(.headline) .fontWeight(.bold) .foregroundStyle(.secondary) .padding(.horizontal, 12) .padding(.top, 10) .padding(.bottom, 4) .id("section-\(letter)") // Grid of artists in this section LazyVGrid(columns: columns, spacing: 8) { ForEach(letterArtists) { artist in NavigationLink(value: artist) { ArtistGridItem(artist: artist) } .buttonStyle(.plain) .id(artist.uri) .task { await loadMoreIfNeeded(currentItem: artist) } } } .padding(.horizontal, 12) .padding(.bottom, 4) } if isLoading { ProgressView() .frame(maxWidth: .infinity) .padding() } } .padding(.trailing, 28) } .scrollPosition(id: $scrollPosition) .overlay(alignment: .trailing) { AlphabetIndexView( letters: allLetters, itemHeight: 17, onSelect: { letter in let target = "section-\(letter)" scrollPosition = target proxy.scrollTo(target, anchor: .top) } ) .padding(.vertical, 8) .padding(.trailing, 2) } } .refreshable { await loadArtists(refresh: true) } .task { if artists.isEmpty { await loadArtists(refresh: true) } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let errorMessage { Text(errorMessage) } } .overlay { if artists.isEmpty && !isLoading { ContentUnavailableView( albumArtistsOnly ? "No Album Artists" : "No Artists", systemImage: "music.mic", description: Text(albumArtistsOnly ? "Your library doesn't contain any album artists yet" : "Your library doesn't contain any artists yet") ) } } } private func loadArtists(refresh: Bool) async { do { if albumArtistsOnly { try await service.libraryManager.loadAlbumArtists(refresh: refresh) } else { try await service.libraryManager.loadArtists(refresh: refresh) } } catch { errorMessage = error.localizedDescription showError = true } } private func loadMoreIfNeeded(currentItem: MAArtist) async { do { if albumArtistsOnly { try await service.libraryManager.loadMoreAlbumArtistsIfNeeded(currentItem: currentItem) } else { try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem) } } catch { errorMessage = error.localizedDescription showError = true } } } // MARK: - Alphabet Index struct AlphabetIndexView: View { let letters: [String] let itemHeight: CGFloat let onSelect: (String) -> Void @State private var activeLetter: String? var body: some View { VStack(spacing: 0) { ForEach(letters, id: \.self) { letter in Text(letter) .font(.system(size: min(13, itemHeight * 0.65), weight: .bold)) .frame(width: 20, height: itemHeight) .padding(.top, letter == "#" ? 6 : 0) .foregroundStyle(activeLetter == letter ? .white : .accentColor) .background { if activeLetter == letter { Circle() .fill(Color.accentColor) .frame(width: min(18, itemHeight - 2), height: min(18, itemHeight - 2)) } } } } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in let safeHeight = max(1, itemHeight) let index = Int(value.location.y / safeHeight) 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 } ) } } // 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: 256)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Circle() .fill(Color.gray.opacity(0.2)) .overlay { Image(systemName: "music.mic") .font(.system(size: 30)) .foregroundStyle(.secondary) } } .aspectRatio(1, contentMode: .fit) .clipShape(Circle()) .overlay(alignment: .bottomTrailing) { if service.libraryManager.isFavorite(uri: artist.uri) { Image(systemName: "heart.fill") .font(.system(size: 12)) .foregroundStyle(.red) .padding(4) } } Text(artist.name) .font(.caption) .fontWeight(.medium) .lineLimit(1) .multilineTextAlignment(.center) .foregroundStyle(.primary) } } } #Preview { NavigationStack { ArtistsView() .environment(MAService()) } }