import SwiftUI import VoiceCatCore import ReplayKit struct VoiceControlsView: View { @Bindable var session: SessionState 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() // 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) .accessibilityLabel(session.voiceState.screenSharing ? "Stop sharing screen audio" : "Share screen audio") // 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 } } // 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) } }