react-native-queue-player 1.0.6 → 1.0.8
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/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +6 -42
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +7 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +72 -23
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +15 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/ios/AVQueueBuilder.swift +29 -0
- package/ios/QueueState.swift +14 -2
- package/ios/Tests/AVQueueBuilderTests.swift +74 -0
- package/ios/Tests/QueueStateTests.swift +50 -0
- package/ios/TrackPlayer.swift +121 -14
- package/lib/module/hooks/index.js +1 -0
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/useQueue.js +57 -0
- package/lib/module/hooks/useQueue.js.map +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/types.js +7 -0
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/lib/typescript/hooks/index.d.ts +2 -0
- package/lib/typescript/hooks/index.d.ts.map +1 -1
- package/lib/typescript/hooks/useQueue.d.ts +20 -0
- package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +1 -1
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/types.d.ts +5 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
- package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
- package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
- package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
- package/package.json +1 -1
- package/src/TrackPlayer.nitro.ts +33 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useQueue.ts +56 -0
- package/src/index.ts +1 -0
- package/src/types.ts +12 -0
|
@@ -65,7 +65,6 @@ class PlayerCommandsForTest {
|
|
|
65
65
|
fun `commands include every skip pair when capability allows both`() {
|
|
66
66
|
val commands = service.playerCommandsFor(
|
|
67
67
|
SkipCapability(canSkipPrevious = true, canSkipNext = true),
|
|
68
|
-
hasMediaItems = true,
|
|
69
68
|
)
|
|
70
69
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
71
70
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -78,7 +77,6 @@ class PlayerCommandsForTest {
|
|
|
78
77
|
fun `commands omit only the next pair when capability disallows skip-next`() {
|
|
79
78
|
val commands = service.playerCommandsFor(
|
|
80
79
|
SkipCapability(canSkipPrevious = true, canSkipNext = false),
|
|
81
|
-
hasMediaItems = true,
|
|
82
80
|
)
|
|
83
81
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
84
82
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -91,7 +89,6 @@ class PlayerCommandsForTest {
|
|
|
91
89
|
fun `commands omit only the previous pair when capability disallows skip-previous`() {
|
|
92
90
|
val commands = service.playerCommandsFor(
|
|
93
91
|
SkipCapability(canSkipPrevious = false, canSkipNext = true),
|
|
94
|
-
hasMediaItems = true,
|
|
95
92
|
)
|
|
96
93
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
97
94
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -104,7 +101,6 @@ class PlayerCommandsForTest {
|
|
|
104
101
|
fun `commands omit both skip pairs when capability disallows both`() {
|
|
105
102
|
val commands = service.playerCommandsFor(
|
|
106
103
|
SkipCapability(canSkipPrevious = false, canSkipNext = false),
|
|
107
|
-
hasMediaItems = true,
|
|
108
104
|
)
|
|
109
105
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT))
|
|
110
106
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -119,7 +115,6 @@ class PlayerCommandsForTest {
|
|
|
119
115
|
fun `repeat OFF at index 0 of a two-track queue exposes only skip-next`() {
|
|
120
116
|
val commands = service.playerCommandsFor(
|
|
121
117
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 0),
|
|
122
|
-
hasMediaItems = true,
|
|
123
118
|
)
|
|
124
119
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
125
120
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -129,7 +124,6 @@ class PlayerCommandsForTest {
|
|
|
129
124
|
fun `repeat OFF at the last index of a two-track queue exposes only skip-previous`() {
|
|
130
125
|
val commands = service.playerCommandsFor(
|
|
131
126
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 2, current = 1),
|
|
132
|
-
hasMediaItems = true,
|
|
133
127
|
)
|
|
134
128
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
135
129
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -139,7 +133,6 @@ class PlayerCommandsForTest {
|
|
|
139
133
|
fun `repeat OFF on a single-track queue exposes neither skip command`() {
|
|
140
134
|
val commands = service.playerCommandsFor(
|
|
141
135
|
capabilityFor(repeat = QueueSkipRepeatMode.OFF, count = 1, current = 0),
|
|
142
|
-
hasMediaItems = true,
|
|
143
136
|
)
|
|
144
137
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
145
138
|
assertFalse(commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -149,15 +142,12 @@ class PlayerCommandsForTest {
|
|
|
149
142
|
fun `repeat QUEUE wraps so both skip commands stay enabled at every queue position`() {
|
|
150
143
|
val first = service.playerCommandsFor(
|
|
151
144
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 0),
|
|
152
|
-
hasMediaItems = true,
|
|
153
145
|
)
|
|
154
146
|
val mid = service.playerCommandsFor(
|
|
155
147
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 1),
|
|
156
|
-
hasMediaItems = true,
|
|
157
148
|
)
|
|
158
149
|
val last = service.playerCommandsFor(
|
|
159
150
|
capabilityFor(repeat = QueueSkipRepeatMode.QUEUE, count = 3, current = 2),
|
|
160
|
-
hasMediaItems = true,
|
|
161
151
|
)
|
|
162
152
|
for (commands in listOf(first, mid, last)) {
|
|
163
153
|
assertTrue(commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
@@ -170,7 +160,6 @@ class PlayerCommandsForTest {
|
|
|
170
160
|
// First track of 2: next available, no previous (no wrap).
|
|
171
161
|
val atFirst = service.playerCommandsFor(
|
|
172
162
|
capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 0),
|
|
173
|
-
hasMediaItems = true,
|
|
174
163
|
)
|
|
175
164
|
assertTrue(atFirst.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
176
165
|
assertFalse(atFirst.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
@@ -178,31 +167,33 @@ class PlayerCommandsForTest {
|
|
|
178
167
|
// Last track of 2: previous available, no next (no wrap).
|
|
179
168
|
val atLast = service.playerCommandsFor(
|
|
180
169
|
capabilityFor(repeat = QueueSkipRepeatMode.TRACK, count = 2, current = 1),
|
|
181
|
-
hasMediaItems = true,
|
|
182
170
|
)
|
|
183
171
|
assertFalse(atLast.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM))
|
|
184
172
|
assertTrue(atLast.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM))
|
|
185
173
|
}
|
|
186
174
|
|
|
187
175
|
@Test
|
|
188
|
-
fun `empty queue
|
|
176
|
+
fun `empty queue disables skip but keeps transport commands for the System UI`() {
|
|
189
177
|
for (mode in QueueSkipRepeatMode.entries) {
|
|
190
178
|
val commands = service.playerCommandsFor(
|
|
191
179
|
capabilityFor(repeat = mode, count = 0, current = -1),
|
|
192
|
-
hasMediaItems = false,
|
|
193
180
|
)
|
|
194
181
|
assertFalse(
|
|
195
|
-
"skip-next is
|
|
182
|
+
"skip-next is disabled with no tracks (repeat=$mode)",
|
|
196
183
|
commands.contains(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM),
|
|
197
184
|
)
|
|
198
185
|
assertFalse(
|
|
199
|
-
"skip-previous is
|
|
186
|
+
"skip-previous is disabled with no tracks (repeat=$mode)",
|
|
200
187
|
commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
|
|
201
188
|
)
|
|
202
|
-
|
|
203
|
-
"
|
|
189
|
+
assertTrue(
|
|
190
|
+
"play/pause is retained so the API 33+ System UI keeps the button (repeat=$mode)",
|
|
204
191
|
commands.contains(Player.COMMAND_PLAY_PAUSE),
|
|
205
192
|
)
|
|
193
|
+
assertTrue(
|
|
194
|
+
"in-item seek is retained so the seek scrubber stays draggable (repeat=$mode)",
|
|
195
|
+
commands.contains(Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM),
|
|
196
|
+
)
|
|
206
197
|
}
|
|
207
198
|
}
|
|
208
199
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
package com.margelo.nitro.queueplayer
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.util.UnstableApi
|
|
4
|
+
import androidx.test.core.app.ApplicationProvider
|
|
5
|
+
import org.junit.After
|
|
6
|
+
import org.junit.Assert.assertEquals
|
|
7
|
+
import org.junit.Assert.assertTrue
|
|
8
|
+
import org.junit.Before
|
|
9
|
+
import org.junit.Test
|
|
10
|
+
import org.junit.runner.RunWith
|
|
11
|
+
import org.robolectric.RobolectricTestRunner
|
|
12
|
+
import org.robolectric.annotation.Config
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `onQueueChange` emission coverage against a live ExoPlayer via
|
|
16
|
+
* Robolectric. Every queue-content/order mutation fires exactly one
|
|
17
|
+
* event carrying the post-mutation `(queue, index, reason)`; a no-op
|
|
18
|
+
* mutation fires nothing. `emitQueueChange` runs synchronously inside
|
|
19
|
+
* the mutation on the test (main) thread, so no looper idle is needed to
|
|
20
|
+
* observe the fire.
|
|
21
|
+
*/
|
|
22
|
+
@RunWith(RobolectricTestRunner::class)
|
|
23
|
+
@Config(sdk = [34])
|
|
24
|
+
@UnstableApi
|
|
25
|
+
class TrackPlayerQueueChangeTest {
|
|
26
|
+
|
|
27
|
+
private lateinit var player: TrackPlayer
|
|
28
|
+
private lateinit var serviceHandle: PlaybackServiceTestHandle
|
|
29
|
+
|
|
30
|
+
private data class QueueEvent(
|
|
31
|
+
val queue: List<TrackItem>,
|
|
32
|
+
val index: Int,
|
|
33
|
+
val reason: QueueChangeReason,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
private fun context() =
|
|
37
|
+
ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
38
|
+
|
|
39
|
+
private fun track(id: String) = TrackItem(
|
|
40
|
+
id = id, url = "https://example.com/$id.m4a",
|
|
41
|
+
title = null, artist = null, album = null,
|
|
42
|
+
duration = null, artworkUrl = null, extras = null,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@Before
|
|
46
|
+
fun setUp() {
|
|
47
|
+
player = TrackPlayer()
|
|
48
|
+
serviceHandle = bindPlaybackServiceForTest(player, context())
|
|
49
|
+
player.configureInternal(
|
|
50
|
+
PlayerConfig(httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null), context()
|
|
51
|
+
)
|
|
52
|
+
player.setQueueInternal((1..5).map { track("t$it") })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@After
|
|
56
|
+
fun tearDown() {
|
|
57
|
+
player.destroyInternal()
|
|
58
|
+
serviceHandle.destroy()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Register a capturing listener AFTER the @Before setQueue (which is
|
|
62
|
+
* therefore not captured — onQueueChange does not auto-fire). */
|
|
63
|
+
private fun capture(): Pair<MutableList<QueueEvent>, () -> Unit> {
|
|
64
|
+
val events = mutableListOf<QueueEvent>()
|
|
65
|
+
val dispose = player.onQueueChange { queue, idx, reason ->
|
|
66
|
+
events.add(QueueEvent(queue.toList(), idx.toInt(), reason))
|
|
67
|
+
}
|
|
68
|
+
return events to dispose
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- one emission per mutation, with the post-mutation snapshot
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `setQueue fires SET_QUEUE with the new queue + resolved index`() {
|
|
75
|
+
val (events, dispose) = capture()
|
|
76
|
+
player.setQueueInternal((1..3).map { track("n$it") }, startAtIndex = 1)
|
|
77
|
+
dispose()
|
|
78
|
+
assertEquals(1, events.size)
|
|
79
|
+
assertEquals(QueueChangeReason.SET_QUEUE, events[0].reason)
|
|
80
|
+
assertEquals(3, events[0].queue.size)
|
|
81
|
+
assertEquals(1, events[0].index)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test
|
|
85
|
+
fun `addToQueue fires ADD with the grown queue`() {
|
|
86
|
+
val (events, dispose) = capture()
|
|
87
|
+
player.addToQueueInternal(listOf(track("n1")), insertBefore = null)
|
|
88
|
+
dispose()
|
|
89
|
+
assertEquals(1, events.size)
|
|
90
|
+
assertEquals(QueueChangeReason.ADD, events[0].reason)
|
|
91
|
+
assertEquals(6, events[0].queue.size)
|
|
92
|
+
assertEquals("n1", events[0].queue[5].id)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test
|
|
96
|
+
fun `removeFromQueue fires REMOVE with the shrunk queue`() {
|
|
97
|
+
val (events, dispose) = capture()
|
|
98
|
+
player.removeFromQueueInternal(listOf(3)) // remove t4; cur=0 pinned
|
|
99
|
+
dispose()
|
|
100
|
+
assertEquals(1, events.size)
|
|
101
|
+
assertEquals(QueueChangeReason.REMOVE, events[0].reason)
|
|
102
|
+
assertEquals(listOf("t1", "t2", "t3", "t5"), events[0].queue.map { it.id })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@Test
|
|
106
|
+
fun `moveInQueue fires MOVE`() {
|
|
107
|
+
val (events, dispose) = capture()
|
|
108
|
+
player.moveInQueueInternal(from = 3, to = 1) // cur=0 not moved
|
|
109
|
+
dispose()
|
|
110
|
+
assertEquals(1, events.size)
|
|
111
|
+
assertEquals(QueueChangeReason.MOVE, events[0].reason)
|
|
112
|
+
// t4 (index 3) removed + reinserted at 1 → t1,t4,t2,t3,t5.
|
|
113
|
+
assertEquals(listOf("t1", "t4", "t2", "t3", "t5"), events[0].queue.map { it.id })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@Test
|
|
117
|
+
fun `clearQueue fires CLEAR with an empty queue + index -1`() {
|
|
118
|
+
val (events, dispose) = capture()
|
|
119
|
+
player.clearQueueInternal()
|
|
120
|
+
dispose()
|
|
121
|
+
assertEquals(1, events.size)
|
|
122
|
+
assertEquals(QueueChangeReason.CLEAR, events[0].reason)
|
|
123
|
+
assertEquals(0, events[0].queue.size)
|
|
124
|
+
assertEquals(-1, events[0].index)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@Test
|
|
128
|
+
fun `shuffleQueue fires SHUFFLE with a full queue at index 0`() {
|
|
129
|
+
val (events, dispose) = capture()
|
|
130
|
+
player.shuffleQueueInternal()
|
|
131
|
+
dispose()
|
|
132
|
+
assertEquals(1, events.size)
|
|
133
|
+
assertEquals(QueueChangeReason.SHUFFLE, events[0].reason)
|
|
134
|
+
assertEquals(5, events[0].queue.size)
|
|
135
|
+
assertEquals(0, events[0].index)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- no-op mutations do not fire
|
|
139
|
+
|
|
140
|
+
@Test
|
|
141
|
+
fun `empty addToQueue does not fire`() {
|
|
142
|
+
val (events, dispose) = capture()
|
|
143
|
+
player.addToQueueInternal(emptyList(), insertBefore = null)
|
|
144
|
+
dispose()
|
|
145
|
+
assertTrue(events.isEmpty())
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test
|
|
149
|
+
fun `no-op move (from == to) does not fire`() {
|
|
150
|
+
val (events, dispose) = capture()
|
|
151
|
+
player.moveInQueueInternal(from = 2, to = 2)
|
|
152
|
+
dispose()
|
|
153
|
+
assertTrue(events.isEmpty())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@Test
|
|
157
|
+
fun `out-of-range remove does not fire`() {
|
|
158
|
+
val (events, dispose) = capture()
|
|
159
|
+
player.removeFromQueueInternal(listOf(99))
|
|
160
|
+
dispose()
|
|
161
|
+
assertTrue(events.isEmpty())
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@Test
|
|
165
|
+
fun `clearing an already-empty queue does not fire`() {
|
|
166
|
+
player.clearQueueInternal() // empties (fires; no listener yet)
|
|
167
|
+
val (events, dispose) = capture()
|
|
168
|
+
player.clearQueueInternal() // already empty → no-op
|
|
169
|
+
dispose()
|
|
170
|
+
assertTrue(events.isEmpty())
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
@Test
|
|
174
|
+
fun `unsubscribe stops emissions`() {
|
|
175
|
+
val (events, dispose) = capture()
|
|
176
|
+
dispose()
|
|
177
|
+
player.addToQueueInternal(listOf(track("n1")), insertBefore = null)
|
|
178
|
+
assertTrue(events.isEmpty())
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -187,6 +187,7 @@ private class FakePlaybackEngineWithSinks : PlaybackEngine {
|
|
|
187
187
|
override val bufferedPositionMs: Long get() = error("not used in test")
|
|
188
188
|
override val isPlaying: Boolean get() = error("not used in test")
|
|
189
189
|
override val currentMediaItem: androidx.media3.common.MediaItem? get() = error("not used in test")
|
|
190
|
+
override val isCrossfadeActive: Boolean get() = false
|
|
190
191
|
override val allMediaItems: List<androidx.media3.common.MediaItem> get() = error("not used in test")
|
|
191
192
|
override fun currentAudioSessionIds(): IntArray = IntArray(0)
|
|
192
193
|
override fun addAudioSessionIdListener(listener: (IntArray) -> Unit): () -> Unit = { /* noop */ }
|
package/ios/AVQueueBuilder.swift
CHANGED
|
@@ -55,6 +55,35 @@ public enum AVQueueBuilder {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/// Best-effort warm-up of a freshly-built item's asset BEFORE it is
|
|
59
|
+
/// inserted into a live `AVQueuePlayer`. When the insert lands in the
|
|
60
|
+
/// immediate-next slot of a playing queue, `AVQueuePlayer` prerolls the
|
|
61
|
+
/// new item on the shared decode/render pipeline while the current item
|
|
62
|
+
/// is still rendering; faulting the asset's `tracks` / `duration` /
|
|
63
|
+
/// `playable` keys in synchronously at that moment briefly disturbs the
|
|
64
|
+
/// currently-playing item. Loading them here first makes that preroll
|
|
65
|
+
/// cheap.
|
|
66
|
+
///
|
|
67
|
+
/// Best-effort: the load races a [timeout] deadline and the caller
|
|
68
|
+
/// proceeds as soon as either wins. `AVAsset`'s async loader honours
|
|
69
|
+
/// task cancellation, so `cancelAll` caps a slow / unreachable remote
|
|
70
|
+
/// asset at [timeout] and the item is inserted unwarmed rather than
|
|
71
|
+
/// deferring the enqueue. Load errors are ignored for the same reason.
|
|
72
|
+
@MainActor
|
|
73
|
+
static func preloadAssetForInsert(_ item: AVPlayerItem, timeout: TimeInterval = 2.0) async {
|
|
74
|
+
let asset = item.asset
|
|
75
|
+
await withTaskGroup(of: Void.self) { group in
|
|
76
|
+
group.addTask { @MainActor in
|
|
77
|
+
_ = try? await asset.load(.tracks, .duration, .isPlayable)
|
|
78
|
+
}
|
|
79
|
+
group.addTask {
|
|
80
|
+
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
|
|
81
|
+
}
|
|
82
|
+
_ = await group.next()
|
|
83
|
+
group.cancelAll()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
58
87
|
/// Single-item builder. Used by addToQueue. Honours the cache
|
|
59
88
|
/// lookup when a cache instance is supplied.
|
|
60
89
|
///
|
package/ios/QueueState.swift
CHANGED
|
@@ -23,11 +23,23 @@ internal struct QueueState {
|
|
|
23
23
|
/// Returns the freshly-generated IDs for the inserted slice.
|
|
24
24
|
mutating func insert(_ newTracks: [TrackItem], at index: Int) -> [String] {
|
|
25
25
|
let newIds = newTracks.map { _ in UUID().uuidString }
|
|
26
|
-
|
|
27
|
-
queueItemIds.insert(contentsOf: newIds, at: index)
|
|
26
|
+
insert(newTracks, at: index, ids: newIds)
|
|
28
27
|
return newIds
|
|
29
28
|
}
|
|
30
29
|
|
|
30
|
+
/// Insert with caller-supplied IDs — used when the AVPlayerItems were
|
|
31
|
+
/// built (and their assets preloaded) ahead of the state mutation, so
|
|
32
|
+
/// the same lib-generated `queueItemId` that was stamped on each item
|
|
33
|
+
/// stays 1:1 with `queueItemIds`. `ids` must parallel `newTracks`.
|
|
34
|
+
mutating func insert(_ newTracks: [TrackItem], at index: Int, ids: [String]) {
|
|
35
|
+
precondition(
|
|
36
|
+
ids.count == newTracks.count,
|
|
37
|
+
"QueueState.insert requires ids parallel to newTracks (1:1)"
|
|
38
|
+
)
|
|
39
|
+
tracks.insert(contentsOf: newTracks, at: index)
|
|
40
|
+
queueItemIds.insert(contentsOf: ids, at: index)
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
/// Returns the removed `queueItemId`.
|
|
32
44
|
mutating func remove(at index: Int) -> String {
|
|
33
45
|
let removedId = queueItemIds.remove(at: index)
|
|
@@ -15,6 +15,10 @@ import AVFoundation
|
|
|
15
15
|
/// overloads are adapters that unpack the Nitro structs and delegate here,
|
|
16
16
|
/// so exercising the raw entry points is sufficient coverage for the
|
|
17
17
|
/// merge + routing behaviour.
|
|
18
|
+
// Main-actor-isolated: several assertions read `AVPlayerItem.asset`, which
|
|
19
|
+
// the SDK marks main-actor-isolated; running the cases on the main actor
|
|
20
|
+
// keeps those reads warning-free.
|
|
21
|
+
@MainActor
|
|
18
22
|
final class AVQueueBuilderTests: XCTestCase {
|
|
19
23
|
|
|
20
24
|
// MARK: - httpOptionsRaw merging
|
|
@@ -421,10 +425,80 @@ final class AVQueueBuilderTests: XCTestCase {
|
|
|
421
425
|
"queueItemId must be set even when track.id is empty string")
|
|
422
426
|
}
|
|
423
427
|
|
|
428
|
+
// MARK: - preloadAssetForInsert
|
|
429
|
+
|
|
430
|
+
func testPreloadAssetForInsertLoadsTracksForLocalFile() async throws {
|
|
431
|
+
let url = try Self.writeTinyWav()
|
|
432
|
+
defer { try? FileManager.default.removeItem(at: url) }
|
|
433
|
+
let item = try XCTUnwrap(
|
|
434
|
+
AVQueueBuilder.makeItemRaw(urlString: url.absoluteString, headers: nil, userAgent: nil))
|
|
435
|
+
|
|
436
|
+
await AVQueueBuilder.preloadAssetForInsert(item)
|
|
437
|
+
|
|
438
|
+
// The load resolved the tracks key on the same asset instance the
|
|
439
|
+
// enqueue will insert, so the audio track is present without a second
|
|
440
|
+
// fault — the preroll after insert has nothing left to load.
|
|
441
|
+
let tracks = try await item.asset.load(.tracks)
|
|
442
|
+
XCTAssertFalse(tracks.isEmpty,
|
|
443
|
+
"preload must resolve the local asset's tracks so the enqueue preroll is cheap")
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
func testPreloadAssetForInsertLeavesUnreadableAssetInsertable() async throws {
|
|
447
|
+
// Best-effort contract: a missing file's load fails but the item stays
|
|
448
|
+
// intact + insertable, so the enqueue proceeds unwarmed rather than
|
|
449
|
+
// being blocked or corrupted.
|
|
450
|
+
let missing = FileManager.default.temporaryDirectory
|
|
451
|
+
.appendingPathComponent("does-not-exist-\(UUID().uuidString).m4a")
|
|
452
|
+
let item = try XCTUnwrap(
|
|
453
|
+
AVQueueBuilder.makeItemRaw(urlString: missing.absoluteString, headers: nil, userAgent: nil))
|
|
454
|
+
|
|
455
|
+
let start = Date()
|
|
456
|
+
await AVQueueBuilder.preloadAssetForInsert(item, timeout: 0.5)
|
|
457
|
+
let elapsed = Date().timeIntervalSince(start)
|
|
458
|
+
|
|
459
|
+
XCTAssertLessThan(elapsed, 2.0,
|
|
460
|
+
"a best-effort preload of an unreadable asset must return promptly, not hang")
|
|
461
|
+
let urlAsset = try XCTUnwrap(item.asset as? AVURLAsset)
|
|
462
|
+
XCTAssertEqual(urlAsset.url, missing,
|
|
463
|
+
"a failed preload must leave the item's asset unchanged and insertable")
|
|
464
|
+
}
|
|
465
|
+
|
|
424
466
|
private func makeTrack(id: String, url: String) -> TrackItem {
|
|
425
467
|
return TrackItem(
|
|
426
468
|
id: id, url: url, title: nil, artist: nil, album: nil,
|
|
427
469
|
duration: nil, artworkUrl: nil, extras: nil
|
|
428
470
|
)
|
|
429
471
|
}
|
|
472
|
+
|
|
473
|
+
/// Writes a minimal valid 16-bit PCM mono WAV (silent samples) so
|
|
474
|
+
/// AVFoundation resolves exactly one audio track from the file.
|
|
475
|
+
private static func writeTinyWav() throws -> URL {
|
|
476
|
+
let sampleRate: UInt32 = 44100
|
|
477
|
+
let channels: UInt16 = 1
|
|
478
|
+
let bitsPerSample: UInt16 = 16
|
|
479
|
+
let frameCount = 1024
|
|
480
|
+
let dataSize = UInt32(frameCount) * UInt32(channels) * UInt32(bitsPerSample / 8)
|
|
481
|
+
var data = Data()
|
|
482
|
+
func appendLE<T: FixedWidthInteger>(_ value: T) {
|
|
483
|
+
withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) }
|
|
484
|
+
}
|
|
485
|
+
data.append(contentsOf: Array("RIFF".utf8))
|
|
486
|
+
appendLE(UInt32(36) + dataSize)
|
|
487
|
+
data.append(contentsOf: Array("WAVE".utf8))
|
|
488
|
+
data.append(contentsOf: Array("fmt ".utf8))
|
|
489
|
+
appendLE(UInt32(16))
|
|
490
|
+
appendLE(UInt16(1))
|
|
491
|
+
appendLE(channels)
|
|
492
|
+
appendLE(sampleRate)
|
|
493
|
+
appendLE(sampleRate * UInt32(channels) * UInt32(bitsPerSample / 8))
|
|
494
|
+
appendLE(UInt16(channels * bitsPerSample / 8))
|
|
495
|
+
appendLE(bitsPerSample)
|
|
496
|
+
data.append(contentsOf: Array("data".utf8))
|
|
497
|
+
appendLE(dataSize)
|
|
498
|
+
data.append(Data(count: Int(dataSize)))
|
|
499
|
+
let url = FileManager.default.temporaryDirectory
|
|
500
|
+
.appendingPathComponent("preload-\(UUID().uuidString).wav")
|
|
501
|
+
try data.write(to: url)
|
|
502
|
+
return url
|
|
503
|
+
}
|
|
430
504
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import QueuePlayer
|
|
3
|
+
|
|
4
|
+
/// `QueueState` holds `tracks` and `queueItemIds` as parallel arrays. The
|
|
5
|
+
/// id-supplying `insert` overload lets a caller reuse the lib-generated
|
|
6
|
+
/// ids it stamped on pre-built AVPlayerItems (the immediate-next preload
|
|
7
|
+
/// path) while preserving the 1:1 invariant.
|
|
8
|
+
final class QueueStateTests: XCTestCase {
|
|
9
|
+
|
|
10
|
+
func testInsertWithSuppliedIdsPlacesThemParallelToTracks() {
|
|
11
|
+
var state = QueueState()
|
|
12
|
+
_ = state.replaceAll([track("a"), track("b"), track("c")])
|
|
13
|
+
|
|
14
|
+
state.insert([track("x"), track("y")], at: 1, ids: ["id-x", "id-y"])
|
|
15
|
+
|
|
16
|
+
XCTAssertEqual(state.tracks.map { $0.url }, ["a", "x", "y", "b", "c"])
|
|
17
|
+
XCTAssertEqual(state.queueItemIds.count, state.tracks.count)
|
|
18
|
+
XCTAssertEqual(state.queueItemIds[1], "id-x")
|
|
19
|
+
XCTAssertEqual(state.queueItemIds[2], "id-y")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func testInsertWithSuppliedIdsAppendsAtEnd() {
|
|
23
|
+
var state = QueueState()
|
|
24
|
+
_ = state.replaceAll([track("a")])
|
|
25
|
+
|
|
26
|
+
state.insert([track("z")], at: 1, ids: ["id-z"])
|
|
27
|
+
|
|
28
|
+
XCTAssertEqual(state.tracks.map { $0.url }, ["a", "z"])
|
|
29
|
+
XCTAssertEqual(state.queueItemIds.count, 2)
|
|
30
|
+
XCTAssertEqual(state.queueItemIds[1], "id-z")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testGeneratingInsertStillReturnsFreshParallelIds() {
|
|
34
|
+
var state = QueueState()
|
|
35
|
+
_ = state.replaceAll([track("a"), track("b")])
|
|
36
|
+
|
|
37
|
+
let ids = state.insert([track("x")], at: 1)
|
|
38
|
+
|
|
39
|
+
XCTAssertEqual(ids.count, 1)
|
|
40
|
+
XCTAssertEqual(state.queueItemIds[1], ids[0])
|
|
41
|
+
XCTAssertEqual(state.queueItemIds.count, state.tracks.count)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private func track(_ url: String) -> TrackItem {
|
|
45
|
+
TrackItem(
|
|
46
|
+
id: nil, url: url, title: nil, artist: nil, album: nil,
|
|
47
|
+
duration: nil, artworkUrl: nil, extras: nil
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
}
|