// // PodcastDetailView.swift // Mobile Music Assistant // // Created by Sven Hanold on 08.04.26. // import SwiftUI struct PodcastDetailView: View { @Environment(MAService.self) private var service let podcast: MAPodcast @State private var episodes: [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 selectedEpisode: MAMediaItem? @State private var kenBurnsScale: CGFloat = 1.0 private var players: [MAPlayer] { Array(service.playerManager.players.values) .filter { $0.available } .sorted { $0.name < $1.name } } private var nowPlayingURIs: Set { Set(service.playerManager.playerQueues.values.compactMap { $0.currentItem?.mediaItem?.uri }) } var body: some View { ZStack { backgroundArtwork ScrollView { VStack(spacing: 24) { podcastHeader actionButtons Divider() .background(Color.white.opacity(0.3)) if isLoading { ProgressView() .padding() .tint(.white) } else if episodes.isEmpty { Text("No episodes found") .foregroundStyle(.white.opacity(0.7)) .padding() } else { episodeList } } } } .navigationTitle(podcast.name) .navigationBarTitleDisplayMode(.inline) .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { ToolbarItem(placement: .topBarTrailing) { FavoriteButton(uri: podcast.uri, size: 22, showInLight: true) } } .task(id: podcast.uri) { await loadEpisodes() guard !Task.isCancelled else { return } 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 playPodcast(on: player) } } ) } .sheet(isPresented: $showEnqueuePicker) { EnhancedPlayerPickerView( players: players, title: "Add to Queue on...", onSelect: { player in Task { await enqueuePodcast(on: player) } } ) } .sheet(item: $selectedEpisode) { episode in EnhancedPlayerPickerView( players: players, onSelect: { player in Task { await playEpisode(episode, on: player) } } ) } } // MARK: - Background Artwork @ViewBuilder private var backgroundArtwork: some View { GeometryReader { geometry in CachedAsyncImage(url: service.imageProxyURL(path: podcast.imageUrl, provider: podcast.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: - Podcast Header @ViewBuilder private var podcastHeader: some View { VStack(spacing: 16) { CachedAsyncImage(url: service.imageProxyURL(path: podcast.imageUrl, provider: podcast.imageProvider, size: 512)) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { RoundedRectangle(cornerRadius: 12) .fill(Color.white.opacity(0.1)) .overlay { Image(systemName: "mic.fill") .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) VStack(spacing: 8) { if let publisher = podcast.publisher { Text(publisher) .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: podcast.uri, metadata: podcast.metadata) if !episodes.isEmpty { Text("\(episodes.count) episodes") .font(.subheadline) .foregroundStyle(.white.opacity(0.8)) } else if let total = podcast.totalEpisodes { Text("\(total) episodes") .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) { Button { if players.count == 1 { Task { await playPodcast(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) } Button { if players.count == 1 { Task { await enqueuePodcast(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(episodes.isEmpty || players.isEmpty) .opacity((episodes.isEmpty || players.isEmpty) ? 0.5 : 1.0) } // MARK: - Episode List @ViewBuilder private var episodeList: some View { LazyVStack(spacing: 0) { ForEach(Array(episodes.enumerated()), id: \.element.id) { index, episode in TrackRow(track: episode, trackNumber: index + 1, useLightTheme: true, isPlaying: nowPlayingURIs.contains(episode.uri)) .contentShape(Rectangle()) .onTapGesture { if players.count == 1 { Task { await playEpisode(episode, on: players.first!) } } else { selectedEpisode = episode } } if index < episodes.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 loadEpisodes() async { isLoading = true do { episodes = try await service.libraryManager.getPodcastEpisodes(podcastUri: podcast.uri) isLoading = false } catch is CancellationError { return } catch { errorMessage = error.localizedDescription showError = true isLoading = false } } private func playPodcast(on player: MAPlayer) async { do { try await service.playerManager.playMedia(playerId: player.playerId, uri: podcast.uri) } catch { errorMessage = error.localizedDescription showError = true } } private func enqueuePodcast(on player: MAPlayer) async { do { try await service.playerManager.enqueueMedia(playerId: player.playerId, uri: podcast.uri) } catch { errorMessage = error.localizedDescription showError = true } } private func playEpisode(_ episode: MAMediaItem, on player: MAPlayer) async { do { try await service.playerManager.playMedia(playerId: player.playerId, uri: episode.uri) } catch { errorMessage = error.localizedDescription showError = true } } } #Preview { NavigationStack { PodcastDetailView( podcast: MAPodcast( uri: "library://podcast/1", name: "Test Podcast", publisher: "Test Publisher", totalEpisodes: 42 ) ) .environment(MAService()) } }