Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions apps/teleop-native/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// swift-tools-version: 6.0

import PackageDescription

let package = Package(
name: "TeleopNative",
platforms: [
.iOS(.v17),
.macOS(.v14),
],
products: [
.library(name: "TeleopUI", targets: ["TeleopUI"]),
.executable(name: "TeleopMac", targets: ["TeleopMac"]),
],
targets: [
.target(name: "TeleopUI"),
.executableTarget(
name: "TeleopMac",
dependencies: ["TeleopUI"]
),
]
)
29 changes: 29 additions & 0 deletions apps/teleop-native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Teleop Native

Native SwiftUI prototype for low-latency voice teleoperation on macOS and iOS.

## OpenUI Result Summary

- First screen is the control surface, not a landing page.
- One primary animated voice button drives the interaction.
- Visible text is limited to a tiny transient agent cue.
- Telemetry is icon-first: signal, power, and motion are visual gauges.
- Voice/chat owns intent. The button starts, interrupts, resumes, or reconnects.
- WebRTC/media plumbing is intentionally a future integration layer behind the current `TeleopSession` state object.

## Architecture Target

`Mobile or macOS app -> WebRTC session -> global relay -> transceiver -> realtime agent -> teleop gateway -> device`

The app should keep hard safety controls local to the device gateway. The model can interpret intent, but stop limits and degraded-network behavior should not depend on a cloud round trip.

## Run Mac Prototype

```bash
cd apps/teleop-native
swift run TeleopMac
```

## iOS

Open `apps/teleop-native/Package.swift` in Xcode and use `TeleopUI` from a new iOS app target. The shared SwiftUI view is `TeleopHomeView`.
13 changes: 13 additions & 0 deletions apps/teleop-native/Sources/TeleopMac/TeleopMacApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import SwiftUI
import TeleopUI

@main
struct TeleopMacApp: App {
var body: some Scene {
WindowGroup {
TeleopHomeView()
.frame(minWidth: 360, minHeight: 620)
}
.windowStyle(.hiddenTitleBar)
}
}
135 changes: 135 additions & 0 deletions apps/teleop-native/Sources/TeleopUI/TeleopHomeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import SwiftUI

public struct TeleopHomeView: View {
@StateObject private var session = TeleopSession()

public init() {}

public var body: some View {
ZStack {
TeleopBackground(state: session.state)

VStack(spacing: 26) {
TelemetryStrip(sample: session.telemetry, state: session.state)
.padding(.top, 26)

Spacer(minLength: 20)

TeleopPulseButton(state: session.state) {
session.pressPrimary()
}

if !session.agentSummary.isEmpty {
AgentCue(text: session.agentSummary)
.transition(.opacity.combined(with: .scale(scale: 0.96)))
}

Spacer(minLength: 32)
}
.padding(.horizontal, 24)
.padding(.bottom, 20)
}
.accessibilityElement(children: .contain)
.onDisappear {
session.stop()
}
}
}

private struct TeleopBackground: View {
let state: TeleopConnectionState

var body: some View {
LinearGradient(
colors: backgroundColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
.animation(.easeInOut(duration: 0.35), value: state)
}

private var backgroundColors: [Color] {
switch state {
case .idle:
return [Color(red: 0.05, green: 0.06, blue: 0.07), Color(red: 0.0, green: 0.09, blue: 0.08)]
case .connecting:
return [Color(red: 0.03, green: 0.07, blue: 0.10), Color(red: 0.0, green: 0.18, blue: 0.16)]
case .live:
return [Color(red: 0.02, green: 0.11, blue: 0.08), Color(red: 0.10, green: 0.16, blue: 0.07)]
case .thinking:
return [Color(red: 0.10, green: 0.09, blue: 0.02), Color(red: 0.16, green: 0.11, blue: 0.02)]
case .fault:
return [Color(red: 0.12, green: 0.03, blue: 0.04), Color(red: 0.04, green: 0.02, blue: 0.03)]
}
}
}

private struct TelemetryStrip: View {
let sample: TelemetrySample
let state: TeleopConnectionState

var body: some View {
HStack(spacing: 18) {
GaugeGlyph(
systemName: "point.3.connected.trianglepath.dotted",
value: signalValue,
tint: .mint
)
GaugeGlyph(systemName: "bolt.fill", value: sample.battery, tint: .yellow)
GaugeGlyph(systemName: "waveform.path.ecg", value: motionValue, tint: .cyan)
}
.opacity(state == .idle ? 0.38 : 1)
.animation(.easeInOut(duration: 0.25), value: state)
.accessibilityLabel("Telemetry")
}

private var signalValue: Double {
state == .idle ? 0.12 : sample.signal
}

private var motionValue: Double {
state == .idle ? 0.08 : (sample.motion + 1.0) / 2.0
}
}

private struct GaugeGlyph: View {
let systemName: String
let value: Double
let tint: Color

var body: some View {
ZStack {
Circle()
.stroke(.white.opacity(0.12), lineWidth: 4)
Circle()
.trim(from: 0, to: max(0.05, min(1.0, value)))
.stroke(tint, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white.opacity(0.86))
}
.frame(width: 48, height: 48)
.animation(.smooth(duration: 0.18), value: value)
}
}

private struct AgentCue: View {
let text: String

var body: some View {
Text(text)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.white.opacity(0.10), in: Capsule())
.accessibilityLabel(text)
}
}

#Preview {
TeleopHomeView()
.frame(width: 390, height: 740)
}
104 changes: 104 additions & 0 deletions apps/teleop-native/Sources/TeleopUI/TeleopPulseButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import SwiftUI

struct TeleopPulseButton: View {
let state: TeleopConnectionState
let action: () -> Void

@State private var pulse = false

var body: some View {
Button(action: action) {
ZStack {
ForEach(0..<3, id: \.self) { index in
Circle()
.stroke(ringColor.opacity(0.30), lineWidth: 2)
.frame(width: 170 + CGFloat(index * 42), height: 170 + CGFloat(index * 42))
.scaleEffect(pulse ? 1.12 : 0.86)
.opacity(pulse ? 0.0 : 0.55)
.animation(
.easeOut(duration: 1.45)
.repeatForever(autoreverses: false)
.delay(Double(index) * 0.18),
value: pulse
)
}

Circle()
.fill(buttonFill)
.frame(width: 168, height: 168)
.shadow(color: ringColor.opacity(0.34), radius: 30, y: 16)

Image(systemName: iconName)
.font(.system(size: 58, weight: .semibold))
.foregroundStyle(.white)
.symbolEffect(.pulse, options: .repeating, value: isAnimated)
}
.frame(width: 280, height: 280)
}
.buttonStyle(.plain)
.accessibilityLabel(accessibilityLabel)
.onAppear {
pulse = true
}
.onChange(of: state) { _, newState in
pulse = newState != .idle
}
}

private var iconName: String {
switch state {
case .idle:
return "mic.fill"
case .connecting:
return "antenna.radiowaves.left.and.right"
case .live:
return "waveform"
case .thinking:
return "hand.raised.fill"
case .fault:
return "exclamationmark.triangle.fill"
}
}

private var accessibilityLabel: String {
switch state {
case .idle:
return "Start voice teleoperation"
case .connecting:
return "Cancel connection"
case .live:
return "Interrupt or stop motion"
case .thinking:
return "Resume live control"
case .fault:
return "Reconnect"
}
}

private var buttonFill: LinearGradient {
LinearGradient(
colors: [ringColor.opacity(0.95), ringColor.opacity(0.48)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}

private var ringColor: Color {
switch state {
case .idle:
return .teal
case .connecting:
return .cyan
case .live:
return .green
case .thinking:
return .orange
case .fault:
return .red
}
}

private var isAnimated: Bool {
state == .connecting || state == .live || state == .thinking
}
}
Loading
Loading