@capgo/capacitor-screen-recorder 8.3.17 → 8.4.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/README.md +77 -4
- package/android/src/main/java/ee/forgr/plugin/screenrecorder/CapgoScrCast.kt +79 -7
- package/android/src/main/java/ee/forgr/plugin/screenrecorder/ScreenRecorderPlugin.java +46 -13
- package/android/src/main/java/ee/forgr/plugin/screenrecorder/service/CapgoRecorderService.kt +42 -6
- package/dist/docs.json +106 -0
- package/dist/esm/definitions.d.ts +41 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/ScreenRecorderPlugin/ScreenRecorderPlugin.swift +12 -1
- package/ios/Sources/ScreenRecorderPlugin/Wyler.swift +159 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
# Capacitor-screen-recorder
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
Record the device screen from your Capacitor app, with optional audio, for bug reports, tutorials and demos.
|
|
4
|
+
|
|
5
|
+
<a href="https://capgo.app/?ref=plugin_screen_recorder"><img src="https://capgo.app/readme-banner.svg?repo=Cap-go/capacitor-screen-recorder" alt="Capgo - Instant updates for Capacitor" /></a>
|
|
3
6
|
|
|
4
7
|
<div align="center">
|
|
5
|
-
<
|
|
6
|
-
<h2><a href="https://capgo.app/
|
|
8
|
+
<p><b>Capgo</b>: push fixes to your Capacitor users in minutes, build signed iOS and Android apps without a Mac, and roll back in one click.</p>
|
|
9
|
+
<h2><a href="https://capgo.app/register/?ref=plugin_screen_recorder">➡️ Get started for free</a></h2>
|
|
10
|
+
<p>14-day unlimited free trial. No credit card required</p>
|
|
11
|
+
<p><a href="https://capgo.app/consulting/?ref=plugin_screen_recorder">Missing a feature? We'll build the plugin for you 💪</a></p>
|
|
7
12
|
</div>
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="https://raw.githubusercontent.com/Cap-go/capacitor-screen-recorder/main/assets/github-social-preview.png" alt="@capgo/capacitor-screen-recorder for Capacitor apps" width="300" />
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Key features
|
|
19
|
+
|
|
20
|
+
- **Start**: `start()` begins recording after the system consent prompt.
|
|
21
|
+
- **Stop**: `stop()` ends the recording.
|
|
22
|
+
- **Audio**: set `recordAudio` to capture sound with the video.
|
|
23
|
+
- **Native capture**: ReplayKit on iOS, saved to the Photos library, and MediaProjection on Android.
|
|
24
|
+
- **Platforms**: iOS and Android. Not available on web.
|
|
9
25
|
|
|
10
26
|
## Documentation
|
|
11
27
|
|
|
@@ -103,6 +119,8 @@ No configuration required for this plugin.
|
|
|
103
119
|
|
|
104
120
|
* [`start(...)`](#start)
|
|
105
121
|
* [`stop()`](#stop)
|
|
122
|
+
* [`addListener('onStopped', ...)`](#addlisteneronstopped-)
|
|
123
|
+
* [`removeAllListeners()`](#removealllisteners)
|
|
106
124
|
* [`getPluginVersion()`](#getpluginversion)
|
|
107
125
|
* [Interfaces](#interfaces)
|
|
108
126
|
* [Type Aliases](#type-aliases)
|
|
@@ -154,6 +172,39 @@ recording. On Android, the video is saved directly to the gallery.
|
|
|
154
172
|
--------------------
|
|
155
173
|
|
|
156
174
|
|
|
175
|
+
### addListener('onStopped', ...)
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
addListener(eventName: 'onStopped', listenerFunc: (event: ScreenRecorderStoppedEvent) => void) => Promise<PluginListenerHandle>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Listen for recordings that ended without {@link stop} being called.
|
|
182
|
+
|
|
183
|
+
| Param | Type | Description |
|
|
184
|
+
| ------------------ | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
185
|
+
| **`eventName`** | <code>'onStopped'</code> | - `onStopped` |
|
|
186
|
+
| **`listenerFunc`** | <code>(event: <a href="#screenrecorderstoppedevent">ScreenRecorderStoppedEvent</a>) => void</code> | - Called with a `ScreenRecorderStoppedEvent` (`url` and optional `error`) when the recording ended outside `stop()`. |
|
|
187
|
+
|
|
188
|
+
**Returns:** <code>Promise<<a href="#pluginlistenerhandle">PluginListenerHandle</a>></code>
|
|
189
|
+
|
|
190
|
+
**Since:** 8.4.0
|
|
191
|
+
|
|
192
|
+
--------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
### removeAllListeners()
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
removeAllListeners() => Promise<void>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Remove all listeners registered with {@link addListener}.
|
|
202
|
+
|
|
203
|
+
**Since:** 8.4.0
|
|
204
|
+
|
|
205
|
+
--------------------
|
|
206
|
+
|
|
207
|
+
|
|
157
208
|
### getPluginVersion()
|
|
158
209
|
|
|
159
210
|
```typescript
|
|
@@ -182,6 +233,28 @@ Options for {@link ScreenRecorderPlugin.start}.
|
|
|
182
233
|
| **`format`** | <code><a href="#screenrecordervideoformat">ScreenRecorderVideoFormat</a> \| 'video/mp4' \| 'video/quicktime'</code> | Video container format for the saved recording. Accepts `mp4`, `mov`, or MIME types `video/mp4` and `video/quicktime`. iOS supports both `mp4` and `mov`. Android records MPEG-4 (`.mp4`) regardless of this value. | <code>'mp4'</code> | 8.3.0 |
|
|
183
234
|
|
|
184
235
|
|
|
236
|
+
#### PluginListenerHandle
|
|
237
|
+
|
|
238
|
+
| Prop | Type |
|
|
239
|
+
| ------------ | ----------------------------------------- |
|
|
240
|
+
| **`remove`** | <code>() => Promise<void></code> |
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
#### ScreenRecorderStoppedEvent
|
|
244
|
+
|
|
245
|
+
Payload for the `onStopped` event.
|
|
246
|
+
|
|
247
|
+
Fired when a recording ends without {@link ScreenRecorderPlugin.stop} being
|
|
248
|
+
called: the user stopped capture from the system UI (Android "Stop sharing"),
|
|
249
|
+
the recorder hit a limit (max duration or max file size), or the system
|
|
250
|
+
interrupted capture (iOS, for example an incoming call).
|
|
251
|
+
|
|
252
|
+
| Prop | Type | Description | Since |
|
|
253
|
+
| ----------- | ------------------- | ------------------------------------------------------------------------------------------------------------------ | ----- |
|
|
254
|
+
| **`url`** | <code>string</code> | Local URI of the finished recording (`file://` on Android). Empty string when the file path could not be resolved. | 8.4.0 |
|
|
255
|
+
| **`error`** | <code>string</code> | Error message when the recording ended with a failure. | 8.4.0 |
|
|
256
|
+
|
|
257
|
+
|
|
185
258
|
### Type Aliases
|
|
186
259
|
|
|
187
260
|
|
|
@@ -38,6 +38,13 @@ class CapgoScrCast private constructor(
|
|
|
38
38
|
private var serviceBinder: CapgoRecorderService? = null
|
|
39
39
|
private var outputFile: File? = null
|
|
40
40
|
private var startListener: StartListener? = null
|
|
41
|
+
private var stopRequested = false
|
|
42
|
+
private var sessionGeneration = 0L
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Invoked when a recording ends without {@link #stopRecording()} having been called.
|
|
46
|
+
*/
|
|
47
|
+
var externalStopListener: ExternalStopListener? = null
|
|
41
48
|
private var receiverRegistered = false
|
|
42
49
|
|
|
43
50
|
private val metrics by lazy {
|
|
@@ -68,11 +75,30 @@ class CapgoScrCast private constructor(
|
|
|
68
75
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
69
76
|
when (intent?.action) {
|
|
70
77
|
STATE_RECORDING -> {
|
|
78
|
+
val broadcastGeneration =
|
|
79
|
+
intent?.getLongExtra(EXTRA_SESSION_GENERATION, -1L) ?: -1L
|
|
80
|
+
if (broadcastGeneration != sessionGeneration) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
if (stopRequested) {
|
|
84
|
+
startListener?.onFailed(
|
|
85
|
+
IllegalStateException("Recording stopped before it started"),
|
|
86
|
+
)
|
|
87
|
+
startListener = null
|
|
88
|
+
broadcaster.sendBroadcast(Intent(Action.Stop.name))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
71
91
|
startListener?.onStarted()
|
|
72
92
|
startListener = null
|
|
73
93
|
}
|
|
74
94
|
STATE_IDLE -> {
|
|
95
|
+
val broadcastGeneration =
|
|
96
|
+
intent?.getLongExtra(EXTRA_SESSION_GENERATION, -1L) ?: -1L
|
|
97
|
+
if (broadcastGeneration != sessionGeneration) {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
75
100
|
val startPending = startListener != null
|
|
101
|
+
val sessionActive = recordingSession != null
|
|
76
102
|
val error = intent.getSerializableExtra(EXTRA_ERROR) as? Throwable
|
|
77
103
|
if (error != null) {
|
|
78
104
|
startListener?.onFailed(error)
|
|
@@ -81,7 +107,11 @@ class CapgoScrCast private constructor(
|
|
|
81
107
|
IllegalStateException("Recording stopped before it started"),
|
|
82
108
|
)
|
|
83
109
|
}
|
|
84
|
-
cleanupSession(deleteEmptyOutput = startPending || error != null)
|
|
110
|
+
val savedPath = cleanupSession(deleteEmptyOutput = startPending || error != null)
|
|
111
|
+
if (!startPending && sessionActive && !stopRequested) {
|
|
112
|
+
externalStopListener?.onExternalStop(savedPath, error?.message)
|
|
113
|
+
}
|
|
114
|
+
stopRequested = false
|
|
85
115
|
}
|
|
86
116
|
}
|
|
87
117
|
}
|
|
@@ -90,6 +120,10 @@ class CapgoScrCast private constructor(
|
|
|
90
120
|
private val requestRuntimePermissions = activity.registerForActivityResult(
|
|
91
121
|
ActivityResultContracts.RequestMultiplePermissions(),
|
|
92
122
|
) { _ ->
|
|
123
|
+
if (startListener == null || stopRequested) {
|
|
124
|
+
abandonCanceledPendingStart()
|
|
125
|
+
return@registerForActivityResult
|
|
126
|
+
}
|
|
93
127
|
if (requiredRuntimePermissions().all { isPermissionGranted(it) }) {
|
|
94
128
|
startProjection.launch(Unit)
|
|
95
129
|
} else {
|
|
@@ -98,6 +132,10 @@ class CapgoScrCast private constructor(
|
|
|
98
132
|
}
|
|
99
133
|
|
|
100
134
|
private val startProjection = activity.registerForActivityResult(CapgoRecordScreen()) { result ->
|
|
135
|
+
if (startListener == null || stopRequested) {
|
|
136
|
+
abandonCanceledPendingStart()
|
|
137
|
+
return@registerForActivityResult
|
|
138
|
+
}
|
|
101
139
|
if (result.resultCode != Activity.RESULT_OK) {
|
|
102
140
|
notifyStartFailed(IllegalStateException("Screen capture permission denied"))
|
|
103
141
|
return@registerForActivityResult
|
|
@@ -155,9 +193,23 @@ class CapgoScrCast private constructor(
|
|
|
155
193
|
}
|
|
156
194
|
|
|
157
195
|
fun stopRecording() {
|
|
196
|
+
stopRequested = true
|
|
197
|
+
if (recordingSession == null && startListener != null) {
|
|
198
|
+
abandonCanceledPendingStart()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
158
201
|
broadcaster.sendBroadcast(Intent(Action.Stop.name))
|
|
159
202
|
}
|
|
160
203
|
|
|
204
|
+
private fun abandonCanceledPendingStart() {
|
|
205
|
+
if (startListener == null) {
|
|
206
|
+
stopRequested = false
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
notifyStartFailed(IllegalStateException("Recording stopped before it started"))
|
|
210
|
+
stopRequested = false
|
|
211
|
+
}
|
|
212
|
+
|
|
161
213
|
private fun resolveVideoSize(options: Options): Options {
|
|
162
214
|
return VideoSizeResolver.applyTo(options, metrics.widthPixels, metrics.heightPixels)
|
|
163
215
|
}
|
|
@@ -168,6 +220,11 @@ class CapgoScrCast private constructor(
|
|
|
168
220
|
}
|
|
169
221
|
|
|
170
222
|
private fun startService(result: ActivityResult, file: File) {
|
|
223
|
+
if (startListener == null || stopRequested) {
|
|
224
|
+
abandonCanceledPendingStart()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
sessionGeneration += 1
|
|
171
228
|
outputFile = file
|
|
172
229
|
val session = Intent(activity, CapgoRecorderService::class.java).apply {
|
|
173
230
|
putExtra("code", result.resultCode)
|
|
@@ -177,6 +234,7 @@ class CapgoScrCast private constructor(
|
|
|
177
234
|
putExtra("dpi", dpi)
|
|
178
235
|
putExtra("rotation", activity.windowManager.defaultDisplay.rotation)
|
|
179
236
|
putExtra("recordAudio", recordAudio)
|
|
237
|
+
putExtra(EXTRA_SESSION_GENERATION, sessionGeneration)
|
|
180
238
|
}
|
|
181
239
|
recordingSession = session
|
|
182
240
|
|
|
@@ -201,6 +259,7 @@ class CapgoScrCast private constructor(
|
|
|
201
259
|
unregisterRecordingReceiver()
|
|
202
260
|
recordingSession = null
|
|
203
261
|
outputFile = null
|
|
262
|
+
stopRequested = false
|
|
204
263
|
notifyStartFailed(error)
|
|
205
264
|
}
|
|
206
265
|
|
|
@@ -216,7 +275,7 @@ class CapgoScrCast private constructor(
|
|
|
216
275
|
receiverRegistered = false
|
|
217
276
|
}
|
|
218
277
|
|
|
219
|
-
private fun cleanupSession(deleteEmptyOutput: Boolean = false) {
|
|
278
|
+
private fun cleanupSession(deleteEmptyOutput: Boolean = false): String? {
|
|
220
279
|
startListener = null
|
|
221
280
|
unregisterRecordingReceiver()
|
|
222
281
|
|
|
@@ -229,17 +288,24 @@ class CapgoScrCast private constructor(
|
|
|
229
288
|
recordingSession?.let { activity.stopService(it) }
|
|
230
289
|
recordingSession = null
|
|
231
290
|
|
|
232
|
-
|
|
291
|
+
val savedPath = outputFile?.absolutePath
|
|
292
|
+
val deliverablePath = savedPath?.let { path ->
|
|
293
|
+
val file = File(path)
|
|
233
294
|
if (file.isFile && file.length() > 0L) {
|
|
234
|
-
MediaScannerConnection.scanFile(activity, arrayOf(
|
|
235
|
-
Log.i("CapgoScreenRecorder", "Saved recording: $
|
|
295
|
+
MediaScannerConnection.scanFile(activity, arrayOf(path), null) { scanPath, uri ->
|
|
296
|
+
Log.i("CapgoScreenRecorder", "Saved recording: $scanPath uri=$uri")
|
|
297
|
+
}
|
|
298
|
+
path
|
|
299
|
+
} else {
|
|
300
|
+
if (deleteEmptyOutput && file.exists()) {
|
|
301
|
+
runCatching { file.delete() }
|
|
236
302
|
}
|
|
237
|
-
|
|
238
|
-
runCatching { file.delete() }
|
|
303
|
+
null
|
|
239
304
|
}
|
|
240
305
|
}
|
|
241
306
|
outputFile = null
|
|
242
307
|
CapgoRecordingCoordinator.release()
|
|
308
|
+
return deliverablePath
|
|
243
309
|
}
|
|
244
310
|
|
|
245
311
|
interface StartListener {
|
|
@@ -247,7 +313,13 @@ class CapgoScrCast private constructor(
|
|
|
247
313
|
fun onFailed(error: Throwable)
|
|
248
314
|
}
|
|
249
315
|
|
|
316
|
+
fun interface ExternalStopListener {
|
|
317
|
+
fun onExternalStop(path: String?, error: String?)
|
|
318
|
+
}
|
|
319
|
+
|
|
250
320
|
companion object {
|
|
321
|
+
const val EXTRA_SESSION_GENERATION = "capgoSessionGeneration"
|
|
322
|
+
|
|
251
323
|
@JvmStatic
|
|
252
324
|
fun use(activity: ComponentActivity, recordAudio: Boolean): CapgoScrCast {
|
|
253
325
|
return CapgoScrCast(activity, recordAudio)
|
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
package ee.forgr.plugin.screenrecorder;
|
|
2
2
|
|
|
3
|
+
import android.net.Uri;
|
|
3
4
|
import com.getcapacitor.JSObject;
|
|
4
5
|
import com.getcapacitor.Plugin;
|
|
5
6
|
import com.getcapacitor.PluginCall;
|
|
6
7
|
import com.getcapacitor.PluginMethod;
|
|
7
8
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
8
9
|
import dev.bmcreations.scrcast.config.Options;
|
|
10
|
+
import java.io.File;
|
|
9
11
|
|
|
10
12
|
@CapacitorPlugin(name = "ScreenRecorder")
|
|
11
13
|
public class ScreenRecorderPlugin extends Plugin {
|
|
12
14
|
|
|
13
|
-
private final String pluginVersion = "8.
|
|
15
|
+
private final String pluginVersion = "8.4.0";
|
|
14
16
|
|
|
15
17
|
private CapgoScrCast videoRecorder;
|
|
16
18
|
private CapgoScrCast audioRecorder;
|
|
17
|
-
private
|
|
19
|
+
private CapgoScrCast pendingRecorder = null;
|
|
20
|
+
private CapgoScrCast activeRecorder = null;
|
|
18
21
|
|
|
19
22
|
@Override
|
|
20
23
|
public void load() {
|
|
@@ -23,17 +26,35 @@ public class ScreenRecorderPlugin extends Plugin {
|
|
|
23
26
|
final Options options = new Options();
|
|
24
27
|
videoRecorder.updateOptions(options);
|
|
25
28
|
audioRecorder.updateOptions(options);
|
|
29
|
+
|
|
30
|
+
final CapgoScrCast.ExternalStopListener externalStopListener = new CapgoScrCast.ExternalStopListener() {
|
|
31
|
+
@Override
|
|
32
|
+
public void onExternalStop(final String path, final String error) {
|
|
33
|
+
if (activeRecorder == null) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
activeRecorder = null;
|
|
37
|
+
final JSObject ret = new JSObject();
|
|
38
|
+
ret.put("url", path != null ? Uri.fromFile(new File(path)).toString() : "");
|
|
39
|
+
if (error != null) {
|
|
40
|
+
ret.put("error", error);
|
|
41
|
+
}
|
|
42
|
+
notifyListeners("onStopped", ret);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
videoRecorder.setExternalStopListener(externalStopListener);
|
|
46
|
+
audioRecorder.setExternalStopListener(externalStopListener);
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
@PluginMethod
|
|
29
50
|
public void start(final PluginCall call) {
|
|
30
51
|
boolean keptAlive = false;
|
|
52
|
+
CapgoScrCast startRecorder = null;
|
|
31
53
|
try {
|
|
32
54
|
final boolean recordAudio = call.getBoolean("recordAudio", false);
|
|
33
55
|
final String format = call.getString("format");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
final CapgoScrCast recorder = recordAudio ? audioRecorder : videoRecorder;
|
|
56
|
+
startRecorder = recordAudio ? audioRecorder : videoRecorder;
|
|
57
|
+
final CapgoScrCast recorder = startRecorder;
|
|
37
58
|
final Options configuredOptions = VideoFormatResolver.INSTANCE.applyTo(recorder.getOptions(), format);
|
|
38
59
|
recorder.updateOptions(configuredOptions);
|
|
39
60
|
recorder.updateVideoFormat(format);
|
|
@@ -44,13 +65,22 @@ public class ScreenRecorderPlugin extends Plugin {
|
|
|
44
65
|
new CapgoScrCast.StartListener() {
|
|
45
66
|
@Override
|
|
46
67
|
public void onStarted() {
|
|
68
|
+
if (pendingRecorder == recorder) {
|
|
69
|
+
pendingRecorder = null;
|
|
70
|
+
}
|
|
71
|
+
activeRecorder = recorder;
|
|
47
72
|
call.resolve();
|
|
48
73
|
call.release(bridge);
|
|
49
74
|
}
|
|
50
75
|
|
|
51
76
|
@Override
|
|
52
77
|
public void onFailed(final Throwable error) {
|
|
53
|
-
|
|
78
|
+
if (pendingRecorder == recorder) {
|
|
79
|
+
pendingRecorder = null;
|
|
80
|
+
}
|
|
81
|
+
if (activeRecorder == recorder) {
|
|
82
|
+
activeRecorder = null;
|
|
83
|
+
}
|
|
54
84
|
final Exception exception = error instanceof Exception ? (Exception) error : new Exception(error);
|
|
55
85
|
call.reject("Could not start screen recording", exception);
|
|
56
86
|
call.release(bridge);
|
|
@@ -58,12 +88,15 @@ public class ScreenRecorderPlugin extends Plugin {
|
|
|
58
88
|
}
|
|
59
89
|
);
|
|
60
90
|
if (!started) {
|
|
61
|
-
recordingWithAudio = false;
|
|
62
91
|
call.reject("Could not start screen recording", new IllegalStateException("A screen recording is already in progress"));
|
|
63
92
|
call.release(bridge);
|
|
93
|
+
} else {
|
|
94
|
+
pendingRecorder = recorder;
|
|
64
95
|
}
|
|
65
96
|
} catch (final Exception e) {
|
|
66
|
-
|
|
97
|
+
if (startRecorder != null && pendingRecorder == startRecorder) {
|
|
98
|
+
pendingRecorder = null;
|
|
99
|
+
}
|
|
67
100
|
call.reject("Could not start screen recording", e);
|
|
68
101
|
if (keptAlive) {
|
|
69
102
|
call.release(bridge);
|
|
@@ -74,12 +107,12 @@ public class ScreenRecorderPlugin extends Plugin {
|
|
|
74
107
|
@PluginMethod
|
|
75
108
|
public void stop(PluginCall call) {
|
|
76
109
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
videoRecorder.stopRecording();
|
|
110
|
+
final CapgoScrCast recorder = activeRecorder != null ? activeRecorder : pendingRecorder;
|
|
111
|
+
if (recorder != null) {
|
|
112
|
+
recorder.stopRecording();
|
|
81
113
|
}
|
|
82
|
-
|
|
114
|
+
pendingRecorder = null;
|
|
115
|
+
activeRecorder = null;
|
|
83
116
|
call.resolve();
|
|
84
117
|
} catch (final Exception e) {
|
|
85
118
|
call.reject("Could not stop screen recording", e);
|
package/android/src/main/java/ee/forgr/plugin/screenrecorder/service/CapgoRecorderService.kt
CHANGED
|
@@ -23,6 +23,7 @@ import dev.bmcreations.scrcast.internal.recorder.*
|
|
|
23
23
|
import dev.bmcreations.scrcast.internal.recorder.service.orientations
|
|
24
24
|
import dev.bmcreations.scrcast.recorder.*
|
|
25
25
|
import dev.bmcreations.scrcast.recorder.notification.NotificationProvider
|
|
26
|
+
import ee.forgr.plugin.screenrecorder.CapgoScrCast
|
|
26
27
|
import ee.forgr.plugin.screenrecorder.MediaRecorderPrepare
|
|
27
28
|
import kotlinx.coroutines.Dispatchers
|
|
28
29
|
import kotlinx.coroutines.GlobalScope
|
|
@@ -66,10 +67,13 @@ class CapgoRecorderService : Service() {
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
70
|
+
private var sessionGeneration: Long = 0L
|
|
71
|
+
|
|
69
72
|
private var state: RecordingState = RecordingState.Idle()
|
|
70
73
|
set(value) {
|
|
71
74
|
field = value
|
|
72
75
|
broadcaster.sendBroadcast(Intent(value.stateString()).apply {
|
|
76
|
+
putExtra(CapgoScrCast.EXTRA_SESSION_GENERATION, sessionGeneration)
|
|
73
77
|
if (value is RecordingState.Delay) {
|
|
74
78
|
putExtra(EXTRA_DELAY_REMAINING, value.remainingSeconds)
|
|
75
79
|
} else if (value is RecordingState.Idle) {
|
|
@@ -112,7 +116,8 @@ class CapgoRecorderService : Service() {
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
private var mediaRecorder: MediaRecorder? = null
|
|
115
|
-
|
|
119
|
+
private var recordingStarted = false
|
|
120
|
+
private var prepareInProgress = false
|
|
116
121
|
|
|
117
122
|
private fun createMediaRecorder(): MediaRecorder {
|
|
118
123
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
@@ -238,6 +243,7 @@ class CapgoRecorderService : Service() {
|
|
|
238
243
|
|
|
239
244
|
private fun recordInternal(code: Int, data: Intent) {
|
|
240
245
|
GlobalScope.launch(Dispatchers.Main) {
|
|
246
|
+
prepareInProgress = true
|
|
241
247
|
startForeground(
|
|
242
248
|
notificationProvider.getNotificationId(),
|
|
243
249
|
notificationProvider.get(state)
|
|
@@ -259,44 +265,73 @@ class CapgoRecorderService : Service() {
|
|
|
259
265
|
|
|
260
266
|
mediaProjection?.registerCallback(mediaProjectionCallback, Handler())
|
|
261
267
|
if (!createRecorder()) {
|
|
268
|
+
prepareInProgress = false
|
|
262
269
|
stopRecording(IOException("MediaRecorder prepare failed"))
|
|
263
270
|
return@launch
|
|
264
271
|
}
|
|
265
272
|
virtualDisplay // touch
|
|
266
273
|
try {
|
|
267
274
|
mediaRecorder?.start()
|
|
275
|
+
recordingStarted = true
|
|
268
276
|
state = RecordingState.Recording
|
|
269
277
|
notificationProvider.update(state)
|
|
270
278
|
} catch (e: Exception) {
|
|
271
279
|
stopRecording(e)
|
|
280
|
+
} finally {
|
|
281
|
+
prepareInProgress = false
|
|
272
282
|
}
|
|
273
283
|
}
|
|
274
284
|
}
|
|
275
285
|
|
|
276
286
|
private fun stopRecording(error: Throwable? = null) {
|
|
287
|
+
finalizeRecorder()
|
|
277
288
|
mediaProjection?.stop()
|
|
278
|
-
|
|
289
|
+
mediaProjection = null
|
|
279
290
|
state = RecordingState.Idle(error)
|
|
280
291
|
stopForeground(true)
|
|
281
292
|
}
|
|
282
293
|
|
|
283
|
-
private fun
|
|
294
|
+
private fun finalizeRecorder() {
|
|
284
295
|
mediaProjection?.unregisterCallback(mediaProjectionCallback)
|
|
285
|
-
mediaProjection = null
|
|
286
|
-
|
|
287
296
|
_virtualDisplay?.release()
|
|
288
297
|
_virtualDisplay = null
|
|
289
|
-
|
|
290
298
|
runCatching {
|
|
291
299
|
mediaRecorder?.stop()
|
|
292
300
|
}
|
|
293
301
|
releaseRecorder()
|
|
302
|
+
recordingStarted = false
|
|
303
|
+
prepareInProgress = false
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private fun cleanupProjection() {
|
|
307
|
+
finalizeRecorder()
|
|
308
|
+
mediaProjection = null
|
|
294
309
|
}
|
|
295
310
|
|
|
296
311
|
private inner class MediaProjectionCallback : MediaProjection.Callback() {
|
|
297
312
|
override fun onStop() {
|
|
298
313
|
Log.d("scrcast", "projection on stop")
|
|
314
|
+
val hadStarted = recordingStarted
|
|
315
|
+
val preparing = prepareInProgress
|
|
299
316
|
cleanupProjection()
|
|
317
|
+
when {
|
|
318
|
+
state is RecordingState.Idle && (state as RecordingState.Idle).error != null -> {
|
|
319
|
+
// stopRecording() already published Idle with an error.
|
|
320
|
+
}
|
|
321
|
+
state is RecordingState.Idle && hadStarted -> {
|
|
322
|
+
// stopRecording() already published Idle for an active recording.
|
|
323
|
+
}
|
|
324
|
+
!hadStarted || preparing -> {
|
|
325
|
+
state = RecordingState.Idle(
|
|
326
|
+
IllegalStateException("Recording stopped before it started"),
|
|
327
|
+
)
|
|
328
|
+
stopForeground(true)
|
|
329
|
+
}
|
|
330
|
+
else -> {
|
|
331
|
+
state = RecordingState.Idle()
|
|
332
|
+
stopForeground(true)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
300
335
|
}
|
|
301
336
|
}
|
|
302
337
|
|
|
@@ -307,6 +342,7 @@ class CapgoRecorderService : Service() {
|
|
|
307
342
|
dpi = it.getFloatExtra("dpi", 0f)
|
|
308
343
|
outputFile = it.getStringExtra("outputFile") ?: ""
|
|
309
344
|
recordAudio = it.getBooleanExtra("recordAudio", false)
|
|
345
|
+
sessionGeneration = it.getLongExtra(CapgoScrCast.EXTRA_SESSION_GENERATION, 0L)
|
|
310
346
|
|
|
311
347
|
startRecording(
|
|
312
348
|
code = it.getIntExtra("code", -1),
|
package/dist/docs.json
CHANGED
|
@@ -84,6 +84,58 @@
|
|
|
84
84
|
"complexTypes": [],
|
|
85
85
|
"slug": "stop"
|
|
86
86
|
},
|
|
87
|
+
{
|
|
88
|
+
"name": "addListener",
|
|
89
|
+
"signature": "(eventName: 'onStopped', listenerFunc: (event: ScreenRecorderStoppedEvent) => void) => Promise<PluginListenerHandle>",
|
|
90
|
+
"parameters": [
|
|
91
|
+
{
|
|
92
|
+
"name": "eventName",
|
|
93
|
+
"docs": "- `onStopped`",
|
|
94
|
+
"type": "'onStopped'"
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "listenerFunc",
|
|
98
|
+
"docs": "- Called with a `ScreenRecorderStoppedEvent`\n(`url` and optional `error`) when the recording ended outside `stop()`.",
|
|
99
|
+
"type": "(event: ScreenRecorderStoppedEvent) => void"
|
|
100
|
+
}
|
|
101
|
+
],
|
|
102
|
+
"returns": "Promise<PluginListenerHandle>",
|
|
103
|
+
"tags": [
|
|
104
|
+
{
|
|
105
|
+
"name": "param",
|
|
106
|
+
"text": "eventName - `onStopped`"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"name": "param",
|
|
110
|
+
"text": "listenerFunc - Called with a `ScreenRecorderStoppedEvent`\n(`url` and optional `error`) when the recording ended outside `stop()`."
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
"name": "since",
|
|
114
|
+
"text": "8.4.0"
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
"docs": "Listen for recordings that ended without {@link stop} being called.",
|
|
118
|
+
"complexTypes": [
|
|
119
|
+
"PluginListenerHandle",
|
|
120
|
+
"ScreenRecorderStoppedEvent"
|
|
121
|
+
],
|
|
122
|
+
"slug": "addlisteneronstopped-"
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"name": "removeAllListeners",
|
|
126
|
+
"signature": "() => Promise<void>",
|
|
127
|
+
"parameters": [],
|
|
128
|
+
"returns": "Promise<void>",
|
|
129
|
+
"tags": [
|
|
130
|
+
{
|
|
131
|
+
"name": "since",
|
|
132
|
+
"text": "8.4.0"
|
|
133
|
+
}
|
|
134
|
+
],
|
|
135
|
+
"docs": "Remove all listeners registered with {@link addListener}.",
|
|
136
|
+
"complexTypes": [],
|
|
137
|
+
"slug": "removealllisteners"
|
|
138
|
+
},
|
|
87
139
|
{
|
|
88
140
|
"name": "getPluginVersion",
|
|
89
141
|
"signature": "() => Promise<{ version: string; }>",
|
|
@@ -166,6 +218,60 @@
|
|
|
166
218
|
"type": "ScreenRecorderVideoFormat | 'video/mp4' | 'video/quicktime' | undefined"
|
|
167
219
|
}
|
|
168
220
|
]
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
"name": "PluginListenerHandle",
|
|
224
|
+
"slug": "pluginlistenerhandle",
|
|
225
|
+
"docs": "",
|
|
226
|
+
"tags": [],
|
|
227
|
+
"methods": [],
|
|
228
|
+
"properties": [
|
|
229
|
+
{
|
|
230
|
+
"name": "remove",
|
|
231
|
+
"tags": [],
|
|
232
|
+
"docs": "",
|
|
233
|
+
"complexTypes": [],
|
|
234
|
+
"type": "() => Promise<void>"
|
|
235
|
+
}
|
|
236
|
+
]
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
"name": "ScreenRecorderStoppedEvent",
|
|
240
|
+
"slug": "screenrecorderstoppedevent",
|
|
241
|
+
"docs": "Payload for the `onStopped` event.\n\nFired when a recording ends without {@link ScreenRecorderPlugin.stop} being\ncalled: the user stopped capture from the system UI (Android \"Stop sharing\"),\nthe recorder hit a limit (max duration or max file size), or the system\ninterrupted capture (iOS, for example an incoming call).",
|
|
242
|
+
"tags": [
|
|
243
|
+
{
|
|
244
|
+
"text": "8.4.0",
|
|
245
|
+
"name": "since"
|
|
246
|
+
}
|
|
247
|
+
],
|
|
248
|
+
"methods": [],
|
|
249
|
+
"properties": [
|
|
250
|
+
{
|
|
251
|
+
"name": "url",
|
|
252
|
+
"tags": [
|
|
253
|
+
{
|
|
254
|
+
"text": "8.4.0",
|
|
255
|
+
"name": "since"
|
|
256
|
+
}
|
|
257
|
+
],
|
|
258
|
+
"docs": "Local URI of the finished recording (`file://` on Android).\nEmpty string when the file path could not be resolved.",
|
|
259
|
+
"complexTypes": [],
|
|
260
|
+
"type": "string"
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
"name": "error",
|
|
264
|
+
"tags": [
|
|
265
|
+
{
|
|
266
|
+
"text": "8.4.0",
|
|
267
|
+
"name": "since"
|
|
268
|
+
}
|
|
269
|
+
],
|
|
270
|
+
"docs": "Error message when the recording ended with a failure.",
|
|
271
|
+
"complexTypes": [],
|
|
272
|
+
"type": "string | undefined"
|
|
273
|
+
}
|
|
274
|
+
]
|
|
169
275
|
}
|
|
170
276
|
],
|
|
171
277
|
"enums": [],
|
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
import type { PluginListenerHandle } from '@capacitor/core';
|
|
2
|
+
/**
|
|
3
|
+
* Payload for the `onStopped` event.
|
|
4
|
+
*
|
|
5
|
+
* Fired when a recording ends without {@link ScreenRecorderPlugin.stop} being
|
|
6
|
+
* called: the user stopped capture from the system UI (Android "Stop sharing"),
|
|
7
|
+
* the recorder hit a limit (max duration or max file size), or the system
|
|
8
|
+
* interrupted capture (iOS, for example an incoming call).
|
|
9
|
+
*
|
|
10
|
+
* @since 8.4.0
|
|
11
|
+
*/
|
|
12
|
+
export interface ScreenRecorderStoppedEvent {
|
|
13
|
+
/**
|
|
14
|
+
* Local URI of the finished recording (`file://` on Android).
|
|
15
|
+
* Empty string when the file path could not be resolved.
|
|
16
|
+
*
|
|
17
|
+
* @since 8.4.0
|
|
18
|
+
*/
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* Error message when the recording ended with a failure.
|
|
22
|
+
*
|
|
23
|
+
* @since 8.4.0
|
|
24
|
+
*/
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
1
27
|
/**
|
|
2
28
|
* Supported video container formats for screen recordings.
|
|
3
29
|
*
|
|
@@ -80,6 +106,21 @@ export interface ScreenRecorderPlugin {
|
|
|
80
106
|
* ```
|
|
81
107
|
*/
|
|
82
108
|
stop(): Promise<void>;
|
|
109
|
+
/**
|
|
110
|
+
* Listen for recordings that ended without {@link stop} being called.
|
|
111
|
+
*
|
|
112
|
+
* @param eventName - `onStopped`
|
|
113
|
+
* @param listenerFunc - Called with a `ScreenRecorderStoppedEvent`
|
|
114
|
+
* (`url` and optional `error`) when the recording ended outside `stop()`.
|
|
115
|
+
* @since 8.4.0
|
|
116
|
+
*/
|
|
117
|
+
addListener(eventName: 'onStopped', listenerFunc: (event: ScreenRecorderStoppedEvent) => void): Promise<PluginListenerHandle>;
|
|
118
|
+
/**
|
|
119
|
+
* Remove all listeners registered with {@link addListener}.
|
|
120
|
+
*
|
|
121
|
+
* @since 8.4.0
|
|
122
|
+
*/
|
|
123
|
+
removeAllListeners(): Promise<void>;
|
|
83
124
|
/**
|
|
84
125
|
* Get the native Capacitor plugin version.
|
|
85
126
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["/**\n * Supported video container formats for screen recordings.\n *\n * @since 8.3.0\n */\nexport type ScreenRecorderVideoFormat = 'mp4' | 'mov';\n\n/**\n * Options for {@link ScreenRecorderPlugin.start}.\n *\n * @since 8.3.0\n */\nexport interface StartRecordingOptions {\n /**\n * Whether to record audio along with the screen video.\n *\n * @default false\n * @since 1.0.0\n */\n recordAudio?: boolean;\n\n /**\n * Video container format for the saved recording.\n *\n * Accepts `mp4`, `mov`, or MIME types `video/mp4` and `video/quicktime`.\n * iOS supports both `mp4` and `mov`. Android records MPEG-4 (`.mp4`) regardless of this value.\n *\n * @default 'mp4'\n * @since 8.3.0\n * @example 'mov'\n */\n format?: ScreenRecorderVideoFormat | 'video/mp4' | 'video/quicktime';\n}\n\n/**\n * Capacitor Screen Recorder Plugin for recording the device screen.\n * Allows you to capture video recordings of the screen with optional audio.\n *\n * @since 1.0.0\n */\nexport interface ScreenRecorderPlugin {\n /**\n * Start recording the device screen.\n *\n * Initiates screen recording with optional audio capture. The user will be\n * prompted to grant screen recording permissions if not already granted.\n * On iOS, the system recording UI will be displayed. On Android, the recording\n * starts immediately after permission is granted.\n *\n * @param options - Recording configuration options\n * @param options.recordAudio - Whether to record audio along with the screen video. Defaults to false.\n * @param options.format - Video container format for the saved recording. Defaults to `mp4`.\n * @returns Promise that resolves when recording starts\n * @throws Error if recording fails to start or permissions are denied\n * @since 1.0.0\n * @example\n * ```typescript\n * // Start recording without audio\n * await ScreenRecorder.start();\n *\n * // Start recording with audio\n * await ScreenRecorder.start({ recordAudio: true });\n *\n * // Start recording as MOV on iOS\n * await ScreenRecorder.start({ format: 'mov' });\n * ```\n */\n start(options?: StartRecordingOptions): Promise<void>;\n\n /**\n * Stop the current screen recording.\n *\n * Stops the active screen recording and saves the video to the device's\n * camera roll or gallery. On iOS, the system will show a preview of the\n * recording. On Android, the video is saved directly to the gallery.\n *\n * @returns Promise that resolves when recording stops and the video is saved\n * @throws Error if stopping the recording fails or no recording is active\n * @since 1.0.0\n * @example\n * ```typescript\n * await ScreenRecorder.stop();\n * console.log('Recording saved to gallery');\n * ```\n */\n stop(): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version.\n *\n * @returns Promise that resolves with the plugin version\n * @throws Error if getting the version fails\n * @since 1.0.0\n * @example\n * ```typescript\n * const { version } = await ScreenRecorder.getPluginVersion();\n * console.log('Plugin version:', version);\n * ```\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"","sourcesContent":["import type { PluginListenerHandle } from '@capacitor/core';\n\n/**\n * Payload for the `onStopped` event.\n *\n * Fired when a recording ends without {@link ScreenRecorderPlugin.stop} being\n * called: the user stopped capture from the system UI (Android \"Stop sharing\"),\n * the recorder hit a limit (max duration or max file size), or the system\n * interrupted capture (iOS, for example an incoming call).\n *\n * @since 8.4.0\n */\nexport interface ScreenRecorderStoppedEvent {\n /**\n * Local URI of the finished recording (`file://` on Android).\n * Empty string when the file path could not be resolved.\n *\n * @since 8.4.0\n */\n url: string;\n\n /**\n * Error message when the recording ended with a failure.\n *\n * @since 8.4.0\n */\n error?: string;\n}\n\n/**\n * Supported video container formats for screen recordings.\n *\n * @since 8.3.0\n */\nexport type ScreenRecorderVideoFormat = 'mp4' | 'mov';\n\n/**\n * Options for {@link ScreenRecorderPlugin.start}.\n *\n * @since 8.3.0\n */\nexport interface StartRecordingOptions {\n /**\n * Whether to record audio along with the screen video.\n *\n * @default false\n * @since 1.0.0\n */\n recordAudio?: boolean;\n\n /**\n * Video container format for the saved recording.\n *\n * Accepts `mp4`, `mov`, or MIME types `video/mp4` and `video/quicktime`.\n * iOS supports both `mp4` and `mov`. Android records MPEG-4 (`.mp4`) regardless of this value.\n *\n * @default 'mp4'\n * @since 8.3.0\n * @example 'mov'\n */\n format?: ScreenRecorderVideoFormat | 'video/mp4' | 'video/quicktime';\n}\n\n/**\n * Capacitor Screen Recorder Plugin for recording the device screen.\n * Allows you to capture video recordings of the screen with optional audio.\n *\n * @since 1.0.0\n */\nexport interface ScreenRecorderPlugin {\n /**\n * Start recording the device screen.\n *\n * Initiates screen recording with optional audio capture. The user will be\n * prompted to grant screen recording permissions if not already granted.\n * On iOS, the system recording UI will be displayed. On Android, the recording\n * starts immediately after permission is granted.\n *\n * @param options - Recording configuration options\n * @param options.recordAudio - Whether to record audio along with the screen video. Defaults to false.\n * @param options.format - Video container format for the saved recording. Defaults to `mp4`.\n * @returns Promise that resolves when recording starts\n * @throws Error if recording fails to start or permissions are denied\n * @since 1.0.0\n * @example\n * ```typescript\n * // Start recording without audio\n * await ScreenRecorder.start();\n *\n * // Start recording with audio\n * await ScreenRecorder.start({ recordAudio: true });\n *\n * // Start recording as MOV on iOS\n * await ScreenRecorder.start({ format: 'mov' });\n * ```\n */\n start(options?: StartRecordingOptions): Promise<void>;\n\n /**\n * Stop the current screen recording.\n *\n * Stops the active screen recording and saves the video to the device's\n * camera roll or gallery. On iOS, the system will show a preview of the\n * recording. On Android, the video is saved directly to the gallery.\n *\n * @returns Promise that resolves when recording stops and the video is saved\n * @throws Error if stopping the recording fails or no recording is active\n * @since 1.0.0\n * @example\n * ```typescript\n * await ScreenRecorder.stop();\n * console.log('Recording saved to gallery');\n * ```\n */\n stop(): Promise<void>;\n\n /**\n * Listen for recordings that ended without {@link stop} being called.\n *\n * @param eventName - `onStopped`\n * @param listenerFunc - Called with a `ScreenRecorderStoppedEvent`\n * (`url` and optional `error`) when the recording ended outside `stop()`.\n * @since 8.4.0\n */\n addListener(\n eventName: 'onStopped',\n listenerFunc: (event: ScreenRecorderStoppedEvent) => void,\n ): Promise<PluginListenerHandle>;\n\n /**\n * Remove all listeners registered with {@link addListener}.\n *\n * @since 8.4.0\n */\n removeAllListeners(): Promise<void>;\n\n /**\n * Get the native Capacitor plugin version.\n *\n * @returns Promise that resolves with the plugin version\n * @throws Error if getting the version fails\n * @since 1.0.0\n * @example\n * ```typescript\n * const { version } = await ScreenRecorder.getPluginVersion();\n * console.log('Plugin version:', version);\n * ```\n */\n getPluginVersion(): Promise<{ version: string }>;\n}\n"]}
|
|
@@ -7,7 +7,7 @@ import Capacitor
|
|
|
7
7
|
*/
|
|
8
8
|
@objc(ScreenRecorderPlugin)
|
|
9
9
|
public class ScreenRecorderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
10
|
-
private let pluginVersion: String = "8.
|
|
10
|
+
private let pluginVersion: String = "8.4.0"
|
|
11
11
|
public let identifier = "ScreenRecorderPlugin"
|
|
12
12
|
public let jsName = "ScreenRecorder"
|
|
13
13
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -17,6 +17,17 @@ public class ScreenRecorderPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
17
17
|
]
|
|
18
18
|
private let implementation = ScreenRecorder()
|
|
19
19
|
|
|
20
|
+
override public func load() {
|
|
21
|
+
implementation.onExternalStop = { [weak self] url, error in
|
|
22
|
+
guard let self = self else { return }
|
|
23
|
+
var data: [String: Any] = ["url": url?.absoluteString ?? ""]
|
|
24
|
+
if let error = error {
|
|
25
|
+
data["error"] = error.localizedDescription
|
|
26
|
+
}
|
|
27
|
+
self.notifyListeners("onStopped", data: data)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
@objc func start(_ call: CAPPluginCall) {
|
|
21
32
|
let recordAudio = call.getBool("recordAudio") ?? false
|
|
22
33
|
let videoFormat = VideoContainerFormat.from(call.getString("format"))
|
|
@@ -50,7 +50,7 @@ public enum VideoContainerFormat {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
public final class ScreenRecorder {
|
|
53
|
+
public final class ScreenRecorder: NSObject, RPScreenRecorderDelegate {
|
|
54
54
|
private var videoOutputURL: URL?
|
|
55
55
|
private var didCreateOutputFile = false
|
|
56
56
|
private var videoWriter: AVAssetWriter?
|
|
@@ -62,6 +62,14 @@ public final class ScreenRecorder {
|
|
|
62
62
|
private var recordAudio = false
|
|
63
63
|
private var videoFormat: VideoContainerFormat = .mp4
|
|
64
64
|
private var isRecording = false
|
|
65
|
+
private var stopRequested = false
|
|
66
|
+
private var startSettled = false
|
|
67
|
+
private var videoHasContent = false
|
|
68
|
+
private var isFinalizingExternally = false
|
|
69
|
+
private var pendingStartHandler: ((Error?) -> Void)?
|
|
70
|
+
private let stateLock = NSLock()
|
|
71
|
+
/// Fired when recording ends without `stoprecording()` being called.
|
|
72
|
+
public var onExternalStop: ((URL?, Error?) -> Void)?
|
|
65
73
|
let recorder = RPScreenRecorder.shared()
|
|
66
74
|
|
|
67
75
|
public func startRecording(to outputURL: URL? = nil,
|
|
@@ -70,10 +78,16 @@ public final class ScreenRecorder {
|
|
|
70
78
|
recordAudio: Bool = false,
|
|
71
79
|
videoFormat: VideoContainerFormat = .mp4,
|
|
72
80
|
handler: @escaping (Error?) -> Void) {
|
|
73
|
-
guard !isRecording else {
|
|
81
|
+
guard !isRecording, !isFinalizingExternally else {
|
|
74
82
|
return handler(ScreenRecorderError.alreadyRecording)
|
|
75
83
|
}
|
|
76
84
|
isRecording = true
|
|
85
|
+
pendingStartHandler = nil
|
|
86
|
+
stopRequested = false
|
|
87
|
+
startSettled = false
|
|
88
|
+
videoHasContent = false
|
|
89
|
+
isFinalizingExternally = false
|
|
90
|
+
recorder.delegate = self
|
|
77
91
|
|
|
78
92
|
self.saveToCameraRoll = saveToCameraRoll
|
|
79
93
|
self.recordAudio = recordAudio
|
|
@@ -180,19 +194,15 @@ public final class ScreenRecorder {
|
|
|
180
194
|
self.isRecording = false
|
|
181
195
|
return handler(ScreenRecorderError.notAvailable)
|
|
182
196
|
}
|
|
197
|
+
pendingStartHandler = handler
|
|
183
198
|
var sent = false
|
|
184
199
|
recorder.startCapture(handler: { (sampleBuffer, sampleType, passedError) in
|
|
185
200
|
if let passedError = passedError {
|
|
186
|
-
self.videoWriterInput?.markAsFinished()
|
|
187
|
-
self.audioWriterInput?.markAsFinished()
|
|
188
|
-
if self.videoWriter?.status == .writing {
|
|
189
|
-
self.videoWriter?.cancelWriting()
|
|
190
|
-
}
|
|
191
|
-
self.deleteOutputFileIfNeeded(outputURL: outputURL, didCreate: didCreate)
|
|
192
201
|
if !sent {
|
|
193
|
-
self.
|
|
194
|
-
handler(passedError)
|
|
202
|
+
self.failPendingStart(passedError, outputURL: outputURL, didCreate: didCreate)
|
|
195
203
|
sent = true
|
|
204
|
+
} else if self.isRecording && !self.stopRequested && !self.isFinalizingExternally {
|
|
205
|
+
self.handleRecordingEndedExternally(error: passedError)
|
|
196
206
|
}
|
|
197
207
|
return
|
|
198
208
|
}
|
|
@@ -220,12 +230,35 @@ public final class ScreenRecorder {
|
|
|
220
230
|
break
|
|
221
231
|
}
|
|
222
232
|
if !sent {
|
|
223
|
-
|
|
233
|
+
self.completePendingStart(nil)
|
|
224
234
|
sent = true
|
|
225
235
|
}
|
|
226
236
|
})
|
|
227
237
|
}
|
|
228
238
|
|
|
239
|
+
private func completePendingStart(_ error: Error?) {
|
|
240
|
+
stateLock.lock()
|
|
241
|
+
let startHandler = pendingStartHandler
|
|
242
|
+
pendingStartHandler = nil
|
|
243
|
+
startSettled = true
|
|
244
|
+
if error != nil {
|
|
245
|
+
isRecording = false
|
|
246
|
+
}
|
|
247
|
+
stateLock.unlock()
|
|
248
|
+
startHandler?(error)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func failPendingStart(_ error: Error, outputURL: URL?, didCreate: Bool) {
|
|
252
|
+
recorder.delegate = nil
|
|
253
|
+
videoWriterInput?.markAsFinished()
|
|
254
|
+
audioWriterInput?.markAsFinished()
|
|
255
|
+
if videoWriter?.status == .writing {
|
|
256
|
+
videoWriter?.cancelWriting()
|
|
257
|
+
}
|
|
258
|
+
deleteOutputFileIfNeeded(outputURL: outputURL, didCreate: didCreate)
|
|
259
|
+
completePendingStart(error)
|
|
260
|
+
}
|
|
261
|
+
|
|
229
262
|
private func handleSampleBuffer(sampleBuffer: CMSampleBuffer) {
|
|
230
263
|
if self.videoWriter?.status == AVAssetWriter.Status.unknown {
|
|
231
264
|
self.videoWriter?.startWriting()
|
|
@@ -233,6 +266,7 @@ public final class ScreenRecorder {
|
|
|
233
266
|
} else if self.videoWriter?.status == AVAssetWriter.Status.writing &&
|
|
234
267
|
self.videoWriterInput?.isReadyForMoreMediaData == true {
|
|
235
268
|
self.videoWriterInput?.append(sampleBuffer)
|
|
269
|
+
self.videoHasContent = true
|
|
236
270
|
}
|
|
237
271
|
}
|
|
238
272
|
|
|
@@ -245,6 +279,12 @@ public final class ScreenRecorder {
|
|
|
245
279
|
}
|
|
246
280
|
|
|
247
281
|
public func stoprecording(handler: @escaping (Error?) -> Void) {
|
|
282
|
+
if isFinalizingExternally || !isRecording {
|
|
283
|
+
handler(nil)
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
stopRequested = true
|
|
287
|
+
recorder.delegate = nil
|
|
248
288
|
recorder.stopCapture(handler: { error in
|
|
249
289
|
let outputURL = self.videoOutputURL
|
|
250
290
|
let didCreate = self.didCreateOutputFile
|
|
@@ -381,4 +421,112 @@ public final class ScreenRecorder {
|
|
|
381
421
|
debugPrint("Failed to delete recording file \(fileURL): \(error)")
|
|
382
422
|
}
|
|
383
423
|
}
|
|
424
|
+
|
|
425
|
+
private func handleRecordingEndedExternally(error: Error?) {
|
|
426
|
+
stateLock.lock()
|
|
427
|
+
let shouldHandle = isRecording && !stopRequested && !isFinalizingExternally
|
|
428
|
+
if shouldHandle {
|
|
429
|
+
isFinalizingExternally = true
|
|
430
|
+
}
|
|
431
|
+
stateLock.unlock()
|
|
432
|
+
guard shouldHandle else { return }
|
|
433
|
+
recorder.delegate = nil
|
|
434
|
+
|
|
435
|
+
let outputURL = videoOutputURL
|
|
436
|
+
let didCreate = didCreateOutputFile
|
|
437
|
+
let hadVideo = videoHasContent
|
|
438
|
+
|
|
439
|
+
recorder.stopCapture(handler: { _ in
|
|
440
|
+
self.audioMixerQueue.sync {
|
|
441
|
+
if let mixer = self.audioTrackMixer {
|
|
442
|
+
for sample in mixer.drain() {
|
|
443
|
+
self.add(sample: sample, to: self.audioWriterInput)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
self.videoWriterInput?.markAsFinished()
|
|
449
|
+
self.audioWriterInput?.markAsFinished()
|
|
450
|
+
|
|
451
|
+
guard let writer = self.videoWriter else {
|
|
452
|
+
self.finishExternalStop(
|
|
453
|
+
outputURL: outputURL,
|
|
454
|
+
didCreate: didCreate,
|
|
455
|
+
hadVideo: hadVideo,
|
|
456
|
+
triggerError: error,
|
|
457
|
+
finishError: nil,
|
|
458
|
+
writerFailed: true,
|
|
459
|
+
)
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let deliver: (Error?, Bool) -> Void = { finishError, writerFailed in
|
|
464
|
+
self.finishExternalStop(
|
|
465
|
+
outputURL: outputURL,
|
|
466
|
+
didCreate: didCreate,
|
|
467
|
+
hadVideo: hadVideo,
|
|
468
|
+
triggerError: error,
|
|
469
|
+
finishError: finishError,
|
|
470
|
+
writerFailed: writerFailed,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if writer.status == .writing {
|
|
475
|
+
writer.finishWriting {
|
|
476
|
+
if let finishError = writer.error {
|
|
477
|
+
self.deleteOutputFileIfNeeded(outputURL: outputURL, didCreate: didCreate)
|
|
478
|
+
deliver(finishError, true)
|
|
479
|
+
return
|
|
480
|
+
}
|
|
481
|
+
deliver(nil, false)
|
|
482
|
+
}
|
|
483
|
+
} else if writer.status == .failed {
|
|
484
|
+
self.deleteOutputFileIfNeeded(outputURL: outputURL, didCreate: didCreate)
|
|
485
|
+
deliver(writer.error, true)
|
|
486
|
+
} else {
|
|
487
|
+
deliver(nil, false)
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private func finishExternalStop(
|
|
493
|
+
outputURL: URL?,
|
|
494
|
+
didCreate: Bool,
|
|
495
|
+
hadVideo: Bool,
|
|
496
|
+
triggerError: Error?,
|
|
497
|
+
finishError: Error?,
|
|
498
|
+
writerFailed: Bool,
|
|
499
|
+
) {
|
|
500
|
+
stateLock.lock()
|
|
501
|
+
isFinalizingExternally = false
|
|
502
|
+
isRecording = false
|
|
503
|
+
stateLock.unlock()
|
|
504
|
+
|
|
505
|
+
let combinedError = triggerError ?? finishError
|
|
506
|
+
if !hadVideo {
|
|
507
|
+
deleteOutputFileIfNeeded(outputURL: outputURL, didCreate: didCreate)
|
|
508
|
+
onExternalStop?(nil, combinedError)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
let exists = outputURL.map { FileManager.default.fileExists(atPath: $0.path) } ?? false
|
|
512
|
+
let url: URL? = (!writerFailed && exists) ? outputURL : nil
|
|
513
|
+
onExternalStop?(url, combinedError)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
public func screenRecorder(
|
|
517
|
+
_ screenRecorder: RPScreenRecorder,
|
|
518
|
+
didStopRecordingWithError error: Error,
|
|
519
|
+
previewViewController: RPPreviewViewController?
|
|
520
|
+
) {
|
|
521
|
+
if !startSettled {
|
|
522
|
+
failPendingStart(
|
|
523
|
+
error,
|
|
524
|
+
outputURL: videoOutputURL,
|
|
525
|
+
didCreate: didCreateOutputFile,
|
|
526
|
+
)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
guard isRecording, !stopRequested else { return }
|
|
530
|
+
handleRecordingEndedExternally(error: error)
|
|
531
|
+
}
|
|
384
532
|
}
|