Watch: fix command routing, seek bar, volume UI
Fix 1 — Command routing:
All sendMessage calls now use replyHandler (even { _ in }) so
WCSession routes them to didReceiveMessage:replyHandler: where
the actual command handling lives. Previously next/prev/seek/volume
silently went to the fire-and-forget handler which dropped them.
Fix 2 — Seek bar:
Replaced DragGesture with onTapGesture for reliable watchOS taps.
Hit target increased from 3pt to 20pt. Both local and remote views.
Fix 3 — Volume:
Replaced tiny slider bar with speaker.minus/speaker.plus buttons.
Crown still works for fine control. Both local and remote views.
This commit is contained in:
parent
127d5926a3
commit
5205d708c3
4 changed files with 378 additions and 58 deletions
|
|
@ -1382,6 +1382,11 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func setVolume(_ vol: Float) {
|
||||
volume = vol
|
||||
player?.volume = vol
|
||||
}
|
||||
|
||||
// MARK: - Queue Management
|
||||
|
||||
func playNext(_ song: Song) {
|
||||
|
|
@ -1502,6 +1507,14 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
||||
|
||||
// Push state to Apple Watch — fires on song change, play/pause, and every 5s
|
||||
WatchConnectivityManager.shared.sendNowPlayingToWatch(
|
||||
song: currentSong,
|
||||
isPlaying: isPlaying,
|
||||
currentTime: currentTime,
|
||||
duration: duration
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
@ -2116,6 +2129,10 @@ class AudioPlayer: NSObject, ObservableObject {
|
|||
#if os(iOS)
|
||||
offlineVisBuffer = VisFrameBuffer.empty
|
||||
WidgetBridge.shared.clear()
|
||||
// Tell watch playback stopped
|
||||
WatchConnectivityManager.shared.sendNowPlayingToWatch(
|
||||
song: nil, isPlaying: false, currentTime: 0, duration: 0
|
||||
)
|
||||
#endif
|
||||
if isRadioStream {
|
||||
isRadioStream = false
|
||||
|
|
|
|||
|
|
@ -394,11 +394,15 @@ class WatchConnectivityManager: NSObject, ObservableObject {
|
|||
func sendNowPlayingToWatch(song: Song?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
|
||||
guard let session = session, session.isReachable else { return }
|
||||
|
||||
let player = AudioPlayer.shared
|
||||
var info: [String: Any] = [
|
||||
"type": "nowPlaying",
|
||||
"isPlaying": isPlaying,
|
||||
"currentTime": currentTime,
|
||||
"duration": duration
|
||||
"duration": duration,
|
||||
"shuffleEnabled": player.shuffleEnabled,
|
||||
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
||||
"volume": Double(player.volume)
|
||||
]
|
||||
|
||||
if let song = song {
|
||||
|
|
@ -543,6 +547,39 @@ extension WatchConnectivityManager: WCSessionDelegate {
|
|||
replyHandler(["success": true])
|
||||
}
|
||||
|
||||
case "seekCommand":
|
||||
if let time = message["time"] as? TimeInterval {
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.seek(to: time)
|
||||
replyHandler(["success": true])
|
||||
}
|
||||
} else {
|
||||
replyHandler(["error": "missing time"])
|
||||
}
|
||||
|
||||
case "shuffleCommand":
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.toggleShuffle()
|
||||
replyHandler(["shuffleEnabled": AudioPlayer.shared.shuffleEnabled])
|
||||
}
|
||||
|
||||
case "repeatCommand":
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.cycleRepeat()
|
||||
let mode = AudioPlayer.shared.repeatMode
|
||||
replyHandler(["repeatMode": mode == .off ? 0 : mode == .all ? 1 : 2])
|
||||
}
|
||||
|
||||
case "volumeCommand":
|
||||
if let vol = message["volume"] as? Double {
|
||||
DispatchQueue.main.async {
|
||||
AudioPlayer.shared.setVolume(Float(vol))
|
||||
replyHandler(["success": true])
|
||||
}
|
||||
} else {
|
||||
replyHandler(["error": "missing volume"])
|
||||
}
|
||||
|
||||
case "requestDownload":
|
||||
if let songId = message["songId"] as? String {
|
||||
Task {
|
||||
|
|
@ -561,6 +598,26 @@ extension WatchConnectivityManager: WCSessionDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
case "requestNowPlaying":
|
||||
DispatchQueue.main.async {
|
||||
let player = AudioPlayer.shared
|
||||
var reply: [String: Any] = [
|
||||
"isPlaying": player.isPlaying,
|
||||
"currentTime": player.currentTime,
|
||||
"duration": player.duration,
|
||||
"shuffleEnabled": player.shuffleEnabled,
|
||||
"repeatMode": player.repeatMode == .off ? 0 : player.repeatMode == .all ? 1 : 2,
|
||||
"volume": Double(player.volume)
|
||||
]
|
||||
if let song = player.currentSong {
|
||||
reply["title"] = song.title
|
||||
reply["artist"] = song.artist ?? ""
|
||||
reply["album"] = song.album ?? ""
|
||||
reply["coverArtId"] = song.coverArt ?? ""
|
||||
}
|
||||
replyHandler(reply)
|
||||
}
|
||||
|
||||
default:
|
||||
replyHandler(["error": "unhandled type: \(type)"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,11 +23,30 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
var currentTime: TimeInterval
|
||||
var duration: TimeInterval
|
||||
var coverArtId: String?
|
||||
var shuffleEnabled: Bool = false
|
||||
var repeatMode: Int = 0 // 0=off, 1=all, 2=one
|
||||
var volume: Float = 1.0
|
||||
}
|
||||
|
||||
private var session: WCSession?
|
||||
private let client = SubsonicClient()
|
||||
|
||||
/// Parse PhoneNowPlaying from a dictionary (reused by message handler + reply handler)
|
||||
static func parseNowPlaying(from dict: [String: Any]) -> PhoneNowPlaying {
|
||||
PhoneNowPlaying(
|
||||
title: dict["title"] as? String ?? "",
|
||||
artist: dict["artist"] as? String ?? "",
|
||||
album: dict["album"] as? String ?? "",
|
||||
isPlaying: dict["isPlaying"] as? Bool ?? false,
|
||||
currentTime: dict["currentTime"] as? TimeInterval ?? 0,
|
||||
duration: dict["duration"] as? TimeInterval ?? 0,
|
||||
coverArtId: dict["coverArtId"] as? String,
|
||||
shuffleEnabled: dict["shuffleEnabled"] as? Bool ?? false,
|
||||
repeatMode: dict["repeatMode"] as? Int ?? 0,
|
||||
volume: (dict["volume"] as? Double).map { Float($0) } ?? 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
if WCSession.isSupported() {
|
||||
|
|
@ -80,19 +99,70 @@ class WatchSessionManager: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
func sendPlayCommand() {
|
||||
session?.sendMessage(["type": "playCommand"], replyHandler: nil, errorHandler: nil)
|
||||
session?.sendMessage(["type": "playCommand"], replyHandler: { [weak self] reply in
|
||||
if let isPlaying = reply["isPlaying"] as? Bool {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.isPlaying = isPlaying
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendNextCommand() {
|
||||
session?.sendMessage(["type": "nextCommand"], replyHandler: nil, errorHandler: nil)
|
||||
session?.sendMessage(["type": "nextCommand"], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendPreviousCommand() {
|
||||
session?.sendMessage(["type": "previousCommand"], replyHandler: nil, errorHandler: nil)
|
||||
session?.sendMessage(["type": "previousCommand"], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendSeekCommand(to time: TimeInterval) {
|
||||
session?.sendMessage(["type": "seekCommand", "time": time], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendShuffleCommand() {
|
||||
session?.sendMessage(["type": "shuffleCommand"], replyHandler: { [weak self] reply in
|
||||
if let enabled = reply["shuffleEnabled"] as? Bool {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.shuffleEnabled = enabled
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendRepeatCommand() {
|
||||
session?.sendMessage(["type": "repeatCommand"], replyHandler: { [weak self] reply in
|
||||
if let mode = reply["repeatMode"] as? Int {
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.phoneNowPlaying?.repeatMode = mode
|
||||
}
|
||||
}
|
||||
}, errorHandler: nil)
|
||||
}
|
||||
|
||||
func sendVolumeCommand(_ volume: Float) {
|
||||
session?.sendMessage(["type": "volumeCommand", "volume": volume], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
/// Ask iPhone for current playback state (used on watch app launch)
|
||||
func requestNowPlaying() {
|
||||
guard let session = session, session.isReachable else { return }
|
||||
session.sendMessage(["type": "requestNowPlaying"], replyHandler: { reply in
|
||||
guard let title = reply["title"] as? String, !title.isEmpty else {
|
||||
DispatchQueue.main.async { self.phoneNowPlaying = nil }
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.phoneNowPlaying = Self.parseNowPlaying(from: reply)
|
||||
}
|
||||
}, errorHandler: { _ in })
|
||||
}
|
||||
|
||||
func requestDownload(songId: String) {
|
||||
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: nil, errorHandler: nil)
|
||||
session?.sendMessage(["type": "requestDownload", "songId": songId], replyHandler: { _ in }, errorHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - Direct API Access (watch can also talk to server directly)
|
||||
|
|
@ -394,15 +464,12 @@ extension WatchSessionManager: WCSessionDelegate {
|
|||
|
||||
if type == "nowPlaying" {
|
||||
DispatchQueue.main.async {
|
||||
self.phoneNowPlaying = PhoneNowPlaying(
|
||||
title: message["title"] as? String ?? "",
|
||||
artist: message["artist"] as? String ?? "",
|
||||
album: message["album"] as? String ?? "",
|
||||
isPlaying: message["isPlaying"] as? Bool ?? false,
|
||||
currentTime: message["currentTime"] as? TimeInterval ?? 0,
|
||||
duration: message["duration"] as? TimeInterval ?? 0,
|
||||
coverArtId: message["coverArtId"] as? String
|
||||
)
|
||||
// If no title, iPhone stopped playing — clear remote state
|
||||
guard let title = message["title"] as? String, !title.isEmpty else {
|
||||
self.phoneNowPlaying = nil
|
||||
return
|
||||
}
|
||||
self.phoneNowPlaying = Self.parseNowPlaying(from: message)
|
||||
}
|
||||
} else if type == "tagsUpdated" {
|
||||
handleTagsUpdated(message)
|
||||
|
|
|
|||
|
|
@ -6,15 +6,30 @@ struct WatchNowPlayingView: View {
|
|||
|
||||
@State private var crownVolume: Double = 0.8
|
||||
@State private var showRouteInfo = false
|
||||
@State private var volumeDebounceTask: Task<Void, Never>?
|
||||
@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
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,18 +54,24 @@ struct WatchNowPlayingView: View {
|
|||
}
|
||||
.padding(.top, 2)
|
||||
|
||||
// Progress bar
|
||||
// Progress bar (tap to seek)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.2))
|
||||
.frame(height: 3)
|
||||
.frame(height: 4)
|
||||
Capsule()
|
||||
.fill(Color.pink)
|
||||
.frame(width: geo.size.width * audioPlayer.progressPercent, height: 3)
|
||||
.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: 3)
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
// Time labels
|
||||
|
|
@ -115,24 +136,30 @@ struct WatchNowPlayingView: View {
|
|||
}
|
||||
.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))
|
||||
Spacer()
|
||||
|
||||
// Volume buttons
|
||||
Button(action: {
|
||||
let newVol = max(0, audioPlayer.volume - 0.1)
|
||||
audioPlayer.setVolume(newVol)
|
||||
crownVolume = Double(newVol)
|
||||
}) {
|
||||
Image(systemName: "speaker.minus.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.frame(width: 60)
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: {
|
||||
let newVol = min(1, audioPlayer.volume + 0.1)
|
||||
audioPlayer.setVolume(newVol)
|
||||
crownVolume = Double(newVol)
|
||||
}) {
|
||||
Image(systemName: "speaker.plus.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
|
|
@ -281,59 +308,199 @@ struct WatchNowPlayingView: View {
|
|||
// MARK: - Remote Control (iPhone is playing)
|
||||
|
||||
private func remoteNowPlaying(_ nowPlaying: WatchSessionManager.PhoneNowPlaying) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: 4) {
|
||||
// "Playing on iPhone" badge
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "iphone")
|
||||
.font(.system(size: 10))
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.blue)
|
||||
Text("Playing on iPhone")
|
||||
.font(.system(size: 10))
|
||||
Text("iPhone")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(Color.blue.opacity(0.15)))
|
||||
|
||||
VStack(spacing: 2) {
|
||||
Text(nowPlaying.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
// 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)
|
||||
)
|
||||
}
|
||||
|
||||
Text(nowPlaying.artist)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.pink)
|
||||
.lineLimit(1)
|
||||
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: 3)
|
||||
Capsule().fill(Color.white.opacity(0.2)).frame(height: 4)
|
||||
Capsule().fill(Color.pink)
|
||||
.frame(width: geo.size.width * pct, height: 3)
|
||||
.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: 3)
|
||||
.frame(height: 20)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Bottom row: shuffle, repeat, volume
|
||||
HStack(spacing: 14) {
|
||||
Button(action: { watchManager.sendShuffleCommand() }) {
|
||||
Image(systemName: "shuffle")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: { watchManager.sendRepeatCommand() }) {
|
||||
Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Volume buttons
|
||||
Button(action: {
|
||||
crownVolume = max(0, crownVolume - 0.1)
|
||||
watchManager.sendVolumeCommand(Float(crownVolume))
|
||||
}) {
|
||||
Image(systemName: "speaker.minus.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(action: {
|
||||
crownVolume = min(1, crownVolume + 0.1)
|
||||
watchManager.sendVolumeCommand(Float(crownVolume))
|
||||
}) {
|
||||
Image(systemName: "speaker.plus.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.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
|
||||
// Debounce: Crown fires rapidly — only send after 150ms of no changes
|
||||
volumeDebounceTask?.cancel()
|
||||
volumeDebounceTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(150))
|
||||
guard !Task.isCancelled else { return }
|
||||
watchManager.sendVolumeCommand(Float(newValue))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
crownVolume = Double(nowPlaying.volume)
|
||||
isCrownFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -347,7 +514,19 @@ struct WatchNowPlayingView: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
|
||||
// Show connect button if no Bluetooth
|
||||
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() }
|
||||
|
|
|
|||
Loading…
Reference in a new issue