Add BeamScribe iOS app for real-time transcription with multipeer connectivity. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
232 lines
8.8 KiB
Swift
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())
|
|
}
|