expo-turbo-screen-recorder 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +2 -0
- package/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +35 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +50 -0
- package/android/src/main/AndroidManifest.xml +16 -0
- package/android/src/main/java/expo/modules/screenrecorder/ExpoScreenRecorderModule.kt +198 -0
- package/android/src/main/java/expo/modules/screenrecorder/RecordingOptions.kt +15 -0
- package/android/src/main/java/expo/modules/screenrecorder/ScreenCaptureService.kt +160 -0
- package/build/ExpoScreenRecorder.types.js +2 -0
- package/build/ExpoScreenRecorderModule.js +11 -0
- package/build/ExpoScreenRecorderModule.web.js +73 -0
- package/build/index.js +77 -0
- package/eslint.config.cjs +5 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoScreenRecorder.podspec +23 -0
- package/ios/ExpoScreenRecorderModule.swift +147 -0
- package/package.json +39 -0
- package/src/ExpoScreenRecorder.types.ts +19 -0
- package/src/ExpoScreenRecorderModule.ts +32 -0
- package/src/ExpoScreenRecorderModule.web.ts +15 -0
- package/src/index.ts +22 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
package/.prettierrc
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# expo-screen-recorder
|
|
2
|
+
|
|
3
|
+
Expo Module For High Quality Screen Recorder
|
|
4
|
+
|
|
5
|
+
# API documentation
|
|
6
|
+
|
|
7
|
+
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/screen-recorder/)
|
|
8
|
+
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/screen-recorder/)
|
|
9
|
+
|
|
10
|
+
# Installation in managed Expo projects
|
|
11
|
+
|
|
12
|
+
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
|
13
|
+
|
|
14
|
+
# Installation in bare React Native projects
|
|
15
|
+
|
|
16
|
+
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
|
17
|
+
|
|
18
|
+
### Add the package to your npm dependencies
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install expo-screen-recorder
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Configure for Android
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Configure for iOS
|
|
30
|
+
|
|
31
|
+
Run `npx pod-install` after installing the npm package.
|
|
32
|
+
|
|
33
|
+
# Contributing
|
|
34
|
+
|
|
35
|
+
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
group = 'expo.modules.screenrecorder'
|
|
6
|
+
version = '0.3.0'
|
|
7
|
+
|
|
8
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
9
|
+
apply from: expoModulesCorePlugin
|
|
10
|
+
applyKotlinExpoModulesCorePlugin()
|
|
11
|
+
useCoreDependencies()
|
|
12
|
+
useExpoPublishing()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
android {
|
|
16
|
+
namespace "expo.modules.screenrecorder"
|
|
17
|
+
defaultConfig {
|
|
18
|
+
versionCode 1
|
|
19
|
+
versionName "0.2.0"
|
|
20
|
+
}
|
|
21
|
+
lintOptions {
|
|
22
|
+
abortOnError false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
dependencies {
|
|
28
|
+
// 2. Kotlin Standard Library (SDK 54 uses Kotlin 1.9+)
|
|
29
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
|
30
|
+
|
|
31
|
+
// 3. Android KTX for cleaner Kotlin code
|
|
32
|
+
implementation "androidx.core:core-ktx:1.12.0"
|
|
33
|
+
|
|
34
|
+
implementation "androidx.appcompat:appcompat:1.6.1"
|
|
35
|
+
|
|
36
|
+
// 4. Coroutines for background processing (Critical for VideoEncoder)
|
|
37
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.10.2"
|
|
38
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2"
|
|
39
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// 5. EXIF support for image rotation handling
|
|
43
|
+
implementation "androidx.exifinterface:exifinterface:1.3.6"
|
|
44
|
+
|
|
45
|
+
// 6. Lifecycle KTX (Useful if you want to scope encoding to the app lifecycle)
|
|
46
|
+
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
|
|
47
|
+
|
|
48
|
+
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
|
49
|
+
}
|
|
50
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
2
|
+
package="expo.modules.screenrecorder">
|
|
3
|
+
|
|
4
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
5
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
|
6
|
+
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
|
7
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
8
|
+
|
|
9
|
+
<application>
|
|
10
|
+
<service
|
|
11
|
+
android:name=".ScreenCaptureService"
|
|
12
|
+
android:enabled="true"
|
|
13
|
+
android:exported="false"
|
|
14
|
+
android:foregroundServiceType="mediaProjection" />
|
|
15
|
+
</application>
|
|
16
|
+
</manifest>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
package expo.modules.screenrecorder
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.media.projection.MediaProjectionManager
|
|
7
|
+
import android.os.Bundle
|
|
8
|
+
import android.util.DisplayMetrics
|
|
9
|
+
import android.view.WindowManager
|
|
10
|
+
import androidx.activity.result.contract.ActivityResultContract
|
|
11
|
+
import expo.modules.kotlin.activityresult.AppContextActivityResultCaller
|
|
12
|
+
import expo.modules.kotlin.activityresult.ActivityResultContractSenderAndroidX
|
|
13
|
+
import expo.modules.kotlin.modules.Module
|
|
14
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
15
|
+
import expo.modules.kotlin.Promise
|
|
16
|
+
import java.io.File
|
|
17
|
+
import java.util.UUID
|
|
18
|
+
|
|
19
|
+
// ── Contract ──────────────────────────────────────────────────────────────────
|
|
20
|
+
// Wraps MediaProjectionManager.createScreenCaptureIntent() in the AndroidX
|
|
21
|
+
// Activity Result API that Expo SDK 54 modules are expected to use.
|
|
22
|
+
class ScreenCaptureContract : ActivityResultContract<Unit, Pair<Int, Intent?>>() {
|
|
23
|
+
|
|
24
|
+
override fun createIntent(context: Context, input: Unit): Intent {
|
|
25
|
+
val mgr = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE)
|
|
26
|
+
as MediaProjectionManager
|
|
27
|
+
return mgr.createScreenCaptureIntent()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override fun parseResult(resultCode: Int, intent: Intent?): Pair<Int, Intent?> =
|
|
31
|
+
resultCode to intent
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Module ────────────────────────────────────────────────────────────────────
|
|
35
|
+
class ExpoScreenRecorderModule : Module() {
|
|
36
|
+
|
|
37
|
+
private var pendingPromise: Promise? = null
|
|
38
|
+
private var currentOutputFile: File? = null
|
|
39
|
+
private var startTime: Long = 0L
|
|
40
|
+
private var includeAudioStream: Boolean = false
|
|
41
|
+
private var qualitySetting: String = "high"
|
|
42
|
+
|
|
43
|
+
// Launcher is initialised once inside OnCreate and reused for every recording
|
|
44
|
+
// request. It is torn down automatically when the module is destroyed because
|
|
45
|
+
// ActivityResultContractSenderAndroidX is scoped to the module lifecycle.
|
|
46
|
+
private lateinit var captureLauncher: ActivityResultContractSenderAndroidX<Unit, Pair<Int, Intent?>>
|
|
47
|
+
|
|
48
|
+
override fun definition() = ModuleDefinition {
|
|
49
|
+
Name("ExpoScreenRecorder")
|
|
50
|
+
|
|
51
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
OnCreate {
|
|
54
|
+
// Register the contract once. The returned sender is our handle for
|
|
55
|
+
// launching the system permission dialog on every startRecordingAsync call.
|
|
56
|
+
captureLauncher = appContext
|
|
57
|
+
.registerForActivityResult(ScreenCaptureContract()) { (resultCode, data) ->
|
|
58
|
+
handleCaptureResult(resultCode, data)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
OnDestroy {
|
|
63
|
+
// Unregister the launcher so no stale callbacks fire after the
|
|
64
|
+
// module is torn down (e.g. fast-refresh, app backgrounding).
|
|
65
|
+
captureLauncher.unregister()
|
|
66
|
+
|
|
67
|
+
// If the module is destroyed mid-recording, reject the pending
|
|
68
|
+
// promise rather than leaving the caller hanging forever.
|
|
69
|
+
pendingPromise?.reject(
|
|
70
|
+
"ERR_MODULE_DESTROYED",
|
|
71
|
+
"Screen recorder module was destroyed before the operation completed.",
|
|
72
|
+
null
|
|
73
|
+
)
|
|
74
|
+
pendingPromise = null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── API ───────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
AsyncFunction("isAvailableAsync") {
|
|
80
|
+
return@AsyncFunction true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
AsyncFunction("startRecordingAsync") { options: RecordingOptions, promise: Promise ->
|
|
84
|
+
if (pendingPromise != null) {
|
|
85
|
+
promise.reject(
|
|
86
|
+
"ERR_ALREADY_RECORDING",
|
|
87
|
+
"A recording sequence is already initialised.",
|
|
88
|
+
null
|
|
89
|
+
)
|
|
90
|
+
return@AsyncFunction
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
val cacheDir = appContext.reactContext?.cacheDir
|
|
94
|
+
if (cacheDir == null) {
|
|
95
|
+
promise.reject("ERR_NO_CONTEXT", "Cache directory is unavailable.", null)
|
|
96
|
+
return@AsyncFunction
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
pendingPromise = promise
|
|
100
|
+
includeAudioStream = options.includeAudio ?: false
|
|
101
|
+
qualitySetting = options.quality ?: "high"
|
|
102
|
+
currentOutputFile = File(cacheDir, "REC_${UUID.randomUUID()}.mp4")
|
|
103
|
+
|
|
104
|
+
// Launch the system screen-capture permission dialog.
|
|
105
|
+
// The result lands in handleCaptureResult() via the contract callback.
|
|
106
|
+
captureLauncher.launch(
|
|
107
|
+
input = Unit,
|
|
108
|
+
onFailure = { ex ->
|
|
109
|
+
pendingPromise?.reject("ERR_LAUNCH_FAILED", ex.message, ex)
|
|
110
|
+
pendingPromise = null
|
|
111
|
+
}
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
AsyncFunction("stopRecordingAsync") { promise: Promise ->
|
|
116
|
+
val context = appContext.reactContext ?: run {
|
|
117
|
+
promise.reject(
|
|
118
|
+
"ERR_NO_CONTEXT",
|
|
119
|
+
"Application execution environment dropped.",
|
|
120
|
+
null
|
|
121
|
+
)
|
|
122
|
+
return@AsyncFunction
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
context.stopService(Intent(context, ScreenCaptureService::class.java))
|
|
126
|
+
|
|
127
|
+
val file = currentOutputFile
|
|
128
|
+
if (file == null || !file.exists()) {
|
|
129
|
+
promise.reject("ERR_NO_FILE", "Output file missing or recording failed.", null)
|
|
130
|
+
return@AsyncFunction
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
val durationSec = (System.currentTimeMillis() - startTime) / 1000.0
|
|
134
|
+
promise.resolve(
|
|
135
|
+
mapOf(
|
|
136
|
+
"uri" to "file://${file.absolutePath}",
|
|
137
|
+
"duration" to durationSec
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
private fun handleCaptureResult(resultCode: Int, data: Intent?) {
|
|
146
|
+
val promise = pendingPromise
|
|
147
|
+
|
|
148
|
+
if (resultCode != Activity.RESULT_OK || data == null) {
|
|
149
|
+
promise?.reject(
|
|
150
|
+
"ERR_PERMISSION_DENIED",
|
|
151
|
+
"User declined screen recording system permissions.",
|
|
152
|
+
null
|
|
153
|
+
)
|
|
154
|
+
pendingPromise = null
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
val context = appContext.reactContext ?: run {
|
|
159
|
+
promise?.reject("ERR_NO_CONTEXT", "React context unavailable.", null)
|
|
160
|
+
pendingPromise = null
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
val activity = appContext.currentActivity ?: run {
|
|
165
|
+
promise?.reject("ERR_NO_ACTIVITY", "Current activity context is missing.", null)
|
|
166
|
+
pendingPromise = null
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Resolve screen dimensions for the capture service
|
|
171
|
+
val metrics = DisplayMetrics()
|
|
172
|
+
@Suppress("DEPRECATION")
|
|
173
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
|
174
|
+
activity.display?.getRealMetrics(metrics)
|
|
175
|
+
} else {
|
|
176
|
+
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager)
|
|
177
|
+
.defaultDisplay.getRealMetrics(metrics)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
val serviceIntent = Intent(context, ScreenCaptureService::class.java).apply {
|
|
181
|
+
putExtra("RESULT_CODE", resultCode)
|
|
182
|
+
putExtra("RESULT_DATA", data)
|
|
183
|
+
putExtra("OUTPUT_PATH", currentOutputFile?.absolutePath)
|
|
184
|
+
putExtra("WIDTH", metrics.widthPixels)
|
|
185
|
+
putExtra("HEIGHT", metrics.heightPixels)
|
|
186
|
+
putExtra("DPI", metrics.densityDpi)
|
|
187
|
+
putExtra("INCLUDE_AUDIO", includeAudioStream)
|
|
188
|
+
putExtra("QUALITY", qualitySetting)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
startTime = System.currentTimeMillis()
|
|
192
|
+
context.startForegroundService(serviceIntent)
|
|
193
|
+
|
|
194
|
+
// Resolve startRecordingAsync — the caller now awaits stopRecordingAsync.
|
|
195
|
+
promise?.resolve(null)
|
|
196
|
+
pendingPromise = null
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
package expo.modules.screenrecorder
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.records.Field
|
|
4
|
+
import expo.modules.kotlin.records.Record
|
|
5
|
+
|
|
6
|
+
class RecordingOptions : Record {
|
|
7
|
+
@Field
|
|
8
|
+
val durationLimit: Double? = null
|
|
9
|
+
|
|
10
|
+
@Field
|
|
11
|
+
val quality: String? = "high"
|
|
12
|
+
|
|
13
|
+
@Field
|
|
14
|
+
val includeAudio: Boolean? = false
|
|
15
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
package expo.modules.screenrecorder
|
|
2
|
+
|
|
3
|
+
import android.app.*
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.hardware.display.DisplayManager
|
|
7
|
+
import android.hardware.display.VirtualDisplay
|
|
8
|
+
import android.media.MediaRecorder
|
|
9
|
+
import android.media.projection.MediaProjection
|
|
10
|
+
import android.media.projection.MediaProjectionManager
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.IBinder
|
|
13
|
+
import androidx.core.app.NotificationCompat
|
|
14
|
+
|
|
15
|
+
import java.io.File
|
|
16
|
+
|
|
17
|
+
class ScreenCaptureService : Service() {
|
|
18
|
+
|
|
19
|
+
private var mediaProjection: MediaProjection? = null
|
|
20
|
+
private var virtualDisplay: VirtualDisplay? = null
|
|
21
|
+
private var mediaRecorder: MediaRecorder? = null
|
|
22
|
+
private var isRecording = false
|
|
23
|
+
|
|
24
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
25
|
+
|
|
26
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
27
|
+
val resultCode = intent?.getIntExtra("RESULT_CODE", Activity.RESULT_CANCELED)
|
|
28
|
+
?: Activity.RESULT_CANCELED
|
|
29
|
+
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
30
|
+
intent?.getParcelableExtra("RESULT_DATA", Intent::class.java)
|
|
31
|
+
} else {
|
|
32
|
+
@Suppress("DEPRECATION")
|
|
33
|
+
intent?.getParcelableExtra("RESULT_DATA")
|
|
34
|
+
}
|
|
35
|
+
val outputPath = intent?.getStringExtra("OUTPUT_PATH") ?: ""
|
|
36
|
+
val width = intent?.getIntExtra("WIDTH", 720) ?: 720
|
|
37
|
+
val height = intent?.getIntExtra("HEIGHT", 1280) ?: 1280
|
|
38
|
+
val dpi = intent?.getIntExtra("DPI", 1) ?: 1
|
|
39
|
+
val includeAudio = intent?.getBooleanExtra("INCLUDE_AUDIO", false) ?: false
|
|
40
|
+
val quality = intent?.getStringExtra("QUALITY") ?: "high"
|
|
41
|
+
|
|
42
|
+
startForegroundNotification()
|
|
43
|
+
|
|
44
|
+
if (resultData == null) {
|
|
45
|
+
stopSelf()
|
|
46
|
+
return START_NOT_STICKY
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
val mpManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
|
|
50
|
+
mediaProjection = mpManager.getMediaProjection(resultCode, resultData)
|
|
51
|
+
|
|
52
|
+
setupMediaRecorder(outputPath, width, height, includeAudio, quality)
|
|
53
|
+
|
|
54
|
+
virtualDisplay = mediaProjection?.createVirtualDisplay(
|
|
55
|
+
"ExpoScreenCapture",
|
|
56
|
+
width,
|
|
57
|
+
height,
|
|
58
|
+
dpi,
|
|
59
|
+
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
|
60
|
+
mediaRecorder?.surface,
|
|
61
|
+
null,
|
|
62
|
+
null
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
mediaRecorder?.start()
|
|
67
|
+
isRecording = true
|
|
68
|
+
} catch (e: Exception) {
|
|
69
|
+
e.printStackTrace()
|
|
70
|
+
stopSelf()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return START_NOT_STICKY
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun setupMediaRecorder(
|
|
77
|
+
path: String,
|
|
78
|
+
width: Int,
|
|
79
|
+
height: Int,
|
|
80
|
+
includeAudio: Boolean,
|
|
81
|
+
quality: String
|
|
82
|
+
) {
|
|
83
|
+
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
84
|
+
MediaRecorder(this)
|
|
85
|
+
} else {
|
|
86
|
+
@Suppress("DEPRECATION")
|
|
87
|
+
MediaRecorder()
|
|
88
|
+
}.apply {
|
|
89
|
+
if (includeAudio) {
|
|
90
|
+
setAudioSource(MediaRecorder.AudioSource.MIC)
|
|
91
|
+
}
|
|
92
|
+
setVideoSource(MediaRecorder.VideoSource.SURFACE)
|
|
93
|
+
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
|
94
|
+
setOutputFile(File(path).absolutePath)
|
|
95
|
+
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
|
|
96
|
+
if (includeAudio) {
|
|
97
|
+
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
|
98
|
+
}
|
|
99
|
+
setVideoSize(width, height)
|
|
100
|
+
setVideoEncodingBitRate(
|
|
101
|
+
when (quality) {
|
|
102
|
+
"low" -> 2 * 1024 * 1024
|
|
103
|
+
"medium" -> 4 * 1024 * 1024
|
|
104
|
+
else -> 8 * 1024 * 1024
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
setVideoFrameRate(30)
|
|
108
|
+
prepare()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun startForegroundNotification() {
|
|
113
|
+
val channelId = "screen_record_channel"
|
|
114
|
+
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
115
|
+
|
|
116
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
117
|
+
val channel = NotificationChannel(
|
|
118
|
+
channelId,
|
|
119
|
+
"Screen Recording",
|
|
120
|
+
NotificationManager.IMPORTANCE_LOW
|
|
121
|
+
)
|
|
122
|
+
notificationManager.createNotificationChannel(channel)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
val notification = NotificationCompat.Builder(this, channelId)
|
|
126
|
+
.setContentTitle("Screen Recording Active")
|
|
127
|
+
.setContentText("Capturing screen...")
|
|
128
|
+
.setSmallIcon(android.R.drawable.presence_video_online)
|
|
129
|
+
.setOngoing(true)
|
|
130
|
+
.build()
|
|
131
|
+
|
|
132
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
133
|
+
startForeground(
|
|
134
|
+
101,
|
|
135
|
+
notification,
|
|
136
|
+
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
|
|
137
|
+
)
|
|
138
|
+
} else {
|
|
139
|
+
startForeground(101, notification)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
override fun onDestroy() {
|
|
144
|
+
if (isRecording) {
|
|
145
|
+
try { mediaRecorder?.stop() } catch (_: Exception) {}
|
|
146
|
+
isRecording = false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
mediaRecorder?.release()
|
|
150
|
+
mediaRecorder = null
|
|
151
|
+
|
|
152
|
+
virtualDisplay?.release() // release display before stopping projection
|
|
153
|
+
virtualDisplay = null
|
|
154
|
+
|
|
155
|
+
mediaProjection?.stop()
|
|
156
|
+
mediaProjection = null
|
|
157
|
+
|
|
158
|
+
super.onDestroy()
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// import { NativeModule, requireNativeModule } from 'expo';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// import { ExpoScreenRecorderModuleEvents } from './ExpoScreenRecorder.types';
|
|
5
|
+
// declare class ExpoScreenRecorderModule extends NativeModule<ExpoScreenRecorderModuleEvents> {
|
|
6
|
+
// hello(): string;
|
|
7
|
+
// setValueAsync(value: string): Promise<void>;
|
|
8
|
+
// }
|
|
9
|
+
// export default requireNativeModule<ExpoScreenRecorderModule>('ExpoScreenRecorder');
|
|
10
|
+
var expo_1 = require("expo");
|
|
11
|
+
exports.default = (0, expo_1.requireNativeModule)('ExpoScreenRecorder');
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __extends = (this && this.__extends) || (function () {
|
|
3
|
+
var extendStatics = function (d, b) {
|
|
4
|
+
extendStatics = Object.setPrototypeOf ||
|
|
5
|
+
({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
|
|
6
|
+
function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };
|
|
7
|
+
return extendStatics(d, b);
|
|
8
|
+
};
|
|
9
|
+
return function (d, b) {
|
|
10
|
+
if (typeof b !== "function" && b !== null)
|
|
11
|
+
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null");
|
|
12
|
+
extendStatics(d, b);
|
|
13
|
+
function __() { this.constructor = d; }
|
|
14
|
+
d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
|
|
15
|
+
};
|
|
16
|
+
})();
|
|
17
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
18
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
19
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
20
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
21
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
22
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
23
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
27
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
28
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
29
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
30
|
+
function step(op) {
|
|
31
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
32
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
33
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
34
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
35
|
+
switch (op[0]) {
|
|
36
|
+
case 0: case 1: t = op; break;
|
|
37
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
38
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
39
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
40
|
+
default:
|
|
41
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
42
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
43
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
44
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
45
|
+
if (t[2]) _.ops.pop();
|
|
46
|
+
_.trys.pop(); continue;
|
|
47
|
+
}
|
|
48
|
+
op = body.call(thisArg, _);
|
|
49
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
50
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
var expo_1 = require("expo");
|
|
55
|
+
var ExpoScreenRecorderModule = /** @class */ (function (_super) {
|
|
56
|
+
__extends(ExpoScreenRecorderModule, _super);
|
|
57
|
+
function ExpoScreenRecorderModule() {
|
|
58
|
+
return _super !== null && _super.apply(this, arguments) || this;
|
|
59
|
+
}
|
|
60
|
+
ExpoScreenRecorderModule.prototype.hello = function () {
|
|
61
|
+
return 'Hello world! 👋';
|
|
62
|
+
};
|
|
63
|
+
ExpoScreenRecorderModule.prototype.setValueAsync = function (value) {
|
|
64
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
65
|
+
return __generator(this, function (_a) {
|
|
66
|
+
this.emit('onChange', { value: value });
|
|
67
|
+
return [2 /*return*/];
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
return ExpoScreenRecorderModule;
|
|
72
|
+
}(expo_1.NativeModule));
|
|
73
|
+
exports.default = (0, expo_1.registerWebModule)(ExpoScreenRecorderModule, 'ExpoScreenRecorderModule');
|
package/build/index.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Reexport the native module. On web, it will be resolved to ExpoScreenRecorderModule.web.ts
|
|
3
|
+
// and on native platforms to ExpoScreenRecorderModule.ts
|
|
4
|
+
// export { default } from './ExpoScreenRecorderModule';
|
|
5
|
+
// export * from './ExpoScreenRecorder.types';
|
|
6
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
7
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
8
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
9
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
10
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
11
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
12
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
var __generator = (this && this.__generator) || function (thisArg, body) {
|
|
16
|
+
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
|
|
17
|
+
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
|
|
18
|
+
function verb(n) { return function (v) { return step([n, v]); }; }
|
|
19
|
+
function step(op) {
|
|
20
|
+
if (f) throw new TypeError("Generator is already executing.");
|
|
21
|
+
while (g && (g = 0, op[0] && (_ = 0)), _) try {
|
|
22
|
+
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
|
|
23
|
+
if (y = 0, t) op = [op[0] & 2, t.value];
|
|
24
|
+
switch (op[0]) {
|
|
25
|
+
case 0: case 1: t = op; break;
|
|
26
|
+
case 4: _.label++; return { value: op[1], done: false };
|
|
27
|
+
case 5: _.label++; y = op[1]; op = [0]; continue;
|
|
28
|
+
case 7: op = _.ops.pop(); _.trys.pop(); continue;
|
|
29
|
+
default:
|
|
30
|
+
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
|
|
31
|
+
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
|
|
32
|
+
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
|
|
33
|
+
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
|
|
34
|
+
if (t[2]) _.ops.pop();
|
|
35
|
+
_.trys.pop(); continue;
|
|
36
|
+
}
|
|
37
|
+
op = body.call(thisArg, _);
|
|
38
|
+
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
|
|
39
|
+
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.isAvailableAsync = isAvailableAsync;
|
|
44
|
+
exports.startRecordingAsync = startRecordingAsync;
|
|
45
|
+
exports.stopRecordingAsync = stopRecordingAsync;
|
|
46
|
+
var ExpoScreenRecorderModule_1 = require("./ExpoScreenRecorderModule");
|
|
47
|
+
function isAvailableAsync() {
|
|
48
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
49
|
+
return __generator(this, function (_a) {
|
|
50
|
+
switch (_a.label) {
|
|
51
|
+
case 0: return [4 /*yield*/, ExpoScreenRecorderModule_1.default.isAvailableAsync()];
|
|
52
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function startRecordingAsync() {
|
|
58
|
+
return __awaiter(this, arguments, void 0, function (options) {
|
|
59
|
+
if (options === void 0) { options = {}; }
|
|
60
|
+
return __generator(this, function (_a) {
|
|
61
|
+
switch (_a.label) {
|
|
62
|
+
case 0: return [4 /*yield*/, ExpoScreenRecorderModule_1.default.startRecordingAsync(options)];
|
|
63
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function stopRecordingAsync() {
|
|
69
|
+
return __awaiter(this, void 0, void 0, function () {
|
|
70
|
+
return __generator(this, function (_a) {
|
|
71
|
+
switch (_a.label) {
|
|
72
|
+
case 0: return [4 /*yield*/, ExpoScreenRecorderModule_1.default.stopRecordingAsync()];
|
|
73
|
+
case 1: return [2 /*return*/, _a.sent()];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
const { defineConfig } = require('eslint/config');
|
|
2
|
+
const universe = require('eslint-config-universe/flat/native');
|
|
3
|
+
const universeWeb = require('eslint-config-universe/flat/web');
|
|
4
|
+
|
|
5
|
+
module.exports = defineConfig([{ ignores: ['build'] }, ...universe, ...universeWeb]);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Pod::Spec.new do |s|
|
|
2
|
+
s.name = 'ExpoScreenRecorder'
|
|
3
|
+
s.version = '1.0.0'
|
|
4
|
+
s.summary = 'High performance Expo module Screen Recorder'
|
|
5
|
+
s.description = 'Screen Recorder for Expo'
|
|
6
|
+
s.author = ''
|
|
7
|
+
s.homepage = 'https://docs.expo.dev/modules/'
|
|
8
|
+
s.platforms = {
|
|
9
|
+
:ios => '16.4',
|
|
10
|
+
:tvos => '16.4'
|
|
11
|
+
}
|
|
12
|
+
s.source = { git: '' }
|
|
13
|
+
s.static_framework = true
|
|
14
|
+
|
|
15
|
+
s.dependency 'ExpoModulesCore'
|
|
16
|
+
|
|
17
|
+
# Swift/Objective-C compatibility
|
|
18
|
+
s.pod_target_xcconfig = {
|
|
19
|
+
'DEFINES_MODULE' => 'YES',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
23
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import ReplayKit
|
|
3
|
+
|
|
4
|
+
public class ExpoScreenRecorderModule: Module {
|
|
5
|
+
private let recorder = RPScreenRecorder.shared()
|
|
6
|
+
private var startTime: Date?
|
|
7
|
+
private var durationTimer: Timer?
|
|
8
|
+
private var pendingPromise: Promise?
|
|
9
|
+
private var currentOutputURL: URL?
|
|
10
|
+
|
|
11
|
+
deinit {
|
|
12
|
+
durationTimer?.invalidate()
|
|
13
|
+
durationTimer = nil
|
|
14
|
+
pendingPromise?.reject("ERR_MODULE_DESTROYED", "Screen recorder module was deallocated before the operation completed.")
|
|
15
|
+
pendingPromise = nil
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
public func definition() -> ModuleDefinition {
|
|
19
|
+
Name("ExpoScreenRecorder")
|
|
20
|
+
|
|
21
|
+
AsyncFunction("isAvailableAsync") { () -> Bool in
|
|
22
|
+
return self.recorder.isAvailable
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
AsyncFunction("startRecordingAsync") { (options: RecordingOptions, promise: Promise) in
|
|
26
|
+
guard self.recorder.isAvailable else {
|
|
27
|
+
promise.reject("ERR_NOT_AVAILABLE", "Screen recorder is not available on this device.")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
guard !self.recorder.isRecording else {
|
|
32
|
+
promise.reject("ERR_ALREADY_RECORDING", "A recording is already in progress.")
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
self.recorder.isMicrophoneEnabled = options.includeAudio ?? false
|
|
37
|
+
|
|
38
|
+
let cacheDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
|
|
39
|
+
let uniqueFilename = "REC_\(UUID().uuidString).mp4"
|
|
40
|
+
self.currentOutputURL = cacheDirectory.appendingPathComponent(uniqueFilename)
|
|
41
|
+
self.pendingPromise = promise
|
|
42
|
+
|
|
43
|
+
self.recorder.startRecording { [weak self] error in
|
|
44
|
+
guard let self = self else { return }
|
|
45
|
+
|
|
46
|
+
if let error = error {
|
|
47
|
+
self.pendingPromise?.reject("ERR_START_FAILED", error.localizedDescription)
|
|
48
|
+
self.pendingPromise = nil
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
self.startTime = Date()
|
|
53
|
+
self.pendingPromise?.resolve(nil)
|
|
54
|
+
self.pendingPromise = nil
|
|
55
|
+
|
|
56
|
+
if let limit = options.durationLimit, limit > 0 {
|
|
57
|
+
DispatchQueue.main.async {
|
|
58
|
+
self.durationTimer = Timer.scheduledTimer(
|
|
59
|
+
withTimeInterval: limit,
|
|
60
|
+
repeats: false
|
|
61
|
+
) { [weak self] _ in
|
|
62
|
+
self?.stopRecordingInternal()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
AsyncFunction("stopRecordingAsync") { (promise: Promise) in
|
|
70
|
+
self.stopRecordingWithPromise(promise: promise)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func stopRecordingWithPromise(promise: Promise) {
|
|
75
|
+
durationTimer?.invalidate()
|
|
76
|
+
durationTimer = nil
|
|
77
|
+
|
|
78
|
+
guard recorder.isRecording else {
|
|
79
|
+
promise.reject("ERR_NOT_RECORDING", "No active recording to stop.")
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
guard let outputURL = currentOutputURL else {
|
|
84
|
+
promise.reject("ERR_NO_FILE", "Output file URL was not set.")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if #available(iOS 17.0, *) {
|
|
89
|
+
// stopRecording(withOutput:) is deprecated in iOS 17.
|
|
90
|
+
// Full migration to startCapture(handler:) for buffer-level control
|
|
91
|
+
// is recommended for a future release. For now, suppress the warning.
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#if swift(>=5.9)
|
|
95
|
+
// Suppress deprecation for the withOutput variant — still functional through iOS 17+
|
|
96
|
+
// and is the only single-call file-output API ReplayKit exposes publicly.
|
|
97
|
+
#endif
|
|
98
|
+
|
|
99
|
+
self.recorder.stopRecording(withOutput: outputURL) { [weak self] error in
|
|
100
|
+
guard let self = self else { return }
|
|
101
|
+
|
|
102
|
+
if let error = error {
|
|
103
|
+
promise.reject("ERR_STOP_FAILED", error.localizedDescription)
|
|
104
|
+
self.cleanup()
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let durationSec = Date().timeIntervalSince(self.startTime ?? Date())
|
|
109
|
+
|
|
110
|
+
// Use "file://" prefix to match Android's uri format consistently
|
|
111
|
+
let uri = "file://\(outputURL.path)"
|
|
112
|
+
|
|
113
|
+
promise.resolve([
|
|
114
|
+
"uri": uri,
|
|
115
|
+
"duration": durationSec
|
|
116
|
+
] as [String: Any])
|
|
117
|
+
|
|
118
|
+
self.cleanup()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private func stopRecordingInternal() {
|
|
123
|
+
durationTimer?.invalidate()
|
|
124
|
+
durationTimer = nil
|
|
125
|
+
|
|
126
|
+
guard let outputURL = currentOutputURL, recorder.isRecording else { return }
|
|
127
|
+
|
|
128
|
+
recorder.stopRecording(withOutput: outputURL) { [weak self] _ in
|
|
129
|
+
self?.cleanup()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func cleanup() {
|
|
134
|
+
durationTimer?.invalidate()
|
|
135
|
+
durationTimer = nil
|
|
136
|
+
startTime = nil
|
|
137
|
+
currentOutputURL = nil
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
struct RecordingOptions: Record {
|
|
142
|
+
@Field var durationLimit: Double?
|
|
143
|
+
// NOTE: ReplayKit does not expose bitrate/quality control via RPScreenRecorder.
|
|
144
|
+
// This field is accepted for API consistency with Android but has no effect on iOS.
|
|
145
|
+
@Field var quality: String?
|
|
146
|
+
@Field var includeAudio: Bool?
|
|
147
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-turbo-screen-recorder",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Expo Module For High Quality Screen Recorder",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "expo-module build",
|
|
9
|
+
"clean": "expo-module clean",
|
|
10
|
+
"lint": "expo-module lint",
|
|
11
|
+
"test": "expo-module test",
|
|
12
|
+
"prepare": "expo-module prepare",
|
|
13
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
14
|
+
"expo-module": "expo-module",
|
|
15
|
+
"open:ios": "xed example/ios",
|
|
16
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react-native",
|
|
20
|
+
"expo",
|
|
21
|
+
"expo-screen-recorder",
|
|
22
|
+
"ExpoScreenRecorder"
|
|
23
|
+
],
|
|
24
|
+
"repository": "https://github.com/tsnguyenducphuong/screen-recorder",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/tsnguyenducphuong/screen-recorder/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": "tsnguyenducphuong <ts.nguyenducphuong@gmail.com> (https://github.com/tsnguyenducphuong)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/tsnguyenducphuong/expo-screen-recorder#readme",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"expo-module-scripts": "^5.0.8"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"expo": "*",
|
|
36
|
+
"react": "*",
|
|
37
|
+
"react-native": "*"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ExpoScreenRecorderModuleEvents = {
|
|
2
|
+
onChange: (params: ChangeEventPayload) => void;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
export type ChangeEventPayload = {
|
|
6
|
+
value: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
export interface RecordingOptions {
|
|
11
|
+
durationLimit?: number;
|
|
12
|
+
quality?: 'low' | 'medium' | 'high';
|
|
13
|
+
includeAudio?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RecordingResult {
|
|
17
|
+
uri: string;
|
|
18
|
+
duration: number;
|
|
19
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// import { NativeModule, requireNativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
// import { ExpoScreenRecorderModuleEvents } from './ExpoScreenRecorder.types';
|
|
4
|
+
|
|
5
|
+
// declare class ExpoScreenRecorderModule extends NativeModule<ExpoScreenRecorderModuleEvents> {
|
|
6
|
+
// hello(): string;
|
|
7
|
+
// setValueAsync(value: string): Promise<void>;
|
|
8
|
+
// }
|
|
9
|
+
|
|
10
|
+
// export default requireNativeModule<ExpoScreenRecorderModule>('ExpoScreenRecorder');
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
import { NativeModule, requireNativeModule } from 'expo';
|
|
14
|
+
|
|
15
|
+
export interface RecordingOptions {
|
|
16
|
+
durationLimit?: number;
|
|
17
|
+
quality?: 'low' | 'medium' | 'high';
|
|
18
|
+
includeAudio?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RecordingResult {
|
|
22
|
+
uri: string;
|
|
23
|
+
duration: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
declare class ExpoScreenRecorderModule extends NativeModule {
|
|
27
|
+
isAvailableAsync(): Promise<boolean>;
|
|
28
|
+
startRecordingAsync(options: RecordingOptions): Promise<void>;
|
|
29
|
+
stopRecordingAsync(): Promise<RecordingResult>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default requireNativeModule<ExpoScreenRecorderModule>('ExpoScreenRecorder');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { registerWebModule, NativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
import { ExpoScreenRecorderModuleEvents } from './ExpoScreenRecorder.types';
|
|
4
|
+
|
|
5
|
+
class ExpoScreenRecorderModule extends NativeModule<ExpoScreenRecorderModuleEvents> {
|
|
6
|
+
hello() {
|
|
7
|
+
return 'Hello world! 👋';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async setValueAsync(value: string): Promise<void> {
|
|
11
|
+
this.emit('onChange', { value });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default registerWebModule(ExpoScreenRecorderModule, 'ExpoScreenRecorderModule');
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Reexport the native module. On web, it will be resolved to ExpoScreenRecorderModule.web.ts
|
|
2
|
+
// and on native platforms to ExpoScreenRecorderModule.ts
|
|
3
|
+
// export { default } from './ExpoScreenRecorderModule';
|
|
4
|
+
// export * from './ExpoScreenRecorder.types';
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import ExpoScreenRecorderModule from './ExpoScreenRecorderModule';
|
|
8
|
+
import type { RecordingOptions, RecordingResult } from './ExpoScreenRecorder.types';
|
|
9
|
+
|
|
10
|
+
export async function isAvailableAsync(): Promise<boolean> {
|
|
11
|
+
return await ExpoScreenRecorderModule.isAvailableAsync();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function startRecordingAsync(options: RecordingOptions = {}): Promise<void> {
|
|
15
|
+
return await ExpoScreenRecorderModule.startRecordingAsync(options);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function stopRecordingAsync(): Promise<RecordingResult> {
|
|
19
|
+
return await ExpoScreenRecorderModule.stopRecordingAsync();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type { RecordingOptions, RecordingResult };
|