sezo-audio-engine 0.0.12 → 0.0.13

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
@@ -38,7 +38,7 @@ allprojects {
38
38
  Optionally pin the engine version in `android/gradle.properties`:
39
39
 
40
40
  ```properties
41
- sezoAudioEngineVersion=android-engine-v0.1.5
41
+ sezoAudioEngineVersion=android-engine-v0.1.6
42
42
  ```
43
43
 
44
44
  If you want to build from source instead, include the local engine module in
@@ -38,13 +38,15 @@ android {
38
38
  dependencies {
39
39
  implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.0"
40
40
  implementation project(":expo-modules-core")
41
+ implementation "androidx.core:core-ktx:1.13.1"
42
+ implementation "androidx.media:media:1.7.0"
41
43
 
42
44
  // Link to the android-engine package when available, otherwise fall back to JitPack.
43
45
  def androidEngineProject = rootProject.findProject(":android-engine")
44
46
  if (androidEngineProject != null) {
45
47
  implementation androidEngineProject
46
48
  } else {
47
- def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.5"
49
+ def sezoAudioEngineVersion = project.findProperty("sezoAudioEngineVersion") ?: "android-engine-v0.1.6"
48
50
  implementation "com.github.Sepzie:SezoAudioEngine:${sezoAudioEngineVersion}"
49
51
  }
50
52
  }
@@ -0,0 +1,11 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
4
+
5
+ <application>
6
+ <service
7
+ android:name=".MediaPlaybackService"
8
+ android:exported="false"
9
+ android:foregroundServiceType="mediaPlayback" />
10
+ </application>
11
+ </manifest>
@@ -0,0 +1,69 @@
1
+ package expo.modules.audioengine
2
+
3
+ import android.content.Context
4
+ import android.media.AudioAttributes
5
+ import android.media.AudioFocusRequest
6
+ import android.media.AudioManager
7
+ import android.os.Build
8
+
9
+ internal class AudioFocusManager(
10
+ context: Context,
11
+ private val onAudioFocusChanged: (Int) -> Unit
12
+ ) {
13
+ private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
14
+ private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
15
+ onAudioFocusChanged(change)
16
+ }
17
+
18
+ private var focusRequest: AudioFocusRequest? = null
19
+ @Volatile
20
+ private var hasFocus = false
21
+
22
+ fun requestFocus(): Boolean {
23
+ if (hasFocus) {
24
+ return true
25
+ }
26
+
27
+ val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
28
+ if (focusRequest == null) {
29
+ val attributes = AudioAttributes.Builder()
30
+ .setUsage(AudioAttributes.USAGE_MEDIA)
31
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
32
+ .build()
33
+ focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
34
+ .setAudioAttributes(attributes)
35
+ .setAcceptsDelayedFocusGain(false)
36
+ .setWillPauseWhenDucked(false)
37
+ .setOnAudioFocusChangeListener(focusListener)
38
+ .build()
39
+ }
40
+ audioManager.requestAudioFocus(focusRequest!!)
41
+ } else {
42
+ @Suppress("DEPRECATION")
43
+ audioManager.requestAudioFocus(
44
+ focusListener,
45
+ AudioManager.STREAM_MUSIC,
46
+ AudioManager.AUDIOFOCUS_GAIN
47
+ )
48
+ }
49
+
50
+ hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
51
+ return hasFocus
52
+ }
53
+
54
+ fun abandonFocus() {
55
+ if (!hasFocus) {
56
+ return
57
+ }
58
+
59
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
60
+ focusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
61
+ } else {
62
+ @Suppress("DEPRECATION")
63
+ audioManager.abandonAudioFocus(focusListener)
64
+ }
65
+ hasFocus = false
66
+ }
67
+
68
+ fun hasAudioFocus(): Boolean = hasFocus
69
+ }
@@ -0,0 +1,40 @@
1
+ package expo.modules.audioengine
2
+
3
+ internal object BackgroundPlaybackBridge {
4
+ interface Controller {
5
+ fun play(): Boolean
6
+ fun pause()
7
+ fun stop()
8
+ fun seekTo(positionMs: Double)
9
+ fun isPlaying(): Boolean
10
+ fun getCurrentPositionMs(): Double
11
+ fun getDurationMs(): Double
12
+ }
13
+
14
+ @Volatile
15
+ private var controller: Controller? = null
16
+
17
+ fun setController(nextController: Controller?) {
18
+ controller = nextController
19
+ }
20
+
21
+ fun play(): Boolean = controller?.play() ?: false
22
+
23
+ fun pause() {
24
+ controller?.pause()
25
+ }
26
+
27
+ fun stop() {
28
+ controller?.stop()
29
+ }
30
+
31
+ fun seekTo(positionMs: Double) {
32
+ controller?.seekTo(positionMs)
33
+ }
34
+
35
+ fun isPlaying(): Boolean = controller?.isPlaying() ?: false
36
+
37
+ fun getCurrentPositionMs(): Double = controller?.getCurrentPositionMs() ?: 0.0
38
+
39
+ fun getDurationMs(): Double = controller?.getDurationMs() ?: 0.0
40
+ }
@@ -1,5 +1,7 @@
1
1
  package expo.modules.audioengine
2
2
 
3
+ import android.media.AudioManager
4
+ import android.os.Bundle
3
5
  import android.util.Log
4
6
  import com.sezo.audioengine.AudioEngine
5
7
  import expo.modules.kotlin.Promise
@@ -14,6 +16,40 @@ class ExpoAudioEngineModule : Module() {
14
16
  private val progressLogState = Collections.synchronizedMap(mutableMapOf<Long, Float>())
15
17
  private var lastExtractionJobId: Long? = null
16
18
  private var activeRecordingFormat: String = "aac"
19
+ private var wasPlayingBeforePause: Boolean = false
20
+ private var backgroundPlaybackEnabled: Boolean = false
21
+ private var shouldResumeAfterTransientFocusLoss: Boolean = false
22
+ private var volumeBeforeDuck: Float? = null
23
+ private var audioFocusManager: AudioFocusManager? = null
24
+ private val nowPlayingMetadata = mutableMapOf<String, Any?>()
25
+
26
+ private val backgroundController = object : BackgroundPlaybackBridge.Controller {
27
+ override fun play(): Boolean {
28
+ val engine = audioEngine ?: return false
29
+ return startPlaybackInternal(engine, fromSystem = true)
30
+ }
31
+
32
+ override fun pause() {
33
+ audioEngine?.let { pausePlaybackInternal(it, fromSystem = true) }
34
+ }
35
+
36
+ override fun stop() {
37
+ audioEngine?.let { stopPlaybackInternal(it, fromSystem = true, stopService = false) }
38
+ }
39
+
40
+ override fun seekTo(positionMs: Double) {
41
+ audioEngine?.seek(positionMs)
42
+ if (backgroundPlaybackEnabled) {
43
+ syncBackgroundService(isPlaying = audioEngine?.isPlaying() ?: false)
44
+ }
45
+ }
46
+
47
+ override fun isPlaying(): Boolean = audioEngine?.isPlaying() ?: false
48
+
49
+ override fun getCurrentPositionMs(): Double = audioEngine?.getCurrentPosition() ?: 0.0
50
+
51
+ override fun getDurationMs(): Double = audioEngine?.getDuration() ?: 0.0
52
+ }
17
53
 
18
54
  private data class PendingExtraction(
19
55
  val promise: Promise,
@@ -27,6 +63,84 @@ class ExpoAudioEngineModule : Module() {
27
63
  override fun definition() = ModuleDefinition {
28
64
  Name("ExpoAudioEngineModule")
29
65
 
66
+ OnActivityEntersBackground {
67
+ Log.d(TAG, "Activity entering background")
68
+ val engine = audioEngine ?: return@OnActivityEntersBackground
69
+
70
+ if (backgroundPlaybackEnabled) {
71
+ Log.d(TAG, "Background playback enabled, keeping engine running")
72
+ return@OnActivityEntersBackground
73
+ }
74
+
75
+ wasPlayingBeforePause = engine.isPlaying()
76
+ if (wasPlayingBeforePause) {
77
+ Log.d(TAG, "Pausing engine before background (was playing)")
78
+ pausePlaybackInternal(engine, fromSystem = true, keepAudioFocus = false)
79
+ sendEvent("engineStateChanged", mapOf(
80
+ "reason" to "backgrounded",
81
+ "wasPlaying" to true
82
+ ))
83
+ }
84
+ }
85
+
86
+ OnActivityEntersForeground {
87
+ Log.d(TAG, "Activity entering foreground")
88
+ val engine = audioEngine ?: return@OnActivityEntersForeground
89
+
90
+ // Check stream health on resume
91
+ if (!engine.isStreamHealthy()) {
92
+ Log.w(TAG, "Stream unhealthy on resume, attempting restart")
93
+ val restarted = engine.restartStream()
94
+ sendEvent("engineStateChanged", mapOf(
95
+ "reason" to "streamRestarted",
96
+ "success" to restarted
97
+ ))
98
+ if (!restarted) {
99
+ Log.e(TAG, "Failed to restart stream on resume")
100
+ sendEvent("error", mapOf(
101
+ "code" to "STREAM_DISCONNECTED",
102
+ "message" to "Audio stream could not be recovered after returning to foreground"
103
+ ))
104
+ return@OnActivityEntersForeground
105
+ }
106
+ }
107
+
108
+ // Resume playback if it was playing before backgrounding
109
+ if (wasPlayingBeforePause && !backgroundPlaybackEnabled) {
110
+ Log.d(TAG, "Resuming playback after returning to foreground")
111
+ val resumed = startPlaybackInternal(engine, fromSystem = true)
112
+ wasPlayingBeforePause = false
113
+ sendEvent(
114
+ "engineStateChanged",
115
+ mapOf(
116
+ "reason" to "resumed",
117
+ "wasPlaying" to true,
118
+ "success" to resumed
119
+ )
120
+ )
121
+ }
122
+ }
123
+
124
+ OnDestroy {
125
+ Log.d(TAG, "Module being destroyed, releasing engine")
126
+ teardownBackgroundPlayback(clearMetadata = true, stopService = true)
127
+ audioEngine?.let { engine ->
128
+ synchronized(pendingExtractions) {
129
+ pendingExtractions.keys.forEach { engine.cancelExtraction(it) }
130
+ pendingExtractions.clear()
131
+ }
132
+ progressLogState.clear()
133
+ engine.setExtractionProgressListener(null)
134
+ engine.setExtractionCompletionListener(null)
135
+ engine.setPlaybackStateListener(null)
136
+ engine.release()
137
+ engine.destroy()
138
+ }
139
+ BackgroundPlaybackBridge.setController(null)
140
+ audioEngine = null
141
+ loadedTrackIds.clear()
142
+ }
143
+
30
144
  AsyncFunction("initialize") { config: Map<String, Any?> ->
31
145
  Log.d(TAG, "Initialize called with config: $config")
32
146
 
@@ -40,6 +154,9 @@ class ExpoAudioEngineModule : Module() {
40
154
  throw Exception("Failed to initialize audio engine")
41
155
  }
42
156
 
157
+ ensureAudioFocusManager()
158
+ BackgroundPlaybackBridge.setController(backgroundController)
159
+
43
160
  audioEngine?.setPlaybackStateListener { state, positionMs, durationMs ->
44
161
  sendEvent(
45
162
  "playbackStateChange",
@@ -49,6 +166,7 @@ class ExpoAudioEngineModule : Module() {
49
166
  "durationMs" to durationMs
50
167
  )
51
168
  )
169
+ handlePlaybackStateChange(state)
52
170
  }
53
171
 
54
172
  Log.d(TAG, "Audio engine initialized successfully")
@@ -56,6 +174,7 @@ class ExpoAudioEngineModule : Module() {
56
174
 
57
175
  AsyncFunction("release") {
58
176
  Log.d(TAG, "Release called")
177
+ teardownBackgroundPlayback(clearMetadata = false, stopService = true)
59
178
  audioEngine?.let { engine ->
60
179
  synchronized(pendingExtractions) {
61
180
  pendingExtractions.keys.forEach { engine.cancelExtraction(it) }
@@ -68,6 +187,7 @@ class ExpoAudioEngineModule : Module() {
68
187
  engine.release()
69
188
  engine.destroy()
70
189
  }
190
+ BackgroundPlaybackBridge.setController(null)
71
191
  audioEngine = null
72
192
  loadedTrackIds.clear()
73
193
  Log.d(TAG, "Audio engine released")
@@ -115,21 +235,23 @@ class ExpoAudioEngineModule : Module() {
115
235
  loadedTrackIds.map { mapOf("id" to it) }
116
236
  }
117
237
 
118
- Function("play") {
238
+ AsyncFunction("play") {
119
239
  val engine = audioEngine ?: throw Exception("Engine not initialized")
120
- engine.play()
240
+ if (!startPlaybackInternal(engine, fromSystem = false)) {
241
+ throw Exception("Failed to start playback")
242
+ }
121
243
  Log.d(TAG, "Playback started")
122
244
  }
123
245
 
124
246
  Function("pause") {
125
247
  val engine = audioEngine ?: throw Exception("Engine not initialized")
126
- engine.pause()
248
+ pausePlaybackInternal(engine, fromSystem = false)
127
249
  Log.d(TAG, "Playback paused")
128
250
  }
129
251
 
130
252
  Function("stop") {
131
253
  val engine = audioEngine ?: throw Exception("Engine not initialized")
132
- engine.stop()
254
+ stopPlaybackInternal(engine, fromSystem = false)
133
255
  Log.d(TAG, "Playback stopped")
134
256
  }
135
257
 
@@ -404,9 +526,35 @@ class ExpoAudioEngineModule : Module() {
404
526
  Function("getOutputLevel") { 0.0 }
405
527
  Function("getTrackLevel") { _trackId: String -> 0.0 }
406
528
 
407
- AsyncFunction("enableBackgroundPlayback") { _metadata: Map<String, Any?> -> }
408
- Function("updateNowPlayingInfo") { _metadata: Map<String, Any?> -> }
409
- AsyncFunction("disableBackgroundPlayback") { }
529
+ AsyncFunction("enableBackgroundPlayback") { metadata: Map<String, Any?> ->
530
+ val engine = audioEngine ?: throw Exception("Engine not initialized")
531
+ backgroundPlaybackEnabled = true
532
+ mergeNowPlayingMetadata(metadata)
533
+ ensureAudioFocusManager()
534
+ BackgroundPlaybackBridge.setController(backgroundController)
535
+ if (engine.isPlaying()) {
536
+ requestAudioFocus()
537
+ }
538
+ syncBackgroundService(engine.isPlaying())
539
+ sendEvent("engineStateChanged", mapOf("reason" to "backgroundPlaybackEnabled"))
540
+ }
541
+ Function("updateNowPlayingInfo") { metadata: Map<String, Any?> ->
542
+ mergeNowPlayingMetadata(metadata)
543
+ if (backgroundPlaybackEnabled) {
544
+ syncBackgroundService(audioEngine?.isPlaying() ?: false)
545
+ }
546
+ }
547
+ AsyncFunction("disableBackgroundPlayback") {
548
+ backgroundPlaybackEnabled = false
549
+ shouldResumeAfterTransientFocusLoss = false
550
+ volumeBeforeDuck = null
551
+ stopBackgroundService()
552
+ if (!(audioEngine?.isPlaying() ?: false)) {
553
+ abandonAudioFocus()
554
+ }
555
+ nowPlayingMetadata.clear()
556
+ sendEvent("engineStateChanged", mapOf("reason" to "backgroundPlaybackDisabled"))
557
+ }
410
558
 
411
559
  Events(
412
560
  "playbackStateChange",
@@ -418,6 +566,7 @@ class ExpoAudioEngineModule : Module() {
418
566
  "recordingStopped",
419
567
  "extractionProgress",
420
568
  "extractionComplete",
569
+ "engineStateChanged",
421
570
  "error"
422
571
  )
423
572
  }
@@ -527,6 +676,253 @@ class ExpoAudioEngineModule : Module() {
527
676
  }
528
677
  }
529
678
 
679
+ private fun startPlaybackInternal(engine: AudioEngine, fromSystem: Boolean): Boolean {
680
+ if (!engine.isStreamHealthy()) {
681
+ Log.w(TAG, "Stream unhealthy before play, attempting restart")
682
+ if (!engine.restartStream()) {
683
+ if (!fromSystem) {
684
+ sendEvent(
685
+ "error",
686
+ mapOf(
687
+ "code" to "STREAM_DISCONNECTED",
688
+ "message" to "Audio stream is disconnected and could not be recovered"
689
+ )
690
+ )
691
+ }
692
+ return false
693
+ }
694
+ }
695
+
696
+ if (!requestAudioFocus()) {
697
+ Log.w(TAG, "Audio focus request denied")
698
+ if (!fromSystem) {
699
+ sendEvent(
700
+ "error",
701
+ mapOf(
702
+ "code" to "AUDIO_FOCUS_DENIED",
703
+ "message" to "Could not gain audio focus for playback"
704
+ )
705
+ )
706
+ }
707
+ return false
708
+ }
709
+
710
+ shouldResumeAfterTransientFocusLoss = false
711
+ restoreDuckedVolumeIfNeeded(engine)
712
+ engine.play()
713
+
714
+ if (backgroundPlaybackEnabled) {
715
+ syncBackgroundService(true)
716
+ }
717
+ return true
718
+ }
719
+
720
+ private fun pausePlaybackInternal(
721
+ engine: AudioEngine,
722
+ fromSystem: Boolean,
723
+ keepAudioFocus: Boolean = false
724
+ ) {
725
+ engine.pause()
726
+ shouldResumeAfterTransientFocusLoss = false
727
+ restoreDuckedVolumeIfNeeded(engine)
728
+
729
+ if (!keepAudioFocus) {
730
+ abandonAudioFocus()
731
+ }
732
+
733
+ if (backgroundPlaybackEnabled) {
734
+ syncBackgroundService(false)
735
+ }
736
+
737
+ if (fromSystem) {
738
+ sendEvent("engineStateChanged", mapOf("reason" to "pausedFromSystem"))
739
+ }
740
+ }
741
+
742
+ private fun stopPlaybackInternal(
743
+ engine: AudioEngine,
744
+ fromSystem: Boolean,
745
+ stopService: Boolean = true
746
+ ) {
747
+ engine.stop()
748
+ shouldResumeAfterTransientFocusLoss = false
749
+ restoreDuckedVolumeIfNeeded(engine)
750
+ abandonAudioFocus()
751
+
752
+ if (backgroundPlaybackEnabled && stopService) {
753
+ stopBackgroundService()
754
+ }
755
+
756
+ if (fromSystem) {
757
+ sendEvent("engineStateChanged", mapOf("reason" to "stoppedFromSystem"))
758
+ }
759
+ }
760
+
761
+ private fun handlePlaybackStateChange(state: String) {
762
+ if (!backgroundPlaybackEnabled) {
763
+ return
764
+ }
765
+
766
+ when (state) {
767
+ "playing" -> {
768
+ requestAudioFocus()
769
+ syncBackgroundService(true)
770
+ }
771
+ "paused" -> syncBackgroundService(false)
772
+ "stopped" -> stopBackgroundService()
773
+ }
774
+ }
775
+
776
+ private fun ensureAudioFocusManager(): AudioFocusManager? {
777
+ if (audioFocusManager != null) {
778
+ return audioFocusManager
779
+ }
780
+
781
+ val context = appContext.reactContext?.applicationContext ?: run {
782
+ Log.w(TAG, "React context unavailable; audio focus manager not initialized")
783
+ return null
784
+ }
785
+
786
+ audioFocusManager = AudioFocusManager(context) { focusChange ->
787
+ handleAudioFocusChange(focusChange)
788
+ }
789
+ return audioFocusManager
790
+ }
791
+
792
+ private fun requestAudioFocus(): Boolean {
793
+ return ensureAudioFocusManager()?.requestFocus() ?: false
794
+ }
795
+
796
+ private fun abandonAudioFocus() {
797
+ audioFocusManager?.abandonFocus()
798
+ }
799
+
800
+ private fun handleAudioFocusChange(focusChange: Int) {
801
+ val engine = audioEngine ?: return
802
+ when (focusChange) {
803
+ AudioManager.AUDIOFOCUS_GAIN -> {
804
+ restoreDuckedVolumeIfNeeded(engine)
805
+ if (shouldResumeAfterTransientFocusLoss) {
806
+ shouldResumeAfterTransientFocusLoss = false
807
+ startPlaybackInternal(engine, fromSystem = true)
808
+ }
809
+ }
810
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
811
+ if (engine.isPlaying()) {
812
+ shouldResumeAfterTransientFocusLoss = true
813
+ pausePlaybackInternal(engine, fromSystem = true, keepAudioFocus = true)
814
+ sendEvent("engineStateChanged", mapOf("reason" to "audioFocusLossTransient"))
815
+ }
816
+ }
817
+ AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
818
+ if (volumeBeforeDuck == null) {
819
+ volumeBeforeDuck = engine.getMasterVolume()
820
+ val duckedVolume = (volumeBeforeDuck!! * 0.3f).coerceAtLeast(0.05f)
821
+ engine.setMasterVolume(duckedVolume)
822
+ sendEvent(
823
+ "engineStateChanged",
824
+ mapOf(
825
+ "reason" to "audioFocusDuck",
826
+ "volume" to duckedVolume.toDouble()
827
+ )
828
+ )
829
+ }
830
+ }
831
+ AudioManager.AUDIOFOCUS_LOSS -> {
832
+ shouldResumeAfterTransientFocusLoss = false
833
+ if (engine.isPlaying()) {
834
+ pausePlaybackInternal(engine, fromSystem = true)
835
+ sendEvent("engineStateChanged", mapOf("reason" to "audioFocusLoss"))
836
+ } else {
837
+ abandonAudioFocus()
838
+ }
839
+ restoreDuckedVolumeIfNeeded(engine)
840
+ }
841
+ }
842
+ }
843
+
844
+ private fun restoreDuckedVolumeIfNeeded(engine: AudioEngine) {
845
+ val previousVolume = volumeBeforeDuck ?: return
846
+ engine.setMasterVolume(previousVolume)
847
+ volumeBeforeDuck = null
848
+ }
849
+
850
+ private fun mergeNowPlayingMetadata(metadata: Map<String, Any?>) {
851
+ metadata.forEach { (key, value) ->
852
+ if (value == null) {
853
+ nowPlayingMetadata.remove(key)
854
+ } else {
855
+ nowPlayingMetadata[key] = value
856
+ }
857
+ }
858
+ }
859
+
860
+ private fun syncBackgroundService(isPlaying: Boolean) {
861
+ if (!backgroundPlaybackEnabled) {
862
+ return
863
+ }
864
+
865
+ val context = appContext.reactContext?.applicationContext ?: return
866
+ val metadataBundle = mapToBundle(nowPlayingMetadata)
867
+ MediaPlaybackService.sync(context, metadataBundle, isPlaying)
868
+ }
869
+
870
+ private fun stopBackgroundService() {
871
+ val context = appContext.reactContext?.applicationContext ?: return
872
+ MediaPlaybackService.stop(context)
873
+ }
874
+
875
+ private fun teardownBackgroundPlayback(clearMetadata: Boolean, stopService: Boolean) {
876
+ backgroundPlaybackEnabled = false
877
+ shouldResumeAfterTransientFocusLoss = false
878
+ volumeBeforeDuck = null
879
+ if (stopService) {
880
+ stopBackgroundService()
881
+ }
882
+ if (clearMetadata) {
883
+ nowPlayingMetadata.clear()
884
+ }
885
+ abandonAudioFocus()
886
+ }
887
+
888
+ private fun mapToBundle(map: Map<String, Any?>): Bundle {
889
+ val bundle = Bundle()
890
+ map.forEach { (key, value) ->
891
+ when (value) {
892
+ null -> Unit
893
+ is String -> bundle.putString(key, value)
894
+ is Boolean -> bundle.putBoolean(key, value)
895
+ is Int -> bundle.putInt(key, value)
896
+ is Long -> bundle.putLong(key, value)
897
+ is Float -> bundle.putFloat(key, value)
898
+ is Double -> bundle.putDouble(key, value)
899
+ is Number -> {
900
+ val numberAsDouble = value.toDouble()
901
+ val isIntegral = numberAsDouble % 1.0 == 0.0
902
+ if (isIntegral &&
903
+ numberAsDouble >= Long.MIN_VALUE.toDouble() &&
904
+ numberAsDouble <= Long.MAX_VALUE.toDouble()
905
+ ) {
906
+ bundle.putLong(key, numberAsDouble.toLong())
907
+ } else {
908
+ bundle.putDouble(key, numberAsDouble)
909
+ }
910
+ }
911
+ is Map<*, *> -> {
912
+ val child = mutableMapOf<String, Any?>()
913
+ value.forEach { (nestedKey, nestedValue) ->
914
+ if (nestedKey is String) {
915
+ child[nestedKey] = nestedValue
916
+ }
917
+ }
918
+ bundle.putBundle(key, mapToBundle(child))
919
+ }
920
+ else -> bundle.putString(key, value.toString())
921
+ }
922
+ }
923
+ return bundle
924
+ }
925
+
530
926
  private fun getCacheDir(): String {
531
927
  return appContext.reactContext?.cacheDir?.absolutePath ?: "/tmp"
532
928
  }
@@ -0,0 +1,556 @@
1
+ package expo.modules.audioengine
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.PendingIntent
7
+ import android.app.Service
8
+ import android.content.BroadcastReceiver
9
+ import android.content.Context
10
+ import android.content.Intent
11
+ import android.content.IntentFilter
12
+ import android.graphics.Bitmap
13
+ import android.graphics.BitmapFactory
14
+ import android.graphics.Color
15
+ import android.media.AudioManager
16
+ import android.net.Uri
17
+ import android.os.Build
18
+ import android.os.Bundle
19
+ import android.os.IBinder
20
+ import android.util.Log
21
+ import androidx.core.app.NotificationCompat
22
+ import androidx.core.app.NotificationManagerCompat
23
+ import androidx.core.content.ContextCompat
24
+ import androidx.media.app.NotificationCompat as MediaStyleNotificationCompat
25
+ import android.support.v4.media.MediaMetadataCompat
26
+ import android.support.v4.media.session.MediaSessionCompat
27
+ import android.support.v4.media.session.PlaybackStateCompat
28
+
29
+ internal class MediaPlaybackService : Service() {
30
+ private lateinit var mediaSession: MediaSessionCompat
31
+ private val notificationManager by lazy { NotificationManagerCompat.from(this) }
32
+
33
+ private var metadata = NowPlayingMetadata()
34
+ private var cardOptions = PlaybackCardOptions()
35
+ private var isPlaying = false
36
+ private var isForeground = false
37
+ private var noisyReceiverRegistered = false
38
+
39
+ private val noisyReceiver = object : BroadcastReceiver() {
40
+ override fun onReceive(context: Context?, intent: Intent?) {
41
+ if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) {
42
+ handlePause()
43
+ publishState()
44
+ }
45
+ }
46
+ }
47
+
48
+ override fun onCreate() {
49
+ super.onCreate()
50
+ createNotificationChannel()
51
+ mediaSession = MediaSessionCompat(this, TAG).apply {
52
+ setFlags(
53
+ MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
54
+ MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
55
+ )
56
+ setCallback(object : MediaSessionCompat.Callback() {
57
+ override fun onPlay() {
58
+ handlePlay()
59
+ publishState()
60
+ }
61
+
62
+ override fun onPause() {
63
+ handlePause()
64
+ publishState()
65
+ }
66
+
67
+ override fun onStop() {
68
+ handleStop()
69
+ publishState()
70
+ }
71
+
72
+ override fun onSeekTo(pos: Long) {
73
+ BackgroundPlaybackBridge.seekTo(pos.toDouble())
74
+ publishState()
75
+ }
76
+
77
+ override fun onSkipToPrevious() {
78
+ handleSeekBy(-cardOptions.seekStepMs)
79
+ publishState()
80
+ }
81
+
82
+ override fun onSkipToNext() {
83
+ handleSeekBy(cardOptions.seekStepMs)
84
+ publishState()
85
+ }
86
+ })
87
+ isActive = true
88
+ }
89
+ updatePlaybackState()
90
+ }
91
+
92
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
93
+ when (intent?.action) {
94
+ ACTION_PLAY -> handlePlay()
95
+ ACTION_PAUSE -> handlePause()
96
+ ACTION_TOGGLE -> if (isPlaying) handlePause() else handlePlay()
97
+ ACTION_STOP -> handleStop()
98
+ ACTION_PREVIOUS -> handleSeekBy(-cardOptions.seekStepMs)
99
+ ACTION_NEXT -> handleSeekBy(cardOptions.seekStepMs)
100
+ ACTION_STOP_SERVICE -> {
101
+ stopSelf()
102
+ return START_NOT_STICKY
103
+ }
104
+ ACTION_UPDATE, null -> {
105
+ applyMetadataBundle(intent?.getBundleExtra(EXTRA_METADATA))
106
+ if (intent?.hasExtra(EXTRA_IS_PLAYING) == true) {
107
+ isPlaying = intent.getBooleanExtra(EXTRA_IS_PLAYING, isPlaying)
108
+ }
109
+ }
110
+ else -> Log.w(TAG, "Unknown service action: ${intent.action}")
111
+ }
112
+
113
+ publishState()
114
+ return START_STICKY
115
+ }
116
+
117
+ override fun onDestroy() {
118
+ super.onDestroy()
119
+ unregisterNoisyReceiver()
120
+ if (isForeground) {
121
+ stopForegroundCompat(removeNotification = true)
122
+ isForeground = false
123
+ } else {
124
+ notificationManager.cancel(NOTIFICATION_ID)
125
+ }
126
+ mediaSession.release()
127
+ }
128
+
129
+ override fun onBind(intent: Intent?): IBinder? = null
130
+
131
+ private fun handlePlay() {
132
+ isPlaying = BackgroundPlaybackBridge.play()
133
+ }
134
+
135
+ private fun handlePause() {
136
+ BackgroundPlaybackBridge.pause()
137
+ isPlaying = false
138
+ }
139
+
140
+ private fun handleStop() {
141
+ BackgroundPlaybackBridge.stop()
142
+ isPlaying = false
143
+ }
144
+
145
+ private fun handleSeekBy(deltaMs: Long) {
146
+ val duration = BackgroundPlaybackBridge.getDurationMs().coerceAtLeast(0.0)
147
+ val current = BackgroundPlaybackBridge.getCurrentPositionMs().coerceAtLeast(0.0)
148
+ val unclampedTarget = current + deltaMs.toDouble()
149
+ val maxPosition = if (duration > 0.0) duration else Double.MAX_VALUE
150
+ val target = unclampedTarget.coerceIn(0.0, maxPosition)
151
+ BackgroundPlaybackBridge.seekTo(target)
152
+ }
153
+
154
+ private fun publishState() {
155
+ isPlaying = BackgroundPlaybackBridge.isPlaying()
156
+ updatePlaybackState()
157
+ mediaSession.setMetadata(buildMediaMetadata())
158
+
159
+ val notification = buildNotification()
160
+ if (isPlaying) {
161
+ registerNoisyReceiver()
162
+ if (!isForeground) {
163
+ startForeground(NOTIFICATION_ID, notification)
164
+ isForeground = true
165
+ } else {
166
+ notificationManager.notify(NOTIFICATION_ID, notification)
167
+ }
168
+ } else {
169
+ unregisterNoisyReceiver()
170
+ if (isForeground) {
171
+ stopForegroundCompat(removeNotification = false)
172
+ isForeground = false
173
+ }
174
+ notificationManager.notify(NOTIFICATION_ID, notification)
175
+ }
176
+ }
177
+
178
+ private fun stopForegroundCompat(removeNotification: Boolean) {
179
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
180
+ stopForeground(
181
+ if (removeNotification) {
182
+ Service.STOP_FOREGROUND_REMOVE
183
+ } else {
184
+ Service.STOP_FOREGROUND_DETACH
185
+ }
186
+ )
187
+ } else {
188
+ @Suppress("DEPRECATION")
189
+ stopForeground(removeNotification)
190
+ }
191
+ }
192
+
193
+ private fun updatePlaybackState() {
194
+ val actions = PlaybackStateCompat.ACTION_PLAY or
195
+ PlaybackStateCompat.ACTION_PAUSE or
196
+ PlaybackStateCompat.ACTION_PLAY_PAUSE or
197
+ PlaybackStateCompat.ACTION_STOP or
198
+ PlaybackStateCompat.ACTION_SEEK_TO
199
+
200
+ val state = if (isPlaying) {
201
+ PlaybackStateCompat.STATE_PLAYING
202
+ } else {
203
+ PlaybackStateCompat.STATE_PAUSED
204
+ }
205
+
206
+ mediaSession.setPlaybackState(
207
+ PlaybackStateCompat.Builder()
208
+ .setActions(actions)
209
+ .setState(
210
+ state,
211
+ BackgroundPlaybackBridge.getCurrentPositionMs().toLong(),
212
+ if (isPlaying) 1.0f else 0.0f
213
+ )
214
+ .build()
215
+ )
216
+ }
217
+
218
+ private fun buildMediaMetadata(): MediaMetadataCompat {
219
+ val builder = MediaMetadataCompat.Builder()
220
+ .putString(MediaMetadataCompat.METADATA_KEY_TITLE, metadata.title)
221
+ .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, metadata.artist ?: "")
222
+ .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, metadata.album ?: "")
223
+ .putLong(
224
+ MediaMetadataCompat.METADATA_KEY_DURATION,
225
+ BackgroundPlaybackBridge.getDurationMs().toLong()
226
+ )
227
+
228
+ resolveArtworkBitmap()?.let { bitmap ->
229
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
230
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
231
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap)
232
+ }
233
+ return builder.build()
234
+ }
235
+
236
+ private fun buildNotification(): Notification {
237
+ val style = MediaStyleNotificationCompat.MediaStyle()
238
+ .setMediaSession(mediaSession.sessionToken)
239
+
240
+ val builder = NotificationCompat.Builder(this, CHANNEL_ID)
241
+ .setSmallIcon(resolveSmallIconResId())
242
+ .setContentTitle(metadata.title.ifBlank { "Audio" })
243
+ .setContentText(metadata.artist ?: "")
244
+ .setSubText(metadata.album)
245
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
246
+ .setOnlyAlertOnce(true)
247
+ .setOngoing(isPlaying)
248
+ .setShowWhen(false)
249
+ .setStyle(style)
250
+
251
+ buildContentIntent()?.let { builder.setContentIntent(it) }
252
+ builder.setDeleteIntent(buildServicePendingIntent(ACTION_STOP))
253
+
254
+ resolveArtworkBitmap()?.let { builder.setLargeIcon(it) }
255
+ cardOptions.accentColor?.let { color ->
256
+ builder.setColorized(true)
257
+ builder.setColor(color)
258
+ }
259
+
260
+ val compactActions = mutableListOf<Int>()
261
+ var actionIndex = 0
262
+
263
+ if (cardOptions.showPrevious) {
264
+ builder.addAction(
265
+ android.R.drawable.ic_media_previous,
266
+ "Back",
267
+ buildServicePendingIntent(ACTION_PREVIOUS)
268
+ )
269
+ compactActions.add(actionIndex)
270
+ actionIndex++
271
+ }
272
+
273
+ val playPauseAction = if (isPlaying) ACTION_PAUSE else ACTION_PLAY
274
+ val playPauseIcon = if (isPlaying) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play
275
+ val playPauseLabel = if (isPlaying) "Pause" else "Play"
276
+ builder.addAction(playPauseIcon, playPauseLabel, buildServicePendingIntent(playPauseAction))
277
+ compactActions.add(actionIndex)
278
+ actionIndex++
279
+
280
+ if (cardOptions.showNext) {
281
+ builder.addAction(
282
+ android.R.drawable.ic_media_next,
283
+ "Forward",
284
+ buildServicePendingIntent(ACTION_NEXT)
285
+ )
286
+ compactActions.add(actionIndex)
287
+ actionIndex++
288
+ }
289
+
290
+ if (cardOptions.showStop) {
291
+ builder.addAction(
292
+ android.R.drawable.ic_menu_close_clear_cancel,
293
+ "Stop",
294
+ buildServicePendingIntent(ACTION_STOP)
295
+ )
296
+ }
297
+
298
+ style.setShowActionsInCompactView(*compactActions.take(3).toIntArray())
299
+ return builder.build()
300
+ }
301
+
302
+ private fun buildServicePendingIntent(action: String): PendingIntent {
303
+ val intent = Intent(this, MediaPlaybackService::class.java).apply { this.action = action }
304
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or
305
+ (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
306
+ return PendingIntent.getService(this, action.hashCode(), intent, flags)
307
+ }
308
+
309
+ private fun buildContentIntent(): PendingIntent? {
310
+ val launchIntent = packageManager.getLaunchIntentForPackage(packageName) ?: return null
311
+ launchIntent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
312
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or
313
+ (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
314
+ return PendingIntent.getActivity(this, 1001, launchIntent, flags)
315
+ }
316
+
317
+ private fun applyMetadataBundle(bundle: Bundle?) {
318
+ if (bundle == null) {
319
+ return
320
+ }
321
+
322
+ bundle.getString(KEY_TITLE)?.let { metadata.title = it }
323
+ if (bundle.containsKey(KEY_ARTIST)) {
324
+ metadata.artist = bundle.getString(KEY_ARTIST)
325
+ }
326
+ if (bundle.containsKey(KEY_ALBUM)) {
327
+ metadata.album = bundle.getString(KEY_ALBUM)
328
+ }
329
+ if (bundle.containsKey(KEY_ARTWORK)) {
330
+ metadata.artwork = bundle.getString(KEY_ARTWORK)
331
+ }
332
+ if (bundle.containsKey(KEY_LOGO)) {
333
+ metadata.logo = bundle.getString(KEY_LOGO)
334
+ }
335
+
336
+ val playbackCardBundle = bundle.getBundle(KEY_PLAYBACK_CARD)
337
+ if (playbackCardBundle != null) {
338
+ applyPlaybackCardBundle(playbackCardBundle)
339
+ }
340
+ }
341
+
342
+ private fun applyPlaybackCardBundle(bundle: Bundle) {
343
+ if (bundle.containsKey(KEY_CARD_SMALL_ICON)) {
344
+ cardOptions.smallIcon = bundle.getString(KEY_CARD_SMALL_ICON)
345
+ }
346
+ if (bundle.containsKey(KEY_CARD_SHOW_PREVIOUS)) {
347
+ cardOptions.showPrevious = bundle.getBoolean(KEY_CARD_SHOW_PREVIOUS)
348
+ }
349
+ if (bundle.containsKey(KEY_CARD_SHOW_NEXT)) {
350
+ cardOptions.showNext = bundle.getBoolean(KEY_CARD_SHOW_NEXT)
351
+ }
352
+ if (bundle.containsKey(KEY_CARD_SHOW_STOP)) {
353
+ cardOptions.showStop = bundle.getBoolean(KEY_CARD_SHOW_STOP)
354
+ }
355
+ if (bundle.containsKey(KEY_CARD_SEEK_STEP_MS)) {
356
+ val seekValue = bundle.get(KEY_CARD_SEEK_STEP_MS)
357
+ val seekStepMs = when (seekValue) {
358
+ is Long -> seekValue
359
+ is Int -> seekValue.toLong()
360
+ is Double -> seekValue.toLong()
361
+ is Float -> seekValue.toLong()
362
+ is String -> seekValue.toLongOrNull()
363
+ is Number -> seekValue.toLong()
364
+ else -> null
365
+ }
366
+ if (seekStepMs != null) {
367
+ cardOptions.seekStepMs = seekStepMs.coerceAtLeast(1000L)
368
+ }
369
+ }
370
+ if (bundle.containsKey(KEY_CARD_ACCENT_COLOR)) {
371
+ cardOptions.accentColor = parseColor(bundle.get(KEY_CARD_ACCENT_COLOR))
372
+ }
373
+ }
374
+
375
+ private fun parseColor(value: Any?): Int? {
376
+ return when (value) {
377
+ is Int -> value
378
+ is Long -> value.toInt()
379
+ is Double -> value.toInt()
380
+ is Float -> value.toInt()
381
+ is String -> try {
382
+ Color.parseColor(value)
383
+ } catch (_: IllegalArgumentException) {
384
+ null
385
+ }
386
+ else -> null
387
+ }
388
+ }
389
+
390
+ private fun resolveArtworkBitmap(): Bitmap? {
391
+ val artworkValue = metadata.artwork ?: metadata.logo ?: return null
392
+ return decodeBitmapFromString(artworkValue)
393
+ }
394
+
395
+ private fun decodeBitmapFromString(value: String): Bitmap? {
396
+ val path = when {
397
+ value.startsWith("file://") -> Uri.parse(value).path
398
+ value.startsWith("/") -> value
399
+ else -> null
400
+ }
401
+
402
+ if (!path.isNullOrBlank()) {
403
+ return BitmapFactory.decodeFile(path)
404
+ }
405
+
406
+ val resId = resolveResourceId(value)
407
+ if (resId != 0) {
408
+ return BitmapFactory.decodeResource(resources, resId)
409
+ }
410
+ return null
411
+ }
412
+
413
+ private fun resolveSmallIconResId(): Int {
414
+ val customIconName = cardOptions.smallIcon ?: metadata.logo
415
+ if (!customIconName.isNullOrBlank()) {
416
+ val customResId = resolveResourceId(customIconName)
417
+ if (customResId != 0) {
418
+ return customResId
419
+ }
420
+ }
421
+
422
+ val appIcon = applicationInfo.icon
423
+ if (appIcon != 0) {
424
+ return appIcon
425
+ }
426
+ return android.R.drawable.ic_media_play
427
+ }
428
+
429
+ private fun resolveResourceId(name: String): Int {
430
+ val cleaned = name.substringAfterLast("/").substringBeforeLast(".")
431
+ val drawable = resources.getIdentifier(cleaned, "drawable", packageName)
432
+ if (drawable != 0) {
433
+ return drawable
434
+ }
435
+ return resources.getIdentifier(cleaned, "mipmap", packageName)
436
+ }
437
+
438
+ private fun createNotificationChannel() {
439
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
440
+ return
441
+ }
442
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
443
+ if (manager.getNotificationChannel(CHANNEL_ID) != null) {
444
+ return
445
+ }
446
+
447
+ val channel = NotificationChannel(
448
+ CHANNEL_ID,
449
+ CHANNEL_NAME,
450
+ NotificationManager.IMPORTANCE_LOW
451
+ ).apply {
452
+ description = CHANNEL_DESCRIPTION
453
+ lockscreenVisibility = Notification.VISIBILITY_PUBLIC
454
+ }
455
+ manager.createNotificationChannel(channel)
456
+ }
457
+
458
+ private fun registerNoisyReceiver() {
459
+ if (noisyReceiverRegistered) {
460
+ return
461
+ }
462
+ registerReceiver(noisyReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
463
+ noisyReceiverRegistered = true
464
+ }
465
+
466
+ private fun unregisterNoisyReceiver() {
467
+ if (!noisyReceiverRegistered) {
468
+ return
469
+ }
470
+ try {
471
+ unregisterReceiver(noisyReceiver)
472
+ } catch (_: IllegalArgumentException) {
473
+ // Receiver was already unregistered.
474
+ }
475
+ noisyReceiverRegistered = false
476
+ }
477
+
478
+ private data class NowPlayingMetadata(
479
+ var title: String = "Audio",
480
+ var artist: String? = null,
481
+ var album: String? = null,
482
+ var artwork: String? = null,
483
+ var logo: String? = null
484
+ )
485
+
486
+ private data class PlaybackCardOptions(
487
+ var smallIcon: String? = null,
488
+ var accentColor: Int? = null,
489
+ var showPrevious: Boolean = false,
490
+ var showNext: Boolean = false,
491
+ var showStop: Boolean = true,
492
+ var seekStepMs: Long = 15000L
493
+ )
494
+
495
+ companion object {
496
+ private const val TAG = "MediaPlaybackService"
497
+ private const val CHANNEL_ID = "sezo_audio_playback"
498
+ private const val CHANNEL_NAME = "Audio playback"
499
+ private const val CHANNEL_DESCRIPTION = "Playback controls"
500
+ private const val NOTIFICATION_ID = 7342
501
+
502
+ const val ACTION_UPDATE = "expo.modules.audioengine.action.UPDATE"
503
+ const val ACTION_PLAY = "expo.modules.audioengine.action.PLAY"
504
+ const val ACTION_PAUSE = "expo.modules.audioengine.action.PAUSE"
505
+ const val ACTION_TOGGLE = "expo.modules.audioengine.action.TOGGLE"
506
+ const val ACTION_STOP = "expo.modules.audioengine.action.STOP"
507
+ const val ACTION_PREVIOUS = "expo.modules.audioengine.action.PREVIOUS"
508
+ const val ACTION_NEXT = "expo.modules.audioengine.action.NEXT"
509
+ const val ACTION_STOP_SERVICE = "expo.modules.audioengine.action.STOP_SERVICE"
510
+
511
+ private const val EXTRA_METADATA = "extra_metadata"
512
+ private const val EXTRA_IS_PLAYING = "extra_is_playing"
513
+
514
+ private const val KEY_TITLE = "title"
515
+ private const val KEY_ARTIST = "artist"
516
+ private const val KEY_ALBUM = "album"
517
+ private const val KEY_ARTWORK = "artwork"
518
+ private const val KEY_LOGO = "logo"
519
+ private const val KEY_PLAYBACK_CARD = "playbackCard"
520
+ private const val KEY_CARD_SMALL_ICON = "smallIcon"
521
+ private const val KEY_CARD_ACCENT_COLOR = "accentColor"
522
+ private const val KEY_CARD_SHOW_PREVIOUS = "showPrevious"
523
+ private const val KEY_CARD_SHOW_NEXT = "showNext"
524
+ private const val KEY_CARD_SHOW_STOP = "showStop"
525
+ private const val KEY_CARD_SEEK_STEP_MS = "seekStepMs"
526
+
527
+ fun sync(context: Context, metadata: Bundle, isPlaying: Boolean) {
528
+ val intent = Intent(context, MediaPlaybackService::class.java).apply {
529
+ action = ACTION_UPDATE
530
+ putExtra(EXTRA_METADATA, metadata)
531
+ putExtra(EXTRA_IS_PLAYING, isPlaying)
532
+ }
533
+
534
+ try {
535
+ if (isPlaying) {
536
+ ContextCompat.startForegroundService(context, intent)
537
+ } else {
538
+ context.startService(intent)
539
+ }
540
+ } catch (e: Exception) {
541
+ Log.e(TAG, "Failed to start/update playback service", e)
542
+ }
543
+ }
544
+
545
+ fun stop(context: Context) {
546
+ val stopIntent = Intent(context, MediaPlaybackService::class.java).apply {
547
+ action = ACTION_STOP_SERVICE
548
+ }
549
+ try {
550
+ context.startService(stopIntent)
551
+ } catch (e: Exception) {
552
+ Log.w(TAG, "Failed to request service stop", e)
553
+ }
554
+ }
555
+ }
556
+ }
@@ -53,6 +53,16 @@ export interface MediaMetadata {
53
53
  artist?: string;
54
54
  album?: string;
55
55
  artwork?: string;
56
+ logo?: string;
57
+ playbackCard?: PlaybackCardOptions;
58
+ }
59
+ export interface PlaybackCardOptions {
60
+ smallIcon?: string;
61
+ accentColor?: string | number;
62
+ showPrevious?: boolean;
63
+ showNext?: boolean;
64
+ showStop?: boolean;
65
+ seekStepMs?: number;
56
66
  }
57
67
  export type AudioEngineEvent = 'playbackStateChange' | 'positionUpdate' | 'playbackComplete' | 'trackLoaded' | 'trackUnloaded' | 'recordingStarted' | 'recordingStopped' | 'extractionProgress' | 'extractionComplete' | 'error';
58
68
  export interface AudioEngineError {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sezo-audio-engine",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "Cross-platform Expo module for the Sezo Audio Engine with iOS implementation and background playback.",
5
5
  "license": "MIT",
6
6
  "author": "Sezo",