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.
This commit is contained in:
Dallas Groot 2026-04-30 23:13:40 -07:00
parent 99bf17ec1a
commit 97f2f9ab76
4 changed files with 76 additions and 102 deletions

View file

@ -57,9 +57,11 @@ companion-api/ — Python FastAPI server on Raspberry Pi (Docker)
- Every `player?.replaceCurrentItem(with: item)` MUST also set `self.playerItem = item` - Every `player?.replaceCurrentItem(with: item)` MUST also set `self.playerItem = item`
### watchOS Audio ### watchOS Audio
- NO HealthKit — speaker background runtime uses standard `audio` WKBackgroundModes - NO HealthKit — uses `.longFormAudio` policy for both speaker and Bluetooth
- Speaker mode: `.playback` category + `.default` policy + `setActive(true)` - `.longFormAudio` provides background runtime automatically
- Bluetooth mode: `.playback` category + `.longFormAudio` policy + `activate(options:completionHandler:)` - On watchOS 11+, `.longFormAudio` falls through to built-in speaker when no Bluetooth connected
- Activation MUST use `activate(options:completionHandler:)` — NOT `setActive(true)` (throws OSStatus 561145203)
- `useSpeakerMode` toggle is a user preference only — underlying session policy is always `.longFormAudio`
- Watch remote control: iPhone pushes state via `sendNowPlayingToWatch()` in `updateNowPlayingInfo()` - Watch remote control: iPhone pushes state via `sendNowPlayingToWatch()` in `updateNowPlayingInfo()`
- All WCSession `sendMessage` calls MUST use `replyHandler: { _ in }` (not `nil`) — `nil` routes to wrong delegate method - All WCSession `sendMessage` calls MUST use `replyHandler: { _ in }` (not `nil`) — `nil` routes to wrong delegate method
- iPhone command handlers (`playCommand`, `nextCommand`, etc.) dispatch to `DispatchQueue.main.async` - iPhone command handlers (`playCommand`, `nextCommand`, etc.) dispatch to `DispatchQueue.main.async`

View file

@ -20,7 +20,7 @@ settings:
base: base:
SWIFT_VERSION: "5.9" SWIFT_VERSION: "5.9"
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "13" CURRENT_PROJECT_VERSION: "15"
DEAD_CODE_STRIPPING: true DEAD_CODE_STRIPPING: true
ENABLE_USER_SCRIPT_SANDBOXING: true ENABLE_USER_SCRIPT_SANDBOXING: true
DEVELOPMENT_TEAM: E9C9AGS9K6 DEVELOPMENT_TEAM: E9C9AGS9K6

View file

@ -87,19 +87,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
private func configureAudioSession() { private func configureAudioSession() {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
do { do {
if useSpeakerMode { // Always use .longFormAudio this is the key to background runtime.
// Speaker mode: .default policy allows speaker output. // On watchOS 11+, .longFormAudio falls through to the built-in speaker
// No .longFormAudio that policy requires Bluetooth and blocks // when no Bluetooth device is connected. The system handles routing:
// speaker routing on pre-watchOS 11. The `audio` background mode // - Bluetooth connected routes to Bluetooth
// in Info.plist keeps the app alive while audio plays. // - No Bluetooth routes to speaker (Watch Ultra)
try session.setCategory(.playback, mode: .default, policy: .default) // Activation MUST use activate(options:completionHandler:), not setActive(true).
try session.setActive(true) try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .default (speaker mode)") print("[WatchAudio] Session: .playback / .longFormAudio")
} else {
// Bluetooth mode: .longFormAudio requires BT/AirPlay
try session.setCategory(.playback, mode: .default, policy: .longFormAudio)
print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)")
}
updateRouteInfo() updateRouteInfo()
} catch { } catch {
print("[WatchAudio] Session config FAILED: \(error.localizedDescription)") print("[WatchAudio] Session config FAILED: \(error.localizedDescription)")
@ -116,19 +111,10 @@ class WatchAudioPlayer: NSObject, ObservableObject {
// MARK: - Session Activation // MARK: - Session Activation
/// Activate the audio session. Must be called before playback.
/// Uses activate(options:completionHandler:) which is required for .longFormAudio.
/// On watchOS 11+, this routes to speaker if no Bluetooth is available.
func activateSession() async -> Bool { func activateSession() async -> Bool {
if useSpeakerMode {
// Speaker mode: session is already active from configureAudioSession.
// No Bluetooth route picker needed.
await MainActor.run {
isSessionActive = true
updateRouteInfo()
}
return true
}
// Bluetooth mode: must use activate(options:completionHandler:) NOT setActive(true).
// setActive(true) throws OSStatus 561145203 with .longFormAudio policy.
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in let result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in
@ -175,9 +161,14 @@ class WatchAudioPlayer: NSObject, ObservableObject {
self.updateRouteInfo() self.updateRouteInfo()
switch reason { switch reason {
case .oldDeviceUnavailable: case .oldDeviceUnavailable:
if !self.useSpeakerMode { // Bluetooth disconnected. If speaker is available (Watch Ultra),
print("[WatchAudio] Bluetooth disconnected — pausing") // audio will automatically route to speaker don't pause.
// Only pause if no speaker (standard Watch models).
if !self.isSpeakerAvailable {
print("[WatchAudio] Bluetooth disconnected, no speaker — pausing")
self.pause() self.pause()
} else {
print("[WatchAudio] Bluetooth disconnected — continuing on speaker")
} }
case .newDeviceAvailable: case .newDeviceAvailable:
print("[WatchAudio] New route: \(self.currentRouteName)") print("[WatchAudio] New route: \(self.currentRouteName)")
@ -238,16 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject {
return return
} }
if useSpeakerMode { // Activate session before playback. .longFormAudio provides background
// Speaker mode: session already configured with .default policy. // runtime. On watchOS 11+, routes to speaker if no BT connected.
// setActive(true) was called in configureAudioSession. if isSessionActive && (isBluetoothConnected || useSpeakerMode) {
isSessionActive = true
playFromURL(playURL)
return
}
// Bluetooth mode: activate session if needed
if isSessionActive && isBluetoothConnected {
playFromURL(playURL) playFromURL(playURL)
return return
} }

View file

@ -6,8 +6,6 @@ struct WatchNowPlayingView: View {
@State private var crownVolume: Double = 0.8 @State private var crownVolume: Double = 0.8
@State private var showRouteInfo = false @State private var showRouteInfo = false
@State private var volumeDebounceTask: Task<Void, Never>?
@FocusState private var isCrownFocused: Bool
var body: some View { var body: some View {
Group { Group {
@ -120,64 +118,60 @@ struct WatchNowPlayingView: View {
Spacer() Spacer()
} }
// Bottom row // Shuffle / Repeat
HStack(spacing: 14) { HStack(spacing: 20) {
Button(action: { audioPlayer.toggleShuffle() }) { Button(action: { audioPlayer.toggleShuffle() }) {
Image(systemName: "shuffle") Image(systemName: "shuffle")
.font(.system(size: 11)) .font(.system(size: 13))
.foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray) .foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: { audioPlayer.cycleRepeat() }) { Button(action: { audioPlayer.cycleRepeat() }) {
Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat")
.font(.system(size: 11)) .font(.system(size: 13))
.foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray) .foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
}
Spacer() // Volume controls dedicated row with large tap targets
HStack(spacing: 0) {
// Volume buttons
Button(action: { Button(action: {
let newVol = max(0, audioPlayer.volume - 0.1) let newVol = max(0, audioPlayer.volume - 0.1)
audioPlayer.setVolume(newVol) audioPlayer.setVolume(newVol)
crownVolume = Double(newVol)
}) { }) {
Image(systemName: "speaker.minus.fill") Image(systemName: "speaker.minus.fill")
.font(.system(size: 12)) .font(.system(size: 16))
.foregroundColor(.gray) .foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
} }
.buttonStyle(.plain) .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: { Button(action: {
let newVol = min(1, audioPlayer.volume + 0.1) let newVol = min(1, audioPlayer.volume + 0.1)
audioPlayer.setVolume(newVol) audioPlayer.setVolume(newVol)
crownVolume = Double(newVol)
}) { }) {
Image(systemName: "speaker.plus.fill") Image(systemName: "speaker.plus.fill")
.font(.system(size: 12)) .font(.system(size: 16))
.foregroundColor(.gray) .foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 4)
} }
.padding(.horizontal, 4) .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) { .sheet(isPresented: $showRouteInfo) {
audioRouteSheet audioRouteSheet
} }
@ -433,67 +427,61 @@ struct WatchNowPlayingView: View {
Spacer() Spacer()
} }
// Bottom row: shuffle, repeat, volume // Shuffle / Repeat
HStack(spacing: 14) { HStack(spacing: 20) {
Button(action: { watchManager.sendShuffleCommand() }) { Button(action: { watchManager.sendShuffleCommand() }) {
Image(systemName: "shuffle") Image(systemName: "shuffle")
.font(.system(size: 11)) .font(.system(size: 13))
.foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray) .foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Button(action: { watchManager.sendRepeatCommand() }) { Button(action: { watchManager.sendRepeatCommand() }) {
Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat") Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat")
.font(.system(size: 11)) .font(.system(size: 13))
.foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray) .foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
}
Spacer() // Volume controls dedicated row with large tap targets
HStack(spacing: 0) {
// Volume buttons
Button(action: { Button(action: {
crownVolume = max(0, crownVolume - 0.1) crownVolume = max(0, crownVolume - 0.1)
watchManager.sendVolumeCommand(Float(crownVolume)) watchManager.sendVolumeCommand(Float(crownVolume))
}) { }) {
Image(systemName: "speaker.minus.fill") Image(systemName: "speaker.minus.fill")
.font(.system(size: 12)) .font(.system(size: 16))
.foregroundColor(.gray) .foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
Text("\(Int(crownVolume * 100))%")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.gray)
.frame(width: 44)
Button(action: { Button(action: {
crownVolume = min(1, crownVolume + 0.1) crownVolume = min(1, crownVolume + 0.1)
watchManager.sendVolumeCommand(Float(crownVolume)) watchManager.sendVolumeCommand(Float(crownVolume))
}) { }) {
Image(systemName: "speaker.plus.fill") Image(systemName: "speaker.plus.fill")
.font(.system(size: 12)) .font(.system(size: 16))
.foregroundColor(.gray) .foregroundColor(.white)
.frame(maxWidth: .infinity, minHeight: 36)
.background(Color.white.opacity(0.08))
.cornerRadius(10)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding(.horizontal, 4)
} }
.padding(.horizontal, 4) .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 { .onAppear {
crownVolume = Double(nowPlaying.volume) crownVolume = Double(nowPlaying.volume)
isCrownFocused = true
} }
} }