Files
MobileMusicAssistant/Mobile Music Assistant/ViewsLibraryArtistsView.swift
T

242 lines
8.0 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.
//
// 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(.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 AZ 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(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()
}
}
.padding(.trailing, 28)
}
.overlay(alignment: .trailing) {
AlphabetIndexView(
letters: allLetters,
itemHeight: 17,
onSelect: { letter in
// Scroll to this letter's section, or the nearest one after it
let target = availableLetters.first { $0 >= letter } ?? availableLetters.last
if let target { proxy.scrollTo(target, anchor: .top) }
}
)
.padding(.vertical, 8)
.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 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())
}
}