npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@fugood/react-native-screen-capture

v3.1.0

Published

Programmatic screenshots for React Native, including video and camera components

Readme

@fugood/react-native-screen-capture

CI

Programmatic screenshots for React Native — including the parts that normally come out black.

Video players and camera previews are composited by the GPU, not by the drawing APIs a normal screenshot goes through. Capture them the usual way and you get a black rectangle where the video should be. This module pulls the current frame out of the framework that owns it and composites it back in, so what you get is what was on screen.

No root. No MediaProjection consent dialog. No foreground service.

Requirements

| | | | --- | --- | | React Native | 0.76+ (old and new architecture) | | Android | API 24+ (accessibility mode needs API 30+) | | iOS | 15.1+ | | tvOS | 15.1+ (video only -- tvOS has no camera APIs) |

Install

npm install @fugood/react-native-screen-capture
cd ios && pod install

Autolinking handles the rest. Nothing to register manually.

Quick start

import ScreenCapture from '@fugood/react-native-screen-capture'

const shot = await ScreenCapture.capture()
// { uri: 'file:///.../CAPTURE-....png', width: 1170, height: 2532 }

<Image source={{ uri: shot.uri }} style={{ width: 100, height: 200 }} />

API

capture(options?): Promise<CaptureResult>

type CaptureOptions = {
  mode?: 'auto' | 'view' | 'accessibility'  // default 'auto'
  excludeStatusBar?: boolean                // default false
  extension?: 'png' | 'jpg' | 'jpeg'        // default 'png'
  quality?: number                          // 1-100, JPEG only, default 100
  scale?: number                            // default 1 (native size)
  includeBase64?: boolean                   // default false
  screen?: 'all' | 'main' | string          // default 'all'
  markUnsupported?: boolean                 // default false
}

type CaptureResult = {
  uri: string       // file:// URI in the app's cache directory
  base64?: string   // only when includeBase64 is true
  width: number     // pixels
  height: number
}

includeBase64 costs a second full encode pass. Leave it off unless you need it — the file is already written and uri works directly in <Image>.

Multiple screens

Apps that drive an external display often move their content onto that display, leaving the built-in screen holding an empty window. screen decides what a capture returns:

| value | result | | --- | --- | | 'all' (default) | every screen the app is showing on, stitched side by side, built-in first | | 'main' | the built-in screen only | | a display id | that screen only (iOS: index in UIScreen.screens; Android: Display.getDisplayId()) |

On a single-screen device all three produce the same image, so there is nothing to set.

On Android the selector applies to accessibility mode. view mode can only reach the current Activity's window, and a secondary display shows its content through a Presentation, whose window an Activity cannot get at — so view mode always returns the Activity's own screen.

Files land in the app's cache directory and are never cleaned up automatically. Call clearCache() when it suits you.

setMode(mode) / getMode()

Sets the default mode used by capture() when called without one.

getPermissionStatus(mode?) / requestPermission(mode?)

Resolve to 'granted', 'denied' or 'unavailable'. Only meaningful for accessibility mode; view mode is always 'granted'.

requestPermission('accessibility') opens system settings and resolves with the status before the user acted on it — the OS gives no callback. Re-check with getPermissionStatus() when your app comes back to the foreground.

openAccessibilitySettings(): Promise<boolean>

Android only. Deep-links to Settings → Accessibility. Resolves false on iOS.

isModeAvailable(mode): Promise<boolean>

Whether the mode can work in this app at all, regardless of permission. For accessibility that means both a new enough OS and the service declared in your manifest, so a true here means requestPermission() will open a Settings page that actually lists something to enable.

warmUp() / coolDown()

iOS only; no-ops elsewhere. See Performance.

clearCache(): Promise<number>

Deletes every file this module has written. Resolves with the count.

addScreenshotListener(cb): Subscription

See Screenshot detection.

dumpHierarchy(): Promise<string>

Development helper. Dumps the native view/layer tree with a note on every media component it recognises. Use it when something is not being captured — see Supported media components.

Capture modes

| Mode | Platform | Captures | Cost to the user | | --- | --- | --- | --- | | view (default) | both | this app's own windows | nothing | | accessibility | Android 11+ | the whole display, other apps, real system bars | must enable a service in Settings, once | | auto | both | accessibility if it is already enabled, otherwise view | nothing |

MediaProjection is deliberately not offered. Since Android 14 the consent Intent cannot be cached or reused, so consent cannot survive a process restart — every cold start would show a system dialog. It also requires a foreground service with a persistent notification, shows a status-bar chip from Android 15 QPR1, and stops itself when the device locks. That is the wrong shape for a screenshot utility. See issue #2.

What is and is not captured

| | view | accessibility | | --- | --- | --- | | App UI | yes | yes | | Video / camera components | yes, see below | yes | | TextureView (Android) | yes | yes | | SurfaceView (Android) | yes | yes | | Dialogs, <Modal>, other windows | iOS yes / Android no | yes | | Real system bars | no | yes | | Other apps | no | yes | | DRM content | no — black | no — black | | FLAG_SECURE windows (Android) | no — black | no — black |

Verify on a real device. The iOS and tvOS simulators composite video and camera layers into a snapshot by themselves, so a simulator cannot tell you whether capture is actually working. Devices do not.

Two limits are not fixable from an app and never will be:

  • FairPlay / Widevine L1 video comes out black. Decoded frames never leave the hardware secure path, so no app-accessible API can read them. Apple and Google enforce this deliberately; the system screen recorders behave the same way.
  • FLAG_SECURE surfaces come out black on Android, by design.

On Android, view mode captures the activity window only. Dialogs and React Native <Modal> render into separate windows, and there is no public API to enumerate them — reaching them would mean non-SDK reflection, which we do not do. Use accessibility mode if you need them.

The iOS status bar is drawn by a separate system process and is not in the app's windows, so it is never captured. excludeStatusBar: true crops that area off.

On Android excludeStatusBar applies in both modes, but they measure differently. view mode crops by the inset actually in effect on the app's own window, so immersive and cutout cases are exact. accessibility mode captures the whole display — often while a different app is in front — so it has no window whose inset would mean anything and crops by the platform's nominal status bar height instead. If the foreground app hides the status bar there is nothing there to remove and the option will take a strip of real content. The navigation bar is never cropped.

Supported media components

iOS discovery is pure runtime introspection: we look for AVFoundation objects reached through public properties, never for package names or private classes. That means one rule covers a whole class of packages, and nothing is required from those packages or from you.

| Found via | Frame source | Covers | | --- | --- | --- | | AVCaptureVideoPreviewLayer.session | AVCaptureVideoDataOutput | react-native-vision-camera, expo-camera, react-native-camera-kit | | AVPlayerLayer.player | AVPlayerItemVideoOutput | anything driving an AVPlayerLayer | | AVPlayerViewController.player | AVPlayerItemVideoOutput | react-native-video, expo-video, expo-av | | AVSampleBufferDisplayLayer | copyDisplayedPixelBuffer | low-latency and WebRTC-style players |

AVSampleBufferDisplayLayer needs iOS/tvOS 17.4: sampleBufferRenderer arrived in 17.0 and copyDisplayedPixelBuffer on it in 17.4. Below that there is no public way to read the layer back, and the region renders however drawViewHierarchyInRect: leaves it — black, on device.

Metal-backed layers (CAMetalLayer, MTKView — react-native-skia, react-native-wgpu, react-native-webrtc's RTCMTLVideoView) need no provider: the hierarchy draw renders them itself. Only AVFoundation's video planes are out of its reach, not GPU rendering in general. dumpHierarchy() lists them anyway, so nobody goes looking for a problem that is not there.

What cannot be captured

FairPlay-protected video: those frames never leave the secure path, so no output can read them. An AVSampleBufferDisplayLayer below iOS 17.4 is unreachable for the same practical reason — no public read-back exists on that OS. On Android the equivalent is a SurfaceView the system refuses to read back, which is how DRM playback presents itself.

markUnsupported: true labels those regions instead of leaving them blank:

await ScreenCapture.capture({ markUnsupported: true })

The label is inserted into the component's own layer tree — above the media layer on iOS, in the view's ViewOverlay on Android — so whatever covers the component on screen covers the label too. Occlusion, clipping and transforms stay the platform's job, exactly as for a captured frame.

Android needs no such table: PixelCopy works on any SurfaceView regardless of what renders into it, and TextureView draws through the view hierarchy already.

When something is not captured

console.log(await ScreenCapture.dumpHierarchy())

The dump flags every media layer it recognises and marks the ones it does not. If your component shows up unrecognised, open an issue with the dump — adding a rule is usually a small change.

Android: accessibility mode

This mode is opt-in and unlisted by default. The service is not declared in this library's manifest on purpose: manifest merging would push an accessibility service into every app that depends on this package and drag all of them into Google Play's Accessibility API policy review, including apps that only ever use view mode.

Before you ship this. Google Play requires apps using the Accessibility API to serve users with disabilities, and to make a prominent disclosure of what the service does. Using it purely to take screenshots can get an app removed. It is a good fit for enterprise, kiosk and sideloaded builds, and a risky one for consumer apps on Play.

1. Declare the service in your app's AndroidManifest.xml

<service
    android:name="com.fugood.screencapture.ScreenCaptureAccessibilityService"
    android:exported="false"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
  <intent-filter>
    <action android:name="android.accessibilityservice.AccessibilityService" />
  </intent-filter>
  <meta-data
      android:name="android.accessibilityservice"
      android:resource="@xml/screen_capture_accessibility_service" />
</service>

@xml/screen_capture_accessibility_service ships with this package. Copy it into your own res/xml/ if you want a different description string shown in Settings — the important part is android:canTakeScreenshot="true", without which the platform rejects every capture.

2. Send the user to Settings

if (await ScreenCapture.getPermissionStatus('accessibility') !== 'granted') {
  await ScreenCapture.openAccessibilitySettings()
}

They have to enable it themselves under Settings → Accessibility → Downloaded apps. There is no programmatic way to switch it on, and no callback when they do — re-check on app resume.

You will hit this during development. Android blocks accessibility services for apps installed from outside the Play Store, which includes anything you adb install or run from Android Studio. The toggle either does nothing or silently reverts. Clear the restriction first: Settings → Apps → your app → ⋮ → Allow restricted settings, or from a shell:

adb shell appops set <your.package> ACCESS_RESTRICTED_SETTINGS allow

3. Capture

const shot = await ScreenCapture.capture({ mode: 'accessibility' })

The platform rate-limits these to roughly three per second. This module backs off and retries once automatically; beyond that you will get an error.

Screenshot detection

const sub = ScreenCapture.addScreenshotListener(({ uri }) => {
  console.log('the user took a screenshot', uri)
})
sub.remove()

The event fires when the user takes a screenshot, not when you call capture().

uri is almost always undefined: neither platform hands the user's screenshot file to the app. If you need the image, take your own with capture() when the event arrives.

  • iOS uses UIApplicationUserDidTakeScreenshotNotification.
  • Android 14+ uses Activity.registerScreenCaptureCallback. Add <uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" /> — this package already declares it, so autolinking covers you.
  • Android 13 and below falls back to watching MediaStore for a new file whose name looks like a screenshot. This is unreliable under scoped storage and needs READ_MEDIA_IMAGES (or READ_EXTERNAL_STORAGE), which your app must declare and request. Without it, the fallback simply never fires.

Performance

Nothing is attached while you are not capturing. An idle app pays exactly zero.

iOS. Pulling frames requires hooking into AVFoundation, which is not free — an AVPlayerItemVideoOutput makes the decoder emit an extra app-readable copy. So providers attach on the first capture() and detach again after ~3 idle seconds. The consequence is that the first capture after an idle period waits one or two frames (16–33ms) for the pipeline to produce something. If you are about to capture in a burst, call warmUp() first and coolDown() when you are done.

Compositing is O(1) full-screen renders regardless of how many video or camera components are on screen. Each frame is injected into its own component's layer, and then the whole hierarchy is rendered once. The naive alternative — slicing the hierarchy into layers and rendering each separately — costs a full render per component. The full-screen render itself is the expensive part (roughly 30–100ms at @3x) and it has to happen on the main thread, so expect a dropped frame.

Camera capture borrows the existing AVCaptureVideoDataOutput delegate rather than adding a second output, and forwards every callback. Reconfiguring a live session causes a visible glitch and can fail outright on some presets; borrowing disturbs nothing. Exactly one pixel buffer is retained at a time, so your camera package's buffer pool is never starved.

Android. All SurfaceView copies are issued in parallel and the window is read back once. Encoding happens off the UI thread, as does the hardware-to-software bitmap copy that accessibility mode needs.

Encoding dominates, not the capture. Measured on a Pixel 7 Pro (1080x2340), end to end from the JS call to the resolved promise:

| | time | | --- | --- | | PNG, full resolution | ~480ms | | JPEG quality 80, scale: 0.5 | ~60ms | | view vs accessibility mode, same settings | within noise of each other |

If capture latency matters, change the format and the scale. The capture mode is worth about 1% of the total and is not the lever. PNG is lossless and the default for that reason, but a full-resolution PNG of a phone screen is a genuinely expensive thing to produce.

Example app

example/ is a React Native 0.81 app that demonstrates every API, including a video player forced onto a SurfaceView so you can see the compositing actually working.

yarn                    # in the repo root
cd example && yarn
yarn android            # or: yarn ios  (pod install first)

dumpHierarchy() reports which media components were matched and, for each, whether its frame pipeline is actually delivering:

react_native_video.RCTVideo layer=CALayer  <- AVPlayerLayer, captured via AVPlayerItemVideoOutput

FRAME PROVIDERS
  player:0x10423ec50  hasFrame=YES  gravity=resizeAspect  target=RCTVideo

hasFrame=no means the component was found but its frames are not reachable — DRM being the usual cause.

Migrating from 1.x

The 1.x API was removed in 3.0. Every call has a direct replacement:

| 1.x | 3.x | | --- | --- | | screenCapture(cb, isHiddenStatus, options) | await capture({ excludeStatusBar, ...options }) | | startListener(cb, keywords) | addScreenshotListener(cb) | | stopListener() | subscription.remove() | | clearCache(cb) | await clearCache() | | result { code: '200', uri, base64 } | resolves { uri, width, height }, rejects on failure |

Two of those are not pure renames:

  • isHiddenStatus defaulted to true on Android and false on iOS. excludeStatusBar defaults to false everywhere, so pass it explicitly if you relied on the Android default. 1.x also spelled "native size" as scale: 0; that is scale: 1 now.
  • The screenshot event payload is different. 1.x delivered { code, uri, base64 }; the event now carries { uri } on Android and {} on iOS, because the platform screenshot callbacks hand over no image. If your handler used base64, call capture() from it instead.

Other changes:

  • Callbacks are gone; everything is a promise that rejects with a real error instead of resolving { code: '500' }.
  • base64 is opt-in via includeBase64. It was always computed before, which doubled the cost of every capture.
  • The keywords argument for screenshot detection is gone. Android 14+ uses the platform callback, and the pre-14 fallback uses a built-in keyword list.
  • Minimum React Native is 0.76, minimum Android API is 24, minimum iOS is 15.1.
  • The iOS status bar snapshot hack is gone. It swizzled the private UIStatusBar class, which stopped existing in iOS 13 — it had not worked for years and was an App Store review risk.

License

MIT