// // 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 /// When this player is a sync member, the leader's ID is used for /// queue data, album art, track info, and transport controls. var leaderPlayerId: String? = nil /// The ID to use for everything except volume. private var effectivePlayerId: String { leaderPlayerId ?? playerId } @State private var localVolume: Double = 0 @State private var isVolumeEditing = false @State private var volumeSettleTask: Task? @State private var isMuted = false @State private var preMuteVolume: Double = 50 @State private var displayedElapsed: Double = 0 @State private var isProgressEditing = false @State private var progressSettleTask: Task? @State private var progressTimer: Timer? // Queue scroll trigger @State private var scrollToQueue = false // Queue state @State private var isQueueLoading = false @State private var showClearConfirm = false // Auto-tracks live updates via @Observable /// Volume and mute state come from this player (the member itself). private var player: MAPlayer? { service.playerManager.players[playerId] } /// Queue, track info, and transport state come from the effective player (leader if synced). private var effectivePlayer: MAPlayer? { service.playerManager.players[effectivePlayerId] } private var playerQueue: MAPlayerQueue? { service.playerManager.playerQueues[effectivePlayerId] } private var currentItem: MAQueueItem? { playerQueue?.currentItem } private var mediaItem: MAMediaItem? { currentItem?.mediaItem } private var trackDuration: Double { Double(currentItem?.duration ?? 0) } // Queue computed properties — all use effectivePlayerId (leader when synced) private var queueItems: [MAQueueItem] { service.playerManager.queues[effectivePlayerId] ?? [] } private var currentQueueIndex: Int? { service.playerManager.playerQueues[effectivePlayerId]?.currentIndex } private var currentItemId: String? { service.playerManager.playerQueues[effectivePlayerId]?.currentItem?.queueItemId } private var shuffleEnabled: Bool { service.playerManager.playerQueues[effectivePlayerId]?.shuffleEnabled ?? false } private var repeatMode: RepeatMode { service.playerManager.playerQueues[effectivePlayerId]?.repeatMode ?? .off } var body: some View { VStack(spacing: 0) { // Pinned header — always visible headerView // Scrollable content (album art + queue only) // GeometryReader gives the actual available height so playerContent // can fill exactly (viewport − "Up Next" header), making only the // caption peek at the bottom as a scroll hint. GeometryReader { geo in ScrollViewReader { proxy in ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) { playerContent // Leave ~48 pt so only the "Up Next" header peeks .frame(minHeight: geo.size.height - 48) // Queue section — below the fold queueSection .id("queueSection") } } .onChange(of: scrollToQueue) { _, shouldScroll in if shouldScroll { withAnimation(.easeInOut(duration: 0.4)) { proxy.scrollTo("queueSection", anchor: .top) } scrollToQueue = false } } } } // Pinned controls — always outside the scroll view, no gesture conflicts progressView 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) syncElapsedTime() startProgressTimer() } .onDisappear { progressTimer?.invalidate() progressTimer = nil } .onChange(of: playerQueue?.elapsedTime) { if !isProgressEditing { syncElapsedTime() } } .onChange(of: effectivePlayer?.state) { syncElapsedTime() if effectivePlayer?.state == .playing { startProgressTimer() } else { progressTimer?.invalidate() progressTimer = nil } } .task { isQueueLoading = true try? await service.playerManager.loadQueue(playerId: effectivePlayerId) isQueueLoading = false } .confirmationDialog("Clear the entire queue?", isPresented: $showClearConfirm, titleVisibility: .visible) { Button("Clear Queue", role: .destructive) { Task { try? await service.playerManager.clearQueue(playerId: effectivePlayerId) } } Button("Cancel", role: .cancel) { } } .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(leaderPlayerId != nil ? player?.name ?? "Now Playing" : "Now Playing") .font(.caption) .foregroundStyle(.secondary) Text(effectivePlayer?.name ?? "") .font(.subheadline) .fontWeight(.semibold) .lineLimit(1) } Spacer() HStack(spacing: 4) { // Queue icon — scrolls to the queue section below Button { scrollToQueue = true } label: { Image(systemName: "list.bullet") .font(.title3) .fontWeight(.semibold) .foregroundStyle(.primary) .frame(width: 44, height: 44) .contentShape(Rectangle()) } if let uri = mediaItem?.uri { FavoriteButton(uri: uri, size: 22, itemName: currentItem?.name) .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) } .padding(.bottom, 8) } // MARK: - Transport + Volume Controls @ViewBuilder private var controlsView: some View { VStack(spacing: 16) { // Transport controls if let ep = effectivePlayer { HStack(spacing: 48) { Button { Task { try? await service.playerManager.previousTrack(playerId: effectivePlayerId) } } label: { Image(systemName: "backward.fill") .font(.system(size: 30)) .foregroundStyle(.primary) } Button { Task { if ep.state == .playing { try? await service.playerManager.pause(playerId: effectivePlayerId) } else { try? await service.playerManager.play(playerId: effectivePlayerId) } } } label: { Image(systemName: ep.state == .playing ? "pause.circle.fill" : "play.circle.fill") .font(.system(size: 72)) .foregroundStyle(.primary) .symbolEffect(.bounce, value: ep.state == .playing) } Button { Task { try? await service.playerManager.nextTrack(playerId: effectivePlayerId) } } 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) GeometryReader { geo in let thumbX = max(0, min(geo.size.width, geo.size.width * localVolume / 100)) ZStack(alignment: .leading) { Capsule() .fill(.primary.opacity(0.15)) .frame(height: 6) Capsule() .fill(.primary) .frame(width: thumbX, height: 6) Circle() .fill(.primary) .frame(width: 14, height: 14) .offset(x: thumbX - 7) } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { value in isVolumeEditing = true localVolume = max(0, min(100, value.location.x / geo.size.width * 100)) } .onEnded { value in localVolume = max(0, min(100, value.location.x / geo.size.width * 100)) Task { try? await setVolumeForPlayer(level: Int(localVolume)) } volumeSettleTask?.cancel() volumeSettleTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } isVolumeEditing = false } } ) } .frame(height: 28) 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: - Progress View @ViewBuilder private var progressView: some View { let duration = trackDuration let progress = duration > 0 ? min(displayedElapsed / duration, 1.0) : 0 VStack(spacing: 4) { // Progress bar GeometryReader { geo in let thumbX = max(0, min(geo.size.width, geo.size.width * progress)) ZStack(alignment: .leading) { Capsule() .fill(.primary.opacity(0.15)) .frame(height: 4) Capsule() .fill(.primary) .frame(width: thumbX, height: 4) Circle() .fill(.primary) .frame(width: 14, height: 14) .offset(x: thumbX - 7) } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .simultaneousGesture( DragGesture(minimumDistance: 0) .onChanged { value in guard duration > 0 else { return } isProgressEditing = true progressTimer?.invalidate() displayedElapsed = max(0, min(duration, value.location.x / geo.size.width * duration)) } .onEnded { value in guard duration > 0 else { return } let seekTo = max(0, min(duration, value.location.x / geo.size.width * duration)) displayedElapsed = seekTo Task { do { try await service.playerManager.seek(playerId: effectivePlayerId, position: seekTo) } catch { print("❌ Seek failed: \(error)") } } progressSettleTask?.cancel() progressSettleTask = Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } isProgressEditing = false startProgressTimer() } } ) } .frame(height: 28) // Time labels HStack { Text(formatTime(displayedElapsed)) .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() Spacer() Text("-\(formatTime(max(0, duration - displayedElapsed)))") .font(.caption2) .foregroundStyle(.secondary) .monospacedDigit() } } .padding(.horizontal, 32) .padding(.bottom, 4) } // MARK: - Queue Section @ViewBuilder private var queueSection: some View { VStack(spacing: 0) { // Section header HStack { HStack(spacing: 6) { Image(systemName: "chevron.up") .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) Text("Up Next") .font(.headline) .fontWeight(.bold) } Spacer() if !queueItems.isEmpty { Text("\(queueItems.count) tracks") .font(.caption) .foregroundStyle(.secondary) } } .padding(.horizontal, 16) .padding(.top, 20) .padding(.bottom, 8) // Control bar (shuffle / repeat / clear) queueControlBar .padding(.horizontal, 16) .padding(.vertical, 10) Divider() // Queue items if isQueueLoading && queueItems.isEmpty { ProgressView() .frame(height: 100) .frame(maxWidth: .infinity) } else if queueItems.isEmpty { Text("Queue is empty") .font(.subheadline) .foregroundStyle(.secondary) .frame(height: 100) .frame(maxWidth: .infinity) } else { LazyVStack(spacing: 0) { ForEach(Array(queueItems.enumerated()), id: \.element.id) { index, item in let isCurrent = currentQueueIndex == index || item.queueItemId == currentItemId QueueItemRow(item: item, isCurrent: isCurrent) .contentShape(Rectangle()) .onTapGesture { Task { try? await service.playerManager.playIndex( playerId: effectivePlayerId, index: index ) } } } } } } } // MARK: - Queue Control Bar @ViewBuilder private var queueControlBar: some View { HStack(spacing: 0) { // Shuffle Button { Task { try? await service.playerManager.setShuffle(playerId: effectivePlayerId, enabled: !shuffleEnabled) } } label: { VStack(spacing: 3) { Image(systemName: "shuffle") .font(.system(size: 20)) .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) Text("Shuffle") .font(.caption2) .foregroundStyle(shuffleEnabled ? Color.accentColor : .secondary) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) // Repeat Button { let next: RepeatMode switch repeatMode { case .off: next = .all case .all: next = .one case .one: next = .off } Task { try? await service.playerManager.setRepeatMode(playerId: effectivePlayerId, mode: next) } } label: { VStack(spacing: 3) { Image(systemName: repeatMode == .one ? "repeat.1" : "repeat") .font(.system(size: 20)) .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) Text(repeatMode == .off ? "Repeat" : (repeatMode == .one ? "Repeat 1" : "Repeat All")) .font(.caption2) .foregroundStyle(repeatMode == .off ? .secondary : Color.accentColor) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) // Clear queue Button { showClearConfirm = true } label: { VStack(spacing: 3) { Image(systemName: "xmark.bin") .font(.system(size: 20)) .foregroundStyle(.secondary) Text("Clear") .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) .disabled(queueItems.isEmpty) .opacity(queueItems.isEmpty ? 0.4 : 1.0) } } // MARK: - Progress Helpers private func syncElapsedTime() { guard let queue = playerQueue, let elapsed = queue.elapsedTime else { displayedElapsed = 0 return } if effectivePlayer?.state == .playing, let lastUpdated = queue.elapsedTimeLastUpdated { let serverNow = Date().timeIntervalSince1970 let delta = serverNow - lastUpdated displayedElapsed = elapsed + max(0, delta) } else { displayedElapsed = elapsed } } private func startProgressTimer() { progressTimer?.invalidate() guard effectivePlayer?.state == .playing else { return } progressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in Task { @MainActor in guard effectivePlayer?.state == .playing, !isProgressEditing else { return } displayedElapsed += 0.5 if trackDuration > 0 { displayedElapsed = min(displayedElapsed, trackDuration) } } } } private func formatTime(_ seconds: Double) -> String { let totalSeconds = Int(max(0, seconds)) let m = totalSeconds / 60 let s = totalSeconds % 60 return String(format: "%d:%02d", m, s) } // MARK: - Volume Helpers /// Routes to group_volume for group leaders, individual volume otherwise. private func setVolumeForPlayer(level: Int) async throws { if player?.isGroupLeader == true { try await service.playerManager.setGroupVolume(playerId: playerId, level: level) } else { try await service.playerManager.setVolume(playerId: playerId, level: level) } } 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 setVolumeForPlayer(level: newVolume) } } private func handleMute() { if isMuted { let restore = preMuteVolume > 0 ? preMuteVolume : 50 localVolume = restore isMuted = false Task { try? await setVolumeForPlayer(level: Int(restore)) } } else { preMuteVolume = localVolume localVolume = 0 isMuted = true Task { try? await setVolumeForPlayer(level: 0) } } } }