Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fc968e2
Redesign the monitor screen around the viewfinder
darioalessandro Aug 1, 2026
e671adc
Add camera preview standby mode
darioalessandro Aug 1, 2026
7123ea4
Merge branch 'camera-standby-mode' into redesign-remote
darioalessandro Aug 1, 2026
42a4917
Add the standby tile to the tray's value switch
darioalessandro Aug 1, 2026
86836fb
Cover the preview in standby instead of unmounting it
darioalessandro Aug 2, 2026
c0c8ff1
Stop four mode handlers from fighting over the nav bar
darioalessandro Aug 2, 2026
604b2dd
Keep the shutter on the same physical edge through rotation
darioalessandro Aug 2, 2026
89ccfdc
Put the rail on the edge, and no rail on a Mac
darioalessandro Aug 2, 2026
cc60a5a
Gather landscape chrome into one control zone
darioalessandro Aug 2, 2026
7f80f0e
Give the Mac a way out, and drop Catalyst's button boxes
darioalessandro Aug 2, 2026
0abd2cf
Restore the hit areas .buttonStyle(.plain) took away
darioalessandro Aug 2, 2026
479a71f
Fix the Back and camera-switch targets specifically
darioalessandro Aug 2, 2026
4f568e8
Make the material-filled buttons hit-testable
darioalessandro Aug 2, 2026
640428e
Use .borderless, not .plain, to drop Catalyst's button boxes
darioalessandro Aug 2, 2026
09baf52
Raise invisible hit fills above UIKit's 0.01 alpha threshold
darioalessandro Aug 2, 2026
3261dd7
Make the gallery and camera-switch fills fully opaque
darioalessandro Aug 2, 2026
6087846
Isolate the fill test to the two broken controls
darioalessandro Aug 2, 2026
340deba
Revert the fill changes; the premise was wrong
darioalessandro Aug 2, 2026
4dbf423
TEMPORARY: swap shutter and gallery to isolate cause
darioalessandro Aug 2, 2026
0686289
Widen the action row so its outer buttons receive clicks
darioalessandro Aug 2, 2026
461893c
Size the back chevron like a nav bar back button
darioalessandro Aug 2, 2026
631a197
Keep the nav bar, transparent, to get swipe-back
darioalessandro Aug 2, 2026
bf71705
Give the Mac its back button again
darioalessandro Aug 2, 2026
6501716
Move the control capsule into the iOS navigation bar
darioalessandro Aug 2, 2026
04559c7
Don't parent the bar item's hosting controller
darioalessandro Aug 2, 2026
67b41a6
Make the iOS nav bar actually translucent, and drop the back title
darioalessandro Aug 2, 2026
2c52bf6
Revert the navigation bar experiment
darioalessandro Aug 2, 2026
fd0f069
Tighten comments and drop dead state
darioalessandro Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RemoteCam/CameraControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions RemoteCam/CameraHostController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ final class CameraHostController: UIHostingController<CameraScreenView> {
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)
}))
}

Expand Down
62 changes: 62 additions & 0 deletions RemoteCam/CameraPreviewMode.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 21 additions & 0 deletions RemoteCam/CameraRig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions RemoteCam/CameraScreenView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,6 +32,23 @@ struct CameraScreenView: View {
@ObservedObject var peerLink: PeerLinkStatus = .shared

var body: some View {
ZStack {
Color.black.ignoresSafeArea()

// Always mounted: it owns CameraPreviewView, whose backing layer is
// the AVCaptureVideoPreviewLayer on the live session. Unmounting it
// stops frame delivery — standby covers the preview, never unmounts it.
liveContent

if viewModel.previewMode == .standby {
CameraStandbyView(viewModel: viewModel,
onRestore: { onSetPreviewMode?(.on) })
}
}
}

/// The full-screen live preview and its chrome.
private var liveContent: some View {
ZStack {
Color.black.ignoresSafeArea()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions RemoteCam/CameraViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions RemoteCam/CaptureEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand Down
23 changes: 22 additions & 1 deletion RemoteCam/FlatBufferSchemas.fbs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading
Loading