react-native-queue-player 1.1.2 → 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 (30) 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/Tests/CastNowPlayingControllerTests.swift +5 -0
  26. package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
  27. package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
  28. package/ios/TrackPlayer.swift +9 -0
  29. package/package.json +1 -1
  30. package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt +0 -189
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.1.2",
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
- }