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 +1 -1
- package/android/build.gradle +3 -1
- package/android/src/main/AndroidManifest.xml +11 -0
- package/android/src/main/java/expo/modules/audioengine/AudioFocusManager.kt +69 -0
- package/android/src/main/java/expo/modules/audioengine/BackgroundPlaybackBridge.kt +40 -0
- package/android/src/main/java/expo/modules/audioengine/ExpoAudioEngineModule.kt +403 -7
- package/android/src/main/java/expo/modules/audioengine/MediaPlaybackService.kt +556 -0
- package/dist/AudioEngineModule.types.d.ts +10 -0
- package/package.json +1 -1
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.
|
|
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
|
package/android/build.gradle
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
238
|
+
AsyncFunction("play") {
|
|
119
239
|
val engine = audioEngine ?: throw Exception("Engine not initialized")
|
|
120
|
-
engine
|
|
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
|
|
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
|
|
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") {
|
|
408
|
-
|
|
409
|
-
|
|
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