From fc968e2f9df69e1d28e1ffe4575cf0cd80b22543 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 00:36:18 -0700 Subject: [PATCH 01/27] Redesign the monitor screen around the viewfinder A 1-star review put it plainly: on the remote the preview is tiny while screen space goes to a clumsy arrangement of controls. Measured on an iPhone 16 in portrait against a 16:9 camera, the image was 26% of the screen and the opaque control panel 44%. Invert the layout: the preview is full-bleed under floating chrome instead of stacked above an opaque panel. Portrait is width-limited so the image barely grows there, but the controls no longer sit on top of a fixed budget of pixels, and landscape now reaches 82% -- which is why rotation is unlocked and the nav bar hidden. The action cluster docks to a trailing rail when the view is wider than it is tall (MonitorChromeLayout.dock), a rule keyed off the view's shape rather than size class: an iPhone in landscape is compact width on every non-Max phone, so a size-class rule would bottom-dock the exact case this screen exists to serve. Two real bugs surfaced while measuring, both fixed at the root: Every remote command raised a modal UIAlertController spinner over the live preview. Activity is now derived from session state at the single transition(to:) choke point (MonitorActivity.forState) and rendered on the shutter, mirroring the idiom PeerLinkStatus already documents: an indicator that is a function of the state cannot outlive the thing it describes. The show/dismiss pairs a pushed indicator needs are exactly what stranded those spinners. Removes 6 alert sites and 24 dead dismissCameraAlert() calls. TakePicture gains a CapturePhase so "requesting" and "receiving" are distinguishable without an alert title; the phase swap keeps the same generation, so the armed watchdog carries over. A stalled stream was silent -- StreamStalled re-requested a frame with nothing on screen to say the picture was frozen. Adds a link chip and desaturates the stale frame. The self-timer and quality controls move into a tray behind one tap; each tile carries its own value, which is what lets them stop occupying a permanent row. Zoom pill: cluster the lens stops instead of spacing them by position on the zoom track (the gaps grew with the camera's range), and let the capsule shrink to fit when collapsed rather than holding 268pt for three buttons. The drag becomes relative to where it started -- absolute mapping assumed a fixed 240pt width and would read the first event in the wrong coordinate space now that the collapsed pill is narrower. Layout, tray composition, timer detents, link state and activity are pure functions in MonitorChrome.swift so they are testable without a window. 621 tests pass; adds snapshot coverage for landscape, capture in flight, countdown, and the tray (which had none -- MonitorView owns isTrayOpen privately, so no test could reach those tiles). Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorChrome.swift | 173 +++ RemoteCam/MonitorPresenter.swift | 9 + RemoteCam/MonitorView.swift | 1129 ++++++++++------- RemoteCam/MonitorViewController+SwiftUI.swift | 6 + RemoteCam/MonitorViewController.swift | 49 +- RemoteCam/MonitorViewModel.swift | 7 + RemoteCam/SessionCoordinator.swift | 72 +- RemoteCam/ZoomPill.swift | 48 +- RemoteCamTests/MonitorChromeTests.swift | 185 +++ .../MonitorScreenSnapshotTests.swift | 113 ++ RemoteCamTests/RemoteCamSessionTests.swift | 20 +- RemoteCamTests/SessionTestSupport.swift | 4 + RemoteCamTests/SnapshotTestSupport.swift | 8 + RemoteShutter.xcodeproj/project.pbxproj | 8 + 14 files changed, 1304 insertions(+), 527 deletions(-) create mode 100644 RemoteCam/MonitorChrome.swift create mode 100644 RemoteCamTests/MonitorChromeTests.swift diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift new file mode 100644 index 00000000..224ae2d5 --- /dev/null +++ b/RemoteCam/MonitorChrome.swift @@ -0,0 +1,173 @@ +// +// MonitorChrome.swift +// RemoteShutter +// +// Copyright © 2026 Security Union LLC. All rights reserved. +// + +import CoreGraphics +import Foundation + +// MARK: - Chrome dock + +/// Where the monitor's action cluster (gallery · shutter · camera switch) sits. +enum MonitorChromeDock: Equatable { + /// Across the bottom — the screen is taller than it is wide. + case bottom + /// Down the trailing edge — the screen is wider than it is tall, so the + /// bottom is the scarce axis and the side rails are the free space. + case trailing +} + +/// The monitor screen's pure layout policy. Deliberately a function of the +/// view's own shape rather than of size class or platform: an iPhone in +/// landscape is *compact* width on every non-Max phone, so a size-class rule +/// would bottom-dock the exact case this screen exists to serve. Shape covers +/// iPhone rotation, iPad Split View, and a resized Mac window with one rule and +/// no `#if`. +enum MonitorChromeLayout { + + static func dock(viewSize: CGSize) -> MonitorChromeDock { + viewSize.width > viewSize.height ? .trailing : .bottom + } +} + +// MARK: - Self-timer + +/// The self-timer's detented values. Replaces the 0...20 continuous slider: a +/// remote's timer is picked from a handful of useful delays, and a tap-to-cycle +/// glyph costs a fraction of the screen a labelled slider did. +enum MonitorTimer { + + /// Ascending, starting at "off". Mirrors the delays a camera app offers. + static let stops: [Int] = [0, 3, 5, 10, 20] + + /// The next stop strictly above `value`, wrapping to off at the end. + /// + /// Values that are not themselves stops are rounded *up* to the next one, + /// so a delay restored from an older build's slider (which stored any + /// integer 0...20 under `timerDefault`) lands on a real stop instead of + /// being stranded. + static func next(after value: Int) -> Int { + stops.first { $0 > value } ?? stops[0] + } +} + +// MARK: - Tray + +/// One tile in the capture tray — the controls that leave the viewfinder. +/// +/// Each tile's glyph carries its own current value (the timer shows "5", aspect +/// shows "16:9"), which is what lets them live behind a tap instead of +/// occupying a permanent row. +enum MonitorTrayItem: Equatable { + case timer + case aspect + case resolution + case frameRate + case format + case hdr + case settings + case help +} + +enum MonitorTray { + + /// The tiles for a given mode and set of peer capabilities. + /// + /// Capability-driven tiles are omitted rather than disabled: a camera that + /// cannot do HDR should not show an HDR tile at all. Tiles that exist but + /// are momentarily unavailable (quality during a recording) stay in the + /// list and are dimmed by the view — that is enablement, not composition. + static func items(for state: MonitorUIState, + supportsHEIF: Bool, + supportsHDR: 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 + } + + items.append(.settings) + items.append(.help) + return items + } +} + +// MARK: - Link health + +/// What the monitor can say about the picture it is showing. +/// +/// Apple's Camera has no equivalent — its sensor is in your hand, so a frozen +/// preview is impossible. Here the stream can go quiet while the session still +/// believes it is connected, and the old behaviour was silence: the image +/// simply stopped updating with nothing on screen to say so. +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 the session state at the single `transition(to:)` choke point +/// rather than pushed from each command site, for the reason `PeerLinkStatus` +/// documents about the reconnect overlay: when the indicator is a function of +/// the state, it cannot outlive the thing it describes. The show/dismiss pairs +/// that a pushed indicator needs are exactly what used to leave a modal spinner +/// over the preview. +enum MonitorActivity: Equatable { + /// Shutter pressed; the camera has not acknowledged yet. + case capturing + /// The camera took the shot and the picture is on its way. A distinct state + /// because it is 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 163909d7..8f016d27 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 } } diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 6b090d5b..78b07f6a 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 @@ -33,100 +41,81 @@ struct MonitorView: View { @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)) - // 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) + .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 Mac's window toolbar already owns Back; a floating chevron would + /// duplicate it. Every other platform hides the nav bar for the viewfinder + /// and needs its own way out. + private static var showsFloatingBackButton: Bool { + #if targetEnvironment(macCatalyst) + false + #else + true + #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 +143,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 +167,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 +206,217 @@ 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 .trailing: + trailingCluster + } + } + .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 { + GlassCircleButton(systemImage: "chevron.backward", + size: 36, + glyphSize: 16, + isEnabled: viewModel.isBackEnabled, + action: onBackTapped) + } + LinkChip(state: MonitorLinkState.resolve(link: peerLink.link, + isPreviewStale: viewModel.isPreviewStale)) + .equatable() + .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. + private var bottomCluster: some View { + VStack(spacing: 14) { + activeCameraCaption ZoomPill(scale: viewModel.zoomScale, currentZoomFactor: viewModel.currentZoomFactor, onZoomChange: onZoomChange) - .padding(.bottom, 24) + actionCluster(axis: .horizontal) + modeSelector } } - // 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 bottom is the scarce axis, so the action cluster moves + /// to the trailing rail and only the zoom pill and mode selector stay low. + private var trailingCluster: some View { + HStack(alignment: .bottom, spacing: 16) { + VStack(spacing: 10) { + Spacer(minLength: 0) + activeCameraCaption + HStack(spacing: 16) { + ZoomPill(scale: viewModel.zoomScale, + currentZoomFactor: viewModel.currentZoomFactor, + onZoomChange: onZoomChange) + modeSelector } } - .padding(.horizontal, 20) + + actionCluster(axis: .vertical) } } - @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 + } + } else { + VStack(spacing: 24) { + gallery + shutter + switcher } - .disabled(!viewModel.isQualityControlEnabled) } + } + } - 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) + ) + } + } + + // 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. + Color.black.opacity(0.001) + .ignoresSafeArea() + .onTapGesture { toggleTray() } + + MonitorTrayPanel( + items: MonitorTray.items(for: viewModel.uiState, + supportsHEIF: viewModel.supportsHEIF, + supportsHDR: viewModel.supportsHDR, + 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, + isQualityEnabled: viewModel.isQualityControlEnabled, + isTimerEnabled: viewModel.isTimerSliderEnabled, + isSettingsEnabled: viewModel.isSettingsEnabled, + onTap: handleTrayTap) + .transition(.move(edge: .bottom)) } } @@ -309,336 +425,410 @@ 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 .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) + } + .disabled(!isEnabled) + } +} + +// MARK: - Shutter + +/// The capture button, including what the camera is currently doing about it. +/// +/// The in-flight ring replaces the modal spinner that used to cover the preview +/// on every command: the 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) - } .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 + 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 + case .hdr, .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 + 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 + 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)) + } + } + .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 .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 .settings: return NSLocalizedString("SETTINGS", comment: "tray tile") + case .help: return NSLocalizedString("HELP", comment: "tray tile") + } } } @@ -731,6 +921,8 @@ struct CameraSwitchControlView: View, Equatable { let devices: [RemoteCmd.CameraDeviceEntry] let activeDeviceID: String? let isEnabled: Bool + /// A switch is in flight; the glyph says so instead of a modal. + var isSwitching: Bool = false let onToggleCamera: () -> Void let onSelectCameraDevice: (String) -> Void @@ -739,6 +931,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 +960,17 @@ 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) } } @@ -879,4 +1079,31 @@ 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 } + ) + .preferredColorScheme(.dark) + } +} diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index ce4d8cbe..a4cec5f8 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) }, diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index d8d499d3..16b4c947 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -70,37 +70,36 @@ 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) - ) } - @objc private func showHelpModal() { - presentHelpSheet() + 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 +111,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 fe1e0fe0..9cf8225c 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 = "" diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index ddb56699..7e4063d5 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) @@ -218,6 +226,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 +460,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) } @@ -562,7 +579,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) @@ -1235,7 +1252,12 @@ public actor SessionCoordinator { } } - // MARK: - Progress alerts (camera "Taking picture" and monitor transients) + // MARK: - Progress alerts (camera "Taking picture" only) + // + // The monitor no longer raises these. Its in-flight feedback is + // `MonitorActivity`, derived from the state at `transition(to:)` and drawn + // on the control the user pressed — a modal here covered the live preview + // at exactly the moment the user was framing with it. private var alertHandle: AlertHandle? @@ -1248,14 +1270,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 @@ -1556,7 +1570,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 +1582,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 +1590,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 +1609,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() } @@ -1637,7 +1647,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 +1702,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 +1718,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 +1749,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 +1761,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)) @@ -1776,27 +1776,21 @@ public actor SessionCoordinator { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty peerSupportsFocusPoint = capabilities.supportsFocusPoint monitor?.updateCapabilities(capabilities) - await dismissCameraAlert() } 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 +1809,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 +1820,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: @@ -1894,7 +1881,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: .recording, generation: generation)) } diff --git a/RemoteCam/ZoomPill.swift b/RemoteCam/ZoomPill.swift index 7cf2fdb8..924a5564 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/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift new file mode 100644 index 00000000..7067defb --- /dev/null +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -0,0 +1,185 @@ +// +// 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 + + /// iPhone portrait — the shape this screen has always had. + func testPortraitPhoneDocksBottom() { + XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 393, height: 852)), .bottom) + } + + /// The case the redesign exists for: iPhone turned sideways to match a + /// landscape camera. A `horizontalSizeClass` rule would call this compact + /// and wrongly dock it at the bottom, crushing the preview. + func testLandscapePhoneDocksTrailing() { + XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 852, height: 393)), .trailing) + } + + /// 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(MonitorChromeLayout.dock(viewSize: CGSize(width: 507, height: 1024)), .bottom) + } + + func testWideMacWindowDocksTrailing() { + XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 1440, height: 900)), .trailing) + } + + /// A resized Mac window can be portrait-shaped; nothing about being a Mac + /// should force the trailing rail. + func testNarrowMacWindowDocksBottom() { + XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 600, height: 900)), .bottom) + } + + /// Exactly square resolves to bottom rather than being undefined. + func testSquareDocksBottom() { + XCTAssertEqual(MonitorChromeLayout.dock(viewSize: 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, + resolutionCount: Int = 1, + frameRateCount: Int = 1) -> [MonitorTrayItem] { + MonitorTray.items(for: state, + supportsHEIF: supportsHEIF, + supportsHDR: supportsHDR, + 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]) + } + + /// 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 a2aeb939..4c0ce9f7 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -23,6 +23,8 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { onModeChange: { _ in }, onGalleryTapped: {}, onSettingsTapped: {}, + onHelpTapped: {}, + onBackTapped: {}, onZoomChange: { _ in }, onVideoQualityChange: { _, _ in }, onPhotoQualityChange: { _, _ in }, @@ -127,4 +129,115 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { let image = renderScreen(named: "monitor-video-transfer", makeMonitorView(model)) assertHasChrome(image) } + + // MARK: - Landscape + + /// The shape the redesign exists to serve: the remote turned sideways to + /// match a tripod-mounted landscape camera. The action cluster moves to the + /// trailing rail so the bottom stays free for the picture. + func testLandscapePutsActionClusterOnTrailingRail() { + setWindowSize(CGSize(width: 852, height: 393)) + let model = makeConnectedModel() + model.currentMode = .Photo + model.uiState = .photoMode + + let image = renderScreen(named: "monitor-landscape-photo", 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 + + 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, + 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, + 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 701004a3..73da2a69 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 { diff --git a/RemoteCamTests/SessionTestSupport.swift b/RemoteCamTests/SessionTestSupport.swift index c3b6dc94..b62cb042 100644 --- a/RemoteCamTests/SessionTestSupport.swift +++ b/RemoteCamTests/SessionTestSupport.swift @@ -303,6 +303,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 204f8363..e3dc61c9 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 c6f8c052..bc74c9bc 100644 --- a/RemoteShutter.xcodeproj/project.pbxproj +++ b/RemoteShutter.xcodeproj/project.pbxproj @@ -180,6 +180,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 +199,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 */ @@ -423,6 +425,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 +439,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 +588,7 @@ AABB00032E930002009TESTS /* RemoteCmdSerializationTests.swift */, CAFEBABE0001000000000001 /* CropRectTests.swift */, CAFEBABE00F0000000000001 /* FocusPointMappingTests.swift */, + CAFEBABE0121000000000001 /* MonitorChromeTests.swift */, CAFEBABE0002000000000001 /* WatchCaptureCountdownTests.swift */, CAFEBABE0003000000000001 /* WatchSerializationTests.swift */, CAFEBABE0006000000000001 /* WatchPreviewStreamerTests.swift */, @@ -674,6 +679,7 @@ 0684A2D81BE6E9D400F0B238 /* RemoteCamSession */, 0684A2D01BE65A9800F0B238 /* OrientationUtils.swift */, FC0CF5A201FE65A9800F0B238 /* FocusPointMapping.swift */, + CAFEBABE0120000000000002 /* MonitorChrome.swift */, 060B1E141BE7079800077BCC /* Helpers */, 06E965202535199400E5A8B3 /* MediaProcessors.swift */, 06E9652625351E3F00E5A8B3 /* SwiftConstants.swift */, @@ -1165,6 +1171,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 */, @@ -1235,6 +1242,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 */, From e671adcce67c1e23aa3cb78ed3b43ef2ba9113c8 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 00:47:41 -0700 Subject: [PATCH 02/27] Add camera preview standby mode The camera device can now stop driving its local live preview to save battery/heat on long tripod shoots, while the capture session keeps running and preview frames keep streaming to the monitor unchanged. - CameraPreviewMode (.on default / .standby) persisted per camera phone in UserDefaults (CameraPreviewModeStore); survives relaunch. - Set three ways, one preference: local camera chrome (standby button + tap-to-restore status screen), a new capability-gated RemoteCmd (SetCameraPreviewMode, wire action 24), and reflected on the monitor. - Wire: CameraPreviewModeEnum + supports_preview_mode capability + preview_mode in CameraState; gate mirrors FocusAtPoint (loopback pinned). Camera reports its mode back via CameraPreviewModeResp and in capabilities. - Standby is local-display only: SessionCoordinator/CameraRig touch only the view model, never the FrameSender/streaming path. Pinned by a loopback test that streams a frame to the monitor while in standby. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/CameraControlling.swift | 8 ++ RemoteCam/CameraHostController.swift | 4 + RemoteCam/CameraPreviewMode.swift | 62 ++++++++++ RemoteCam/CameraRig.swift | 21 ++++ RemoteCam/CameraScreenView.swift | 117 ++++++++++++++++++ RemoteCam/CameraViewModel.swift | 25 ++++ RemoteCam/CaptureEngine.swift | 4 + RemoteCam/FlatBufferSchemas.fbs | 23 +++- RemoteCam/FlatBufferSchemas_generated.swift | 47 +++++-- RemoteCam/MonitorPresenter.swift | 6 + RemoteCam/MonitorView.swift | 23 +++- RemoteCam/MonitorViewController+SwiftUI.swift | 13 +- RemoteCam/MonitorViewModel.swift | 6 + RemoteCam/RemoteCmdFlatBuffers.swift | 57 ++++++++- RemoteCam/RemoteCmds.swift | 41 ++++++ RemoteCam/SessionCoordinator.swift | 54 ++++++++ RemoteCam/UICmds.swift | 18 +++ RemoteCamTests/LoopbackSessionTests.swift | 102 +++++++++++++++ .../MonitorScreenSnapshotTests.swift | 3 +- RemoteCamTests/RemoteCamSessionTests.swift | 55 ++++++++ .../RemoteCmdSerializationTests.swift | 41 ++++++ RemoteCamTests/SessionTestSupport.swift | 21 ++++ RemoteShutter.xcodeproj/project.pbxproj | 4 + 23 files changed, 742 insertions(+), 13 deletions(-) create mode 100644 RemoteCam/CameraPreviewMode.swift diff --git a/RemoteCam/CameraControlling.swift b/RemoteCam/CameraControlling.swift index 23b8ae68..9bc9da61 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 8bc55a16..410324bc 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 00000000..f2e640c3 --- /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 84a7370b..9e117ee5 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 a1d433f1..b7f0e4f7 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() + + if viewModel.previewMode == .standby { + // Standby: the capture session and the monitor frame stream keep + // running; only this local display is replaced with a minimal + // status screen to save battery/heat on a long tripod shoot. + CameraStandbyView(viewModel: viewModel, + onRestore: { onSetPreviewMode?(.on) }) + } else { + liveContent + } + } + } + + /// The full-screen live preview and its chrome — shown when preview is on. + 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 770e9cc4..1c6285e8 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 5d7d05fb..405eb268 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 1acbb66b..303bf1ea 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 2ec08c71..951f0efd 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/MonitorPresenter.swift b/RemoteCam/MonitorPresenter.swift index 163909d7..7215852c 100644 --- a/RemoteCam/MonitorPresenter.swift +++ b/RemoteCam/MonitorPresenter.swift @@ -170,6 +170,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 6b090d5b..7908b865 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -27,6 +27,8 @@ 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. @@ -589,6 +591,9 @@ struct MonitorView: View { Spacer() + // Camera standby toggle: icon reflects the camera's reported mode. + cameraStandbyButton + // Settings Button Button(action: onSettingsTapped) { Image(systemName: "gearshape") @@ -598,6 +603,21 @@ struct MonitorView: View { .disabled(!viewModel.isSettingsEnabled) } } + + /// Puts the peer camera into / out of standby. The glyph doubles as the + /// reflection of what the camera is doing (filled = standby). + private var cameraStandbyButton: some View { + let isStandby = viewModel.cameraPreviewMode == .standby + return Button(action: onToggleCameraStandby) { + Image(systemName: isStandby ? "moon.zzz.fill" : "moon.zzz") + .font(.system(size: 20)) + .foregroundColor(isStandby ? .yellow : .white) + } + .padding(.trailing, 20) + .accessibilityLabel(Text(isStandby + ? NSLocalizedString("Turn on camera preview", comment: "monitor standby") + : NSLocalizedString("Turn off camera preview", comment: "monitor standby"))) + } private var flashButton: some View { Button(action: onToggleFlash) { @@ -636,7 +656,8 @@ struct MonitorView_Previews: PreviewProvider { onVideoQualityChange: { _, _ in }, onPhotoQualityChange: { _, _ in }, onAspectRatioChange: { _ in }, - onFocusTap: { _ in } + onFocusTap: { _ in }, + onToggleCameraStandby: {} ) .preferredColorScheme(.dark) } diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index ce4d8cbe..5a79736c 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -52,9 +52,12 @@ extension MonitorViewController { }, onFocusTap: { [weak self] point in self?.handleFocusTap(point) + }, + onToggleCameraStandby: { [weak self] in + self?.handleToggleCameraStandby() } ) - + self.swiftUIHostingController = embedSwiftUIView(monitorView) } @@ -194,6 +197,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 diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index fe1e0fe0..b5e16271 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -70,6 +70,12 @@ 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 + /// Which switch control the monitor shows for the peer's cameras. enum CameraSwitchControl { /// One camera — nothing to switch to. diff --git a/RemoteCam/RemoteCmdFlatBuffers.swift b/RemoteCam/RemoteCmdFlatBuffers.swift index e58db8a7..206e718a 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 72f72370..4071071e 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 ddb56699..745f4cab 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -203,6 +203,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 @@ -523,6 +530,7 @@ public actor SessionCoordinator { func popToScanning() async { peerAdvertisedCameraDevices = false peerSupportsFocusPoint = false + peerSupportsPreviewMode = false monitorReceivedVP9Frame = false switch state { case .scanning: @@ -788,6 +796,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()) @@ -1413,6 +1423,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 +1474,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 { @@ -1615,7 +1649,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 +1665,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) @@ -1775,7 +1820,9 @@ public actor SessionCoordinator { if let capabilities = toggleResp.cameraCapabilities { peerAdvertisedCameraDevices = !capabilities.cameraDevices.isEmpty peerSupportsFocusPoint = capabilities.supportsFocusPoint + peerSupportsPreviewMode = capabilities.supportsPreviewMode monitor?.updateCapabilities(capabilities) + monitor?.updatePreviewMode(capabilities.previewMode) await dismissCameraAlert() } else if let error = toggleResp.error { await dismissCameraAlert() @@ -1889,6 +1936,13 @@ 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) diff --git a/RemoteCam/UICmds.swift b/RemoteCam/UICmds.swift index 92d9a2fc..f934eb84 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/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index b533b675..0b87fb18 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -847,4 +847,106 @@ 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") + } + + /// THE CRITICAL INVARIANT: standby stops the camera's LOCAL preview only — + /// the camera keeps streaming preview frames to the monitor. Here a real + /// FrameSender is wired to the camera's session; after the camera is put in + /// standby, a produced frame still crosses the wire to the monitor. + func testStandbyDoesNotStopFrameStreamingToMonitor() 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/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index a2aeb939..2b62c862 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -27,7 +27,8 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { onVideoQualityChange: { _, _ in }, onPhotoQualityChange: { _, _ in }, onAspectRatioChange: { _ in }, - onFocusTap: { _ in }) + onFocusTap: { _ in }, + onToggleCameraStandby: {}) } /// A connected monitor with a live frame and the full lens/zoom surface. diff --git a/RemoteCamTests/RemoteCamSessionTests.swift b/RemoteCamTests/RemoteCamSessionTests.swift index 701004a3..d08476cd 100644 --- a/RemoteCamTests/RemoteCamSessionTests.swift +++ b/RemoteCamTests/RemoteCamSessionTests.swift @@ -1266,6 +1266,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 41e3c6e1..a045eccf 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 c3b6dc94..720eccac 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) } diff --git a/RemoteShutter.xcodeproj/project.pbxproj b/RemoteShutter.xcodeproj/project.pbxproj index c6f8c052..6f4f2092 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 */; }; @@ -312,6 +313,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 = ""; }; @@ -622,6 +624,7 @@ 0A11B22C33D44E55F6070003 /* ZoomPill.swift */, 06BB79BB2E3884F00094E085 /* CameraProgressOverlayView.swift */, 06BB79BE2E3884FA0094E085 /* CameraViewModel.swift */, + CAFEBABE0013000000000001 /* CameraPreviewMode.swift */, 06BB79C02E38852B0094E085 /* VideoTransferProgressView.swift */, 068DF59B2E3544AD00A49279 /* MonitorViewController+SwiftUI.swift */, 068DF59C2E3544AD00A49279 /* MonitorViewModel.swift */, @@ -1173,6 +1176,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 */, From 42a4917fff53daa781019cdff9e56bc8c9185ae4 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 04:48:47 -0700 Subject: [PATCH 03/27] Add the standby tile to the tray's value switch The merge put MonitorTrayItem.cameraStandby in front of an exhaustive switch that predated it. Standby is glyph-only -- its state reads off the filled/unfilled moon, not a value label -- so it joins the nil group. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 2fff084f..730bce7d 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -753,7 +753,8 @@ struct MonitorTrayPanel: View { case .resolution: return resolution.displayName case .frameRate: return frameRate.displayName case .format: return photoFormat.displayName - case .hdr, .settings, .help: return nil + // Glyph-only tiles: their state is carried by the symbol, not a label. + case .hdr, .cameraStandby, .settings, .help: return nil } } From 86836fb53b1bef25c39a6bd3369f45cd4b9dc759 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 20:35:34 -0700 Subject: [PATCH 04/27] Cover the preview in standby instead of unmounting it Standby swapped `liveContent` out of the view tree for the status screen. `liveContent` owns `CameraPreviewView`, whose backing layer IS the AVCaptureVideoPreviewLayer holding a reference to the running capture session -- so the swap dismantled the UIView and mutated a live capture graph as a side effect of a view change. Frame delivery to the monitor stopped outright. It came back only when CameraRig's first-frame watchdog fired 5s later, fell the camera over to another device, and bounced the session -- so recovery could also leave you on a different camera than the one you framed with. Keep `liveContent` mounted in every mode and draw the standby screen over it. The capture graph is never touched by a view change now. Reproduced on two real devices; the simulator suite stayed green throughout, including the loopback test that claimed to pin this. That test calls FrameSender.send directly, so it only ever covered the transport -- the producer chain (AVCaptureVideoDataOutput -> CaptureEngine -> FrameStreamingCoordinator) was bypassed entirely. Renamed it to say what it actually checks and documented the gap; producing frames needs real capture hardware, so that belongs in CaptureIntegrationTests. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/CameraScreenView.swift | 19 +++++++++++++------ RemoteCamTests/LoopbackSessionTests.swift | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index b7f0e4f7..487accd1 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -35,19 +35,26 @@ struct CameraScreenView: View { ZStack { Color.black.ignoresSafeArea() + // `liveContent` stays mounted in every mode. It owns + // `CameraPreviewView`, whose backing layer IS the + // AVCaptureVideoPreviewLayer holding a reference to the running + // capture session — so swapping it out of the tree dismantles the + // UIView and mutates a live capture graph as a side effect of a + // view change. That stopped frame delivery outright, and the only + // thing that recovered it was CameraRig's 5s first-frame watchdog + // bouncing the session (which can also land you on a different + // camera than the one you framed with). Standby covers the + // preview; it must never unmount it. + liveContent + if viewModel.previewMode == .standby { - // Standby: the capture session and the monitor frame stream keep - // running; only this local display is replaced with a minimal - // status screen to save battery/heat on a long tripod shoot. CameraStandbyView(viewModel: viewModel, onRestore: { onSetPreviewMode?(.on) }) - } else { - liveContent } } } - /// The full-screen live preview and its chrome — shown when preview is on. + /// The full-screen live preview and its chrome. private var liveContent: some View { ZStack { Color.black.ignoresSafeArea() diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 0b87fb18..243cea15 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -901,11 +901,19 @@ class LoopbackSessionTests: XCTestCase { "an ungated command could decode as a capture on an old peer") } - /// THE CRITICAL INVARIANT: standby stops the camera's LOCAL preview only — - /// the camera keeps streaming preview frames to the monitor. Here a real - /// FrameSender is wired to the camera's session; after the camera is put in - /// standby, a produced frame still crosses the wire to the monitor. - func testStandbyDoesNotStopFrameStreamingToMonitor() async { + /// 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 — read before trusting this. It calls `sender.send(...)` directly, + /// so it covers only the half of the path from FrameSender outward. It says + /// nothing about whether frames are still *produced*: + /// `AVCaptureVideoDataOutput` → `CaptureEngine` → `FrameStreamingCoordinator` + /// is bypassed entirely. An earlier version of standby unmounted + /// `CameraPreviewView` and killed delivery at the producer; this test passed + /// throughout. Producing frames needs real capture hardware — cover it in + /// `CaptureIntegrationTests`, not here. + func testStandbyDoesNotBlockTheFrameTransport() async { let fakeCamera = await connectCameraAndMonitor() // Wire a real FrameSender into the camera's session, pointed at the From c0c8ff1e420f87a6dcd04987f1178d5e0a9fdb9c Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 20:41:44 -0700 Subject: [PATCH 05/27] Stop four mode handlers from fighting over the nav bar The monitor showed two Back buttons: the nav bar's "Disconnect" and the viewfinder's floating chevron. `viewWillAppear` hid the bar, then the coordinator dropped the monitor into photo mode and `swiftUIConfigurePhotoMode` showed it again. Nav-bar visibility was set in four places keyed to capture mode -- photo, video and shorts showed it, recording hid it -- left from the design where the bar was part of the screen and only got out of the way during a take. The viewfinder is full-bleed in every mode now, so visibility is a property of the screen, not of the mode. Flipping those four booleans to `true` would have fixed the screenshot and kept the defect: four handlers with an opinion about one screen-level constant. They no longer touch it. `viewWillAppear` hides the bar once and `viewWillDisappear` hands it back, which is the whole policy. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorViewController+SwiftUI.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index f018f9e9..1a1480c3 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -312,29 +312,32 @@ extension MonitorViewController { // MARK: - SwiftUI Configuration Methods extension MonitorViewController { + // Nav-bar visibility is deliberately absent from every method below. It is a + // property of this screen, not of the capture mode: the viewfinder is + // full-bleed in all of them, so `viewWillAppear` hides the bar once and + // `viewWillDisappear` hands it back. These handlers used to each set it — + // three showed it, one hid it — which re-showed the bar right after + // `viewWillAppear` hid it and put a second Back button over the preview. + 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() } From 604b2dd96524e26b78ec1cb68bbbc9259d0c7dad Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 20:54:52 -0700 Subject: [PATCH 06/27] Keep the shutter on the same physical edge through rotation In landscape the action cluster docked to the trailing rail regardless of which way the device turned. Rotate clockwise and the home-indicator edge swings left while the cluster goes right, so the shutter crossed the device -- the one control reached for by muscle memory was the one that moved. Apple's Camera avoids this by locking to portrait and only rotating its glyphs. This screen can't: its frames come from another device, and a landscape frame in a portrait-locked window is the letterbox the redesign exists to remove. So keep the rotation and pin the cluster to the home-indicator edge instead -- dock() now takes the interface orientation and returns .leading or .trailing, since both landscapes are the same shape and size alone can't tell them apart. Zoom, mode and the control capsule still move; they're glance-and-tap, not muscle memory. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/CameraScreenView.swift | 13 ++----- RemoteCam/MonitorChrome.swift | 24 ++++++------- RemoteCam/MonitorView.swift | 18 ++++++---- RemoteCam/MonitorViewController+SwiftUI.swift | 8 ++--- RemoteCam/MonitorViewController.swift | 22 ++++++++++++ RemoteCam/MonitorViewModel.swift | 4 +++ RemoteCamTests/MonitorChromeTests.swift | 36 ++++++++++++------- .../MonitorScreenSnapshotTests.swift | 19 ++++++++-- 8 files changed, 94 insertions(+), 50 deletions(-) diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index 487accd1..f18e5c0d 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -35,16 +35,9 @@ struct CameraScreenView: View { ZStack { Color.black.ignoresSafeArea() - // `liveContent` stays mounted in every mode. It owns - // `CameraPreviewView`, whose backing layer IS the - // AVCaptureVideoPreviewLayer holding a reference to the running - // capture session — so swapping it out of the tree dismantles the - // UIView and mutates a live capture graph as a side effect of a - // view change. That stopped frame delivery outright, and the only - // thing that recovered it was CameraRig's 5s first-frame watchdog - // bouncing the session (which can also land you on a different - // camera than the one you framed with). Standby covers the - // preview; it must never unmount it. + // 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 { diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index aef67802..45010b18 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -7,28 +7,28 @@ import CoreGraphics import Foundation +import UIKit // MARK: - Chrome dock -/// Where the monitor's action cluster (gallery · shutter · camera switch) sits. +/// Which edge the action cluster (gallery · shutter · camera switch) sits on. enum MonitorChromeDock: Equatable { - /// Across the bottom — the screen is taller than it is wide. case bottom - /// Down the trailing edge — the screen is wider than it is tall, so the - /// bottom is the scarce axis and the side rails are the free space. + case leading case trailing } -/// The monitor screen's pure layout policy. Deliberately a function of the -/// view's own shape rather than of size class or platform: an iPhone in -/// landscape is *compact* width on every non-Max phone, so a size-class rule -/// would bottom-dock the exact case this screen exists to serve. Shape covers -/// iPhone rotation, iPad Split View, and a resized Mac window with one rule and -/// no `#if`. +/// 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) -> MonitorChromeDock { - viewSize.width > viewSize.height ? .trailing : .bottom + static func dock(viewSize: CGSize, + interfaceOrientation: UIInterfaceOrientation) -> MonitorChromeDock { + guard viewSize.width > viewSize.height else { return .bottom } + // Interface orientation is the inverse of device orientation. + return interfaceOrientation == .landscapeLeft ? .leading : .trailing } } diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 730bce7d..972d2d39 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -50,7 +50,9 @@ struct MonitorView: View { ZStack { previewLayer - chrome(dock: MonitorChromeLayout.dock(viewSize: geometry.size)) + chrome(dock: MonitorChromeLayout.dock( + viewSize: geometry.size, + interfaceOrientation: viewModel.interfaceOrientation)) if isTrayOpen { trayLayer @@ -221,8 +223,10 @@ struct MonitorView: View { switch dock { case .bottom: bottomCluster + case .leading: + sideCluster(onLeading: true) case .trailing: - trailingCluster + sideCluster(onLeading: false) } } .padding(.horizontal, 16) @@ -279,10 +283,12 @@ struct MonitorView: View { } } - /// Wide shapes: the bottom is the scarce axis, so the action cluster moves - /// to the trailing rail and only the zoom pill and mode selector stay low. - private var trailingCluster: some View { + /// 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 { actionCluster(axis: .vertical) } + VStack(spacing: 10) { Spacer(minLength: 0) activeCameraCaption @@ -294,7 +300,7 @@ struct MonitorView: View { } } - actionCluster(axis: .vertical) + if !onLeading { actionCluster(axis: .vertical) } } } diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index 1a1480c3..6564b877 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -312,12 +312,8 @@ extension MonitorViewController { // MARK: - SwiftUI Configuration Methods extension MonitorViewController { - // Nav-bar visibility is deliberately absent from every method below. It is a - // property of this screen, not of the capture mode: the viewfinder is - // full-bleed in all of them, so `viewWillAppear` hides the bar once and - // `viewWillDisappear` hands it back. These handlers used to each set it — - // three showed it, one hid it — which re-showed the bar right after - // `viewWillAppear` hid it and put a second Back button over the preview. + // 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() diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 16b4c947..0d056f44 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -94,6 +94,28 @@ public class MonitorViewController: UIViewController { // side suppresses its own chevron there. self.navigationController?.setNavigationBarHidden(true, animated: animated) navigationItem.title = nil + syncInterfaceOrientation() + } + + 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) { diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index c59be5ad..84c85335 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -83,6 +83,10 @@ class MonitorViewModel: ObservableObject { /// 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. diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift index 18a7cb65..0a8d4af3 100644 --- a/RemoteCamTests/MonitorChromeTests.swift +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -15,37 +15,47 @@ final class MonitorChromeTests: XCTestCase { // MARK: - Dock - /// iPhone portrait — the shape this screen has always had. + private func dock(_ size: CGSize, + _ orientation: UIInterfaceOrientation = .portrait) -> MonitorChromeDock { + MonitorChromeLayout.dock(viewSize: size, interfaceOrientation: orientation) + } + func testPortraitPhoneDocksBottom() { - XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 393, height: 852)), .bottom) + 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 case the redesign exists for: iPhone turned sideways to match a - /// landscape camera. A `horizontalSizeClass` rule would call this compact - /// and wrongly dock it at the bottom, crushing the preview. - func testLandscapePhoneDocksTrailing() { - XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 852, height: 393)), .trailing) + /// 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(MonitorChromeLayout.dock(viewSize: CGSize(width: 507, height: 1024)), .bottom) + XCTAssertEqual(dock(CGSize(width: 507, height: 1024), .landscapeRight), .bottom) } func testWideMacWindowDocksTrailing() { - XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 1440, height: 900)), .trailing) + XCTAssertEqual(dock(CGSize(width: 1440, height: 900)), .trailing) } /// A resized Mac window can be portrait-shaped; nothing about being a Mac - /// should force the trailing rail. + /// should force a rail. func testNarrowMacWindowDocksBottom() { - XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 600, height: 900)), .bottom) + XCTAssertEqual(dock(CGSize(width: 600, height: 900)), .bottom) } - /// Exactly square resolves to bottom rather than being undefined. func testSquareDocksBottom() { - XCTAssertEqual(MonitorChromeLayout.dock(viewSize: CGSize(width: 800, height: 800)), .bottom) + XCTAssertEqual(dock(CGSize(width: 800, height: 800)), .bottom) } // MARK: - Self-timer diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index 55f8c64a..5dea2129 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -133,19 +133,31 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { // MARK: - Landscape - /// The shape the redesign exists to serve: the remote turned sideways to - /// match a tripod-mounted landscape camera. The action cluster moves to the - /// trailing rail so the bottom stays free for the picture. + /// 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() @@ -154,6 +166,7 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { 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) From 89ccfdc7b6de702d546a07ff63579f9bcf691bc0 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:01:55 -0700 Subject: [PATCH 07/27] Put the rail on the edge, and no rail on a Mac Two problems in the same screenshot. The action cluster floated mid-window with a few hundred points of empty space beside it, and the shutter straddled the letterbox boundary. sideCluster is an HStack whose contents size to fit, so the whole row centered instead of the column hugging the edge. The zoom/mode side now takes the slack. And a rail is wrong on a Mac. It exists so a rotating device doesn't move the shutter out from under a thumb; a Mac window neither rotates nor is held. Catalyst also reports .landscapeRight permanently, so the rule railed it forever. dock() now takes the input kind and pointer-driven windows keep the conventional bottom bar. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorChrome.swift | 12 +++++++++++- RemoteCam/MonitorView.swift | 16 +++++++++++++++- RemoteCamTests/MonitorChromeTests.swift | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index 45010b18..8359b3af 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -18,6 +18,14 @@ enum MonitorChromeDock: Equatable { 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 @@ -25,7 +33,9 @@ enum MonitorChromeDock: Equatable { enum MonitorChromeLayout { static func dock(viewSize: CGSize, - interfaceOrientation: UIInterfaceOrientation) -> MonitorChromeDock { + 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 diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 972d2d39..cf930cdc 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -52,7 +52,8 @@ struct MonitorView: View { chrome(dock: MonitorChromeLayout.dock( viewSize: geometry.size, - interfaceOrientation: viewModel.interfaceOrientation)) + interfaceOrientation: viewModel.interfaceOrientation, + input: Self.chromeInput)) if isTrayOpen { trayLayer @@ -96,6 +97,16 @@ struct MonitorView: View { #endif } + /// 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 @@ -289,6 +300,8 @@ struct MonitorView: View { HStack(alignment: .bottom, spacing: 16) { if onLeading { actionCluster(axis: .vertical) } + // Takes the slack so the action cluster lands on the edge rather + // than floating in the middle of a wide window. VStack(spacing: 10) { Spacer(minLength: 0) activeCameraCaption @@ -299,6 +312,7 @@ struct MonitorView: View { modeSelector } } + .frame(maxWidth: .infinity) if !onLeading { actionCluster(axis: .vertical) } } diff --git a/RemoteCamTests/MonitorChromeTests.swift b/RemoteCamTests/MonitorChromeTests.swift index 0a8d4af3..355a462e 100644 --- a/RemoteCamTests/MonitorChromeTests.swift +++ b/RemoteCamTests/MonitorChromeTests.swift @@ -16,8 +16,19 @@ final class MonitorChromeTests: XCTestCase { // MARK: - Dock private func dock(_ size: CGSize, - _ orientation: UIInterfaceOrientation = .portrait) -> MonitorChromeDock { - MonitorChromeLayout.dock(viewSize: size, interfaceOrientation: orientation) + _ 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() { From cc60a5ad0ec76863653dbdf0eaafe4e5610f9919 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:06:49 -0700 Subject: [PATCH 08/27] Gather landscape chrome into one control zone Landscape had three groups in three places: the action rail hard against one edge, zoom and mode adrift near the bottom centre, and the active camera name floating over the middle of the picture. Zoom and mode now sit inboard of the rail, so the docked edge carries one control zone instead of scattering across the frame. The camera name moves up beside the link chip -- it is status, and it was sitting on the subject the user is trying to frame. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index cf930cdc..0074478d 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -267,6 +267,10 @@ struct MonitorView: View { isPreviewStale: viewModel.isPreviewStale)) .equatable() .padding(.leading, 8) + // Belongs with the other status, not floating over the middle + // of the picture the user is framing. + activeCameraCaption + .padding(.leading, 8) Spacer(minLength: 0) ControlCapsule(showsFlash: viewModel.uiState == .photoMode, isFlashEnabled: viewModel.isFlashEnabled, @@ -285,7 +289,6 @@ struct MonitorView: View { /// Portrait and other tall shapes: everything stacks across the bottom. private var bottomCluster: some View { VStack(spacing: 14) { - activeCameraCaption ZoomPill(scale: viewModel.zoomScale, currentZoomFactor: viewModel.currentZoomFactor, onZoomChange: onZoomChange) @@ -298,23 +301,21 @@ struct MonitorView: View { /// 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) } - // Takes the slack so the action cluster lands on the edge rather - // than floating in the middle of a wide window. + // Sits inboard of the rail, not adrift in the middle: one control + // zone on the docked edge instead of three scattered groups. VStack(spacing: 10) { Spacer(minLength: 0) - activeCameraCaption - HStack(spacing: 16) { - ZoomPill(scale: viewModel.zoomScale, - currentZoomFactor: viewModel.currentZoomFactor, - onZoomChange: onZoomChange) - modeSelector - } + ZoomPill(scale: viewModel.zoomScale, + currentZoomFactor: viewModel.currentZoomFactor, + onZoomChange: onZoomChange) + modeSelector } - .frame(maxWidth: .infinity) if !onLeading { actionCluster(axis: .vertical) } + if onLeading { Spacer(minLength: 0) } } } From 7f80f0e780889774f6982eb4142bcf097c1ffd3f Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:11:45 -0700 Subject: [PATCH 09/27] Give the Mac a way out, and drop Catalyst's button boxes The Mac had no Back at all. The floating chevron was suppressed on Catalyst on the assumption the window toolbar owned Back -- it does not, the window carries only the traffic lights and a title -- and the viewfinder hides the nav bar that used to carry Disconnect. Between the two, the screen was a dead end. The chevron now shows everywhere. Rectangles were drawn around the shutter, gallery and flip: every control here draws its own shape, but Catalyst's default button style paints a bordered box behind it. Set .buttonStyle(.plain) once at the chrome root rather than on each control. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 0074478d..b95f93e8 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -71,6 +71,11 @@ struct MonitorView: View { PeerLinkOverlay(status: peerLink) } } + // Every control here draws its own shape. Catalyst's default button + // style paints a bordered box behind them, which showed as rectangles + // around the shutter, gallery and flip. Set once at the root so no + // control has to remember. + .buttonStyle(.plain) .onPreferenceChange(PreviewSizePreferenceKey.self) { previewSize = $0 } .statusBarHidden() } @@ -86,16 +91,10 @@ struct MonitorView: View { #endif } - /// The Mac's window toolbar already owns Back; a floating chevron would - /// duplicate it. Every other platform hides the nav bar for the viewfinder - /// and needs its own way out. - private static var showsFloatingBackButton: Bool { - #if targetEnvironment(macCatalyst) - false - #else - true - #endif - } + /// 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. From 0abd2cf956c8b4d6f210ce04892ae827f0fcedc2 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:18:20 -0700 Subject: [PATCH 10/27] Restore the hit areas .buttonStyle(.plain) took away Removing Catalyst's bordered button chrome also removed the hit region that came with it: under .plain a button is only clickable where its label actually draws. These labels are a glyph over an .ultraThinMaterial circle, and material fills do not hit-test, so the targets collapsed to roughly the glyph strokes. Declare the region explicitly on every control the chrome owns -- shutter, gallery, flip, capsule glyphs, mode buttons and tray tiles -- so the whole 44pt (78pt for the shutter) is clickable again. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 7 +++++++ RemoteCam/WatchSessionManager.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index b95f93e8..17eb657b 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -403,6 +403,7 @@ struct MonitorView: View { .background( Capsule().fill(isActive ? Color.white.opacity(0.16) : Color.clear) ) + .contentShape(Capsule()) } } @@ -566,6 +567,7 @@ struct GlassCircleButton: View { .foregroundColor(tint) .frame(width: size, height: size) .background(Circle().fill(.ultraThinMaterial)) + .contentShape(Circle()) } .disabled(!isEnabled) } @@ -629,6 +631,7 @@ struct ControlCapsule: View, Equatable { .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) } @@ -685,6 +688,8 @@ struct ShutterButton: View, Equatable { ShutterActivityRing() } } + .frame(width: Self.diameter, height: Self.diameter) + .contentShape(Circle()) } .disabled(!isEnabled) .opacity(isEnabled ? 1 : 0.5) @@ -834,6 +839,7 @@ struct MonitorTrayTile: View { .tracking(0.5) .foregroundColor(isEnabled ? .white.opacity(0.6) : .white.opacity(0.3)) } + .contentShape(Rectangle()) } .disabled(!isEnabled) } @@ -1011,6 +1017,7 @@ struct CameraSwitchControlView: View, Equatable { .animation(.easeInOut(duration: 0.35), value: isSwitching) } .frame(width: 44, height: 44) + .contentShape(Circle()) } } diff --git a/RemoteCam/WatchSessionManager.swift b/RemoteCam/WatchSessionManager.swift index a77e11f0..872d4b10 100644 --- a/RemoteCam/WatchSessionManager.swift +++ b/RemoteCam/WatchSessionManager.swift @@ -10,7 +10,7 @@ import Foundation import UIKit #if !targetEnvironment(macCatalyst) -import WatchConnectivity +import WatchConnectivity #endif import FlatBuffers From 479a71fffd55ed680c9142bbfaef4a1ae0acb227 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:22:11 -0700 Subject: [PATCH 11/27] Fix the Back and camera-switch targets specifically Both wrap their icon in another control, so shaping the label was not enough -- the Button and the Menu still hit-test their own frames. Both now declare the region on the outside as well, and the device menu takes .borderlessButton so Catalyst stops applying its own menu chrome and hit region. Back also drew at 36pt, under the 44pt minimum, so it was a small target on top of the shaping problem. GlassCircleButton now never takes a hit area below 44pt regardless of what it draws. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 17eb657b..46d6c4e1 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -569,6 +569,11 @@ struct GlassCircleButton: View { .background(Circle().fill(.ultraThinMaterial)) .contentShape(Circle()) } + // Outside the Button too: the inner shape only covers the label, and the + // Button still hit-tests its own frame. Never smaller than 44pt — Back + // draws at 36 and was a correspondingly small target. + .frame(width: max(size, 44), height: max(size, 44)) + .contentShape(Circle()) .disabled(!isEnabled) } @@ -988,6 +993,8 @@ struct CameraSwitchControlView: View, Equatable { Button(action: onToggleCamera) { switchIcon } + .frame(width: 44, height: 44) + .contentShape(Circle()) .disabled(!isEnabled) case .deviceMenu: Menu { @@ -1001,6 +1008,9 @@ struct CameraSwitchControlView: View, Equatable { } label: { switchIcon } + .menuStyle(.borderlessButton) + .frame(width: 44, height: 44) + .contentShape(Circle()) .disabled(!isEnabled) } } From 4f568e893f6dba9d83185fb79fff7c0c4872a82e Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:27:04 -0700 Subject: [PATCH 12/27] Make the material-filled buttons hit-testable Gallery and camera-switch were the two controls whose entire visible surface is .ultraThinMaterial. A material fill does not hit-test, so under .buttonStyle(.plain) -- which drops the bordered style's own hit region -- the target collapsed to where the glyph draws. Everything that kept working fills with a colour instead: the shutter opaque white, mode buttons and tray tiles white at low opacity. .contentShape did not override it. Back appeared fixed in the previous commit only because max(size, 44) grew its frame from 36; the shaping did nothing there either, and it shares the same underlying problem. Put a colour fill under the material, at an opacity that cannot be seen but does take clicks. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 46d6c4e1..f0248411 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -566,7 +566,14 @@ struct GlassCircleButton: View { .font(.system(size: glyphSize, weight: .semibold)) .foregroundColor(tint) .frame(width: size, height: size) - .background(Circle().fill(.ultraThinMaterial)) + // A material fill does not hit-test; a colour fill does, even at + // an opacity you cannot see. Without this the target is only + // where the glyph draws. + .background( + Circle() + .fill(Color.white.opacity(0.001)) + .background(Circle().fill(.ultraThinMaterial)) + ) .contentShape(Circle()) } // Outside the Button too: the inner shape only covers the label, and the @@ -1017,8 +1024,10 @@ struct CameraSwitchControlView: View, Equatable { private var switchIcon: some View { ZStack { + // See GlassCircleButton: the material fill alone does not hit-test. Circle() - .fill(.ultraThinMaterial) + .fill(Color.white.opacity(0.001)) + .background(Circle().fill(.ultraThinMaterial)) .frame(width: 44, height: 44) Image(systemName: "arrow.triangle.2.circlepath.camera.fill") .font(.system(size: 19, weight: .semibold)) From 640428e55ca0d4341cf4c14b86ed511ea606d1c0 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:33:11 -0700 Subject: [PATCH 13/27] Use .borderless, not .plain, to drop Catalyst's button boxes .plain removes two things: the bordered background, which is what was wanted, and the style's hit region, which is what broke the gallery and camera-switch buttons on macOS. .borderless removes the box and keeps a normal hit region. The colour fills under the material stay -- they are harmless and make the targets robust regardless of style. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index f0248411..b8eee6d2 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -71,11 +71,11 @@ struct MonitorView: View { PeerLinkOverlay(status: peerLink) } } - // Every control here draws its own shape. Catalyst's default button - // style paints a bordered box behind them, which showed as rectangles - // around the shutter, gallery and flip. Set once at the root so no - // control has to remember. - .buttonStyle(.plain) + // Every control here draws its own shape; Catalyst's default style + // paints a bordered box behind them. .borderless removes that box. + // NOT .plain -- that also drops the style's hit region, which left the + // material-filled controls clickable only where their glyph draws. + .buttonStyle(.borderless) .onPreferenceChange(PreviewSizePreferenceKey.self) { previewSize = $0 } .statusBarHidden() } From 09baf527aa07e9d848543e4113c4363727e3c284 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:40:03 -0700 Subject: [PATCH 14/27] Raise invisible hit fills above UIKit's 0.01 alpha threshold UIKit's hitTest skips any view that is hidden, interaction-disabled, or at alpha <= 0.01. The invisible fills added to make the material-backed buttons clickable were 0.001 -- under it -- so they were exactly as dead as the material they were meant to cover. That is the difference between the shutter and the gallery/camera-switch buttons: the shutter's label is an opaque Circle().fill(Color.white) across its whole diameter. Mode buttons (0.16) and tray tiles (0.12) are also above the threshold and also work. Material and 0.001 were the only fills that failed. 0.02 is still imperceptible over the material and is hit-testable. Same bug in the tray's dismiss scrim, which was also 0.001: tapping outside an open tray could not close it. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index b8eee6d2..d8eb7d2d 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -418,8 +418,9 @@ struct MonitorView: View { 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. - Color.black.opacity(0.001) + // 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() } @@ -571,7 +572,7 @@ struct GlassCircleButton: View { // where the glyph draws. .background( Circle() - .fill(Color.white.opacity(0.001)) + .fill(Color.white.opacity(0.02)) .background(Circle().fill(.ultraThinMaterial)) ) .contentShape(Circle()) @@ -1026,7 +1027,7 @@ struct CameraSwitchControlView: View, Equatable { ZStack { // See GlassCircleButton: the material fill alone does not hit-test. Circle() - .fill(Color.white.opacity(0.001)) + .fill(Color.white.opacity(0.02)) .background(Circle().fill(.ultraThinMaterial)) .frame(width: 44, height: 44) Image(systemName: "arrow.triangle.2.circlepath.camera.fill") From 3261dd73b34d573d4c6a03ab44772d7ca5137ac5 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:41:48 -0700 Subject: [PATCH 15/27] Make the gallery and camera-switch fills fully opaque Diagnostic: replace the translucent material behind both controls with a solid colour, to settle whether alpha is what makes them unclickable on macOS while the shutter (opaque white) works. If this fixes them the cause is the fill and the opacity can be dialled back to the lightest value that still hit-tests. If it does not, the fill was never the cause. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index d8eb7d2d..4afd9eed 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -561,20 +561,17 @@ struct GlassCircleButton: View { let isEnabled: Bool let action: () -> Void + /// Opaque on purpose. Shared with the camera-switch glyph so the two match. + static let fill = Color(white: 0.18) + var body: some View { Button(action: action) { Image(systemName: systemImage) .font(.system(size: glyphSize, weight: .semibold)) .foregroundColor(tint) .frame(width: size, height: size) - // A material fill does not hit-test; a colour fill does, even at - // an opacity you cannot see. Without this the target is only - // where the glyph draws. - .background( - Circle() - .fill(Color.white.opacity(0.02)) - .background(Circle().fill(.ultraThinMaterial)) - ) + // Fully opaque, no material: a material fill does not hit-test. + .background(Circle().fill(Self.fill)) .contentShape(Circle()) } // Outside the Button too: the inner shape only covers the label, and the @@ -1025,10 +1022,9 @@ struct CameraSwitchControlView: View, Equatable { private var switchIcon: some View { ZStack { - // See GlassCircleButton: the material fill alone does not hit-test. + // Opaque, matching GlassCircleButton: material does not hit-test. Circle() - .fill(Color.white.opacity(0.02)) - .background(Circle().fill(.ultraThinMaterial)) + .fill(GlassCircleButton.fill) .frame(width: 44, height: 44) Image(systemName: "arrow.triangle.2.circlepath.camera.fill") .font(.system(size: 19, weight: .semibold)) From 6087846552eae4fc3794990cedb9215bca6b0b8f Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:44:55 -0700 Subject: [PATCH 16/27] Isolate the fill test to the two broken controls Back and the top-right capsule glyphs were never broken, and changing GlassCircleButton's fill outright changed Back too. Revert that: the material fill is the default again, and only the gallery button opts into the solid one via usesSolidFill. Also reverts the frame/contentShape/menuStyle I had added to the camera switch, so the fill is the single variable that differs between the two controls that do not work and the ones that do. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 4afd9eed..0180938c 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -324,6 +324,7 @@ struct MonitorView: View { size: 44, glyphSize: 20, isEnabled: viewModel.isGalleryEnabled, + usesSolidFill: true, action: onGalleryTapped) let shutter = ShutterButton(uiState: viewModel.uiState, isRecording: viewModel.isRecording, @@ -559,10 +560,13 @@ struct GlassCircleButton: View { let glyphSize: CGFloat var isActive: Bool = false let isEnabled: Bool + /// Opaque fill instead of the usual material. Only the gallery button sets + /// this — it is the one instance that does not respond to clicks on macOS, + /// and this isolates the fill as the single variable under test. + var usesSolidFill: Bool = false let action: () -> Void - /// Opaque on purpose. Shared with the camera-switch glyph so the two match. - static let fill = Color(white: 0.18) + static let solidFill = Color(white: 0.18) var body: some View { Button(action: action) { @@ -570,18 +574,15 @@ struct GlassCircleButton: View { .font(.system(size: glyphSize, weight: .semibold)) .foregroundColor(tint) .frame(width: size, height: size) - // Fully opaque, no material: a material fill does not hit-test. - .background(Circle().fill(Self.fill)) - .contentShape(Circle()) + .background(Circle().fill(background)) } - // Outside the Button too: the inner shape only covers the label, and the - // Button still hit-tests its own frame. Never smaller than 44pt — Back - // draws at 36 and was a correspondingly small target. - .frame(width: max(size, 44), height: max(size, 44)) - .contentShape(Circle()) .disabled(!isEnabled) } + private var background: AnyShapeStyle { + usesSolidFill ? AnyShapeStyle(Self.solidFill) : AnyShapeStyle(.ultraThinMaterial) + } + private var tint: Color { if !isEnabled { return .white.opacity(0.35) } return isActive ? AppTheme.accent : .white @@ -998,8 +999,6 @@ struct CameraSwitchControlView: View, Equatable { Button(action: onToggleCamera) { switchIcon } - .frame(width: 44, height: 44) - .contentShape(Circle()) .disabled(!isEnabled) case .deviceMenu: Menu { @@ -1013,18 +1012,16 @@ struct CameraSwitchControlView: View, Equatable { } label: { switchIcon } - .menuStyle(.borderlessButton) - .frame(width: 44, height: 44) - .contentShape(Circle()) .disabled(!isEnabled) } } private var switchIcon: some View { ZStack { - // Opaque, matching GlassCircleButton: material does not hit-test. + // Opaque, matching the gallery button: the fill is the variable + // under test. Circle() - .fill(GlassCircleButton.fill) + .fill(GlassCircleButton.solidFill) .frame(width: 44, height: 44) Image(systemName: "arrow.triangle.2.circlepath.camera.fill") .font(.system(size: 19, weight: .semibold)) From 340deba01dedafa5a73ccfd5873021aa59391728 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:47:00 -0700 Subject: [PATCH 17/27] Revert the fill changes; the premise was wrong The Back button was never a hit-testing fix. 7f80f0e turned it on for Catalyst -- it did not exist on the Mac before that -- and it worked immediately with .ultraThinMaterial and no shaping at all. So GlassCircleButton with a material fill works on macOS. Gallery is the same component with the same fill, which means the fill cannot be why it does not. contentShape, 44pt frames, alpha thresholds and opaque fills were all fixing a problem that was not there. Back to material everywhere. What remains is position: Back is in the top bar and works; gallery and the camera switch are the outer two items of actionCluster and do not, while the shutter between them does. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 0180938c..5614d0f6 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -324,7 +324,6 @@ struct MonitorView: View { size: 44, glyphSize: 20, isEnabled: viewModel.isGalleryEnabled, - usesSolidFill: true, action: onGalleryTapped) let shutter = ShutterButton(uiState: viewModel.uiState, isRecording: viewModel.isRecording, @@ -560,29 +559,19 @@ struct GlassCircleButton: View { let glyphSize: CGFloat var isActive: Bool = false let isEnabled: Bool - /// Opaque fill instead of the usual material. Only the gallery button sets - /// this — it is the one instance that does not respond to clicks on macOS, - /// and this isolates the fill as the single variable under test. - var usesSolidFill: Bool = false let action: () -> Void - static let solidFill = Color(white: 0.18) - 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(background)) + .background(Circle().fill(.ultraThinMaterial)) } .disabled(!isEnabled) } - private var background: AnyShapeStyle { - usesSolidFill ? AnyShapeStyle(Self.solidFill) : AnyShapeStyle(.ultraThinMaterial) - } - private var tint: Color { if !isEnabled { return .white.opacity(0.35) } return isActive ? AppTheme.accent : .white @@ -1018,10 +1007,8 @@ struct CameraSwitchControlView: View, Equatable { private var switchIcon: some View { ZStack { - // Opaque, matching the gallery button: the fill is the variable - // under test. Circle() - .fill(GlassCircleButton.solidFill) + .fill(.ultraThinMaterial) .frame(width: 44, height: 44) Image(systemName: "arrow.triangle.2.circlepath.camera.fill") .font(.system(size: 19, weight: .semibold)) From 4dbf4239e8be2442fd38058c6d9b1cfc52623dcd Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:48:07 -0700 Subject: [PATCH 18/27] TEMPORARY: swap shutter and gallery to isolate cause Component or position -- one build tells us which. position -> shutter breaks on the edge, gallery works in the centre component -> gallery stays broken in the centre, shutter still works Revert this commit either way once the answer is in. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 5614d0f6..4745a09a 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -341,17 +341,24 @@ struct MonitorView: View { onSelectCameraDevice: onSelectCameraDevice) .equatable() + // TEMPORARY DIAGNOSTIC — shutter and gallery are swapped. + // Back (top bar, material) works; gallery and switcher (outer items + // here, material) do not; shutter (centre, opaque) does. That leaves + // either the component or the position as the cause. + // position -> shutter breaks on the edge, gallery works in the centre + // component -> gallery stays broken in the centre, shutter still works + // Revert this block either way once we know. return Group { if axis == .horizontal { HStack(spacing: 40) { - gallery shutter + gallery switcher } } else { VStack(spacing: 24) { - gallery shutter + gallery switcher } } From 06862892f96976c6d245a8dec6cd6c8c99b8230f Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:53:33 -0700 Subject: [PATCH 19/27] Widen the action row so its outer buttons receive clicks Revert the diagnostic swap; it answered the question. Gallery worked as soon as it moved to the centre, and the shutter kept working when it moved out to the edge -- so the cause was never the button. The row is 242pt wide and centred (x from -121 to +121). The shutter was alive at -121..-47 while the gallery had been dead at -121..-77: same left edge, but the shutter reaches further inward. There is a centred live band of roughly +/-80pt, and a 44pt button outside it sits entirely in dead space while a 74pt one still pokes in. That is a hit-test bounds problem. A stack sizes to its widest child and SwiftUI happily draws children outside those bounds, but UIKit hit testing stops at them -- so the row rendered correctly and only partly responded. The camera switch was dead in both arrangements because it never left the far edge. Give the cluster and the bottom stack the full width so nothing sits outside its parent. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 4745a09a..8b7c2347 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -286,6 +286,10 @@ struct MonitorView: View { } /// Portrait and other tall shapes: everything stacks across the bottom. + /// + /// Full width on purpose. A stack sizes to its widest child, and children + /// wider than that draw outside its bounds but stop receiving clicks there + /// — which left a centred live band and killed the outer buttons. private var bottomCluster: some View { VStack(spacing: 14) { ZoomPill(scale: viewModel.zoomScale, @@ -294,6 +298,7 @@ struct MonitorView: View { actionCluster(axis: .horizontal) modeSelector } + .frame(maxWidth: .infinity) } /// Wide shapes: the action cluster rides the rail on the home-indicator side @@ -341,26 +346,21 @@ struct MonitorView: View { onSelectCameraDevice: onSelectCameraDevice) .equatable() - // TEMPORARY DIAGNOSTIC — shutter and gallery are swapped. - // Back (top bar, material) works; gallery and switcher (outer items - // here, material) do not; shutter (centre, opaque) does. That leaves - // either the component or the position as the cause. - // position -> shutter breaks on the edge, gallery works in the centre - // component -> gallery stays broken in the centre, shutter still works - // Revert this block either way once we know. return Group { if axis == .horizontal { HStack(spacing: 40) { - shutter gallery + shutter switcher } + .frame(maxWidth: .infinity) } else { VStack(spacing: 24) { - shutter gallery + shutter switcher } + .frame(maxHeight: .infinity) } } } From 461893c99c82807e4cc152457840c73de5539452 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 21:57:37 -0700 Subject: [PATCH 20/27] Size the back chevron like a nav bar back button 36pt with a 16pt glyph read as a small auxiliary control next to the system back button it replaces. 44pt with a 22pt chevron matches the nav bar's target size and glyph weight, and lines up with the gallery and camera-switch buttons, which are also 44. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 8b7c2347..84373eb8 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -256,9 +256,12 @@ struct MonitorView: View { 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: 36, - glyphSize: 16, + size: 44, + glyphSize: 22, isEnabled: viewModel.isBackEnabled, action: onBackTapped) } From 631a197985f95ca2dbf895298fb90eb58304182b Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:06:49 -0700 Subject: [PATCH 21/27] Keep the nav bar, transparent, to get swipe-back Hiding the bar cost the interactive pop gesture: UIKit disables it along with the bar, since it normally drives the bar's own back button. Rather than take over the recogniser's delegate to force it back on -- the usual workaround, and one that can wedge the navigation stack -- keep the bar present and configure it with a transparent background. The preview still runs edge to edge behind it, Back is the system button again (with its long-press history menu, and the "Disconnect" title the scanner sets), and the swipe is the real one because nothing was disabled. The floating chevron would now duplicate it, so it goes. The bar's previous appearance is saved on the way in and restored on the way out, so this screen's transparency does not leak to other screens. Step 1 of 2: the link chip, timecode and control capsule still live in the SwiftUI chrome and now sit below the bar. Moving them into left, title and right bar items is the follow-up. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 8 ++--- RemoteCam/MonitorViewController.swift | 42 +++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 84373eb8..e491ae68 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -91,10 +91,10 @@ struct MonitorView: View { #endif } - /// 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 + /// The nav bar stays present (transparent) so the swipe-back gesture + /// survives, which means Back is the system's own button and a floating + /// chevron would duplicate it. + private static let showsFloatingBackButton = false /// A Mac window neither rotates nor is held, so the side rail buys nothing /// there and the bottom bar is the convention. diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 0d056f44..aec3debd 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -86,17 +86,40 @@ public class MonitorViewController: UIViewController { return true } + /// The nav bar's appearance before this screen made it transparent. + private var savedBarAppearance: (standard: UINavigationBarAppearance, + scrollEdge: UINavigationBarAppearance?, + tint: UIColor?)? + override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(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) + // The bar stays *present* but is made fully transparent, so the preview + // still runs edge to edge behind it. Hiding it instead costs the + // interactive swipe-back gesture, which UIKit disables along with the + // bar; keeping it means Back and the swipe are both the system's own. + self.navigationController?.setNavigationBarHidden(false, animated: animated) + makeNavigationBarTransparent() navigationItem.title = nil syncInterfaceOrientation() } + private func makeNavigationBarTransparent() { + guard let bar = navigationController?.navigationBar else { return } + if savedBarAppearance == nil { + savedBarAppearance = (bar.standardAppearance, bar.scrollEdgeAppearance, bar.tintColor) + } + let transparent = UINavigationBarAppearance() + transparent.configureWithTransparentBackground() + transparent.backgroundColor = .clear + transparent.shadowColor = .clear + bar.standardAppearance = transparent + bar.scrollEdgeAppearance = transparent + bar.compactAppearance = transparent + // The viewfinder is dark in every state, so the back chevron and its + // title are always white rather than following the tint. + bar.tintColor = .white + } + override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -120,7 +143,14 @@ public class MonitorViewController: UIViewController { override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Hand the bar back to whatever screen comes next. + // Hand the bar back to whatever screen comes next exactly as we found it. + if let bar = navigationController?.navigationBar, let saved = savedBarAppearance { + bar.standardAppearance = saved.standard + bar.scrollEdgeAppearance = saved.scrollEdge + bar.compactAppearance = nil + bar.tintColor = saved.tint + savedBarAppearance = nil + } self.navigationController?.setNavigationBarHidden(false, animated: animated) } From bf717055cda7d3ef7f8ca89a4b72a367db57c6f8 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:09:58 -0700 Subject: [PATCH 22/27] Give the Mac its back button again Catalyst does not render the navigation bar, so making it transparent and dropping the floating chevron left macOS with no way off the screen -- the same dead end as before, reintroduced by assuming the bar exists everywhere. Split it, because the platforms genuinely differ. iOS keeps the transparent bar: it carries Back and, more importantly, the swipe. A Mac has no bar and no swipe gesture to protect, so the bar stays hidden there and MonitorView draws its own chevron. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 14 ++++++++++---- RemoteCam/MonitorViewController.swift | 9 ++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index e491ae68..81fd6209 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -91,10 +91,16 @@ struct MonitorView: View { #endif } - /// The nav bar stays present (transparent) so the swipe-back gesture - /// survives, which means Back is the system's own button and a floating - /// chevron would duplicate it. - private static let showsFloatingBackButton = false + /// On iOS the transparent nav bar carries Back, so a chevron here would + /// duplicate it. Catalyst does not render that bar at all — without this + /// the Mac has no way off the screen. + private static var showsFloatingBackButton: Bool { + #if targetEnvironment(macCatalyst) + true + #else + false + #endif + } /// A Mac window neither rotates nor is held, so the side rail buys nothing /// there and the bottom bar is the convention. diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index aec3debd..43b5aa6c 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -93,12 +93,19 @@ public class MonitorViewController: UIViewController { override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // The bar stays *present* but is made fully transparent, so the preview + // iOS: the bar stays *present* but fully transparent, so the preview // still runs edge to edge behind it. Hiding it instead costs the // interactive swipe-back gesture, which UIKit disables along with the // bar; keeping it means Back and the swipe are both the system's own. + // + // Catalyst does not render this bar, and a Mac has no swipe to protect, + // so there it stays hidden and MonitorView draws its own chevron. + #if targetEnvironment(macCatalyst) + self.navigationController?.setNavigationBarHidden(true, animated: animated) + #else self.navigationController?.setNavigationBarHidden(false, animated: animated) makeNavigationBarTransparent() + #endif navigationItem.title = nil syncInterfaceOrientation() } From 6501716b3249eb3dcd8a27e6214fb41decd10a50 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:20:15 -0700 Subject: [PATCH 23/27] Move the control capsule into the iOS navigation bar The bar already claims the top strip on iOS, and the flash/torch/tray capsule was floating just below it -- two rows of chrome doing one row's work. The capsule is now a right bar button item there, hosted from a retained UIHostingController; MonitorNavControls observes the view model directly, because a UIBarButtonItem cannot be re-rendered from outside. Catalyst renders no navigation bar, so it keeps the capsule inline in the chrome exactly as before. showsInlineControlCapsule is the single switch. isTrayOpen moves from MonitorView's private @State onto the view model: on iOS the button that opens the tray now lives outside that view. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 66 +++++++++++++++---- RemoteCam/MonitorViewController+SwiftUI.swift | 8 ++- RemoteCam/MonitorViewController.swift | 29 ++++++++ RemoteCam/MonitorViewModel.swift | 5 ++ 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 81fd6209..81e45339 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -43,7 +43,6 @@ struct MonitorView: View { @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 @@ -55,7 +54,7 @@ struct MonitorView: View { interfaceOrientation: viewModel.interfaceOrientation, input: Self.chromeInput)) - if isTrayOpen { + if viewModel.isTrayOpen { trayLayer } @@ -102,6 +101,16 @@ struct MonitorView: View { #endif } + /// Mac keeps the flash/torch/tray capsule in the chrome, because it has no + /// navigation bar to put it in. iOS hosts it as a right bar button item. + static var showsInlineControlCapsule: Bool { + #if targetEnvironment(macCatalyst) + true + #else + false + #endif + } + /// 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 { @@ -280,16 +289,20 @@ struct MonitorView: View { 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() + // On iOS this capsule is a right bar button item instead, so the + // top strip the nav bar already occupies isn't spent twice. + if Self.showsInlineControlCapsule { + ControlCapsule(showsFlash: viewModel.uiState == .photoMode, + isFlashEnabled: viewModel.isFlashEnabled, + isFlashButtonEnabled: viewModel.isFlashButtonEnabled, + isTorchEnabled: viewModel.isTorchEnabled, + isTorchButtonEnabled: viewModel.isTorchButtonEnabled, + isTrayOpen: viewModel.isTrayOpen, + onToggleFlash: onToggleFlash, + onToggleTorch: onToggleTorch, + onToggleTray: toggleTray) + .equatable() + } } } } @@ -427,7 +440,7 @@ struct MonitorView: View { private func toggleTray() { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - isTrayOpen.toggle() + viewModel.isTrayOpen.toggle() } } @@ -566,6 +579,33 @@ struct LinkChip: View, Equatable { } } +// MARK: - Navigation bar controls + +/// The flash · torch · tray capsule, hosted as a right bar button item on iOS. +/// +/// Observes the view model directly so the bar item refreshes itself; a +/// `UIBarButtonItem` has no way to be re-rendered from outside. +struct MonitorNavControls: View { + @ObservedObject var viewModel: MonitorViewModel + let onToggleFlash: () -> Void + let onToggleTorch: () -> Void + let onToggleTray: () -> Void + + var body: some View { + ControlCapsule(showsFlash: viewModel.uiState == .photoMode, + isFlashEnabled: viewModel.isFlashEnabled, + isFlashButtonEnabled: viewModel.isFlashButtonEnabled, + isTorchEnabled: viewModel.isTorchEnabled, + isTorchButtonEnabled: viewModel.isTorchButtonEnabled, + isTrayOpen: viewModel.isTrayOpen, + onToggleFlash: onToggleFlash, + onToggleTorch: onToggleTorch, + onToggleTray: onToggleTray) + .equatable() + .buttonStyle(.borderless) + } +} + // MARK: - Glass circle button /// A translucent round button — the monitor's standard auxiliary control. diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index 6564b877..f9f5624b 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -180,11 +180,13 @@ extension MonitorViewController { session ! UICmd.ToggleCamera() } - private func handleToggleFlash() { + // Internal rather than private: the nav-bar capsule is built in + // MonitorViewController.swift, and `private` is file-scoped. + func handleToggleFlash() { session ! UICmd.ToggleFlash() } - - private func handleToggleTorch() { + + func handleToggleTorch() { if StoreManager.shared.hasTorchFeature() { session ! UICmd.ToggleTorch() } else { diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 43b5aa6c..ba790ee2 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -105,11 +105,40 @@ public class MonitorViewController: UIViewController { #else self.navigationController?.setNavigationBarHidden(false, animated: animated) makeNavigationBarTransparent() + installNavigationBarControls() #endif navigationItem.title = nil syncInterfaceOrientation() } + /// Retained: a `UIBarButtonItem(customView:)` does not own its hosting + /// controller, and without a strong reference the SwiftUI view stops + /// updating as soon as it is released. + private var navControlsHost: UIHostingController? + + /// Puts the flash · torch · tray capsule in the bar rather than in the + /// chrome, so the strip the bar already occupies isn't spent twice. + private func installNavigationBarControls() { + guard navControlsHost == nil else { return } + let controls = MonitorNavControls( + viewModel: viewModel, + onToggleFlash: { [weak self] in self?.handleToggleFlash() }, + onToggleTorch: { [weak self] in self?.handleToggleTorch() }, + onToggleTray: { [weak self] in + guard let self else { return } + withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { + self.viewModel.isTrayOpen.toggle() + } + }) + let host = UIHostingController(rootView: controls) + host.view.backgroundColor = .clear + host.view.sizeToFit() + addChild(host) + host.didMove(toParent: self) + navControlsHost = host + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: host.view) + } + private func makeNavigationBarTransparent() { guard let bar = navigationController?.navigationBar else { return } if savedBarAppearance == nil { diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 84c85335..8ba51df9 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -87,6 +87,11 @@ class MonitorViewModel: ObservableObject { /// answer that — both landscapes are the same shape. @Published var interfaceOrientation: UIInterfaceOrientation = .portrait + /// Whether the capture tray is showing. On the view model rather than as + /// private view state because on iOS the button that opens it lives in the + /// navigation bar, outside `MonitorView`. + @Published var isTrayOpen: Bool = false + /// Which switch control the monitor shows for the peer's cameras. enum CameraSwitchControl { /// One camera — nothing to switch to. From 04559c7a563243d5f4bb1926361354a7d233d22d Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:22:34 -0700 Subject: [PATCH 24/27] Don't parent the bar item's hosting controller UIViewControllerHierarchyInconsistency on iOS at launch. UIBarButtonItem(customView:) puts the hosting controller's *view* inside the navigation bar, which is owned by the UINavigationController, while addChild made MonitorViewController its parent. UIKit checks that a child controller's view lives under its parent's and throws when it does not. Keep the strong reference -- that is what keeps the SwiftUI view alive and updating -- but do not add it as a child. Sizing goes through Auto Layout and SwiftUI's intrinsic size rather than a one-shot sizeToFit, since the capsule is narrower in video mode where the flash glyph drops out. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorViewController.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index ba790ee2..9ffc3464 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -111,9 +111,13 @@ public class MonitorViewController: UIViewController { syncInterfaceOrientation() } - /// Retained: a `UIBarButtonItem(customView:)` does not own its hosting - /// controller, and without a strong reference the SwiftUI view stops - /// updating as soon as it is released. + /// Retained, but deliberately NOT added as a child view controller. + /// `UIBarButtonItem(customView:)` puts the *view* inside the navigation + /// bar, whose owning controller is the UINavigationController — parenting + /// the hosting controller here as well makes UIKit see a child whose view + /// lives under a different controller, and it raises + /// UIViewControllerHierarchyInconsistency. The strong reference is what + /// keeps the SwiftUI view alive and updating. private var navControlsHost: UIHostingController? /// Puts the flash · torch · tray capsule in the bar rather than in the @@ -132,9 +136,9 @@ public class MonitorViewController: UIViewController { }) let host = UIHostingController(rootView: controls) host.view.backgroundColor = .clear - host.view.sizeToFit() - addChild(host) - host.didMove(toParent: self) + // Let Auto Layout ask SwiftUI for the size: the capsule is narrower in + // video mode, where the flash glyph drops out. + host.view.translatesAutoresizingMaskIntoConstraints = false navControlsHost = host navigationItem.rightBarButtonItem = UIBarButtonItem(customView: host.view) } From 67b41a616dad15864bb773c89e1a4470eb6d8a68 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:26:31 -0700 Subject: [PATCH 25/27] Make the iOS nav bar actually translucent, and drop the back title The bar went opaque because configureWithTransparentBackground was only applied to standard, scrollEdge and compact. compactScrollEdgeAppearance -- the one iPhone uses in landscape -- was left to fall back to the default opaque background. Set it too, clear backgroundEffect, and set isTranslucent plus the legacy background/shadow images so nothing paints a backdrop. The hosting controller's view is opaque by default, which put a solid rectangle behind the capsule regardless of the bar; it is now clear and non-opaque. Back shows the chevron alone via backButtonDisplayMode = .minimal, set on this screen's navigation item rather than by blanking the scanner's back item, which the camera screen also uses. The restore on the way out now undoes all of it, including the legacy overrides, so the next screen does not inherit a transparent bar. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorViewController.swift | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 9ffc3464..94f3632e 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -135,7 +135,10 @@ public class MonitorViewController: UIViewController { } }) let host = UIHostingController(rootView: controls) + // Both: a hosting controller's view is opaque by default, which would + // put a solid rectangle behind the capsule in the bar. host.view.backgroundColor = .clear + host.view.isOpaque = false // Let Auto Layout ask SwiftUI for the size: the capsule is narrower in // video mode, where the flash glyph drops out. host.view.translatesAutoresizingMaskIntoConstraints = false @@ -151,10 +154,23 @@ public class MonitorViewController: UIViewController { let transparent = UINavigationBarAppearance() transparent.configureWithTransparentBackground() transparent.backgroundColor = .clear + transparent.backgroundEffect = nil transparent.shadowColor = .clear bar.standardAppearance = transparent bar.scrollEdgeAppearance = transparent bar.compactAppearance = transparent + // The one used in landscape on iPhone. Left unset it falls back to the + // default opaque background, so the bar goes solid when you rotate. + if #available(iOS 15.0, *) { + bar.compactScrollEdgeAppearance = transparent + } + bar.isTranslucent = true + bar.setBackgroundImage(UIImage(), for: .default) + bar.shadowImage = UIImage() + + // Chevron only: the destination is a viewfinder, and "Disconnect" as a + // back title read as a button rather than as where you came from. + navigationItem.backButtonDisplayMode = .minimal // The viewfinder is dark in every state, so the back chevron and its // title are always white rather than following the tint. bar.tintColor = .white @@ -188,6 +204,14 @@ public class MonitorViewController: UIViewController { bar.standardAppearance = saved.standard bar.scrollEdgeAppearance = saved.scrollEdge bar.compactAppearance = nil + if #available(iOS 15.0, *) { + bar.compactScrollEdgeAppearance = nil + } + // Undo the legacy overrides too, or the next screen inherits a + // transparent bar. + bar.setBackgroundImage(nil, for: .default) + bar.shadowImage = nil + bar.isTranslucent = true bar.tintColor = saved.tint savedBarAppearance = nil } From 2c52bf686ad739aa0f698cef8cf2f72ac3a10995 Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:30:59 -0700 Subject: [PATCH 26/27] Revert the navigation bar experiment Back to the hidden nav bar with custom chrome on every platform, which is exactly the state at 461893c. The transparent-bar route was meant to buy the swipe-back gesture for free. It cost more than it bought: the bar would not stay translucent across appearance slots, Catalyst renders no bar at all so the platforms diverged, and putting the control capsule in a bar button item has no good answer -- parenting the hosting controller raises UIViewControllerHierarchyInconsistency, and leaving it unparented gives a view that draws opaque and does not take taps. Swipe-to-go-back is not currently available on this screen. If it is wanted later, the honest options are plain UIBarButtonItems (no SwiftUI hosting) or taking over interactivePopGestureRecognizer's delegate -- both understood, neither started here. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/MonitorView.swift | 80 +++----------- RemoteCam/MonitorViewController+SwiftUI.swift | 8 +- RemoteCam/MonitorViewController.swift | 104 +----------------- RemoteCam/MonitorViewModel.swift | 5 - 4 files changed, 25 insertions(+), 172 deletions(-) diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 81e45339..84373eb8 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -43,6 +43,7 @@ struct MonitorView: View { @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 @@ -54,7 +55,7 @@ struct MonitorView: View { interfaceOrientation: viewModel.interfaceOrientation, input: Self.chromeInput)) - if viewModel.isTrayOpen { + if isTrayOpen { trayLayer } @@ -90,26 +91,10 @@ struct MonitorView: View { #endif } - /// On iOS the transparent nav bar carries Back, so a chevron here would - /// duplicate it. Catalyst does not render that bar at all — without this - /// the Mac has no way off the screen. - private static var showsFloatingBackButton: Bool { - #if targetEnvironment(macCatalyst) - true - #else - false - #endif - } - - /// Mac keeps the flash/torch/tray capsule in the chrome, because it has no - /// navigation bar to put it in. iOS hosts it as a right bar button item. - static var showsInlineControlCapsule: Bool { - #if targetEnvironment(macCatalyst) - true - #else - false - #endif - } + /// 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. @@ -289,20 +274,16 @@ struct MonitorView: View { activeCameraCaption .padding(.leading, 8) Spacer(minLength: 0) - // On iOS this capsule is a right bar button item instead, so the - // top strip the nav bar already occupies isn't spent twice. - if Self.showsInlineControlCapsule { - ControlCapsule(showsFlash: viewModel.uiState == .photoMode, - isFlashEnabled: viewModel.isFlashEnabled, - isFlashButtonEnabled: viewModel.isFlashButtonEnabled, - isTorchEnabled: viewModel.isTorchEnabled, - isTorchButtonEnabled: viewModel.isTorchButtonEnabled, - isTrayOpen: viewModel.isTrayOpen, - onToggleFlash: onToggleFlash, - onToggleTorch: onToggleTorch, - onToggleTray: toggleTray) - .equatable() - } + 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() } } } @@ -440,7 +421,7 @@ struct MonitorView: View { private func toggleTray() { withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - viewModel.isTrayOpen.toggle() + isTrayOpen.toggle() } } @@ -579,33 +560,6 @@ struct LinkChip: View, Equatable { } } -// MARK: - Navigation bar controls - -/// The flash · torch · tray capsule, hosted as a right bar button item on iOS. -/// -/// Observes the view model directly so the bar item refreshes itself; a -/// `UIBarButtonItem` has no way to be re-rendered from outside. -struct MonitorNavControls: View { - @ObservedObject var viewModel: MonitorViewModel - let onToggleFlash: () -> Void - let onToggleTorch: () -> Void - let onToggleTray: () -> Void - - var body: some View { - ControlCapsule(showsFlash: viewModel.uiState == .photoMode, - isFlashEnabled: viewModel.isFlashEnabled, - isFlashButtonEnabled: viewModel.isFlashButtonEnabled, - isTorchEnabled: viewModel.isTorchEnabled, - isTorchButtonEnabled: viewModel.isTorchButtonEnabled, - isTrayOpen: viewModel.isTrayOpen, - onToggleFlash: onToggleFlash, - onToggleTorch: onToggleTorch, - onToggleTray: onToggleTray) - .equatable() - .buttonStyle(.borderless) - } -} - // MARK: - Glass circle button /// A translucent round button — the monitor's standard auxiliary control. diff --git a/RemoteCam/MonitorViewController+SwiftUI.swift b/RemoteCam/MonitorViewController+SwiftUI.swift index f9f5624b..6564b877 100644 --- a/RemoteCam/MonitorViewController+SwiftUI.swift +++ b/RemoteCam/MonitorViewController+SwiftUI.swift @@ -180,13 +180,11 @@ extension MonitorViewController { session ! UICmd.ToggleCamera() } - // Internal rather than private: the nav-bar capsule is built in - // MonitorViewController.swift, and `private` is file-scoped. - func handleToggleFlash() { + private func handleToggleFlash() { session ! UICmd.ToggleFlash() } - - func handleToggleTorch() { + + private func handleToggleTorch() { if StoreManager.shared.hasTorchFeature() { session ! UICmd.ToggleTorch() } else { diff --git a/RemoteCam/MonitorViewController.swift b/RemoteCam/MonitorViewController.swift index 94f3632e..0d056f44 100644 --- a/RemoteCam/MonitorViewController.swift +++ b/RemoteCam/MonitorViewController.swift @@ -86,96 +86,17 @@ public class MonitorViewController: UIViewController { return true } - /// The nav bar's appearance before this screen made it transparent. - private var savedBarAppearance: (standard: UINavigationBarAppearance, - scrollEdge: UINavigationBarAppearance?, - tint: UIColor?)? - override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - // iOS: the bar stays *present* but fully transparent, so the preview - // still runs edge to edge behind it. Hiding it instead costs the - // interactive swipe-back gesture, which UIKit disables along with the - // bar; keeping it means Back and the swipe are both the system's own. - // - // Catalyst does not render this bar, and a Mac has no swipe to protect, - // so there it stays hidden and MonitorView draws its own chevron. - #if targetEnvironment(macCatalyst) + // 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) - #else - self.navigationController?.setNavigationBarHidden(false, animated: animated) - makeNavigationBarTransparent() - installNavigationBarControls() - #endif navigationItem.title = nil syncInterfaceOrientation() } - /// Retained, but deliberately NOT added as a child view controller. - /// `UIBarButtonItem(customView:)` puts the *view* inside the navigation - /// bar, whose owning controller is the UINavigationController — parenting - /// the hosting controller here as well makes UIKit see a child whose view - /// lives under a different controller, and it raises - /// UIViewControllerHierarchyInconsistency. The strong reference is what - /// keeps the SwiftUI view alive and updating. - private var navControlsHost: UIHostingController? - - /// Puts the flash · torch · tray capsule in the bar rather than in the - /// chrome, so the strip the bar already occupies isn't spent twice. - private func installNavigationBarControls() { - guard navControlsHost == nil else { return } - let controls = MonitorNavControls( - viewModel: viewModel, - onToggleFlash: { [weak self] in self?.handleToggleFlash() }, - onToggleTorch: { [weak self] in self?.handleToggleTorch() }, - onToggleTray: { [weak self] in - guard let self else { return } - withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) { - self.viewModel.isTrayOpen.toggle() - } - }) - let host = UIHostingController(rootView: controls) - // Both: a hosting controller's view is opaque by default, which would - // put a solid rectangle behind the capsule in the bar. - host.view.backgroundColor = .clear - host.view.isOpaque = false - // Let Auto Layout ask SwiftUI for the size: the capsule is narrower in - // video mode, where the flash glyph drops out. - host.view.translatesAutoresizingMaskIntoConstraints = false - navControlsHost = host - navigationItem.rightBarButtonItem = UIBarButtonItem(customView: host.view) - } - - private func makeNavigationBarTransparent() { - guard let bar = navigationController?.navigationBar else { return } - if savedBarAppearance == nil { - savedBarAppearance = (bar.standardAppearance, bar.scrollEdgeAppearance, bar.tintColor) - } - let transparent = UINavigationBarAppearance() - transparent.configureWithTransparentBackground() - transparent.backgroundColor = .clear - transparent.backgroundEffect = nil - transparent.shadowColor = .clear - bar.standardAppearance = transparent - bar.scrollEdgeAppearance = transparent - bar.compactAppearance = transparent - // The one used in landscape on iPhone. Left unset it falls back to the - // default opaque background, so the bar goes solid when you rotate. - if #available(iOS 15.0, *) { - bar.compactScrollEdgeAppearance = transparent - } - bar.isTranslucent = true - bar.setBackgroundImage(UIImage(), for: .default) - bar.shadowImage = UIImage() - - // Chevron only: the destination is a viewfinder, and "Disconnect" as a - // back title read as a button rather than as where you came from. - navigationItem.backButtonDisplayMode = .minimal - // The viewfinder is dark in every state, so the back chevron and its - // title are always white rather than following the tint. - bar.tintColor = .white - } - override public func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) @@ -199,22 +120,7 @@ public class MonitorViewController: UIViewController { override public func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - // Hand the bar back to whatever screen comes next exactly as we found it. - if let bar = navigationController?.navigationBar, let saved = savedBarAppearance { - bar.standardAppearance = saved.standard - bar.scrollEdgeAppearance = saved.scrollEdge - bar.compactAppearance = nil - if #available(iOS 15.0, *) { - bar.compactScrollEdgeAppearance = nil - } - // Undo the legacy overrides too, or the next screen inherits a - // transparent bar. - bar.setBackgroundImage(nil, for: .default) - bar.shadowImage = nil - bar.isTranslucent = true - bar.tintColor = saved.tint - savedBarAppearance = nil - } + // Hand the bar back to whatever screen comes next. self.navigationController?.setNavigationBarHidden(false, animated: animated) } diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 8ba51df9..84c85335 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -87,11 +87,6 @@ class MonitorViewModel: ObservableObject { /// answer that — both landscapes are the same shape. @Published var interfaceOrientation: UIInterfaceOrientation = .portrait - /// Whether the capture tray is showing. On the view model rather than as - /// private view state because on iOS the button that opens it lives in the - /// navigation bar, outside `MonitorView`. - @Published var isTrayOpen: Bool = false - /// Which switch control the monitor shows for the peer's cameras. enum CameraSwitchControl { /// One camera — nothing to switch to. From fd0f0699e4563f62151ea217480ded1e7315d7bf Mon Sep 17 00:00:00 2001 From: Dario Lencina Date: Sat, 1 Aug 2026 22:41:35 -0700 Subject: [PATCH 27/27] Tighten comments and drop dead state Comments now describe the code as it is: removed six that narrated what it used to do (the timer's old slider, the modal spinners, the earlier standby behaviour), and cut the MonitorChrome doc blocks to the rule they state rather than the argument for it. Deletes MonitorViewModel.areControlsExpanded. Its only readers were the collapsible controls panel and its chevron, which the redesign replaced; it was persisting a preference nothing consults. The UserDefaults key stays behind on existing installs as an orphan. MonitorTray.items no longer defaults supportsCameraStandby. A default in the middle of the parameter list lets a caller omit it and silently lose the tile; all three call sites now say what they mean. Also restores a mangled import in WatchSessionManager that had crept in. Co-Authored-By: Claude Opus 5 (1M context) --- RemoteCam/CameraScreenView.swift | 2 +- RemoteCam/MonitorChrome.swift | 62 +++++++------------ RemoteCam/MonitorView.swift | 41 +++++------- RemoteCam/MonitorViewModel.swift | 7 --- RemoteCam/SessionCoordinator.swift | 7 +-- RemoteCam/WatchSessionManager.swift | 2 +- RemoteCamTests/LoopbackSessionTests.swift | 11 ++-- .../MonitorScreenSnapshotTests.swift | 2 + 8 files changed, 49 insertions(+), 85 deletions(-) diff --git a/RemoteCam/CameraScreenView.swift b/RemoteCam/CameraScreenView.swift index f18e5c0d..417b84cd 100644 --- a/RemoteCam/CameraScreenView.swift +++ b/RemoteCam/CameraScreenView.swift @@ -37,7 +37,7 @@ struct CameraScreenView: View { // 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. + // stops frame delivery — standby covers the preview, never unmounts it. liveContent if viewModel.previewMode == .standby { diff --git a/RemoteCam/MonitorChrome.swift b/RemoteCam/MonitorChrome.swift index 8359b3af..cecb5c91 100644 --- a/RemoteCam/MonitorChrome.swift +++ b/RemoteCam/MonitorChrome.swift @@ -44,20 +44,15 @@ enum MonitorChromeLayout { // MARK: - Self-timer -/// The self-timer's detented values. Replaces the 0...20 continuous slider: a -/// remote's timer is picked from a handful of useful delays, and a tap-to-cycle -/// glyph costs a fraction of the screen a labelled slider did. +/// The self-timer's detented values. enum MonitorTimer { - /// Ascending, starting at "off". Mirrors the delays a camera app offers. + /// 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. - /// - /// Values that are not themselves stops are rounded *up* to the next one, - /// so a delay restored from an older build's slider (which stored any - /// integer 0...20 under `timerDefault`) lands on a real stop instead of - /// being stranded. + /// 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] } @@ -65,11 +60,9 @@ enum MonitorTimer { // MARK: - Tray -/// One tile in the capture tray — the controls that leave the viewfinder. -/// -/// Each tile's glyph carries its own current value (the timer shows "5", aspect -/// shows "16:9"), which is what lets them live behind a tap instead of -/// occupying a permanent row. +/// 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 @@ -77,9 +70,8 @@ enum MonitorTrayItem: Equatable { case frameRate case format case hdr - /// Puts the peer camera's *local* preview to sleep. The camera keeps - /// capturing and keeps streaming here — this only stops it compositing a - /// preview nobody is looking at while it sits on a tripod. + /// Puts the peer camera's *local* preview to sleep. It keeps capturing and + /// keeps streaming here. case cameraStandby case settings case help @@ -87,16 +79,13 @@ enum MonitorTrayItem: Equatable { enum MonitorTray { - /// The tiles for a given mode and set of peer capabilities. - /// - /// Capability-driven tiles are omitted rather than disabled: a camera that - /// cannot do HDR should not show an HDR tile at all. Tiles that exist but - /// are momentarily unavailable (quality during a recording) stay in the - /// list and are dimmed by the view — that is enablement, not composition. + /// 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 = false, + supportsCameraStandby: Bool, resolutionCount: Int, frameRateCount: Int) -> [MonitorTrayItem] { var items: [MonitorTrayItem] = [] @@ -118,8 +107,6 @@ enum MonitorTray { break } - // Capability-gated like the rest: a camera that predates the feature - // would silently ignore the command, so it must not be offered one. if supportsCameraStandby { items.append(.cameraStandby) } items.append(.settings) @@ -130,12 +117,9 @@ enum MonitorTray { // MARK: - Link health -/// What the monitor can say about the picture it is showing. -/// -/// Apple's Camera has no equivalent — its sensor is in your hand, so a frozen -/// preview is impossible. Here the stream can go quiet while the session still -/// believes it is connected, and the old behaviour was silence: the image -/// simply stopped updating with nothing on screen to say so. +/// 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 @@ -159,17 +143,15 @@ enum MonitorLinkState: Equatable { /// What the monitor is waiting on the camera for, right now. /// -/// Derived from the session state at the single `transition(to:)` choke point -/// rather than pushed from each command site, for the reason `PeerLinkStatus` -/// documents about the reconnect overlay: when the indicator is a function of -/// the state, it cannot outlive the thing it describes. The show/dismiss pairs -/// that a pushed indicator needs are exactly what used to leave a modal spinner -/// over the preview. +/// 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 camera took the shot and the picture is on its way. A distinct state - /// because it is the moment the subject can stop holding the pose. + /// The shot is taken and on its way — the moment the subject can stop + /// holding the pose. case receivingCapture case switchingCamera case togglingFlash diff --git a/RemoteCam/MonitorView.swift b/RemoteCam/MonitorView.swift index 84373eb8..2a464b63 100644 --- a/RemoteCam/MonitorView.swift +++ b/RemoteCam/MonitorView.swift @@ -71,10 +71,9 @@ struct MonitorView: View { PeerLinkOverlay(status: peerLink) } } - // Every control here draws its own shape; Catalyst's default style - // paints a bordered box behind them. .borderless removes that box. - // NOT .plain -- that also drops the style's hit region, which left the - // material-filled controls clickable only where their glyph draws. + // 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() @@ -269,8 +268,7 @@ struct MonitorView: View { isPreviewStale: viewModel.isPreviewStale)) .equatable() .padding(.leading, 8) - // Belongs with the other status, not floating over the middle - // of the picture the user is framing. + // Status, not an overlay on the picture being framed. activeCameraCaption .padding(.leading, 8) Spacer(minLength: 0) @@ -289,10 +287,8 @@ struct MonitorView: View { } /// Portrait and other tall shapes: everything stacks across the bottom. - /// - /// Full width on purpose. A stack sizes to its widest child, and children - /// wider than that draw outside its bounds but stop receiving clicks there - /// — which left a centred live band and killed the outer buttons. + /// 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, @@ -311,8 +307,7 @@ struct MonitorView: View { if !onLeading { Spacer(minLength: 0) } if onLeading { actionCluster(axis: .vertical) } - // Sits inboard of the rail, not adrift in the middle: one control - // zone on the docked edge instead of three scattered groups. + // Inboard of the rail: one control zone on the docked edge. VStack(spacing: 10) { Spacer(minLength: 0) ZoomPill(scale: viewModel.zoomScale, @@ -490,8 +485,8 @@ struct MonitorView: View { viewModel.currentHDRMode == .on ? .off : .on) case .cameraStandby: - // Stays open: the tile's glyph is the reflection of what the camera - // is doing, so you want to watch it settle rather than lose it. + // Stays open: the glyph reflects the camera's confirmed mode, so + // it is worth watching settle. onToggleCameraStandby() case .settings: @@ -650,10 +645,8 @@ struct ControlCapsule: View, Equatable { // MARK: - Shutter /// The capture button, including what the camera is currently doing about it. -/// -/// The in-flight ring replaces the modal spinner that used to cover the preview -/// on every command: the feedback belongs on the control you pressed, not over -/// the picture you are framing. +/// 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 @@ -741,9 +734,8 @@ struct MonitorTrayPanel: View { let frameRate: VideoFrameRate let photoFormat: PhotoFormat let hdrMode: HDRMode - /// The camera's reported local-preview mode. The standby tile is a - /// reflection of the peer's actual state, not of a local intent, so it - /// only lights up once the camera has confirmed. + /// 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 @@ -788,7 +780,7 @@ struct MonitorTrayPanel: View { case .resolution: return resolution.displayName case .frameRate: return frameRate.displayName case .format: return photoFormat.displayName - // Glyph-only tiles: their state is carried by the symbol, not a label. + // Glyph-only: state is carried by the symbol. case .hdr, .cameraStandby, .settings, .help: return nil } } @@ -806,8 +798,7 @@ struct MonitorTrayPanel: View { switch item { case .timer: return isTimerEnabled case .aspect, .resolution, .frameRate, .format, .hdr: return isQualityEnabled - // Not a capture setting — it stays usable mid-recording, when quality - // controls are locked. + // Not a capture setting: usable mid-recording. case .cameraStandby: return true case .settings: return isSettingsEnabled case .help: return true @@ -977,7 +968,7 @@ struct CameraSwitchControlView: View, Equatable { let devices: [RemoteCmd.CameraDeviceEntry] let activeDeviceID: String? let isEnabled: Bool - /// A switch is in flight; the glyph says so instead of a modal. + /// A switch is in flight; the glyph says so. var isSwitching: Bool = false let onToggleCamera: () -> Void let onSelectCameraDevice: (String) -> Void diff --git a/RemoteCam/MonitorViewModel.swift b/RemoteCam/MonitorViewModel.swift index 84c85335..d733105d 100644 --- a/RemoteCam/MonitorViewModel.swift +++ b/RemoteCam/MonitorViewModel.swift @@ -140,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() { diff --git a/RemoteCam/SessionCoordinator.swift b/RemoteCam/SessionCoordinator.swift index 6519606d..58e8ff0f 100644 --- a/RemoteCam/SessionCoordinator.swift +++ b/RemoteCam/SessionCoordinator.swift @@ -1264,10 +1264,9 @@ public actor SessionCoordinator { // MARK: - Progress alerts (camera "Taking picture" only) // - // The monitor no longer raises these. Its in-flight feedback is - // `MonitorActivity`, derived from the state at `transition(to:)` and drawn - // on the control the user pressed — a modal here covered the live preview - // at exactly the moment the user was framing with it. + // 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? diff --git a/RemoteCam/WatchSessionManager.swift b/RemoteCam/WatchSessionManager.swift index 872d4b10..a77e11f0 100644 --- a/RemoteCam/WatchSessionManager.swift +++ b/RemoteCam/WatchSessionManager.swift @@ -10,7 +10,7 @@ import Foundation import UIKit #if !targetEnvironment(macCatalyst) -import WatchConnectivity +import WatchConnectivity #endif import FlatBuffers diff --git a/RemoteCamTests/LoopbackSessionTests.swift b/RemoteCamTests/LoopbackSessionTests.swift index 243cea15..87b176c7 100644 --- a/RemoteCamTests/LoopbackSessionTests.swift +++ b/RemoteCamTests/LoopbackSessionTests.swift @@ -905,14 +905,11 @@ class LoopbackSessionTests: XCTestCase { /// frame handed to a real FrameSender still crosses the wire and leaves the /// monitor in `.monitor`. /// - /// SCOPE — read before trusting this. It calls `sender.send(...)` directly, - /// so it covers only the half of the path from FrameSender outward. It says - /// nothing about whether frames are still *produced*: + /// 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 entirely. An earlier version of standby unmounted - /// `CameraPreviewView` and killed delivery at the producer; this test passed - /// throughout. Producing frames needs real capture hardware — cover it in - /// `CaptureIntegrationTests`, not here. + /// is bypassed. That needs real capture hardware; cover it in + /// `CaptureIntegrationTests`. func testStandbyDoesNotBlockTheFrameTransport() async { let fakeCamera = await connectCameraAndMonitor() diff --git a/RemoteCamTests/MonitorScreenSnapshotTests.swift b/RemoteCamTests/MonitorScreenSnapshotTests.swift index 5dea2129..20580a0b 100644 --- a/RemoteCamTests/MonitorScreenSnapshotTests.swift +++ b/RemoteCamTests/MonitorScreenSnapshotTests.swift @@ -233,6 +233,7 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { 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") @@ -246,6 +247,7 @@ final class MonitorScreenSnapshotTests: SnapshotTestCase { 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")