Files
furumi_macos/furumi_macos/ArtistScreen.swift
T
Ultradesu 37a6ccaf0c fix: liked state not showing until track row disappears/reappears
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>
2026-06-09 10:49:05 +01:00

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)
}
}