Files
2026-04-07 20:15:56 +02:00

257 lines
8.7 KiB
Swift
Raw Permalink 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
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 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("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())
}
}