import SwiftUI struct WatchNowPlayingView: View { @EnvironmentObject var audioPlayer: WatchAudioPlayer @EnvironmentObject var watchManager: WatchSessionManager @State private var crownVolume: Double = 0.8 @State private var showRouteInfo = false var body: some View { Group { if let song = audioPlayer.currentSong { localNowPlaying(song) } else if let phone = watchManager.phoneNowPlaying { remoteNowPlaying(phone) } else { emptyState } } .onAppear { // If nothing is playing locally, ask iPhone for its current state if audioPlayer.currentSong == nil { watchManager.requestNowPlaying() } } .onChange(of: watchManager.isPhoneReachable) { _, reachable in // iPhone reconnected — refresh state if reachable && audioPlayer.currentSong == nil { watchManager.requestNowPlaying() } } } // MARK: - Local Playback (watch is playing) private func localNowPlaying(_ song: Song) -> some View { VStack(spacing: 4) { // Audio route indicator routeIndicator // Song info VStack(spacing: 2) { Text(song.title) .font(.system(size: 14, weight: .semibold)) .lineLimit(1) .foregroundColor(.white) Text(song.artist ?? "") .font(.system(size: 11)) .foregroundColor(.pink) .lineLimit(1) } .padding(.top, 2) // Progress bar (tap to seek) GeometryReader { geo in ZStack(alignment: .leading) { Capsule() .fill(Color.white.opacity(0.2)) .frame(height: 4) Capsule() .fill(Color.pink) .frame(width: geo.size.width * audioPlayer.progressPercent, height: 4) } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .onTapGesture { location in let pct = max(0, min(location.x / geo.size.width, 1)) audioPlayer.seek(to: pct * audioPlayer.duration) } } .frame(height: 20) .padding(.horizontal, 8) // Time labels HStack { Text(audioPlayer.currentTimeFormatted) .font(.system(size: 9)) .foregroundColor(.gray) Spacer() Text(audioPlayer.remainingTimeFormatted) .font(.system(size: 9)) .foregroundColor(.gray) } .padding(.horizontal, 8) // Transport controls HStack(spacing: 0) { Spacer() Button(action: { audioPlayer.previous() }) { Image(systemName: "backward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 40, height: 36) Spacer() Button(action: { audioPlayer.togglePlayPause() }) { Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 50, height: 44) Spacer() Button(action: { audioPlayer.next() }) { Image(systemName: "forward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 40, height: 36) Spacer() } // Shuffle / Repeat HStack(spacing: 20) { Button(action: { audioPlayer.toggleShuffle() }) { Image(systemName: "shuffle") .font(.system(size: 13)) .foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray) } .buttonStyle(.plain) Button(action: { audioPlayer.cycleRepeat() }) { Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") .font(.system(size: 13)) .foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray) } .buttonStyle(.plain) } // Volume controls — dedicated row with large tap targets HStack(spacing: 0) { Button(action: { let newVol = max(0, audioPlayer.volume - 0.1) audioPlayer.setVolume(newVol) }) { Image(systemName: "speaker.minus.fill") .font(.system(size: 16)) .foregroundColor(.white) .frame(maxWidth: .infinity, minHeight: 36) .background(Color.white.opacity(0.08)) .cornerRadius(10) } .buttonStyle(.plain) // Volume level indicator Text("\(Int(audioPlayer.volume * 100))%") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.gray) .frame(width: 44) Button(action: { let newVol = min(1, audioPlayer.volume + 0.1) audioPlayer.setVolume(newVol) }) { Image(systemName: "speaker.plus.fill") .font(.system(size: 16)) .foregroundColor(.white) .frame(maxWidth: .infinity, minHeight: 36) .background(Color.white.opacity(0.08)) .cornerRadius(10) } .buttonStyle(.plain) } .padding(.horizontal, 4) } .padding(.horizontal, 4) .sheet(isPresented: $showRouteInfo) { audioRouteSheet } } // MARK: - Audio Route Indicator private var routeIndicator: some View { Button(action: { showRouteInfo = true }) { HStack(spacing: 4) { Image(systemName: audioPlayer.useSpeakerMode ? "speaker.wave.2.fill" : (audioPlayer.isBluetoothConnected ? "airpodspro" : "airpodspro")) .font(.system(size: 9)) .foregroundColor(routeColor) Text(routeLabel) .font(.system(size: 9, weight: .medium)) .foregroundColor(routeColor) .lineLimit(1) } .padding(.horizontal, 8) .padding(.vertical, 3) .background(Capsule().fill(routeColor.opacity(0.15))) } .buttonStyle(.plain) } private var routeColor: Color { if audioPlayer.useSpeakerMode { return .cyan } return audioPlayer.isBluetoothConnected ? .green : .orange } private var routeLabel: String { if audioPlayer.useSpeakerMode { return "Speaker" } return audioPlayer.isBluetoothConnected ? audioPlayer.currentRouteName : "Connect Audio" } // MARK: - Audio Route Sheet private var audioRouteSheet: some View { ScrollView { VStack(spacing: 14) { Text("Audio Output") .font(.system(size: 16, weight: .bold)) .padding(.top, 8) // Speaker option if audioPlayer.isSpeakerAvailable { Button(action: { audioPlayer.useSpeakerMode = true showRouteInfo = false }) { HStack(spacing: 10) { Image(systemName: "speaker.wave.2.fill") .font(.system(size: 16)) .foregroundColor(.cyan) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text("Built-in Speaker") .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) Text("Plays through watch speaker") .font(.system(size: 10)) .foregroundColor(.gray) } Spacer() if audioPlayer.useSpeakerMode { Image(systemName: "checkmark.circle.fill") .foregroundColor(.cyan) } } .padding(.horizontal, 12) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10) .fill(audioPlayer.useSpeakerMode ? Color.cyan.opacity(0.12) : Color.white.opacity(0.06)) ) } .buttonStyle(.plain) } // Bluetooth option Button(action: { audioPlayer.useSpeakerMode = false showRouteInfo = false Task { await audioPlayer.activateSession() } }) { HStack(spacing: 10) { Image(systemName: "airpodspro") .font(.system(size: 16)) .foregroundColor(.green) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text("Bluetooth / AirPlay") .font(.system(size: 14, weight: .medium)) .foregroundColor(.white) Text(audioPlayer.isBluetoothConnected ? audioPlayer.currentRouteName : "Tap to connect") .font(.system(size: 10)) .foregroundColor(.gray) } Spacer() if !audioPlayer.useSpeakerMode { Image(systemName: "checkmark.circle.fill") .foregroundColor(.green) } } .padding(.horizontal, 12) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: 10) .fill(!audioPlayer.useSpeakerMode ? Color.green.opacity(0.12) : Color.white.opacity(0.06)) ) } .buttonStyle(.plain) if audioPlayer.useSpeakerMode { Text("Speaker mode uses a background workout session to keep the app alive. A green ring indicator will appear on your watch face.") .font(.system(size: 10)) .foregroundColor(.gray) .multilineTextAlignment(.center) .padding(.horizontal, 8) } } .padding(.horizontal, 4) } } // MARK: - Remote Control (iPhone is playing) private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View { VStack(spacing: 4) { // "Playing on iPhone" badge HStack(spacing: 4) { Image(systemName: "iphone") .font(.system(size: 9)) .foregroundColor(.blue) Text("iPhone") .font(.system(size: 9, weight: .medium)) .foregroundColor(.blue) } .padding(.horizontal, 8) .padding(.vertical, 2) .background(Capsule().fill(Color.blue.opacity(0.15))) // Cover art + song info HStack(spacing: 10) { if let coverArtId = nowPlaying.coverArtId, let url = watchManager.coverArtURL(id: coverArtId, size: 80) { AsyncImage(url: url) { image in image.resizable().aspectRatio(contentMode: .fill) } placeholder: { RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.1)) .overlay( Image(systemName: "music.note") .font(.system(size: 14)) .foregroundColor(.gray) ) } .frame(width: 40, height: 40) .cornerRadius(6) } else { RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.1)) .frame(width: 40, height: 40) .overlay( Image(systemName: "music.note") .font(.system(size: 14)) .foregroundColor(.gray) ) } VStack(alignment: .leading, spacing: 2) { Text(nowPlaying.title) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) Text(nowPlaying.artist) .font(.system(size: 11)) .foregroundColor(.pink) .lineLimit(1) Text(nowPlaying.album) .font(.system(size: 10)) .foregroundColor(.gray) .lineLimit(1) } Spacer(minLength: 0) } .padding(.horizontal, 4) // Progress bar (tap to seek) GeometryReader { geo in let pct = nowPlaying.duration > 0 ? nowPlaying.currentTime / nowPlaying.duration : 0 ZStack(alignment: .leading) { Capsule().fill(Color.white.opacity(0.2)).frame(height: 4) Capsule().fill(Color.pink) .frame(width: geo.size.width * pct, height: 4) } .frame(maxHeight: .infinity) .contentShape(Rectangle()) .onTapGesture { location in let seekPct = max(0, min(location.x / geo.size.width, 1)) let seekTime = nowPlaying.duration * seekPct watchManager.sendSeekCommand(to: seekTime) } } .frame(height: 20) .padding(.horizontal, 8) // Time labels HStack { Text(formatTime(nowPlaying.currentTime)) .font(.system(size: 9)) .foregroundColor(.gray) Spacer() Text("-\(formatTime(max(0, nowPlaying.duration - nowPlaying.currentTime)))") .font(.system(size: 9)) .foregroundColor(.gray) } .padding(.horizontal, 8) // Transport controls HStack(spacing: 0) { Spacer() Button(action: { watchManager.sendPreviousCommand() }) { Image(systemName: "backward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 40, height: 36) Spacer() Button(action: { watchManager.sendPlayCommand() }) { Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 50, height: 44) Spacer() Button(action: { watchManager.sendNextCommand() }) { Image(systemName: "forward.fill") .font(.system(size: 16)) .foregroundColor(.white) } .buttonStyle(.plain) .frame(width: 40, height: 36) Spacer() } // Shuffle / Repeat HStack(spacing: 20) { Button(action: { watchManager.sendShuffleCommand() }) { Image(systemName: "shuffle") .font(.system(size: 13)) .foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray) } .buttonStyle(.plain) Button(action: { watchManager.sendRepeatCommand() }) { Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat") .font(.system(size: 13)) .foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray) } .buttonStyle(.plain) } // Volume controls — dedicated row with large tap targets HStack(spacing: 0) { Button(action: { crownVolume = max(0, crownVolume - 0.1) watchManager.sendVolumeCommand(Float(crownVolume)) }) { Image(systemName: "speaker.minus.fill") .font(.system(size: 16)) .foregroundColor(.white) .frame(maxWidth: .infinity, minHeight: 36) .background(Color.white.opacity(0.08)) .cornerRadius(10) } .buttonStyle(.plain) Text("\(Int(crownVolume * 100))%") .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(.gray) .frame(width: 44) Button(action: { crownVolume = min(1, crownVolume + 0.1) watchManager.sendVolumeCommand(Float(crownVolume)) }) { Image(systemName: "speaker.plus.fill") .font(.system(size: 16)) .foregroundColor(.white) .frame(maxWidth: .infinity, minHeight: 36) .background(Color.white.opacity(0.08)) .cornerRadius(10) } .buttonStyle(.plain) } .padding(.horizontal, 4) } .padding(.horizontal, 4) .onAppear { crownVolume = Double(nowPlaying.volume) } } private func formatTime(_ seconds: TimeInterval) -> String { let m = Int(seconds) / 60 let s = Int(seconds) % 60 return String(format: "%d:%02d", m, s) } // MARK: - Empty State private var emptyState: some View { VStack(spacing: 12) { Image(systemName: "music.note") .font(.title2) .foregroundColor(.gray) Text("Not Playing") .font(.caption) .foregroundColor(.gray) if watchManager.isPhoneReachable { Text("Play music on your iPhone to control it here") .font(.system(size: 10)) .foregroundColor(.gray.opacity(0.7)) .multilineTextAlignment(.center) .padding(.horizontal, 16) } else { Text("iPhone not connected") .font(.system(size: 10)) .foregroundColor(.orange.opacity(0.8)) } // Show connect button if no Bluetooth (for local watch playback) if !audioPlayer.isBluetoothConnected { Button(action: { Task { await audioPlayer.activateSession() } }) { HStack(spacing: 4) { Image(systemName: "airpodspro") .font(.system(size: 11)) Text("Connect AirPods") .font(.system(size: 12)) } .foregroundColor(.pink) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Capsule().stroke(Color.pink, lineWidth: 1)) } .buttonStyle(.plain) .padding(.top, 4) } if let error = audioPlayer.activationError { Text(error) .font(.system(size: 9)) .foregroundColor(.red) .multilineTextAlignment(.center) .padding(.horizontal) } } } }