react-native-queue-player 1.0.7 → 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/TrackPlayer.kt +39 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -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
|
@@ -544,6 +544,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
544
544
|
// simultaneously without clobbering each other.
|
|
545
545
|
private val stateChangeListeners = ListenerRegistry<(PlayerState, StateChangeReason) -> Unit>()
|
|
546
546
|
private val trackChangeListeners = ListenerRegistry<(TrackItem?, Double, TrackChangeReason, Double?) -> Unit>()
|
|
547
|
+
private val queueChangeListeners = ListenerRegistry<(Array<TrackItem>, Double, QueueChangeReason) -> Unit>()
|
|
547
548
|
private val progressListeners = ListenerRegistry<(PlayerProgress) -> Unit>()
|
|
548
549
|
// Buffer state — discrete empty/buffering/stalled/full for the active track, derived
|
|
549
550
|
// from Media3's playback state + buffered-ahead. Recomputed on the engine
|
|
@@ -1445,6 +1446,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1445
1446
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
|
|
1446
1447
|
rescheduleLookahead()
|
|
1447
1448
|
recomputeCapabilities()
|
|
1449
|
+
emitQueueChange(QueueChangeReason.SET_QUEUE)
|
|
1448
1450
|
}
|
|
1449
1451
|
|
|
1450
1452
|
/**
|
|
@@ -1503,6 +1505,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1503
1505
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
|
|
1504
1506
|
rescheduleLookahead()
|
|
1505
1507
|
recomputeCapabilities()
|
|
1508
|
+
emitQueueChange(QueueChangeReason.SET_QUEUE)
|
|
1506
1509
|
return items
|
|
1507
1510
|
}
|
|
1508
1511
|
|
|
@@ -1595,6 +1598,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1595
1598
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
|
|
1596
1599
|
rescheduleLookahead()
|
|
1597
1600
|
recomputeCapabilities()
|
|
1601
|
+
emitQueueChange(QueueChangeReason.ADD)
|
|
1598
1602
|
return at
|
|
1599
1603
|
}
|
|
1600
1604
|
|
|
@@ -1688,6 +1692,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1688
1692
|
serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
|
|
1689
1693
|
rescheduleLookahead()
|
|
1690
1694
|
recomputeCapabilities()
|
|
1695
|
+
// Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
|
|
1696
|
+
// removed; tracks + index are final.
|
|
1697
|
+
emitQueueChange(QueueChangeReason.REMOVE)
|
|
1691
1698
|
return sanitised
|
|
1692
1699
|
}
|
|
1693
1700
|
|
|
@@ -1753,6 +1760,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1753
1760
|
for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
|
|
1754
1761
|
rescheduleLookahead()
|
|
1755
1762
|
recomputeCapabilities()
|
|
1763
|
+
// Reached only past the invalid / `from == to` / pinned-current
|
|
1764
|
+
// guards, so the order actually changed.
|
|
1765
|
+
emitQueueChange(QueueChangeReason.MOVE)
|
|
1756
1766
|
return true
|
|
1757
1767
|
}
|
|
1758
1768
|
|
|
@@ -1771,6 +1781,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1771
1781
|
@VisibleForTesting
|
|
1772
1782
|
internal fun clearQueueInternal() {
|
|
1773
1783
|
val p = player ?: return
|
|
1784
|
+
val queueWasNonEmpty = tracks.isNotEmpty()
|
|
1774
1785
|
// Reset authoritative state BEFORE the ExoPlayer mutation.
|
|
1775
1786
|
// `clearMediaItems()` fires `onMediaItemTransition(null,
|
|
1776
1787
|
// REASON_PLAYLIST_CHANGED)` synchronously, which routes through
|
|
@@ -1788,6 +1799,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
1788
1799
|
retryAttemptsRemaining.clear()
|
|
1789
1800
|
tracks = emptyList()
|
|
1790
1801
|
currentTrackIndex = -1
|
|
1802
|
+
// Fire queue-change BEFORE setMediaItems (which synchronously fires
|
|
1803
|
+
// onTrackChange via onMediaItemTransition) so a `useQueue()` consumer
|
|
1804
|
+
// sees the empty queue + index -1 together. Only when the queue was
|
|
1805
|
+
// actually non-empty — clearing an empty queue is a no-op.
|
|
1806
|
+
if (queueWasNonEmpty) emitQueueChange(QueueChangeReason.CLEAR)
|
|
1791
1807
|
// Route through the engine surface (not directly through the
|
|
1792
1808
|
// leading leg) so CrossfadeEngine can also tear down the
|
|
1793
1809
|
// standby leg's preroll'd items + cancel any in-flight fade.
|
|
@@ -2802,6 +2818,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2802
2818
|
|
|
2803
2819
|
rescheduleLookahead()
|
|
2804
2820
|
recomputeCapabilities()
|
|
2821
|
+
// Skip a single-track shuffle — the order can't change.
|
|
2822
|
+
if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
|
|
2805
2823
|
|
|
2806
2824
|
return ShuffleResult(
|
|
2807
2825
|
tracks = newTracks.toTypedArray(),
|
|
@@ -2892,6 +2910,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
|
|
|
2892
2910
|
return { trackChangeListeners.remove(id) }
|
|
2893
2911
|
}
|
|
2894
2912
|
|
|
2913
|
+
override fun onQueueChange(
|
|
2914
|
+
callback: (queue: Array<TrackItem>, currentIndex: Double, reason: QueueChangeReason) -> Unit
|
|
2915
|
+
): () -> Unit {
|
|
2916
|
+
val id = queueChangeListeners.add(callback)
|
|
2917
|
+
return { queueChangeListeners.remove(id) }
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
/**
|
|
2921
|
+
* Fire onQueueChange with an atomic snapshot of the post-mutation
|
|
2922
|
+
* queue + current index. Main-thread only — every queue mutation
|
|
2923
|
+
* commits on main, so [tracks] / [currentTrackIndex] are settled here.
|
|
2924
|
+
* Callers gate this on an actual content/order change so a no-op
|
|
2925
|
+
* mutation never emits.
|
|
2926
|
+
*/
|
|
2927
|
+
@MainThread
|
|
2928
|
+
private fun emitQueueChange(reason: QueueChangeReason) {
|
|
2929
|
+
val snapshot = tracks.toTypedArray()
|
|
2930
|
+
val index = currentTrackIndex.toDouble()
|
|
2931
|
+
queueChangeListeners.forEach { it(snapshot, index, reason) }
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2895
2934
|
override fun onProgress(
|
|
2896
2935
|
callback: (progress: PlayerProgress) -> Unit
|
|
2897
2936
|
): () -> Unit {
|
|
@@ -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
|
+
}
|
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
|
+
}
|