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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +15 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt +28 -0
- package/ios/GaplessEngine.swift +11 -5
- package/ios/Tests/GaplessEngineLifecycleTests.swift +26 -0
- package/ios/TrackPlayer.swift +23 -32
- package/package.json +1 -1
|
@@ -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.
|
|
766
|
-
*
|
|
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(
|
|
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.
|
package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceArtworkLoaderTest.kt
CHANGED
|
@@ -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
|
package/ios/GaplessEngine.swift
CHANGED
|
@@ -277,11 +277,17 @@ final class GaplessEngine: PlaybackEngine {
|
|
|
277
277
|
reapplyPitchAlgorithm()
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
func setRepeatMode(_: RepeatMode) {
|
|
281
|
-
// AVQueuePlayer
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
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
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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
|
|
3956
|
-
///
|
|
3957
|
-
///
|
|
3958
|
-
///
|
|
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
|
|
3972
|
-
//
|
|
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
|
-
//
|
|
3983
|
-
//
|
|
3984
|
-
//
|
|
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),
|
|
3990
|
-
//
|
|
3991
|
-
//
|
|
3992
|
-
//
|
|
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
|
-
//
|
|
4007
|
-
//
|
|
4008
|
-
//
|
|
4009
|
-
//
|
|
4010
|
-
//
|
|
4011
|
-
//
|
|
4012
|
-
//
|
|
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.
|
|
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",
|