react-native-queue-player 1.1.1 → 1.2.0

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 (32) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/consumer-rules.pro +72 -3
  3. package/android/src/main/cpp/airplay2_jni.cpp +23 -3
  4. package/android/src/main/cpp/airplay_jni.cpp +12 -3
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +31 -7
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +13 -0
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +19 -10
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +6 -12
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +26 -15
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +6 -2
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +162 -0
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +122 -0
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
  23. package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
  24. package/ios/Cast/Core/CastSession.swift +11 -0
  25. package/ios/GaplessEngine.swift +11 -5
  26. package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
  27. package/ios/Tests/GaplessEngineLifecycleTests.swift +26 -0
  28. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  29. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  30. package/ios/TrackPlayer.swift +32 -32
  31. package/package.json +1 -1
  32. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
@@ -177,6 +177,7 @@ final class ControllableRemoteSession: CastSessionRemotePlayer {
177
177
  var isPushSink: Bool { pushSink }
178
178
 
179
179
  var lastState: RemotePlaybackStateKind = .idle
180
+ var hasReportedState: Bool = false
180
181
  var lastPositionMs: Int64 = 0
181
182
  var lastDurationMs: Int64 = 0
182
183
  var lastBufferedMs: Int64 = 0
@@ -186,8 +187,12 @@ final class ControllableRemoteSession: CastSessionRemotePlayer {
186
187
  private var stateCb: ((RemotePlaybackStateKind) -> Void)?
187
188
  private var positionCb: ((Int64, Int64, Int64) -> Void)?
188
189
 
190
+ /// Drive the receiver state the way a live session does: a non-idle report
191
+ /// latches `hasReportedState`. Set `lastState` directly to stage a state the
192
+ /// receiver has not reported yet.
189
193
  func fireState(_ state: RemotePlaybackStateKind) {
190
194
  lastState = state
195
+ if state != .idle { hasReportedState = true }
191
196
  stateCb?(state)
192
197
  }
193
198
 
@@ -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
@@ -124,6 +124,7 @@ final class FakeRemoteSession: CastSessionRemotePlayer {
124
124
  var isPushSink: Bool { false }
125
125
 
126
126
  var lastState: RemotePlaybackStateKind = .idle
127
+ var hasReportedState: Bool = false
127
128
  var lastPositionMs: Int64 = 0
128
129
  var lastDurationMs: Int64 = 0
129
130
  var lastBufferedMs: Int64 = 0
@@ -0,0 +1,78 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// `getState()` reports the receiver while a cast session owns playback. The
5
+ /// local engine is parked and paused for the session's duration, so reading it
6
+ /// would describe nothing the user can hear.
7
+ final class TrackPlayerCastStateTests: XCTestCase {
8
+
9
+ private let router = PlaybackStateRouter.shared
10
+
11
+ override func setUp() {
12
+ super.setUp()
13
+ router._resetForTesting()
14
+ }
15
+
16
+ override func tearDown() {
17
+ router._resetForTesting()
18
+ super.tearDown()
19
+ }
20
+
21
+ /// Register a session that has already reported `state` to the receiver.
22
+ private func activateReporting(_ state: RemotePlaybackStateKind) throws {
23
+ let session = ControllableRemoteSession()
24
+ session.fireState(state)
25
+ try router.setActive(session)
26
+ }
27
+
28
+ func testReportsReceiverPlayingDuringCastSession() throws {
29
+ try activateReporting(.playing)
30
+ XCTAssertEqual(try TrackPlayer().getState(), .playing)
31
+ }
32
+
33
+ func testReportsReceiverPausedDuringCastSession() throws {
34
+ try activateReporting(.paused)
35
+ XCTAssertEqual(try TrackPlayer().getState(), .paused)
36
+ }
37
+
38
+ func testMapsBufferingReceiverToBuffering() throws {
39
+ try activateReporting(.buffering)
40
+ XCTAssertEqual(try TrackPlayer().getState(), .buffering)
41
+ }
42
+
43
+ func testMapsEndedReceiverToEnded() throws {
44
+ try activateReporting(.ended)
45
+ XCTAssertEqual(try TrackPlayer().getState(), .ended)
46
+ }
47
+
48
+ func testReportsIdleReceiverOnceItHasReported() throws {
49
+ // A receiver that has spoken stays authoritative: a later idle means it
50
+ // genuinely has nothing loaded, not that it is still activating.
51
+ let session = ControllableRemoteSession()
52
+ session.fireState(.playing)
53
+ session.fireState(.idle)
54
+ try router.setActive(session)
55
+
56
+ XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
57
+ }
58
+
59
+ func testReadsLocalEngineUntilReceiverReports() throws {
60
+ // Session activating: the receiver holds a state it has not reported, so
61
+ // the local engine answers. Distinguishable from the receiver's `.playing`
62
+ // precisely because no engine is configured here.
63
+ let session = ControllableRemoteSession()
64
+ session.lastState = .playing
65
+ try router.setActive(session)
66
+
67
+ XCTAssertFalse(session.hasReportedState)
68
+ XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
69
+ }
70
+
71
+ func testReadsLocalEngineWithNoCastSession() throws {
72
+ // An unregistered session must not be consulted, however it reports.
73
+ let session = ControllableRemoteSession()
74
+ session.fireState(.playing)
75
+
76
+ XCTAssertEqual(try TrackPlayer().getState(), PlayerState.none)
77
+ }
78
+ }
@@ -2968,6 +2968,15 @@ class TrackPlayer: HybridTrackPlayerSpec {
2968
2968
  // hop for consistency. `onMainSync` short-circuits when already
2969
2969
  // on main, so callers that are on main don't deadlock.
2970
2970
  func getState() throws -> PlayerState {
2971
+ // The receiver owns playback during a cast session — the local engine is
2972
+ // parked and paused for its duration, so its derived state describes
2973
+ // nothing the user can hear. Until the receiver has reported (session
2974
+ // still activating, holding its initial `.idle`) the local read is the
2975
+ // better answer; after that the receiver is authoritative, including a
2976
+ // later `.idle`, which means it genuinely has nothing loaded.
2977
+ if let remote = PlaybackStateRouter.shared.activeRemote(), remote.hasReportedState {
2978
+ return remote.lastState.asPlayerState
2979
+ }
2971
2980
  return onMainSync {
2972
2981
  // Both engines derive PlayerState from the same inputs via
2973
2982
  // `engine.timeControlStatus` (gapless returns its AVQueuePlayer's, which
@@ -3952,10 +3961,12 @@ class TrackPlayer: HybridTrackPlayerSpec {
3952
3961
  /// pre-wrote.
3953
3962
  ///
3954
3963
  /// 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 —
3964
+ /// the just-finished item back to zero + replay. The gapless engine
3965
+ /// holds the item at its end (`AVQueuePlayer.actionAtItemEnd = .none`
3966
+ /// under `.track`, set in `GaplessEngine.setRepeatMode`), so the
3967
+ /// player does not advance and `player.currentItem` is still the
3968
+ /// finished item when this async block runs — the rewind is
3969
+ /// deterministic. We do NOT set `didPlayToEndPending` in this branch —
3959
3970
  /// the seek-to-zero produces no `\.currentItem` change, so there
3960
3971
  /// is no auto-advance to gate.
3961
3972
  @objc private func handlePlayerItemDidPlayToEndTime(_ notification: Notification) {
@@ -3968,30 +3979,21 @@ class TrackPlayer: HybridTrackPlayerSpec {
3968
3979
  let sleepFired = self.sleepTimerCore.fireAtTrackEnd()
3969
3980
  if sleepFired.pauseNow { self.applySleepTimerResult(sleepFired) }
3970
3981
  // 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.
3982
+ // currentItem === item check, and never arm `didPlayToEndPending`
3983
+ // here in repeat-track mode there is no auto-advance to gate.
3980
3984
  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.
3985
+ // Repeat-track: rewind the just-finished item + replay. The
3986
+ // gapless engine holds the item via `actionAtItemEnd = .none`
3987
+ // under `.track`, so the player does not advance and
3988
+ // `currentItem` is still the finished item here.
3985
3989
  if let player = self.player {
3986
3990
  // Repeat-one loop = a new milestone playthrough of the same track.
3987
3991
  // The gapless engine fires no track-change / delegate here, so
3988
3992
  // 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.)
3993
+ // UNCONDITIONALLY for the gapless engine (self.player non-nil), so
3994
+ // milestones re-arm for the new playthrough. (CrossfadeEngine has
3995
+ // self.player == nil here and resets via engineDidPlayItemToEnd
3996
+ // instead no double reset.)
3995
3997
  self.milestoneTracker.reset()
3996
3998
  if let item = notification.object as? AVPlayerItem,
3997
3999
  player.currentItem === item {
@@ -4003,15 +4005,13 @@ class TrackPlayer: HybridTrackPlayerSpec {
4003
4005
  if !sleepFired.pauseNow { self.engine?.play() }
4004
4006
  }
4005
4007
  }
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.
4008
+ // Defense in depth: if `currentItem` is somehow not the finished
4009
+ // item (e.g. a mode change raced this fire before `.none` was
4010
+ // applied), skip the rewind and let the `\.currentItem` KVO
4011
+ // re-sync `currentTrackIndex`WITHOUT arming
4012
+ // `didPlayToEndPending`, since repeat-track should not advance.
4013
+ // User mutations pre-write the index, so the shadow index stays
4014
+ // correct.
4015
4015
  return
4016
4016
  }
4017
4017
  // 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.1",
3
+ "version": "1.2.0",
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",
@@ -1,189 +0,0 @@
1
- package com.margelo.nitro.queueplayer.cast.airplay
2
-
3
- import android.content.Context
4
- import android.graphics.Bitmap
5
- import android.graphics.BitmapFactory
6
- import android.net.Uri
7
- import android.os.Handler
8
- import android.os.Looper
9
- import android.util.Log
10
- import androidx.media3.common.MediaItem
11
- import androidx.media3.common.Player
12
- import kotlinx.coroutines.*
13
- import java.io.ByteArrayOutputStream
14
- import java.net.URL
15
-
16
- /**
17
- * Syncs ExoPlayer track metadata and playback progress to an [AirPlay2Session].
18
- * Identical logic to [AirPlayMetadataSync] but targets the AP2 session.
19
- */
20
- internal class AirPlay2MetadataSync(
21
- private val context: Context,
22
- private val session: AirPlay2Session,
23
- private val player: Player,
24
- private val scope: CoroutineScope,
25
- ) {
26
- companion object {
27
- private const val TAG = "AP2MetadataSync"
28
- private const val PROGRESS_INTERVAL_MS = 5000L
29
- private const val MAX_ARTWORK_DIM = 600
30
- private const val JPEG_QUALITY = 80
31
- }
32
-
33
- private var progressJob: Job? = null
34
- private var artworkJob: Job? = null
35
- private var lastArtworkUri: String? = null
36
- private var started = false
37
-
38
- // ExoPlayer rejects add/removeListener + state reads off its application
39
- // looper. start()/stop() are driven by session activation/close, and
40
- // close() runs on whichever thread disconnected (the JS thread for a
41
- // user disconnect, the native drain thread on receiver death) — so the
42
- // player touches are marshalled onto the player's looper, run inline when
43
- // already on it.
44
- private val playerHandler = Handler(player.applicationLooper)
45
-
46
- private fun onPlayerLooper(block: () -> Unit) {
47
- if (Looper.myLooper() == player.applicationLooper) block()
48
- else playerHandler.post(block)
49
- }
50
-
51
- private val listener = object : Player.Listener {
52
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
53
- sendCurrentMetadata()
54
- }
55
-
56
- override fun onPositionDiscontinuity(
57
- oldPosition: Player.PositionInfo,
58
- newPosition: Player.PositionInfo,
59
- reason: Int,
60
- ) {
61
- if (reason == Player.DISCONTINUITY_REASON_SEEK) {
62
- sendProgress()
63
- }
64
- }
65
-
66
- override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
67
- if (playWhenReady) {
68
- startProgressTimer()
69
- } else {
70
- progressJob?.cancel()
71
- progressJob = null
72
- }
73
- }
74
- }
75
-
76
- fun start() {
77
- if (started) return
78
- started = true
79
- onPlayerLooper {
80
- player.addListener(listener)
81
- sendCurrentMetadata()
82
- if (player.playWhenReady) {
83
- startProgressTimer()
84
- }
85
- }
86
- Log.i(TAG, "Started metadata sync")
87
- }
88
-
89
- fun stop() {
90
- if (!started) return
91
- started = false
92
- progressJob?.cancel()
93
- progressJob = null
94
- artworkJob?.cancel()
95
- artworkJob = null
96
- onPlayerLooper { player.removeListener(listener) }
97
- Log.i(TAG, "Stopped metadata sync")
98
- }
99
-
100
- private fun sendCurrentMetadata() {
101
- val meta = player.currentMediaItem?.mediaMetadata ?: return
102
- val title = meta.title?.toString()
103
- val artist = meta.artist?.toString()
104
- val album = meta.albumTitle?.toString()
105
-
106
- Log.i(TAG, "Sending metadata: title=$title, artist=$artist, album=$album")
107
- session.setMetadata(title, artist, album)
108
- sendProgress()
109
-
110
- val artUri = meta.artworkUri?.toString()
111
- if (artUri != null && artUri != lastArtworkUri) {
112
- lastArtworkUri = artUri
113
- fetchAndSendArtwork(artUri)
114
- }
115
- }
116
-
117
- private fun sendProgress() {
118
- val position = player.currentPosition.toInt()
119
- val duration = player.duration.let { if (it > 0) it.toInt() else 0 }
120
- if (duration > 0) {
121
- session.setProgress(position, duration)
122
- }
123
- }
124
-
125
- private fun startProgressTimer() {
126
- progressJob?.cancel()
127
- progressJob = scope.launch {
128
- while (isActive) {
129
- delay(PROGRESS_INTERVAL_MS)
130
- if (player.isPlaying) {
131
- sendProgress()
132
- }
133
- }
134
- }
135
- }
136
-
137
- private fun fetchAndSendArtwork(uriString: String) {
138
- artworkJob?.cancel()
139
- artworkJob = scope.launch(Dispatchers.IO) {
140
- try {
141
- val uri = Uri.parse(uriString)
142
- val scheme = uri.scheme ?: return@launch
143
-
144
- // Read raw bytes — use ContentResolver for content:// and
145
- // android.resource://, URL for http(s), file for file://
146
- val rawBytes: ByteArray = when (scheme) {
147
- "content", "android.resource" ->
148
- context.contentResolver.openInputStream(uri)
149
- ?.use { it.readBytes() } ?: return@launch
150
- "file" ->
151
- java.io.File(uri.path ?: return@launch).readBytes()
152
- "http", "https" ->
153
- URL(uriString).openStream().use { it.readBytes() }
154
- else -> {
155
- Log.w(TAG, "Unsupported artwork scheme: $scheme")
156
- return@launch
157
- }
158
- }
159
-
160
- val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
161
- BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size, boundsOpts)
162
-
163
- var sampleSize = 1
164
- while (boundsOpts.outWidth / sampleSize > MAX_ARTWORK_DIM ||
165
- boundsOpts.outHeight / sampleSize > MAX_ARTWORK_DIM) {
166
- sampleSize *= 2
167
- }
168
-
169
- val decodeOpts = BitmapFactory.Options().apply { inSampleSize = sampleSize }
170
- val bitmap = BitmapFactory.decodeByteArray(rawBytes, 0, rawBytes.size, decodeOpts)
171
- ?: return@launch
172
-
173
- val baos = ByteArrayOutputStream()
174
- bitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, baos)
175
- bitmap.recycle()
176
-
177
- val bytes = baos.toByteArray()
178
- if (isActive) {
179
- session.setArtwork(bytes, "image/jpeg")
180
- Log.i(TAG, "Sent artwork: ${bytes.size} bytes from $uriString")
181
- }
182
- } catch (e: Exception) {
183
- if (e !is CancellationException) {
184
- Log.w(TAG, "Artwork fetch failed for $uriString", e)
185
- }
186
- }
187
- }
188
- }
189
- }