capacitor-plugin-playlist 0.11.0 → 0.11.2

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 (43) hide show
  1. package/README.md +63 -6
  2. package/android/build.gradle +21 -0
  3. package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +76 -19
  4. package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioStatusMessage.java +2 -0
  5. package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +7 -6
  6. package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +124 -30
  7. package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistManagerMoveTest.kt +76 -0
  8. package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistManagerReplaceTest.kt +57 -0
  9. package/dist/docs.json +111 -1
  10. package/dist/esm/Constants.d.ts +10 -0
  11. package/dist/esm/Constants.js +10 -0
  12. package/dist/esm/Constants.js.map +1 -1
  13. package/dist/esm/RmxAudioPlayer.d.ts +16 -2
  14. package/dist/esm/RmxAudioPlayer.js +28 -3
  15. package/dist/esm/RmxAudioPlayer.js.map +1 -1
  16. package/dist/esm/definitions.d.ts +28 -1
  17. package/dist/esm/web.d.ts +3 -1
  18. package/dist/esm/web.js +54 -2
  19. package/dist/esm/web.js.map +1 -1
  20. package/dist/plugin.cjs.js +92 -5
  21. package/dist/plugin.cjs.js.map +1 -1
  22. package/dist/plugin.js +92 -5
  23. package/dist/plugin.js.map +1 -1
  24. package/ios/Sources/PlaylistPlugin/Constants.swift +2 -0
  25. package/ios/Sources/PlaylistPlugin/Plugin.swift +52 -5
  26. package/ios/Sources/PlaylistPlugin/RmxAudioPlayer.swift +123 -1
  27. package/ios/Tests/PlaylistPluginTests/PlaylistMutationTests.swift +168 -0
  28. package/package.json +10 -4
  29. package/android/.gradle/8.14.3/checksums/checksums.lock +0 -0
  30. package/android/.gradle/8.14.3/checksums/md5-checksums.bin +0 -0
  31. package/android/.gradle/8.14.3/checksums/sha1-checksums.bin +0 -0
  32. package/android/.gradle/8.14.3/executionHistory/executionHistory.bin +0 -0
  33. package/android/.gradle/8.14.3/executionHistory/executionHistory.lock +0 -0
  34. package/android/.gradle/8.14.3/fileChanges/last-build.bin +0 -0
  35. package/android/.gradle/8.14.3/fileHashes/fileHashes.bin +0 -0
  36. package/android/.gradle/8.14.3/fileHashes/fileHashes.lock +0 -0
  37. package/android/.gradle/8.14.3/fileHashes/resourceHashesCache.bin +0 -0
  38. package/android/.gradle/8.14.3/gc.properties +0 -0
  39. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  40. package/android/.gradle/buildOutputCleanup/cache.properties +0 -2
  41. package/android/.gradle/buildOutputCleanup/outputFiles.bin +0 -0
  42. package/android/.gradle/file-system.probe +0 -0
  43. package/android/.gradle/vcs-1/gc.properties +0 -0
package/README.md CHANGED
@@ -24,7 +24,9 @@ Requires **Capacitor 8+** (peer dependency `@capacitor/core >= 8.0.0`).
24
24
  ### Playlist management
25
25
 
26
26
  - `setPlaylistItems` — replace entire playlist (optional position retention)
27
- - `addItem` / `addAllItems` — append tracks
27
+ - `addItem` / `addAllItems` — append tracks; `addItem` accepts optional insert index (“play next”)
28
+ - `moveItem` — reorder without disrupting current playback
29
+ - `replaceItem` — swap track metadata/URL in place (e.g. stream → offline file)
28
30
  - `removeItem` / `removeItems` / `clearAllItems` — remove tracks
29
31
  - `getPlaylist` — snapshot of current items
30
32
  - `setLoop` — loop entire playlist when the last track completes
@@ -83,7 +85,7 @@ See [Video handoff](#video-handoff).
83
85
  ### Not supported
84
86
 
85
87
  - Shuffle
86
- - Reorder API
88
+ - Audio ads / IMA integration ([#71](https://github.com/phiamo/capacitor-plugin-playlist/issues/71))
87
89
  - Mixable / low-latency game audio (use [cordova-plugin-nativeaudio](https://github.com/floatinghotpot/cordova-plugin-nativeaudio) instead)
88
90
  - Simultaneous audio mixing (lock-screen controls require exclusive audio focus)
89
91
 
@@ -443,6 +445,8 @@ See [CHANGELOG.md](./CHANGELOG.md) for version-specific fixes (0.8.8–0.10.3).
443
445
  * [`release()`](#release)
444
446
  * [`setPlaylistItems(...)`](#setplaylistitems)
445
447
  * [`addItem(...)`](#additem)
448
+ * [`moveItem(...)`](#moveitem)
449
+ * [`replaceItem(...)`](#replaceitem)
446
450
  * [`addAllItems(...)`](#addallitems)
447
451
  * [`removeItem(...)`](#removeitem)
448
452
  * [`removeItems(...)`](#removeitems)
@@ -552,7 +556,9 @@ Use `options.retainPosition` to keep the current track and playback position.
552
556
  addItem(options: AddItemOptions) => Promise<void>
553
557
  ```
554
558
 
555
- Append a single track to the end of the playlist.
559
+ Append a single track to the end of the playlist, or insert at a 0-based index.
560
+ When `index` is omitted the track is appended. Insertion does not interrupt playback
561
+ of the current track.
556
562
 
557
563
  | Param | Type |
558
564
  | ------------- | --------------------------------------------------------- |
@@ -561,6 +567,37 @@ Append a single track to the end of the playlist.
561
567
  --------------------
562
568
 
563
569
 
570
+ ### moveItem(...)
571
+
572
+ ```typescript
573
+ moveItem(options: MoveItemOptions) => Promise<void>
574
+ ```
575
+
576
+ Move a track from one index to another without restarting the current track.
577
+
578
+ | Param | Type |
579
+ | ------------- | ----------------------------------------------------------- |
580
+ | **`options`** | <code><a href="#moveitemoptions">MoveItemOptions</a></code> |
581
+
582
+ --------------------
583
+
584
+
585
+ ### replaceItem(...)
586
+
587
+ ```typescript
588
+ replaceItem(options: ReplaceItemOptions) => Promise<void>
589
+ ```
590
+
591
+ Replace a track's metadata and source URL in place (e.g. stream URL → local file).
592
+ When replacing the currently playing track, playback position and play/pause state are preserved.
593
+
594
+ | Param | Type |
595
+ | ------------- | ----------------------------------------------------------------- |
596
+ | **`options`** | <code><a href="#replaceitemoptions">ReplaceItemOptions</a></code> |
597
+
598
+ --------------------
599
+
600
+
564
601
  ### addAllItems(...)
565
602
 
566
603
  ```typescript
@@ -1035,9 +1072,27 @@ that were in the previous list.
1035
1072
 
1036
1073
  #### AddItemOptions
1037
1074
 
1038
- | Prop | Type |
1039
- | ---------- | ------------------------------------------------- |
1040
- | **`item`** | <code><a href="#audiotrack">AudioTrack</a></code> |
1075
+ | Prop | Type | Description |
1076
+ | ----------- | ------------------------------------------------- | ------------------------------------------- |
1077
+ | **`item`** | <code><a href="#audiotrack">AudioTrack</a></code> | |
1078
+ | **`index`** | <code>number</code> | 0-based index to insert at. Omit to append. |
1079
+
1080
+
1081
+ #### MoveItemOptions
1082
+
1083
+ | Prop | Type | Description |
1084
+ | ---------- | ------------------- | ------------------------------------------ |
1085
+ | **`from`** | <code>number</code> | Source index (0-based). |
1086
+ | **`to`** | <code>number</code> | Destination index (0-based) after removal. |
1087
+
1088
+
1089
+ #### ReplaceItemOptions
1090
+
1091
+ | Prop | Type | Description |
1092
+ | ----------- | ------------------------------------------------- | -------------------------------------------------------------------------- |
1093
+ | **`item`** | <code><a href="#audiotrack">AudioTrack</a></code> | Replacement track data. When `trackId` is omitted the existing id is kept. |
1094
+ | **`index`** | <code>number</code> | Index of the track to replace (preferred over `id`). |
1095
+ | **`id`** | <code>string</code> | Id of the track to replace. |
1041
1096
 
1042
1097
 
1043
1098
  #### AddAllItemOptions
@@ -1189,6 +1244,8 @@ that were in the previous list.
1189
1244
  | **`RMXSTATUS_PLAYLIST_COMPLETED`** | <code>105</code> | The entire playlist has completed playback. After this event has been raised, the current item is set to null and the current index to -1. |
1190
1245
  | **`RMXSTATUS_ITEM_ADDED`** | <code>110</code> | An item has been added to the playlist. For the setPlaylistItems and addAllItems methods, this status is raised once for every track in the collection. |
1191
1246
  | **`RMXSTATUS_ITEM_REMOVED`** | <code>115</code> | An item has been removed from the playlist. For the removeItems and clearAllItems methods, this status is raised once for every track that was removed. |
1247
+ | **`RMXSTATUS_ITEM_MOVED`** | <code>112</code> | An item has been moved within the playlist. |
1248
+ | **`RMXSTATUS_ITEM_REPLACED`** | <code>113</code> | An item in the playlist has been replaced in place. |
1192
1249
  | **`RMXSTATUS_PLAYLIST_CLEARED`** | <code>120</code> | All items have been removed from the playlist |
1193
1250
  | **`RMXSTATUS_VIEWDISAPPEAR`** | <code>200</code> | Just for testing.. you don't need this and in fact can never receive it, the plugin is destroyed before it can be raised. |
1194
1251
 
@@ -1,9 +1,27 @@
1
+ // Consuming Capacitor apps normally supply the Android/Kotlin Gradle plugin classpath from
2
+ // their own root build.gradle. This buildscript block is only needed to run `./gradlew test`
3
+ // standalone from this module (e.g. `npm run verify:android`); it is additive to this
4
+ // subproject's own build-script classpath and does not affect a parent app's configuration.
5
+ buildscript {
6
+ repositories {
7
+ google()
8
+ mavenCentral()
9
+ }
10
+ dependencies {
11
+ classpath 'com.android.tools.build:gradle:8.13.0'
12
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0'
13
+ }
14
+ }
15
+
1
16
  import org.jetbrains.kotlin.gradle.dsl.JvmTarget
2
17
 
3
18
  ext {
4
19
  junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
5
20
  androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.3.0'
6
21
  androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.7.0'
22
+ // Only used when this module is built standalone (e.g. `npm run verify:android`); when
23
+ // embedded, the consuming app's root build.gradle already sets rootProject.ext.kotlin_version.
24
+ kotlin_version = project.hasProperty('kotlinVersion') ? rootProject.ext.kotlinVersion : '2.1.0'
7
25
  }
8
26
 
9
27
  apply plugin: 'com.android.library'
@@ -50,6 +68,9 @@ dependencies {
50
68
  implementation fileTree(dir: 'libs', include: ['*.jar'])
51
69
  implementation project(':capacitor-android')
52
70
  testImplementation "junit:junit:$junitVersion"
71
+ // Real org.json implementation for plain JVM unit tests: the android.jar stub used for
72
+ // compilation throws "Stub!" for org.json.* at runtime outside instrumented/Robolectric tests.
73
+ testImplementation "org.json:json:20231013"
53
74
  androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
54
75
  androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
55
76
  implementation "androidx.core:core-ktx:1.17.0"
@@ -97,15 +97,19 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
97
97
  fun addItem(call: PluginCall) {
98
98
  Handler(Looper.getMainLooper()).post {
99
99
  val item: JSONObject = call.getObject("item")
100
+ val index: Int = call.getInt("index", -1)!!
100
101
  val playerItem: AudioTrack? = getTrackItem(item)
101
- audioPlayerImpl!!.getPlaylistManager().addItem(playerItem)
102
-
102
+ audioPlayerImpl!!.getPlaylistManager().addItem(playerItem, index)
103
103
 
104
104
  if (playerItem?.trackId != null) {
105
+ val payload = playerItem.toDict()
106
+ if (index >= 0) {
107
+ payload.put("index", index.coerceIn(0, audioPlayerImpl!!.playlistManager.getAllItems().size - 1))
108
+ }
105
109
  onStatus(
106
110
  RmxAudioStatusMessage.RMXSTATUS_ITEM_ADDED,
107
111
  playerItem.trackId,
108
- playerItem.toDict()
112
+ payload
109
113
  )
110
114
  }
111
115
  call.resolve()
@@ -113,6 +117,56 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
113
117
  }
114
118
  }
115
119
 
120
+ @PluginMethod
121
+ fun moveItem(call: PluginCall) {
122
+ Handler(Looper.getMainLooper()).post {
123
+ val from: Int = call.getInt("from", -1)!!
124
+ val to: Int = call.getInt("to", -1)!!
125
+ val moved = audioPlayerImpl!!.playlistManager.moveItem(from, to)
126
+ if (!moved) {
127
+ call.reject("Index out of bounds")
128
+ return@post
129
+ }
130
+
131
+ val payload = JSONObject()
132
+ payload.put("from", from)
133
+ payload.put("to", to)
134
+ payload.put("currentIndex", audioPlayerImpl!!.playlistManager.currentPosition)
135
+ onStatus(
136
+ RmxAudioStatusMessage.RMXSTATUS_ITEM_MOVED,
137
+ audioPlayerImpl!!.playlistManager.currentItem?.trackId ?: "INVALID",
138
+ payload
139
+ )
140
+ call.resolve()
141
+ Log.i(TAG, "moveItem from=$from to=$to")
142
+ }
143
+ }
144
+
145
+ @PluginMethod
146
+ fun replaceItem(call: PluginCall) {
147
+ Handler(Looper.getMainLooper()).post {
148
+ val trackIndex: Int = call.getInt("index", -1)!!
149
+ val trackId: String = call.getString("id", "")!!
150
+ val item: JSONObject = call.getObject("item")
151
+ // An omitted trackId in the payload intentionally means "keep the existing id",
152
+ // so this must not be rejected the way a brand-new addItem() track would be.
153
+ val replacement: AudioTrack? = getTrackItem(item, requireTrackId = false)
154
+ val replaced = audioPlayerImpl!!.playlistManager.replaceItem(trackIndex, trackId, replacement)
155
+
156
+ if (replaced != null) {
157
+ onStatus(
158
+ RmxAudioStatusMessage.RMXSTATUS_ITEM_REPLACED,
159
+ replaced.trackId,
160
+ replaced.toDict()
161
+ )
162
+ call.resolve()
163
+ } else {
164
+ call.reject("Could not find item!")
165
+ }
166
+ Log.i(TAG, "replaceItem")
167
+ }
168
+ }
169
+
116
170
  @PluginMethod
117
171
  fun addAllItems(call: PluginCall) {
118
172
  Handler(Looper.getMainLooper()).post {
@@ -257,13 +311,16 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
257
311
  Handler(Looper.getMainLooper()).post {
258
312
  val id: String = call.getString("id")!!
259
313
  if ("" != id) {
260
- val code = id.hashCode()
261
- val seekPosition = (call.getFloat("position", 0f)!! * 1000.0f).toLong()
262
- audioPlayerImpl!!.playlistManager.setCurrentItem(code.toLong())
263
- val handler = audioPlayerImpl!!.playlistManager.playlistHandler
264
- val alreadyPlaying = handler?.currentMediaPlayer?.isPlaying == true
265
- if (!audioPlayerImpl!!.tryResumeVideoHandoffInPlace(seekPosition) && !alreadyPlaying) {
266
- audioPlayerImpl!!.playlistManager.beginPlayback(seekPosition, false)
314
+ val playlistManager = audioPlayerImpl!!.playlistManager
315
+ val position = playlistManager.findTrackPosition(id)
316
+ if (position >= 0) {
317
+ val seekPosition = (call.getFloat("position", 0f)!! * 1000.0f).toLong()
318
+ playlistManager.currentPosition = position
319
+ val handler = playlistManager.playlistHandler
320
+ val alreadyPlaying = handler?.currentMediaPlayer?.isPlaying == true
321
+ if (!audioPlayerImpl!!.tryResumeVideoHandoffInPlace(seekPosition) && !alreadyPlaying) {
322
+ playlistManager.beginPlayback(seekPosition, false)
323
+ }
267
324
  }
268
325
  }
269
326
 
@@ -297,13 +354,13 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
297
354
  Handler(Looper.getMainLooper()).post {
298
355
  val id: String = call.getString("id")!!
299
356
  if ("" != id) {
300
- // alternatively we could search for the item and set the current index to that item.
301
- val code = id.hashCode()
302
- audioPlayerImpl!!.playlistManager.setCurrentItem(code.toLong())
303
-
304
- val seekPosition = (call.getFloat("position", 0f)!! * 1000.0f).toLong()
305
-
306
- audioPlayerImpl!!.playlistManager.beginPlayback(seekPosition, true)
357
+ val playlistManager = audioPlayerImpl!!.playlistManager
358
+ val position = playlistManager.findTrackPosition(id)
359
+ if (position >= 0) {
360
+ val seekPosition = (call.getFloat("position", 0f)!! * 1000.0f).toLong()
361
+ playlistManager.currentPosition = position
362
+ playlistManager.beginPlayback(seekPosition, true)
363
+ }
307
364
  }
308
365
  call.resolve()
309
366
 
@@ -475,10 +532,10 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
475
532
  audioPlayerImpl!!.playlistManager.clearItems()
476
533
  }
477
534
 
478
- private fun getTrackItem(item: JSONObject?): AudioTrack? {
535
+ private fun getTrackItem(item: JSONObject?, requireTrackId: Boolean = true): AudioTrack? {
479
536
  if (item != null) {
480
537
  val track = AudioTrack(item)
481
- return if (track.trackId != null) {
538
+ return if (!requireTrackId || track.trackId != null) {
482
539
  track
483
540
  } else null
484
541
  }
@@ -24,6 +24,8 @@ public enum RmxAudioStatusMessage {
24
24
  RMXSTATUS_TRACK_CHANGED(100),
25
25
  RMXSTATUS_PLAYLIST_COMPLETED(105),
26
26
  RMXSTATUS_ITEM_ADDED(110),
27
+ RMXSTATUS_ITEM_MOVED(112),
28
+ RMXSTATUS_ITEM_REPLACED(113),
27
29
  RMXSTATUS_ITEM_REMOVED(115),
28
30
  RMXSTATUS_PLAYLIST_CLEARED(120),
29
31
 
@@ -5,8 +5,15 @@ import com.devbrackets.android.playlistcore.api.PlaylistItem
5
5
  import com.devbrackets.android.playlistcore.manager.BasePlaylistManager
6
6
  import org.json.JSONException
7
7
  import org.json.JSONObject
8
+ import java.util.concurrent.atomic.AtomicLong
8
9
 
9
10
  class AudioTrack (private val config: JSONObject) : PlaylistItem {
11
+ companion object {
12
+ private val nextPlaylistId = AtomicLong(1)
13
+ }
14
+
15
+ override val id: Long = nextPlaylistId.getAndIncrement()
16
+
10
17
  var bufferPercentFloat = 0f
11
18
  set(buff) {
12
19
  // There is a bug in MediaProgress where if bufferPercent == 100 it sets bufferPercentFloat
@@ -38,12 +45,6 @@ class AudioTrack (private val config: JSONObject) : PlaylistItem {
38
45
  return info
39
46
  }
40
47
 
41
- override val id: Long
42
- get() =
43
- if (trackId == null) {
44
- 0
45
- } else trackId.hashCode().toLong()
46
-
47
48
  val isStream: Boolean
48
49
  get() = config.optBoolean("isStream", false)
49
50
 
@@ -104,28 +104,22 @@ class PlaylistManager(application: Application) :
104
104
  * List management
105
105
  */
106
106
  fun setAllItems(items: List<AudioTrack>?, options: PlaylistItemOptions) {
107
+ val seekStart = when {
108
+ options.playFromPosition >= 0 -> options.playFromPosition
109
+ options.retainPosition -> currentProgress?.position ?: 0
110
+ else -> 0
111
+ }
112
+
107
113
  clearItems()
108
114
  addAllItems(items)
109
115
  currentPosition = 0
110
- // If the options said to start from a specific position, do so.
111
- var seekStart: Long = 0
112
- if (options.playFromPosition > 0) {
113
- seekStart = options.playFromPosition
114
- } else if (options.retainPosition) {
115
- val progress = currentProgress
116
- if (progress != null) {
117
- seekStart = progress.position
118
- }
119
- }
120
116
 
121
- // If the options said to start from a specific id, do so.
122
- var idStart: String? = null
123
- if (options.playFromId != null) {
124
- idStart = options.playFromId
125
- }
126
- if (idStart != null && "" != idStart) {
127
- val code = idStart.hashCode()
128
- setCurrentItem(code.toLong())
117
+ // If the requested id exists, start there; otherwise keep the first track.
118
+ options.playFromId?.let { trackId ->
119
+ val position = findTrackPosition(trackId)
120
+ if (position != INVALID_POSITION) {
121
+ currentPosition = position
122
+ }
129
123
  }
130
124
 
131
125
  // We assume that if the playlist is fully loaded in one go,
@@ -135,22 +129,89 @@ class PlaylistManager(application: Application) :
135
129
  beginPlayback(seekStart, options.startPaused)
136
130
  }
137
131
 
138
- fun addItem(item: AudioTrack?) {
132
+ fun addItem(item: AudioTrack?, index: Int = -1) {
139
133
  if (item == null) {
140
134
  return
141
135
  }
142
- val countBefore = audioTracks.size;
143
- audioTracks.add(item)
136
+ val countBefore = audioTracks.size
137
+ val insertIndex = if (index >= 0) {
138
+ index.coerceIn(0, audioTracks.size)
139
+ } else {
140
+ audioTracks.size
141
+ }
142
+
143
+ if (insertIndex >= audioTracks.size) {
144
+ audioTracks.add(item)
145
+ } else {
146
+ audioTracks.add(insertIndex, item)
147
+ if (currentPosition >= insertIndex && currentPosition != INVALID_POSITION) {
148
+ currentPosition++
149
+ }
150
+ }
144
151
  items = audioTracks
152
+
145
153
  if (countBefore == 0) {
146
154
  currentPosition = 0
147
155
  beginPlayback(1, true)
156
+ } else if (this.playlistHandler != null) {
157
+ this.playlistHandler!!.updateMediaControls()
158
+ }
159
+ }
160
+
161
+ fun moveItem(from: Int, to: Int): Boolean {
162
+ if (from < 0 || from >= audioTracks.size || to < 0 || to >= audioTracks.size) {
163
+ return false
148
164
  }
165
+ if (from == to) {
166
+ return true
167
+ }
168
+
169
+ val item = audioTracks.removeAt(from)
170
+ audioTracks.add(to, item)
171
+ items = audioTracks
172
+
173
+ currentPosition = adjustCurrentIndexForMove(currentPosition, from, to, INVALID_POSITION)
174
+
149
175
  if (this.playlistHandler != null) {
150
176
  this.playlistHandler!!.updateMediaControls()
151
177
  }
178
+ return true
179
+ }
180
+
181
+ fun replaceItem(index: Int, itemId: String, replacement: AudioTrack?): AudioTrack? {
182
+ if (replacement == null) {
183
+ return null
184
+ }
185
+ val resolvedIndex = resolveItemPosition(index, itemId)
186
+ if (resolvedIndex < 0 || resolvedIndex >= audioTracks.size) {
187
+ return null
188
+ }
189
+
190
+ val existing = audioTracks[resolvedIndex]
191
+ val resolvedReplacement = mergeReplacementTrackId(existing, replacement)
192
+
193
+ val isCurrent = existing == currentItem
194
+ val wasPlaying = isPlaying
195
+ val progress = currentProgress
196
+ val seekPosition: Long = if (progress != null) progress.position else 0
197
+
198
+ if (isCurrent && playlistHandler != null) {
199
+ playlistHandler!!.pause(true)
200
+ }
201
+
202
+ audioTracks[resolvedIndex] = resolvedReplacement
203
+ items = audioTracks
204
+
205
+ if (isCurrent) {
206
+ beginPlayback(seekPosition, !wasPlaying)
207
+ } else if (this.playlistHandler != null) {
208
+ this.playlistHandler!!.updateMediaControls()
209
+ }
210
+
211
+ return resolvedReplacement
152
212
  }
153
213
 
214
+
154
215
  fun addAllItems(its: List<AudioTrack>?) {
155
216
  val currentItem = currentItem // may be null
156
217
  audioTracks.addAll(its.orEmpty())
@@ -240,18 +301,16 @@ class PlaylistManager(application: Application) :
240
301
  }
241
302
 
242
303
  private fun resolveItemPosition(trackIndex: Int, trackId: String): Int {
243
- var resolvedPosition = -1
244
- if (trackIndex >= 0 && trackIndex < audioTracks.size) {
245
- resolvedPosition = trackIndex
246
- } else if ("" != trackId) {
247
- val itemPos = getPositionForItem(trackId.hashCode().toLong())
248
- if (itemPos != INVALID_POSITION) {
249
- resolvedPosition = itemPos
250
- }
304
+ return when {
305
+ trackIndex in audioTracks.indices -> trackIndex
306
+ trackId.isNotEmpty() -> findTrackPosition(trackId)
307
+ else -> INVALID_POSITION
251
308
  }
252
- return resolvedPosition
253
309
  }
254
310
 
311
+ internal fun findTrackPosition(trackId: String): Int =
312
+ audioTracks.indexOfFirst { it.trackId == trackId }
313
+
255
314
  fun getVolumeLeft(): Float {
256
315
  return volumeLeft
257
316
  }
@@ -308,6 +367,41 @@ class PlaylistManager(application: Application) :
308
367
 
309
368
  companion object {
310
369
  private const val TAG = "PlaylistManager"
370
+
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
+ @JvmStatic
379
+ fun adjustCurrentIndexForMove(currentIndex: Int, from: Int, to: Int, invalidPosition: Int): Int {
380
+ if (currentIndex == invalidPosition) {
381
+ return currentIndex
382
+ }
383
+ if (from == currentIndex) {
384
+ return to
385
+ }
386
+ val mid = if (from < currentIndex) currentIndex - 1 else currentIndex
387
+ return if (mid >= to) mid + 1 else mid
388
+ }
389
+
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
+ @JvmStatic
398
+ fun mergeReplacementTrackId(existing: AudioTrack, replacement: AudioTrack): AudioTrack {
399
+ val replacementConfig = replacement.toDict()
400
+ if (replacement.trackId.isNullOrEmpty() && existing.trackId != null) {
401
+ replacementConfig.put("trackId", existing.trackId)
402
+ }
403
+ return AudioTrack(replacementConfig)
404
+ }
311
405
  }
312
406
 
313
407
  init {
@@ -0,0 +1,76 @@
1
+ package org.dwbn.plugins.playlist.manager
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Test
5
+
6
+ /**
7
+ * Regression coverage for the currentPosition arithmetic used by [PlaylistManager.moveItem].
8
+ * Verified against a brute-force ground truth (simulate removeAt(from)+add(to,item) on a real
9
+ * list and look up the moved-tracked item's resulting index) for lists of size 2..6 and every
10
+ * valid (from, to, currentIndex) combination.
11
+ */
12
+ class PlaylistManagerMoveTest {
13
+
14
+ private val invalid = -1
15
+
16
+ @Test
17
+ fun currentItem_isTheOneMoved_returnsDestinationIndex() {
18
+ assertEquals(2, PlaylistManager.adjustCurrentIndexForMove(0, 0, 2, invalid))
19
+ assertEquals(0, PlaylistManager.adjustCurrentIndexForMove(3, 3, 0, invalid))
20
+ }
21
+
22
+ @Test
23
+ fun invalidPosition_isReturnedUnchanged() {
24
+ assertEquals(invalid, PlaylistManager.adjustCurrentIndexForMove(invalid, 0, 2, invalid))
25
+ }
26
+
27
+ @Test
28
+ fun movingItemFromBeforeCurrent_toAtOrBeforeCurrent_shiftsCurrentLeft() {
29
+ // list [a,b,c,d], current = b (index 1), move a(0) -> 1: [b,a,c,d]; b is now at 0.
30
+ assertEquals(0, PlaylistManager.adjustCurrentIndexForMove(1, 0, 1, invalid))
31
+ }
32
+
33
+ @Test
34
+ fun movingItemFromBeforeCurrent_toAfterCurrent_leavesCurrentUnchanged() {
35
+ // list [a,b,c,d], current = b (index 1), move a(0) -> 3: [b,c,d,a]; b stays at 0.
36
+ assertEquals(0, PlaylistManager.adjustCurrentIndexForMove(1, 0, 3, invalid))
37
+ }
38
+
39
+ @Test
40
+ fun movingItemFromAfterCurrent_toAtOrBeforeCurrent_shiftsCurrentRight() {
41
+ // list [a,b,c,d], current = a (index 0), move c(2) -> 0: [c,a,b,d]; a is now at 1.
42
+ assertEquals(1, PlaylistManager.adjustCurrentIndexForMove(0, 2, 0, invalid))
43
+ }
44
+
45
+ @Test
46
+ fun movingItemFromAfterCurrent_toAfterCurrent_leavesCurrentUnchanged() {
47
+ // list [a,b,c,d], current = a (index 0), move c(2) -> 3: [a,b,d,c]; a stays at 0.
48
+ assertEquals(0, PlaylistManager.adjustCurrentIndexForMove(0, 2, 3, invalid))
49
+ }
50
+
51
+ @Test
52
+ fun bruteForce_matchesGroundTruthForAllCombinations() {
53
+ for (n in 2..6) {
54
+ val base = (0 until n).toList()
55
+ for (from in 0 until n) {
56
+ for (to in 0 until n) {
57
+ if (from == to) continue
58
+ val expectedArr = base.toMutableList()
59
+ val moved = expectedArr.removeAt(from)
60
+ expectedArr.add(to, moved)
61
+
62
+ for (currentIndex in 0 until n) {
63
+ val trackedId = base[currentIndex]
64
+ val expectedNewIndex = expectedArr.indexOf(trackedId)
65
+ val got = PlaylistManager.adjustCurrentIndexForMove(currentIndex, from, to, invalid)
66
+ assertEquals(
67
+ "n=$n from=$from to=$to currentIndex=$currentIndex",
68
+ expectedNewIndex,
69
+ got
70
+ )
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,57 @@
1
+ package org.dwbn.plugins.playlist.manager
2
+
3
+ import org.dwbn.plugins.playlist.data.AudioTrack
4
+ import org.json.JSONObject
5
+ import org.junit.Assert.assertEquals
6
+ import org.junit.Assert.assertNull
7
+ import org.junit.Test
8
+
9
+ /**
10
+ * Regression coverage for [PlaylistManager.mergeReplacementTrackId], which decides whether a
11
+ * replacement track keeps its own id or inherits the id of the track it is replacing.
12
+ */
13
+ class PlaylistManagerReplaceTest {
14
+
15
+ private fun track(trackId: String? = null, assetUrl: String = "https://example.com/a.mp3"): AudioTrack {
16
+ val json = JSONObject()
17
+ if (trackId != null) {
18
+ json.put("trackId", trackId)
19
+ }
20
+ json.put("assetUrl", assetUrl)
21
+ json.put("artist", "Artist")
22
+ json.put("album", "Album")
23
+ json.put("title", "Title")
24
+ return AudioTrack(json)
25
+ }
26
+
27
+ @Test
28
+ fun replacementWithoutTrackId_inheritsExistingTrackId() {
29
+ val existing = track(trackId = "existing-id")
30
+ val replacement = track(trackId = null, assetUrl = "https://example.com/local.mp3")
31
+
32
+ val result = PlaylistManager.mergeReplacementTrackId(existing, replacement)
33
+
34
+ assertEquals("existing-id", result.trackId)
35
+ assertEquals("https://example.com/local.mp3", result.mediaUrl)
36
+ }
37
+
38
+ @Test
39
+ fun replacementWithExplicitTrackId_isNotOverridden() {
40
+ val existing = track(trackId = "existing-id")
41
+ val replacement = track(trackId = "new-id")
42
+
43
+ val result = PlaylistManager.mergeReplacementTrackId(existing, replacement)
44
+
45
+ assertEquals("new-id", result.trackId)
46
+ }
47
+
48
+ @Test
49
+ fun replacementWithoutTrackId_andNoExistingTrackId_staysNull() {
50
+ val existing = track(trackId = null)
51
+ val replacement = track(trackId = null)
52
+
53
+ val result = PlaylistManager.mergeReplacementTrackId(existing, replacement)
54
+
55
+ assertNull(result.trackId)
56
+ }
57
+ }