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:
parent
99bf17ec1a
commit
97f2f9ab76
4 changed files with 76 additions and 102 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue