capacitor-plugin-playlist 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +133 -127
  2. package/android/build.gradle +31 -18
  3. package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
  4. package/android/src/main/java/org/dwbn/plugins/playlist/AudioMediaItemFactory.java +96 -0
  5. package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +3 -3
  6. package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioPlayer.java +145 -188
  7. package/android/src/main/java/org/dwbn/plugins/playlist/RmxPlaybackErrorMapper.kt +26 -0
  8. package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +28 -28
  9. package/android/src/main/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridge.kt +102 -0
  10. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaybackProgress.kt +8 -0
  11. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +418 -183
  12. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicy.kt +98 -0
  13. package/android/src/main/java/org/dwbn/plugins/playlist/manager/RmxPlaybackState.kt +11 -0
  14. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandler.java +107 -211
  15. package/android/src/main/java/org/dwbn/plugins/playlist/service/GlideBitmapLoader.kt +121 -0
  16. package/android/src/main/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayer.kt +185 -0
  17. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicy.kt +253 -0
  18. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaService.kt +260 -68
  19. package/android/src/main/java/org/dwbn/plugins/playlist/service/PlaylistMediaSessionCallback.kt +39 -0
  20. package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryMimeTest.kt +98 -0
  21. package/android/src/test/java/org/dwbn/plugins/playlist/AudioMediaItemFactoryTest.java +71 -0
  22. package/android/src/test/java/org/dwbn/plugins/playlist/data/AudioTrackTest.kt +53 -0
  23. package/android/src/test/java/org/dwbn/plugins/playlist/handoff/VideoPlayerBridgeTest.kt +84 -0
  24. package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistPlaybackPolicyTest.kt +128 -0
  25. package/android/src/test/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandlerTest.kt +145 -0
  26. package/android/src/test/java/org/dwbn/plugins/playlist/service/GlideBitmapLoaderTest.kt +76 -0
  27. package/android/src/test/java/org/dwbn/plugins/playlist/service/HandoffForwardingPlayerTest.kt +282 -0
  28. package/android/src/test/java/org/dwbn/plugins/playlist/service/MediaNotificationPolicyTest.kt +285 -0
  29. package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationCommandsTest.kt +54 -0
  30. package/android/src/test/java/org/dwbn/plugins/playlist/service/VideoHandoffNotificationPolicyTest.kt +47 -0
  31. package/dist/docs.json +12 -846
  32. package/dist/esm/RmxAudioPlayer.d.ts +2 -2
  33. package/dist/esm/RmxAudioPlayer.js.map +1 -1
  34. package/dist/esm/definitions.d.ts +7 -7
  35. package/dist/esm/interfaces.d.ts +7 -1
  36. package/dist/esm/utils.d.ts +1 -1
  37. package/dist/esm/utils.js +2 -2
  38. package/dist/esm/utils.js.map +1 -1
  39. package/dist/esm/web.d.ts +3 -3
  40. package/dist/esm/web.js +5 -5
  41. package/dist/esm/web.js.map +1 -1
  42. package/dist/plugin.cjs.js +7 -7
  43. package/dist/plugin.cjs.js.map +1 -1
  44. package/dist/plugin.js +7 -7
  45. package/dist/plugin.js.map +1 -1
  46. package/ios/Sources/PlaylistPlugin/AVBidirectionalQueuePlayer.swift +45 -6
  47. package/ios/Sources/PlaylistPlugin/AudioTrack.swift +15 -2
  48. package/ios/Tests/PlaylistPluginTests/PositionResumeTests.swift +82 -0
  49. package/package.json +15 -19
  50. package/android/src/main/java/org/dwbn/plugins/playlist/notification/PlaylistNotificationProvider.kt +0 -26
  51. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioApi.kt +0 -114
  52. package/android/src/main/java/org/dwbn/plugins/playlist/playlist/BaseMediaApi.kt +0 -36
  53. package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaImageProvider.kt +0 -83
@@ -1,31 +1,39 @@
1
1
  package org.dwbn.plugins.playlist.manager
2
2
 
3
3
  import android.app.Application
4
+ import android.content.Intent
5
+ import android.os.Build
4
6
  import android.util.Log
5
7
  import androidx.annotation.FloatRange
6
8
  import androidx.annotation.IntRange
7
- import com.devbrackets.android.exomedia.listener.OnErrorListener
8
- import com.devbrackets.android.playlistcore.api.MediaPlayerApi
9
- import com.devbrackets.android.playlistcore.manager.ListPlaylistManager
9
+ import androidx.annotation.OptIn
10
+ import androidx.media3.common.MediaItem
11
+ import androidx.media3.common.Player
12
+ import androidx.media3.common.util.UnstableApi
13
+ import androidx.media3.exoplayer.ExoPlayer
14
+ import org.dwbn.plugins.playlist.AudioMediaItemFactory
10
15
  import org.dwbn.plugins.playlist.PlaylistItemOptions
16
+ import org.dwbn.plugins.playlist.RmxAudioPlayer
11
17
  import org.dwbn.plugins.playlist.TrackRemovalItem
12
18
  import org.dwbn.plugins.playlist.data.AudioTrack
13
- import org.dwbn.plugins.playlist.playlist.AudioApi
19
+ import org.dwbn.plugins.playlist.playlist.AudioPlaylistHandler
20
+ import org.dwbn.plugins.playlist.handoff.VideoPlayerBridge
21
+ import org.dwbn.plugins.playlist.service.MediaNotificationPolicy
14
22
  import org.dwbn.plugins.playlist.service.MediaService
15
23
  import java.lang.ref.WeakReference
16
- import java.util.*
17
-
18
- /**
19
- * A PlaylistManager that extends the [ListPlaylistManager] for use with the
20
- * [MediaService] which extends [com.devbrackets.android.playlistcore.service.BasePlaylistService].
21
- */
22
- class PlaylistManager(application: Application) :
23
- ListPlaylistManager<AudioTrack>(application, MediaService::class.java), OnErrorListener {
24
+ import java.util.ArrayList
25
+
26
+ @OptIn(UnstableApi::class)
27
+ class PlaylistManager(private val application: Application) {
24
28
  private val audioTracks: MutableList<AudioTrack> = ArrayList()
25
29
  private var volumeLeft = 1.0f
26
30
  private var volumeRight = 1.0f
27
31
  private var playbackSpeed = 1.0f
28
32
  var loop = false
33
+ set(value) {
34
+ field = value
35
+ applyPlayerSettings()
36
+ }
29
37
  var isShouldStopPlaylist = false
30
38
  var currentErrorTrack: AudioTrack? = null
31
39
 
@@ -33,88 +41,173 @@ class PlaylistManager(application: Application) :
33
41
  var videoHandoffForegroundRetain = false
34
42
  var mediaServiceInForeground = false
35
43
 
36
- // Really need a way to propagate the settings through the app
37
44
  var resetStreamOnPause = true
38
- var options: Options
45
+ var options: Options = Options(application.baseContext)
46
+ set(value) {
47
+ field = value
48
+ mediaServiceRef.get()?.applyNotificationIcon()
49
+ }
39
50
  private var mediaControlsListener = WeakReference<MediaControlsListener?>(null)
40
- private var errorListener = WeakReference<OnErrorListener?>(null)
41
- private var currentMediaPlayer: WeakReference<MediaPlayerApi<AudioTrack>?>? =
42
- WeakReference(null)
51
+ private var playbackStatusListener = WeakReference<RmxAudioPlayer?>(null)
52
+
53
+ private var player: ExoPlayer? = null
54
+ private var mediaServiceRef = WeakReference<MediaService?>(null)
55
+ private var handlerInstance: AudioPlaylistHandler? = null
56
+ private var seeking = false
57
+ private var sequentialErrors = 0
58
+ private var rmxPlaybackState = RmxPlaybackState.STOPPED
59
+ private var pendingBeginPlayback: PendingBegin? = null
60
+
61
+ private data class PendingBegin(val seekPosition: Long, val startPaused: Boolean)
43
62
 
44
- fun setOnErrorListener(listener: OnErrorListener?) {
45
- errorListener = WeakReference(listener)
63
+ var currentPosition: Int = INVALID_POSITION
64
+
65
+ val currentItem: AudioTrack?
66
+ get() {
67
+ val index = player?.currentMediaItemIndex ?: currentPosition
68
+ return if (index in audioTracks.indices) audioTracks[index] else null
69
+ }
70
+
71
+ val isPlaying: Boolean
72
+ get() = player?.isPlaying == true
73
+
74
+ val playlistHandler: AudioPlaylistHandler?
75
+ get() {
76
+ if (handlerInstance == null) {
77
+ handlerInstance = AudioPlaylistHandler(application, this)
78
+ }
79
+ return handlerInstance
80
+ }
81
+
82
+ fun setOnErrorListener(listener: RmxAudioPlayer?) {
83
+ playbackStatusListener = WeakReference(listener)
46
84
  }
47
85
 
48
86
  fun setMediaControlsListener(listener: MediaControlsListener?) {
49
87
  mediaControlsListener = WeakReference(listener)
50
88
  }
51
89
 
52
- val isPlaying: Boolean
53
- get() = playlistHandler != null && playlistHandler!!.currentMediaPlayer != null && playlistHandler!!.currentMediaPlayer!!.isPlaying
90
+ fun setPlaybackStatusListener(listener: RmxAudioPlayer?) {
91
+ playbackStatusListener = WeakReference(listener)
92
+ }
54
93
 
55
- override fun onError(e: Exception?): Boolean {
94
+ @Suppress("UNUSED_PARAMETER")
95
+ fun setId(id: Int) {
96
+ // PlaylistCore required a playlist id; Media3 stack does not use it.
97
+ }
56
98
 
57
- if (e != null && errorListener.get() != null) {
58
- Log.i(TAG, "onError: $e")
59
- errorListener.get()!!.onError(e)
99
+ fun attachPlayer(exoPlayer: ExoPlayer) {
100
+ player = exoPlayer
101
+ if (audioTracks.isNotEmpty()) {
102
+ exoPlayer.setMediaItems(audioTracks.map { AudioMediaItemFactory.fromAudioTrack(it) })
103
+ if (currentPosition in audioTracks.indices) {
104
+ exoPlayer.seekTo(currentPosition, 0)
105
+ }
106
+ }
107
+ applyPlayerSettings()
108
+ exoPlayer.addListener(playerListener)
109
+ playbackStatusListener.get()?.onPlayerAttached(exoPlayer)
110
+ pendingBeginPlayback?.let { pending ->
111
+ pendingBeginPlayback = null
112
+ beginPlaybackAt(pending.seekPosition, pending.startPaused)
60
113
  }
61
- return true
62
114
  }
63
115
 
64
- /*
65
- * isNextAvailable, getCurrentItem, and next() are overridden because there is
66
- * a glaring bug in playlist core where when an item completes, isNextAvailable and
67
- * getCurrentItem return wildly contradictory things, resulting in endless repeat
68
- * of the last item in the playlist.
69
- */
70
- override val isNextAvailable: Boolean
71
- get() {
72
- if (itemCount <= 1) {
73
- return false;
74
- }
75
- val isAtEnd = currentPosition + 1 >= itemCount
76
- val isConstrained = currentPosition + 1 in 0 until itemCount
77
- return if (isAtEnd) {
78
- loop
79
- } else isConstrained
80
- }
81
-
82
- override operator fun next(): AudioTrack? {
83
- if (isNextAvailable) {
84
- val isAtEnd = currentPosition + 1 >= itemCount
85
- currentPosition = if (isAtEnd && loop) {
86
- 0
87
- } else {
88
- (currentPosition + 1).coerceAtMost(itemCount)
89
- }
116
+ fun detachPlayer() {
117
+ player?.removeListener(playerListener)
118
+ playbackStatusListener.get()?.onPlayerDetached()
119
+ player = null
120
+ }
121
+
122
+ fun attachService(service: MediaService) {
123
+ mediaServiceRef = WeakReference(service)
124
+ }
125
+
126
+ fun getPlayer(): ExoPlayer? = player
127
+
128
+ fun isVideoHandoffPrewarmActive(): Boolean = videoHandoffForegroundRetain
129
+
130
+ fun getCurrentPlaybackState(): RmxPlaybackState = rmxPlaybackState
131
+
132
+ fun getCurrentProgress(): PlaybackProgress? {
133
+ val exoPlayer = player ?: return null
134
+ val duration = exoPlayer.duration.coerceAtLeast(0)
135
+ val position = exoPlayer.currentPosition.coerceAtLeast(0)
136
+ val buffered = if (duration > 0) {
137
+ ((exoPlayer.bufferedPosition.toFloat() / duration) * 100f).toInt().coerceIn(0, 100)
90
138
  } else {
91
- if (loop) {
92
- currentPosition = INVALID_POSITION
93
- } else {
94
- isShouldStopPlaylist = true
95
- return null
96
- }
139
+ 0
140
+ }
141
+ val bufferedFloat = if (duration > 0) {
142
+ (exoPlayer.bufferedPosition.toFloat() / duration).coerceIn(0f, 1f)
143
+ } else {
144
+ 0f
97
145
  }
146
+ return PlaybackProgress(position, duration, buffered, bufferedFloat)
147
+ }
98
148
 
99
- return currentItem
149
+ val isNextAvailable: Boolean
150
+ get() = PlaylistPlaybackPolicy.nextAvailable(
151
+ player?.currentMediaItemIndex ?: currentPosition,
152
+ audioTracks.size,
153
+ loop
154
+ )
155
+
156
+ val isPreviousAvailable: Boolean
157
+ get() = PlaylistPlaybackPolicy.previousAvailable(
158
+ player?.currentMediaItemIndex ?: currentPosition,
159
+ loop,
160
+ audioTracks.size
161
+ )
162
+
163
+ fun invokeNext() {
164
+ playlistHandler?.next()
100
165
  }
101
166
 
167
+ fun invokePrevious() {
168
+ playlistHandler?.previous()
169
+ }
170
+
171
+ fun skipToNext() {
172
+ val exoPlayer = player ?: return
173
+ if (!isNextAvailable) {
174
+ return
175
+ }
176
+ val fromIndex = exoPlayer.currentMediaItemIndex
177
+ // Snapshot the position being left so a later skip back within this session resumes
178
+ // correctly instead of restarting the track from its queue-build-time position.
179
+ audioTracks.getOrNull(fromIndex)?.startPositionMs = exoPlayer.currentPosition
180
+ val targetIndex = PlaylistPlaybackPolicy.nextSkipIndex(fromIndex, audioTracks.size, loop)
181
+ val targetPositionMs = audioTracks.getOrNull(targetIndex)?.startPositionMs ?: 0
182
+ exoPlayer.seekTo(targetIndex, targetPositionMs)
183
+ notifySkipForward(fromIndex)
184
+ }
185
+
186
+ fun skipToPrevious() {
187
+ val exoPlayer = player ?: return
188
+ if (!isPreviousAvailable) {
189
+ return
190
+ }
191
+ val fromIndex = exoPlayer.currentMediaItemIndex
192
+ audioTracks.getOrNull(fromIndex)?.startPositionMs = exoPlayer.currentPosition
193
+ val targetIndex = PlaylistPlaybackPolicy.previousSkipIndex(fromIndex, audioTracks.size, loop)
194
+ val targetPositionMs = audioTracks.getOrNull(targetIndex)?.startPositionMs ?: 0
195
+ exoPlayer.seekTo(targetIndex, targetPositionMs)
196
+ notifySkipBack(fromIndex)
197
+ }
102
198
 
103
- /*
104
- * List management
105
- */
106
199
  fun setAllItems(items: List<AudioTrack>?, options: PlaylistItemOptions) {
107
200
  val seekStart = when {
108
201
  options.playFromPosition >= 0 -> options.playFromPosition
109
- options.retainPosition -> currentProgress?.position ?: 0
202
+ options.retainPosition -> getCurrentProgress()?.position ?: 0
110
203
  else -> 0
111
204
  }
112
205
 
113
206
  clearItems()
114
- addAllItems(items)
207
+ audioTracks.addAll(items.orEmpty())
208
+ player?.setMediaItems(audioTracks.map { AudioMediaItemFactory.fromAudioTrack(it) })
115
209
  currentPosition = 0
116
210
 
117
- // If the requested id exists, start there; otherwise keep the first track.
118
211
  options.playFromId?.let { trackId ->
119
212
  val position = findTrackPosition(trackId)
120
213
  if (position != INVALID_POSITION) {
@@ -122,10 +215,6 @@ class PlaylistManager(application: Application) :
122
215
  }
123
216
  }
124
217
 
125
- // We assume that if the playlist is fully loaded in one go,
126
- // that the next thing to happen will be to play. So let's start
127
- // paused, which will allow the player to pre-buffer until the
128
- // user says Go.
129
218
  beginPlayback(seekStart, options.startPaused)
130
219
  }
131
220
 
@@ -142,19 +231,21 @@ class PlaylistManager(application: Application) :
142
231
 
143
232
  if (insertIndex >= audioTracks.size) {
144
233
  audioTracks.add(item)
234
+ player?.addMediaItem(AudioMediaItemFactory.fromAudioTrack(item))
145
235
  } else {
146
236
  audioTracks.add(insertIndex, item)
237
+ player?.addMediaItem(insertIndex, AudioMediaItemFactory.fromAudioTrack(item))
147
238
  if (currentPosition >= insertIndex && currentPosition != INVALID_POSITION) {
148
239
  currentPosition++
149
240
  }
150
241
  }
151
- items = audioTracks
152
242
 
243
+ applyPlayerSettings()
153
244
  if (countBefore == 0) {
154
245
  currentPosition = 0
155
246
  beginPlayback(1, true)
156
- } else if (this.playlistHandler != null) {
157
- this.playlistHandler!!.updateMediaControls()
247
+ } else {
248
+ playlistHandler?.updateMediaControls()
158
249
  }
159
250
  }
160
251
 
@@ -168,13 +259,10 @@ class PlaylistManager(application: Application) :
168
259
 
169
260
  val item = audioTracks.removeAt(from)
170
261
  audioTracks.add(to, item)
171
- items = audioTracks
262
+ player?.moveMediaItem(from, to)
172
263
 
173
264
  currentPosition = adjustCurrentIndexForMove(currentPosition, from, to, INVALID_POSITION)
174
-
175
- if (this.playlistHandler != null) {
176
- this.playlistHandler!!.updateMediaControls()
177
- }
265
+ playlistHandler?.updateMediaControls()
178
266
  return true
179
267
  }
180
268
 
@@ -192,113 +280,115 @@ class PlaylistManager(application: Application) :
192
280
 
193
281
  val isCurrent = existing == currentItem
194
282
  val wasPlaying = isPlaying
195
- val progress = currentProgress
196
- val seekPosition: Long = if (progress != null) progress.position else 0
283
+ val progress = getCurrentProgress()
284
+ val seekPosition: Long = progress?.position ?: 0
197
285
 
198
- if (isCurrent && playlistHandler != null) {
199
- playlistHandler!!.pause(true)
286
+ if (isCurrent) {
287
+ player?.pause()
200
288
  }
201
289
 
202
290
  audioTracks[resolvedIndex] = resolvedReplacement
203
- items = audioTracks
291
+ player?.replaceMediaItem(resolvedIndex, AudioMediaItemFactory.fromAudioTrack(resolvedReplacement))
204
292
 
205
293
  if (isCurrent) {
206
294
  beginPlayback(seekPosition, !wasPlaying)
207
- } else if (this.playlistHandler != null) {
208
- this.playlistHandler!!.updateMediaControls()
295
+ } else {
296
+ playlistHandler?.updateMediaControls()
209
297
  }
210
298
 
211
299
  return resolvedReplacement
212
300
  }
213
301
 
214
-
215
302
  fun addAllItems(its: List<AudioTrack>?) {
216
- val currentItem = currentItem // may be null
217
- audioTracks.addAll(its.orEmpty())
218
- items =
219
- audioTracks // not *strictly* needed since they share the reference, but for good measure..
220
- currentPosition = audioTracks.indexOf(currentItem)
303
+ val current = currentItem
304
+ its.orEmpty().forEach { track ->
305
+ audioTracks.add(track)
306
+ player?.addMediaItem(AudioMediaItemFactory.fromAudioTrack(track))
307
+ }
308
+ currentPosition = audioTracks.indexOf(current)
309
+ applyPlayerSettings()
221
310
  }
222
311
 
223
312
  fun removeItem(index: Int, itemId: String): AudioTrack? {
224
313
  val wasPlaying = isPlaying
225
- if (playlistHandler != null) {
226
- playlistHandler!!.pause(true)
227
- }
228
- var currentPosition = currentPosition
229
- var foundItem: AudioTrack? = null
230
- var removingCurrent = false
314
+ player?.pause()
231
315
 
232
- // Get the current playback position in milliseconds before removing items
233
- val progress = currentProgress
234
- val seekPosition: Long = if (progress != null) progress.position else 0
316
+ var removingCurrent = false
317
+ val current = currentItem
318
+ val progress = getCurrentProgress()
319
+ val seekPosition: Long = progress?.position ?: 0
235
320
 
236
- // If isPlaying is true, and currentItem is not null,
237
- // that implies that currentItem is the currently playing item.
238
- // If removingCurrent gets set to true, we are removing the currently playing item,
239
- // and we need to restart playback once we do.
240
321
  val resolvedIndex = resolveItemPosition(index, itemId)
322
+ var foundItem: AudioTrack? = null
241
323
  if (resolvedIndex >= 0) {
242
324
  foundItem = audioTracks[resolvedIndex]
243
- if (foundItem == currentItem) {
325
+ if (foundItem == current) {
244
326
  removingCurrent = true
245
327
  }
246
328
  audioTracks.removeAt(resolvedIndex)
329
+ player?.removeMediaItem(resolvedIndex)
247
330
  }
248
- items = audioTracks
249
- currentPosition = if (removingCurrent) currentPosition else audioTracks.indexOf(currentItem)
250
- // If removing the current item, start from beginning (0), otherwise preserve playback position
331
+
332
+ currentPosition = if (removingCurrent) {
333
+ (player?.currentMediaItemIndex ?: INVALID_POSITION).coerceAtLeast(0)
334
+ } else {
335
+ audioTracks.indexOf(current)
336
+ }
337
+
251
338
  val seekStart = if (removingCurrent) 0 else seekPosition
339
+ applyPlayerSettings()
252
340
  beginPlayback(seekStart, !wasPlaying)
253
- if (this.playlistHandler != null) {
254
- this.playlistHandler!!.updateMediaControls()
255
- }
341
+ playlistHandler?.updateMediaControls()
256
342
  return foundItem
257
343
  }
258
344
 
259
345
  fun removeAllItems(its: ArrayList<TrackRemovalItem>): ArrayList<AudioTrack> {
260
346
  val removedTracks = ArrayList<AudioTrack>()
261
347
  val wasPlaying = isPlaying
262
- if (playlistHandler != null) {
263
- playlistHandler!!.pause(true)
264
- }
265
- var currentPosition = currentPosition
266
- val currentItem = currentItem // may be null
267
- var removingCurrent = false
348
+ player?.pause()
268
349
 
269
- // Get the current playback position in milliseconds before removing items
270
- val progress = currentProgress
271
- val seekPosition: Long = if (progress != null) progress.position else 0
350
+ var removingCurrent = false
351
+ val current = currentItem
352
+ val progress = getCurrentProgress()
353
+ val seekPosition: Long = progress?.position ?: 0
272
354
 
273
- for (item in its) {
355
+ val indicesToRemove = its.mapNotNull { item ->
274
356
  val resolvedIndex = resolveItemPosition(item.trackIndex, item.trackId)
275
- if (resolvedIndex >= 0) {
276
- val foundItem = audioTracks[resolvedIndex]
277
- if (foundItem == currentItem) {
278
- removingCurrent = true
279
- }
280
- removedTracks.add(foundItem)
281
- audioTracks.removeAt(resolvedIndex)
357
+ if (resolvedIndex >= 0) resolvedIndex else null
358
+ }.distinct().sortedDescending()
359
+
360
+ for (resolvedIndex in indicesToRemove) {
361
+ val foundItem = audioTracks[resolvedIndex]
362
+ if (foundItem == current) {
363
+ removingCurrent = true
282
364
  }
365
+ removedTracks.add(foundItem)
366
+ audioTracks.removeAt(resolvedIndex)
367
+ player?.removeMediaItem(resolvedIndex)
283
368
  }
284
- items = audioTracks
285
- currentPosition = if (removingCurrent) currentPosition else audioTracks.indexOf(currentItem)
286
- // If removing the current item, start from beginning (0), otherwise preserve playback position
369
+
370
+ currentPosition = if (removingCurrent) {
371
+ (player?.currentMediaItemIndex ?: INVALID_POSITION).coerceAtLeast(0)
372
+ } else {
373
+ audioTracks.indexOf(current)
374
+ }
375
+
287
376
  val seekStart = if (removingCurrent) 0 else seekPosition
377
+ applyPlayerSettings()
288
378
  beginPlayback(seekStart, !wasPlaying)
289
379
  return removedTracks
290
380
  }
291
381
 
292
382
  fun clearItems() {
293
- playlistHandler?.stop()
383
+ player?.stop()
384
+ player?.clearMediaItems()
294
385
  audioTracks.clear()
295
- items = audioTracks
296
386
  currentPosition = INVALID_POSITION
387
+ sequentialErrors = 0
388
+ rmxPlaybackState = RmxPlaybackState.STOPPED
297
389
  }
298
390
 
299
- fun getAllItems(): List<AudioTrack> {
300
- return audioTracks.toList()
301
- }
391
+ fun getAllItems(): List<AudioTrack> = audioTracks.toList()
302
392
 
303
393
  private fun resolveItemPosition(trackIndex: Int, trackId: String): Int {
304
394
  return when {
@@ -311,13 +401,9 @@ class PlaylistManager(application: Application) :
311
401
  internal fun findTrackPosition(trackId: String): Int =
312
402
  audioTracks.indexOfFirst { it.trackId == trackId }
313
403
 
314
- fun getVolumeLeft(): Float {
315
- return volumeLeft
316
- }
404
+ fun getVolumeLeft(): Float = volumeLeft
317
405
 
318
- fun getVolumeRight(): Float {
319
- return volumeRight
320
- }
406
+ fun getVolumeRight(): Float = volumeRight
321
407
 
322
408
  fun setVolume(
323
409
  @FloatRange(from = 0.0, to = 1.0) left: Float,
@@ -325,56 +411,218 @@ class PlaylistManager(application: Application) :
325
411
  ) {
326
412
  volumeLeft = left
327
413
  volumeRight = right
328
- if (currentMediaPlayer != null && currentMediaPlayer!!.get() != null) {
329
- Log.i("PlaylistManager", "setVolume completing with volume = $left")
330
- currentMediaPlayer!!.get()!!.setVolume(volumeLeft, volumeRight)
331
- }
414
+ player?.volume = (left + right) / 2f
332
415
  }
333
416
 
334
- fun getPlaybackSpeed(): Float {
335
- return playbackSpeed
336
- }
417
+ fun getPlaybackSpeed(): Float = playbackSpeed
337
418
 
338
419
  fun setPlaybackSpeed(@FloatRange(from = 0.0625, to = 16.0) speed: Float) {
339
420
  val validSpeed = speed.coerceIn(0.0625f, 16.0f)
340
421
  playbackSpeed = validSpeed
341
- playlistHandler?.let { handler ->
342
- handler.currentMediaPlayer?.let { mediaPlayer ->
343
- if (mediaPlayer is AudioApi) {
344
- Log.i(TAG, "setPlaybackSpeed completing with speed = $validSpeed")
345
- mediaPlayer.setPlaybackSpeed(playbackSpeed)
346
- }
347
- }
348
- }
422
+ player?.setPlaybackSpeed(validSpeed)
349
423
  }
350
424
 
351
425
  fun beginPlayback(@IntRange(from = 0) seekPosition: Long, startPaused: Boolean) {
352
- currentItem ?: return
426
+ if (audioTracks.isEmpty()) {
427
+ return
428
+ }
429
+ beginPlaybackAt(seekPosition, startPaused)
430
+ }
431
+
432
+ fun beginPlaybackAt(@IntRange(from = 0) seekPosition: Long, startPaused: Boolean) {
433
+ if (audioTracks.isEmpty()) {
434
+ return
435
+ }
353
436
  try {
354
- super.play(seekPosition, startPaused)
437
+ ensureServiceStarted()
438
+ val exoPlayer = player
439
+ if (exoPlayer == null) {
440
+ pendingBeginPlayback = PendingBegin(seekPosition, startPaused)
441
+ return
442
+ }
443
+ val index = currentPosition.coerceIn(0, audioTracks.size - 1)
444
+ currentPosition = index
445
+ exoPlayer.repeatMode = PlaylistPlaybackPolicy.repeatMode(loop, audioTracks.size)
446
+ exoPlayer.seekTo(index, seekPosition)
447
+ exoPlayer.prepare()
448
+ exoPlayer.playWhenReady = !startPaused && !videoHandoffForegroundRetain
449
+ applyPlayerSettings()
450
+ if (MediaNotificationPolicy.shouldRequestLegacyAudioFocus()) {
451
+ // Media3 handleAudioFocus owns pause/resume; do not call AudioManager.requestAudioFocus.
452
+ }
453
+ ensureForeground()
355
454
  } catch (e: IllegalStateException) {
356
- // Android 12+: BackgroundServiceStartNotAllowedException when app is backgrounded
357
455
  Log.w(TAG, "beginPlayback: cannot start MediaService while backgrounded: ${e.message}")
456
+ } catch (e: Exception) {
457
+ Log.w(TAG, "beginPlayback: ${e.message}")
458
+ }
459
+ }
460
+
461
+ fun ensureServiceStarted() {
462
+ if (!MediaNotificationPolicy.shouldStartForegroundService(MediaService.instance != null)) {
358
463
  return
359
464
  }
465
+ val intent = Intent(application, MediaService::class.java)
360
466
  try {
361
- setVolume(volumeLeft, volumeRight)
362
- setPlaybackSpeed(playbackSpeed)
363
- } catch (e: Exception) {
364
- Log.w(TAG, "beginPlayback: Error setting volume or playback speed: " + e.message)
467
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
468
+ application.startForegroundService(intent)
469
+ } else {
470
+ application.startService(intent)
471
+ }
472
+ } catch (e: IllegalStateException) {
473
+ Log.w(TAG, "ensureServiceStarted: ${e.message}")
474
+ }
475
+ }
476
+
477
+ fun ensureForeground() {
478
+ // Media3 MediaSessionService owns FGS; do not startForeground in parallel.
479
+ mediaServiceRef.get()?.promoteToForeground()
480
+ }
481
+
482
+ fun updateForegroundNotification() {
483
+ mediaServiceRef.get()?.updateForegroundNotification()
484
+ }
485
+
486
+ private fun applyPlayerSettings() {
487
+ player?.let { exoPlayer ->
488
+ exoPlayer.volume = (volumeLeft + volumeRight) / 2f
489
+ exoPlayer.setPlaybackSpeed(playbackSpeed)
490
+ exoPlayer.repeatMode = PlaylistPlaybackPolicy.repeatMode(loop, audioTracks.size)
491
+ }
492
+ }
493
+
494
+ private fun notifySkipForward(fromIndex: Int) {
495
+ val listener = mediaControlsListener.get()
496
+ val item = if (fromIndex in audioTracks.indices) audioTracks[fromIndex] else currentItem
497
+ listener?.onNext(item, fromIndex)
498
+ }
499
+
500
+ private fun notifySkipBack(fromIndex: Int) {
501
+ val listener = mediaControlsListener.get()
502
+ val item = if (fromIndex in audioTracks.indices) audioTracks[fromIndex] else currentItem
503
+ listener?.onPrevious(item, fromIndex)
504
+ }
505
+
506
+ private val playerListener = object : Player.Listener {
507
+ override fun onPlaybackStateChanged(playbackState: Int) {
508
+ rmxPlaybackState = mapPlaybackState(playbackState)
509
+ playbackStatusListener.get()?.onNativePlaybackStateChanged(rmxPlaybackState)
510
+ if (playbackState == Player.STATE_ENDED) {
511
+ handleItemEnded()
512
+ }
513
+ }
514
+
515
+ override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
516
+ MediaNotificationPolicy.applyRetainPlayWhenReadyGuard(
517
+ playWhenReady,
518
+ videoHandoffForegroundRetain,
519
+ VideoPlayerBridge.hasActivePlayer()
520
+ ) {
521
+ player?.playWhenReady = false
522
+ }
523
+ }
524
+
525
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
526
+ if (isPlaying) {
527
+ sequentialErrors = 0
528
+ rmxPlaybackState = RmxPlaybackState.PLAYING
529
+ } else if (player?.playbackState == Player.STATE_READY) {
530
+ rmxPlaybackState = RmxPlaybackState.PAUSED
531
+ }
532
+ playbackStatusListener.get()?.onNativePlaybackStateChanged(rmxPlaybackState)
533
+ }
534
+
535
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
536
+ val previousIndex = currentPosition
537
+ currentPosition = player?.currentMediaItemIndex ?: currentPosition
538
+ if (PlaylistPlaybackPolicy.isNaturalItemEnd(reason) && previousIndex in audioTracks.indices) {
539
+ notifyItemCompleted(
540
+ audioTracks[previousIndex],
541
+ PlaylistPlaybackPolicy.nextAvailable(previousIndex, audioTracks.size, loop)
542
+ )
543
+ }
544
+ playbackStatusListener.get()?.onNativeTrackChanged(
545
+ currentItem,
546
+ isNextAvailable,
547
+ isPreviousAvailable
548
+ )
549
+ }
550
+
551
+ override fun onPositionDiscontinuity(
552
+ oldPosition: Player.PositionInfo,
553
+ newPosition: Player.PositionInfo,
554
+ reason: Int
555
+ ) {
556
+ if (reason == Player.DISCONTINUITY_REASON_SEEK) {
557
+ seeking = true
558
+ rmxPlaybackState = RmxPlaybackState.SEEKING
559
+ playbackStatusListener.get()?.onNativePlaybackStateChanged(rmxPlaybackState)
560
+ }
561
+ }
562
+
563
+ override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
564
+ currentErrorTrack = currentItem
565
+ rmxPlaybackState = RmxPlaybackState.ERROR
566
+ playbackStatusListener.get()?.onNativePlayerError(error)
567
+ skipFailedItem()
568
+ }
569
+ }
570
+
571
+ /** PlaylistCore moved past a failing item (up to [PlaylistPlaybackPolicy.MAX_SEQUENTIAL_ERRORS] in a row). */
572
+ private fun skipFailedItem() {
573
+ val exoPlayer = player ?: return
574
+ sequentialErrors++
575
+ val failedIndex = exoPlayer.currentMediaItemIndex
576
+ val hasNext = PlaylistPlaybackPolicy.nextAvailable(failedIndex, audioTracks.size, loop)
577
+ if (!PlaylistPlaybackPolicy.shouldSkipAfterError(sequentialErrors, hasNext)) {
578
+ return
579
+ }
580
+ val resume = PlaylistPlaybackPolicy.playWhenReadyAfterErrorSkip(exoPlayer.playWhenReady, failedIndex)
581
+ val targetIndex = PlaylistPlaybackPolicy.errorSkipWindowIndex(failedIndex, audioTracks.size)
582
+ exoPlayer.seekTo(targetIndex, 0)
583
+ exoPlayer.prepare()
584
+ exoPlayer.playWhenReady = resume && !videoHandoffForegroundRetain
585
+ }
586
+
587
+ /** Former PlaylistCore order on a natural item end: COMPLETED, PLAYLIST_COMPLETED when last, then STOPPED. */
588
+ private fun notifyItemCompleted(track: AudioTrack, nextAvailable: Boolean) {
589
+ playbackStatusListener.get()?.onNativeItemCompleted(track, nextAvailable)
590
+ }
591
+
592
+ private fun mapPlaybackState(playbackState: Int): RmxPlaybackState {
593
+ return when (playbackState) {
594
+ Player.STATE_IDLE -> RmxPlaybackState.STOPPED
595
+ Player.STATE_BUFFERING -> {
596
+ if (player?.isPlaying == true) RmxPlaybackState.PLAYING else RmxPlaybackState.PREPARING
597
+ }
598
+ Player.STATE_READY -> {
599
+ if (seeking) {
600
+ seeking = false
601
+ if (player?.isPlaying == true) RmxPlaybackState.PLAYING else RmxPlaybackState.PAUSED
602
+ } else if (player?.isPlaying == true) {
603
+ RmxPlaybackState.PLAYING
604
+ } else {
605
+ RmxPlaybackState.PAUSED
606
+ }
607
+ }
608
+ Player.STATE_ENDED -> RmxPlaybackState.STOPPED
609
+ else -> RmxPlaybackState.PREPARING
610
+ }
611
+ }
612
+
613
+ private fun handleItemEnded() {
614
+ val track = currentItem
615
+ val nextAvailable = isNextAvailable
616
+ track?.let { notifyItemCompleted(it, nextAvailable) }
617
+ if (!nextAvailable) {
618
+ playbackStatusListener.get()?.onNativePlaylistCompleted()
365
619
  }
366
620
  }
367
621
 
368
622
  companion object {
623
+ const val INVALID_POSITION = -1
369
624
  private const val TAG = "PlaylistManager"
370
625
 
371
- /**
372
- * Computes the new position of the "current" item after moving an item from index
373
- * [from] to index [to] (post-removal insertion semantics, i.e. matching
374
- * `MutableList.removeAt(from)` followed by `MutableList.add(to, item)`).
375
- *
376
- * Pure function so it can be unit-tested without an Android/Application context.
377
- */
378
626
  @JvmStatic
379
627
  fun adjustCurrentIndexForMove(currentIndex: Int, from: Int, to: Int, invalidPosition: Int): Int {
380
628
  if (currentIndex == invalidPosition) {
@@ -387,13 +635,6 @@ class PlaylistManager(application: Application) :
387
635
  return if (mid >= to) mid + 1 else mid
388
636
  }
389
637
 
390
- /**
391
- * Builds the [AudioTrack] that should replace [existing], backfilling `trackId` from
392
- * [existing] when [replacement] doesn't specify one (an omitted id signals "keep the
393
- * existing id" rather than "generate a new one").
394
- *
395
- * Pure function so it can be unit-tested without an Android/Application context.
396
- */
397
638
  @JvmStatic
398
639
  fun mergeReplacementTrackId(existing: AudioTrack, replacement: AudioTrack): AudioTrack {
399
640
  val replacementConfig = replacement.toDict()
@@ -403,10 +644,4 @@ class PlaylistManager(application: Application) :
403
644
  return AudioTrack(replacementConfig)
404
645
  }
405
646
  }
406
-
407
- init {
408
- setParameters(audioTracks, 0)
409
- options = Options(application.baseContext)
410
- }
411
-
412
647
  }