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.
- package/README.md +63 -6
- package/android/build.gradle +21 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +76 -19
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioStatusMessage.java +2 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +7 -6
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +124 -30
- package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistManagerMoveTest.kt +76 -0
- package/android/src/test/java/org/dwbn/plugins/playlist/manager/PlaylistManagerReplaceTest.kt +57 -0
- package/dist/docs.json +111 -1
- package/dist/esm/Constants.d.ts +10 -0
- package/dist/esm/Constants.js +10 -0
- package/dist/esm/Constants.js.map +1 -1
- package/dist/esm/RmxAudioPlayer.d.ts +16 -2
- package/dist/esm/RmxAudioPlayer.js +28 -3
- package/dist/esm/RmxAudioPlayer.js.map +1 -1
- package/dist/esm/definitions.d.ts +28 -1
- package/dist/esm/web.d.ts +3 -1
- package/dist/esm/web.js +54 -2
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +92 -5
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +92 -5
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/PlaylistPlugin/Constants.swift +2 -0
- package/ios/Sources/PlaylistPlugin/Plugin.swift +52 -5
- package/ios/Sources/PlaylistPlugin/RmxAudioPlayer.swift +123 -1
- package/ios/Tests/PlaylistPluginTests/PlaylistMutationTests.swift +168 -0
- package/package.json +10 -4
- package/android/.gradle/8.14.3/checksums/checksums.lock +0 -0
- package/android/.gradle/8.14.3/checksums/md5-checksums.bin +0 -0
- package/android/.gradle/8.14.3/checksums/sha1-checksums.bin +0 -0
- package/android/.gradle/8.14.3/executionHistory/executionHistory.bin +0 -0
- package/android/.gradle/8.14.3/executionHistory/executionHistory.lock +0 -0
- package/android/.gradle/8.14.3/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.14.3/fileHashes/fileHashes.bin +0 -0
- package/android/.gradle/8.14.3/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.14.3/fileHashes/resourceHashesCache.bin +0 -0
- package/android/.gradle/8.14.3/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +0 -2
- package/android/.gradle/buildOutputCleanup/outputFiles.bin +0 -0
- package/android/.gradle/file-system.probe +0 -0
- 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
|
-
-
|
|
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
|
|
1039
|
-
|
|
|
1040
|
-
| **`item`**
|
|
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
|
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
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
|
|
261
|
-
val
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
301
|
-
val
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|