// // PlayerNowPlayingView.swift // Mobile Music Assistant // // Created by Sven Hanold on 05.04.26. // import SwiftUI struct PlayerNowPlayingView: View { @Environment(MAService.self) private var service @Environment(\.dismiss) private var dismiss let playerId: String @State private var localVolume: Double = 0 @State private var isVolumeEditing = false @State private var isMuted = false @State private var preMuteVolume: Double = 50 @State private var showQueue = false // Auto-tracks live updates via @Observable private var player: MAPlayer? { service.playerManager.players[playerId] } private var currentItem: MAQueueItem? { service.playerManager.playerQueues[playerId]?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } var body: some View { VStack(spacing: 0) { // Header headerView // Conditional content area if showQueue { PlayerQueueView(playerId: playerId) .transition(.move(edge: .trailing).combined(with: .opacity)) } else { playerContent .transition(.move(edge: .leading).combined(with: .opacity)) } Spacer(minLength: 0) // Transport + volume (always visible) controlsView } .background { ZStack { CachedAsyncImage(url: service.imageProxyURL( path: mediaItem?.imageUrl, provider: mediaItem?.imageProvider, size: 64 )) { image in image .resizable() .aspectRatio(contentMode: .fill) } placeholder: { Color.clear } .blur(radius: 80) .scaleEffect(1.4) .opacity(0.5) Rectangle() .fill(.ultraThinMaterial) } .ignoresSafeArea() } .onChange(of: player?.volume) { _, newVolume in if !isVolumeEditing, let v = newVolume { localVolume = Double(v) if v > 0 { isMuted = false } } } .onAppear { localVolume = Double(player?.volume ?? 50) } .presentationDetents([.large]) .presentationDragIndicator(.hidden) } // MARK: - Header @ViewBuilder private var headerView: some View { VStack(spacing: 0) { // Drag indicator Capsule() .fill(.secondary.opacity(0.4)) .frame(width: 36, height: 4) .padding(.top, 8) HStack { Button { dismiss() } label: { Image(systemName: "chevron.down") .font(.title3) .fontWeight(.semibold) .foregroundStyle(.primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } Spacer() VStack(spacing: 2) { Text(showQueue ? "Up Next" : "Now Playing") .font(.caption) .foregroundStyle(.secondary) Text(player?.name ?? "") .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) } Spacer() HStack(spacing: 4) { Button { withAnimation(.easeInOut(duration: 0.3)) { showQueue.toggle() } } label: { Image(systemName: "list.bullet") .font(.title3) .fontWeight(.semibold) .foregroundStyle(showQueue ? .accent : .primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } if let uri = mediaItem?.uri { FavoriteButton(uri: uri, size: 22) .frame(width: 44, height: 44) } } } .padding(.horizontal, 20) } } // MARK: - Player Content (album art + track info) @ViewBuilder private var playerContent: some View { VStack(spacing: 16) { Spacer(minLength: 8) // Album art CachedAsyncImage(url: service.imageProxyURL( path: mediaItem?.imageUrl, provider: mediaItem?.imageProvider, size: 512 )) { image in image .resizable() .scaledToFill() } placeholder: { Color.gray.opacity(0.2) .overlay { Image(systemName: "music.note") .font(.system(size: 56)) .foregroundStyle(.secondary) } } .frame(width: 260, height: 260) .clipShape(RoundedRectangle(cornerRadius: 16)) .shadow(color: .black.opacity(0.35), radius: 24, y: 12) // Track info VStack(spacing: 6) { Text(currentItem?.name ?? "–") .font(.title2) .fontWeight(.bold) .lineLimit(2) .multilineTextAlignment(.center) if let artists = mediaItem?.artists, !artists.isEmpty { Text(artists.map { $0.name }.joined(separator: ", ")) .font(.body) .foregroundStyle(.secondary) .lineLimit(1) } else if let album = mediaItem?.album { Text(album.name) .font(.body) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.horizontal, 32) Spacer(minLength: 8) } } // MARK: - Transport + Volume Controls @ViewBuilder private var controlsView: some View { VStack(spacing: 16) { // Transport controls if let player { HStack(spacing: 48) { Button { Task { try? await service.playerManager.previousTrack(playerId: playerId) } } label: { Image(systemName: "backward.fill") .font(.system(size: 30)) .foregroundStyle(.primary) } Button { Task { if player.state == .playing { try? await service.playerManager.pause(playerId: playerId) } else { try? await service.playerManager.play(playerId: playerId) } } } label: { Image(systemName: player.state == .playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) .foregroundStyle(.primary) .symbolEffect(.bounce, value: player.state == .playing) } Button { Task { try? await service.playerManager.nextTrack(playerId: playerId) } } label: { Image(systemName: "forward.fill") .font(.system(size: 30)) .foregroundStyle(.primary) } } .buttonStyle(.plain) } // Volume control HStack(spacing: 10) { Button { handleMute() } label: { Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.slash") .font(.system(size: 15)) .foregroundStyle(isMuted ? .primary : .secondary) .frame(width: 28, height: 28) } .buttonStyle(.plain) Button { adjustVolume(by: -5) } label: { Image(systemName: "speaker.fill") .font(.system(size: 13)) .foregroundStyle(.secondary) } .buttonStyle(.plain) Slider(value: $localVolume, in: 0...100, step: 1) { editing in isVolumeEditing = editing if !editing { Task { try? await service.playerManager.setVolume( playerId: playerId, level: Int(localVolume) ) } } } Button { adjustVolume(by: 5) } label: { Image(systemName: "speaker.wave.3.fill") .font(.system(size: 20)) .foregroundStyle(.secondary) } .buttonStyle(.plain) } .padding(.horizontal, 32) .padding(.bottom, 32) } } // MARK: - Volume Helpers private func adjustVolume(by delta: Int) { let newVolume = max(0, min(100, Int(localVolume) + delta)) localVolume = Double(newVolume) if isMuted && delta > 0 { isMuted = false } Task { try? await service.playerManager.setVolume(playerId: playerId, level: newVolume) } } private func handleMute() { if isMuted { let restore = preMuteVolume > 0 ? preMuteVolume : 50 localVolume = restore isMuted = false Task { try? await service.playerManager.setVolume(playerId: playerId, level: Int(restore)) } } else { preMuteVolume = localVolume localVolume = 0 isMuted = true Task { try? await service.playerManager.setVolume(playerId: playerId, level: 0) } } } }