Three bugs fixed across the full stack (proto/server/core/ABI/Win/macOS/iOS): 1. Join/Leave Voice now truly subscribes/unsubscribes from the voice plane. Previously the button only toggled the local mic — receiving was always on (gated by channel membership alone). Added a protocol-level voice subscription concept: new SubscribeVoiceRequest/UnsubscribeVoiceRequest/VoiceSubscriptionResult proto messages, User.voice_subscribed field, vc_join_voice/vc_leave_voice C ABI functions, VC_EVENT_VOICE_STATE event, server-side voice_subscribed flag checked by the SFU relay recipient filter, and core-client gating of remote-stream decoder setup. All three clients rewired to subscribe+mic on Join / unsubscribe on Leave. Text chat works regardless of voice subscription. 2. Channel edit dialog now shows the channel's actual current settings. The read struct vc_channel was missing sort_order and audio fields — only the write struct vc_channel_info had them. Extended vc_channel with both (additive, no ABI break), updated the session model and list_channels marshaling to populate them, and updated all three clients' edit callers to use actual channel info instead of hardcoded defaults. 3. Channel parameter updates now automatically restart everyone's streams. Previously editing a channel's audio config persisted and broadcast a ChannelEvent::UPDATED, but no layer restarted streams — encoders/decoders are frozen at announce time. handle_channel_event now detects audio-config changes on the user's current channel and stop->starts each active local stream. The server reads the updated config on re-announce; peers wire up fresh decoders at the new ssrc. All 29 CTest tests pass; Windows DLL + C# client build clean. Apple clients not yet compile-verified (Windows environment).
183 lines
7.4 KiB
Swift
183 lines
7.4 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.leaveVoice()
|
|
} else {
|
|
session.joinVoice()
|
|
}
|
|
} 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" : "Join Voice")
|
|
}
|
|
|
|
// 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.leaveVoice()
|
|
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.joinVoice() }
|
|
session.setPushToTalk(true)
|
|
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
}
|
|
.onEnded { _ in
|
|
session.setPushToTalk(false)
|
|
session.leaveVoice()
|
|
}
|
|
)
|
|
.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)
|
|
}
|
|
}
|