Skip to content
Open
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1097,6 +1097,7 @@ Defines the configuration options for starting the camera preview.
| **`positioning`** | <code><a href="#camerapositioning">CameraPositioning</a></code> | The vertical positioning of the camera preview. | <code>"center"</code> | 2.3.0 |
| **`enableVideoMode`** | <code>boolean</code> | If true, enables video capture capabilities when the camera starts. | <code>false</code> | 7.11.0 |
| **`force`** | <code>boolean</code> | If true, forces the camera to start/restart even if it's already running or busy. This will kill the current camera session and start a new one, ignoring all state checks. | <code>false</code> | |
| **`videoQuality`** | <code><a href="#videoquality">VideoQuality</a></code> | Sets the quality of video for recording. On iOS will lower quality of photos. Recommend increasing quality in capture() for iOS. | <code>"high"</code> | |


#### ExifData
Expand Down Expand Up @@ -1246,6 +1247,8 @@ iOS: Values are expressed in physical pixels and exclude status bar.

<code>'none' | '3x3' | '4x4'</code>

#### VideoQuality
<code>'low' \| 'medium' \| 'high'</code>

#### CameraPosition

Expand Down Expand Up @@ -1279,7 +1282,9 @@ The available flash modes for the camera.

From T, pick a set of properties whose keys are in the union K

<code>{ [P in K]: T[P]; }</code>
<code>{
[P in K]: T[P];
}</code>


#### FlashMode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,7 @@ private void startCamera(final PluginCall call) {
//noinspection DataFlowIssue
final boolean disableFocusIndicator = call.getBoolean("disableFocusIndicator", false);
final boolean enableVideoMode = Boolean.TRUE.equals(call.getBoolean("enableVideoMode", false));
final String videoQuality = call.getString("videoQuality", "high");

// Check for conflict between aspectRatio and size
if (call.getData().has("aspectRatio") && (call.getData().has("width") || call.getData().has("height"))) {
Expand Down Expand Up @@ -1193,7 +1194,8 @@ private void startCamera(final PluginCall call) {
aspectRatio,
gridMode,
disableFocusIndicator,
enableVideoMode
enableVideoMode,
videoQuality
);
config.setTargetZoom(finalTargetZoom);
config.setCentered(isCentered);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -856,10 +856,36 @@ private void bindCameraUseCases() {

// Only setup VideoCapture if enableVideoMode is true
if (sessionConfig.isVideoModeEnabled()) {
QualitySelector qualitySelector = QualitySelector.fromOrderedList(
Arrays.asList(Quality.FHD, Quality.HD, Quality.SD),
FallbackStrategy.higherQualityOrLowerThan(Quality.FHD)
);
QualitySelector qualitySelector;

// Get quality from sessionConfig default to high if null
String videoQuality = sessionConfig.getVideoQuality() != null ? sessionConfig.getVideoQuality() : "high";

switch (videoQuality.toLowerCase()) {
case "low":
// Target SD, allow falling back to lower if needed
qualitySelector = QualitySelector.fromOrderedList(
Arrays.asList(Quality.SD, Quality.LOWEST),
FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
);
break;
case "medium":
// Target HD, allow falling back to SD
qualitySelector = QualitySelector.fromOrderedList(
Arrays.asList(Quality.HD, Quality.SD),
FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
);
break;
case "high":
default:
// Target FHD allow falling back SD
qualitySelector = QualitySelector.fromOrderedList(
Arrays.asList(Quality.FHD, Quality.HD, Quality.SD),
FallbackStrategy.higherQualityOrLowerThan(Quality.FHD)
);
break;
}

Recorder recorder = new Recorder.Builder().setQualitySelector(qualitySelector).build();
videoCapture = VideoCapture.withOutput(recorder);
}
Expand Down Expand Up @@ -2736,7 +2762,8 @@ public void switchToDevice(String deviceId) {
sessionConfig.getAspectRatio(),
sessionConfig.getGridMode(),
sessionConfig.getDisableFocusIndicator(),
sessionConfig.isVideoModeEnabled()
sessionConfig.isVideoModeEnabled(),
sessionConfig.getVideoQuality()
);

sessionConfig.setCentered(wasCentered);
Expand Down Expand Up @@ -2780,7 +2807,8 @@ public void flipCamera() {
sessionConfig.getAspectRatio(), // aspectRatio
sessionConfig.getGridMode(), // gridMode
sessionConfig.getDisableFocusIndicator(), // disableFocusIndicator
sessionConfig.isVideoModeEnabled() // enableVideoMode
sessionConfig.isVideoModeEnabled(), // enableVideoMode
sessionConfig.getVideoQuality() // videoQuality
);

sessionConfig.setCentered(wasCentered);
Expand Down Expand Up @@ -2898,7 +2926,8 @@ public void setAspectRatio(String aspectRatio, Float x, Float y, Runnable callba
aspectRatio,
currentGridMode,
sessionConfig.getDisableFocusIndicator(),
sessionConfig.isVideoModeEnabled()
sessionConfig.isVideoModeEnabled(),
sessionConfig.getVideoQuality()
);
sessionConfig.setCentered(true);

Expand Down Expand Up @@ -2972,7 +3001,8 @@ public void forceAspectRatioRecalculation(String aspectRatio, Float x, Float y,
aspectRatio,
currentGridMode,
sessionConfig.getDisableFocusIndicator(),
sessionConfig.isVideoModeEnabled()
sessionConfig.isVideoModeEnabled(),
sessionConfig.getVideoQuality()
);
sessionConfig.setCentered(true);

Expand Down Expand Up @@ -3033,7 +3063,8 @@ public void setGridMode(String gridMode) {
sessionConfig.getAspectRatio(),
gridMode,
sessionConfig.getDisableFocusIndicator(),
sessionConfig.isVideoModeEnabled()
sessionConfig.isVideoModeEnabled(),
sessionConfig.getVideoQuality()
);

// Update the grid overlay immediately
Expand Down Expand Up @@ -3371,7 +3402,8 @@ public void setPreviewSize(int x, int y, int width, int height, Runnable callbac
calculatedAspectRatio,
sessionConfig.getGridMode(),
sessionConfig.getDisableFocusIndicator(),
sessionConfig.isVideoModeEnabled()
sessionConfig.isVideoModeEnabled(),
sessionConfig.getVideoQuality()
);

// If aspect ratio changed due to size update, rebind camera
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class CameraSessionConfiguration {
private final boolean enableVideoMode;
private float targetZoom = 1.0f;
private boolean isCentered = false;
private final String videoQuality;

public CameraSessionConfiguration(
String deviceId,
Expand All @@ -42,7 +43,8 @@ public CameraSessionConfiguration(
String aspectRatio,
String gridMode,
boolean disableFocusIndicator,
boolean enableVideoMode
boolean enableVideoMode,
String videoQuality
) {
this.deviceId = deviceId;
this.position = position;
Expand All @@ -61,6 +63,7 @@ public CameraSessionConfiguration(
this.gridMode = gridMode != null ? gridMode : "none";
this.disableFocusIndicator = disableFocusIndicator;
this.enableVideoMode = enableVideoMode;
this.videoQuality = videoQuality != null ? videoQuality : "high";
}

public void setTargetZoom(float zoom) {
Expand Down Expand Up @@ -131,6 +134,10 @@ public String getGridMode() {
return gridMode;
}

public String getVideoQuality() {
return videoQuality;
}

// Additional getters with "get" prefix for compatibility
public boolean getToBack() {
return toBack;
Expand Down
104 changes: 75 additions & 29 deletions ios/Sources/CapgoCameraPreviewPlugin/CameraController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ class CameraController: NSObject {
var videoFileURL: URL?
private let saneMaxZoomFactor: CGFloat = 25.5

var videoQuality: String = "high"

// Track output preparation status
private var outputsPrepared: Bool = false

Expand Down Expand Up @@ -348,7 +350,7 @@ extension CameraController {
self.outputsPrepared = true
}

func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float?, disableFocusIndicator: Bool = false, completionHandler: @escaping (Error?) -> Void) {
func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, initialZoomLevel: Float?, disableFocusIndicator: Bool = false, videoQuality: String = "high", completionHandler: @escaping (Error?) -> Void) {
print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), zoom: \(initialZoomLevel ?? 1)")

DispatchQueue.global(qos: .userInitiated).async { [weak self] in
Expand Down Expand Up @@ -379,6 +381,9 @@ extension CameraController {
throw CameraControllerError.captureSessionIsMissing
}

// Set quality of video
self.videoQuality = videoQuality

// Prepare outputs early
self.prepareOutputs()

Expand Down Expand Up @@ -471,35 +476,62 @@ extension CameraController {
guard let captureSession = self.captureSession else { return }

var targetPreset: AVCaptureSession.Preset = .photo
if let aspectRatio = aspectRatio {
switch aspectRatio {
case "16:9":
// Start with 1080p for faster initialization, 4K only when explicitly needed
// This maintains capture quality while optimizing preview performance
if captureSession.canSetSessionPreset(.hd1920x1080) {
targetPreset = .hd1920x1080
} else if captureSession.canSetSessionPreset(.hd4K3840x2160) {
targetPreset = .hd4K3840x2160
}
case "4:3":
if captureSession.canSetSessionPreset(.photo) {
targetPreset = .photo
} else if captureSession.canSetSessionPreset(.high) {
targetPreset = .high
} else {
targetPreset = captureSession.sessionPreset
}
default:
if captureSession.canSetSessionPreset(.photo) {
targetPreset = .photo
} else if captureSession.canSetSessionPreset(.high) {
targetPreset = .high
} else {
targetPreset = captureSession.sessionPreset

// Prioritize video quality setting
switch self.videoQuality.lowercased() {
case "low":
// Match Android "Low" (SD/480p)
if captureSession.canSetSessionPreset(.vga640x480) {
targetPreset = .vga640x480
} else {
targetPreset = .low
}
case "medium":
// Match Android "Medium" (HD/720p)
if captureSession.canSetSessionPreset(.hd1280x720) {
targetPreset = .hd1280x720
} else {
targetPreset = .medium
}
case "high":
// Exisiting logic for High Quality (4K/1080p based on Asepct Ratio)

if let aspectRatio = aspectRatio {
switch aspectRatio {
case "16:9":
// Start with 1080p for faster initialization, 4K only when explicitly needed
// This maintains capture quality while optimizing preview performance
if captureSession.canSetSessionPreset(.hd1920x1080) {
targetPreset = .hd1920x1080
} else if captureSession.canSetSessionPreset(.hd4K3840x2160) {
targetPreset = .hd4K3840x2160
}
case "4:3":
if captureSession.canSetSessionPreset(.photo) {
targetPreset = .photo
} else if captureSession.canSetSessionPreset(.high) {
targetPreset = .high
} else {
targetPreset = captureSession.sessionPreset
}
default:
if captureSession.canSetSessionPreset(.photo) {
targetPreset = .photo
} else if captureSession.canSetSessionPreset(.high) {
targetPreset = .high
} else {
targetPreset = captureSession.sessionPreset
}
}
}
// Handle unexpected values
default:
if captureSession.canSetSessionPreset(.photo) {
targetPreset = .photo
} else {
targetPreset = .high
}
}

if captureSession.canSetSessionPreset(targetPreset) {
captureSession.sessionPreset = targetPreset
}
Expand Down Expand Up @@ -802,7 +834,21 @@ extension CameraController {
}

// Helper: pick the best preset the TARGET device supports for a given aspect ratio
private func bestPreset(for aspectRatio: String?, on device: AVCaptureDevice) -> AVCaptureSession.Preset {
private func bestPreset(for aspectRatio: String?, quality: String, on device: AVCaptureDevice) -> AVCaptureSession.Preset {

// Handle specific quality overrides first
switch quality.lowercased() {
case "low":
if device.supportsSessionPreset(.vga640x480) { return .vga640x480 }
return .low
case "medium":
if device.supportsSessionPreset(.hd1280x720) { return .hd1280x720 }
return .medium
case "high":
break // Exit and go off code below
default:
break // Exit and go off code below
}
// Preference order depends on aspect ratio
if aspectRatio == "16:9" {
// Prefer 4K → 1080p → 720p → high → photo → vga
Expand Down Expand Up @@ -840,7 +886,7 @@ extension CameraController {
}

// Compute the desired preset for the TARGET device up front
let desiredPreset = bestPreset(for: self.requestedAspectRatio, on: targetDevice)
let desiredPreset = bestPreset(for: self.requestedAspectRatio, quality: self.videoQuality, on: targetDevice)

// Keep the preview layer visually stable during the swap
let savedPreviewFrame = self.previewLayer?.frame
Expand Down
6 changes: 5 additions & 1 deletion ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
print(" - initialZoomLevel: \(call.getFloat("initialZoomLevel") ?? 1.0)")
print(" - disableFocusIndicator: \(call.getBool("disableFocusIndicator") ?? false)")
print(" - force: \(call.getBool("force") ?? false)")
print(" - videoQuality: \(call.getString("videoQuality") ?? "high")")

let force = call.getBool("force") ?? false

Expand Down Expand Up @@ -752,6 +753,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
self.positioning = call.getString("positioning") ?? "top"
self.disableFocusIndicator = call.getBool("disableFocusIndicator") ?? false

// Default to high if not provided
let videoQuality = call.getString("videoQuality") ?? "high"

let initialZoomLevel = call.getFloat("initialZoomLevel")

// Check for conflict between aspectRatio and size (width/height)
Expand All @@ -772,7 +776,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
return
}

self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, initialZoomLevel: initialZoomLevel, disableFocusIndicator: self.disableFocusIndicator) { error in
self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, initialZoomLevel: initialZoomLevel, disableFocusIndicator: self.disableFocusIndicator, videoQuality: videoQuality) { error in
if let error = error {
print(error)
DispatchQueue.main.async {
Expand Down
9 changes: 9 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,15 @@ export interface CameraPreviewOptions {
* @platform android, ios, web
*/
force?: boolean;
/**
* Sets the quality of video for recording.
* Options: 'low', 'medium', 'high'
* @note On Android requires 'enableVideoMode' to be true
* @note Will affect the entire preview stream for iOS
* @platform ios, android
* @default "high"
*/
videoQuality?: 'low' | 'medium' | 'high';
}

/**
Expand Down