Version 1 in App Store

This commit is contained in:
2026-04-05 19:44:05 +02:00
parent f931c92d94
commit c780be089d
12 changed files with 744 additions and 1484 deletions
@@ -6,55 +6,98 @@
//
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: 160), spacing: 16)
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 {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(artists) { artist in
NavigationLink(value: artist) {
ArtistGridItem(artist: artist)
}
.buttonStyle(.plain)
.task {
await loadMoreIfNeeded(currentItem: artist)
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)
}
if isLoading {
ProgressView()
.gridCellColumns(columns.count)
.padding()
// Floating alphabet index on the right edge
if !availableLetters.isEmpty {
AlphabetIndexView(letters: availableLetters) { letter in
proxy.scrollTo(letter, anchor: .top)
}
.padding(.trailing, 2)
}
}
.padding()
}
.navigationDestination(for: MAArtist.self) { artist in
ArtistDetailView(artist: artist)
}
.refreshable {
await loadArtists(refresh: true)
}
.task {
if artists.isEmpty {
await loadArtists(refresh: false)
}
await loadArtists(refresh: !artists.isEmpty)
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
@@ -73,7 +116,7 @@ struct ArtistsView: View {
}
}
}
private func loadArtists(refresh: Bool) async {
do {
try await service.libraryManager.loadArtists(refresh: refresh)
@@ -82,7 +125,7 @@ struct ArtistsView: View {
showError = true
}
}
private func loadMoreIfNeeded(currentItem: MAArtist) async {
do {
try await service.libraryManager.loadMoreArtistsIfNeeded(currentItem: currentItem)
@@ -93,44 +136,85 @@ struct ArtistsView: View {
}
}
// 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: 8) {
// Artist Image
if let imageUrl = artist.imageUrl {
let coverURL = service.imageProxyURL(path: imageUrl, size: 256)
CachedAsyncImage(url: coverURL) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.2))
}
.frame(width: 160, height: 160)
.clipShape(Circle())
} else {
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))
.frame(width: 160, height: 160)
.overlay {
Image(systemName: "music.mic")
.font(.system(size: 40))
.font(.system(size: 22))
.foregroundStyle(.secondary)
}
}
// Artist Name
.frame(width: 76, height: 76)
.clipShape(Circle())
Text(artist.name)
.font(.subheadline)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
.lineLimit(1)
.multilineTextAlignment(.center)
.foregroundStyle(.primary)
}