Add BeamScribe iOS app for real-time transcription with multipeer connectivity. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
235 lines
8.2 KiB
Swift
235 lines
8.2 KiB
Swift
//
|
|
// GuestBrowserView.swift
|
|
// BeamScribe
|
|
//
|
|
// Displays list of nearby hosts for guests to join.
|
|
//
|
|
|
|
import SwiftUI
|
|
import MultipeerConnectivity
|
|
|
|
struct GuestBrowserView: View {
|
|
@EnvironmentObject var sessionState: SessionState
|
|
@ObservedObject var multipeerManager: MultipeerManager
|
|
@ObservedObject var fileManager: FileStorageManager
|
|
|
|
@State private var isConnecting: Bool = false
|
|
@State private var selectedHost: MCPeerID?
|
|
@State private var connectionFailed: Bool = false
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Background
|
|
LinearGradient(
|
|
colors: [Color.purple.opacity(0.05), Color.blue.opacity(0.05)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
.ignoresSafeArea()
|
|
|
|
VStack(spacing: 24) {
|
|
// Header
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: 50))
|
|
.foregroundColor(.purple)
|
|
.symbolEffect(.variableColor.iterative.reversing, options: .repeating)
|
|
|
|
Text("Finding Sessions...")
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
|
|
Text("Looking for nearby hosts")
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.top, 40)
|
|
|
|
// Connection Failed Alert
|
|
if connectionFailed {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.orange)
|
|
Text("Connection failed. Tap to try again.")
|
|
.font(.callout)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.padding()
|
|
.background(Color.orange.opacity(0.15))
|
|
.cornerRadius(12)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
// Host List
|
|
if multipeerManager.availableHosts.isEmpty {
|
|
Spacer()
|
|
|
|
VStack(spacing: 16) {
|
|
ProgressView()
|
|
.scaleEffect(1.5)
|
|
|
|
Text("Searching for active sessions")
|
|
.font(.callout)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Make sure the host has started transcribing")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
|
|
Spacer()
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
ForEach(Array(multipeerManager.availableHosts.keys), id: \.self) { peerID in
|
|
HostRow(
|
|
eventName: multipeerManager.availableHosts[peerID] ?? "Unknown",
|
|
deviceName: peerID.displayName,
|
|
isConnecting: isConnecting && selectedHost == peerID,
|
|
statusMessage: selectedHost == peerID ? multipeerManager.connectionStatus : ""
|
|
) {
|
|
joinHost(peerID)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|
|
|
|
// Cancel Button
|
|
Button(action: {
|
|
// Cancel any pending retries
|
|
if let host = selectedHost {
|
|
multipeerManager.cancelRetry(for: host.displayName)
|
|
}
|
|
multipeerManager.stopBrowsing()
|
|
withAnimation {
|
|
sessionState.appPhase = .roleSelection
|
|
}
|
|
}) {
|
|
Text("Cancel")
|
|
.foregroundColor(.secondary)
|
|
.padding(.vertical, 16)
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
}
|
|
.onAppear {
|
|
multipeerManager.startBrowsing()
|
|
setupConnectionFailedHandler()
|
|
}
|
|
.onDisappear {
|
|
if sessionState.appPhase != .activeSession {
|
|
multipeerManager.stopBrowsing()
|
|
}
|
|
}
|
|
.onChange(of: multipeerManager.isConnectedToHost) { oldValue, newValue in
|
|
if newValue, let host = selectedHost,
|
|
let eventName = multipeerManager.availableHosts[host] {
|
|
// Connected successfully
|
|
// Note: We do NOT call stopBrowsing() here anymore because it kills the session.
|
|
// The MultipeerManager handles the radio shutdown safely after stabilization.
|
|
|
|
// Create a transcript file for the guest immediately
|
|
_ = try? fileManager.createTranscriptFile(eventName: eventName)
|
|
|
|
sessionState.joinGuestSession(eventName: eventName)
|
|
|
|
withAnimation {
|
|
sessionState.appPhase = .activeSession
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func joinHost(_ peerID: MCPeerID) {
|
|
selectedHost = peerID
|
|
isConnecting = true
|
|
connectionFailed = false
|
|
|
|
multipeerManager.joinHost(peerID)
|
|
}
|
|
|
|
private func setupConnectionFailedHandler() {
|
|
multipeerManager.onConnectionFailed = { [self] failedPeer in
|
|
Task { @MainActor in
|
|
if failedPeer == selectedHost {
|
|
isConnecting = false
|
|
connectionFailed = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Host Row
|
|
|
|
struct HostRow: View {
|
|
let eventName: String
|
|
let deviceName: String
|
|
let isConnecting: Bool
|
|
let statusMessage: String
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 16) {
|
|
// Icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.purple.opacity(0.15))
|
|
.frame(width: 50, height: 50)
|
|
|
|
Image(systemName: "waveform")
|
|
.font(.title2)
|
|
.foregroundColor(.purple)
|
|
}
|
|
|
|
// Event Info
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(eventName)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Text(deviceName)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
// Show connection status when connecting
|
|
if isConnecting && !statusMessage.isEmpty {
|
|
Text(statusMessage)
|
|
.font(.caption2)
|
|
.foregroundColor(.orange)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Join Indicator
|
|
if isConnecting {
|
|
ProgressView()
|
|
} else {
|
|
Image(systemName: "arrow.right.circle.fill")
|
|
.font(.title2)
|
|
.foregroundColor(.purple)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(Color(.systemBackground))
|
|
.cornerRadius(16)
|
|
.shadow(color: .black.opacity(0.05), radius: 5, x: 0, y: 2)
|
|
}
|
|
.disabled(isConnecting)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
GuestBrowserView(
|
|
multipeerManager: MultipeerManager(),
|
|
fileManager: FileStorageManager()
|
|
)
|
|
.environmentObject(SessionState())
|
|
}
|