nosnia-audio-recorder 0.2.0 β†’ 0.3.1

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 CHANGED
@@ -9,6 +9,7 @@ A compact, high-performance audio recorder library for React Native that records
9
9
  - 🎚️ **Configurable**: Adjust bitrate, sample rate, and channels
10
10
  - ⏸️ **Pause/Resume**: Full control over recording with pause and resume
11
11
  - ⏱️ **Real-time Duration**: Get continuous recording duration updates via callback
12
+ - ▢️ **MP3 Playback**: Built-in audio player with play/pause/resume/stop/seek
12
13
  - πŸ”’ **Permission Handling**: Built-in permission checking and requesting
13
14
  - πŸ’Ύ **File Management**: Automatic file naming and directory management
14
15
  - πŸ“¦ **Compact**: Minimal dependencies, optimized for bundle size
@@ -49,10 +50,10 @@ const filePath = await NosniaAudioRecorder.stopRecording();
49
50
  console.log('Recording saved to:', filePath);
50
51
  ```
51
52
 
52
- ### Full Example
53
+ ### Full Example (Record + Playback)
53
54
 
54
55
  ```js
55
- import { NosniaAudioRecorder } from 'nosnia-audio-recorder';
56
+ import { NosniaAudioRecorder, NosniaAudioPlayer } from 'nosnia-audio-recorder';
56
57
  import { useState } from 'react';
57
58
 
58
59
  // Check and request permission
@@ -87,6 +88,16 @@ removeListener();
87
88
 
88
89
  // Or cancel (discard)
89
90
  await NosniaAudioRecorder.cancelRecording();
91
+
92
+ // Play it back
93
+ await NosniaAudioPlayer.startPlaying({ filePath });
94
+ // Optionally listen to progress
95
+ const removePlayback = NosniaAudioPlayer.addPlaybackProgressListener(({ currentTime, duration }) => {
96
+ console.log(`Progress: ${Math.floor(currentTime / 1000)} / ${Math.floor(duration / 1000)}s`);
97
+ });
98
+ // Stop playback
99
+ await NosniaAudioPlayer.stopPlaying();
100
+ removePlayback();
90
101
  ```
91
102
 
92
103
  ## API Reference
@@ -104,6 +115,20 @@ await NosniaAudioRecorder.cancelRecording();
104
115
  - `addRecordingProgressListener(callback: RecordingProgressCallback): () => void` - Add listener for duration updates (returns cleanup function)
105
116
  - `removeRecordingProgressListener(): void` - Remove duration listener
106
117
 
118
+ ### Player Methods
119
+
120
+ - `startPlaying(options: PlayOptions): Promise<void>` - Start playing an audio file
121
+ - `stopPlaying(): Promise<void>` - Stop playback and reset
122
+ - `pausePlaying(): Promise<void>` - Pause playback
123
+ - `resumePlaying(): Promise<void>` - Resume playback
124
+ - `seekToTime(time: number): Promise<void>` - Seek to time (seconds)
125
+ - `setVolume(volume: number): Promise<void>` - Set playback volume (0.0–1.0)
126
+ - `getPlayerStatus(): Promise<PlayerStatus>` - Get playback status
127
+ - `addPlaybackProgressListener(callback: PlaybackProgressCallback): () => void` - Listen to playback progress
128
+ - `addPlaybackCompleteListener(callback: () => void): () => void` - Listen for playback completion
129
+ - `removePlaybackProgressListener(): void` - Remove progress listener
130
+ - `removePlaybackCompleteListener(): void` - Remove completion listener
131
+
107
132
  ### Types
108
133
 
109
134
  ```ts
@@ -124,6 +149,25 @@ type RecordingProgressCallback = (data: {
124
149
  duration: number; // In milliseconds
125
150
  isRecording: boolean;
126
151
  }) => void;
152
+
153
+ interface PlayOptions {
154
+ filePath: string;
155
+ volume?: number; // 0.0 - 1.0
156
+ loop?: boolean; // repeat playback
157
+ }
158
+
159
+ interface PlayerStatus {
160
+ isPlaying: boolean;
161
+ duration: number; // In milliseconds
162
+ currentTime: number; // In milliseconds
163
+ currentFilePath?: string;
164
+ }
165
+
166
+ type PlaybackProgressCallback = (data: {
167
+ currentTime: number;
168
+ duration: number;
169
+ isPlaying: boolean;
170
+ }) => void;
127
171
  ```
128
172
 
129
173
  ## Platform Setup
@@ -0,0 +1,238 @@
1
+ package com.nosniaaudiorecorder
2
+
3
+ import android.media.MediaPlayer
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import com.facebook.react.bridge.Promise
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReadableMap
9
+ import com.facebook.react.bridge.WritableMap
10
+ import com.facebook.react.bridge.WritableNativeMap
11
+ import com.facebook.react.bridge.Arguments
12
+ import com.facebook.react.modules.core.DeviceEventManagerModule
13
+ import com.facebook.react.module.annotations.ReactModule
14
+ import java.io.File
15
+
16
+ @ReactModule(name = NosniaAudioPlayerModule.NAME)
17
+ class NosniaAudioPlayerModule(reactContext: ReactApplicationContext) :
18
+ NativeNosniaAudioPlayerSpec(reactContext) {
19
+
20
+ private var mediaPlayer: MediaPlayer? = null
21
+ private var currentFilePath: String? = null
22
+ private var isPlaying = false
23
+
24
+ private val progressHandler = Handler(Looper.getMainLooper())
25
+ private val progressRunnable = object : Runnable {
26
+ override fun run() {
27
+ mediaPlayer?.let { player ->
28
+ if (isPlaying && player.isPlaying) {
29
+ val currentTime = player.currentPosition.toLong()
30
+ val duration = player.duration.toLong()
31
+
32
+ sendEvent("onPlaybackProgress", Arguments.createMap().apply {
33
+ putDouble("currentTime", currentTime.toDouble())
34
+ putDouble("duration", duration.toDouble())
35
+ putBoolean("isPlaying", true)
36
+ })
37
+
38
+ progressHandler.postDelayed(this, 100)
39
+ }
40
+ }
41
+ }
42
+ }
43
+
44
+ private fun sendEvent(eventName: String, params: WritableMap?) {
45
+ reactApplicationContext
46
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
47
+ .emit(eventName, params)
48
+ }
49
+
50
+ private fun startProgressUpdates() {
51
+ progressHandler.post(progressRunnable)
52
+ }
53
+
54
+ private fun stopProgressUpdates() {
55
+ progressHandler.removeCallbacks(progressRunnable)
56
+ }
57
+
58
+ override fun getName(): String {
59
+ return NAME
60
+ }
61
+
62
+ override fun startPlaying(options: ReadableMap, promise: Promise) {
63
+ try {
64
+ val filePath = options.getString("filePath")
65
+ if (filePath == null) {
66
+ promise.reject("INVALID_PATH", "File path is required")
67
+ return
68
+ }
69
+
70
+ val volume = if (options.hasKey("volume")) {
71
+ options.getDouble("volume").toFloat()
72
+ } else {
73
+ 1.0f
74
+ }
75
+
76
+ val loop = if (options.hasKey("loop")) {
77
+ options.getBoolean("loop")
78
+ } else {
79
+ false
80
+ }
81
+
82
+ // Check if file exists
83
+ val file = File(filePath)
84
+ if (!file.exists()) {
85
+ promise.reject("FILE_NOT_FOUND", "Audio file not found: $filePath")
86
+ return
87
+ }
88
+
89
+ // Release existing player if any
90
+ mediaPlayer?.release()
91
+
92
+ mediaPlayer = MediaPlayer().apply {
93
+ setDataSource(filePath)
94
+ setVolume(volume, volume)
95
+ isLooping = loop
96
+
97
+ setOnCompletionListener {
98
+ isPlaying = false
99
+ stopProgressUpdates()
100
+ sendEvent("onPlaybackComplete", Arguments.createMap())
101
+ }
102
+
103
+ setOnErrorListener { _, what, extra ->
104
+ isPlaying = false
105
+ stopProgressUpdates()
106
+ true
107
+ }
108
+
109
+ prepare()
110
+ start()
111
+ }
112
+
113
+ isPlaying = true
114
+ currentFilePath = filePath
115
+ startProgressUpdates()
116
+ promise.resolve(null)
117
+ } catch (e: Exception) {
118
+ mediaPlayer?.release()
119
+ mediaPlayer = null
120
+ isPlaying = false
121
+ promise.reject("START_PLAYING_ERROR", e.message, e)
122
+ }
123
+ }
124
+
125
+ override fun stopPlaying(promise: Promise) {
126
+ try {
127
+ stopProgressUpdates()
128
+
129
+ mediaPlayer?.apply {
130
+ if (isPlaying) {
131
+ stop()
132
+ }
133
+ release()
134
+ }
135
+ mediaPlayer = null
136
+ isPlaying = false
137
+ currentFilePath = null
138
+
139
+ promise.resolve(null)
140
+ } catch (e: Exception) {
141
+ promise.reject("STOP_PLAYING_ERROR", e.message, e)
142
+ }
143
+ }
144
+
145
+ override fun pausePlaying(promise: Promise) {
146
+ try {
147
+ if (!isPlaying || mediaPlayer == null) {
148
+ promise.reject("NOT_PLAYING", "No playback in progress")
149
+ return
150
+ }
151
+
152
+ mediaPlayer?.pause()
153
+ isPlaying = false
154
+ promise.resolve(null)
155
+ } catch (e: Exception) {
156
+ promise.reject("PAUSE_ERROR", e.message, e)
157
+ }
158
+ }
159
+
160
+ override fun resumePlaying(promise: Promise) {
161
+ try {
162
+ if (isPlaying || mediaPlayer == null) {
163
+ promise.reject("NOT_PAUSED", "Playback is not paused")
164
+ return
165
+ }
166
+
167
+ mediaPlayer?.start()
168
+ isPlaying = true
169
+ promise.resolve(null)
170
+ } catch (e: Exception) {
171
+ promise.reject("RESUME_ERROR", e.message, e)
172
+ }
173
+ }
174
+
175
+ override fun seekToTime(time: Double, promise: Promise) {
176
+ try {
177
+ if (mediaPlayer == null) {
178
+ promise.reject("NO_PLAYER", "No audio player initialized")
179
+ return
180
+ }
181
+
182
+ val timeMs = (time * 1000).toInt()
183
+ mediaPlayer?.seekTo(timeMs)
184
+ promise.resolve(null)
185
+ } catch (e: Exception) {
186
+ promise.reject("SEEK_ERROR", e.message, e)
187
+ }
188
+ }
189
+
190
+ override fun setVolume(volume: Double, promise: Promise) {
191
+ try {
192
+ if (mediaPlayer == null) {
193
+ promise.reject("NO_PLAYER", "No audio player initialized")
194
+ return
195
+ }
196
+
197
+ val volumeFloat = volume.toFloat()
198
+ mediaPlayer?.setVolume(volumeFloat, volumeFloat)
199
+ promise.resolve(null)
200
+ } catch (e: Exception) {
201
+ promise.reject("SET_VOLUME_ERROR", e.message, e)
202
+ }
203
+ }
204
+
205
+ override fun getPlayerStatus(promise: Promise) {
206
+ try {
207
+ val status = WritableNativeMap().apply {
208
+ putBoolean("isPlaying", isPlaying)
209
+
210
+ if (mediaPlayer != null) {
211
+ putDouble("duration", mediaPlayer!!.duration.toDouble())
212
+ putDouble("currentTime", mediaPlayer!!.currentPosition.toDouble())
213
+ } else {
214
+ putDouble("duration", 0.0)
215
+ putDouble("currentTime", 0.0)
216
+ }
217
+
218
+ if (currentFilePath != null) {
219
+ putString("currentFilePath", currentFilePath)
220
+ }
221
+ }
222
+ promise.resolve(status)
223
+ } catch (e: Exception) {
224
+ promise.reject("STATUS_ERROR", e.message, e)
225
+ }
226
+ }
227
+
228
+ override fun onCatalystInstanceDestroy() {
229
+ super.onCatalystInstanceDestroy()
230
+ stopProgressUpdates()
231
+ mediaPlayer?.release()
232
+ mediaPlayer = null
233
+ }
234
+
235
+ companion object {
236
+ const val NAME = "NosniaAudioPlayer"
237
+ }
238
+ }
@@ -72,39 +72,109 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
72
72
 
73
73
  override fun startRecording(options: ReadableMap, promise: Promise) {
74
74
  try {
75
+ // Prevent concurrent recordings
75
76
  if (isRecording) {
76
77
  promise.reject("ALREADY_RECORDING", "Recording is already in progress")
77
78
  return
78
79
  }
79
80
 
80
- val filename = options.getString("filename") ?: generateFilename()
81
- val bitrate = options.getInt("bitrate").takeIf { it > 0 } ?: 128000
82
- val channels = options.getInt("channels").takeIf { it > 0 } ?: 1
83
- val sampleRate = options.getInt("sampleRate").takeIf { it > 0 } ?: 44100
81
+ // Validate and get filename
82
+ val filename = options.getString("filename")?.takeIf { it.isNotBlank() } ?: generateFilename()
83
+
84
+ // Validate and sanitize audio parameters with bounds checking
85
+ val bitrate = options.getInt("bitrate").let {
86
+ when {
87
+ it <= 0 -> 128000 // Default
88
+ it > 320000 -> 320000 // Max reasonable bitrate
89
+ else -> it
90
+ }
91
+ }
92
+
93
+ val channels = options.getInt("channels").let {
94
+ when {
95
+ it <= 0 -> 1 // Default to mono
96
+ it > 2 -> 2 // Max 2 channels (stereo)
97
+ else -> it
98
+ }
99
+ }
100
+
101
+ val sampleRate = options.getInt("sampleRate").let {
102
+ when {
103
+ it <= 0 -> 44100 // Default
104
+ it < 8000 -> 8000 // Min reasonable sample rate
105
+ it > 48000 -> 48000 // Max reasonable sample rate
106
+ else -> it
107
+ }
108
+ }
84
109
 
110
+ // Get and validate recording directory
85
111
  val recordingDir = getRecordingDirectory()
86
112
  if (!recordingDir.exists()) {
87
- recordingDir.mkdirs()
113
+ if (!recordingDir.mkdirs()) {
114
+ promise.reject("DIRECTORY_ERROR", "Failed to create recording directory")
115
+ return
116
+ }
117
+ }
118
+
119
+ // Validate directory is writable
120
+ if (!recordingDir.canWrite()) {
121
+ promise.reject("DIRECTORY_ERROR", "Recording directory is not writable")
122
+ return
123
+ }
124
+
125
+ // Create file path
126
+ val recordingFile = File(recordingDir, filename)
127
+ currentFilePath = recordingFile.absolutePath
128
+
129
+ if (currentFilePath == null || currentFilePath?.isEmpty() == true) {
130
+ promise.reject("PATH_ERROR", "Invalid file path")
131
+ return
88
132
  }
89
133
 
90
- currentFilePath = File(recordingDir, filename).absolutePath
134
+ // Clean up any existing file
135
+ try {
136
+ if (recordingFile.exists()) {
137
+ recordingFile.delete()
138
+ }
139
+ } catch (e: Exception) {
140
+ // Continue even if cleanup fails
141
+ }
91
142
 
143
+ // Create and configure media recorder
92
144
  mediaRecorder = (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
93
145
  MediaRecorder(reactApplicationContext)
94
146
  } else {
95
147
  @Suppress("DEPRECATION")
96
148
  MediaRecorder()
97
- }).apply {
98
- setAudioSource(MediaRecorder.AudioSource.MIC)
99
- // Use MPEG output format for MP3-compatible encoding
100
- setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
101
- setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
102
- setAudioEncodingBitRate(bitrate)
103
- setAudioChannels(channels)
104
- setAudioSamplingRate(sampleRate)
105
- setOutputFile(currentFilePath)
106
- prepare()
107
- start()
149
+ })
150
+
151
+ if (mediaRecorder == null) {
152
+ promise.reject("INIT_RECORDER_ERROR", "Failed to create MediaRecorder instance")
153
+ return
154
+ }
155
+
156
+ try {
157
+ mediaRecorder?.apply {
158
+ setAudioSource(MediaRecorder.AudioSource.MIC)
159
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
160
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
161
+ setAudioEncodingBitRate(bitrate)
162
+ setAudioChannels(channels)
163
+ setAudioSamplingRate(sampleRate)
164
+ setOutputFile(currentFilePath)
165
+
166
+ // Prepare recorder
167
+ prepare()
168
+
169
+ // Start recording
170
+ start()
171
+ }
172
+ } catch (e: Exception) {
173
+ mediaRecorder?.release()
174
+ mediaRecorder = null
175
+ currentFilePath = null
176
+ promise.reject("START_RECORDING_ERROR", e.message ?: "Failed to start recording", e)
177
+ return
108
178
  }
109
179
 
110
180
  isRecording = true
@@ -115,75 +185,122 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
115
185
  mediaRecorder?.release()
116
186
  mediaRecorder = null
117
187
  isRecording = false
118
- promise.reject("START_RECORDING_ERROR", e.message, e)
188
+ promise.reject("START_RECORDING_ERROR", e.message ?: "Unknown error", e)
119
189
  }
120
190
  }
121
191
 
122
192
  override fun stopRecording(promise: Promise) {
123
193
  try {
124
- if (!isRecording && mediaRecorder == null) {
194
+ if (!isRecording || mediaRecorder == null) {
125
195
  promise.reject("NOT_RECORDING", "No recording in progress")
126
196
  return
127
197
  }
128
198
 
129
199
  stopProgressUpdates()
130
- mediaRecorder?.apply {
131
- stop()
132
- release()
200
+
201
+ try {
202
+ mediaRecorder?.apply {
203
+ if (isRecording) {
204
+ stop()
205
+ }
206
+ release()
207
+ }
208
+ } catch (e: Exception) {
209
+ // Try to release even if stop fails
210
+ try {
211
+ mediaRecorder?.release()
212
+ } catch (releaseError: Exception) {
213
+ // Ignore
214
+ }
133
215
  }
216
+
134
217
  mediaRecorder = null
135
218
  isRecording = false
136
219
  isPaused = false
137
220
 
221
+ // Validate file exists before returning
138
222
  val filePath = currentFilePath ?: ""
223
+ if (filePath.isNotEmpty()) {
224
+ try {
225
+ if (!File(filePath).exists()) {
226
+ promise.reject("FILE_NOT_FOUND", "Recording file was not created")
227
+ return
228
+ }
229
+ } catch (e: Exception) {
230
+ // Continue anyway, file might exist
231
+ }
232
+ }
233
+
139
234
  currentFilePath = null
140
235
  promise.resolve(filePath)
141
236
  } catch (e: Exception) {
142
237
  stopProgressUpdates()
143
- mediaRecorder?.release()
238
+ try {
239
+ mediaRecorder?.release()
240
+ } catch (releaseError: Exception) {
241
+ // Ignore
242
+ }
144
243
  mediaRecorder = null
145
244
  isRecording = false
146
- promise.reject("STOP_RECORDING_ERROR", e.message, e)
245
+ promise.reject("STOP_RECORDING_ERROR", e.message ?: "Failed to stop recording", e)
147
246
  }
148
247
  }
149
248
 
150
249
  override fun pauseRecording(promise: Promise) {
151
250
  try {
152
- if (!isRecording) {
251
+ if (!isRecording || mediaRecorder == null) {
153
252
  promise.reject("NOT_RECORDING", "No recording in progress")
154
253
  return
155
254
  }
156
255
 
256
+ if (isPaused) {
257
+ promise.reject("ALREADY_PAUSED", "Recording is already paused")
258
+ return
259
+ }
260
+
157
261
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
158
- mediaRecorder?.pause()
159
- isPaused = true
160
- pauseStartTime = System.currentTimeMillis()
161
- promise.resolve(null)
262
+ try {
263
+ mediaRecorder?.pause()
264
+ isPaused = true
265
+ pauseStartTime = System.currentTimeMillis()
266
+ promise.resolve(null)
267
+ } catch (e: Exception) {
268
+ promise.reject("PAUSE_ERROR", e.message ?: "Failed to pause recording", e)
269
+ }
162
270
  } else {
163
- promise.reject("NOT_SUPPORTED", "Pause is not supported on this API level")
271
+ promise.reject("PAUSE_NOT_SUPPORTED", "Pause is not supported on this Android version")
164
272
  }
165
273
  } catch (e: Exception) {
166
- promise.reject("PAUSE_ERROR", e.message, e)
274
+ promise.reject("PAUSE_ERROR", e.message ?: "Unknown error during pause", e)
167
275
  }
168
276
  }
169
277
 
170
278
  override fun resumeRecording(promise: Promise) {
171
279
  try {
172
- if (!isRecording || !isPaused) {
280
+ if (!isRecording || mediaRecorder == null) {
281
+ promise.reject("NOT_RECORDING", "No recording in progress")
282
+ return
283
+ }
284
+
285
+ if (!isPaused) {
173
286
  promise.reject("NOT_PAUSED", "Recording is not paused")
174
287
  return
175
288
  }
176
289
 
177
290
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
178
- mediaRecorder?.resume()
179
- isPaused = false
180
- pausedDuration += System.currentTimeMillis() - pauseStartTime
181
- promise.resolve(null)
291
+ try {
292
+ mediaRecorder?.resume()
293
+ isPaused = false
294
+ pausedDuration += System.currentTimeMillis() - pauseStartTime
295
+ promise.resolve(null)
296
+ } catch (e: Exception) {
297
+ promise.reject("RESUME_ERROR", e.message ?: "Failed to resume recording", e)
298
+ }
182
299
  } else {
183
- promise.reject("NOT_SUPPORTED", "Resume is not supported on this API level")
300
+ promise.reject("RESUME_NOT_SUPPORTED", "Resume is not supported on this Android version")
184
301
  }
185
302
  } catch (e: Exception) {
186
- promise.reject("RESUME_ERROR", e.message, e)
303
+ promise.reject("RESUME_ERROR", e.message ?: "Unknown error during resume", e)
187
304
  }
188
305
  }
189
306
 
@@ -191,46 +308,83 @@ class NosniaAudioRecorderModule(reactContext: ReactApplicationContext) :
191
308
  try {
192
309
  stopProgressUpdates()
193
310
 
194
- mediaRecorder?.apply {
311
+ // Safely stop recorder
312
+ try {
313
+ mediaRecorder?.apply {
314
+ if (isRecording || isPaused) {
315
+ stop()
316
+ }
317
+ release()
318
+ }
319
+ } catch (e: Exception) {
320
+ // Try to release even if stop fails
195
321
  try {
196
- stop()
197
- } catch (e: Exception) {
198
- // Might throw if already stopped
322
+ mediaRecorder?.release()
323
+ } catch (releaseError: Exception) {
324
+ // Ignore
199
325
  }
200
- release()
201
326
  }
327
+
202
328
  mediaRecorder = null
203
329
 
330
+ // Safely delete recording file
204
331
  currentFilePath?.let { filePath ->
205
- File(filePath).delete()
332
+ try {
333
+ val file = File(filePath)
334
+ if (file.exists()) {
335
+ file.delete()
336
+ }
337
+ } catch (e: Exception) {
338
+ // Log but don't fail - file cleanup is not critical
339
+ }
206
340
  }
341
+
207
342
  currentFilePath = null
208
343
  isRecording = false
209
344
  isPaused = false
345
+ pausedDuration = 0
346
+ pauseStartTime = 0
210
347
 
211
348
  promise.resolve(null)
212
349
  } catch (e: Exception) {
213
- promise.reject("CANCEL_ERROR", e.message, e)
350
+ mediaRecorder = null
351
+ isRecording = false
352
+ promise.reject("CANCEL_ERROR", e.message ?: "Failed to cancel recording", e)
214
353
  }
215
354
  }
216
355
 
217
356
  override fun getRecorderStatus(promise: Promise) {
218
357
  try {
219
358
  val currentTime = System.currentTimeMillis()
220
- val duration = if (isRecording) {
359
+
360
+ // Safely calculate duration
361
+ val duration = if (isRecording && mediaRecorder != null) {
221
362
  if (isPaused) {
222
- pauseStartTime - startTime - pausedDuration
363
+ // Paused: duration = pause time - start time - previous paused duration
364
+ val pausedDur = pauseStartTime - startTime - pausedDuration
365
+ if (pausedDur < 0) 0L else pausedDur
223
366
  } else {
224
- currentTime - startTime - pausedDuration
367
+ // Recording: duration = current time - start time - previous paused duration
368
+ val recordingDur = currentTime - startTime - pausedDuration
369
+ if (recordingDur < 0) 0L else recordingDur
225
370
  }
226
371
  } else {
227
- 0
372
+ 0L
228
373
  }
229
374
 
230
375
  val status = WritableNativeMap().apply {
231
376
  putBoolean("isRecording", isRecording)
232
377
  putDouble("duration", duration.toDouble())
233
- if (currentFilePath != null) {
378
+ if (!currentFilePath.isNullOrEmpty()) {
379
+ putString("currentFilePath", currentFilePath)
380
+ }
381
+ }
382
+
383
+ promise.resolve(status)
384
+ } catch (e: Exception) {
385
+ promise.reject("STATUS_ERROR", e.message ?: "Failed to get recorder status", e)
386
+ }
387
+ }
234
388
  putString("currentFilePath", currentFilePath)
235
389
  }
236
390
  }