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.
Files changed (49) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +39 -0
  2. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
  3. package/ios/AVQueueBuilder.swift +29 -0
  4. package/ios/QueueState.swift +14 -2
  5. package/ios/Tests/AVQueueBuilderTests.swift +74 -0
  6. package/ios/Tests/QueueStateTests.swift +50 -0
  7. package/ios/TrackPlayer.swift +121 -14
  8. package/lib/module/hooks/index.js +1 -0
  9. package/lib/module/hooks/index.js.map +1 -1
  10. package/lib/module/hooks/useQueue.js +57 -0
  11. package/lib/module/hooks/useQueue.js.map +1 -0
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/types.js +7 -0
  14. package/lib/module/types.js.map +1 -1
  15. package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
  16. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  17. package/lib/typescript/hooks/index.d.ts +2 -0
  18. package/lib/typescript/hooks/index.d.ts.map +1 -1
  19. package/lib/typescript/hooks/useQueue.d.ts +20 -0
  20. package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
  21. package/lib/typescript/index.d.ts +1 -1
  22. package/lib/typescript/index.d.ts.map +1 -1
  23. package/lib/typescript/types.d.ts +5 -0
  24. package/lib/typescript/types.d.ts.map +1 -1
  25. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
  26. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
  27. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  28. package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
  32. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
  33. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
  34. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
  35. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  36. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
  37. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
  38. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  39. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
  40. package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
  41. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  42. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  43. package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
  44. package/package.json +1 -1
  45. package/src/TrackPlayer.nitro.ts +33 -0
  46. package/src/hooks/index.ts +3 -0
  47. package/src/hooks/useQueue.ts +56 -0
  48. package/src/index.ts +1 -0
  49. 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
+ }
@@ -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
+ }