NavidromeApp/watchOS/Views/WatchNowPlayingView.swift

388 lines
15 KiB
Swift
Raw Normal View History

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
@FocusState private var isCrownFocused: Bool
var body: some View {
if let song = audioPlayer.currentSong {
localNowPlaying(song)
} else if let phone = watchManager.phoneNowPlaying {
remoteNowPlaying(phone)
} else {
emptyState
}
}
// 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)
// Visualizer
WatchVisualizerView(
levels: audioPlayer.audioLevels,
isPlaying: audioPlayer.isPlaying
)
.frame(height: 28)
.padding(.horizontal, 4)
// Progress bar
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.white.opacity(0.2))
.frame(height: 3)
Capsule()
.fill(Color.pink)
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 3)
}
}
.frame(height: 3)
.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()
}
// Bottom row
HStack(spacing: 14) {
Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle")
.font(.system(size: 11))
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
}
.buttonStyle(.plain)
Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: 11))
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
}
.buttonStyle(.plain)
// Volume
HStack(spacing: 2) {
Image(systemName: "speaker.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule().fill(Color.white.opacity(0.15)).frame(height: 2)
Capsule().fill(Color.white.opacity(0.6))
.frame(width: geo.size.width * CGFloat(audioPlayer.volume), height: 2)
}
}
.frame(height: 8)
Image(systemName: "speaker.wave.2.fill")
.font(.system(size: 7))
.foregroundColor(.gray)
}
.frame(width: 60)
}
}
.padding(.horizontal, 4)
.focused($isCrownFocused)
.digitalCrownRotation(
$crownVolume,
from: 0, through: 1, by: 0.05,
sensitivity: .medium,
isContinuous: false,
isHapticFeedbackEnabled: true
)
.onChange(of: crownVolume) { _, newValue in
audioPlayer.setVolume(Float(newValue))
}
.onAppear {
crownVolume = Double(audioPlayer.volume)
isCrownFocused = true
}
.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: 8) {
HStack(spacing: 4) {
Image(systemName: "iphone")
.font(.system(size: 10))
.foregroundColor(.blue)
Text("Playing on iPhone")
.font(.system(size: 10))
.foregroundColor(.blue)
}
VStack(spacing: 2) {
Text(nowPlaying.title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
Text(nowPlaying.artist)
.font(.system(size: 11))
.foregroundColor(.pink)
.lineLimit(1)
}
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: 3)
Capsule().fill(Color.pink)
.frame(width: geo.size.width * pct, height: 3)
}
}
.frame(height: 3)
.padding(.horizontal, 8)
HStack(spacing: 20) {
Button(action: { watchManager.sendPreviousCommand() }) {
Image(systemName: "backward.fill")
.font(.system(size: 16))
}
.buttonStyle(.plain)
Button(action: { watchManager.sendPlayCommand() }) {
Image(systemName: nowPlaying.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 24))
}
.buttonStyle(.plain)
Button(action: { watchManager.sendNextCommand() }) {
Image(systemName: "forward.fill")
.font(.system(size: 16))
}
.buttonStyle(.plain)
}
}
.padding()
}
// 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)
// Show connect button if no Bluetooth
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)
}
}
}
}