From 97f2f9ab76b58dd9d98e6164826464c5e43d5ca2 Mon Sep 17 00:00:00 2001 From: Dallas Groot Date: Thu, 30 Apr 2026 23:13:40 -0700 Subject: [PATCH] Watch: large volume buttons, remove Digital Crown volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 8 +- project.yml | 2 +- watchOS/Audio/WatchAudioPlayer.swift | 58 +++++-------- watchOS/Views/WatchNowPlayingView.swift | 110 +++++++++++------------- 4 files changed, 76 insertions(+), 102 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9d67bb..16d9083 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,9 +57,11 @@ companion-api/ — Python FastAPI server on Raspberry Pi (Docker) - Every `player?.replaceCurrentItem(with: item)` MUST also set `self.playerItem = item` ### watchOS Audio -- NO HealthKit — speaker background runtime uses standard `audio` WKBackgroundModes -- Speaker mode: `.playback` category + `.default` policy + `setActive(true)` -- Bluetooth mode: `.playback` category + `.longFormAudio` policy + `activate(options:completionHandler:)` +- NO HealthKit — uses `.longFormAudio` policy for both speaker and Bluetooth +- `.longFormAudio` provides background runtime automatically +- 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()` - 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` diff --git a/project.yml b/project.yml index cf9602b..17b31bd 100644 --- a/project.yml +++ b/project.yml @@ -20,7 +20,7 @@ settings: base: SWIFT_VERSION: "5.9" MARKETING_VERSION: "1.0.0" - CURRENT_PROJECT_VERSION: "13" + CURRENT_PROJECT_VERSION: "15" DEAD_CODE_STRIPPING: true ENABLE_USER_SCRIPT_SANDBOXING: true DEVELOPMENT_TEAM: E9C9AGS9K6 diff --git a/watchOS/Audio/WatchAudioPlayer.swift b/watchOS/Audio/WatchAudioPlayer.swift index aeef898..b5504c2 100644 --- a/watchOS/Audio/WatchAudioPlayer.swift +++ b/watchOS/Audio/WatchAudioPlayer.swift @@ -87,19 +87,14 @@ class WatchAudioPlayer: NSObject, ObservableObject { private func configureAudioSession() { let session = AVAudioSession.sharedInstance() do { - if useSpeakerMode { - // Speaker mode: .default policy allows speaker output. - // No .longFormAudio — that policy requires Bluetooth and blocks - // speaker routing on pre-watchOS 11. The `audio` background mode - // in Info.plist keeps the app alive while audio plays. - try session.setCategory(.playback, mode: .default, policy: .default) - try session.setActive(true) - print("[WatchAudio] Session: .playback / .default (speaker mode)") - } else { - // Bluetooth mode: .longFormAudio requires BT/AirPlay - try session.setCategory(.playback, mode: .default, policy: .longFormAudio) - print("[WatchAudio] Session: .playback / .longFormAudio (bluetooth mode)") - } + // Always use .longFormAudio — this is the key to background runtime. + // On watchOS 11+, .longFormAudio falls through to the built-in speaker + // when no Bluetooth device is connected. The system handles routing: + // - Bluetooth connected → routes to Bluetooth + // - No Bluetooth → routes to speaker (Watch Ultra) + // Activation MUST use activate(options:completionHandler:), not setActive(true). + try session.setCategory(.playback, mode: .default, policy: .longFormAudio) + print("[WatchAudio] Session: .playback / .longFormAudio") updateRouteInfo() } catch { print("[WatchAudio] Session config FAILED: \(error.localizedDescription)") @@ -116,19 +111,10 @@ class WatchAudioPlayer: NSObject, ObservableObject { // 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 { - 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 result = await withCheckedContinuation { (continuation: CheckedContinuation<(Bool, String?), Never>) in @@ -175,9 +161,14 @@ class WatchAudioPlayer: NSObject, ObservableObject { self.updateRouteInfo() switch reason { case .oldDeviceUnavailable: - if !self.useSpeakerMode { - print("[WatchAudio] Bluetooth disconnected — pausing") + // Bluetooth disconnected. If speaker is available (Watch Ultra), + // 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() + } else { + print("[WatchAudio] Bluetooth disconnected — continuing on speaker") } case .newDeviceAvailable: print("[WatchAudio] New route: \(self.currentRouteName)") @@ -238,16 +229,9 @@ class WatchAudioPlayer: NSObject, ObservableObject { return } - if useSpeakerMode { - // Speaker mode: session already configured with .default policy. - // setActive(true) was called in configureAudioSession. - isSessionActive = true - playFromURL(playURL) - return - } - - // Bluetooth mode: activate session if needed - if isSessionActive && isBluetoothConnected { + // Activate session before playback. .longFormAudio provides background + // runtime. On watchOS 11+, routes to speaker if no BT connected. + if isSessionActive && (isBluetoothConnected || useSpeakerMode) { playFromURL(playURL) return } diff --git a/watchOS/Views/WatchNowPlayingView.swift b/watchOS/Views/WatchNowPlayingView.swift index 7b8a699..418a4e1 100644 --- a/watchOS/Views/WatchNowPlayingView.swift +++ b/watchOS/Views/WatchNowPlayingView.swift @@ -6,8 +6,6 @@ struct WatchNowPlayingView: View { @State private var crownVolume: Double = 0.8 @State private var showRouteInfo = false - @State private var volumeDebounceTask: Task? - @FocusState private var isCrownFocused: Bool var body: some View { Group { @@ -120,64 +118,60 @@ struct WatchNowPlayingView: View { Spacer() } - // Bottom row - HStack(spacing: 14) { + // Shuffle / Repeat + HStack(spacing: 20) { Button(action: { audioPlayer.toggleShuffle() }) { Image(systemName: "shuffle") - .font(.system(size: 11)) + .font(.system(size: 13)) .foregroundColor(audioPlayer.shuffleEnabled ? .pink : .gray) } .buttonStyle(.plain) Button(action: { audioPlayer.cycleRepeat() }) { Image(systemName: audioPlayer.repeatMode == .one ? "repeat.1" : "repeat") - .font(.system(size: 11)) + .font(.system(size: 13)) .foregroundColor(audioPlayer.repeatMode != .off ? .pink : .gray) } .buttonStyle(.plain) - - Spacer() - - // Volume buttons + } + + // Volume controls — dedicated row with large tap targets + HStack(spacing: 0) { 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) + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 36) + .background(Color.white.opacity(0.08)) + .cornerRadius(10) } .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: { 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) + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 36) + .background(Color.white.opacity(0.08)) + .cornerRadius(10) } .buttonStyle(.plain) } + .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) { audioRouteSheet } @@ -433,67 +427,61 @@ struct WatchNowPlayingView: View { Spacer() } - // Bottom row: shuffle, repeat, volume - HStack(spacing: 14) { + // Shuffle / Repeat + HStack(spacing: 20) { Button(action: { watchManager.sendShuffleCommand() }) { Image(systemName: "shuffle") - .font(.system(size: 11)) + .font(.system(size: 13)) .foregroundColor(nowPlaying.shuffleEnabled ? .pink : .gray) } .buttonStyle(.plain) Button(action: { watchManager.sendRepeatCommand() }) { Image(systemName: nowPlaying.repeatMode == 2 ? "repeat.1" : "repeat") - .font(.system(size: 11)) + .font(.system(size: 13)) .foregroundColor(nowPlaying.repeatMode != 0 ? .pink : .gray) } .buttonStyle(.plain) - - Spacer() - - // Volume buttons + } + + // Volume controls — dedicated row with large tap targets + HStack(spacing: 0) { Button(action: { crownVolume = max(0, crownVolume - 0.1) watchManager.sendVolumeCommand(Float(crownVolume)) }) { Image(systemName: "speaker.minus.fill") - .font(.system(size: 12)) - .foregroundColor(.gray) + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 36) + .background(Color.white.opacity(0.08)) + .cornerRadius(10) } .buttonStyle(.plain) + Text("\(Int(crownVolume * 100))%") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.gray) + .frame(width: 44) + Button(action: { crownVolume = min(1, crownVolume + 0.1) watchManager.sendVolumeCommand(Float(crownVolume)) }) { Image(systemName: "speaker.plus.fill") - .font(.system(size: 12)) - .foregroundColor(.gray) + .font(.system(size: 16)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: 36) + .background(Color.white.opacity(0.08)) + .cornerRadius(10) } .buttonStyle(.plain) } + .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 { crownVolume = Double(nowPlaying.volume) - isCrownFocused = true } }