Files
MobileMusicAssistant/Mobile Music Assistant/ViewsLibraryGenresView.swift
T
2026-04-20 13:02:51 +02:00

203 lines
6.6 KiB
Swift

//
// GenresView.swift
// Mobile Music Assistant
//
// Created by Sven Hanold on 17.04.26.
//
import SwiftUI
// MARK: - Genres List
struct GenresView: View {
@Environment(MAService.self) private var service
@State private var errorMessage: String?
@State private var showError = false
// Deduplicated, non-empty genres populated by MALibraryManager after load + filter pass.
private var genres: [MAGenre] { service.libraryManager.displayGenres }
private var isLoading: Bool { service.libraryManager.isLoadingGenres }
var body: some View {
List(genres) { genre in
NavigationLink(value: genre) {
HStack(spacing: 12) {
Image(systemName: "guitars")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 36, height: 36)
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
Text(genre.name.capitalized)
.font(.body)
}
.padding(.vertical, 2)
}
}
.listStyle(.plain)
.overlay {
if genres.isEmpty && isLoading {
ProgressView()
} else if genres.isEmpty && !isLoading {
ContentUnavailableView(
"No Genres",
systemImage: "guitars",
description: Text("Your library doesn't contain any genres yet")
)
}
}
.refreshable {
await loadGenres(refresh: true)
}
.task {
if service.libraryManager.genres.isEmpty {
await loadGenres(refresh: true)
}
}
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func loadGenres(refresh: Bool) async {
do {
try await service.libraryManager.loadGenres(refresh: refresh)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Genre Detail
struct GenreDetailView: View {
@Environment(MAService.self) private var service
let genre: MAGenre
@State private var items: [MAMediaItem] = []
@State private var isLoading = false
@State private var errorMessage: String?
@State private var showError = false
private var artists: [MAMediaItem] { items.filter { $0.mediaType == .artist }.sorted { $0.name < $1.name } }
private var albums: [MAMediaItem] { items.filter { $0.mediaType == .album }.sorted { $0.name < $1.name } }
private var others: [MAMediaItem] { items.filter { $0.mediaType != .artist && $0.mediaType != .album } }
var body: some View {
List {
if !artists.isEmpty {
Section("Artists") {
ForEach(artists) { item in
let artist = MAArtist(uri: item.uri, name: item.name,
imageUrl: item.imageUrl, imageProvider: item.imageProvider)
NavigationLink(value: artist) {
GenreItemRow(item: item, icon: "music.mic")
}
}
}
}
if !albums.isEmpty {
Section("Albums") {
ForEach(albums) { item in
let album = MAAlbum(uri: item.uri, name: item.name,
artists: item.artists,
imageUrl: item.imageUrl, imageProvider: item.imageProvider)
NavigationLink(value: album) {
GenreItemRow(item: item, icon: "square.stack")
}
}
}
}
if !others.isEmpty {
Section("Other") {
ForEach(others) { item in
GenreItemRow(item: item, icon: "music.note")
}
}
}
}
.navigationTitle(genre.name.capitalized)
.navigationBarTitleDisplayMode(.large)
.overlay {
if isLoading {
ProgressView()
} else if items.isEmpty && !isLoading {
ContentUnavailableView(
"No Items",
systemImage: "guitars",
description: Text("Nothing found for this genre")
)
}
}
.task { await loadItems() }
.refreshable { await loadItems() }
.alert("Error", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
if let errorMessage { Text(errorMessage) }
}
}
private func loadItems() async {
isLoading = true
defer { isLoading = false }
do {
items = try await service.libraryManager.browseGenresByName(genre.name)
} catch {
errorMessage = error.localizedDescription
showError = true
}
}
}
// MARK: - Genre Item Row
private struct GenreItemRow: View {
@Environment(MAService.self) private var service
let item: MAMediaItem
let icon: String
var body: some View {
HStack(spacing: 12) {
CachedAsyncImage(url: service.imageProxyURL(
path: item.imageUrl,
provider: item.imageProvider,
size: 64
)) { image in
image.resizable().aspectRatio(contentMode: .fill)
} placeholder: {
RoundedRectangle(cornerRadius: 6)
.fill(Color.gray.opacity(0.2))
.overlay {
Image(systemName: icon)
.foregroundStyle(.secondary)
}
}
.frame(width: 44, height: 44)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(item.name)
.font(.body)
.lineLimit(1)
if let artists = item.artists, !artists.isEmpty {
Text(artists.map(\.name).joined(separator: ", "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
.padding(.vertical, 2)
}
}
#Preview {
NavigationStack {
GenresView()
.environment(MAService())
}
}