Files
voice-cat/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Talon 5be6d8430d feat(ios): user-toggleable speakerphone output
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.
2026-06-21 15:47:20 +02:00

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)
}
}