Skip to content
Closed
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
63 changes: 63 additions & 0 deletions RemoteCam/CameraLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
//
// CameraLink.swift
// RemoteShutter
//
// Copyright © 2026 Security Union LLC. All rights reserved.
//

import Foundation
import MPCCompat
import Stormo

/// One camera in a multicam director session. The director holds one of these
/// per connected camera, keyed by `MCPeerID`; it is the multicam analog of the
/// single link + single `FrameStreamReceiver` that `SessionCoordinator` holds
/// for a 1:1 monitor.
///
/// A reference type, not a struct: it owns a `FrameStreamReceiver` (a class
/// with a running decode timer) and the `MulticamController` actor mutates it
/// in place as frames, capabilities and clock samples arrive — a value type
/// would force a dictionary read-modify-write on every frame.
final class CameraLink {

enum Status: Equatable {
/// The session is up and frames are expected.
case linked
/// The link dropped; the director is re-browsing to invite it back.
/// The tile stays on screen (last frame frozen) rather than vanishing.
case reconnecting
/// The peer is gone for good (removed by the user, or version-refused).
case failed
}

let peerID: MCPeerID
let displayName: String
var status: Status = .linked

/// The most recent capabilities the camera advertised, or nil until the
/// first exchange completes. `supportsMulticam` gates the multicam-only
/// wire messages (clock sync now; scheduled capture in later PRs).
var capabilities: RemoteCmd.CameraCapabilitiesResp?
var supportsMulticam: Bool { capabilities?.supportsMulticam ?? false }

/// Rolling clock-offset estimate for this camera, fed by ClockSyncPong.
/// Stored here so a future synced capture can schedule on the camera's own
/// clock (PR4); PR3 only measures and surfaces it.
var clockEstimator = ClockOffsetEstimator()
var latestOffset: ClockOffsetSample? { clockEstimator.best }

/// Monitor side: this lane has produced at least one VP9 frame, proving the
/// camera speaks VP9 — the gate for sending it `RequestKeyframe` (mirrors
/// `SessionCoordinator.monitorReceivedVP9Frame`, but per camera).
var sawVP9 = false

/// This camera's own preview decoder + stall watchdog. Frames tagged with
/// this peer's id are fed here; its `onImage` drives exactly this lane's
/// tile, so a frame from another camera never touches it.
let receiver = FrameStreamReceiver()

init(peerID: MCPeerID) {
self.peerID = peerID
self.displayName = peerID.displayName
}
}
27 changes: 27 additions & 0 deletions RemoteCam/DeviceScannerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ struct DeviceScannerView: View {
let onShareApp: () -> Void
let onOpenSettings: () -> Void
let onHelp: () -> Void
/// Multicam only: begin a director session with the cameras collected so
/// far. Nil in the single-camera build (flag off), where it never shows.
var onStartMulticam: (() -> Void)? = nil

/// Peer-link state; the reconnect overlay is a function of it.
@ObservedObject var peerLink: PeerLinkStatus = .shared
Expand All @@ -34,10 +37,34 @@ struct DeviceScannerView: View {
connectingOverlay
}

if viewModel.multicamCollectedCount >= 1, let onStartMulticam {
startMulticamButton(onStartMulticam)
}

PeerLinkOverlay(status: peerLink)
}
}

/// Floating "Start (N)" for the multicam collecting flow. One camera starts
/// the classic monitor; two or more starts the director grid.
private func startMulticamButton(_ action: @escaping () -> Void) -> some View {
VStack {
Spacer()
Button(action: action) {
Text(String(format: NSLocalizedString("Start (%d)", comment: "start multicam with N cameras"),
viewModel.multicamCollectedCount))
.font(.headline)
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding()
.background(AppTheme.accent)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}

// MARK: - Peer List

private var peerList: some View {
Expand Down
38 changes: 37 additions & 1 deletion RemoteCam/DeviceScannerViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ public class DeviceScannerViewController: UIViewController {
remoteCamSession.setFrameSender(frameSender)
self.remoteCamSession ! SetScannerLobby(lobby: self)
scannerViewModel.role = role
// Multicam director collecting: only the monitor role, only behind the
// flag. Off, the coordinator's scanning path is byte-identical.
if FeatureFlags.ENABLE_MULTICAM && role == .monitor {
remoteCamSession ! UICmd.SetMulticamCollecting(on: true)
}
// The reconnect overlay's only action, routed like every other UI
// command; the overlay itself is pure state (PeerLinkStatus).
PeerLinkStatus.shared.onCancel = { [weak self] in
Expand Down Expand Up @@ -188,7 +193,10 @@ public class DeviceScannerViewController: UIViewController {
},
onHelp: { [weak self] in
self?.showHelpModal()
}
},
onStartMulticam: (FeatureFlags.ENABLE_MULTICAM && role == .monitor)
? { [weak self] in self?.startMulticamSession() }
: nil
)

swiftUIHostingController = embedSwiftUIView(scannerView)
Expand Down Expand Up @@ -331,6 +339,29 @@ public class DeviceScannerViewController: UIViewController {
}
}

/// Multicam "Start (N)": one camera runs the classic 1:1 monitor
/// (unchanged); two or more hands the live transport to a
/// `MulticamController` and pushes the director screen.
private func startMulticamSession() {
Task { @MainActor in
// Two or more cameras: hand the live transport to a director.
if let handoff = await remoteCamSession.detachTransportForMulticam() {
let controller = MulticamController()
await controller.install(transport: handoff.transport,
initialPeers: handoff.peers,
mode: .photo)
let directorVC = MulticamViewController(controller: controller)
navigationController?.pushViewController(directorVC, animated: true)
return
}
// Exactly one camera: promote it to a normal session and run the
// classic 1:1 monitor, unchanged.
if await remoteCamSession.promoteSingleCollectedToConnected() {
goToRole()
}
}
}

func goToAppSettings() {
#if targetEnvironment(macCatalyst)
// Local-network permission lives in System Settings on the Mac.
Expand Down Expand Up @@ -377,6 +408,11 @@ extension DeviceScannerViewController: ScannerLobby {
navigationController?.popToViewController(self, animated: true)
}

/// Multicam collecting: surface the running count so "Start (N)" appears.
func didCollectMulticamCameras(_ peers: [MCPeerID]) {
scannerViewModel.multicamCollectedCount = peers.count
}

func presentScanningError() {
let alert = UIAlertController(
title: NSLocalizedString("Scanning Error", comment: ""),
Expand Down
3 changes: 3 additions & 0 deletions RemoteCam/DeviceScannerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ final class DeviceScannerViewModel: ObservableObject {
@Published var hasScanningError: Bool = false
@Published var isConnecting: Bool = false
@Published var hasConnectionError: Bool = false
/// Multicam director collecting: how many cameras are connected so far.
/// Drives the "Start (N)" affordance; stays 0 in the single-camera build.
@Published var multicamCollectedCount: Int = 0

/// When the current scan began. Not @Published: the view samples it on a
/// TimelineView clock, so publishing would only cause redundant redraws.
Expand Down
Loading
Loading