Files
BeamScribe/BeamScribe/ContentView.swift
jared ce40831933 Initial commit
Add BeamScribe iOS app for real-time transcription with multipeer connectivity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:03:21 -05:00

232 lines
8.8 KiB
Swift

//
// ContentView.swift
// BeamScribe
//
// Main navigation container that switches between app phases.
//
import SwiftUI
import Combine
import MultipeerConnectivity
import AVFoundation
struct ContentView: View {
@EnvironmentObject var sessionState: SessionState
@StateObject private var transcriptionManager = TranscriptionManager()
@StateObject private var multipeerManager = MultipeerManager()
@StateObject private var fileManager = FileStorageManager()
@StateObject private var audioManager = AudioSessionManager()
@StateObject private var settings = SettingsModel()
var body: some View {
Group {
switch sessionState.appPhase {
case .roleSelection:
RoleSelectionView()
.transition(.move(edge: .leading))
case .hostSetup:
HostSetupView(
transcriptionManager: transcriptionManager,
multipeerManager: multipeerManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
case .soloSetup:
SoloSetupView(
transcriptionManager: transcriptionManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
case .guestBrowsing:
GuestBrowserView(
multipeerManager: multipeerManager,
fileManager: fileManager
)
.transition(.move(edge: .trailing))
case .activeSession:
TranscriptView(
transcriptionManager: transcriptionManager,
multipeerManager: multipeerManager,
fileManager: fileManager,
audioManager: audioManager
)
.transition(.opacity)
case .transcriptionHistory:
TranscriptionHistoryView(fileManager: fileManager)
.transition(.move(edge: .trailing))
}
}
.animation(.easeInOut(duration: 0.3), value: sessionState.appPhase)
.environmentObject(settings)
.onAppear {
setupCallbacks()
audioManager.startBatteryMonitoring()
}
}
// MARK: - Setup
private func setupCallbacks() {
// Handle transcription results (Host)
transcriptionManager.onPartialResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
// Broadcast partial result to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: false
)
}
}
transcriptionManager.onFinalResult = { [weak sessionState] text in
guard let sessionState = sessionState else { return }
// Add to local state
Task { @MainActor in
sessionState.addSegment(TranscriptSegment(text: text, isFinal: true))
}
// Save to file
fileManager.appendText(text)
// Broadcast to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendLiveChunk(
text: text,
eventName: sessionState.eventName,
isFinal: true
)
}
}
transcriptionManager.onSessionResumed = { [weak sessionState] in
guard let sessionState = sessionState else { return }
Task { @MainActor in
sessionState.insertResumedMarker()
}
// Broadcast resume marker to guests (skip if solo mode)
if !sessionState.isSoloMode {
multipeerManager.sendAlert(
type: .sessionResumed,
eventName: sessionState.eventName
)
}
// Also append to file
let formatter = DateFormatter()
formatter.timeStyle = .short
let marker = "[Session Resumed at \(formatter.string(from: Date()))]"
fileManager.appendMarker(marker)
}
// Handle received packets (Guest)
multipeerManager.onPacketReceived = { [weak sessionState, weak transcriptionManager] packet in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
Task { @MainActor in
switch packet.type {
case .fullHistory:
if let text = packet.text {
sessionState.loadFullHistory(text)
// Sync start time from host (using relative duration to avoid clock skew)
if let duration = packet.currentSessionDuration {
let calculatedStartTime = Date().addingTimeInterval(-duration)
sessionState.startTime = calculatedStartTime
}
// Cancel pending appends to avoid race conditions? No, just overwrite.
// Overwrite file to avoid duplication if full history is received multiple times
fileManager.overwriteCurrentFile(with: text, eventName: sessionState.eventName)
}
case .liveChunk:
if let text = packet.text {
let isFinal = packet.isFinal ?? true
if isFinal {
// Use updateLastPartialSegment even for final to ensure we replace any existing partial
// This prevents "Ghost" partials from staying on screen alongside the final result
sessionState.updateLastPartialSegment(text, isFinal: true)
fileManager.appendText(text)
} else {
sessionState.updateLastPartialSegment(text, isFinal: false)
}
}
case .alert:
switch packet.alertType {
case .hostDisconnected:
sessionState.showHostLostBanner = true
transcriptionManager.sessionEndTime = Date() // Freeze timer
case .hostBatteryLow:
sessionState.showBatteryWarning = true
case .sessionResumed:
sessionState.insertResumedMarker()
case .none:
break
}
}
}
}
// Handle host lost (Guest)
multipeerManager.onHostLost = { [weak sessionState, weak transcriptionManager] in
guard let sessionState = sessionState, let transcriptionManager = transcriptionManager else { return }
Task { @MainActor in
sessionState.showHostLostBanner = true
sessionState.isConnectedToHost = false
// Freeze the timer
transcriptionManager.sessionEndTime = Date()
}
}
// Handle peer count changes (UI update only)
multipeerManager.onPeerCountChanged = { [weak sessionState] count in
guard let sessionState = sessionState else { return }
Task { @MainActor in
sessionState.connectedPeerCount = count
}
}
// Handle specific peer join (Send History)
multipeerManager.onPeerJoined = { [weak sessionState] peerID in
guard let sessionState = sessionState else { return }
Task { @MainActor in
// If host, send full history to the new peer
if sessionState.userRole == .host {
print("New peer joined: \(peerID.displayName). Sending full history.")
multipeerManager.sendFullHistory(
to: peerID,
text: sessionState.fullTranscriptText,
eventName: sessionState.eventName,
startTime: sessionState.startTime
)
}
}
}
}
}
#Preview {
ContentView()
.environmentObject(SessionState())
}