Lynx/Modules/Camera/Usage
@sigx/lynx-camera · Stable

Using Camera#

Open the system camera to take a photo or record a video, and get the captured file back — on both iOS and Android.

Basic usage#

The whole surface lives on a single Camera object. On Android, capture auto-requests the CAMERA permission, so you can call takePicture directly and catch a denial:

TSX
import { Camera } from '@sigx/lynx-camera';

async function snap() {
  try {
    const photo = await Camera.takePicture({ facing: 'back', quality: 0.8 });
    if (!photo.uri) return; // user cancelled
    console.log(photo.uri, photo.width, photo.height);
  } catch (err) {
    console.warn('Camera unavailable or permission denied', err);
  }
}

takePicture follows a three-outcome contract: it resolves with a PhotoResult (always carrying a uri) on success, resolves with { cancelled: true } (no uri) if the user dismisses the camera, and throws on a real failure (permission denied, no camera, …). Narrow on uri for the success path and try / catch failures.

The uri is normalized to a scheme: file:// on iOS, content:// on Android (backed by a JPEG in the app cache directory). Move or upload it promptly, since the OS may reclaim cached files.

Recording video#

recordVideo opens the camera in video mode and follows the exact same three-outcome contract as takePicture. The returned uri is loadable directly by @sigx/lynx-video's VideoPlayer:

TSX
import { component, signal } from '@sigx/lynx';
import { Camera } from '@sigx/lynx-camera';
import { VideoPlayer } from '@sigx/lynx-video';

const Recorder = component(() => {
  const clip = signal<string | null>(null);

  const record = async () => {
    try {
      const result = await Camera.recordVideo({ maxDurationMs: 30_000 });
      if (result.uri) clip.set(result.uri); // else: user cancelled
    } catch (err) {
      console.warn('Recording failed', err);
    }
  };

  return () => (
    <view>
      <text bindtap={record}>Record</text>
      {clip() ? (
        <VideoPlayer src={clip()!} controls style={{ width: '100%', aspectRatio: 16 / 9 }} />
      ) : null}
    </view>
  );
});

maxDurationMs caps the recording length on iOS (videoMaximumDuration); Android's ACTION_VIDEO_CAPTURE intent exposes no duration cap. On Android, recordVideo auto-requests the CAMERA permission, and also microphone when the app declares RECORD_AUDIO (e.g. by installing @sigx/lynx-audio). A single in-camera photo/video toggle is iOS-only at the system level — present a "photo or video" choice in your own UI and call the matching method.

Installing and linking#

Install the package, then let the CLI wire up the native module:

Terminal
pnpm add @sigx/lynx-camera
sigx prebuild

sigx prebuild auto-discovers the package, links the native module, injects android.permission.CAMERA, and adds the iOS usage descriptions (NSCameraUsageDescription, NSMicrophoneUsageDescription, NSPhotoLibraryAddUsageDescription). The companion @sigx/lynx-permissions package is auto-linked too — there is nothing extra to install.

To customize the iOS permission prompt copy, set it in signalx.config.ts:

TypeScript
import { defineLynxConfig } from '@sigx/lynx-cli/config';

export default defineLynxConfig({
  ios: {
    usageDescriptions: {
      NSCameraUsageDescription: 'Acme uses the camera to scan QR codes.',
    },
  },
});

Handling permissions properly#

Calling requestPermission before capture is optionaltakePicture / recordVideo auto-request the CAMERA permission on Android (iOS auto-requests at the capture layer), and a denial surfaces as a thrown error you can catch. Request explicitly when you want to gate your own UI ahead of time or inspect canAskAgain. Use getPermissionStatus for a silent check (it never prompts) and requestPermission to show the OS dialog:

TSX
import { Camera } from '@sigx/lynx-camera';

async function ensureCamera(): Promise<boolean> {
  let { status, canAskAgain } = await Camera.getPermissionStatus();

  if (status === 'undetermined') {
    ({ status, canAskAgain } = await Camera.requestPermission());
  }

  if (status === 'granted') return true;

  // status is 'denied' or 'blocked'. If canAskAgain is false the user must
  // re-enable the permission from system Settings — prompting again is a no-op.
  return false;
}

canAskAgain is false once the user picks "Don't ask again" on Android, or after the first denial on iOS (where the only path back is the Settings app). Surface a "open Settings" message in that case rather than re-requesting.

Capturing and using a photo#

A complete capture flow. A user cancel resolves with { cancelled: true } (no uri); a real failure — permission denied, no camera, or a missing native module — throws. So narrow on uri for the success path and wrap the call in try / catch:

TSX
import { Camera } from '@sigx/lynx-camera';
import type { PhotoResult } from '@sigx/lynx-camera';

async function capturePortrait(): Promise<PhotoResult | null> {
  if (!Camera.isAvailable()) {
    console.warn('Camera module not present in this build');
    return null;
  }

  try {
    const result = await Camera.takePicture({ facing: 'front', quality: 0.9 });

    // Cancel resolves with { cancelled: true } and no uri.
    if (!result.uri) {
      console.warn('Capture cancelled', result);
      return null;
    }

    return result;
  } catch (err) {
    console.warn('Capture failed or permission denied', err);
    return null;
  }
}

Notes#

  • Options are mostly iOS-only. iOS honors facing and JPEG quality (default 0.8) for photos and facing / maxDurationMs for video; Android's capture intents ignore options (the user can still switch cameras in the system UI). CameraOptions.maxWidth / maxHeight are reserved and not yet applied on either platform.
  • Three outcomes, not one shape. Only a successful capture returns a PhotoResult / VideoResult with a uri. A cancel resolves with { cancelled: true } (no uri); a real failure throws. Narrow on uri for success and try / catch failures. Metadata beyond uri (iOS fileSize, dimensions) is best-effort; base64 is declared but not populated by either native implementation today.
  • isAvailable() is JS-only — it reports whether the native module is registered in the build; it does not check hardware. iOS additionally gates real capture on UIImagePickerController source availability.
  • Test on a physical device. The iOS Simulator has no camera, so UIImagePickerController's camera source is unavailable there and capture fails — isAvailable() is a JS-only module check and won't catch this. Run capture on a real device.
  • If you customize AndroidManifest.xml, keep the auto-injected FileProvider <provider> (authority ${applicationId}.fileprovider) intact — the capture intent needs it as a write target.

See also#

  • Overview — what the package is and how it fits the family.
  • API reference — every export with signatures.