NavidromeApp/watchOS/Views/WatchNowPlayingView.swift
Dallas Groot 97f2f9ab76 Watch: large volume buttons, remove Digital Crown volume
Volume controls moved to dedicated row with large tap targets
(36pt height, full-width, dark background, percentage readout).
Digital Crown binding removed — buttons are the primary control.
Applied to both local playback and iPhone remote views.
2026-04-30 23:13:40 -07:00

546 lines
22 KiB
Swift

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)
}
}
}
}