Add a "speaker output" override so users can route audio to the built-in speaker instead of the earpiece when no headphones/Bluetooth are connected. Previously the receiver was the only fallback on the default Voice Chat preset. The toggle inserts .defaultToSpeaker into the AVAudioSession category options (skipped for the A2DP mode, where it would break Bluetooth routing). It yields to connected BT/wired output and is orthogonal to preset matching. Exposed both as a call-bar button in VoiceControlsView and a persisted Settings toggle.
183 lines
7.5 KiB
Swift
183 lines
7.5 KiB
Swift
import SwiftUI
|
|
import VoiceCatCore
|
|
import ReplayKit
|
|
|
|
struct VoiceControlsView: View {
|
|
@Bindable var session: SessionState
|
|
@StateObject private var router = IOSAudioRouter.shared
|
|
|
|
var body: some View {
|
|
HStack(spacing: 20) {
|
|
// Join/Leave Voice button (mirrors macOS micToggleButton)
|
|
if session.voiceState.inputMode == .pushToTalk {
|
|
PTTButton(session: session)
|
|
} else {
|
|
Button {
|
|
if session.voiceState.micActive {
|
|
session.stopMicStream()
|
|
} else {
|
|
session.startMicStream()
|
|
}
|
|
} label: {
|
|
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
|
|
.font(.body.weight(.semibold))
|
|
.frame(minWidth: 110)
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 12)
|
|
.background(session.voiceState.micActive ? Color.green.opacity(0.2) : Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
|
|
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
|
|
}
|
|
.disabled(session.currentChannelId == 0)
|
|
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio")
|
|
}
|
|
|
|
// Level meter
|
|
LevelMeterView(level: session.voiceState.level)
|
|
.frame(width: 80, height: 8)
|
|
.accessibilityHidden(true)
|
|
|
|
Spacer()
|
|
|
|
// Speaker output toggle — force the built-in speaker instead of the earpiece when
|
|
// no headphones/BT are connected. Mirrors the persisted Settings ▸ Audio toggle.
|
|
Button {
|
|
router.setForceSpeaker(!router.forceSpeaker)
|
|
} label: {
|
|
Image(systemName: router.forceSpeaker ? "speaker.wave.2.fill" : "speaker.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(router.forceSpeaker ? Color.accentColor : .primary)
|
|
}
|
|
.accessibilityLabel(router.forceSpeaker
|
|
? "Speaker on — turn off to use the earpiece"
|
|
: "Speaker off — turn on for speakerphone")
|
|
|
|
// Self mute (disabled when not in voice)
|
|
Button {
|
|
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
|
|
} label: {
|
|
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
|
|
.font(.title3)
|
|
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
|
|
}
|
|
.disabled(!session.voiceState.micActive)
|
|
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
|
|
|
// Self deafen (disabled when not in voice)
|
|
Button {
|
|
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
|
|
} label: {
|
|
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
|
|
.font(.title3)
|
|
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
|
|
}
|
|
.disabled(!session.voiceState.micActive)
|
|
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
|
|
|
// Share screen audio (ReplayKit system broadcast picker). The picker launches the
|
|
// broadcast upload extension; the host's BroadcastAudioPump then owns + feeds the
|
|
// SCREEN_AUDIO stream. Tinted while sharing.
|
|
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
|
|
.frame(width: 32, height: 32)
|
|
|
|
// Disconnect
|
|
Button(role: .destructive) {
|
|
session.stopMicStream()
|
|
session.client.disconnect()
|
|
} label: {
|
|
Image(systemName: "phone.down.fill")
|
|
.font(.title3)
|
|
.foregroundStyle(.red)
|
|
}
|
|
.accessibilityLabel("Disconnect from server")
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
.background(.bar)
|
|
}
|
|
}
|
|
|
|
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
|
|
|
|
private struct PTTButton: View {
|
|
@Bindable var session: SessionState
|
|
@GestureState private var isPressing = false
|
|
|
|
var body: some View {
|
|
Circle()
|
|
.fill(isPressing ? Color.blue : Color(.systemGray4))
|
|
.frame(width: 44, height: 44)
|
|
.overlay {
|
|
Image(systemName: "mic.fill")
|
|
.foregroundStyle(isPressing ? .white : .primary)
|
|
}
|
|
.gesture(
|
|
DragGesture(minimumDistance: 0)
|
|
.updating($isPressing) { _, state, _ in state = true }
|
|
.onChanged { _ in
|
|
if !isPressing { return }
|
|
if !session.voiceState.micActive { session.startMicStream() }
|
|
session.setPushToTalk(true)
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
}
|
|
.onEnded { _ in
|
|
session.setPushToTalk(false)
|
|
session.stopMicStream()
|
|
}
|
|
)
|
|
.accessibilityLabel("Push to talk, hold to transmit")
|
|
.accessibilityAddTraits(.isButton)
|
|
}
|
|
}
|
|
|
|
// MARK: - Broadcast picker
|
|
|
|
/// Wraps `RPSystemBroadcastPickerView` (which contains its own button) and points it at our
|
|
/// broadcast upload extension. Tapping it shows the system broadcast picker; the user starts the
|
|
/// broadcast and our extension launches.
|
|
private struct BroadcastPickerButton: UIViewRepresentable {
|
|
let isSharing: Bool
|
|
|
|
func makeUIView(context: Context) -> RPSystemBroadcastPickerView {
|
|
let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
|
|
picker.preferredExtension = "cat.voice.VoiceCatiOS.broadcast"
|
|
picker.showsMicrophoneButton = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
|
|
uiView.tintColor = isSharing ? .systemGreen : .label
|
|
// RPSystemBroadcastPickerView owns an inner UIButton that VoiceOver focuses; its default
|
|
// label is the picker's image name ("module icon"). Label the inner button directly —
|
|
// a SwiftUI .accessibilityLabel on the representable doesn't reach it.
|
|
let label = isSharing ? "Stop sharing screen audio" : "Share screen audio"
|
|
for case let button as UIButton in uiView.subviews {
|
|
button.accessibilityLabel = label
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Level Meter
|
|
|
|
private struct LevelMeterView: View {
|
|
let level: Float
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color(.systemGray5))
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(levelColor)
|
|
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
|
|
.animation(.linear(duration: 0.05), value: level)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var levelColor: Color {
|
|
if level > 0.15 { return .orange }
|
|
if level > 0.05 { return .green }
|
|
return .green.opacity(0.5)
|
|
}
|
|
}
|