37a6ccaf0c
likedTrackIds loads async after views appear, so isLiked was always false on initial render. Add onChange(of: likedTrackIds) to all track row types and the player bar to re-sync when the set is populated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
237 lines
8.8 KiB
Swift
237 lines
8.8 KiB
Swift
//
|
|
// ArtistScreen.swift
|
|
// furumi_macos
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ArtistScreen: View {
|
|
let artistId: Int64
|
|
let titleFallback: String
|
|
var onOpenRelease: (Int64, String) -> Void
|
|
var onBack: () -> Void
|
|
var onPlayTrack: (TrackCard) -> Void
|
|
var onAddToQueue: (TrackCard) -> Void
|
|
var onPlayNext: (TrackCard) -> Void
|
|
|
|
@Environment(AuthManager.self) private var authManager
|
|
@State private var vm: ArtistDetailViewModel?
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Back + title
|
|
HStack(spacing: 8) {
|
|
Button(action: onBack) {
|
|
Image(systemName: "chevron.left")
|
|
}
|
|
.buttonStyle(.plain)
|
|
Text(vm?.detail?.card.name ?? titleFallback)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
|
|
Divider()
|
|
|
|
if let vm {
|
|
if vm.isLoading && vm.detail == nil {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
} else if let error = vm.error {
|
|
Spacer()
|
|
Text(error).foregroundColor(.secondary).font(.caption).padding()
|
|
Spacer()
|
|
} else if let detail = vm.detail {
|
|
artistContent(detail)
|
|
}
|
|
} else {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
}
|
|
.task {
|
|
guard let service = authManager.catalogService else { return }
|
|
let newVM = ArtistDetailViewModel(service: service)
|
|
vm = newVM
|
|
await newVM.load(artistId: artistId)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func artistContent(_ detail: ArtistDetail) -> some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
// Artist header
|
|
HStack(spacing: 12) {
|
|
ImageView(
|
|
urlString: detail.card.imageUrl,
|
|
width: 64, height: 64,
|
|
systemPlaceholder: "person.fill"
|
|
)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(detail.card.name)
|
|
.font(.title3).fontWeight(.semibold)
|
|
Text("\(detail.card.releaseCount) releases • \(detail.card.trackCount) tracks")
|
|
.font(.caption).foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
|
|
// Releases grouped by type
|
|
let grouped = groupedReleases(detail.releases)
|
|
if !grouped.isEmpty {
|
|
ForEach(grouped, id: \.0) { label, releases in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label)
|
|
.font(.headline)
|
|
.padding(.horizontal, 12)
|
|
ForEach(releases) { release in
|
|
ArtistReleaseRow(release: release)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onOpenRelease(release.id, release.title) }
|
|
Divider().padding(.leading, 64)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Top tracks
|
|
if !detail.topTracks.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Top Tracks")
|
|
.font(.headline)
|
|
.padding(.horizontal, 12)
|
|
ForEach(detail.topTracks) { track in
|
|
ArtistTrackRow(track: track,
|
|
onPlay: { onPlayTrack(track) },
|
|
onPlayNext: { onPlayNext(track) },
|
|
onAddToQueue: { onAddToQueue(track) })
|
|
Divider().padding(.leading, 64)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
private func groupedReleases(_ releases: [ReleaseCard]) -> [(String, [ReleaseCard])] {
|
|
let order = ["album", "ep", "single", "other"]
|
|
let labels = ["album": "Albums", "ep": "EPs", "single": "Singles", "other": "Other"]
|
|
let dict = Dictionary(grouping: releases) { $0.type.lowercased() }
|
|
var result: [(String, [ReleaseCard])] = []
|
|
for key in order {
|
|
if let items = dict[key], !items.isEmpty {
|
|
result.append((labels[key] ?? key.capitalized, items.sorted { $0.year > $1.year }))
|
|
}
|
|
}
|
|
for (key, items) in dict where !order.contains(key) && !items.isEmpty {
|
|
result.append((key.capitalized, items.sorted { $0.year > $1.year }))
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
private struct ArtistReleaseRow: View {
|
|
let release: ReleaseCard
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
ImageView(urlString: release.coverUrl, width: 40, height: 40, systemPlaceholder: "opticaldisc")
|
|
.frame(width: 40, height: 40)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(release.title).font(.subheadline).lineLimit(1)
|
|
Text(release.year > 0 ? String(release.year) : "—").font(.caption).foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
Text("\(release.trackCount) tracks").font(.caption).foregroundColor(.secondary)
|
|
Image(systemName: "chevron.right").font(.caption).foregroundColor(.secondary)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
}
|
|
}
|
|
|
|
private struct ArtistTrackRow: View {
|
|
let track: TrackCard
|
|
var onPlay: () -> Void
|
|
var onPlayNext: () -> Void
|
|
var onAddToQueue: () -> Void
|
|
|
|
@Environment(AuthManager.self) private var authManager
|
|
@State private var isLiked = false
|
|
@State private var showInfo = false
|
|
|
|
var body: some View {
|
|
HStack(spacing: 10) {
|
|
ImageView(urlString: track.coverUrl, width: 40, height: 40, systemPlaceholder: "music.note")
|
|
.frame(width: 40, height: 40)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(track.title).font(.subheadline).lineLimit(1)
|
|
Text(track.artistNames).font(.caption).foregroundColor(.secondary).lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Text(formatDuration(track.durationSeconds))
|
|
.font(.caption).foregroundColor(.secondary)
|
|
Button(action: { toggleLike() }) {
|
|
Image(systemName: isLiked ? "heart.fill" : "heart")
|
|
.font(.system(size: 13))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundColor(isLiked ? .red : .secondary)
|
|
Menu {
|
|
Button { onPlayNext() } label: { Label("Play Next", systemImage: "text.insert") }
|
|
Button { onAddToQueue() } label: { Label("Add to Queue", systemImage: "text.badge.plus") }
|
|
Divider()
|
|
Button { shareTrack() } label: { Label("Share", systemImage: "square.and.arrow.up") }
|
|
Button { showInfo = true } label: { Label("Track Info", systemImage: "info.circle") }
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.system(size: 13))
|
|
.frame(width: 20, height: 20)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { onPlay() }
|
|
.onAppear { isLiked = authManager.likedTrackIds.contains(track.id) }
|
|
.onChange(of: authManager.likedTrackIds) { _, ids in isLiked = ids.contains(track.id) }
|
|
.popover(isPresented: $showInfo, arrowEdge: .trailing) {
|
|
TrackInfoView(track: track)
|
|
}
|
|
}
|
|
|
|
private func toggleLike() {
|
|
isLiked.toggle()
|
|
let id = track.id
|
|
Task {
|
|
do {
|
|
let result = try await authManager.catalogService?.toggleLike(trackId: id)
|
|
if let result {
|
|
isLiked = result
|
|
authManager.updateLikedState(trackId: id, liked: result)
|
|
}
|
|
} catch {
|
|
isLiked.toggle()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func shareTrack() {
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString("\(track.title) — \(track.artistNames)", forType: .string)
|
|
}
|
|
|
|
private func formatDuration(_ s: Int) -> String {
|
|
String(format: "%d:%02d", s / 60, s % 60)
|
|
}
|
|
}
|