feat(ios): ship iOS SwiftUI client (VoiceCatiOS)
Full SwiftUI app at clients/apple/iOS/VoiceCatiOS.xcodeproj:
- 24 Swift source files: AppState + SessionState (@Observable @MainActor),
AudioSessionManager (AVAudioSession owner + interruption/route handling),
ServerListStore/SavedServer (App Group container + Keychain sharing),
and 14 SwiftUI views covering the full feature set
- NavigationSplitView on iPad, TabView on iPhone (horizontalSizeClass)
- Channel tree via OutlineGroup, user list with context menu admin actions
- PTT via DragGesture(minimumDistance: 0) + @GestureState
- onEvent closures hop to MainActor via Task { @MainActor in ... }
- App Group: group.cat.voice.VoiceCat (shared with future ReplayKit extension)
C ABI: add vc_audio_suspend / vc_audio_resume (AudioEngine::suspend/resume)
called by AudioSessionManager on AVAudioSession interruption events.
XCFramework: add ios-arm64 and ios-arm64-simulator slices to build-xcframework.sh;
Package.swift gains .iOS(.v17) platform; CMakePresets.json adds apple-ios /
apple-ios-sim presets with arm64-ios / arm64-ios-simulator vcpkg triplets.
Verified: xcodebuild -target VoiceCatiOS -sdk iphonesimulator26.5 BUILD SUCCEEDED.
This commit is contained in:
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
130
clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import VoiceCatCore
|
||||
|
||||
struct VoiceControlsView: View {
|
||||
@Bindable var session: SessionState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 20) {
|
||||
// Mic toggle / PTT button
|
||||
if session.voiceState.inputMode == .pushToTalk {
|
||||
PTTButton(session: session)
|
||||
} else {
|
||||
Button {
|
||||
if session.voiceState.micActive {
|
||||
session.stopMicStream()
|
||||
} else {
|
||||
session.startMicStream()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: session.voiceState.micActive ? "mic.fill" : "mic.slash.fill")
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(session.voiceState.micActive ? Color.green : Color(.systemGray4), in: Circle())
|
||||
.foregroundStyle(session.voiceState.micActive ? .white : .primary)
|
||||
}
|
||||
.disabled(session.currentChannelId == 0)
|
||||
.accessibilityLabel(session.voiceState.micActive ? "Stop microphone" : "Start microphone")
|
||||
}
|
||||
|
||||
// Level meter
|
||||
LevelMeterView(level: session.voiceState.level)
|
||||
.frame(width: 80, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Self mute
|
||||
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)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
|
||||
|
||||
// Self deafen
|
||||
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)
|
||||
}
|
||||
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
|
||||
|
||||
// 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: - 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user