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
@@ -57,7 +57,8 @@ object MediaItemBuilder {
57
57
  fun buildMediaItems(
58
58
  context: Context,
59
59
  tracks: List<TrackItem>,
60
- lookaheadCache: LookaheadCache? = null
60
+ lookaheadCache: LookaheadCache? = null,
61
+ customPlaceholderUri: String? = null
61
62
  ): List<MediaItem> {
62
63
  return tracks.map { track ->
63
64
  val builder = MediaItem.Builder()
@@ -68,7 +69,7 @@ object MediaItemBuilder {
68
69
  // Media3's documented default when track.id is omitted.
69
70
  .setMediaId(track.id ?: "")
70
71
  .setUri(Uri.parse(track.url))
71
- .setMediaMetadata(buildMetadata(context, track))
72
+ .setMediaMetadata(buildMetadata(context, track, customPlaceholderUri))
72
73
  val mime = resolveMimeTypeForBuild(track.url, lookaheadCache)
73
74
  if (mime != null) builder.setMimeType(mime)
74
75
  builder.build()
@@ -86,13 +87,26 @@ object MediaItemBuilder {
86
87
  * so Media3's BitmapLoader resolves it once per process rather than
87
88
  * cloning a byte[] into every MediaMetadata.
88
89
  */
89
- private fun buildMetadata(context: Context, track: TrackItem): MediaMetadata {
90
+ private fun buildMetadata(
91
+ context: Context,
92
+ track: TrackItem,
93
+ customPlaceholderUri: String?
94
+ ): MediaMetadata {
90
95
  val builder = MediaMetadata.Builder()
91
96
  .setTitle(track.title.orEmpty())
92
97
  .setArtist(track.artist.orEmpty())
93
98
  .setAlbumTitle(track.album.orEmpty())
94
99
 
95
100
  val artworkUrl = track.artworkUrl?.trim()
101
+ // Only a local `file://` placeholder is honoured (the built-in is a
102
+ // local file; a remote URL would break offline + change behaviour).
103
+ // Scheme match is case-insensitive to mirror iOS's `URL.isFileURL`;
104
+ // normalize to a lowercase `file` scheme so the case-sensitive
105
+ // `content://` rewrite below applies to an uppercase `FILE://` too.
106
+ val customFileUri = customPlaceholderUri
107
+ ?.let { Uri.parse(it) }
108
+ ?.takeIf { it.scheme.equals("file", ignoreCase = true) }
109
+ ?.buildUpon()?.scheme("file")?.build()
96
110
  val artworkUri = if (!artworkUrl.isNullOrEmpty()) {
97
111
  // Rewrite private `file://` paths through the lib's exported
98
112
  // `ArtworkContentProvider` so cross-process controllers (Android
@@ -100,6 +114,10 @@ object MediaItemBuilder {
100
114
  // their own process. `http(s)://` and `content://` URIs pass
101
115
  // through unchanged.
102
116
  ArtworkContentProvider.mapFileUri(Uri.parse(artworkUrl))
117
+ } else if (customFileUri != null) {
118
+ // Consumer-supplied placeholder in place of the built-in — same
119
+ // cross-process file-URI rewrite as the real-art branch.
120
+ ArtworkContentProvider.mapFileUri(customFileUri)
103
121
  } else {
104
122
  placeholderArtworkUri(context)
105
123
  }
@@ -3,6 +3,7 @@ package com.margelo.nitro.queueplayer
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.BitmapFactory
6
+ import android.net.Uri
6
7
 
7
8
  /**
8
9
  * Bundled fallback artwork used by the MediaSession + notification
@@ -26,8 +27,10 @@ internal object PlaceholderArtwork {
26
27
  * to call from any thread (the bitmap is immutable once decoded).
27
28
  */
28
29
  fun bitmap(context: Context): Bitmap {
30
+ customBitmap?.let { return it }
29
31
  cached?.let { return it }
30
32
  synchronized(this) {
33
+ customBitmap?.let { return it }
31
34
  cached?.let { return it }
32
35
  val decoded = decode(context)
33
36
  cached = decoded
@@ -35,6 +38,32 @@ internal object PlaceholderArtwork {
35
38
  }
36
39
  }
37
40
 
41
+ /**
42
+ * Set (or clear) the consumer-supplied placeholder from
43
+ * `PlayerConfig.placeholderArtworkUri`. Decoded once from the local
44
+ * `file://` path via `BitmapFactory.decodeFile` (scheme match is
45
+ * case-insensitive); a non-`file://` URI, an unset URI, or a decode
46
+ * failure leaves the bundled placeholder as the floor. Decode is
47
+ * synchronous, so a very large consumer image briefly blocks the
48
+ * `configure` call. Called from configure.
49
+ */
50
+ fun setCustom(uri: String?) {
51
+ synchronized(this) {
52
+ // Local file:// only — remote URLs would break offline + change
53
+ // behaviour, so anything else leaves the built-in as the floor.
54
+ customBitmap = uri
55
+ ?.takeIf { Uri.parse(it).scheme.equals("file", ignoreCase = true) }
56
+ ?.let { decodeUri(it) }
57
+ }
58
+ }
59
+
60
+ private fun decodeUri(uri: String): Bitmap? =
61
+ try {
62
+ Uri.parse(uri).path?.let { BitmapFactory.decodeFile(it) }
63
+ } catch (e: Exception) {
64
+ null
65
+ }
66
+
38
67
  /**
39
68
  * Returns the placeholder bytes (raw JPEG payload). Useful for
40
69
  * paths that want to write the bytes into a `MediaMetadata`
@@ -53,6 +82,7 @@ internal object PlaceholderArtwork {
53
82
 
54
83
  @Volatile private var cached: Bitmap? = null
55
84
  @Volatile private var cachedBytes: ByteArray? = null
85
+ @Volatile private var customBitmap: Bitmap? = null
56
86
 
57
87
  private fun decode(context: Context): Bitmap {
58
88
  val stream = context.resources.openRawResource(R.raw.rnqp_placeholder)
@@ -37,7 +37,10 @@ import com.google.common.util.concurrent.MoreExecutors
37
37
  @UnstableApi
38
38
  internal class PlaceholderFallbackBitmapLoader(
39
39
  private val delegate: BitmapLoader,
40
- private val placeholder: Bitmap,
40
+ // Read on each fallback (not captured at construction) so a placeholder
41
+ // swapped at `configure` — after the session/loader was built in
42
+ // `onCreate` — is reflected without rebuilding the loader.
43
+ private val placeholder: () -> Bitmap,
41
44
  ) : BitmapLoader {
42
45
 
43
46
  override fun supportsMimeType(mimeType: String): Boolean =
@@ -47,7 +50,7 @@ internal class PlaceholderFallbackBitmapLoader(
47
50
  Futures.catching(
48
51
  delegate.decodeBitmap(data),
49
52
  Throwable::class.java,
50
- { placeholder },
53
+ { placeholder() },
51
54
  MoreExecutors.directExecutor(),
52
55
  )
53
56
 
@@ -55,20 +58,20 @@ internal class PlaceholderFallbackBitmapLoader(
55
58
  Futures.catching(
56
59
  delegate.loadBitmap(uri),
57
60
  Throwable::class.java,
58
- { placeholder },
61
+ { placeholder() },
59
62
  MoreExecutors.directExecutor(),
60
63
  )
61
64
 
62
65
  override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap>? {
63
66
  if (metadata.artworkUri == null && metadata.artworkData == null) {
64
- return Futures.immediateFuture(placeholder)
67
+ return Futures.immediateFuture(placeholder())
65
68
  }
66
69
  val delegated = delegate.loadBitmapFromMetadata(metadata)
67
- ?: return Futures.immediateFuture(placeholder)
70
+ ?: return Futures.immediateFuture(placeholder())
68
71
  return Futures.catching(
69
72
  delegated,
70
73
  Throwable::class.java,
71
- { placeholder },
74
+ { placeholder() },
72
75
  MoreExecutors.directExecutor(),
73
76
  )
74
77
  }
@@ -777,7 +777,10 @@ class PlaybackService : HeadlessJsMediaService() {
777
777
  internal fun buildBitmapLoader(): PlaceholderFallbackBitmapLoader =
778
778
  PlaceholderFallbackBitmapLoader(
779
779
  delegate = CacheBitmapLoader(DataSourceBitmapLoader.Builder(this).build()),
780
- placeholder = PlaceholderArtwork.bitmap(this),
780
+ // Read on each fallback so a placeholder set at `configure` (after
781
+ // this loader was built in onCreate) is reflected — `PlaceholderArtwork`
782
+ // returns the consumer image when one is configured, else the built-in.
783
+ placeholder = { PlaceholderArtwork.bitmap(applicationContext) },
781
784
  )
782
785
 
783
786
  /**
@@ -201,7 +201,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
201
201
  visualizationEnabled = null,
202
202
  clampSeekToBuffered = null,
203
203
  lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
204
- progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null
204
+ progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null
205
205
  )
206
206
 
207
207
  @Volatile
@@ -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
@@ -879,6 +880,10 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
879
880
  // shared instance is released and its directory deleted below.
880
881
  val previousEvictionPolicy = evictionPolicyFrom(this.config)
881
882
  this.config = newConfig
883
+ // Swap the cover-art placeholder bitmap to the consumer-supplied image
884
+ // (or back to the built-in when unset). The no-art metadata URI is
885
+ // threaded separately at each buildMediaItems call.
886
+ PlaceholderArtwork.setCustom(newConfig.placeholderArtworkUri)
882
887
  if (evictionPolicyFrom(newConfig) != previousEvictionPolicy) {
883
888
  lookaheadCacheWriter?.release()
884
889
  lookaheadCacheWriter = null
@@ -1445,6 +1450,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1445
1450
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1446
1451
  rescheduleLookahead()
1447
1452
  recomputeCapabilities()
1453
+ emitQueueChange(QueueChangeReason.SET_QUEUE)
1448
1454
  }
1449
1455
 
1450
1456
  /**
@@ -1478,7 +1484,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1478
1484
  val attempts = effectiveAutoRetries()
1479
1485
  retryAttemptsRemaining =
1480
1486
  (newTracks.indices.associateWith { attempts }).toMutableMap()
1481
- return MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1487
+ return MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1482
1488
  }
1483
1489
 
1484
1490
  /**
@@ -1503,6 +1509,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1503
1509
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
1504
1510
  rescheduleLookahead()
1505
1511
  recomputeCapabilities()
1512
+ emitQueueChange(QueueChangeReason.SET_QUEUE)
1506
1513
  return items
1507
1514
  }
1508
1515
 
@@ -1575,7 +1582,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1575
1582
  for (newIdx in at until at + shift) {
1576
1583
  retryAttemptsRemaining[newIdx] = attempts
1577
1584
  }
1578
- val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
1585
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
1579
1586
  serviceBinder?.engine?.addMediaItems(items, insertBefore = at)
1580
1587
  // Diverges from iOS: add-into-empty-queue on iOS leaves the
1581
1588
  // AVQueuePlayer empty until the consumer calls skipToIndex(0).
@@ -1595,6 +1602,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1595
1602
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1596
1603
  rescheduleLookahead()
1597
1604
  recomputeCapabilities()
1605
+ emitQueueChange(QueueChangeReason.ADD)
1598
1606
  return at
1599
1607
  }
1600
1608
 
@@ -1688,6 +1696,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1688
1696
  serviceBinder?.engine?.setWakeMode(pickWakeMode(this.tracks))
1689
1697
  rescheduleLookahead()
1690
1698
  recomputeCapabilities()
1699
+ // Reached only past the `sanitised.isEmpty()` guard, so ≥1 track was
1700
+ // removed; tracks + index are final.
1701
+ emitQueueChange(QueueChangeReason.REMOVE)
1691
1702
  return sanitised
1692
1703
  }
1693
1704
 
@@ -1753,6 +1764,9 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1753
1764
  for (i in this.tracks.indices) retryAttemptsRemaining[i] = attempts
1754
1765
  rescheduleLookahead()
1755
1766
  recomputeCapabilities()
1767
+ // Reached only past the invalid / `from == to` / pinned-current
1768
+ // guards, so the order actually changed.
1769
+ emitQueueChange(QueueChangeReason.MOVE)
1756
1770
  return true
1757
1771
  }
1758
1772
 
@@ -1771,6 +1785,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1771
1785
  @VisibleForTesting
1772
1786
  internal fun clearQueueInternal() {
1773
1787
  val p = player ?: return
1788
+ val queueWasNonEmpty = tracks.isNotEmpty()
1774
1789
  // Reset authoritative state BEFORE the ExoPlayer mutation.
1775
1790
  // `clearMediaItems()` fires `onMediaItemTransition(null,
1776
1791
  // REASON_PLAYLIST_CHANGED)` synchronously, which routes through
@@ -1788,6 +1803,11 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
1788
1803
  retryAttemptsRemaining.clear()
1789
1804
  tracks = emptyList()
1790
1805
  currentTrackIndex = -1
1806
+ // Fire queue-change BEFORE setMediaItems (which synchronously fires
1807
+ // onTrackChange via onMediaItemTransition) so a `useQueue()` consumer
1808
+ // sees the empty queue + index -1 together. Only when the queue was
1809
+ // actually non-empty — clearing an empty queue is a no-op.
1810
+ if (queueWasNonEmpty) emitQueueChange(QueueChangeReason.CLEAR)
1791
1811
  // Route through the engine surface (not directly through the
1792
1812
  // leading leg) so CrossfadeEngine can also tear down the
1793
1813
  // standby leg's preroll'd items + cancel any in-flight fade.
@@ -2388,7 +2408,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2388
2408
  // playback continues from the current track / position. The
2389
2409
  // post-swap engine starts empty by construction.
2390
2410
  if (resumeIndex >= 0 && resumeIndex < tracks.count()) {
2391
- val media = MediaItemBuilder.buildMediaItems(service, tracks, lookaheadCache)
2411
+ val media = MediaItemBuilder.buildMediaItems(service, tracks, lookaheadCache, config.placeholderArtworkUri)
2392
2412
  newEngine.setMediaItems(
2393
2413
  items = media,
2394
2414
  startIndex = resumeIndex,
@@ -2790,7 +2810,7 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2790
2810
  retryAttemptsRemaining.clear()
2791
2811
  for (i in newTracks.indices) retryAttemptsRemaining[i] = attempts
2792
2812
 
2793
- val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache)
2813
+ val items = MediaItemBuilder.buildMediaItems(ctx, newTracks, lookaheadCache, config.placeholderArtworkUri)
2794
2814
  serviceBinder?.engine?.setMediaItems(items, startIndex = 0, startPositionMs = 0L)
2795
2815
  p.prepare()
2796
2816
  serviceBinder?.engine?.setWakeMode(pickWakeMode(newTracks))
@@ -2802,6 +2822,8 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2802
2822
 
2803
2823
  rescheduleLookahead()
2804
2824
  recomputeCapabilities()
2825
+ // Skip a single-track shuffle — the order can't change.
2826
+ if (newTracks.size > 1) emitQueueChange(QueueChangeReason.SHUFFLE)
2805
2827
 
2806
2828
  return ShuffleResult(
2807
2829
  tracks = newTracks.toTypedArray(),
@@ -2892,6 +2914,27 @@ class TrackPlayer : HybridTrackPlayerSpec(), PlaybackEngineDelegate {
2892
2914
  return { trackChangeListeners.remove(id) }
2893
2915
  }
2894
2916
 
2917
+ override fun onQueueChange(
2918
+ callback: (queue: Array<TrackItem>, currentIndex: Double, reason: QueueChangeReason) -> Unit
2919
+ ): () -> Unit {
2920
+ val id = queueChangeListeners.add(callback)
2921
+ return { queueChangeListeners.remove(id) }
2922
+ }
2923
+
2924
+ /**
2925
+ * Fire onQueueChange with an atomic snapshot of the post-mutation
2926
+ * queue + current index. Main-thread only — every queue mutation
2927
+ * commits on main, so [tracks] / [currentTrackIndex] are settled here.
2928
+ * Callers gate this on an actual content/order change so a no-op
2929
+ * mutation never emits.
2930
+ */
2931
+ @MainThread
2932
+ private fun emitQueueChange(reason: QueueChangeReason) {
2933
+ val snapshot = tracks.toTypedArray()
2934
+ val index = currentTrackIndex.toDouble()
2935
+ queueChangeListeners.forEach { it(snapshot, index, reason) }
2936
+ }
2937
+
2895
2938
  override fun onProgress(
2896
2939
  callback: (progress: PlayerProgress) -> Unit
2897
2940
  ): () -> Unit {
@@ -1,6 +1,7 @@
1
1
  package com.margelo.nitro.queueplayer
2
2
 
3
3
  import android.content.Context
4
+ import android.net.Uri
4
5
  import androidx.media3.common.C
5
6
  import androidx.media3.common.MediaMetadata
6
7
  import androidx.media3.common.util.UnstableApi
@@ -277,6 +278,60 @@ class MediaItemBuilderTest {
277
278
  assertEquals(expected, metadata.artworkUri?.toString())
278
279
  }
279
280
 
281
+ @Test
282
+ fun `MediaMetadata uses the custom placeholder URI when artworkUrl is null and a file URI is configured`() {
283
+ val customUri = "file:///data/user/0/com.example/files/logo.png"
284
+ val metadata = MediaItemBuilder.buildMediaItems(
285
+ context,
286
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
287
+ customPlaceholderUri = customUri
288
+ )[0].mediaMetadata
289
+ val expected = ArtworkContentProvider.mapFileUri(Uri.parse(customUri)).toString()
290
+ assertEquals(expected, metadata.artworkUri?.toString())
291
+ }
292
+
293
+ @Test
294
+ fun `MediaMetadata ignores a non-file custom placeholder URI and uses the built-in`() {
295
+ val metadata = MediaItemBuilder.buildMediaItems(
296
+ context,
297
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
298
+ customPlaceholderUri = "https://example.com/logo.png"
299
+ )[0].mediaMetadata
300
+ val expected =
301
+ "android.resource://${context.packageName}/${R.raw.rnqp_placeholder}"
302
+ assertEquals(expected, metadata.artworkUri?.toString())
303
+ }
304
+
305
+ @Test
306
+ fun `MediaMetadata prefers the real artworkUrl over a configured custom placeholder`() {
307
+ val metadata = MediaItemBuilder.buildMediaItems(
308
+ context,
309
+ listOf(
310
+ makeTrack(
311
+ id = "t",
312
+ url = "https://example.com/s.mp3",
313
+ artworkUrl = "https://example.com/cover.jpg"
314
+ )
315
+ ),
316
+ customPlaceholderUri = "file:///data/user/0/com.example/files/logo.png"
317
+ )[0].mediaMetadata
318
+ assertEquals("https://example.com/cover.jpg", metadata.artworkUri?.toString())
319
+ }
320
+
321
+ @Test
322
+ fun `MediaMetadata normalizes an uppercase FILE scheme custom placeholder to lowercase file`() {
323
+ val customUri = "FILE:///data/user/0/com.example/files/logo.png"
324
+ val metadata = MediaItemBuilder.buildMediaItems(
325
+ context,
326
+ listOf(makeTrack(id = "t", url = "https://example.com/s.mp3")),
327
+ customPlaceholderUri = customUri
328
+ )[0].mediaMetadata
329
+ val normalized = Uri.parse(customUri).buildUpon().scheme("file").build()
330
+ val expected = ArtworkContentProvider.mapFileUri(normalized).toString()
331
+ assertEquals(expected, metadata.artworkUri?.toString())
332
+ assertEquals("file", metadata.artworkUri?.scheme)
333
+ }
334
+
280
335
  @Test
281
336
  fun `MediaMetadata sets durationMs from positive seconds`() {
282
337
  val track = makeTrack(
@@ -0,0 +1,72 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import androidx.test.core.app.ApplicationProvider
6
+ import org.junit.After
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertNull
9
+ import org.junit.Assert.assertSame
10
+ import org.junit.Test
11
+ import org.junit.runner.RunWith
12
+ import org.robolectric.RobolectricTestRunner
13
+ import org.robolectric.annotation.Config
14
+
15
+ /**
16
+ * Coverage for [PlaceholderArtwork.setCustom] — the consumer-supplied
17
+ * cover-art placeholder from `PlayerConfig.placeholderArtworkUri`. Only a
18
+ * local `file://` URI is honoured; a remote or unset URI leaves the
19
+ * bundled placeholder as the floor.
20
+ */
21
+ @RunWith(RobolectricTestRunner::class)
22
+ @Config(sdk = [34])
23
+ class PlaceholderArtworkTest {
24
+
25
+ private val context: Context
26
+ get() = ApplicationProvider.getApplicationContext()
27
+
28
+ @After
29
+ fun tearDown() {
30
+ // Process-wide singleton — reset so a set custom bitmap does not
31
+ // leak into other suites that read the built-in placeholder.
32
+ PlaceholderArtwork.setCustom(null)
33
+ }
34
+
35
+ @Test
36
+ fun `setCustom with a file URI installs a custom bitmap that bitmap returns`() {
37
+ PlaceholderArtwork.setCustom("file:///data/user/0/com.example/files/logo.png")
38
+ val custom = customBitmap()
39
+ assertNotNull(custom)
40
+ assertSame(custom, PlaceholderArtwork.bitmap(context))
41
+ }
42
+
43
+ @Test
44
+ fun `setCustom ignores a non-file URI and leaves the built-in placeholder`() {
45
+ PlaceholderArtwork.setCustom("https://example.com/logo.png")
46
+ assertNull(customBitmap())
47
+ }
48
+
49
+ @Test
50
+ fun `setCustom ignores an empty string and leaves the built-in placeholder`() {
51
+ PlaceholderArtwork.setCustom("")
52
+ assertNull(customBitmap())
53
+ }
54
+
55
+ @Test
56
+ fun `setCustom accepts an uppercase FILE scheme (case-insensitive)`() {
57
+ PlaceholderArtwork.setCustom("FILE:///data/user/0/com.example/files/logo.png")
58
+ assertNotNull(customBitmap())
59
+ }
60
+
61
+ @Test
62
+ fun `setCustom with null clears a previously-installed custom bitmap`() {
63
+ PlaceholderArtwork.setCustom("file:///data/user/0/com.example/files/logo.png")
64
+ PlaceholderArtwork.setCustom(null)
65
+ assertNull(customBitmap())
66
+ }
67
+
68
+ private fun customBitmap(): Bitmap? =
69
+ PlaceholderArtwork.javaClass.getDeclaredField("customBitmap")
70
+ .apply { isAccessible = true }
71
+ .get(PlaceholderArtwork) as Bitmap?
72
+ }
@@ -40,7 +40,7 @@ class PlaceholderFallbackBitmapLoaderTest {
40
40
 
41
41
  @Test
42
42
  fun `loadBitmapFromMetadata returns placeholder when artwork URI and data are absent`() {
43
- val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader, placeholder)
43
+ val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader, { placeholder })
44
44
  val metadata = MediaMetadata.Builder().setTitle("no artwork").build()
45
45
 
46
46
  val future = loader.loadBitmapFromMetadata(metadata)
@@ -49,10 +49,22 @@ class PlaceholderFallbackBitmapLoaderTest {
49
49
  assertSame(placeholder, future!!.get())
50
50
  }
51
51
 
52
+ @Test
53
+ fun `loadBitmapFromMetadata reads the placeholder provider on every call so a live swap is reflected`() {
54
+ val swapped = Bitmap.createBitmap(4, 4, Bitmap.Config.ARGB_8888)
55
+ var current = placeholder
56
+ val loader = PlaceholderFallbackBitmapLoader(NeverCalledLoader) { current }
57
+ val metadata = MediaMetadata.Builder().setTitle("no artwork").build()
58
+
59
+ assertSame(placeholder, loader.loadBitmapFromMetadata(metadata)!!.get())
60
+ current = swapped
61
+ assertSame(swapped, loader.loadBitmapFromMetadata(metadata)!!.get())
62
+ }
63
+
52
64
  @Test
53
65
  fun `loadBitmapFromMetadata returns delegate result when artwork URI is present`() {
54
66
  val delegate = StubLoader(metadataResult = Futures.immediateFuture(delegateBitmap))
55
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
67
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
56
68
  val metadata = MediaMetadata.Builder()
57
69
  .setArtworkUri(Uri.parse("https://example.com/cover.jpg"))
58
70
  .build()
@@ -68,7 +80,7 @@ class PlaceholderFallbackBitmapLoaderTest {
68
80
  val delegate = StubLoader(
69
81
  metadataResult = Futures.immediateFailedFuture(RuntimeException("decode failed"))
70
82
  )
71
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
83
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
72
84
  val metadata = MediaMetadata.Builder()
73
85
  .setArtworkUri(Uri.parse("https://example.com/broken.jpg"))
74
86
  .build()
@@ -82,7 +94,7 @@ class PlaceholderFallbackBitmapLoaderTest {
82
94
  @Test
83
95
  fun `loadBitmapFromMetadata returns placeholder when delegate returns null future`() {
84
96
  val delegate = StubLoader(metadataResult = null)
85
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
97
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
86
98
  val metadata = MediaMetadata.Builder()
87
99
  .setArtworkUri(Uri.parse("https://example.com/cover.jpg"))
88
100
  .build()
@@ -98,7 +110,7 @@ class PlaceholderFallbackBitmapLoaderTest {
98
110
  val delegate = StubLoader(
99
111
  uriResult = Futures.immediateFailedFuture(RuntimeException("network error"))
100
112
  )
101
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
113
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
102
114
 
103
115
  val result = loader.loadBitmap(Uri.parse("https://example.com/cover.jpg")).get()
104
116
 
@@ -108,7 +120,7 @@ class PlaceholderFallbackBitmapLoaderTest {
108
120
  @Test
109
121
  fun `loadBitmap returns delegate result on success`() {
110
122
  val delegate = StubLoader(uriResult = Futures.immediateFuture(delegateBitmap))
111
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
123
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
112
124
 
113
125
  val result = loader.loadBitmap(Uri.parse("https://example.com/cover.jpg")).get()
114
126
 
@@ -120,7 +132,7 @@ class PlaceholderFallbackBitmapLoaderTest {
120
132
  val delegate = StubLoader(
121
133
  bytesResult = Futures.immediateFailedFuture(RuntimeException("malformed bytes"))
122
134
  )
123
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
135
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
124
136
 
125
137
  val result = loader.decodeBitmap(byteArrayOf(0x00, 0x01)).get()
126
138
 
@@ -130,7 +142,7 @@ class PlaceholderFallbackBitmapLoaderTest {
130
142
  @Test
131
143
  fun `decodeBitmap returns delegate result on success`() {
132
144
  val delegate = StubLoader(bytesResult = Futures.immediateFuture(delegateBitmap))
133
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
145
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
134
146
 
135
147
  val result = loader.decodeBitmap(byteArrayOf(0x00, 0x01)).get()
136
148
 
@@ -140,7 +152,7 @@ class PlaceholderFallbackBitmapLoaderTest {
140
152
  @Test
141
153
  fun `supportsMimeType proxies through to delegate`() {
142
154
  val delegate = StubLoader(supportedMimeTypes = setOf("image/jpeg"))
143
- val loader = PlaceholderFallbackBitmapLoader(delegate, placeholder)
155
+ val loader = PlaceholderFallbackBitmapLoader(delegate, { placeholder })
144
156
 
145
157
  assertTrue(loader.supportsMimeType("image/jpeg"))
146
158
  assertFalse(loader.supportsMimeType("image/heic"))
@@ -80,16 +80,16 @@ class PlaybackServiceArtworkLoaderTest {
80
80
 
81
81
  @Test
82
82
  fun `repeat builds reuse the same placeholder bitmap`() {
83
- // PlaceholderArtwork is process-cached after first read so the
84
- // ~10 KB JPEG decode runs once service-recreate cycles +
85
- // multiple consumer-side reconfigures should not multiply the
86
- // memory cost.
83
+ // The loader holds a placeholder *provider* closure; invoking it
84
+ // reads the process-cached PlaceholderArtwork bitmap so the ~10 KB
85
+ // JPEG decode runs once service-recreate cycles + multiple
86
+ // consumer-side reconfigures do not multiply the memory cost.
87
87
  val first = service.buildBitmapLoader()
88
88
  val second = service.buildBitmapLoader()
89
- val firstPlaceholder = first.javaClass.getDeclaredField("placeholder")
90
- .apply { isAccessible = true }.get(first)
91
- val secondPlaceholder = second.javaClass.getDeclaredField("placeholder")
92
- .apply { isAccessible = true }.get(second)
93
- assertSame(firstPlaceholder, secondPlaceholder)
89
+ val firstProvider = first.javaClass.getDeclaredField("placeholder")
90
+ .apply { isAccessible = true }.get(first) as kotlin.jvm.functions.Function0<*>
91
+ val secondProvider = second.javaClass.getDeclaredField("placeholder")
92
+ .apply { isAccessible = true }.get(second) as kotlin.jvm.functions.Function0<*>
93
+ assertSame(firstProvider.invoke(), secondProvider.invoke())
94
94
  }
95
95
  }
@@ -785,7 +785,7 @@ class PlaybackServiceCallbackTest {
785
785
  audioCategoryOptions = null,
786
786
  visualizationEnabled = null,
787
787
  clampSeekToBuffered = null,
788
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
788
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null,
789
789
  ),
790
790
  context,
791
791
  )
@@ -66,7 +66,7 @@ class TrackPlayerAirPlayMetadataWiringTest {
66
66
  retryBackoffMs = null, networkTimeoutMs = null, audioContentType = null,
67
67
  audioCategoryOptions = null, visualizationEnabled = null,
68
68
  clampSeekToBuffered = null,
69
- lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
69
+ lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null, progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null, placeholderArtworkUri = null,
70
70
  ),
71
71
  context(),
72
72
  )
@@ -44,7 +44,7 @@ class TrackPlayerAudioFocusTest {
44
44
  player = TrackPlayer()
45
45
  serviceHandle = bindPlaybackServiceForTest(player, context())
46
46
  player.configureInternal(
47
- 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()
47
+ 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()
48
48
  )
49
49
  }
50
50
 
@@ -51,7 +51,7 @@ class TrackPlayerEventsTest {
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
  }
57
57
 
@@ -230,7 +230,7 @@ class TrackPlayerEventsTest {
230
230
  // the public effects — a new configure + getStateInternal
231
231
  // should return NONE.
232
232
  player.configureInternal(
233
- 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()
233
+ 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()
234
234
  )
235
235
  assertEquals(PlayerState.NONE, player.getStateInternal())
236
236
  }
@@ -479,7 +479,7 @@ class TrackPlayerEventsTest {
479
479
  serviceHandle.destroy()
480
480
  serviceHandle = bindPlaybackServiceForTest(player, context())
481
481
  player.configureInternal(
482
- 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()
482
+ 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()
483
483
  )
484
484
  // No listener is bound to the new ExoPlayer's actual STATE_ENDED
485
485
  // path in a Robolectric test, but the callback slot itself is