// // AlbumDetailView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI struct AlbumDetailView: View { @Environment(MAService.self) private var service 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 showEnqueuePicker = false @State private var selectedPlayer: MAPlayer? @State private var selectedTrackIndex: Int? = nil @State private var kenBurnsScale: CGFloat = 1.0 @State private var completeAlbum: MAAlbum? @State private var albumDescription: String? @State private var isDescriptionExpanded = false private var players: [MAPlayer] { Array(service.playerManager.players.values) .filter { $0.available } .sorted { $0.name < $1.name } } /// URIs of tracks currently playing on any player. private var nowPlayingURIs: Set { Set(service.playerManager.playerQueues.values.compactMap { $0.currentItem?.mediaItem?.uri }) } var body: some View { ZStack { // Blurred Background with Ken Burns Effect backgroundArtwork // Content ScrollView { VStack(spacing: 24) { // Album Header albumHeader // Action Buttons actionButtons Divider() .background(Color.white.opacity(0.3)) // Album description if let albumDescription { descriptionSection(albumDescription) } // Tracklist if isLoading { ProgressView() .padding() .tint(.white) } else if tracks.isEmpty { Text("No tracks found") .foregroundStyle(.white.opacity(0.7)) .padding() } else { trackList } // Show when the album came from a provider-specific URI and the // full library version is available if let completeAlbum { NavigationLink(value: completeAlbum) { Label("Show complete album", systemImage: "music.note.list") .font(.subheadline.bold()) .foregroundStyle(.white.opacity(0.85)) .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.1)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.2), lineWidth: 1) ) ) } .padding(.horizontal) .padding(.bottom, 8) } } } } .navigationTitle(album.name) .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { FavoriteButton(uri: album.uri, size: 22, showInLight: true) } } .task(id: "tracks-\(album.uri)") { await loadTracks() } .task(id: "detail-\(album.uri)") { await loadAlbumDetail() } .onAppear { withAnimation(.linear(duration: 20).repeatForever(autoreverses: true)) { kenBurnsScale = 1.15 } } .alert("Error", isPresented: $showError) { Button("OK", role: .cancel) { } } message: { if let errorMessage { Text(errorMessage) } } .sheet(isPresented: $showPlayerPicker) { EnhancedPlayerPickerView( players: players, onSelect: { player in if let index = selectedTrackIndex { Task { await playTrack(fromIndex: index, on: player) } } else { Task { await playAlbum(on: player) } } } ) } .sheet(isPresented: $showEnqueuePicker) { EnhancedPlayerPickerView( players: players, title: "Add to Queue on...", onSelect: { player in Task { await enqueueAlbum(on: player) } } ) } } // MARK: - Background Artwork @ViewBuilder private var backgroundArtwork: some View { GeometryReader { geometry in CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 512)) { image in image .resizable() .aspectRatio(contentMode: .fill) .frame(width: geometry.size.width, height: geometry.size.height) .scaleEffect(kenBurnsScale) .blur(radius: 50) .overlay { // Dark gradient overlay for better text contrast LinearGradient( colors: [ Color.black.opacity(0.7), Color.black.opacity(0.5), Color.black.opacity(0.7) ], startPoint: .top, endPoint: .bottom ) } .clipped() } placeholder: { Rectangle() .fill( LinearGradient( colors: [Color(.systemGray6), Color(.systemGray5)], startPoint: .topLeading, endPoint: .bottomTrailing ) ) .overlay { Color.black.opacity(0.6) } } } .ignoresSafeArea() } // MARK: - Album Header @ViewBuilder private var albumHeader: some View { VStack(spacing: 16) { // Cover Art CachedAsyncImage(url: service.imageProxyURL(path: album.imageUrl, provider: album.imageProvider, size: 512)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "opticaldisc") .font(.system(size: 60)) .foregroundStyle(.white.opacity(0.5)) } } .frame(width: 250, height: 250) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(color: .black.opacity(0.5), radius: 20, y: 10) // Album Info VStack(spacing: 8) { if let artists = album.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.title3) .fontWeight(.semibold) .foregroundStyle(.white) .multilineTextAlignment(.center) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) } HStack(spacing: 6) { ProviderBadge(uri: album.uri, imageProvider: album.imageProvider) if let year = album.year { Text(String(year)) .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } if !tracks.isEmpty { Text("•") .foregroundStyle(.white.opacity(0.8)) Text("\(tracks.count) tracks") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } } .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) } .padding(.horizontal) } .padding(.top) } // MARK: - Action Buttons @ViewBuilder private var actionButtons: some View { HStack(spacing: 12) { // Play Album Button { if players.count == 1 { selectedPlayer = players.first Task { await playAlbum(on: players.first!) } } else { selectedTrackIndex = nil showPlayerPicker = true } } label: { Label("Play", systemImage: "play.fill") .font(.headline) .frame(maxWidth: .infinity) .padding() .background( LinearGradient( colors: [Color.white.opacity(0.3), Color.white.opacity(0.2)], startPoint: .top, endPoint: .bottom ) ) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.3), lineWidth: 1) ) .shadow(color: .black.opacity(0.3), radius: 10, y: 5) } // Add to Queue Button { if players.count == 1 { Task { await enqueueAlbum(on: players.first!) } } else { showEnqueuePicker = true } } label: { Label("Add to Queue", systemImage: "text.badge.plus") .font(.headline) .frame(maxWidth: .infinity) .padding() .background( LinearGradient( colors: [Color.white.opacity(0.2), Color.white.opacity(0.1)], startPoint: .top, endPoint: .bottom ) ) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.2), lineWidth: 1) ) .shadow(color: .black.opacity(0.3), radius: 10, y: 5) } } .padding(.horizontal) .disabled(tracks.isEmpty || players.isEmpty) .opacity((tracks.isEmpty || players.isEmpty) ? 0.5 : 1.0) } // 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, useLightTheme: true, isPlaying: nowPlayingURIs.contains(track.uri)) .contentShape(Rectangle()) .onTapGesture { if players.count == 1 { Task { await playTrack(fromIndex: index, on: players.first!) } } else { selectedTrackIndex = index showPlayerPicker = true } } if index < tracks.count - 1 { Divider() .background(Color.white.opacity(0.2)) .padding(.leading, 60) } } } .background( RoundedRectangle(cornerRadius: 12) .fill(Color.black.opacity(0.3)) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color.white.opacity(0.1), lineWidth: 1) ) ) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal) .padding(.bottom, 24) } // MARK: - Description Section @ViewBuilder private func descriptionSection(_ text: String) -> some View { VStack(alignment: .leading, spacing: 10) { Text("About") .font(.title2.bold()) .foregroundStyle(.white) .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) Text(verbatim: text) .font(.body) .foregroundStyle(.white.opacity(0.85)) .lineLimit(isDescriptionExpanded ? nil : 4) .lineSpacing(3) Button { withAnimation(.easeInOut(duration: 0.25)) { isDescriptionExpanded.toggle() } } label: { Text(isDescriptionExpanded ? "Show less" : "Show more") .font(.subheadline.bold()) .foregroundStyle(.white.opacity(0.6)) } } .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) } // MARK: - Actions private func loadTracks() async { isLoading = true errorMessage = nil do { tracks = try await service.libraryManager.getAlbumTracks(albumUri: album.uri) isLoading = false } catch is CancellationError { // View disappeared during load — leave isLoading true so a retry // happens automatically when the view reappears. return } catch { errorMessage = error.localizedDescription showError = true isLoading = false } // If this album came from a provider-specific URI (not the full library version), // try to find the matching library album so we can offer a "Show complete album" link. if let scheme = URL(string: album.uri)?.scheme, scheme != "library" { completeAlbum = service.libraryManager.albums.first { $0.name.caseInsensitiveCompare(album.name) == .orderedSame && $0.uri.hasPrefix("library://") && ($0.year == album.year || album.year == nil) } } } private func loadAlbumDetail() async { do { let detail = try await service.getAlbumDetail(albumUri: album.uri) if let desc = detail.metadata?.description, !desc.isEmpty { albumDescription = desc } } catch is CancellationError { return } catch { // Description is optional — silently ignore if unavailable } } 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 enqueueAlbum(on player: MAPlayer) async { do { try await service.playerManager.enqueueMedia( playerId: player.playerId, uri: album.uri ) } catch { errorMessage = error.localizedDescription showError = true } } private func playTrack(fromIndex index: Int, on player: MAPlayer) async { let uris = tracks[index...].map { $0.uri } do { try await service.playerManager.playMedia( playerId: player.playerId, uris: uris ) } catch { errorMessage = error.localizedDescription showError = true } } } // MARK: - Track Row struct TrackRow: View { @Environment(MAService.self) private var service let track: MAMediaItem let trackNumber: Int var useLightTheme: Bool = false var isPlaying: Bool = false var body: some View { HStack(spacing: 12) { // Track Number / Now Playing indicator Group { if isPlaying { Image(systemName: "waveform") .font(.caption) .foregroundStyle(.green) .symbolEffect(.variableColor.iterative, isActive: true) } else { Text("\(trackNumber)") .font(.subheadline) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) } } .frame(width: 30, alignment: .trailing) // Track Info VStack(alignment: .leading, spacing: 4) { Text(track.name) .font(.body) .foregroundStyle(useLightTheme ? .white : .primary) .lineLimit(1) if let artists = track.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.caption) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .secondary) .lineLimit(1) } } Spacer() // Favorite FavoriteButton(uri: track.uri, size: 16, showInLight: useLightTheme) // Duration if let duration = track.duration { Text(formatDuration(duration)) .font(.caption) .foregroundStyle(useLightTheme ? .white.opacity(0.7) : .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()) } }