react-native-queue-player 1.0.7 → 1.0.9

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 (79) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +21 -3
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderArtwork.kt +30 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoader.kt +9 -6
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +4 -1
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +48 -5
  6. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +55 -0
  7. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderArtworkTest.kt +72 -0
  8. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaceholderFallbackBitmapLoaderTest.kt +21 -9
  9. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +9 -9
  10. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +1 -1
  11. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAirPlayMetadataWiringTest.kt +1 -1
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerAudioFocusTest.kt +1 -1
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +3 -3
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLifecycleTest.kt +7 -7
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerLookaheadConfigTest.kt +6 -6
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -2
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerProgressThrottleTest.kt +1 -1
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueChangeTest.kt +180 -0
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerQueueTest.kt +1 -1
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReadersTest.kt +1 -1
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipCapabilityTest.kt +2 -2
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerSkipTest.kt +1 -1
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +1 -1
  24. package/ios/AVQueueBuilder.swift +29 -0
  25. package/ios/Cast/Core/CastNowPlayingController.swift +7 -4
  26. package/ios/NowPlayingInfo.swift +7 -9
  27. package/ios/PlaceholderArtwork.swift +24 -1
  28. package/ios/QueueState.swift +14 -2
  29. package/ios/Tests/AVQueueBuilderTests.swift +83 -9
  30. package/ios/Tests/CastNowPlayingControllerTests.swift +33 -0
  31. package/ios/Tests/PlaceholderArtworkTests.swift +81 -0
  32. package/ios/Tests/QueueStateTests.swift +50 -0
  33. package/ios/TrackPlayer.swift +126 -15
  34. package/lib/module/hooks/index.js +1 -0
  35. package/lib/module/hooks/index.js.map +1 -1
  36. package/lib/module/hooks/useQueue.js +57 -0
  37. package/lib/module/hooks/useQueue.js.map +1 -0
  38. package/lib/module/index.js.map +1 -1
  39. package/lib/module/types.js +7 -0
  40. package/lib/module/types.js.map +1 -1
  41. package/lib/typescript/TrackPlayer.nitro.d.ts +27 -1
  42. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  43. package/lib/typescript/hooks/index.d.ts +2 -0
  44. package/lib/typescript/hooks/index.d.ts.map +1 -1
  45. package/lib/typescript/hooks/useQueue.d.ts +20 -0
  46. package/lib/typescript/hooks/useQueue.d.ts.map +1 -0
  47. package/lib/typescript/index.d.ts +1 -1
  48. package/lib/typescript/index.d.ts.map +1 -1
  49. package/lib/typescript/types.d.ts +22 -0
  50. package/lib/typescript/types.d.ts.map +1 -1
  51. package/nitrogen/generated/android/c++/JFunc_void_std__vector_TrackItem__double_QueueChangeReason.hpp +101 -0
  52. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +18 -0
  53. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  54. package/nitrogen/generated/android/c++/JPlayerConfig.hpp +5 -1
  55. package/nitrogen/generated/android/c++/JQueueChangeReason.hpp +70 -0
  56. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/Func_void_std__vector_TrackItem__double_QueueChangeReason.kt +80 -0
  57. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +9 -0
  58. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/PlayerConfig.kt +7 -2
  59. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/QueueChangeReason.kt +27 -0
  60. package/nitrogen/generated/android/queueplayerOnLoad.cpp +2 -0
  61. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.cpp +8 -0
  62. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Bridge.hpp +25 -0
  63. package/nitrogen/generated/ios/QueuePlayer-Swift-Cxx-Umbrella.hpp +3 -0
  64. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +11 -0
  65. package/nitrogen/generated/ios/swift/Func_void_std__vector_TrackItem__double_QueueChangeReason.swift +46 -0
  66. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  67. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +26 -0
  68. package/nitrogen/generated/ios/swift/PlayerConfig.swift +19 -1
  69. package/nitrogen/generated/ios/swift/QueueChangeReason.swift +56 -0
  70. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  71. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +4 -0
  72. package/nitrogen/generated/shared/c++/PlayerConfig.hpp +5 -1
  73. package/nitrogen/generated/shared/c++/QueueChangeReason.hpp +92 -0
  74. package/package.json +1 -1
  75. package/src/TrackPlayer.nitro.ts +33 -0
  76. package/src/hooks/index.ts +3 -0
  77. package/src/hooks/useQueue.ts +56 -0
  78. package/src/index.ts +1 -0
  79. package/src/types.ts +29 -0
@@ -68,7 +68,7 @@ class TrackPlayerLifecycleTest {
68
68
  autoRetries = null,
69
69
  retryBackoffMs = null,
70
70
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
71
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
71
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
72
72
  )
73
73
  player.configureInternal(config, context())
74
74
 
@@ -92,7 +92,7 @@ class TrackPlayerLifecycleTest {
92
92
  // public path owns the destroy step. This test drives the
93
93
  // destroy + reconstruct shape directly to mirror what
94
94
  // `configure()` does end-to-end.
95
- val empty = 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)
95
+ val empty = 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, placeholderArtworkUri = null)
96
96
 
97
97
  player.configureInternal(empty, context())
98
98
  val firstInstance = player.player
@@ -125,12 +125,12 @@ class TrackPlayerLifecycleTest {
125
125
  val first = PlayerConfig(
126
126
  httpHeaders = mapOf("A" to "1"), userAgent = "ua-1",
127
127
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
128
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
128
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
129
129
  )
130
130
  val second = PlayerConfig(
131
131
  httpHeaders = mapOf("B" to "2"), userAgent = "ua-2",
132
132
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
133
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
133
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
134
134
  )
135
135
 
136
136
  player.configureInternal(first, context())
@@ -144,7 +144,7 @@ class TrackPlayerLifecycleTest {
144
144
  val configured = PlayerConfig(
145
145
  httpHeaders = mapOf("h" to "v"), userAgent = "ua",
146
146
  autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null,
147
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
147
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
148
148
  )
149
149
  player.configureInternal(configured, context())
150
150
  // Non-default playback state the consumer set post-configure.
@@ -180,7 +180,7 @@ class TrackPlayerLifecycleTest {
180
180
  @Test
181
181
  fun `destroyInternal is safe to call twice`() {
182
182
  player.configureInternal(
183
- 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()
183
+ 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, placeholderArtworkUri = null), context()
184
184
  )
185
185
  player.destroyInternal()
186
186
  player.destroyInternal()
@@ -189,7 +189,7 @@ class TrackPlayerLifecycleTest {
189
189
 
190
190
  @Test
191
191
  fun `configureInternal after destroyInternal rebuilds the ExoPlayer`() {
192
- val cfg = 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)
192
+ val cfg = 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, placeholderArtworkUri = null)
193
193
 
194
194
  player.configureInternal(cfg, context())
195
195
  val first = player.player
@@ -80,7 +80,7 @@ class TrackPlayerLookaheadConfigTest {
80
80
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
81
81
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
82
82
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = 250.0, lookaheadCacheEvictionPolicy = null,
83
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
83
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
84
84
  ),
85
85
  context()
86
86
  )
@@ -120,7 +120,7 @@ class TrackPlayerLookaheadConfigTest {
120
120
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
121
121
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null,
122
122
  lookaheadCacheEvictionPolicy = policy,
123
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
123
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
124
124
  )
125
125
 
126
126
  // --- setLookaheadCacheInternal
@@ -298,7 +298,7 @@ class TrackPlayerLookaheadConfigTest {
298
298
  @Test
299
299
  fun `re-configure with changed httpHeaders rebuilds the player (writer + cache)`() {
300
300
  player.configureInternal(
301
- PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-1"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null),
301
+ PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-1"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null),
302
302
  context()
303
303
  )
304
304
  val firstWriter = player.lookaheadCacheWriter
@@ -307,7 +307,7 @@ class TrackPlayerLookaheadConfigTest {
307
307
  assertNotNull(firstCache)
308
308
 
309
309
  player.configureInternal(
310
- PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-2"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null),
310
+ PlayerConfig(httpHeaders = mapOf("X-Auth" to "token-2"), userAgent = null, autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null),
311
311
  context()
312
312
  )
313
313
 
@@ -339,7 +339,7 @@ class TrackPlayerLookaheadConfigTest {
339
339
  // simulated here by `destroyInternal` between
340
340
  // `configureInternal` calls) yields a fresh writer instance,
341
341
  // even when headers are unchanged.
342
- val cfg = PlayerConfig(httpHeaders = mapOf("X-Auth" to "token"), userAgent = "rnqp/test", autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null)
342
+ val cfg = PlayerConfig(httpHeaders = mapOf("X-Auth" to "token"), userAgent = "rnqp/test", autoRetries = null, retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null, visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null)
343
343
  player.configureInternal(cfg, context())
344
344
  val firstWriter = player.lookaheadCacheWriter
345
345
 
@@ -413,5 +413,5 @@ class TrackPlayerLookaheadConfigTest {
413
413
 
414
414
  // --- Helpers
415
415
 
416
- private fun emptyConfig() = 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)
416
+ private fun emptyConfig() = 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, placeholderArtworkUri = null)
417
417
  }
@@ -45,7 +45,7 @@ class TrackPlayerMutationTest {
45
45
  player = TrackPlayer()
46
46
  serviceHandle = bindPlaybackServiceForTest(player, context())
47
47
  player.configureInternal(
48
- 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()
48
+ 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, placeholderArtworkUri = null), context()
49
49
  )
50
50
  player.setQueueInternal((1..5).map { track("t$it") })
51
51
  // cur defaults to 0 after setQueue; tests needing mid-queue
@@ -127,7 +127,7 @@ class TrackPlayerMutationTest {
127
127
  val freshHandle = bindPlaybackServiceForTest(fresh, context())
128
128
  try {
129
129
  fresh.configureInternal(
130
- 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()
130
+ 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, placeholderArtworkUri = null), context()
131
131
  )
132
132
  assertEquals(-1, fresh.currentTrackIndex)
133
133
  assertEquals(Player.STATE_IDLE, fresh.player!!.playbackState)
@@ -35,7 +35,7 @@ class TrackPlayerProgressThrottleTest {
35
35
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
36
36
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
37
37
  visualizationEnabled = null, clampSeekToBuffered = null, lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
38
- progressUpdateIntervalMs = foregroundMs, backgroundProgressUpdateIntervalMs = backgroundMs
38
+ progressUpdateIntervalMs = foregroundMs, backgroundProgressUpdateIntervalMs = backgroundMs, placeholderArtworkUri = null
39
39
  )
40
40
 
41
41
  @Before
@@ -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, placeholderArtworkUri = 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,7 +55,7 @@ class TrackPlayerQueueTest {
55
55
  player = TrackPlayer()
56
56
  serviceHandle = bindPlaybackServiceForTest(player, context())
57
57
  player.configureInternal(
58
- 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()
58
+ 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, placeholderArtworkUri = null), context()
59
59
  )
60
60
  }
61
61
 
@@ -42,7 +42,7 @@ class TrackPlayerReadersTest {
42
42
  httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
43
43
  networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
44
44
  visualizationEnabled = null, clampSeekToBuffered = null,
45
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
45
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
46
46
  ),
47
47
  context()
48
48
  )
@@ -53,7 +53,7 @@ class TrackPlayerSkipCapabilityTest {
53
53
  player = TrackPlayer()
54
54
  serviceHandle = bindPlaybackServiceForTest(player, context())
55
55
  player.configureInternal(
56
- 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()
56
+ 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, placeholderArtworkUri = null), context()
57
57
  )
58
58
  }
59
59
 
@@ -318,7 +318,7 @@ class TrackPlayerSkipCapabilityTest {
318
318
  player = TrackPlayer()
319
319
  serviceHandle = bindPlaybackServiceForTest(player, context())
320
320
  player.configureInternal(
321
- 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()
321
+ 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, placeholderArtworkUri = null), context()
322
322
  )
323
323
  }
324
324
  }
@@ -51,7 +51,7 @@ class TrackPlayerSkipTest {
51
51
  player = TrackPlayer()
52
52
  serviceHandle = bindPlaybackServiceForTest(player, context())
53
53
  player.configureInternal(
54
- 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()
54
+ 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, placeholderArtworkUri = null), context()
55
55
  )
56
56
  player.tracks = (1..5).map { track("t$it") }
57
57
  player.currentTrackIndex = 2 // mid-queue default for most cases
@@ -50,7 +50,7 @@ class TrackPlayerTransportTest {
50
50
  player = TrackPlayer()
51
51
  serviceHandle = bindPlaybackServiceForTest(player, context())
52
52
  player.configureInternal(
53
- 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()
53
+ 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, placeholderArtworkUri = null), context()
54
54
  )
55
55
  }
56
56
 
@@ -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
  ///
@@ -35,12 +35,15 @@ final class CastNowPlayingController {
35
35
  /// `TrackPlayer.configure` via `configureArtwork`.
36
36
  private let artworkResolver = ArtworkResolver()
37
37
 
38
- /// Bundled placeholder shown until the real cover resolves, so the
39
- /// lockscreen never renders artwork-less during the network fetch.
40
- private static let placeholderArtwork: MPMediaItemArtwork = {
38
+ /// Placeholder shown until the real cover resolves, so the lockscreen
39
+ /// never renders artwork-less during the network fetch. Read live —
40
+ /// `PlaceholderArtwork.image` is the consumer-configured image when one
41
+ /// is set (`PlayerConfig.placeholderArtworkUri`), else the bundled one —
42
+ /// so a per-refresh write here never clobbers it back to the built-in.
43
+ private static var placeholderArtwork: MPMediaItemArtwork {
41
44
  let image = PlaceholderArtwork.image
42
45
  return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
43
- }()
46
+ }
44
47
 
45
48
  /// Snapshot of the active receiver track for the lockscreen surface.
46
49
  /// Pushed by `TrackPlayer` when the receiver's current track (resolved
@@ -43,7 +43,6 @@ final class NowPlayingInfo {
43
43
  var snapshotProvider: (() -> Snapshot)?
44
44
 
45
45
  private var periodicTimer: Timer?
46
- private var cachedPlaceholderArtwork: MPMediaItemArtwork?
47
46
  /// Subscription to the global cast active-session router. When a
48
47
  /// remote session is active, `CastNowPlayingController` owns the
49
48
  /// lockscreen surface and this writer becomes a no-op — every
@@ -176,15 +175,14 @@ final class NowPlayingInfo {
176
175
  info[MPNowPlayingInfoPropertyPlaybackRate] = rate
177
176
  }
178
177
 
179
- /// Lazy MPMediaItemArtwork wrapper around the bundled placeholder
180
- /// UIImage. Built once per process; the closure that the system
181
- /// invokes for size requests just returns the same image (the system
182
- /// handles its own scaling).
178
+ /// MPMediaItemArtwork wrapper around the active placeholder image —
179
+ /// `PlaceholderArtwork.image` is the consumer-configured image when one
180
+ /// is set (`PlayerConfig.placeholderArtworkUri`), else the bundled one.
181
+ /// Rebuilt per call (cheap: the closure just returns the image, the
182
+ /// system handles scaling) so a placeholder set at `configure` is
183
+ /// reflected rather than serving a stale wrapper.
183
184
  private func placeholderArtwork() -> MPMediaItemArtwork {
184
- if let cached = cachedPlaceholderArtwork { return cached }
185
185
  let image = PlaceholderArtwork.image
186
- let mp = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
187
- cachedPlaceholderArtwork = mp
188
- return mp
186
+ return MPMediaItemArtwork(boundsSize: image.size) { _ in image }
189
187
  }
190
188
  }
@@ -15,7 +15,30 @@ import UIKit
15
15
  /// Android skins (mirror that defence on iOS too).
16
16
  enum PlaceholderArtwork {
17
17
 
18
- static let image: UIImage = loadImage()
18
+ /// The active placeholder: the consumer-supplied image when one is
19
+ /// configured + loadable, otherwise the bundled RNQP placeholder.
20
+ static var image: UIImage { customImage ?? bundledImage }
21
+
22
+ /// Set (or clear) the consumer-supplied placeholder from
23
+ /// `PlayerConfig.placeholderArtworkUri`. Loaded from a local `file://`
24
+ /// image the same way the bundled placeholder is (`UIImage(contentsOfFile:)`).
25
+ /// Only `file://` is honoured (scheme match is case-insensitive) — the
26
+ /// built-in is a local file, and a remote URL would break offline +
27
+ /// change behaviour; anything else (or an unloadable file) leaves the
28
+ /// bundled placeholder in effect. Decode is synchronous, so a very large
29
+ /// consumer image briefly blocks the `configure` call. Main-thread only
30
+ /// (called from `configure`).
31
+ static func setCustom(uri: String?) {
32
+ guard let uri = uri, !uri.isEmpty,
33
+ let url = URL(string: uri), url.scheme?.lowercased() == "file" else {
34
+ customImage = nil
35
+ return
36
+ }
37
+ customImage = UIImage(contentsOfFile: url.path)
38
+ }
39
+
40
+ private static var customImage: UIImage?
41
+ private static let bundledImage: UIImage = loadImage()
19
42
 
20
43
  private static func loadImage() -> UIImage {
21
44
  let frameworkBundle = Bundle(for: BundleToken.self)
@@ -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)