react-native-queue-player 1.1.0 → 1.1.2

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.
@@ -762,8 +762,15 @@ class PlaybackService : HeadlessJsMediaService() {
762
762
  * (the same one ExoPlayer drives for HTTP playback), so consumer
763
763
  * `userAgent` + custom HTTP headers configured on the player's
764
764
  * `MediaSource.Factory` propagate into artwork fetches without a
765
- * second HTTP client. `CacheBitmapLoader` deduplicates repeat
766
- * requests for the same URI inside a single foreground session.
765
+ * second HTTP client. It mirrors Media3's own default loader config:
766
+ * `setMaximumOutputDimension(MediaSession.getBitmapDimensionLimit(...))`
767
+ * downsamples an oversized consumer image during decode (device-adaptive
768
+ * cap; no full-resolution bitmap for a small control), and
769
+ * `setMakeShared(true)` puts the bitmap in shared memory so the System UI
770
+ * process reuses it instead of copying. Supplying our own loader (for the
771
+ * placeholder guarantee below) had otherwise dropped both defaults, which
772
+ * is what left artwork decoding unbounded. `CacheBitmapLoader` deduplicates
773
+ * repeat requests for the same URI inside a single foreground session.
767
774
  * The outer [PlaceholderFallbackBitmapLoader] guarantees a bitmap
768
775
  * always reaches the notification — the OEM-skin failure mode
769
776
  * where a null bitmap drops the transport controls is closed.
@@ -776,7 +783,12 @@ class PlaybackService : HeadlessJsMediaService() {
776
783
  @VisibleForTesting
777
784
  internal fun buildBitmapLoader(): PlaceholderFallbackBitmapLoader =
778
785
  PlaceholderFallbackBitmapLoader(
779
- delegate = CacheBitmapLoader(DataSourceBitmapLoader.Builder(this).build()),
786
+ delegate = CacheBitmapLoader(
787
+ DataSourceBitmapLoader.Builder(this)
788
+ .setMaximumOutputDimension(MediaSession.getBitmapDimensionLimit(this))
789
+ .setMakeShared(true)
790
+ .build()
791
+ ),
780
792
  // Read on each fallback so a placeholder set at `configure` (after
781
793
  // this loader was built in onCreate) is reflected — `PlaceholderArtwork`
782
794
  // returns the consumer image when one is configured, else the built-in.
@@ -3,7 +3,9 @@ package com.margelo.nitro.queueplayer
3
3
  import androidx.media3.common.util.UnstableApi
4
4
  import androidx.media3.datasource.DataSourceBitmapLoader
5
5
  import androidx.media3.session.CacheBitmapLoader
6
+ import androidx.media3.session.MediaSession
6
7
  import org.junit.After
8
+ import org.junit.Assert.assertEquals
7
9
  import org.junit.Assert.assertNotNull
8
10
  import org.junit.Assert.assertSame
9
11
  import org.junit.Assert.assertTrue
@@ -78,6 +80,32 @@ class PlaybackServiceArtworkLoaderTest {
78
80
  )
79
81
  }
80
82
 
83
+ @Test
84
+ fun `DataSourceBitmapLoader mirrors Media3's default cap + shared-memory bitmap`() {
85
+ val loader = service.buildBitmapLoader()
86
+ val cached = loader.javaClass.getDeclaredField("delegate")
87
+ .apply { isAccessible = true }.get(loader) as CacheBitmapLoader
88
+ val backing = cached.javaClass.getDeclaredField("bitmapLoader")
89
+ .apply { isAccessible = true }.get(cached) as DataSourceBitmapLoader
90
+ val maxDim = DataSourceBitmapLoader::class.java
91
+ .getDeclaredField("maximumOutputDimension")
92
+ .apply { isAccessible = true }
93
+ .getInt(backing)
94
+ assertEquals(
95
+ "artwork decode is capped to Media3's device-adaptive limit (no full-res allocation)",
96
+ MediaSession.getBitmapDimensionLimit(service),
97
+ maxDim
98
+ )
99
+ val makeShared = DataSourceBitmapLoader::class.java
100
+ .getDeclaredField("makeShared")
101
+ .apply { isAccessible = true }
102
+ .getBoolean(backing)
103
+ assertTrue(
104
+ "decoded bitmap is shared-memory so the System UI process reuses it instead of copying",
105
+ makeShared
106
+ )
107
+ }
108
+
81
109
  @Test
82
110
  fun `repeat builds reuse the same placeholder bitmap`() {
83
111
  // The loader holds a placeholder *provider* closure; invoking it
@@ -277,11 +277,17 @@ final class GaplessEngine: PlaybackEngine {
277
277
  reapplyPitchAlgorithm()
278
278
  }
279
279
 
280
- func setRepeatMode(_: RepeatMode) {
281
- // AVQueuePlayer doesn't have a native repeat mode equivalent
282
- // to ExoPlayer; TrackPlayer's QueueSkipArithmetic handles the
283
- // repeat semantics by rebuilding the queue at the appropriate
284
- // moment. No work to do at the engine layer.
280
+ func setRepeatMode(_ mode: RepeatMode) {
281
+ // AVQueuePlayer has no native repeat mode. Under repeat-one, hold the
282
+ // finished item at its end (`actionAtItemEnd = .none`) so the player
283
+ // does not auto-advance `TrackPlayer.handlePlayerItemDidPlayToEndTime`
284
+ // then deterministically rewinds it to zero and replays it. `.off` /
285
+ // `.queue` advance + wrap are driven by TrackPlayer's queue rebuild, so
286
+ // they keep `.advance`. Switching mode only affects future end-of-item
287
+ // behaviour; an item already parked at its end (repeat-one + the
288
+ // end-of-track sleep-timer pause) is not retroactively advanced — a
289
+ // resume or skip recovers it.
290
+ player.actionAtItemEnd = (mode == .track) ? .none : .advance
285
291
  }
286
292
 
287
293
  func setPitchCorrectionMode(_ mode: PitchCorrectionMode) {
@@ -30,6 +30,32 @@ final class GaplessEngineLifecycleTests: XCTestCase {
30
30
  XCTAssertNil(engine.delegate)
31
31
  }
32
32
 
33
+ // MARK: - Repeat mode
34
+
35
+ /// Repeat-one holds the current item at its end
36
+ /// (`actionAtItemEnd = .none`) so `TrackPlayer`'s end-of-track handler
37
+ /// can deterministically rewind + replay it; off / queue keep the
38
+ /// default `.advance`. Without the hold, the AVQueuePlayer advances
39
+ /// into the next enqueued item before the async rewind runs, so the
40
+ /// track skips forward instead of repeating.
41
+ func testRepeatTrackHoldsItemAtEndWhileOthersAdvance() {
42
+ let engine = GaplessEngine()
43
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
44
+
45
+ engine.setRepeatMode(.track)
46
+ XCTAssertEqual(engine.player.actionAtItemEnd, .none)
47
+
48
+ engine.setRepeatMode(.queue)
49
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
50
+
51
+ engine.setRepeatMode(.off)
52
+ XCTAssertEqual(engine.player.actionAtItemEnd, .advance)
53
+
54
+ // Re-entering repeat-one re-applies the hold.
55
+ engine.setRepeatMode(.track)
56
+ XCTAssertEqual(engine.player.actionAtItemEnd, .none)
57
+ }
58
+
33
59
  // MARK: - Queue-mutation surface
34
60
 
35
61
  /// `setItems` installs the slice from `startIndex` onward; items
@@ -3952,10 +3952,12 @@ class TrackPlayer: HybridTrackPlayerSpec {
3952
3952
  /// pre-wrote.
3953
3953
  ///
3954
3954
  /// Repeat-track handling: when `repeatModeState == .track`, seek
3955
- /// the just-finished item back to zero + replay. The notification
3956
- /// fires BEFORE AVQueuePlayer auto-advances (per Apple docs), so
3957
- /// we get the chance to rewind without flashing through the next
3958
- /// item. We do NOT set `didPlayToEndPending` in this branch —
3955
+ /// the just-finished item back to zero + replay. The gapless engine
3956
+ /// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
3957
+ /// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
3958
+ /// player does not advance and `player.currentItem` is still the
3959
+ /// finished item when this async block runs — the rewind is
3960
+ /// deterministic. We do NOT set `didPlayToEndPending` in this branch —
3959
3961
  /// the seek-to-zero produces no `\.currentItem` change, so there
3960
3962
  /// is no auto-advance to gate.
3961
3963
  @objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
@@ -3968,30 +3970,21 @@ class TrackPlayer: HybridTrackPlayerSpec {
3968
3970
  let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
3969
3971
  if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
3970
3972
  // Branch on repeat-track FIRST, separately from the
3971
- // currentItem === item check. The notification dispatches
3972
- // async to main but AVQueuePlayer
3973
- // runs `actionAtItemEnd = .advance` synchronously on its own
3974
- // queue — under load, the player can have already advanced
3975
- // by the time our async block runs. Falling through to the
3976
- // auto-advance branch in repeat-track mode would incorrectly
3977
- // arm `didPlayToEndPending`; the resync would then run on
3978
- // the unintended-advance currentItem and clobber the user's
3979
- // expected repeat-track stay-on-current.
3973
+ // currentItem === item check, and never arm `didPlayToEndPending`
3974
+ // here in repeat-track mode there is no auto-advance to gate.
3980
3975
  if self.repeatModeState == .track {
3981
- // Repeat-track: rewind the just-finished item + replay.
3982
- // Apple's docs note the notification fires BEFORE
3983
- // actionAtItemEnd is honoured, so the seek-to-zero +
3984
- // play normally beats the auto-advance.
3976
+ // Repeat-track: rewind the just-finished item + replay. The
3977
+ // gapless engine holds the item via `actionAtItemEnd = .none`
3978
+ // under `.track`, so the player does not advance and
3979
+ // `currentItem` is still the finished item here.
3985
3980
  if let player = self.player {
3986
3981
  // Repeat-one loop = a new milestone playthrough of the same track.
3987
3982
  // The gapless engine fires no track-change / delegate here, so
3988
3983
  // dispatchTrackChange's reset is skipped — reset directly, and
3989
- // UNCONDITIONALLY for the gapless engine (self.player non-nil), not
3990
- // only when the rewind race below is won: under load this async block
3991
- // can run AFTER AVQueuePlayer already chain-advanced, in which case
3992
- // the seek is skipped but the track still loops, so milestones must
3993
- // re-arm regardless. (CrossfadeEngine has self.player == nil here and
3994
- // resets via engineDidPlayItemToEnd instead — no double reset.)
3984
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), so
3985
+ // milestones re-arm for the new playthrough. (CrossfadeEngine has
3986
+ // self.player == nil here and resets via engineDidPlayItemToEnd
3987
+ // instead no double reset.)
3995
3988
  self.milestoneTracker.reset()
3996
3989
  if let item = notification.object as? AVPlayerItem,
3997
3990
  player.currentItem === item {
@@ -4003,15 +3996,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
4003
3996
  if !sleepFired.pauseNow { self.engine?.play() }
4004
3997
  }
4005
3998
  }
4006
- // If the rewind window was lost (player chain-advanced
4007
- // before our async block ran), accept that the
4008
- // KVO fire will re-sync currentTrackIndex via the next
4009
- // currentItem changebut DO NOT arm `didPlayToEndPending`
4010
- // here, because in repeat-track mode the auto-advance
4011
- // shouldn't have happened. The resync via KVO without
4012
- // didPlayToEndPending stays correct because user
4013
- // mutations pre-write the index; nothing overwrites the
4014
- // shadow index incorrectly.
3999
+ // Defense in depth: if `currentItem` is somehow not the finished
4000
+ // item (e.g. a mode change raced this fire before `.none` was
4001
+ // applied), skip the rewind and let the `\.currentItem` KVO
4002
+ // re-sync `currentTrackIndex`WITHOUT arming
4003
+ // `didPlayToEndPending`, since repeat-track should not advance.
4004
+ // User mutations pre-write the index, so the shadow index stays
4005
+ // correct.
4015
4006
  return
4016
4007
  }
4017
4008
  // Repeat-off / repeat-queue: arm the resync gate so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/index.d.ts",