388 lines
15 KiB
Swift
388 lines
15 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
|
||
|
|
@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)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|