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:
Dallas Groot 2026-04-20 13:06:06 -07:00
parent 127d5926a3
commit 5205d708c3
4 changed files with 378 additions and 58 deletions

View file

@ -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

View file

@ -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)"])
}

View file

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

View file

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