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.
Files changed (64) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +6 -42
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +7 -15
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +72 -23
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +15 -19
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
  18. package/ios/AVQueueBuilder.swift +29 -0
  19. package/ios/QueueState.swift +14 -2
  20. package/ios/Tests/AVQueueBuilderTests.swift +74 -0
  21. package/ios/Tests/QueueStateTests.swift +50 -0
  22. package/ios/TrackPlayer.swift +121 -14
  23. package/lib/module/hooks/index.js +1 -0
  24. package/lib/module/hooks/index.js.map +1 -1
  25. package/lib/module/hooks/useQueue.js +57 -0
  26. package/lib/module/hooks/useQueue.js.map +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/types.js +7 -0
  29. package/lib/module/types.js.map +1 -1
  30. package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
  31. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  32. package/lib/typescript/hooks/index.d.ts +2 -0
  33. package/lib/typescript/hooks/index.d.ts.map +1 -1
  34. package/lib/typescript/hooks/useQueue.d.ts +20 -0
  35. package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
  36. package/lib/typescript/index.d.ts +1 -1
  37. package/lib/typescript/index.d.ts.map +1 -1
  38. package/lib/typescript/types.d.ts +5 -0
  39. package/lib/typescript/types.d.ts.map +1 -1
  40. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
  41. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
  42. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  43. package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
  44. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
  45. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
  46. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
  47. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
  48. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
  49. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
  50. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  51. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
  52. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
  53. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  54. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
  55. package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
  56. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  57. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  58. package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
  59. package/package.json +1 -1
  60. package/src/TrackPlayer.nitro.ts +33 -0
  61. package/src/hooks/index.ts +3 -0
  62. package/src/hooks/useQueue.ts +56 -0
  63. package/src/index.ts +1 -0
  64. 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 exposes neither skip command regardless of repeat mode`() {
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 always disabled with no tracks (repeat=$mode)",
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 always disabled with no tracks (repeat=$mode)",
186
+ "skip-previous is disabled with no tracks (repeat=$mode)",
200
187
  commands.contains(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM),
201
188
  )
202
- assertFalse(
203
- "PLAY_PAUSE is stripped on empty queue so AA lands on Library (repeat=$mode)",
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 */ }
@@ -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
  ///
@@ -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
- tracks.insert(contentsOf: newTracks, at: index)
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
+ }