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.
- package/android/build.gradle +7 -0
- package/android/consumer-rules.pro +72 -3
- package/android/src/main/cpp/airplay2_jni.cpp +23 -3
- package/android/src/main/cpp/airplay_jni.cpp +12 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +31 -7
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastEventBridge.kt +1 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastSession.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/CastTransportRouter.kt +19 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/RemotePlaybackStateMapping.kt +18 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2Session.kt +19 -10
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSync.kt +58 -21
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlaySession.kt +26 -14
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/MetadataSyncTarget.kt +20 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/cast/chromecast/ChromecastSession.kt +73 -13
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +6 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +26 -15
- package/android/src/test/java/com/margelo/nitro/queueplayer/RobolectricServiceBindHelper.kt +6 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/ShadowEqualizer.kt +68 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastCommandRoutingTest.kt +162 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerCastStateTest.kt +122 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/FakeRemotePlayer.kt +27 -11
- package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt +303 -0
- package/ios/Cast/Chromecast/ChromecastSession.swift +90 -11
- package/ios/Cast/Core/CastSession.swift +11 -0
- package/ios/Tests/CastNowPlayingControllerTests.swift +5 -0
- package/ios/Tests/PlaybackStateRouterTests.swift +1 -0
- package/ios/Tests/TrackPlayerCastStateTests.swift +78 -0
- package/ios/TrackPlayer.swift +9 -0
- package/package.json +1 -1
- 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
|
+
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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.
|
|
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",
|
package/android/src/main/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlay2MetadataSync.kt
DELETED
|
@@ -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
|
-
}
|