// // PlaylistDetailView.swift // Mobile Music Assistant // // Created by Sven Hanold on 26.03.26. // import SwiftUI struct PlaylistDetailView: View { @Environment(MAService.self) private var service let playlist: MAPlaylist @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 kenBurnsScale: CGFloat = 1.0 private var players: [MAPlayer] { Array(service.playerManager.players.values) .filter { $0.available } .sorted { $0.name < $1.name } } var body: some View { ZStack { // Blurred Background with Ken Burns Effect backgroundArtwork // Content ScrollView { VStack(spacing: 24) { // Playlist Header playlistHeader // Action Buttons actionButtons Divider() .background(Color.white.opacity(0.3)) // Tracklist if isLoading { ProgressView() .padding() .tint(.white) } else if tracks.isEmpty { Text("No tracks found") .foregroundStyle(.white.opacity(0.7)) .padding() } else { trackList } } } } .navigationTitle(playlist.name) .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { FavoriteButton(uri: playlist.uri, size: 22, showInLight: true) } } .task { await loadTracks() 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 Task { await playPlaylist(on: player) } } ) } .sheet(isPresented: $showEnqueuePicker) { EnhancedPlayerPickerView( players: players, title: "Add to Queue on...", onSelect: { player in Task { await enqueuePlaylist(on: player) } } ) } } // MARK: - Background Artwork @ViewBuilder private var backgroundArtwork: some View { GeometryReader { geometry in CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.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 { 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: - Playlist Header @ViewBuilder private var playlistHeader: some View { VStack(spacing: 16) { // Cover Art CachedAsyncImage(url: service.imageProxyURL(path: playlist.imageUrl, provider: playlist.imageProvider, size: 512)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "music.note.list") .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) // Playlist Info VStack(spacing: 8) { if let owner = playlist.owner { Text("By \(owner)") .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: playlist.uri, imageProvider: playlist.imageProvider) if !tracks.isEmpty { Text("\(tracks.count) tracks") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } if playlist.isEditable { Text("•") .foregroundStyle(.white.opacity(0.8)) Label("Editable", systemImage: "pencil") .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 Playlist Button { if players.count == 1 { Task { await playPlaylist(on: players.first!) } } else { 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 enqueuePlaylist(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) .contentShape(Rectangle()) .onTapGesture { if players.count == 1 { Task { await playTrack(track, on: players.first!) } } else { 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: - Actions private func loadTracks() async { isLoading = true do { tracks = try await service.libraryManager.getPlaylistTracks(playlistUri: playlist.uri) isLoading = false } catch { errorMessage = error.localizedDescription showError = true isLoading = false } } private func playPlaylist(on player: MAPlayer) async { do { try await service.playerManager.playMedia( playerId: player.playerId, uri: playlist.uri ) } catch { errorMessage = error.localizedDescription showError = true } } private func enqueuePlaylist(on player: MAPlayer) async { do { try await service.playerManager.enqueueMedia( playerId: player.playerId, uri: playlist.uri ) } 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 } } } #Preview { NavigationStack { PlaylistDetailView( playlist: MAPlaylist( uri: "library://playlist/1", name: "Test Playlist", owner: "Test User", imageUrl: nil, isEditable: true ) ) .environment(MAService()) } }