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`
|
- 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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue