// // AlbumDetailView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI struct AlbumDetailView: View { @Environment(MAService.self) private var service @Environment(\.audioPlayer) private var audioPlayer let album: MAAlbum @State private var tracks: [MAMediaItem] = [] @State private var isLoading = true @State private var errorMessage: String? @State private var showError = false @State private var showPlayerPicker = false @State private var selectedPlayer: MAPlayer? private var players: [MAPlayer] { Array(service.playerManager.players.values).sorted { $0.name < $1.name } } var body: some View { ScrollView { VStack(spacing: 24) { // Album Header albumHeader // Play Button playButton Divider() // Tracklist if isLoading { ProgressView() .padding() } else if tracks.isEmpty { Text("No tracks found") .foregroundStyle(.secondary) .padding() } else { trackList } } } .navigationTitle(album.name) .navigationBarTitleDisplayMode(.inline) .task { await loadTracks() } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let errorMessage { Text(errorMessage) } } .sheet(isPresented: $showPlayerPicker) { EnhancedPlayerPickerView( players: players, supportsLocalPlayback: audioPlayer != nil, onSelect: { selection in Task { switch selection { case .localPlayer: await playOnLocalPlayer() case .remotePlayer(let player): await playAlbum(on: player) } } } ) } } // MARK: - Album Header @ViewBuilder private var albumHeader: some View { VStack(spacing: 16) { // Cover Art if let imageUrl = album.imageUrl { let coverURL = service.imageProxyURL(path: imageUrl, size: 512) CachedAsyncImage(url: coverURL) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Rectangle() .fill(Color.gray.opacity(0.2)) } .frame(width: 250, height: 250) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 10) } else { RoundedRectangle(cornerRadius: 12) .fill(Color.gray.opacity(0.2)) .frame(width: 250, height: 250) .overlay { Image(systemName: "opticaldisc") .font(.system(size: 60)) .foregroundStyle(.secondary) } } // Album Info VStack(spacing: 8) { if let artists = album.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.title3) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } HStack { if let year = album.year { Text(String(year)) .font(.subheadline) .foregroundStyle(.tertiary) } if !tracks.isEmpty { Text("•") .foregroundStyle(.tertiary) Text("\(tracks.count) tracks") .font(.subheadline) .foregroundStyle(.tertiary) } } } .padding(.horizontal) } .padding(.top) } // MARK: - Play Button @ViewBuilder private var playButton: some View { Button { if players.count == 1 { selectedPlayer = players.first Task { await playAlbum(on: players.first!) } } else { showPlayerPicker = true } } label: { Label("Play Album", systemImage: "play.fill") .font(.headline) .frame(maxWidth: .infinity) .padding() .background(Color.accentColor) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } .padding(.horizontal) .disabled(tracks.isEmpty || players.isEmpty) } // MARK: - Track List @ViewBuilder private var trackList: some View { LazyVStack(spacing: 0) { ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in TrackRow(track: track, trackNumber: index + 1) .contentShape(Rectangle()) .onTapGesture { if players.count == 1 { Task { await playTrack(track, on: players.first!) } } else { showPlayerPicker = true } } if index < tracks.count - 1 { Divider() .padding(.leading, 60) } } } .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) } // MARK: - Actions private func loadTracks() async { isLoading = true errorMessage = nil do { tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri) isLoading = false } catch { errorMessage = error.localizedDescription showError = true isLoading = false } } private func playAlbum(on player: MAPlayer) async { do { try await service.playerManager.playMedia( playerId: player.playerId, uri: album.uri ) } catch { errorMessage = error.localizedDescription showError = true } } private func playOnLocalPlayer() async { guard let audioPlayer else { errorMessage = "Local player not available" showError = true return } do { // Play first track on local player // Note: We use "local_player" as a virtual queue ID if let firstTrack = tracks.first { try await audioPlayer.playMediaItem( uri: firstTrack.uri, queueId: "local_player" ) } } catch { errorMessage = error.localizedDescription showError = true } } private func playTrack(_ track: MAMediaItem, on player: MAPlayer) async { do { try await service.playerManager.playMedia( playerId: player.playerId, uri: track.uri ) } catch { errorMessage = error.localizedDescription showError = true } } } // MARK: - Track Row struct TrackRow: View { let track: MAMediaItem let trackNumber: Int var body: some View { HStack(spacing: 12) { // Track Number Text("\(trackNumber)") .font(.subheadline) .foregroundStyle(.secondary) .frame(width: 30, alignment: .trailing) // Track Info VStack(alignment: .leading, spacing: 4) { Text(track.name) .font(.body) .lineLimit(1) if let artists = track.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } Spacer() // Duration if let duration = track.duration { Text(formatDuration(duration)) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 8) .padding(.horizontal) } private func formatDuration(_ seconds: Int) -> String { let minutes = seconds / 60 let remainingSeconds = seconds % 60 return String(format: "%d:%02d", minutes, remainingSeconds) } } #Preview { NavigationStack { AlbumDetailView( album: MAAlbum( uri: "library://album/1", name: "Test Album", artists: [ MAArtist(uri: "library://artist/1", name: "Test Artist", imageUrl: nil, sortName: nil, musicbrainzId: nil) ], imageUrl: nil, year: 2024 ) ) .environment(MAService()) } }