diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index 23b8ae6..9bc9da6 100644 --- a/RemoteCam/CameraControlling.swift +++ b/RemoteCam/CameraControlling.swift @@ -69,6 +69,14 @@ protocol CameraControlling: AnyObject, Sendable { func getZoomStops() async -> [CGFloat] func getWideAngleZoomFactor() async -> CGFloat + /// Applies and persists the local-preview mode (on / standby). Standby + /// stops only the camera's own on-screen preview compositing — the capture + /// session and the frames streamed to the monitor are untouched. Persisted + /// on the camera device, so it survives relaunch. + func setPreviewMode(_ mode: CameraPreviewMode) async + /// The persisted local-preview mode. + func currentPreviewMode() async -> CameraPreviewMode + /// Drives the on-phone countdown overlay/chime for timer captures. /// value > 0: tick; 0: fired; < 0: cancelled. func updateTimerCountdown(value: Int) diff --git a/RemoteCam/CameraHostController.swift b/RemoteCam/CameraHostController.swift index 8bc55a1..410324b 100644 --- a/RemoteCam/CameraHostController.swift +++ b/RemoteCam/CameraHostController.swift @@ -25,6 +25,10 @@ final class CameraHostController: UIHostingController { viewModel: rig.cameraViewModel, onSelectCameraDevice: { [weak rig] uniqueID in rig?.selectCameraDeviceLocally(uniqueID: uniqueID) + }, + onSetPreviewMode: { [weak rig] mode in + // Route through the session so it persists and the monitor is told. + rig?.session ! UICmd.SetCameraPreviewMode(mode: mode) })) } diff --git a/RemoteCam/CameraPreviewMode.swift b/RemoteCam/CameraPreviewMode.swift new file mode 100644 index 0000000..f2e640c --- /dev/null +++ b/RemoteCam/CameraPreviewMode.swift @@ -0,0 +1,62 @@ +// +// CameraPreviewMode.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import Foundation + +/// Whether the **camera** device drives its own on-screen live preview. +/// +/// - `.on` — the shipping behavior: a full-screen live preview. This is the +/// default and must stay the default, so enabling standby is strictly opt-in +/// and nobody's existing experience changes. +/// - `.standby` — the camera stops compositing its *local* preview (which costs +/// battery and heat on a long tripod shoot) and shows a minimal status screen +/// instead. The capture session keeps running and preview frames keep +/// streaming to the monitor exactly as before — standby is a LOCAL-DISPLAY +/// concern only. +public enum CameraPreviewMode: String, Sendable, Equatable, CaseIterable { + case on + case standby + + /// The shipping default. Opt-in feature: never flip this to `.standby`. + public static let `default`: CameraPreviewMode = .on +} + +/// `UserDefaults`-backed persistence for `CameraPreviewMode`, stored on the +/// camera device so the choice survives relaunch. +/// +/// There is exactly ONE preference. A remote `RemoteCmd.SetCameraPreviewMode` +/// writes the same store a local toggle does — the wire command is not a +/// session override layered on top of a stored value, it *is* the stored value. +public struct CameraPreviewModeStore { + + /// The `UserDefaults` key. Namespaced so it can't collide with the app's + /// other loosely-typed preference keys. + static let defaultsKey = "camera.previewMode" + + private let defaults: UserDefaults + + /// Injectable defaults so tests can round-trip against an isolated suite + /// instead of `.standard`. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// The persisted mode, or `.default` (preview on) when nothing has been + /// stored yet or a stored value is unreadable. + public func load() -> CameraPreviewMode { + guard let raw = defaults.string(forKey: Self.defaultsKey), + let mode = CameraPreviewMode(rawValue: raw) else { + return .default + } + return mode + } + + /// Persists `mode` so it survives relaunch. + public func save(_ mode: CameraPreviewMode) { + defaults.set(mode.rawValue, forKey: Self.defaultsKey) + } +} diff --git a/RemoteCam/CameraRig.swift b/RemoteCam/CameraRig.swift index 84a7370..9e117ee 100644 --- a/RemoteCam/CameraRig.swift +++ b/RemoteCam/CameraRig.swift @@ -96,12 +96,33 @@ final class CameraRig: @unchecked Sendable { /// Microphone permission denied while starting a recording. Main thread. var onMicrophoneDenied: (() -> Void)? + /// Persisted local-preview preference (on / standby). One store, written by + /// both the local toggle and the remote command. + private let previewModeStore = CameraPreviewModeStore() + init(session: SessionCoordinator, frameSender: FrameSender) { self.session = session self.frameSender = frameSender + // Seed the screen with the persisted preference so a relaunch honors the + // last choice (default: preview on). + cameraViewModel.previewMode = previewModeStore.load() wireCallbacks() } + // MARK: - Preview mode (CameraControlling) + + /// Applies + persists the local-preview mode. Only touches the on-screen + /// preview (via the view model); the capture session and the monitor frame + /// stream are deliberately untouched. + func setPreviewMode(_ mode: CameraPreviewMode) async { + previewModeStore.save(mode) + cameraViewModel.setPreviewMode(mode) + } + + func currentPreviewMode() async -> CameraPreviewMode { + previewModeStore.load() + } + /// Bridges the non-UI engine/pipeline back to the actor system and the screen. private func wireCallbacks() { // A device swap is a hard scene cut that the VP9 encoder cannot see, so it diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index a1d433f..417b84c 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -18,6 +18,9 @@ struct CameraScreenView: View { @ObservedObject var viewModel: CameraViewModel /// Local device selection from the picker chrome (nil in previews/tests). var onSelectCameraDevice: ((String) -> Void)? + /// Sets the local preview mode (standby button / tap-to-restore). Routed + /// through the session so the change persists and the monitor is told. + var onSetPreviewMode: ((CameraPreviewMode) -> Void)? /// The letterbox-fitted video rect (view coords), reported by the preview so /// the focus reticle lands on the image, not the black bars. @State private var videoRect: CGRect = .zero @@ -29,6 +32,23 @@ struct CameraScreenView: View { @ObservedObject var peerLink: PeerLinkStatus = .shared var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + // Always mounted: it owns CameraPreviewView, whose backing layer is + // the AVCaptureVideoPreviewLayer on the live session. Unmounting it + // stops frame delivery — standby covers the preview, never unmounts it. + liveContent + + if viewModel.previewMode == .standby { + CameraStandbyView(viewModel: viewModel, + onRestore: { onSetPreviewMode?(.on) }) + } + } + } + + /// The full-screen live preview and its chrome. + private var liveContent: some View { ZStack { Color.black.ignoresSafeArea() @@ -84,10 +104,35 @@ struct CameraScreenView: View { CameraProgressOverlayView(viewModel: viewModel) + // Standby toggle, top trailing (the device picker owns top leading). + VStack { + HStack { + Spacer() + standbyButton + } + Spacer() + } + .padding(.top, 17) + .padding(.trailing, 16) + PeerLinkOverlay(status: peerLink) } } + /// Puts the camera into standby (stops the local preview only). Frames keep + /// streaming to the monitor. + private var standbyButton: some View { + Button(action: { onSetPreviewMode?(.standby) }) { + Image(systemName: "moon.zzz.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 44, height: 44) + .background(Color.black.opacity(0.4)) + .clipShape(Circle()) + } + .accessibilityLabel(Text(NSLocalizedString("Turn off preview", comment: "camera standby"))) + } + /// The remote focus reticle — the same box/animation the monitor draws. The /// tapped point is normalized in the displayed image; we recompute the fitted /// (letterboxed) video rect inside a GeometryReader measuring THIS overlay's @@ -147,6 +192,78 @@ struct CameraScreenView: View { } } +// MARK: - Standby screen + +/// The minimal status screen shown on the camera device while preview is in +/// standby. It deliberately draws almost nothing — the whole point is to stop +/// compositing the ~30fps preview to save battery and heat. The capture session +/// and the frames streamed to the monitor keep running; only this display is +/// idle. Tapping anywhere restores the live preview. +struct CameraStandbyView: View { + @ObservedObject var viewModel: CameraViewModel + let onRestore: () -> Void + + var body: some View { + ZStack { + Color.black.ignoresSafeArea() + + VStack(spacing: 18) { + Image(systemName: "moon.zzz.fill") + .font(.system(size: 44)) + .foregroundColor(.white.opacity(0.55)) + + Text(NSLocalizedString("Preview off", comment: "camera standby title")) + .font(.title3.weight(.semibold)) + .foregroundColor(.white) + + // Recording indicator + elapsed time (only while recording). + if viewModel.isRecordingTimerActive { + HStack(spacing: 8) { + Circle() + .fill(Color.red) + .frame(width: 10, height: 10) + CameraRecordingTimerView( + recordingStartTime: viewModel.recordingStartTime, + isRecording: viewModel.isRecordingTimerActive) + } + } + + // Current mode + quality, so the operator knows what's armed. + Text("\(modeLabel) · \(viewModel.qualityInfo)") + .font(.subheadline) + .foregroundColor(.white.opacity(0.7)) + + // Who is driving the camera. + if let peer = viewModel.connectedPeerName, !peer.isEmpty { + Text(String(format: NSLocalizedString("Controlled by %@", comment: "standby peer name"), peer)) + .font(.footnote) + .foregroundColor(.white.opacity(0.5)) + } + + Text(NSLocalizedString("Tap to restore preview", comment: "camera standby hint")) + .font(.footnote.weight(.semibold)) + .foregroundColor(.white.opacity(0.85)) + .padding(.top, 8) + } + .multilineTextAlignment(.center) + .padding(28) + } + .contentShape(Rectangle()) + .onTapGesture { onRestore() } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(Text(NSLocalizedString("Restore preview", comment: "camera standby restore"))) + } + + private var modeLabel: String { + switch viewModel.currentMode { + case .Photo: return NSLocalizedString("Photo", comment: "capture mode") + case .Video: return NSLocalizedString("Video", comment: "capture mode") + case .Shorts: return NSLocalizedString("Shorts", comment: "capture mode") + } + } +} + // MARK: - Live preview /// Hosts an `AVCaptureVideoPreviewLayer` as the view's backing layer, so the diff --git a/RemoteCam/CameraViewModel.swift b/RemoteCam/CameraViewModel.swift index 770e9cc..1c6285e 100644 --- a/RemoteCam/CameraViewModel.swift +++ b/RemoteCam/CameraViewModel.swift @@ -17,6 +17,31 @@ class CameraViewModel: ObservableObject { /// Spinner shown while the capture session is being configured. @Published var isBusy = false + // MARK: - Local Preview Mode (on / standby) + /// The camera's own preview mode. `.standby` renders the minimal status + /// screen instead of the live preview; the capture session and the frames + /// streamed to the monitor keep running either way. Seeded from the + /// persisted preference and updated by `CameraRig`. + @Published var previewMode: CameraPreviewMode = .on + /// The connected monitor's display name, shown on the standby screen so the + /// operator knows who is driving the camera. Nil when no peer is connected. + @Published var connectedPeerName: String? + + /// Main-thread setter for the connected peer's name (called from the rig / + /// coordinator glue, which may be off-main). + func setConnectedPeerName(_ name: String?) { + DispatchQueue.main.async { + if self.connectedPeerName != name { self.connectedPeerName = name } + } + } + + /// Main-thread setter for the local preview mode. + func setPreviewMode(_ mode: CameraPreviewMode) { + DispatchQueue.main.async { + if self.previewMode != mode { self.previewMode = mode } + } + } + // MARK: - Local Camera Devices (picker chrome; a Mac has N cameras) @Published var availableCameraDevices: [CameraDeviceDescriptor] = [] @Published var activeCameraDeviceID: String? diff --git a/RemoteCam/CaptureEngine.swift b/RemoteCam/CaptureEngine.swift index 5d7d05f..405eb26 100644 --- a/RemoteCam/CaptureEngine.swift +++ b/RemoteCam/CaptureEngine.swift @@ -935,6 +935,10 @@ final class CaptureEngine: NSObject, AVCapturePhotoCaptureDelegate { // supports only exposure POI still benefits from a tap. supportsFocusPoint: currentDevice.isFocusPointOfInterestSupported || currentDevice.isExposurePointOfInterestSupported, + // This build understands SetCameraPreviewMode; advertise the current + // persisted mode so the monitor reflects it from the first exchange. + supportsPreviewMode: true, + previewMode: CameraPreviewModeStore().load(), error: nil ) diff --git a/RemoteCam/FlatBufferSchemas.fbs b/RemoteCam/FlatBufferSchemas.fbs index 1acbb66..303bf1e 100644 --- a/RemoteCam/FlatBufferSchemas.fbs +++ b/RemoteCam/FlatBufferSchemas.fbs @@ -37,7 +37,19 @@ enum CommandAction : byte { SelectCameraDevice = 20, // only sent to peers advertising camera_devices RequestKeyframe = 21, // monitor -> camera: force a VP9 keyframe FocusAtPoint = 22, // monitor -> camera: focus/exposure point - EndSession = 23 // either side: "I am leaving on purpose" + EndSession = 23, // either side: "I am leaving on purpose" + SetCameraPreviewMode = 24 // monitor -> camera: local preview on/standby; + // only sent to peers advertising supports_preview_mode +} + +// Whether the camera device drives its own on-screen live preview. On is the +// shipping default and preserves existing behavior; Standby stops LOCAL preview +// compositing only — the capture session and the frames streamed to the monitor +// are unaffected. Unknown = legacy peer / no signal — treat as On. +enum CameraPreviewModeEnum : byte { + Unknown = 0, + On = 1, + Standby = 2 } enum CameraPosition : byte { @@ -145,6 +157,7 @@ table CommandParameters { device_unique_id: string; // payload for SelectCameraDevice focus_point_x: float; // payload for FocusAtPoint (normalized 0..1, focus_point_y: float; // upright-display space; origin top-left) + camera_preview_mode: CameraPreviewModeEnum; // payload for SetCameraPreviewMode } // MARK: - Command Structure @@ -223,6 +236,10 @@ table CameraState { aspect_ratio: AspectRatioEnum; // Appended fields only below this line (FlatBuffers schema evolution). active_device_id: string; + // The camera device's current local-preview mode, reported to the monitor + // so the operator can see whether the camera is showing a live preview or + // sitting in standby. Absent/Unknown => legacy peer; treat as On. + preview_mode: CameraPreviewModeEnum; } table CameraCapabilities { @@ -236,6 +253,10 @@ table CameraCapabilities { // False/absent = peer predates tap-to-focus; a monitor must not send // FocusAtPoint to such a peer. supports_focus_point: bool; + // False/absent = peer predates camera preview-mode control; a monitor must + // not send SetCameraPreviewMode to such a peer (old decoders read the + // unknown action as its enum default). + supports_preview_mode: bool; } // MARK: - Response Structure diff --git a/RemoteCam/FlatBufferSchemas_generated.swift b/RemoteCam/FlatBufferSchemas_generated.swift index 2ec08c7..951f0ef 100644 --- a/RemoteCam/FlatBufferSchemas_generated.swift +++ b/RemoteCam/FlatBufferSchemas_generated.swift @@ -32,12 +32,26 @@ public enum RemoteShutter_CommandAction: Int8, Enum, Verifiable { case requestkeyframe = 21 case focusatpoint = 22 case endsession = 23 + case setcamerapreviewmode = 24 - public static var max: RemoteShutter_CommandAction { return .endsession } + public static var max: RemoteShutter_CommandAction { return .setcamerapreviewmode } public static var min: RemoteShutter_CommandAction { return .unknown } } +public enum RemoteShutter_CameraPreviewModeEnum: Int8, Enum, Verifiable { + public typealias T = Int8 + public static var byteSize: Int { return MemoryLayout.size } + public var value: Int8 { return self.rawValue } + case unknown = 0 + case on = 1 + case standby = 2 + + public static var max: RemoteShutter_CameraPreviewModeEnum { return .standby } + public static var min: RemoteShutter_CameraPreviewModeEnum { return .unknown } +} + + public enum RemoteShutter_CameraPosition: Int8, Enum, Verifiable { public typealias T = Int8 public static var byteSize: Int { return MemoryLayout.size } @@ -316,6 +330,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { case deviceUniqueId = 34 case focusPointX = 36 case focusPointY = 38 + case cameraPreviewMode = 40 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -341,7 +356,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public var deviceUniqueIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.deviceUniqueId.v) } public var focusPointX: Float32 { let o = _accessor.offset(VTOFFSET.focusPointX.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } public var focusPointY: Float32 { let o = _accessor.offset(VTOFFSET.focusPointY.v); return o == 0 ? 0.0 : _accessor.readBuffer(of: Float32.self, at: o) } - public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 18) } + public var cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum { let o = _accessor.offset(VTOFFSET.cameraPreviewMode.v); return o == 0 ? .unknown : RemoteShutter_CameraPreviewModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public static func startCommandParameters(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 19) } public static func add(sendToRemote: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: sendToRemote, def: false, at: VTOFFSET.sendToRemote.p) } public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } @@ -361,6 +377,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { public static func add(deviceUniqueId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: deviceUniqueId, at: VTOFFSET.deviceUniqueId.p) } public static func add(focusPointX: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: focusPointX, def: 0.0, at: VTOFFSET.focusPointX.p) } public static func add(focusPointY: Float32, _ fbb: inout FlatBufferBuilder) { fbb.add(element: focusPointY, def: 0.0, at: VTOFFSET.focusPointY.p) } + public static func add(cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: cameraPreviewMode.rawValue, def: 0, at: VTOFFSET.cameraPreviewMode.p) } public static func endCommandParameters(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCommandParameters( _ fbb: inout FlatBufferBuilder, @@ -381,7 +398,8 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { aspectRatio: RemoteShutter_AspectRatioEnum = .unknown, deviceUniqueIdOffset deviceUniqueId: Offset = Offset(), focusPointX: Float32 = 0.0, - focusPointY: Float32 = 0.0 + focusPointY: Float32 = 0.0, + cameraPreviewMode: RemoteShutter_CameraPreviewModeEnum = .unknown ) -> Offset { let __start = RemoteShutter_CommandParameters.startCommandParameters(&fbb) RemoteShutter_CommandParameters.add(sendToRemote: sendToRemote, &fbb) @@ -402,6 +420,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { RemoteShutter_CommandParameters.add(deviceUniqueId: deviceUniqueId, &fbb) RemoteShutter_CommandParameters.add(focusPointX: focusPointX, &fbb) RemoteShutter_CommandParameters.add(focusPointY: focusPointY, &fbb) + RemoteShutter_CommandParameters.add(cameraPreviewMode: cameraPreviewMode, &fbb) return RemoteShutter_CommandParameters.endCommandParameters(&fbb, start: __start) } @@ -425,6 +444,7 @@ public struct RemoteShutter_CommandParameters: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.deviceUniqueId.p, fieldName: "deviceUniqueId", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.focusPointX.p, fieldName: "focusPointX", required: false, type: Float32.self) try _v.visit(field: VTOFFSET.focusPointY.p, fieldName: "focusPointY", required: false, type: Float32.self) + try _v.visit(field: VTOFFSET.cameraPreviewMode.p, fieldName: "cameraPreviewMode", required: false, type: RemoteShutter_CameraPreviewModeEnum.self) _v.finish() } } @@ -892,6 +912,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { case hdrMode = 20 case aspectRatio = 22 case activeDeviceId = 24 + case previewMode = 26 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -908,7 +929,8 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { public var aspectRatio: RemoteShutter_AspectRatioEnum { let o = _accessor.offset(VTOFFSET.aspectRatio.v); return o == 0 ? .unknown : RemoteShutter_AspectRatioEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } public var activeDeviceId: String? { let o = _accessor.offset(VTOFFSET.activeDeviceId.v); return o == 0 ? nil : _accessor.string(at: o) } public var activeDeviceIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.activeDeviceId.v) } - public static func startCameraState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 11) } + public var previewMode: RemoteShutter_CameraPreviewModeEnum { let o = _accessor.offset(VTOFFSET.previewMode.v); return o == 0 ? .unknown : RemoteShutter_CameraPreviewModeEnum(rawValue: _accessor.readBuffer(of: Int8.self, at: o)) ?? .unknown } + public static func startCameraState(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 12) } public static func add(currentCamera: RemoteShutter_CameraPosition, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentCamera.rawValue, def: 0, at: VTOFFSET.currentCamera.p) } public static func add(currentLens: RemoteShutter_CameraLensType, _ fbb: inout FlatBufferBuilder) { fbb.add(element: currentLens.rawValue, def: 0, at: VTOFFSET.currentLens.p) } public static func add(zoomFactor: Double, _ fbb: inout FlatBufferBuilder) { fbb.add(element: zoomFactor, def: 0.0, at: VTOFFSET.zoomFactor.p) } @@ -920,6 +942,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { public static func add(hdrMode: RemoteShutter_HDRMode, _ fbb: inout FlatBufferBuilder) { fbb.add(element: hdrMode.rawValue, def: 0, at: VTOFFSET.hdrMode.p) } public static func add(aspectRatio: RemoteShutter_AspectRatioEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: aspectRatio.rawValue, def: 0, at: VTOFFSET.aspectRatio.p) } public static func add(activeDeviceId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: activeDeviceId, at: VTOFFSET.activeDeviceId.p) } + public static func add(previewMode: RemoteShutter_CameraPreviewModeEnum, _ fbb: inout FlatBufferBuilder) { fbb.add(element: previewMode.rawValue, def: 0, at: VTOFFSET.previewMode.p) } public static func endCameraState(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraState( _ fbb: inout FlatBufferBuilder, @@ -933,7 +956,8 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { photoFormat: RemoteShutter_PhotoFormat = .unknown, hdrMode: RemoteShutter_HDRMode = .unknown, aspectRatio: RemoteShutter_AspectRatioEnum = .unknown, - activeDeviceIdOffset activeDeviceId: Offset = Offset() + activeDeviceIdOffset activeDeviceId: Offset = Offset(), + previewMode: RemoteShutter_CameraPreviewModeEnum = .unknown ) -> Offset { let __start = RemoteShutter_CameraState.startCameraState(&fbb) RemoteShutter_CameraState.add(currentCamera: currentCamera, &fbb) @@ -947,6 +971,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { RemoteShutter_CameraState.add(hdrMode: hdrMode, &fbb) RemoteShutter_CameraState.add(aspectRatio: aspectRatio, &fbb) RemoteShutter_CameraState.add(activeDeviceId: activeDeviceId, &fbb) + RemoteShutter_CameraState.add(previewMode: previewMode, &fbb) return RemoteShutter_CameraState.endCameraState(&fbb, start: __start) } @@ -963,6 +988,7 @@ public struct RemoteShutter_CameraState: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.hdrMode.p, fieldName: "hdrMode", required: false, type: RemoteShutter_HDRMode.self) try _v.visit(field: VTOFFSET.aspectRatio.p, fieldName: "aspectRatio", required: false, type: RemoteShutter_AspectRatioEnum.self) try _v.visit(field: VTOFFSET.activeDeviceId.p, fieldName: "activeDeviceId", required: false, type: ForwardOffset.self) + try _v.visit(field: VTOFFSET.previewMode.p, fieldName: "previewMode", required: false, type: RemoteShutter_CameraPreviewModeEnum.self) _v.finish() } } @@ -984,6 +1010,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { case cameraDevices = 8 case activeDeviceId = 10 case supportsFocusPoint = 12 + case supportsPreviewMode = 14 var v: Int32 { Int32(self.rawValue) } var p: VOffset { self.rawValue } } @@ -996,13 +1023,16 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { public var activeDeviceId: String? { let o = _accessor.offset(VTOFFSET.activeDeviceId.v); return o == 0 ? nil : _accessor.string(at: o) } public var activeDeviceIdSegmentArray: [UInt8]? { return _accessor.getVector(at: VTOFFSET.activeDeviceId.v) } public var supportsFocusPoint: Bool { let o = _accessor.offset(VTOFFSET.supportsFocusPoint.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } - public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 5) } + public var supportsPreviewMode: Bool { let o = _accessor.offset(VTOFFSET.supportsPreviewMode.v); return o == 0 ? false : _accessor.readBuffer(of: Bool.self, at: o) } + public static func startCameraCapabilities(_ fbb: inout FlatBufferBuilder) -> UOffset { fbb.startTable(with: 6) } public static func add(frontCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: frontCamera, at: VTOFFSET.frontCamera.p) } public static func add(backCamera: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: backCamera, at: VTOFFSET.backCamera.p) } public static func addVectorOf(cameraDevices: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: cameraDevices, at: VTOFFSET.cameraDevices.p) } public static func add(activeDeviceId: Offset, _ fbb: inout FlatBufferBuilder) { fbb.add(offset: activeDeviceId, at: VTOFFSET.activeDeviceId.p) } public static func add(supportsFocusPoint: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsFocusPoint, def: false, at: VTOFFSET.supportsFocusPoint.p) } + public static func add(supportsPreviewMode: Bool, _ fbb: inout FlatBufferBuilder) { fbb.add(element: supportsPreviewMode, def: false, + at: VTOFFSET.supportsPreviewMode.p) } public static func endCameraCapabilities(_ fbb: inout FlatBufferBuilder, start: UOffset) -> Offset { let end = Offset(offset: fbb.endTable(at: start)); return end } public static func createCameraCapabilities( _ fbb: inout FlatBufferBuilder, @@ -1010,7 +1040,8 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { backCameraOffset backCamera: Offset = Offset(), cameraDevicesVectorOffset cameraDevices: Offset = Offset(), activeDeviceIdOffset activeDeviceId: Offset = Offset(), - supportsFocusPoint: Bool = false + supportsFocusPoint: Bool = false, + supportsPreviewMode: Bool = false ) -> Offset { let __start = RemoteShutter_CameraCapabilities.startCameraCapabilities(&fbb) RemoteShutter_CameraCapabilities.add(frontCamera: frontCamera, &fbb) @@ -1018,6 +1049,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { RemoteShutter_CameraCapabilities.addVectorOf(cameraDevices: cameraDevices, &fbb) RemoteShutter_CameraCapabilities.add(activeDeviceId: activeDeviceId, &fbb) RemoteShutter_CameraCapabilities.add(supportsFocusPoint: supportsFocusPoint, &fbb) + RemoteShutter_CameraCapabilities.add(supportsPreviewMode: supportsPreviewMode, &fbb) return RemoteShutter_CameraCapabilities.endCameraCapabilities(&fbb, start: __start) } @@ -1028,6 +1060,7 @@ public struct RemoteShutter_CameraCapabilities: FlatBufferObject, Verifiable { try _v.visit(field: VTOFFSET.cameraDevices.p, fieldName: "cameraDevices", required: false, type: ForwardOffset, RemoteShutter_CameraDeviceInfo>>.self) try _v.visit(field: VTOFFSET.activeDeviceId.p, fieldName: "activeDeviceId", required: false, type: ForwardOffset.self) try _v.visit(field: VTOFFSET.supportsFocusPoint.p, fieldName: "supportsFocusPoint", required: false, type: Bool.self) + try _v.visit(field: VTOFFSET.supportsPreviewMode.p, fieldName: "supportsPreviewMode", required: false, type: Bool.self) _v.finish() } } diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift new file mode 100644 index 0000000..cecb5c9 --- /dev/null +++ b/RemoteCam/MonitorChrome.swift @@ -0,0 +1,174 @@ +// +// MonitorChrome.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import CoreGraphics +import Foundation +import UIKit + +// MARK: - Chrome dock + +/// Which edge the action cluster (gallery · shutter · camera switch) sits on. +enum MonitorChromeDock: Equatable { + case bottom + case leading + case trailing +} + +/// How the screen is driven. The rail exists so a rotating device doesn't move +/// the shutter out from under a thumb; a pointer-driven window neither rotates +/// nor has a thumb, so it keeps the conventional bottom bar. +enum MonitorChromeInput: Equatable { + case touch + case pointer +} + +/// Layout policy. Shape decides whether to dock on a rail — a size-class rule +/// would bottom-dock iPhone landscape, which is compact width. Orientation +/// decides which rail: the cluster stays on the home-indicator edge so the +/// shutter doesn't move under the user's hand when the device turns. +enum MonitorChromeLayout { + + static func dock(viewSize: CGSize, + interfaceOrientation: UIInterfaceOrientation, + input: MonitorChromeInput = .touch) -> MonitorChromeDock { + guard input == .touch else { return .bottom } + guard viewSize.width > viewSize.height else { return .bottom } + // Interface orientation is the inverse of device orientation. + return interfaceOrientation == .landscapeLeft ? .leading : .trailing + } +} + +// MARK: - Self-timer + +/// The self-timer's detented values. +enum MonitorTimer { + + /// Ascending, starting at "off". + static let stops: [Int] = [0, 3, 5, 10, 20] + + /// The next stop strictly above `value`, wrapping to off at the end. + /// Non-stop values round *up*, so a delay persisted as any integer 0...20 + /// under `timerDefault` lands on a real stop rather than being stranded. + static func next(after value: Int) -> Int { + stops.first { $0 > value } ?? stops[0] + } +} + +// MARK: - Tray + +/// One tile in the capture tray. Each tile's glyph carries its own current +/// value (timer shows "5", aspect "16:9"), which is what lets it live behind a +/// tap rather than occupy a permanent row. +enum MonitorTrayItem: Equatable { + case timer + case aspect + case resolution + case frameRate + case format + case hdr + /// Puts the peer camera's *local* preview to sleep. It keeps capturing and + /// keeps streaming here. + case cameraStandby + case settings + case help +} + +enum MonitorTray { + + /// Capability-driven tiles are omitted, not disabled: a camera that cannot + /// do HDR shows no HDR tile. Tiles that merely aren't available right now + /// (quality mid-recording) stay and are dimmed by the view. + static func items(for state: MonitorUIState, + supportsHEIF: Bool, + supportsHDR: Bool, + supportsCameraStandby: Bool, + resolutionCount: Int, + frameRateCount: Int) -> [MonitorTrayItem] { + var items: [MonitorTrayItem] = [] + + // Shorts runs to a fixed duration, so a self-timer has nothing to delay. + if state != .shortsMode { + items.append(.timer) + } + items.append(.aspect) + + switch state { + case .videoMode, .videoRecording: + if resolutionCount > 1 { items.append(.resolution) } + if frameRateCount > 1 { items.append(.frameRate) } + case .photoMode: + if supportsHEIF { items.append(.format) } + if supportsHDR { items.append(.hdr) } + case .shortsMode: + break + } + + if supportsCameraStandby { items.append(.cameraStandby) } + + items.append(.settings) + items.append(.help) + return items + } +} + +// MARK: - Link health + +/// What the monitor can say about the picture it is showing. The stream can go +/// quiet while the session still believes it is connected, so a frozen preview +/// needs saying out loud. +enum MonitorLinkState: Equatable { + /// Frames are arriving. Rendered as a single quiet dot. + case live + /// The session is up but frames have stopped — the picture on screen is + /// stale and must not be trusted for framing. + case stalled + /// The peer link itself is being rebuilt. + case reconnecting + + /// Reconnecting outranks a stall: when the link is down the stall is a + /// symptom, and naming the cause is more useful than naming the effect. + static func resolve(link: PeerLinkStatus.Link, isPreviewStale: Bool) -> MonitorLinkState { + switch link { + case .reconnecting: return .reconnecting + case .linked: return isPreviewStale ? .stalled : .live + } + } +} + +// MARK: - In-flight remote commands + +/// What the monitor is waiting on the camera for, right now. +/// +/// Derived from session state at the single `transition(to:)` choke point +/// rather than pushed from each command site: an indicator that is a function +/// of the state cannot outlive the thing it describes, and needs no +/// show/dismiss pairing to get wrong. +enum MonitorActivity: Equatable { + /// Shutter pressed; the camera has not acknowledged yet. + case capturing + /// The shot is taken and on its way — the moment the subject can stop + /// holding the pose. + case receivingCapture + case switchingCamera + case togglingFlash + case switchingLens + + /// The activity a state implies, or `nil` when nothing is in flight. + static func forState(_ state: SessionState) -> MonitorActivity? { + switch state { + case .monitorTakingPicture(_, let phase): + switch phase { + case .requesting: return .capturing + case .receiving: return .receivingCapture + } + case .monitorTogglingCamera: return .switchingCamera + case .monitorTogglingFlash: return .togglingFlash + case .monitorSwitchingLens: return .switchingLens + default: return nil + } + } +} diff --git a/RemoteCam/MonitorPresenter.swift b/RemoteCam/MonitorPresenter.swift index 163909d..7ce8385 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -56,6 +56,15 @@ public final class MonitorPresenter { onMain { $0.swiftUIConfigureShortsMode() } } + /// What the monitor is waiting on the camera for, or `nil` when nothing is. + /// + /// Written from the session's single transition point, so the indicator is + /// a function of the state rather than a side effect that has to be + /// balanced — the reason the old modal spinner could sit over the preview. + func setActivity(_ activity: MonitorActivity?) { + onMain { $0.viewModel.activity = activity } + } + func syncRecordingStartTime(_ startTime: Date?) { onMain { $0.viewModel.recordingStartTime = startTime } } @@ -115,6 +124,11 @@ public final class MonitorPresenter { capabilities.cameraDevices, activeID: capabilities.activeDeviceID) + // Set before the cameraInfo guard below: preview-mode support is a + // property of the peer, not of whichever camera it has selected, so + // a peer that reports no current camera must not lose the flag. + display.viewModel.supportsCameraStandby = capabilities.supportsPreviewMode + guard let cameraInfo = capabilities.getCurrentCameraInfo() else { return } // Update lens controls in view model display.updateLensTypesInViewModel( @@ -170,6 +184,12 @@ public final class MonitorPresenter { onMain { $0.viewModel.updateAspectRatio(ratio) } } + /// Reflects the camera device's current local-preview mode so the operator + /// can see whether the camera is showing a live preview or in standby. + func updatePreviewMode(_ mode: CameraPreviewMode) { + onMain { $0.viewModel.cameraPreviewMode = mode } + } + // MARK: - Video transfer progress func videoTransferStarted(totalBytes: Int64) { diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 6b090d5..2a464b6 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -3,14 +3,20 @@ import UIKit import Combine // MARK: - Monitor View + +/// The remote control's viewfinder. +/// +/// The preview is the screen: everything else floats over it on translucent +/// surfaces, and the set-once controls (timer, aspect, quality) live behind the +/// tray button rather than occupying permanent rows. The action cluster docks to +/// whichever edge the screen's *shape* makes cheap — see `MonitorChromeLayout`. struct MonitorView: View { @ObservedObject var viewModel: MonitorViewModel @State private var zoomAtGestureStart: CGFloat? /// Peer-link state; the reconnect overlay is a function of it. @ObservedObject var peerLink: PeerLinkStatus = .shared - - // Callbacks to MonitorViewController for Actor integration + // Callbacks to MonitorViewController for session integration let onTakePicture: () -> Void let onToggleCamera: () -> Void let onSelectCameraDevice: (String) -> Void @@ -20,6 +26,8 @@ struct MonitorView: View { let onModeChange: (RecordingMode) -> Void let onGalleryTapped: () -> Void let onSettingsTapped: () -> Void + let onHelpTapped: () -> Void + let onBackTapped: () -> Void let onZoomChange: (CGFloat) -> Void let onVideoQualityChange: (VideoResolution, VideoFrameRate) -> Void let onPhotoQualityChange: (PhotoFormat, HDRMode) -> Void @@ -27,106 +35,100 @@ struct MonitorView: View { /// Tap-to-focus: the tap point normalized (0..1) in the displayed image, /// origin top-left. Not called for taps that land in the letterbox bars. let onFocusTap: (CGPoint) -> Void + /// Toggles the connected camera's local-preview mode (on ⇄ standby). + let onToggleCameraStandby: () -> Void /// The live focus reticle (view-space position + identity to re-trigger the /// animation on each tap). Local UI only — no round-trip to the camera. @State private var focusReticle: FocusReticle? /// The preview area's measured size, for tap → normalized-image mapping. @State private var previewSize: CGSize = .zero + @State private var isTrayOpen = false var body: some View { GeometryReader { geometry in ZStack { - Color.black.ignoresSafeArea() + previewLayer - VStack(spacing: 0) { - // MARK: - Camera Preview - cameraPreviewSection - .frame(maxWidth: .infinity, maxHeight: .infinity) + chrome(dock: MonitorChromeLayout.dock( + viewSize: geometry.size, + interfaceOrientation: viewModel.interfaceOrientation, + input: Self.chromeInput)) - // MARK: - Controls Section - controlsSection - .background(Color.black.opacity(0.8)) + if isTrayOpen { + trayLayer + } + + if viewModel.isVideoTransferring { + VideoTransferProgressView( + progress: viewModel.videoTransferProgress, + transferSizeText: viewModel.videoTransferSizeText, + transferSpeedText: viewModel.videoTransferSpeedText, + isVisible: viewModel.isVideoTransferring + ) } PeerLinkOverlay(status: peerLink) } } - .ignoresSafeArea(edges: Self.topBleedEdges) + // Catalyst's default style paints a bordered box behind controls that + // already draw their own shape. Not .plain — that also drops the + // style's hit region, leaving material fills unclickable. + .buttonStyle(.borderless) + .onPreferenceChange(PreviewSizePreferenceKey.self) { previewSize = $0 } .statusBarHidden() } - /// iPhone/iPad draw the preview full-bleed under the notch/status bar. - /// The Mac's toolbar (Back button, window title) is opaque chrome — the - /// screen must never draw under it, or the active-camera label and timer - /// land on top of the Back button. - private static var topBleedEdges: Edge.Set { + /// iPhone/iPad draw the preview full-bleed under the notch and the home + /// indicator. The Mac's toolbar (Back button, window title) is opaque + /// chrome — the preview must never draw under it. + private static var previewBleedEdges: Edge.Set { #if targetEnvironment(macCatalyst) [] #else - .top + .all #endif } - - // MARK: - Camera Preview Section - private var cameraPreviewSection: some View { + + /// The viewfinder hides the nav bar on every platform, and the Mac window + /// has no toolbar Back of its own, so the floating chevron is the only way + /// out everywhere. + private static let showsFloatingBackButton = true + + /// A Mac window neither rotates nor is held, so the side rail buys nothing + /// there and the bottom bar is the convention. + private static var chromeInput: MonitorChromeInput { + #if targetEnvironment(macCatalyst) + .pointer + #else + .touch + #endif + } + + // MARK: - Preview layer + + /// The image and everything that shares its coordinate space. Measured as + /// one unit so a tap maps to the same rect the frame is drawn in. + private var previewLayer: some View { ZStack { - // Camera preview background Color.black - - // Camera image with aspect ratio crop overlay. Its own view so - // the ~20fps frame stream re-renders ONLY this subtree — not the - // menu and the rest of the chrome. + + // Its own view so the ~20fps frame stream re-renders ONLY this + // subtree — not the chrome. LiveFrameView(frames: viewModel.frames, aspectRatio: viewModel.currentAspectRatio) - - // Which camera is driving the preview. Mac-only: choosing among several - // attached cameras is a Mac capability, so that's where the name earns its - // place. The iOS monitor sits inside a nav controller whose back button - // owns this corner — the label would render on top of it. - #if targetEnvironment(macCatalyst) - if let active = viewModel.remoteCameraDevices.first(where: { $0.isActive }) { - VStack { - HStack { - Text(active.localizedName) - .font(.caption) - .foregroundColor(.white.opacity(0.9)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.black.opacity(0.45)) - .cornerRadius(6) - .padding(.top, 20) - .padding(.leading, 12) - Spacer() - } - Spacer() - } - .allowsHitTesting(false) - } - #endif + // A stalled stream is a stale picture. Desaturating it says so + // continuously, where a badge alone can be missed. + .saturation(viewModel.isPreviewStale ? 0.25 : 1) + .opacity(viewModel.isPreviewStale ? 0.65 : 1) + .animation(.easeInOut(duration: 0.25), value: viewModel.isPreviewStale) - // Recording indicator with duration timer - if viewModel.isShowingRecordingDuration { - VStack { - HStack { - Spacer() - RecordingTimer( - startTime: viewModel.recordingStartTime, - isRecording: viewModel.isRecording - ) - .padding(.top, 20) - .padding(.trailing, 20) - } - Spacer() - } - } - - // Preview gestures live on this layer, which sits BELOW the - // interactive zoom pill (added next) so a tap on the pill is never - // stolen as a focus tap. Double tap toggles the camera; a single tap - // focuses (runs simultaneously so the reticle is instant — an - // exclusive gesture would stall it for the double-tap window); pinch - // zooms. The translation guard keeps drags/pinches from focusing. + // Preview gestures sit BELOW the interactive chrome, so a tap on a + // control is never stolen as a focus tap. Double tap toggles the + // camera; a single tap focuses (simultaneous so the reticle is + // instant — an exclusive gesture would stall it for the double-tap + // window); pinch zooms. The translation guard keeps drags and + // pinches from focusing. Color.clear .contentShape(Rectangle()) .onTapGesture(count: 2) { @@ -154,44 +156,16 @@ struct MonitorView: View { } ) - zoomControls - - // Video transfer progress overlay - positioned at center - if viewModel.isVideoTransferring { - VideoTransferProgressView( - progress: viewModel.videoTransferProgress, - transferSizeText: viewModel.videoTransferSizeText, - transferSpeedText: viewModel.videoTransferSpeedText, - isVisible: viewModel.isVideoTransferring - ) - } - - // Flash status overlay - if !viewModel.flashStatus.isEmpty && viewModel.uiState == .photoMode { - VStack { - HStack { - Text(viewModel.flashStatus) - .foregroundColor(.white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.black.opacity(0.6)) - .cornerRadius(8) - .padding(.leading, 20) - .padding(.top, 20) - Spacer() - } - Spacer() - } - } + focusReticleOverlay + countdownOverlay } .background( GeometryReader { geo in Color.clear.preference(key: PreviewSizePreferenceKey.self, value: geo.size) } ) - .onPreferenceChange(PreviewSizePreferenceKey.self) { previewSize = $0 } - .overlay(focusReticleOverlay) + .ignoresSafeArea(edges: Self.previewBleedEdges) } /// The focus reticle, drawn in the preview's coordinate space. Non-interactive @@ -206,6 +180,27 @@ struct MonitorView: View { } } + /// The self-timer, centered and large. The capture happens across the room: + /// a subject walking into frame has to read this at a glance, which a digit + /// tucked inside the shutter never allowed. + @ViewBuilder + private var countdownOverlay: some View { + if viewModel.timerValue > 0 { + Text("\(viewModel.timerValue)") + .font(.system(size: 96, weight: .bold, design: .rounded)) + .foregroundColor(AppTheme.accent) + // The number must read against whatever the camera is pointed + // at, including a bright wall. Two shadows — one tight for edge + // definition, one wide for separation — keep it legible without + // a backing plate smudging the frame the user is composing. + .shadow(color: .black.opacity(0.85), radius: 3) + .shadow(color: .black.opacity(0.55), radius: 16) + .id(viewModel.timerValue) + .transition(.scale(scale: 1.15).combined(with: .opacity)) + .allowsHitTesting(false) + } + } + /// Maps a preview tap into a normalized image point and, if it landed on the /// image (not the letterbox), shows the reticle and forwards it to the camera. private func handleFocusTap(at location: CGPoint) { @@ -224,83 +219,235 @@ struct MonitorView: View { if focusReticle?.id == reticle.id { focusReticle = nil } } } - - // MARK: - Recording Indicator - // Note: Replaced with RecordingTimer component that includes duration - - // MARK: - Zoom & Lens Control - /// One detented pill drives both zoom and lens selection on every platform — tap a - /// stop to jump lenses, drag or scroll to zoom between them. It floats at the bottom - /// over the preview; pinch-to-zoom still works underneath it. - private var zoomControls: some View { - VStack { - Spacer() + // MARK: - Chrome + + /// Everything that floats over the preview, arranged for the docked edge. + private func chrome(dock: MonitorChromeDock) -> some View { + VStack(spacing: 0) { + topBar + + Spacer(minLength: 0) + + switch dock { + case .bottom: + bottomCluster + case .leading: + sideCluster(onLeading: true) + case .trailing: + sideCluster(onLeading: false) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 8) + } + + /// Back (leading) · recording timecode (centered) · state capsule (trailing). + private var topBar: some View { + ZStack { + if viewModel.isShowingRecordingDuration { + RecordingTimer( + startTime: viewModel.recordingStartTime, + isRecording: viewModel.isRecording + ) + } + + HStack(spacing: 0) { + if Self.showsFloatingBackButton { + // 44pt with a 22pt chevron: the nav bar's own back button is + // a 44pt target and its glyph reads at about this weight, so + // leaving the viewfinder feels like the same control. + GlassCircleButton(systemImage: "chevron.backward", + size: 44, + glyphSize: 22, + isEnabled: viewModel.isBackEnabled, + action: onBackTapped) + } + LinkChip(state: MonitorLinkState.resolve(link: peerLink.link, + isPreviewStale: viewModel.isPreviewStale)) + .equatable() + .padding(.leading, 8) + // Status, not an overlay on the picture being framed. + activeCameraCaption + .padding(.leading, 8) + Spacer(minLength: 0) + ControlCapsule(showsFlash: viewModel.uiState == .photoMode, + isFlashEnabled: viewModel.isFlashEnabled, + isFlashButtonEnabled: viewModel.isFlashButtonEnabled, + isTorchEnabled: viewModel.isTorchEnabled, + isTorchButtonEnabled: viewModel.isTorchButtonEnabled, + isTrayOpen: isTrayOpen, + onToggleFlash: onToggleFlash, + onToggleTorch: onToggleTorch, + onToggleTray: toggleTray) + .equatable() + } + } + } + + /// Portrait and other tall shapes: everything stacks across the bottom. + /// Full width so no child sits outside its parent — SwiftUI draws those + /// but UIKit will not hit-test them. + private var bottomCluster: some View { + VStack(spacing: 14) { ZoomPill(scale: viewModel.zoomScale, currentZoomFactor: viewModel.currentZoomFactor, onZoomChange: onZoomChange) - .padding(.bottom, 24) + actionCluster(axis: .horizontal) + modeSelector } + .frame(maxWidth: .infinity) } - // MARK: - Quality Controls - private var qualityControls: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - if viewModel.uiState == .videoMode { - videoQualityButtons - } else if viewModel.uiState == .photoMode { - photoQualityButtons - } + /// Wide shapes: the action cluster rides the rail on the home-indicator side + /// so it doesn't move when the device turns; zoom and mode stay low. + private func sideCluster(onLeading: Bool) -> some View { + HStack(alignment: .bottom, spacing: 16) { + if !onLeading { Spacer(minLength: 0) } + if onLeading { actionCluster(axis: .vertical) } + + // Inboard of the rail: one control zone on the docked edge. + VStack(spacing: 10) { + Spacer(minLength: 0) + ZoomPill(scale: viewModel.zoomScale, + currentZoomFactor: viewModel.currentZoomFactor, + onZoomChange: onZoomChange) + modeSelector } - .padding(.horizontal, 20) + + if !onLeading { actionCluster(axis: .vertical) } + if onLeading { Spacer(minLength: 0) } } } - @ViewBuilder - private var videoQualityButtons: some View { - // Resolution picker - if viewModel.supportedResolutions.count > 1 { - ForEach(viewModel.supportedResolutions, id: \.self) { resolution in - Button(action: { - onVideoQualityChange(resolution, viewModel.currentVideoFrameRate) - }) { - Text(resolution.displayName) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.currentVideoResolution == resolution ? .black : .white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.currentVideoResolution == resolution ? - AppTheme.accent : Color.gray.opacity(0.3) - ) - .cornerRadius(6) + /// Gallery · shutter · camera switch, laid out along `axis`. + private func actionCluster(axis: Axis) -> some View { + let gallery = GlassCircleButton(systemImage: "photo.on.rectangle.angled", + size: 44, + glyphSize: 20, + isEnabled: viewModel.isGalleryEnabled, + action: onGalleryTapped) + let shutter = ShutterButton(uiState: viewModel.uiState, + isRecording: viewModel.isRecording, + activity: viewModel.activity, + isEnabled: viewModel.isSegmentedControlEnabled || viewModel.isRecording, + action: onTakePicture) + .equatable() + let switcher = CameraSwitchControlView( + control: viewModel.cameraSwitchControl, + devices: viewModel.remoteCameraDevices, + activeDeviceID: viewModel.activeRemoteDeviceID, + isEnabled: viewModel.isToggleCameraEnabled, + isSwitching: viewModel.activity == .switchingCamera, + onToggleCamera: onToggleCamera, + onSelectCameraDevice: onSelectCameraDevice) + .equatable() + + return Group { + if axis == .horizontal { + HStack(spacing: 40) { + gallery + shutter + switcher + } + .frame(maxWidth: .infinity) + } else { + VStack(spacing: 24) { + gallery + shutter + switcher } - .disabled(!viewModel.isQualityControlEnabled) + .frame(maxHeight: .infinity) } + } + } - Divider() - .frame(height: 20) - .background(Color.gray.opacity(0.5)) - } - - // FPS picker - ForEach(availableFrameRates, id: \.self) { rate in - Button(action: { - onVideoQualityChange(viewModel.currentVideoResolution, rate) - }) { - Text("\(rate.displayName) fps") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.currentVideoFrameRate == rate ? .black : .white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.currentVideoFrameRate == rate ? - AppTheme.accent : Color.gray.opacity(0.3) - ) - .cornerRadius(6) + /// Which camera is driving the preview. Only meaningful when the peer has + /// more than one — a single-camera peer has nothing to disambiguate. + @ViewBuilder + private var activeCameraCaption: some View { + if viewModel.remoteCameraDevices.count > 1, + let active = viewModel.remoteCameraDevices.first(where: { $0.uniqueID == viewModel.activeRemoteDeviceID }) { + Text(active.localizedName) + .font(.caption) + .foregroundColor(.white.opacity(0.9)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(.ultraThinMaterial)) + .allowsHitTesting(false) + } + } + + // MARK: - Mode selector + + private var modeSelector: some View { + HStack(spacing: 4) { + modeButton(title: NSLocalizedString("PHOTO", comment: "capture mode"), mode: .Photo) + modeButton(title: NSLocalizedString("VIDEO", comment: "capture mode"), mode: .Video) + + if FeatureFlags.ENABLE_SHORTS_MODE { + modeButton(title: NSLocalizedString("SHORTS", comment: "capture mode"), mode: .Shorts) } - .disabled(!viewModel.isQualityControlEnabled) + } + .padding(4) + .background(Capsule().fill(.ultraThinMaterial)) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.08))) + .disabled(!viewModel.isSegmentedControlEnabled) + } + + private func modeButton(title: String, mode: RecordingMode) -> some View { + let isActive = viewModel.currentMode == mode + return Button(action: { onModeChange(mode) }) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .tracking(0.5) + .foregroundColor(isActive ? AppTheme.accent : .white.opacity(0.75)) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background( + Capsule().fill(isActive ? Color.white.opacity(0.16) : Color.clear) + ) + .contentShape(Capsule()) + } + } + + // MARK: - Tray + + private func toggleTray() { + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + isTrayOpen.toggle() + } + } + + private var trayLayer: some View { + ZStack(alignment: .bottom) { + // Near-invisible full-screen scrim: dismisses on tap without + // dimming the preview the user is still framing with. Must stay + // above 0.01 — UIKit does not hit-test at or below that. + Color.black.opacity(0.02) + .ignoresSafeArea() + .onTapGesture { toggleTray() } + + MonitorTrayPanel( + items: MonitorTray.items(for: viewModel.uiState, + supportsHEIF: viewModel.supportsHEIF, + supportsHDR: viewModel.supportsHDR, + supportsCameraStandby: viewModel.supportsCameraStandby, + resolutionCount: viewModel.supportedResolutions.count, + frameRateCount: availableFrameRates.count), + timerValue: Int(viewModel.timerSliderValue), + aspectRatio: viewModel.currentAspectRatio, + resolution: viewModel.currentVideoResolution, + frameRate: viewModel.currentVideoFrameRate, + photoFormat: viewModel.currentPhotoFormat, + hdrMode: viewModel.currentHDRMode, + cameraPreviewMode: viewModel.cameraPreviewMode, + isQualityEnabled: viewModel.isQualityControlEnabled, + isTimerEnabled: viewModel.isTimerSliderEnabled, + isSettingsEnabled: viewModel.isSettingsEnabled, + onTap: handleTrayTap) + .transition(.move(edge: .bottom)) } } @@ -309,336 +456,426 @@ struct MonitorView: View { return (rates?.isEmpty == false) ? rates! : viewModel.supportedFrameRates } - @ViewBuilder - private var photoQualityButtons: some View { - // Format picker - if viewModel.supportsHEIF { - ForEach(PhotoFormat.selectableCases, id: \.self) { format in - Button(action: { - onPhotoQualityChange(format, viewModel.currentHDRMode) - }) { - Text(format.displayName) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.currentPhotoFormat == format ? .black : .white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.currentPhotoFormat == format ? - AppTheme.accent : Color.gray.opacity(0.3) - ) - .cornerRadius(6) - } - .disabled(!viewModel.isQualityControlEnabled) - } + /// Value tiles cycle in place and leave the tray open — you watch the glyph + /// change. Tiles that push a screen close it first. + private func handleTrayTap(_ item: MonitorTrayItem) { + switch item { + case .timer: + let next = MonitorTimer.next(after: Int(viewModel.timerSliderValue)) + viewModel.timerSliderValue = Double(next) + onTimerChange(next) - Divider() - .frame(height: 20) - .background(Color.gray.opacity(0.5)) - } - - // HDR toggle - if viewModel.supportsHDR { - Button(action: { - let newHDR: HDRMode = viewModel.currentHDRMode == .on ? .off : .on - onPhotoQualityChange(viewModel.currentPhotoFormat, newHDR) - }) { - Text("HDR") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.currentHDRMode == .on ? .black : .white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.currentHDRMode == .on ? - AppTheme.accent : Color.gray.opacity(0.3) - ) - .cornerRadius(6) - } - .disabled(!viewModel.isQualityControlEnabled) + case .aspect: + onAspectRatioChange(Self.cycled(viewModel.currentAspectRatio, in: AspectRatio.selectableCases)) + + case .resolution: + onVideoQualityChange(Self.cycled(viewModel.currentVideoResolution, in: viewModel.supportedResolutions), + viewModel.currentVideoFrameRate) + + case .frameRate: + onVideoQualityChange(viewModel.currentVideoResolution, + Self.cycled(viewModel.currentVideoFrameRate, in: availableFrameRates)) + + case .format: + onPhotoQualityChange(Self.cycled(viewModel.currentPhotoFormat, in: PhotoFormat.selectableCases), + viewModel.currentHDRMode) + + case .hdr: + onPhotoQualityChange(viewModel.currentPhotoFormat, + viewModel.currentHDRMode == .on ? .off : .on) + + case .cameraStandby: + // Stays open: the glyph reflects the camera's confirmed mode, so + // it is worth watching settle. + onToggleCameraStandby() + + case .settings: + toggleTray() + onSettingsTapped() + + case .help: + toggleTray() + onHelpTapped() } } - // MARK: - Controls Section - private var controlsSection: some View { - VStack(spacing: viewModel.areControlsExpanded ? 20 : 12) { - // Mode Selector + expand/collapse toggle - HStack { - modeSelector + /// The next option after `current`, wrapping. Falls back to the first + /// option when the current value isn't among them (a capability list can + /// change under us when the peer swaps cameras). + static func cycled(_ current: T, in options: [T]) -> T { + guard let index = options.firstIndex(of: current) else { return options.first ?? current } + return options[(index + 1) % options.count] + } +} - Button(action: { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.areControlsExpanded.toggle() - } - }) { - Image(systemName: viewModel.areControlsExpanded ? "chevron.down" : "chevron.up") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 24, height: 24) - .background(Color.gray.opacity(0.2)) - .cornerRadius(4) - } - } +// MARK: - Link chip - if viewModel.areControlsExpanded { - // Quality Controls (video: resolution + fps, photo: format + HDR) - if FeatureFlags.ENABLE_QUALITY_CONTROLS - && (viewModel.uiState == .videoMode || viewModel.uiState == .photoMode) { - qualityControls - } +/// The state of the picture, top-leading. +/// +/// Quiet by design: a healthy link is one small dot, because a remote that +/// shouts about being connected is noise. It earns words only when the picture +/// on screen has stopped being trustworthy. +struct LinkChip: View, Equatable { + let state: MonitorLinkState - // Timer Controls (only for Photo/Video modes) - if viewModel.uiState != .shortsMode { - timerControls - } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.state == rhs.state } + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 7, height: 7) - // Aspect Ratio Controls - aspectRatioControls + if let label { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundColor(.white) } + } + .padding(.horizontal, label == nil ? 7 : 10) + .padding(.vertical, 6) + .background(Capsule().fill(.ultraThinMaterial)) + .allowsHitTesting(false) + } - // Main Action Buttons (always visible) - mainActionButtons + private var dotColor: Color { + switch state { + case .live: return AppTheme.success + case .stalled, .reconnecting: return AppTheme.accent + } + } - // Bottom Navigation (always visible) - bottomNavigation + private var label: String? { + switch state { + case .live: return nil + case .stalled: return NSLocalizedString("NO SIGNAL", comment: "preview stream stalled") + case .reconnecting: return NSLocalizedString("RECONNECTING", comment: "peer link dropped") } - .padding(.horizontal, 20) - .padding(.vertical, 16) } - - // MARK: - Mode Selector - private var modeSelector: some View { - HStack(spacing: 0) { - modeButton(title: "Photo", mode: .Photo) - modeButton(title: "Video", mode: .Video) - - // Shorts mode - feature flagged - if FeatureFlags.ENABLE_SHORTS_MODE { - modeButton(title: "Shorts", mode: .Shorts) - } +} + +// MARK: - Glass circle button + +/// A translucent round button — the monitor's standard auxiliary control. +struct GlassCircleButton: View { + let systemImage: String + let size: CGFloat + let glyphSize: CGFloat + var isActive: Bool = false + let isEnabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: glyphSize, weight: .semibold)) + .foregroundColor(tint) + .frame(width: size, height: size) + .background(Circle().fill(.ultraThinMaterial)) } - .background(Color.gray.opacity(0.2)) - .cornerRadius(8) - .disabled(!viewModel.isSegmentedControlEnabled) + .disabled(!isEnabled) } - - private func modeButton(title: String, mode: RecordingMode) -> some View { - Button(action: { - onModeChange(mode) - }) { - Text(title) - .font(.system(size: 16, weight: .medium)) - .foregroundColor(viewModel.currentMode == mode ? .black : .white) - .frame(maxWidth: .infinity) - .padding(.vertical, 10) - .background( - viewModel.currentMode == mode ? - AppTheme.accent : Color.clear - ) - .cornerRadius(6) - } - } - - // MARK: - Timer Controls - private var timerControls: some View { - HStack(spacing: 16) { - Text("Timer:") - .font(.system(size: 16)) - .foregroundColor(.white) - - Slider( - value: Binding( - get: { viewModel.timerSliderValue }, - set: { newValue in - viewModel.timerSliderValue = newValue - onTimerChange(Int(newValue)) - } - ), - in: 0...viewModel.maxTimerValue, - step: 1 - ) - .accentColor(AppTheme.accent) - .disabled(!viewModel.isTimerSliderEnabled) - - Text("\(Int(viewModel.timerSliderValue))s") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.white) - .frame(width: 30) - } - } - - // MARK: - Aspect Ratio Controls - private var aspectRatioControls: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - ForEach(AspectRatio.selectableCases, id: \.self) { ratio in - Button(action: { - onAspectRatioChange(ratio) - }) { - Text(ratio.displayName) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(viewModel.currentAspectRatio == ratio ? .black : .white) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - viewModel.currentAspectRatio == ratio ? - AppTheme.accent : Color.gray.opacity(0.3) - ) - .cornerRadius(6) - } - .disabled(!viewModel.isQualityControlEnabled) - } + + private var tint: Color { + if !isEnabled { return .white.opacity(0.35) } + return isActive ? AppTheme.accent : .white + } +} + +// MARK: - Control capsule + +/// The state-carrying toggles, top-trailing: flash (photo only), torch, and the +/// tray button. `Equatable` over value inputs so the ~20fps frame stream — and +/// any unrelated view-model change — cannot rebuild it. +struct ControlCapsule: View, Equatable { + let showsFlash: Bool + let isFlashEnabled: Bool + let isFlashButtonEnabled: Bool + let isTorchEnabled: Bool + let isTorchButtonEnabled: Bool + let isTrayOpen: Bool + let onToggleFlash: () -> Void + let onToggleTorch: () -> Void + let onToggleTray: () -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.showsFlash == rhs.showsFlash + && lhs.isFlashEnabled == rhs.isFlashEnabled + && lhs.isFlashButtonEnabled == rhs.isFlashButtonEnabled + && lhs.isTorchEnabled == rhs.isTorchEnabled + && lhs.isTorchButtonEnabled == rhs.isTorchButtonEnabled + && lhs.isTrayOpen == rhs.isTrayOpen + } + + var body: some View { + HStack(spacing: 6) { + if showsFlash { + glyph(isFlashEnabled ? "bolt.fill" : "bolt.slash.fill", + isActive: isFlashEnabled, + isEnabled: isFlashButtonEnabled, + action: onToggleFlash) } - .padding(.horizontal, 20) - } - } - - // MARK: - Main Action Buttons - private var mainActionButtons: some View { - HStack(spacing: 40) { - // Gallery Button - actionButton( - systemImage: "photo.on.rectangle", - action: onGalleryTapped, - isEnabled: viewModel.isGalleryEnabled - ) - - // Main Action Button (Take Photo/Record Video) - mainActionButton - - // Camera switch control, isolated behind Equatable: it must not - // re-render (an open menu dismisses if rebuilt) unless the - // devices, active ID, or enabled state actually changed. - CameraSwitchControlView( - control: viewModel.cameraSwitchControl, - devices: viewModel.remoteCameraDevices, - activeDeviceID: viewModel.activeRemoteDeviceID, - isEnabled: viewModel.isToggleCameraEnabled, - onToggleCamera: onToggleCamera, - onSelectCameraDevice: onSelectCameraDevice - ) - .equatable() + glyph(isTorchEnabled ? "flashlight.on.fill" : "flashlight.off.fill", + isActive: isTorchEnabled, + isEnabled: isTorchButtonEnabled, + action: onToggleTorch) + glyph("circle.grid.3x3.fill", + isActive: isTrayOpen, + isEnabled: true, + action: onToggleTray) } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Capsule().fill(.ultraThinMaterial)) } - - private var mainActionButton: some View { - Button(action: onTakePicture) { + + private func glyph(_ name: String, isActive: Bool, isEnabled: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: name) + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(isEnabled ? (isActive ? AppTheme.accent : .white) : .white.opacity(0.35)) + .frame(width: 38, height: 34) + .contentShape(Rectangle()) + } + .disabled(!isEnabled) + } +} + +// MARK: - Shutter + +/// The capture button, including what the camera is currently doing about it. +/// In-flight feedback belongs on the control you pressed, not over the picture +/// you are framing. +struct ShutterButton: View, Equatable { + let uiState: MonitorUIState + let isRecording: Bool + let activity: MonitorActivity? + let isEnabled: Bool + let action: () -> Void + + private static let diameter: CGFloat = 74 + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.uiState == rhs.uiState + && lhs.isRecording == rhs.isRecording + && lhs.activity == rhs.activity + && lhs.isEnabled == rhs.isEnabled + } + + var body: some View { + Button(action: action) { ZStack { - // Outer border (always white) Circle() .fill(Color.white) - .frame(width: 80, height: 80) - - // Main button styling based on mode and recording state - if viewModel.isRecording { - // Recording: Red background with white stop square + .frame(width: Self.diameter, height: Self.diameter) + + if isRecording { Circle() - .fill(Color.red) - .frame(width: 70, height: 70) - + .fill(AppTheme.record) + .frame(width: 64, height: 64) RoundedRectangle(cornerRadius: 4) .fill(Color.white) .frame(width: 22, height: 22) - } else if viewModel.uiState == .videoMode || viewModel.uiState == .shortsMode { - // Video mode (not recording): Red circle ready to record + } else if uiState == .videoMode || uiState == .shortsMode { Circle() - .fill(Color.red) - .frame(width: 70, height: 70) + .fill(AppTheme.record) + .frame(width: 64, height: 64) } else { - // Photo mode: White with black inner border Circle() - .stroke(Color.black, lineWidth: 3) - .frame(width: 65, height: 65) + .stroke(Color.black.opacity(0.85), lineWidth: 3) + .frame(width: 60, height: 60) } - - // Show countdown number if timer is active - if viewModel.timerValue > 0 { - Text("\(viewModel.timerValue)") - .font(.system(size: 24, weight: .bold)) - .foregroundColor(viewModel.uiState == .photoMode ? .black : .white) + + if isCaptureInFlight { + ShutterActivityRing() } } - } - .disabled(!viewModel.isSegmentedControlEnabled && !viewModel.isRecording) - } - - private func actionButton(systemImage: String, action: @escaping () -> Void, isEnabled: Bool) -> some View { - Button(action: action) { - Image(systemName: systemImage) - .font(.system(size: 24)) - .foregroundColor(isEnabled ? .white : .gray) - .frame(width: 44, height: 44) + .frame(width: Self.diameter, height: Self.diameter) + .contentShape(Circle()) } .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.5) } - - // MARK: - Bottom Navigation - private var bottomNavigation: some View { - HStack { - Spacer() - - // Flash/Torch Controls (context-dependent) - if viewModel.uiState == .photoMode { - // Show both flash and torch for photo mode - HStack(spacing: 20) { - flashButton - torchButton + + /// Only capture states dim the shutter — a flash or lens change in flight + /// shows on its own control, not here. + private var isCaptureInFlight: Bool { + activity == .capturing || activity == .receivingCapture + } +} + +/// A rotating arc drawn around the shutter while a capture is in flight. +struct ShutterActivityRing: View { + @State private var spinning = false + + var body: some View { + Circle() + .trim(from: 0, to: 0.28) + .stroke(AppTheme.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .frame(width: 86, height: 86) + .rotationEffect(.degrees(spinning ? 360 : 0)) + .onAppear { + withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) { + spinning = true } - } else if viewModel.uiState == .videoMode || viewModel.uiState == .shortsMode { - torchButton } + } +} - Spacer() +// MARK: - Tray panel - // Settings Button - Button(action: onSettingsTapped) { - Image(systemName: "gearshape") - .font(.system(size: 20)) - .foregroundColor(.white) +/// The capture tray: the controls that earn a tap rather than a permanent row. +struct MonitorTrayPanel: View { + let items: [MonitorTrayItem] + let timerValue: Int + let aspectRatio: AspectRatio + let resolution: VideoResolution + let frameRate: VideoFrameRate + let photoFormat: PhotoFormat + let hdrMode: HDRMode + /// The camera's *confirmed* mode, not local intent — the tile only lights + /// up once the peer has said so. + var cameraPreviewMode: CameraPreviewMode = .on + let isQualityEnabled: Bool + let isTimerEnabled: Bool + let isSettingsEnabled: Bool + let onTap: (MonitorTrayItem) -> Void + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 12), count: 3) + + var body: some View { + VStack(spacing: 18) { + Capsule() + .fill(Color.white.opacity(0.3)) + .frame(width: 36, height: 5) + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(items, id: \.self) { item in + MonitorTrayTile(item: item, + value: value(for: item), + isActive: isActive(item), + isEnabled: isEnabled(item), + action: { onTap(item) }) + } } - .disabled(!viewModel.isSettingsEnabled) + } + .padding(.top, 10) + .padding(.horizontal, 20) + .padding(.bottom, 28) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(.ultraThinMaterial) + .ignoresSafeArea(edges: .bottom) + ) + } + + /// The glyph's payload — each tile shows its own current value, which is + /// what lets it live behind a tap. + private func value(for item: MonitorTrayItem) -> String? { + switch item { + case .timer: return timerValue > 0 ? "\(timerValue)" : nil + case .aspect: return aspectRatio.displayName + case .resolution: return resolution.displayName + case .frameRate: return frameRate.displayName + case .format: return photoFormat.displayName + // Glyph-only: state is carried by the symbol. + case .hdr, .cameraStandby, .settings, .help: return nil } } - - private var flashButton: some View { - Button(action: onToggleFlash) { - Image(systemName: viewModel.isFlashEnabled ? "bolt.fill" : "bolt.slash") - .font(.system(size: 20)) - .foregroundColor(viewModel.isFlashEnabled ? .yellow : .white) + + private func isActive(_ item: MonitorTrayItem) -> Bool { + switch item { + case .timer: return timerValue > 0 + case .hdr: return hdrMode == .on + case .cameraStandby: return cameraPreviewMode == .standby + default: return false } - .disabled(!viewModel.isFlashButtonEnabled) } - - private var torchButton: some View { - Button(action: onToggleTorch) { - Image(systemName: viewModel.isTorchEnabled ? "flashlight.on.fill" : "flashlight.off.fill") - .font(.system(size: 20)) - .foregroundColor(viewModel.isTorchEnabled ? .yellow : .white) + + private func isEnabled(_ item: MonitorTrayItem) -> Bool { + switch item { + case .timer: return isTimerEnabled + case .aspect, .resolution, .frameRate, .format, .hdr: return isQualityEnabled + // Not a capture setting: usable mid-recording. + case .cameraStandby: return true + case .settings: return isSettingsEnabled + case .help: return true } - .disabled(!viewModel.isTorchButtonEnabled) } } -// MARK: - Preview -struct MonitorView_Previews: PreviewProvider { - static var previews: some View { - MonitorView( - viewModel: MonitorViewModel(), - onTakePicture: {}, - onToggleCamera: {}, - onSelectCameraDevice: { _ in }, - onToggleFlash: {}, - onToggleTorch: {}, - onTimerChange: { _ in }, - onModeChange: { _ in }, - onGalleryTapped: {}, - onSettingsTapped: {}, - onZoomChange: { _ in }, - onVideoQualityChange: { _, _ in }, - onPhotoQualityChange: { _, _ in }, - onAspectRatioChange: { _ in }, - onFocusTap: { _ in } - ) - .preferredColorScheme(.dark) +/// One tray tile: a circular glyph carrying its current value, over a caption. +struct MonitorTrayTile: View { + let item: MonitorTrayItem + let value: String? + let isActive: Bool + let isEnabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + ZStack { + Circle() + .fill(Color.white.opacity(0.12)) + .frame(width: 56, height: 56) + + if let value { + Text(value) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .minimumScaleFactor(0.6) + .lineLimit(1) + .padding(.horizontal, 6) + } else { + Image(systemName: symbol) + .font(.system(size: 22, weight: .semibold)) + } + } + .foregroundColor(tint) + + Text(caption) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundColor(isEnabled ? .white.opacity(0.6) : .white.opacity(0.3)) + } + .contentShape(Rectangle()) + } + .disabled(!isEnabled) + } + + private var tint: Color { + if !isEnabled { return .white.opacity(0.3) } + return isActive ? AppTheme.accent : .white + } + + private var symbol: String { + switch item { + case .timer: return "timer" + case .aspect: return "aspectratio" + case .resolution: return "rectangle.on.rectangle" + case .frameRate: return "speedometer" + case .format: return "doc" + case .hdr: return "camera.filters" + case .cameraStandby: return isActive ? "moon.zzz.fill" : "moon.zzz" + case .settings: return "gearshape.fill" + case .help: return "questionmark" + } + } + + private var caption: String { + switch item { + case .timer: return NSLocalizedString("TIMER", comment: "tray tile") + case .aspect: return NSLocalizedString("ASPECT", comment: "tray tile") + case .resolution: return NSLocalizedString("QUALITY", comment: "tray tile") + case .frameRate: return NSLocalizedString("FPS", comment: "tray tile") + case .format: return NSLocalizedString("FORMAT", comment: "tray tile") + case .hdr: return NSLocalizedString("HDR", comment: "tray tile") + case .cameraStandby: return NSLocalizedString("STANDBY", comment: "tray tile") + case .settings: return NSLocalizedString("SETTINGS", comment: "tray tile") + case .help: return NSLocalizedString("HELP", comment: "tray tile") + } } } @@ -731,6 +968,8 @@ struct CameraSwitchControlView: View, Equatable { let devices: [RemoteCmd.CameraDeviceEntry] let activeDeviceID: String? let isEnabled: Bool + /// A switch is in flight; the glyph says so. + var isSwitching: Bool = false let onToggleCamera: () -> Void let onSelectCameraDevice: (String) -> Void @@ -739,6 +978,7 @@ struct CameraSwitchControlView: View, Equatable { && lhs.devices == rhs.devices && lhs.activeDeviceID == rhs.activeDeviceID && lhs.isEnabled == rhs.isEnabled + && lhs.isSwitching == rhs.isSwitching } var body: some View { @@ -767,10 +1007,18 @@ struct CameraSwitchControlView: View, Equatable { } private var switchIcon: some View { - Image(systemName: "arrow.triangle.2.circlepath.camera") - .font(.system(size: 24)) - .foregroundColor(isEnabled ? .white : .gray) - .frame(width: 44, height: 44) + ZStack { + Circle() + .fill(.ultraThinMaterial) + .frame(width: 44, height: 44) + Image(systemName: "arrow.triangle.2.circlepath.camera.fill") + .font(.system(size: 19, weight: .semibold)) + .foregroundColor(isEnabled ? .white : .white.opacity(0.35)) + .rotationEffect(.degrees(isSwitching ? 180 : 0)) + .animation(.easeInOut(duration: 0.35), value: isSwitching) + } + .frame(width: 44, height: 44) + .contentShape(Circle()) } } @@ -879,4 +1127,32 @@ struct AspectRatioCropOverlay: View { return CGSize(width: fittedSize.width, height: fittedSize.width / targetRatio) } } -} \ No newline at end of file +} + +// MARK: - Preview + +struct MonitorView_Previews: PreviewProvider { + static var previews: some View { + MonitorView( + viewModel: MonitorViewModel(), + onTakePicture: {}, + onToggleCamera: {}, + onSelectCameraDevice: { _ in }, + onToggleFlash: {}, + onToggleTorch: {}, + onTimerChange: { _ in }, + onModeChange: { _ in }, + onGalleryTapped: {}, + onSettingsTapped: {}, + onHelpTapped: {}, + onBackTapped: {}, + onZoomChange: { _ in }, + onVideoQualityChange: { _, _ in }, + onPhotoQualityChange: { _, _ in }, + onAspectRatioChange: { _ in }, + onFocusTap: { _ in }, + onToggleCameraStandby: {} + ) + .preferredColorScheme(.dark) + } +} diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index ce4d8cb..6564b87 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -38,6 +38,12 @@ extension MonitorViewController { onSettingsTapped: { [weak self] in self?.handleSettingsTapped() }, + onHelpTapped: { [weak self] in + self?.presentHelpSheet() + }, + onBackTapped: { [weak self] in + self?.navigationController?.popViewController(animated: true) + }, onZoomChange: { [weak self] factor in self?.handleZoomChange(factor) }, @@ -52,9 +58,12 @@ extension MonitorViewController { }, onFocusTap: { [weak self] point in self?.handleFocusTap(point) + }, + onToggleCameraStandby: { [weak self] in + self?.handleToggleCameraStandby() } ) - + self.swiftUIHostingController = embedSwiftUIView(monitorView) } @@ -194,6 +203,14 @@ extension MonitorViewController { session ! UICmd.FocusAtPoint(x: Float(point.x), y: Float(point.y)) } + /// Toggles the peer camera's local-preview mode. The coordinator gates the + /// send on the peer having advertised support, so a camera that predates the + /// feature simply ignores the tap. + private func handleToggleCameraStandby() { + let target: CameraPreviewMode = viewModel.cameraPreviewMode == .standby ? .on : .standby + session ! UICmd.SetCameraPreviewMode(mode: target) + } + private func handleTimerChange(_ value: Int) { viewModel.timerSliderValue = Double(value) // UserDefaults persistence is now handled in the view model @@ -295,29 +312,28 @@ extension MonitorViewController { // MARK: - SwiftUI Configuration Methods extension MonitorViewController { + // Nav-bar visibility is a property of this screen, not of the capture mode: + // viewWillAppear hides it, viewWillDisappear hands it back. Don't set it here. + func swiftUIConfigurePhotoMode() { viewModel.configurePhotoMode() viewModel.currentMode = .Photo - navigationController?.setNavigationBarHidden(false, animated: true) sendSyncMonitorSettings() } func swiftUIConfigureVideoMode() { viewModel.configureVideoMode() viewModel.currentMode = .Video - navigationController?.setNavigationBarHidden(false, animated: true) sendSyncMonitorSettings() } func swiftUIConfigureVideoRecording() { viewModel.configureVideoRecording() - navigationController?.setNavigationBarHidden(true, animated: true) } func swiftUIConfigureShortsMode() { viewModel.configureShortsMode() viewModel.currentMode = .Shorts - navigationController?.setNavigationBarHidden(false, animated: true) sendSyncMonitorSettings() } diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index d8d499d..0d056f4 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -70,37 +70,58 @@ public class MonitorViewController: UIViewController { var buttonPrompt: String = "" // MARK: - Orientation Control + + /// The monitor follows the hand that holds it. + /// + /// The camera device rotates freely, so a tripod-mounted camera is usually + /// landscape — and a portrait-locked monitor letterboxed that 16:9 frame + /// into a 393pt-wide column, leaving the picture at 26% of the screen. + /// Turning the remote to match puts it at ~82%. Nothing about capture is + /// coupled to this: frames arrive already rotated by the camera. override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return .portrait + return .allButUpsideDown } - + override public var shouldAutorotate: Bool { - return false + return true } override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - self.navigationController?.setNavigationBarHidden(false, animated: animated) - + // The viewfinder is full-bleed: Back is a floating chevron in the + // chrome and Help is a tray tile, so the nav bar has nothing left to + // carry. On Catalyst the window toolbar still owns Back — the SwiftUI + // side suppresses its own chevron there. + self.navigationController?.setNavigationBarHidden(true, animated: animated) navigationItem.title = nil - navigationController?.navigationBar.prefersLargeTitles = false - - let appearance = UINavigationBarAppearance() - appearance.configureWithTransparentBackground() - navigationController?.navigationBar.standardAppearance = appearance - navigationController?.navigationBar.scrollEdgeAppearance = appearance - navigationController?.navigationBar.tintColor = .white - - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "questionmark.circle"), - style: .plain, - target: self, - action: #selector(showHelpModal) - ) + syncInterfaceOrientation() } - @objc private func showHelpModal() { - presentHelpSheet() + override public func viewWillTransition(to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + coordinator.animate(alongsideTransition: { _ in + self.syncInterfaceOrientation() + }) + } + + /// Both landscapes are the same shape, so the view can't infer which rail + /// keeps the shutter on the device's home-indicator edge. + private func syncInterfaceOrientation() { + let orientation = view.window?.windowScene?.interfaceOrientation + ?? UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.interfaceOrientation } + .first + ?? .portrait + if viewModel.interfaceOrientation != orientation { + viewModel.interfaceOrientation = orientation + } + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + // Hand the bar back to whatever screen comes next. + self.navigationController?.setNavigationBarHidden(false, animated: animated) } override public func viewDidLoad() { @@ -112,10 +133,18 @@ public class MonitorViewController: UIViewController { frameStreamReceiver.onImage = { [weak self] image in OperationQueue.main.addOperation { + // A frame arrived: whatever the watchdog thought, the stream is + // live again. + self?.viewModel.isPreviewStale = false self?.updateCameraImageInViewModel(image) } } frameStreamReceiver.onStall = { [weak self] in + // Say so on screen. Re-requesting a frame silently left the user + // looking at a frozen picture with no way to tell it was frozen. + OperationQueue.main.addOperation { + self?.viewModel.isPreviewStale = true + } if let session = self?.session { session ! UICmd.StreamStalled() } diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index fe1e0fe..d733105 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -29,6 +29,13 @@ class MonitorViewModel: ObservableObject { /// Live frames — deliberately NOT @Published here; see FrameDisplayModel. let frames = FrameDisplayModel() @Published var flashStatus: String = "" + /// What the monitor is waiting on the camera for, or `nil` when nothing is + /// in flight. Rendered in place (on the shutter, on the switch glyph) + /// instead of by a modal that would cover the preview. + @Published var activity: MonitorActivity? + /// The frame stream has gone quiet: what is on screen is a stale picture. + /// Set by the stall watchdog, cleared by the next frame that arrives. + @Published var isPreviewStale: Bool = false @Published var isFlashEnabled: Bool = false @Published var isTorchEnabled: Bool = false @Published var buttonPrompt: String = "" @@ -70,6 +77,16 @@ class MonitorViewModel: ObservableObject { @Published var remoteCameraDevices: [RemoteCmd.CameraDeviceEntry] = [] @Published var activeRemoteDeviceID: String? + // MARK: - Camera Preview Mode (the peer camera's local preview: on / standby) + /// The connected camera's current local-preview mode, reflected so the + /// operator can see whether the camera is showing a live preview or sitting + /// in standby. The standby button icon is a function of this. + @Published var cameraPreviewMode: CameraPreviewMode = .on + + /// Mirrored from the hosting controller; picks the dock rail. Size can't + /// answer that — both landscapes are the same shape. + @Published var interfaceOrientation: UIInterfaceOrientation = .portrait + /// Which switch control the monitor shows for the peer's cameras. enum CameraSwitchControl { /// One camera — nothing to switch to. @@ -123,13 +140,6 @@ class MonitorViewModel: ObservableObject { @Published var isLensControlEnabled: Bool = true @Published var isZoomSliderEnabled: Bool = true @Published var isQualityControlEnabled: Bool = true - @Published var areControlsExpanded: Bool = UserDefaults.standard.object(forKey: "areControlsExpanded") == nil - ? true - : UserDefaults.standard.bool(forKey: "areControlsExpanded") { - didSet { - UserDefaults.standard.set(areControlsExpanded, forKey: "areControlsExpanded") - } - } // MARK: - UI Configuration Methods func configurePhotoMode() { @@ -289,6 +299,10 @@ class MonitorViewModel: ObservableObject { @Published var currentHDRMode: HDRMode = .off @Published var supportsHEIF: Bool = false @Published var supportsHDR: Bool = false + /// Whether the peer advertised the `supports_preview_mode` capability. Gates + /// the standby tray tile — an older camera ignores the command, so offering + /// a control that does nothing would be worse than hiding it. + @Published var supportsCameraStandby: Bool = false // MARK: - Video Quality Update Methods func updateVideoQuality(resolution: VideoResolution, frameRate: VideoFrameRate) { diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index e58db8a..206e718 100644 --- a/RemoteCam/RemoteCmdFlatBuffers.swift +++ b/RemoteCam/RemoteCmdFlatBuffers.swift @@ -32,6 +32,8 @@ func serializeToFlatBuffer(_ msg: Message) -> Data? { case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() + case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() + case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.EndSession: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() case let m as RemoteCmd.SwitchLens: return m.toFlatBuffer() @@ -415,7 +417,8 @@ private func encodeCapabilitiesEnvelope( backCameraOffset: backOffset, cameraDevicesVectorOffset: devicesVector, activeDeviceIdOffset: activeIDOffset, - supportsFocusPoint: c.supportsFocusPoint) + supportsFocusPoint: c.supportsFocusPoint, + supportsPreviewMode: c.supportsPreviewMode) let stateOffset = RemoteShutter_CameraState.createCameraState( &fbb, @@ -426,7 +429,8 @@ private func encodeCapabilitiesEnvelope( videoFrameRate: toFBFrameRate(c.currentVideoFrameRate), photoFormat: toFBPhotoFormat(c.currentPhotoFormat), hdrMode: toFBHDRMode(c.currentHDRMode), - activeDeviceIdOffset: activeIDOffset) + activeDeviceIdOffset: activeIDOffset, + previewMode: toFBPreviewMode(c.previewMode)) return (capsOffset, stateOffset) } @@ -593,6 +597,29 @@ extension RemoteCmd.EndSession { } } +extension RemoteCmd.SetCameraPreviewMode { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let params = RemoteShutter_CommandParameters.createCommandParameters( + &fbb, cameraPreviewMode: toFBPreviewMode(mode)) + return buildCommand(&fbb, action: .setcamerapreviewmode, parameters: params) + } +} + +extension RemoteCmd.CameraPreviewModeResp { + func toFlatBuffer() -> Data { + var fbb = FlatBufferBuilder() + let state = RemoteShutter_CameraState.createCameraState( + &fbb, previewMode: toFBPreviewMode(mode)) + let resp = RemoteShutter_CameraStateResponse.createCameraStateResponse( + &fbb, + action: .setcamerapreviewmode, + success: true, + currentStateOffset: state) + return buildResponse(&fbb, action: .setcamerapreviewmode, response: resp) + } +} + extension RemoteCmd.SetZoomResp { func toFlatBuffer() -> Data { var fbb = FlatBufferBuilder() @@ -934,6 +961,23 @@ private func fromFBAspectRatio(_ r: RemoteShutter_AspectRatioEnum) -> AspectRati } } +// MARK: - CameraPreviewMode enum conversions + +func toFBPreviewMode(_ mode: CameraPreviewMode) -> RemoteShutter_CameraPreviewModeEnum { + switch mode { + case .on: return .on + case .standby: return .standby + } +} + +/// Absent/Unknown => legacy peer or no signal; treat as On (the default). +func fromFBPreviewMode(_ mode: RemoteShutter_CameraPreviewModeEnum) -> CameraPreviewMode { + switch mode { + case .standby: return .standby + case .on, .unknown: return .on + } +} + // MARK: - SetAspectRatio toFlatBuffer() extension RemoteCmd.SetAspectRatio { @@ -1101,6 +1145,9 @@ extension RemoteCmd { case .focusatpoint: return FocusAtPoint(x: params?.focusPointX ?? 0.5, y: params?.focusPointY ?? 0.5) + case .setcamerapreviewmode: + return SetCameraPreviewMode(mode: fromFBPreviewMode(params?.cameraPreviewMode ?? .unknown)) + case .endsession: return EndSession() } @@ -1207,6 +1254,10 @@ extension RemoteCmd { let ratio: AspectRatio? = state.map { fromFBAspectRatio($0.aspectRatio) } return SetAspectRatioResp(aspectRatio: ratio, error: nsError) + case .setcamerapreviewmode: + let mode = resp.currentState.map { fromFBPreviewMode($0.previewMode) } ?? .on + return CameraPreviewModeResp(mode: mode) + default: return nil } @@ -1251,6 +1302,8 @@ extension RemoteCmd { cameraDevices: cameraDevices, activeDeviceID: activeDeviceID, supportsFocusPoint: caps?.supportsFocusPoint ?? false, + supportsPreviewMode: caps?.supportsPreviewMode ?? false, + previewMode: state.map { fromFBPreviewMode($0.previewMode) } ?? .on, error: error ) } diff --git a/RemoteCam/RemoteCmds.swift b/RemoteCam/RemoteCmds.swift index 72f7237..4071071 100644 --- a/RemoteCam/RemoteCmds.swift +++ b/RemoteCam/RemoteCmds.swift @@ -262,6 +262,36 @@ public class RemoteCmd: Message, @unchecked Sendable { } } + // MARK: - Camera Preview Mode Remote Commands + + /// Monitor -> camera: set the camera device's local-preview mode (on / + /// standby). Standby stops the camera's own on-screen preview only — the + /// capture session and the frames streamed back to the monitor are + /// unaffected. Only sent to peers that advertised + /// `CameraCapabilitiesResp.supportsPreviewMode` (old decoders read the + /// unknown action as its enum default). + public class SetCameraPreviewMode: Message, @unchecked Sendable { + public let mode: CameraPreviewMode + + public init(mode: CameraPreviewMode) { + self.mode = mode + super.init(sender: nil) + } + } + + /// Camera -> monitor: the camera's current local-preview mode, sent as the + /// ack to `SetCameraPreviewMode` and whenever the camera changes the mode + /// on its own (a local toggle), so the monitor can reflect what the camera + /// is doing. + public class CameraPreviewModeResp: Message, @unchecked Sendable { + public let mode: CameraPreviewMode + + public init(mode: CameraPreviewMode) { + self.mode = mode + super.init(sender: nil) + } + } + // MARK: - Camera Capabilities Structure public struct CameraInfo: Codable, Equatable { @@ -376,6 +406,13 @@ public class RemoteCmd: Message, @unchecked Sendable { /// monitor's tap-to-focus gate reads this so it never sends the command /// to a peer that would decode it as `TakePicture`. public let supportsFocusPoint: Bool + /// True when this peer's build understands + /// `RemoteCmd.SetCameraPreviewMode`. The monitor's standby gate reads + /// this so it never sends the command to a peer that would misread it. + public let supportsPreviewMode: Bool + /// The camera's current local-preview mode, so the monitor can reflect + /// it from the first capabilities exchange. + public let previewMode: CameraPreviewMode public let error: Error? public init(frontCamera: CameraInfo?, backCamera: CameraInfo?, @@ -388,6 +425,8 @@ public class RemoteCmd: Message, @unchecked Sendable { cameraDevices: [CameraDeviceEntry] = [], activeDeviceID: String? = nil, supportsFocusPoint: Bool = false, + supportsPreviewMode: Bool = false, + previewMode: CameraPreviewMode = .on, error: Error?) { self.frontCamera = frontCamera self.backCamera = backCamera @@ -401,6 +440,8 @@ public class RemoteCmd: Message, @unchecked Sendable { self.cameraDevices = cameraDevices self.activeDeviceID = activeDeviceID self.supportsFocusPoint = supportsFocusPoint + self.supportsPreviewMode = supportsPreviewMode + self.previewMode = previewMode self.error = error super.init(sender: nil) } diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index ddb5669..58e8ff0 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -35,6 +35,14 @@ enum LensSwitchReturn: Equatable { /// Theater machine as a compiler-checked case. Transient states carry their /// timeout generation; states whose "stack parent" varies carry where they /// return to. +/// How far a monitor-initiated photo has got. The camera acks the shutter +/// before the picture itself arrives, and the gap is long enough to matter: +/// `.receiving` is the moment the subject can stop holding the pose. +enum CapturePhase: Equatable { + case requesting + case receiving +} + enum SessionState: Equatable { case waitingForLobby case lobby @@ -54,7 +62,7 @@ enum SessionState: Equatable { // Monitor family case monitor(mode: MonitorMode) - case monitorTakingPicture(generation: Int) + case monitorTakingPicture(generation: Int, phase: CapturePhase) case monitorTogglingFlash(generation: Int) case monitorTogglingCamera(mode: MonitorMode, generation: Int) case monitorSwitchingLens(returnTo: LensSwitchReturn, generation: Int) @@ -203,6 +211,13 @@ public actor SessionCoordinator { /// Test support. func peerSupportsFocusPointForTesting() -> Bool { peerSupportsFocusPoint } + /// Whether the connected camera peer advertised preview-mode support in its + /// capabilities — the feature gate for `RemoteCmd.SetCameraPreviewMode`. + private var peerSupportsPreviewMode = false + + /// Test support. + func peerSupportsPreviewModeForTesting() -> Bool { peerSupportsPreviewMode } + /// Monitor side: at least one VP9 preview frame has arrived. Proves the /// camera peer speaks VP9, which gates sending `RemoteCmd.RequestKeyframe`. private var monitorReceivedVP9Frame = false @@ -218,6 +233,12 @@ public actor SessionCoordinator { state.name } + /// Test support: the full state, for assertions the name vocabulary cannot + /// express — a capture's `CapturePhase`, for one. + func currentState() -> SessionState { + state + } + /// Test support: place the machine directly into a state with context. func seed(state: SessionState, lobby: WeakScannerLobby? = nil, @@ -446,6 +467,9 @@ public actor SessionCoordinator { } state = newState publishWaitingOverlay() + // One write, at the one place state changes: an in-flight indicator + // cannot outlive the command it describes. + monitor?.setActivity(MonitorActivity.forState(newState)) await didEnter(newState) } @@ -523,6 +547,7 @@ public actor SessionCoordinator { func popToScanning() async { peerAdvertisedCameraDevices = false peerSupportsFocusPoint = false + peerSupportsPreviewMode = false monitorReceivedVP9Frame = false switch state { case .scanning: @@ -562,7 +587,7 @@ public actor SessionCoordinator { await inCameraTransmittingVideo(msg) case .monitor(let mode): await inMonitor(msg, mode: mode) - case .monitorTakingPicture(let generation): + case .monitorTakingPicture(let generation, _): await inMonitorTakingPicture(msg, generation: generation) case .monitorTogglingFlash(let generation): await inMonitorToggling(msg, kind: .flash, mode: .photo, generation: generation) @@ -788,6 +813,8 @@ public actor SessionCoordinator { switch msg { case let become as UICmd.BecomeCamera: ctrl = become.ctrl + // The standby screen shows who is driving the camera. + become.ctrl.cameraViewModel.setConnectedPeerName(peer?.displayName) await transition(to: .camera) await sendOrGoToScanning(RemoteCmd.PeerBecameCamera.createWithDefaults()) @@ -1235,7 +1262,11 @@ public actor SessionCoordinator { } } - // MARK: - Progress alerts (camera "Taking picture" and monitor transients) + // MARK: - Progress alerts (camera "Taking picture" only) + // + // Camera-side only. The monitor's in-flight feedback is `MonitorActivity`, + // drawn on the control the user pressed; a modal there would cover the + // preview they are framing with. private var alertHandle: AlertHandle? @@ -1248,14 +1279,6 @@ public actor SessionCoordinator { } } - private func updateCameraAlert(_ title: String) { - guard let handle = alertHandle else { return } - let presenter = alertPresenter - OperationQueue.main.addOperation { - presenter.updateAlert(handle, title: title) - } - } - private func dismissCameraAlert() async { guard let handle = alertHandle else { return } alertHandle = nil @@ -1413,6 +1436,20 @@ public actor SessionCoordinator { // Forward capabilities to the connected monitor. await sendOrGoToScanning(capabilities) + case let cmd as RemoteCmd.SetCameraPreviewMode: + // Camera side: a monitor asked us to change the local preview mode. + await applyCameraPreviewMode(cmd.mode) + + case let ui as UICmd.SetCameraPreviewMode: + // Camera side: a local toggle from this device's own chrome. (On a + // monitor this is handled by the monitor states before reaching here; + // `ctrl` is nil there, so `applyCameraPreviewMode` no-ops safely.) + await applyCameraPreviewMode(ui.mode) + + case let resp as RemoteCmd.CameraPreviewModeResp: + // Monitor side: the camera reported its current preview mode. + monitor?.updatePreviewMode(resp.mode) + case let retry as RetryCapabilities: await attemptToSendCapabilities(attempt: retry.attempt) @@ -1450,6 +1487,16 @@ public actor SessionCoordinator { } } + /// Camera side: apply + persist the local preview mode and report it back to + /// the monitor. A no-op off the camera (no `ctrl`). The report is + /// best-effort (`sendMessage`, not `sendOrGoToScanning`): a local toggle + /// while briefly unlinked must never tear the session down. + private func applyCameraPreviewMode(_ mode: CameraPreviewMode) async { + guard let ctrl else { return } + await ctrl.setPreviewMode(mode) + sendMessage(RemoteCmd.CameraPreviewModeResp(mode: mode)) + } + // MARK: - Video resource transfer (camera side) private func handleSendVideoResource(_ sendVideo: UICmd.SendVideoResource) async { @@ -1556,7 +1603,6 @@ public actor SessionCoordinator { case is UICmd.ToggleCamera: if sendMessage(RemoteCmd.ToggleCamera()) { - await showCameraAlert("Requesting camera toggle") let generation = scheduleTimeout(.monitorTogglingCamera) await transition(to: .monitorTogglingCamera(mode: mode, generation: generation)) } else { @@ -1569,7 +1615,6 @@ public actor SessionCoordinator { break } if sendMessage(RemoteCmd.SelectCameraDevice(uniqueID: select.uniqueID)) { - await showCameraAlert(NSLocalizedString("Switching camera", comment: "")) let generation = scheduleTimeout(.monitorTogglingCamera) await transition(to: .monitorTogglingCamera(mode: mode, generation: generation)) } else { @@ -1578,7 +1623,6 @@ public actor SessionCoordinator { case is UICmd.ToggleFlash where mode == .photo: if sendMessage(RemoteCmd.ToggleFlash()) { - await showCameraAlert("Requesting flash toggle") let generation = scheduleTimeout(.monitorTogglingFlash) await transition(to: .monitorTogglingFlash(generation: generation)) } else { @@ -1598,9 +1642,8 @@ public actor SessionCoordinator { switch mode { case .photo: if sendMessage(RemoteCmd.TakePic(sender: nil, sendMediaToPeer: take.sendMediaToRemote)) { - await showCameraAlert(NSLocalizedString("Requesting picture", comment: "")) let generation = scheduleTimeout(.monitorTakingPicture) - await transition(to: .monitorTakingPicture(generation: generation)) + await transition(to: .monitorTakingPicture(generation: generation, phase: .requesting)) } else { await popToScanning() } @@ -1615,7 +1658,9 @@ public actor SessionCoordinator { case let capabilities as RemoteCmd.CameraCapabilitiesResp: peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty peerSupportsFocusPoint = capabilities.supportsFocusPoint + peerSupportsPreviewMode = capabilities.supportsPreviewMode monitor?.updateCapabilities(capabilities) + monitor?.updatePreviewMode(capabilities.previewMode) case let zoom as UICmd.SetZoom: sendMessage(RemoteCmd.SetZoom(zoomFactor: zoom.zoomFactor)) @@ -1629,6 +1674,15 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.FocusAtPoint(x: focus.x, y: focus.y)) + case let preview as UICmd.SetCameraPreviewMode: + // Wire-safety gate mirroring FocusAtPoint: never send action 24 to a + // peer that predates it (it would misread the unknown action). + guard peerSupportsPreviewMode else { + debugLog("SetCameraPreviewMode dropped: peer did not advertise preview-mode support") + break + } + sendMessage(RemoteCmd.SetCameraPreviewMode(mode: preview.mode)) + case let zoomResp as RemoteCmd.SetZoomResp: monitor?.updateZoom(zoomResp.zoomFactor, zoomRange: zoomResp.zoomRange, currentLens: zoomResp.currentLens) @@ -1637,7 +1691,6 @@ public actor SessionCoordinator { case let lens as UICmd.SwitchLens: if sendMessage(RemoteCmd.SwitchLens(lensType: lens.lensType)) { - await showCameraAlert("Switching lens") let generation = scheduleTimeout(.monitorSwitchingLens) await transition(to: .monitorSwitchingLens(returnTo: .mode(mode), generation: generation)) } else { @@ -1693,11 +1746,12 @@ public actor SessionCoordinator { switch msg { case let timeout as UICmd.StateTimeout: guard timeout.stateName == .monitorTakingPicture && timeout.generation == generation else { break } - await dismissCameraAlert() await transition(to: .monitor(mode: .photo)) case is RemoteCmd.TakePicAck: - updateCameraAlert(NSLocalizedString("Receiving picture", comment: "")) + // Same generation, so the armed 10s watchdog stays valid — this is + // an in-place phase swap, not a new request. + await transition(to: .monitorTakingPicture(generation: generation, phase: .receiving)) // Quirk preserved from the old machine: the ack is echoed back to // the peers (the camera drops it via its root default). await sendOrGoToScanning(msg) @@ -1708,31 +1762,25 @@ public actor SessionCoordinator { case let resp as RemoteCmd.TakePicResp: if let pic = resp.pic { savePictureOnMonitor(pic) - await dismissCameraAlert() } else if let error = resp.error { - await dismissCameraAlert() showErrorAlert(error._domain) } await transition(to: .monitor(mode: .photo)) case is UICmd.UnbecomeMonitor: - await dismissCameraAlert() await transition(to: .connected) case let disconnected as DisconnectPeer: - await dismissCameraAlert() if let lost = disconnected.peer, lost.displayName == peer?.displayName, connectedPeers.isEmpty { await loseSessionPeer(lost) } case is UICmd.ScannerDidAppear: - await dismissCameraAlert() await leaveSession() default: // The old state dismissed the alert and dropped unhandled messages // (deliberately NOT the root handler — no error-resp synthesis here). - await dismissCameraAlert() debugLog("monitorTakingPicture: ignoring \(type(of: msg))") } } @@ -1745,7 +1793,6 @@ public actor SessionCoordinator { switch msg { case let timeout as UICmd.StateTimeout: guard timeout.stateName == ownName && timeout.generation == generation else { break } - await dismissCameraAlert() await transition(to: .monitor(mode: mode)) case is UICmd.ToggleFlash where kind == .flash: @@ -1758,12 +1805,9 @@ public actor SessionCoordinator { case let flashResp as RemoteCmd.ToggleFlashResp where kind == .flash: if flashResp.flashMode != nil { monitor?.updateFlashMode(flashResp.flashMode) - await dismissCameraAlert() } else if let error = flashResp.error { - await dismissCameraAlert() showErrorAlert(error._domain) } else { - await dismissCameraAlert() } await transition(to: .monitor(mode: mode)) @@ -1775,28 +1819,24 @@ public actor SessionCoordinator { if let capabilities = toggleResp.cameraCapabilities { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty peerSupportsFocusPoint = capabilities.supportsFocusPoint + peerSupportsPreviewMode = capabilities.supportsPreviewMode monitor?.updateCapabilities(capabilities) - await dismissCameraAlert() + monitor?.updatePreviewMode(capabilities.previewMode) } else if let error = toggleResp.error { - await dismissCameraAlert() showErrorAlert(error._domain) } else { - await dismissCameraAlert() } await transition(to: .monitor(mode: mode)) case let disconnected as DisconnectPeer: - await dismissCameraAlert() if let lost = disconnected.peer, lost.displayName == peer?.displayName, connectedPeers.isEmpty { await loseSessionPeer(lost) } case is UICmd.ScannerDidAppear: - await dismissCameraAlert() await leaveSession() case is UICmd.UnbecomeMonitor: - await dismissCameraAlert() await transition(to: .connected) default: @@ -1815,7 +1855,6 @@ public actor SessionCoordinator { switch msg { case let timeout as UICmd.StateTimeout: guard timeout.stateName == .monitorSwitchingLens && timeout.generation == generation else { break } - await dismissCameraAlert() await transition(to: returnState()) case is UICmd.SwitchLens: @@ -1827,27 +1866,21 @@ public actor SessionCoordinator { availableLenses: lensResp.availableLenses, currentZoom: lensResp.currentZoom, zoomRange: lensResp.zoomRange) - await dismissCameraAlert() } else if let error = lensResp.error { - await dismissCameraAlert() showErrorAlert(error._domain) } else { - await dismissCameraAlert() } await transition(to: returnState()) case let disconnected as DisconnectPeer: - await dismissCameraAlert() if let lost = disconnected.peer, lost.displayName == peer?.displayName, connectedPeers.isEmpty { await loseSessionPeer(lost) } case is UICmd.ScannerDidAppear: - await dismissCameraAlert() await leaveSession() case is UICmd.UnbecomeMonitor: - await dismissCameraAlert() await transition(to: .connected) default: @@ -1889,12 +1922,18 @@ public actor SessionCoordinator { } sendMessage(RemoteCmd.FocusAtPoint(x: focus.x, y: focus.y)) + case let preview as UICmd.SetCameraPreviewMode: + guard peerSupportsPreviewMode else { + debugLog("SetCameraPreviewMode dropped: peer did not advertise preview-mode support") + break + } + sendMessage(RemoteCmd.SetCameraPreviewMode(mode: preview.mode)) + case let zoomResp as RemoteCmd.SetZoomResp: monitor?.updateZoom(zoomResp.zoomFactor, zoomRange: zoomResp.zoomRange, currentLens: zoomResp.currentLens) case let lens as UICmd.SwitchLens: if sendMessage(RemoteCmd.SwitchLens(lensType: lens.lensType)) { - await showCameraAlert("Switching lens") let generation = scheduleTimeout(.monitorSwitchingLens) await transition(to: .monitorSwitchingLens(returnTo: .recording, generation: generation)) } diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 92d9a2f..f934eb8 100644 --- a/RemoteCam/UICmds.swift +++ b/RemoteCam/UICmds.swift @@ -202,6 +202,24 @@ public class UICmd { } } + // MARK: - Camera Preview Mode Commands + + /// Set the camera device's local-preview mode (on / standby). Role-directed + /// by whoever holds the coordinator: + /// - on the **camera** device it applies + persists the mode locally (a + /// local toggle from the camera's own chrome), then reports back; + /// - on the **monitor** device it forwards to the camera peer as + /// `RemoteCmd.SetCameraPreviewMode` (capability-gated). + /// Either way there is one persisted preference on the camera phone. + public class SetCameraPreviewMode: Message, @unchecked Sendable { + public let mode: CameraPreviewMode + + public init(mode: CameraPreviewMode) { + self.mode = mode + super.init(sender: nil) + } + } + public class SetZoomResp: Message, @unchecked Sendable { public let zoomFactor: CGFloat? public let currentLens: CameraLensType? diff --git a/RemoteCam/ZoomPill.swift b/RemoteCam/ZoomPill.swift index 7cf2fdb..924a556 100644 --- a/RemoteCam/ZoomPill.swift +++ b/RemoteCam/ZoomPill.swift @@ -21,11 +21,19 @@ struct ZoomPill: View { /// round trip — so without this the thumb visibly trails the cursor. @State private var pendingZoom: CGFloat? @State private var isAdjusting = false + /// Track position (0…1) when the current drag began, so movement is applied + /// as a delta. Nil when no drag is in flight. + @State private var dragStartPosition: Double? private static let trackWidth: CGFloat = 240 private static let horizontalPadding: CGFloat = 14 private static let height: CGFloat = 46 private static let stopDiameter: CGFloat = 32 + /// Gap between adjacent lens circles when collapsed. The stops sit in a tight + /// cluster rather than spread along the track: a lens button is a *choice*, + /// not a position, and spacing them by their zoom factor left ragged gaps + /// that grow with the camera's range. + private static let stopSpacing: CGFloat = 10 /// Breathing room between the number and the circle's edge. private static let stopTextInset: CGFloat = 5 private static let thumbWidth: CGFloat = 3 @@ -46,7 +54,11 @@ struct ZoomPill: View { stopRow } } - .frame(width: Self.trackWidth, height: Self.height) + // Collapsed, the pill is only as wide as its lens circles; it grows to the + // full track only while the ruler is up. A fixed track-width capsule sat + // there at 268pt permanently, which is a lot of viewfinder to spend on + // three buttons. + .frame(width: isExpanded ? Self.trackWidth : collapsedWidth, height: Self.height) .padding(.horizontal, Self.horizontalPadding) .background(glassBackground) // Scrolling over the pill zooms — reaching for the wheel is the reflex on a Mac. @@ -87,13 +99,20 @@ struct ZoomPill: View { // MARK: - Collapsed: the lens stops private var stopRow: some View { - ZStack(alignment: .leading) { + HStack(spacing: Self.stopSpacing) { ForEach(scale.stops, id: \.self) { stop in stopButton(stop) - .offset(x: offset(forHardware: stop, itemWidth: Self.stopDiameter)) } } - .frame(width: Self.trackWidth, alignment: .leading) + } + + /// The cluster's intrinsic width, which the pill collapses to. Held as a + /// number rather than left to `fit` so the capsule can animate between the + /// two widths. + private var collapsedWidth: CGFloat { + let count = CGFloat(scale.stops.count) + guard count > 0 else { return Self.stopDiameter } + return count * Self.stopDiameter + (count - 1) * Self.stopSpacing } private func stopButton(_ stop: CGFloat) -> some View { @@ -188,17 +207,30 @@ struct ZoomPill: View { // MARK: - Interaction + /// Zoom moves *relative* to where it was when the drag began, rather than + /// jumping to the absolute position under the finger. Two reasons: the pill + /// is narrower than the track while collapsed, so an absolute mapping would + /// read the first event in the wrong coordinate space and snap somewhere + /// unintended; and picking up from the current value is what the Camera + /// app's ruler does, so a small correction stays a small correction. private var dragGesture: some Gesture { DragGesture(minimumDistance: 2) .onChanged { value in cancelCollapse() isAdjusting = true - if !isExpanded { isExpanded = true } - let x = value.location.x - Self.horizontalPadding - let raw = scale.hardwareFactor(atPosition: Double(x / Self.trackWidth)) - commit(scale.snappedToStop(raw)) + let start: Double + if let existing = dragStartPosition { + start = existing + } else { + start = scale.position(forHardware: displayedZoom) + dragStartPosition = start + isExpanded = true + } + let moved = start + Double(value.translation.width) / Double(Self.trackWidth) + commit(scale.snappedToStop(scale.hardwareFactor(atPosition: moved))) } .onEnded { _ in + dragStartPosition = nil isAdjusting = false scheduleCollapse() } diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index b533b67..87b176c 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -847,4 +847,111 @@ class LoopbackSessionTests: XCTestCase { XCTAssertTrue(monitorAlerts.shownErrors.isEmpty) XCTAssertTrue(cameraAlerts.shownErrors.isEmpty) } + + // MARK: - Camera preview mode (standby) + + /// A monitor tells the camera to go to standby; the camera applies + persists + /// the mode and reports it back across the wire. + func testSetCameraPreviewModeHappyPathAcrossTheWire() async { + let fakeCamera = await connectCameraAndMonitor() + + monitorCoordinator.tell(UICmd.SetCameraPreviewMode(mode: .standby)) + await drainBothSessions() + + // Camera applied standby exactly once. + XCTAssertEqual(fakeCamera.previewModeCalls, [.standby]) + // Never confused with a capture: standby is display-only. + XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty) + + // The camera reported the new mode back to the monitor. + let resps = cameraTransport.sentMessages.compactMap { $0 as? RemoteCmd.CameraPreviewModeResp } + XCTAssertEqual(resps.count, 1) + XCTAssertEqual(resps.first?.mode, .standby) + + // Restoring the preview round-trips the same way. + monitorCoordinator.tell(UICmd.SetCameraPreviewMode(mode: .on)) + await drainBothSessions() + XCTAssertEqual(fakeCamera.previewModeCalls, [.standby, .on]) + + let monitorState = await monitorCoordinator.currentStateName() + XCTAssertEqual(monitorState, .monitor) + XCTAssertTrue(monitorAlerts.shownErrors.isEmpty) + XCTAssertTrue(cameraAlerts.shownErrors.isEmpty) + } + + /// Safety gate mirroring FocusAtPoint: a peer that did not advertise + /// preview-mode support must never be sent action 24 (it would misread it). + func testSetCameraPreviewModeIsNeverSentToLegacyPeer() async { + await connectBothSessions() + let fakeCamera = LoopbackFakeCamera() + fakeCamera.advertisesPreviewMode = false // peer predates the feature + fakeCamera.coordinator = cameraCoordinator + cameraCoordinator.tell(UICmd.BecomeCamera(sender: nil, ctrl: fakeCamera)) + await drainBothSessions() + await becomeMonitor(mode: .Photo) + monitorTransport.sentMessages.removeAll() + + monitorCoordinator.tell(UICmd.SetCameraPreviewMode(mode: .standby)) + await drainBothSessions() + + XCTAssertFalse(monitorTransport.sentMessages.contains { $0 is RemoteCmd.SetCameraPreviewMode }, + "SetCameraPreviewMode must be gated on advertised supports_preview_mode") + XCTAssertTrue(fakeCamera.previewModeCalls.isEmpty) + XCTAssertTrue(fakeCamera.takePictureCalls.isEmpty, + "an ungated command could decode as a capture on an old peer") + } + + /// Standby does not gate the *transport*: with the camera in standby, a + /// frame handed to a real FrameSender still crosses the wire and leaves the + /// monitor in `.monitor`. + /// + /// SCOPE: calls `sender.send(...)` directly, so it covers only FrameSender + /// outward. It says nothing about whether frames are still *produced* — + /// `AVCaptureVideoDataOutput` → `CaptureEngine` → `FrameStreamingCoordinator` + /// is bypassed. That needs real capture hardware; cover it in + /// `CaptureIntegrationTests`. + func testStandbyDoesNotBlockTheFrameTransport() async { + let fakeCamera = await connectCameraAndMonitor() + + // Wire a real FrameSender into the camera's session, pointed at the + // monitor peer — exactly what the camera rig does in production. + let sender = FrameSender(coordinator: cameraCoordinator) + sender.setSession(peer: monitorTransport.localPeerID, transport: cameraTransport) + cameraCoordinator.setFrameSender(sender) + + // Put the camera into standby over the wire. + monitorCoordinator.tell(UICmd.SetCameraPreviewMode(mode: .standby)) + await drainBothSessions() + XCTAssertEqual(fakeCamera.previewModeCalls, [.standby], "standby must have been applied") + + cameraTransport.sentMessages.removeAll() + + // Produce a preview frame the way the capture pipeline would. If standby + // had touched the streaming path this frame would never leave. + sender.send(RemoteCmd.SendFrame( + data: Data([0xFF, 0xD8, 0xFF, 0xE0]), + sender: nil, + fps: 30, + camPosition: .back, + camOrientation: .portrait, + codec: .jpeg)) + + // FrameSender streams on its own serial queue; give it a moment to flush. + var streamed = false + for _ in 0..<50 { + if cameraTransport.sentMessages.contains(where: { $0 is RemoteCmd.SendFrame }) { + streamed = true + break + } + await MainActor.run { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) } + } + + XCTAssertTrue(streamed, + "frame streaming to the monitor must continue while the camera is in standby") + // The loopback transport delivered that SendFrame to the monitor peer + // (didReceiveFrame → OnFrame), so the monitor's live preview kept going. + await drainBothSessions() + let monitorState = await monitorCoordinator.currentStateName() + XCTAssertEqual(monitorState, .monitor) + } } diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift new file mode 100644 index 0000000..355a462 --- /dev/null +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -0,0 +1,234 @@ +// +// MonitorChromeTests.swift +// RemoteShutterTests +// +// Pure policy tests for the monitor screen's chrome: where the action cluster +// docks, how the self-timer cycles, which tiles the tray composes, and which +// in-flight indicator a session state implies. +// + +import XCTest +import CoreGraphics +@testable import RemoteShutter + +final class MonitorChromeTests: XCTestCase { + + // MARK: - Dock + + private func dock(_ size: CGSize, + _ orientation: UIInterfaceOrientation = .portrait, + _ input: MonitorChromeInput = .touch) -> MonitorChromeDock { + MonitorChromeLayout.dock(viewSize: size, + interfaceOrientation: orientation, + input: input) + } + + /// The rail exists so rotation doesn't move the shutter under a thumb. A + /// pointer-driven window has neither, and Catalyst reports .landscapeRight + /// permanently — without this it would rail forever. + func testPointerDrivenWindowAlwaysDocksBottom() { + XCTAssertEqual(dock(CGSize(width: 1440, height: 900), .landscapeRight, .pointer), .bottom) + XCTAssertEqual(dock(CGSize(width: 1440, height: 900), .landscapeLeft, .pointer), .bottom) + } + + func testPortraitPhoneDocksBottom() { + XCTAssertEqual(dock(CGSize(width: 393, height: 852)), .bottom) + } + + /// A `horizontalSizeClass` rule would call iPhone landscape compact and + /// wrongly dock it at the bottom, crushing the preview. + func testLandscapePhoneDocksToARail() { + XCTAssertEqual(dock(CGSize(width: 852, height: 393), .landscapeRight), .trailing) + XCTAssertEqual(dock(CGSize(width: 852, height: 393), .landscapeLeft), .leading) + } + + /// The shutter is muscle memory: the two landscapes must dock to opposite + /// rails, so the cluster stays on the same physical edge as the device turns. + func testOppositeLandscapesDockToOppositeRails() { + let size = CGSize(width: 852, height: 393) + XCTAssertNotEqual(dock(size, .landscapeLeft), dock(size, .landscapeRight)) + } + + /// An iPad in Split View is landscape as a *device* but portrait-shaped as + /// a view; the layout must follow the view. + func testNarrowSplitViewDocksBottom() { + XCTAssertEqual(dock(CGSize(width: 507, height: 1024), .landscapeRight), .bottom) + } + + func testWideMacWindowDocksTrailing() { + XCTAssertEqual(dock(CGSize(width: 1440, height: 900)), .trailing) + } + + /// A resized Mac window can be portrait-shaped; nothing about being a Mac + /// should force a rail. + func testNarrowMacWindowDocksBottom() { + XCTAssertEqual(dock(CGSize(width: 600, height: 900)), .bottom) + } + + func testSquareDocksBottom() { + XCTAssertEqual(dock(CGSize(width: 800, height: 800)), .bottom) + } + + // MARK: - Self-timer + + func testTimerCyclesThroughStopsAndWraps() { + XCTAssertEqual(MonitorTimer.next(after: 0), 3) + XCTAssertEqual(MonitorTimer.next(after: 3), 5) + XCTAssertEqual(MonitorTimer.next(after: 5), 10) + XCTAssertEqual(MonitorTimer.next(after: 10), 20) + XCTAssertEqual(MonitorTimer.next(after: 20), 0, "the last stop wraps back to off") + } + + /// Older builds stored any integer 0...20 under `timerDefault` via the + /// slider. Such a value must round up onto a real stop, not strand the + /// cycle. + func testTimerRoundsLegacySliderValueUpToNextStop() { + XCTAssertEqual(MonitorTimer.next(after: 7), 10) + XCTAssertEqual(MonitorTimer.next(after: 1), 3) + XCTAssertEqual(MonitorTimer.next(after: 19), 20) + } + + /// Above the top stop there is nowhere to go but off. + func testTimerBeyondLastStopWrapsToOff() { + XCTAssertEqual(MonitorTimer.next(after: 25), 0) + } + + // MARK: - Tray composition + + private func items(_ state: MonitorUIState, + supportsHEIF: Bool = false, + supportsHDR: Bool = false, + supportsCameraStandby: Bool = false, + resolutionCount: Int = 1, + frameRateCount: Int = 1) -> [MonitorTrayItem] { + MonitorTray.items(for: state, + supportsHEIF: supportsHEIF, + supportsHDR: supportsHDR, + supportsCameraStandby: supportsCameraStandby, + resolutionCount: resolutionCount, + frameRateCount: frameRateCount) + } + + /// A camera with no optional capabilities gets the irreducible tray. + func testPhotoModeMinimalTray() { + XCTAssertEqual(items(.photoMode), [.timer, .aspect, .settings, .help]) + } + + func testPhotoModeAddsFormatAndHDRWhenSupported() { + XCTAssertEqual(items(.photoMode, supportsHEIF: true, supportsHDR: true), + [.timer, .aspect, .format, .hdr, .settings, .help]) + } + + /// Photo-only tiles must not leak into video mode, and vice versa. + func testVideoModeShowsQualityNotPhotoTiles() { + XCTAssertEqual(items(.videoMode, supportsHEIF: true, supportsHDR: true, + resolutionCount: 3, frameRateCount: 2), + [.timer, .aspect, .resolution, .frameRate, .settings, .help]) + } + + /// A single choice is not a choice — don't show a tile that cannot change. + func testSingleResolutionAndFrameRateAreOmitted() { + XCTAssertEqual(items(.videoMode, resolutionCount: 1, frameRateCount: 1), + [.timer, .aspect, .settings, .help]) + } + + /// Quality tiles stay composed while recording; the view dims them. Their + /// disappearing mid-take would be a layout jump at the worst moment. + func testRecordingKeepsQualityTiles() { + XCTAssertEqual(items(.videoRecording, resolutionCount: 3, frameRateCount: 2), + [.timer, .aspect, .resolution, .frameRate, .settings, .help]) + } + + /// Shorts runs to a fixed duration, so a self-timer has nothing to delay. + func testShortsModeHasNoTimer() { + XCTAssertEqual(items(.shortsMode), [.aspect, .settings, .help]) + } + + // MARK: - Camera standby + + /// A camera that never advertised `supports_preview_mode` would silently + /// ignore the command, so it must not be offered the control at all — the + /// same rule the device picker and focus point follow. + func testStandbyTileIsHiddenWhenPeerDoesNotSupportIt() { + for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { + XCTAssertFalse(items(state).contains(.cameraStandby), + "\(state) offered standby to a peer that can't do it") + } + } + + func testStandbyTileAppearsForEveryModeWhenSupported() { + for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { + XCTAssertTrue(items(state, supportsCameraStandby: true).contains(.cameraStandby), + "\(state) is missing the standby tile") + } + } + + /// Standby sits with Settings and Help at the end rather than among the + /// capture settings — it controls the other device, not this shot. + func testStandbyTileSitsBeforeSettings() { + let tiles = items(.photoMode, supportsCameraStandby: true) + XCTAssertEqual(tiles, [.timer, .aspect, .cameraStandby, .settings, .help]) + } + + /// Settings and Help are the tray's floor — they are how the viewfinder + /// gives up its nav bar. + func testEveryModeOffersSettingsAndHelp() { + for state in [MonitorUIState.photoMode, .videoMode, .videoRecording, .shortsMode] { + let tiles = items(state) + XCTAssertTrue(tiles.contains(.settings), "\(state) is missing Settings") + XCTAssertTrue(tiles.contains(.help), "\(state) is missing Help") + } + } + + // MARK: - Link health + + func testLiveWhenLinkedAndFramesFlowing() { + XCTAssertEqual(MonitorLinkState.resolve(link: .linked, isPreviewStale: false), .live) + } + + /// The bug this exists for: the session still believes it is connected, the + /// frames have stopped, and the old UI said nothing at all. + func testStalledWhenLinkedButFramesStopped() { + XCTAssertEqual(MonitorLinkState.resolve(link: .linked, isPreviewStale: true), .stalled) + } + + /// A dropped link outranks a stall — the stall is its symptom, and naming + /// the cause is more useful than naming the effect. + func testReconnectingOutranksStall() { + XCTAssertEqual(MonitorLinkState.resolve(link: .reconnecting(peerName: "iPhone"), isPreviewStale: true), + .reconnecting) + XCTAssertEqual(MonitorLinkState.resolve(link: .reconnecting(peerName: "iPhone"), isPreviewStale: false), + .reconnecting) + } + + // MARK: - In-flight activity + + func testTransientMonitorStatesMapToActivities() { + XCTAssertEqual(MonitorActivity.forState(.monitorTakingPicture(generation: 1, phase: .requesting)), + .capturing) + XCTAssertEqual(MonitorActivity.forState(.monitorTakingPicture(generation: 1, phase: .receiving)), + .receivingCapture) + XCTAssertEqual(MonitorActivity.forState(.monitorTogglingCamera(mode: .photo, generation: 2)), + .switchingCamera) + XCTAssertEqual(MonitorActivity.forState(.monitorTogglingFlash(generation: 3)), .togglingFlash) + XCTAssertEqual(MonitorActivity.forState(.monitorSwitchingLens(returnTo: .mode(.photo), generation: 4)), + .switchingLens) + } + + /// The whole point of deriving this from state: a settled state has no + /// activity, so an indicator cannot outlive the command it described. + func testSettledStatesHaveNoActivity() { + XCTAssertNil(MonitorActivity.forState(.monitor(mode: .photo))) + XCTAssertNil(MonitorActivity.forState(.monitor(mode: .video))) + XCTAssertNil(MonitorActivity.forState(.monitorRecordingVideo)) + XCTAssertNil(MonitorActivity.forState(.connected)) + XCTAssertNil(MonitorActivity.forState(.scanning)) + } + + /// Camera-side states drive the camera screen, never the monitor's chrome. + func testCameraStatesHaveNoMonitorActivity() { + XCTAssertNil(MonitorActivity.forState(.camera)) + XCTAssertNil(MonitorActivity.forState(.cameraTakingPic(sendMediaToPeer: true, generation: 1))) + XCTAssertNil(MonitorActivity.forState(.cameraRecordingVideo)) + } +} diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index a2aeb93..20580a0 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -23,11 +23,14 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { onModeChange: { _ in }, onGalleryTapped: {}, onSettingsTapped: {}, + onHelpTapped: {}, + onBackTapped: {}, onZoomChange: { _ in }, onVideoQualityChange: { _, _ in }, onPhotoQualityChange: { _, _ in }, onAspectRatioChange: { _ in }, - onFocusTap: { _ in }) + onFocusTap: { _ in }, + onToggleCameraStandby: {}) } /// A connected monitor with a live frame and the full lens/zoom surface. @@ -127,4 +130,130 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { let image = renderScreen(named: "monitor-video-transfer", makeMonitorView(model)) assertHasChrome(image) } + + // MARK: - Landscape + + /// The remote turned sideways to match a tripod-mounted landscape camera. + func testLandscapePutsActionClusterOnTrailingRail() { + setWindowSize(CGSize(width: 852, height: 393)) + let model = makeConnectedModel() + model.currentMode = .Photo + model.uiState = .photoMode + model.interfaceOrientation = .landscapeRight + + let image = renderScreen(named: "monitor-landscape-photo", makeMonitorView(model)) + assertHasChrome(image) + } + + /// The other landscape: the cluster rides the opposite rail so it stays on + /// the same physical edge of the device. + func testOppositeLandscapePutsActionClusterOnLeadingRail() { + setWindowSize(CGSize(width: 852, height: 393)) + let model = makeConnectedModel() + model.currentMode = .Photo + model.uiState = .photoMode + model.interfaceOrientation = .landscapeLeft + + let image = renderScreen(named: "monitor-landscape-photo-left", makeMonitorView(model)) + assertHasChrome(image) + } + + func testLandscapeVideoRecording() { + setWindowSize(CGSize(width: 852, height: 393)) + let model = makeConnectedModel() + model.currentMode = .Video + model.uiState = .videoRecording + model.isRecording = true + model.recordingStartTime = Date().addingTimeInterval(-42) + model.isShowingRecordingDuration = true + model.interfaceOrientation = .landscapeRight + + let image = renderScreen(named: "monitor-landscape-recording", makeMonitorView(model)) + assertHasChrome(image) + } + + // MARK: - Capture feedback + + /// A capture in flight shows on the shutter, not as a modal over the + /// preview — the whole point of deriving activity from session state. + func testCaptureInFlightShowsOnShutterNotOverPreview() { + let model = makeConnectedModel() + model.currentMode = .Photo + model.uiState = .photoMode + model.activity = .capturing + + let image = renderScreen(named: "monitor-capture-in-flight", makeMonitorView(model)) + assertHasChrome(image) + } + + /// The self-timer is centered and large enough to read from across the + /// room, where the subject actually is. + func testCountdownIsCenteredOverPreview() { + let model = makeConnectedModel() + model.currentMode = .Photo + model.uiState = .photoMode + model.timerValue = 5 + + let image = renderScreen(named: "monitor-countdown", makeMonitorView(model)) + assertHasChrome(image) + } + + // MARK: - Tray + + /// The tray is where the self-timer and the quality controls went when they + /// left the permanent row, so it needs its own render: `MonitorView` owns + /// `isTrayOpen` as private state and no test can reach it, which would + /// otherwise leave every tile uncovered by snapshots. + private func makeTrayPanel(items: [MonitorTrayItem], timerValue: Int) -> MonitorTrayPanel { + MonitorTrayPanel( + items: items, + timerValue: timerValue, + aspectRatio: .sixteenNine, + resolution: .hd1080p, + frameRate: .fps30, + photoFormat: .heif, + hdrMode: .on, + isQualityEnabled: true, + isTimerEnabled: true, + isSettingsEnabled: true, + onTap: { _ in }) + } + + /// Docked to the bottom over the dimmed viewfinder, the way it appears in + /// the screen rather than floating in isolation. + private func trayAsPresented(_ panel: MonitorTrayPanel) -> some View { + ZStack(alignment: .bottom) { + Color.black + panel + } + .ignoresSafeArea() + } + + func testPhotoTrayShowsTimerWithItsValue() { + let items = MonitorTray.items(for: .photoMode, + supportsHEIF: true, + supportsHDR: true, + supportsCameraStandby: false, + resolutionCount: 0, + frameRateCount: 0) + XCTAssertEqual(items.first, .timer, "Timer should lead the photo tray") + + let image = renderScreen(named: "monitor-tray-photo", + trayAsPresented(makeTrayPanel(items: items, timerValue: 10))) + assertRendered(image) + } + + func testVideoTrayShowsTimerAlongsideQuality() { + let items = MonitorTray.items(for: .videoMode, + supportsHEIF: false, + supportsHDR: false, + supportsCameraStandby: false, + resolutionCount: 2, + frameRateCount: 3) + XCTAssertEqual(items.first, .timer, "Timer should lead the video tray") + + let image = renderScreen(named: "monitor-tray-video", + trayAsPresented(makeTrayPanel(items: items, timerValue: 3))) + assertRendered(image) + } } diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index 701004a..e748d14 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -540,14 +540,26 @@ class SessionCoordinatorTests: XCTestCase { XCTAssertEqual(name, .monitorTakingPicture) } - func testMonitorTakingPictureTakePicAckUpdatesTitle() async { + /// The ack advances the capture to `.receiving` — the moment the shot is + /// taken and the subject can stop holding the pose — without raising a + /// modal. The monitor's in-flight feedback is derived from this phase and + /// drawn on the shutter; a modal here covered the live preview. + func testMonitorTakingPictureTakePicAckAdvancesToReceivingWithoutAlert() async { await enterMonitor(.Photo) await harness.deliver(UICmd.TakePicture(sender: nil, sendMediaToRemote: true)) - // Keep the alert handle from the real transition. + let requesting = await harness.currentState() + XCTAssertEqual(MonitorActivity.forState(requesting), .capturing) + + let armed = await harness.coordinator.currentTimeoutGeneration() await harness.deliver(RemoteCmd.TakePicAck(sender: nil)) - let updatedHandles = harness.alerts.shownAlerts.filter { $0.currentTitle == "Receiving picture" } - XCTAssertEqual(updatedHandles.count, 1) + let receiving = await harness.currentState() + let generationAfterAck = await harness.coordinator.currentTimeoutGeneration() + XCTAssertEqual(MonitorActivity.forState(receiving), .receivingCapture) + XCTAssertEqual(generationAfterAck, armed, + "the ack swaps the phase in place — it must not re-arm the watchdog") + XCTAssertTrue(harness.alerts.shownAlerts.isEmpty, + "the monitor must not raise a modal over the preview for a capture") } func testMonitorTakingPictureTakePicRespErrorShowsErrorAlert() async { @@ -1266,6 +1278,61 @@ class SessionCoordinatorTests: XCTestCase { XCTAssertNotNil(resp, "the monitor must receive a stop response") XCTAssertNotNil(resp?.error, "…carrying the error") } + + // MARK: - Camera preview mode (standby) + + /// The persisted preference defaults to preview-on — the shipping behavior. + /// Opt-in feature: an unset store must never report standby. + func testCameraPreviewModeDefaultsToOn() { + let suite = UserDefaults(suiteName: "preview-mode-default-\(UUID().uuidString)")! + let store = CameraPreviewModeStore(defaults: suite) + XCTAssertEqual(store.load(), .on) + XCTAssertEqual(CameraPreviewMode.default, .on) + } + + /// The preference round-trips through UserDefaults (survives relaunch). + func testCameraPreviewModePersistsRoundTrip() { + let suite = UserDefaults(suiteName: "preview-mode-roundtrip-\(UUID().uuidString)")! + + CameraPreviewModeStore(defaults: suite).save(.standby) + // A fresh store over the same suite = a relaunch. + XCTAssertEqual(CameraPreviewModeStore(defaults: suite).load(), .standby) + + CameraPreviewModeStore(defaults: suite).save(.on) + XCTAssertEqual(CameraPreviewModeStore(defaults: suite).load(), .on) + } + + /// Camera side: a remote SetCameraPreviewMode applies the mode on the rig and + /// reports it back to the monitor. + func testCameraAppliesRemoteSetPreviewMode() async { + await enterCamera() + + await harness.deliver(RemoteCmd.SetCameraPreviewMode(mode: .standby)) + + XCTAssertEqual(camera.previewModeCalls, [.standby]) + let sent = harness.fakeMP.sentMessages.map(\.msg) + let resps = sent.compactMap { $0 as? RemoteCmd.CameraPreviewModeResp } + XCTAssertEqual(resps.count, 1) + XCTAssertEqual(resps.first?.mode, .standby) + // Display-only: never mistaken for a capture. + XCTAssertTrue(camera.takePictureCalls.isEmpty) + } + + /// Camera side: a LOCAL toggle (the camera's own chrome) applies + persists + /// the mode and reports it to the monitor, same as the remote path. + func testCameraAppliesLocalSetPreviewMode() async { + await enterCamera() + + await harness.deliver(UICmd.SetCameraPreviewMode(mode: .standby)) + + XCTAssertEqual(camera.previewModeCalls, [.standby]) + let sent = harness.fakeMP.sentMessages.map(\.msg) + let resps = sent.compactMap { $0 as? RemoteCmd.CameraPreviewModeResp } + XCTAssertEqual(resps.count, 1) + XCTAssertEqual(resps.first?.mode, .standby) + let state = await harness.stateName() + XCTAssertEqual(state, .camera) + } } // MARK: - Peer-backgrounded reconnect flow (C-5) diff --git a/RemoteCamTests/RemoteCmdSerializationTests.swift b/RemoteCamTests/RemoteCmdSerializationTests.swift index 41e3c6e..a045ecc 100644 --- a/RemoteCamTests/RemoteCmdSerializationTests.swift +++ b/RemoteCamTests/RemoteCmdSerializationTests.swift @@ -50,6 +50,8 @@ final class RemoteCmdSerializationTests: XCTestCase { case let m as RemoteCmd.SetZoom: return m.toFlatBuffer() case let m as RemoteCmd.SetZoomResp: return m.toFlatBuffer() case let m as RemoteCmd.FocusAtPoint: return m.toFlatBuffer() + case let m as RemoteCmd.SetCameraPreviewMode: return m.toFlatBuffer() + case let m as RemoteCmd.CameraPreviewModeResp: return m.toFlatBuffer() case let m as RemoteCmd.CameraCapabilitiesResp: return m.toFlatBuffer() case let m as RemoteCmd.SwitchLens: return m.toFlatBuffer() case let m as RemoteCmd.SwitchLensResp: return m.toFlatBuffer() @@ -1141,4 +1143,43 @@ extension RemoteCmdSerializationTests { XCTAssertNotNil(data) XCTAssertTrue(RemoteCmd.fromFlatBuffer(data!) is RemoteCmd.EndSession) } + + // MARK: - Camera preview mode + + func testSetCameraPreviewMode_roundTrip() { + for mode: CameraPreviewMode in [.on, .standby] { + let result = roundTrip(RemoteCmd.SetCameraPreviewMode(mode: mode)) + XCTAssertEqual(result.mode, mode) + } + } + + func testCameraPreviewModeResp_roundTrip() { + for mode: CameraPreviewMode in [.on, .standby] { + let result = roundTrip(RemoteCmd.CameraPreviewModeResp(mode: mode)) + XCTAssertEqual(result.mode, mode) + } + } + + /// Capabilities carry both the support flag and the current mode so the + /// monitor learns them from the first exchange. + func testCapabilitiesCarryPreviewModeSupportAndMode() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + supportsPreviewMode: true, previewMode: .standby, error: nil) + let result = roundTrip(caps) + XCTAssertTrue(result.supportsPreviewMode) + XCTAssertEqual(result.previewMode, .standby) + } + + /// A peer that predates the feature decodes as unsupported / preview-on. + func testCapabilitiesDefaultPreviewModeIsOnAndUnsupported() { + let caps = RemoteCmd.CameraCapabilitiesResp( + frontCamera: nil, backCamera: nil, + currentCamera: .back, currentLens: .wideAngle, currentZoom: 1.0, + error: nil) + let result = roundTrip(caps) + XCTAssertFalse(result.supportsPreviewMode) + XCTAssertEqual(result.previewMode, .on) + } } diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index c3b6dc9..90b60dd 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -222,6 +222,25 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { /// TakePicture). var advertisesFocusPoint = true + /// False simulates a peer whose capabilities omit preview-mode support — the + /// monitor must never send SetCameraPreviewMode to it. + var advertisesPreviewMode = true + + /// Records every applied preview mode. Lock-backed: `setPreviewMode` is + /// called from the coordinator's actor context while the test body reads + /// this from the test thread (a bare array races under TSan). + private let previewModeCallsStorage = Locked<[CameraPreviewMode]>([]) + var previewModeCalls: [CameraPreviewMode] { previewModeCallsStorage.value } + /// The persisted mode this fake reports (advertised in capabilities and + /// returned by `currentPreviewMode`). Written on the actor, read on the actor. + var storedPreviewMode: CameraPreviewMode = .on + + func setPreviewMode(_ mode: CameraPreviewMode) async { + storedPreviewMode = mode + previewModeCallsStorage.mutate { $0.append(mode) } + } + func currentPreviewMode() async -> CameraPreviewMode { storedPreviewMode } + /// Devices that accept the input swap but never deliver a frame (a /// wedged virtual camera). While one is active, awaitFrameDelivery fails. var stalledDeviceIDs: Set = [] @@ -250,6 +269,8 @@ class FakeCameraControlling: CameraControlling, @unchecked Sendable { cameraDevices: entries, activeDeviceID: advertisesCameraDevices ? activeDeviceID : nil, supportsFocusPoint: advertisesFocusPoint, + supportsPreviewMode: advertisesPreviewMode, + previewMode: storedPreviewMode, error: nil) } @@ -303,6 +324,10 @@ struct CoordinatorHarness { await MainActor.run { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) } } + func currentState() async -> SessionState { + await coordinator.currentState() + } + func stateName() async -> RemoteCamState { await coordinator.currentStateName() } diff --git a/RemoteCamTests/SnapshotTestSupport.swift b/RemoteCamTests/SnapshotTestSupport.swift index 204f836..e3dc61c 100644 --- a/RemoteCamTests/SnapshotTestSupport.swift +++ b/RemoteCamTests/SnapshotTestSupport.swift @@ -26,6 +26,14 @@ class SnapshotTestCase: XCTestCase { super.tearDown() } + /// Re-sizes the host window. For screens whose layout is a function of the + /// view's shape rather than of a size class — the monitor docks its action + /// cluster on `width > height` — portrait and landscape are genuinely + /// different renders and both need covering. + func setWindowSize(_ size: CGSize) { + window.frame = CGRect(origin: .zero, size: size) + } + /// True when the last renderScreen call fell back to ImageRenderer /// (headless CI). ScrollView-rooted screens produce no content on that /// path — tests for such screens should skip pixel assertions when set. diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index c6f8c05..9cc9f5f 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ 06BB79BC2E3884F00094E085 /* CameraProgressOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */; }; 06BB79BD2E3884F00094E085 /* CameraProgressOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */; }; 06BB79BF2E3884FA0094E085 /* CameraViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */; }; + CAFEBABE0013000000000002 /* CameraPreviewMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0013000000000001 /* CameraPreviewMode.swift */; }; 06BB79C12E38852B0094E085 /* VideoTransferProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BB79C02E38852B0094E085 /* VideoTransferProgressView.swift */; }; 06BB79C42E389D6D0094E085 /* VideoTransferProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BB79C02E38852B0094E085 /* VideoTransferProgressView.swift */; }; 06CDFB17252E9A7900EA56FB /* RemoteCmds.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CDFB16252E9A7900EA56FB /* RemoteCmds.swift */; }; @@ -180,6 +181,7 @@ CAFEBABE0012000000000002 /* VP9StreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0012000000000001 /* VP9StreamingTests.swift */; }; CAFEBABE0099000000000002 /* HEVCFrameEncoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */; }; CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */; }; + CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0121000000000001 /* MonitorChromeTests.swift */; }; CAFEBABE0100000000000002 /* PeerCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0100000000000001 /* PeerCompatibility.swift */; }; CB5F78DFB9D567955BC863AF /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99FC5340BB98B2BD307FFA1A /* SoundManager.swift */; }; D14174575AF4813B523869A4 /* MultipeerCompatAliases.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3BD30A73B8F4FBEBF12B15 /* MultipeerCompatAliases.swift */; }; @@ -198,6 +200,7 @@ FADEC0DE0001000000000003 /* FrameCreditWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0001000000000001 /* FrameCreditWindow.swift */; }; FADEC0DE0002000000000002 /* PeerLinkStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = FADEC0DE0002000000000001 /* PeerLinkStatus.swift */; }; FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */; }; + CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFEBABE0120000000000002 /* MonitorChrome.swift */; }; FEEDFACE0000000000000001 /* StormoLoopbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */; }; /* End PBXBuildFile section */ @@ -312,6 +315,7 @@ 06BB79B72E374D410094E085 /* FeatureFlags.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureFlags.swift; sourceTree = ""; }; 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraProgressOverlayView.swift; sourceTree = ""; }; 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraViewModel.swift; sourceTree = ""; }; + CAFEBABE0013000000000001 /* CameraPreviewMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPreviewMode.swift; sourceTree = ""; }; 06BB79C02E38852B0094E085 /* VideoTransferProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoTransferProgressView.swift; sourceTree = ""; }; 06CDFB16252E9A7900EA56FB /* RemoteCmds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCmds.swift; sourceTree = ""; }; 06CDFB1D252E9CD200EA56FB /* UICmds.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICmds.swift; sourceTree = ""; }; @@ -423,6 +427,7 @@ CAFEBABE0012000000000001 /* VP9StreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VP9StreamingTests.swift; sourceTree = ""; }; CAFEBABE0099000000000001 /* HEVCFrameEncoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HEVCFrameEncoder.swift; sourceTree = ""; }; CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPointMappingTests.swift; sourceTree = ""; }; + CAFEBABE0121000000000001 /* MonitorChromeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MonitorChromeTests.swift; sourceTree = ""; }; CAFEBABE0100000000000001 /* PeerCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerCompatibility.swift; sourceTree = ""; }; CD857DFD7882DAA5012B70C9 /* FlatBufferSchemas.fbs */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; path = FlatBufferSchemas.fbs; sourceTree = ""; }; D92E4A9C10FB90A6F07DB857 /* RemoteShutterWatch/RemoteShutterWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RemoteShutterWatch/RemoteShutterWatchApp.swift; sourceTree = SOURCE_ROOT; }; @@ -436,6 +441,7 @@ FADEC0DE0001000000000001 /* FrameCreditWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrameCreditWindow.swift; sourceTree = ""; }; FADEC0DE0002000000000001 /* PeerLinkStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerLinkStatus.swift; sourceTree = ""; }; FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusPointMapping.swift; sourceTree = ""; }; + CAFEBABE0120000000000002 /* MonitorChrome.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MonitorChrome.swift; sourceTree = ""; }; FCB0F6BA2086BF9BB4242D66 /* Pods_RemoteShutterWatch.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RemoteShutterWatch.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FEEDFACE0000000000000002 /* StormoLoopbackTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StormoLoopbackTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -584,6 +590,7 @@ AABB00032E930002009TESTS /* RemoteCmdSerializationTests.swift */, CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, + CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, CAFEBABE0003000000000001 /* WatchSerializationTests.swift */, CAFEBABE0006000000000001 /* WatchPreviewStreamerTests.swift */, @@ -622,6 +629,7 @@ 0A11B22C33D44E55F6070003 /* ZoomPill.swift */, 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */, 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */, + CAFEBABE0013000000000001 /* CameraPreviewMode.swift */, 06BB79C02E38852B0094E085 /* VideoTransferProgressView.swift */, 068DF59B2E3544AD00A49279 /* MonitorViewController+SwiftUI.swift */, 068DF59C2E3544AD00A49279 /* MonitorViewModel.swift */, @@ -674,6 +682,7 @@ 0684A2D81BE6E9D400F0B238 /* RemoteCamSession */, 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, + CAFEBABE0120000000000002 /* MonitorChrome.swift */, 060B1E141BE7079800077BCC /* Helpers */, 06E965202535199400E5A8B3 /* MediaProcessors.swift */, 06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */, @@ -1165,6 +1174,7 @@ 0673275D2E2DF142003E5F94 /* PermissionManager.swift in Sources */, 0684A2D11BE65A9800F0B238 /* OrientationUtils.swift in Sources */, FC0CF5A101FE65A9800F0B238 /* FocusPointMapping.swift in Sources */, + CAFEBABE0120000000000001 /* MonitorChrome.swift in Sources */, 06BB79B82E374D410094E085 /* FeatureFlags.swift in Sources */, 0692844F1BE5C0E600AF4678 /* MultipeerMessages.swift in Sources */, 069284531BE5C0E600AF4678 /* RemoteCamStateNames.swift in Sources */, @@ -1173,6 +1183,7 @@ 06E9652725351E3F00E5A8B3 /* SwiftConstants.swift in Sources */, 06BB79AE2E372D420094E085 /* RecordingTimer.swift in Sources */, 06BB79BF2E3884FA0094E085 /* CameraViewModel.swift in Sources */, + CAFEBABE0013000000000002 /* CameraPreviewMode.swift in Sources */, 067BEFCC2E2CD1A200F54B4E /* LocalNetworkPermissionView.swift in Sources */, 0634594A25886387009F9BE0 /* DeviceScannerViewController.swift in Sources */, B1C0DE0300000006 /* DeviceScannerView.swift in Sources */, @@ -1235,6 +1246,7 @@ AABB00042E930002009TESTS /* RemoteCmdSerializationTests.swift in Sources */, CAFEBABE0001000000000002 /* CropRectTests.swift in Sources */, CAFEBABE00F0000000000002 /* FocusPointMappingTests.swift in Sources */, + CAFEBABE0121000000000002 /* MonitorChromeTests.swift in Sources */, CAFEBABE0002000000000002 /* WatchCaptureCountdownTests.swift in Sources */, CAFEBABE0003000000000002 /* WatchSerializationTests.swift in Sources */, CAFEBABE0006000000000002 /* WatchPreviewStreamerTests.swift in Sources */,