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,122 @@
|
|
|
1
|
+
package com.margelo.nitro.queueplayer
|
|
2
|
+
|
|
3
|
+
import androidx.media3.common.util.UnstableApi
|
|
4
|
+
import androidx.test.core.app.ApplicationProvider
|
|
5
|
+
import com.margelo.nitro.queueplayer.cast.FakeRemotePlayer
|
|
6
|
+
import com.margelo.nitro.queueplayer.cast.PlaybackStateRouter
|
|
7
|
+
import com.margelo.nitro.queueplayer.cast.RemotePlaybackState
|
|
8
|
+
import org.junit.After
|
|
9
|
+
import org.junit.Assert.assertEquals
|
|
10
|
+
import org.junit.Assert.assertFalse
|
|
11
|
+
import org.junit.Assert.assertTrue
|
|
12
|
+
import org.junit.Before
|
|
13
|
+
import org.junit.Test
|
|
14
|
+
import org.junit.runner.RunWith
|
|
15
|
+
import org.robolectric.RobolectricTestRunner
|
|
16
|
+
import org.robolectric.annotation.Config
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `getState()` reports the receiver while a cast session owns playback. The
|
|
20
|
+
* local player is parked and paused for the session's duration, so reading it
|
|
21
|
+
* would describe nothing the user can hear.
|
|
22
|
+
*/
|
|
23
|
+
@RunWith(RobolectricTestRunner::class)
|
|
24
|
+
@Config(sdk = [34])
|
|
25
|
+
@UnstableApi
|
|
26
|
+
class TrackPlayerCastStateTest {
|
|
27
|
+
|
|
28
|
+
private lateinit var player: TrackPlayer
|
|
29
|
+
private lateinit var serviceHandle: PlaybackServiceTestHandle
|
|
30
|
+
private val remote = FakeRemotePlayer()
|
|
31
|
+
|
|
32
|
+
private fun context() = ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
33
|
+
|
|
34
|
+
@Before
|
|
35
|
+
fun setUp() {
|
|
36
|
+
PlaybackStateRouter.setActive(null)
|
|
37
|
+
player = TrackPlayer()
|
|
38
|
+
serviceHandle = bindPlaybackServiceForTest(player, context())
|
|
39
|
+
player.configureInternal(
|
|
40
|
+
PlayerConfig(
|
|
41
|
+
httpHeaders = null, userAgent = null, autoRetries = null, retryBackoffMs = null,
|
|
42
|
+
networkTimeoutMs = null, audioContentType = null, audioCategoryOptions = null,
|
|
43
|
+
visualizationEnabled = null, clampSeekToBuffered = null,
|
|
44
|
+
lookaheadCacheMaxSizeMb = null, lookaheadCacheEvictionPolicy = null,
|
|
45
|
+
progressUpdateIntervalMs = null, backgroundProgressUpdateIntervalMs = null,
|
|
46
|
+
placeholderArtworkUri = null, skipToPreviousBehavior = null
|
|
47
|
+
),
|
|
48
|
+
context()
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@After
|
|
53
|
+
fun tearDown() {
|
|
54
|
+
PlaybackStateRouter.setActive(null)
|
|
55
|
+
player.destroyInternal()
|
|
56
|
+
serviceHandle.destroy()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Test
|
|
60
|
+
fun `reports the receiver's playing state during a cast session`() {
|
|
61
|
+
remote.reportState(RemotePlaybackState.PLAYING)
|
|
62
|
+
PlaybackStateRouter.setActive(remote)
|
|
63
|
+
|
|
64
|
+
assertEquals(PlayerState.PLAYING, player.getState())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test
|
|
68
|
+
fun `reports the receiver's paused state during a cast session`() {
|
|
69
|
+
remote.reportState(RemotePlaybackState.PAUSED)
|
|
70
|
+
PlaybackStateRouter.setActive(remote)
|
|
71
|
+
|
|
72
|
+
assertEquals(PlayerState.PAUSED, player.getState())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Test
|
|
76
|
+
fun `maps a buffering receiver to buffering`() {
|
|
77
|
+
remote.reportState(RemotePlaybackState.BUFFERING)
|
|
78
|
+
PlaybackStateRouter.setActive(remote)
|
|
79
|
+
|
|
80
|
+
assertEquals(PlayerState.BUFFERING, player.getState())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Test
|
|
84
|
+
fun `maps an ended receiver to ended`() {
|
|
85
|
+
remote.reportState(RemotePlaybackState.ENDED)
|
|
86
|
+
PlaybackStateRouter.setActive(remote)
|
|
87
|
+
|
|
88
|
+
assertEquals(PlayerState.ENDED, player.getState())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@Test
|
|
92
|
+
fun `reads the local player until the receiver reports`() {
|
|
93
|
+
// Session activating: the receiver holds a state it has not reported, so
|
|
94
|
+
// the local player answers. The staged PLAYING makes the two distinct —
|
|
95
|
+
// with no queue configured the local read is NONE.
|
|
96
|
+
remote.backingState.value = RemotePlaybackState.PLAYING
|
|
97
|
+
PlaybackStateRouter.setActive(remote)
|
|
98
|
+
|
|
99
|
+
assertFalse(remote.hasReportedState)
|
|
100
|
+
assertEquals(PlayerState.NONE, player.getState())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun `keeps reading the receiver once it has reported`() {
|
|
105
|
+
// A receiver that has spoken stays authoritative: a later idle means it
|
|
106
|
+
// genuinely has nothing loaded, not that it is still activating.
|
|
107
|
+
remote.reportState(RemotePlaybackState.PLAYING)
|
|
108
|
+
remote.backingState.value = RemotePlaybackState.IDLE
|
|
109
|
+
PlaybackStateRouter.setActive(remote)
|
|
110
|
+
|
|
111
|
+
assertTrue(remote.hasReportedState)
|
|
112
|
+
assertEquals(PlayerState.NONE, player.getState())
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
@Test
|
|
116
|
+
fun `reads the local player when no cast session is active`() {
|
|
117
|
+
// An unregistered session must not be consulted, however it reports.
|
|
118
|
+
remote.reportState(RemotePlaybackState.PLAYING)
|
|
119
|
+
|
|
120
|
+
assertEquals(PlayerState.NONE, player.getState())
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -9,10 +9,11 @@ import kotlinx.coroutines.flow.asSharedFlow
|
|
|
9
9
|
import kotlinx.coroutines.flow.asStateFlow
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* Test double for [CastSession.RemotePlayer]. Transport calls
|
|
13
|
-
* the StateFlows are driveable via the public
|
|
14
|
-
* fields
|
|
15
|
-
*
|
|
12
|
+
* Test double for [CastSession.RemotePlayer]. Transport calls record their
|
|
13
|
+
* name in [transportCalls]; the StateFlows are driveable via the public
|
|
14
|
+
* backing fields, or via [reportState] to mirror the real session's
|
|
15
|
+
* [hasReportedState] latch; [mediaSessionPlayer] is injectable so bind tests
|
|
16
|
+
* can assert the MediaSession swaps to it.
|
|
16
17
|
*/
|
|
17
18
|
internal class FakeRemotePlayer(
|
|
18
19
|
private val player: Player? = null,
|
|
@@ -36,15 +37,30 @@ internal class FakeRemotePlayer(
|
|
|
36
37
|
override val mediaError: SharedFlow<RemoteMediaError> = backingError.asSharedFlow()
|
|
37
38
|
override val seekCompleted: SharedFlow<Long> = backingSeek.asSharedFlow()
|
|
38
39
|
|
|
40
|
+
/** Transport calls in the order they arrived, for routing assertions. */
|
|
41
|
+
val transportCalls = mutableListOf<String>()
|
|
42
|
+
|
|
43
|
+
override var hasReportedState: Boolean = false
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Drive the receiver state the way a live session does: a non-idle report
|
|
47
|
+
* latches [hasReportedState]. Set [backingState] directly to stage a state
|
|
48
|
+
* the receiver has not reported yet.
|
|
49
|
+
*/
|
|
50
|
+
fun reportState(state: RemotePlaybackState) {
|
|
51
|
+
if (state != RemotePlaybackState.IDLE) hasReportedState = true
|
|
52
|
+
backingState.value = state
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
override fun setVolume(value: Float) {}
|
|
40
56
|
override fun close() {}
|
|
41
|
-
override fun play() {}
|
|
42
|
-
override fun pause() {}
|
|
43
|
-
override fun stop() {}
|
|
44
|
-
override fun seekTo(positionMs: Long) {}
|
|
45
|
-
override fun queueNext() {}
|
|
46
|
-
override fun queuePrev() {}
|
|
47
|
-
override fun skipToQueueIndex(index: Int) {}
|
|
57
|
+
override fun play() { transportCalls += "play" }
|
|
58
|
+
override fun pause() { transportCalls += "pause" }
|
|
59
|
+
override fun stop() { transportCalls += "stop" }
|
|
60
|
+
override fun seekTo(positionMs: Long) { transportCalls += "seekTo:$positionMs" }
|
|
61
|
+
override fun queueNext() { transportCalls += "queueNext" }
|
|
62
|
+
override fun queuePrev() { transportCalls += "queuePrev" }
|
|
63
|
+
override fun skipToQueueIndex(index: Int) { transportCalls += "skipToQueueIndex:$index" }
|
|
48
64
|
override fun setPlaybackRate(rate: Float) {}
|
|
49
65
|
override fun setRepeatMode(mode: RemoteRepeatMode) {}
|
|
50
66
|
|
package/android/src/test/java/com/margelo/nitro/queueplayer/cast/airplay/AirPlayMetadataSyncTest.kt
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
package com.margelo.nitro.queueplayer.cast.airplay
|
|
2
|
+
|
|
3
|
+
import android.net.Uri
|
|
4
|
+
import android.os.Looper
|
|
5
|
+
import androidx.media3.common.MediaItem
|
|
6
|
+
import androidx.media3.common.MediaMetadata
|
|
7
|
+
import androidx.media3.common.Player
|
|
8
|
+
import androidx.media3.common.SimpleBasePlayer
|
|
9
|
+
import androidx.media3.common.util.UnstableApi
|
|
10
|
+
import androidx.test.core.app.ApplicationProvider
|
|
11
|
+
import com.google.common.collect.ImmutableList
|
|
12
|
+
import kotlinx.coroutines.CoroutineScope
|
|
13
|
+
import kotlinx.coroutines.Dispatchers
|
|
14
|
+
import kotlinx.coroutines.cancel
|
|
15
|
+
import org.junit.After
|
|
16
|
+
import org.junit.Assert.assertEquals
|
|
17
|
+
import org.junit.Assert.assertFalse
|
|
18
|
+
import org.junit.Assert.assertTrue
|
|
19
|
+
import org.junit.Before
|
|
20
|
+
import org.junit.Test
|
|
21
|
+
import org.junit.runner.RunWith
|
|
22
|
+
import org.robolectric.RobolectricTestRunner
|
|
23
|
+
import org.robolectric.Shadows.shadowOf
|
|
24
|
+
import org.robolectric.annotation.Config
|
|
25
|
+
import java.io.File
|
|
26
|
+
import java.util.Base64
|
|
27
|
+
import java.time.Duration
|
|
28
|
+
import java.util.concurrent.CopyOnWriteArrayList
|
|
29
|
+
import java.util.concurrent.Executors
|
|
30
|
+
import java.util.concurrent.TimeUnit
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Receiver-display sync lifecycle: what reaches the receiver while a sync is
|
|
34
|
+
* running, and that nothing reaches it once the session has been torn down.
|
|
35
|
+
*
|
|
36
|
+
* The teardown cases drive `stop()` from a non-looper thread, which is where
|
|
37
|
+
* it comes from in production — the JS thread on a user disconnect, the
|
|
38
|
+
* native drain thread on receiver death.
|
|
39
|
+
*/
|
|
40
|
+
@RunWith(RobolectricTestRunner::class)
|
|
41
|
+
@Config(sdk = [34])
|
|
42
|
+
@UnstableApi
|
|
43
|
+
class AirPlayMetadataSyncTest {
|
|
44
|
+
|
|
45
|
+
private lateinit var player: FakePlayer
|
|
46
|
+
private lateinit var target: RecordingTarget
|
|
47
|
+
private lateinit var sync: AirPlayMetadataSync
|
|
48
|
+
private val scope = CoroutineScope(Dispatchers.Main.immediate)
|
|
49
|
+
|
|
50
|
+
private fun context() = ApplicationProvider.getApplicationContext<android.content.Context>()
|
|
51
|
+
|
|
52
|
+
private fun idle() = shadowOf(Looper.getMainLooper()).idle()
|
|
53
|
+
|
|
54
|
+
private fun idleFor(ms: Long) =
|
|
55
|
+
shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(ms))
|
|
56
|
+
|
|
57
|
+
/** Runs [block] off the looper and waits for it, as a real teardown does. */
|
|
58
|
+
private fun offLooper(block: () -> Unit) {
|
|
59
|
+
val executor = Executors.newSingleThreadExecutor()
|
|
60
|
+
try {
|
|
61
|
+
executor.submit(block).get(5, TimeUnit.SECONDS)
|
|
62
|
+
} finally {
|
|
63
|
+
executor.shutdownNow()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Waits for work dispatched off the looper, e.g. the artwork fetch. */
|
|
68
|
+
private fun awaitTrue(timeoutMs: Long = 5_000, condition: () -> Boolean): Boolean {
|
|
69
|
+
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
|
|
70
|
+
while (System.nanoTime() < deadline) {
|
|
71
|
+
idle()
|
|
72
|
+
if (condition()) return true
|
|
73
|
+
Thread.sleep(20)
|
|
74
|
+
}
|
|
75
|
+
return condition()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Before
|
|
79
|
+
fun setUp() {
|
|
80
|
+
player = FakePlayer(Looper.getMainLooper())
|
|
81
|
+
target = RecordingTarget()
|
|
82
|
+
sync = AirPlayMetadataSync(context(), target, player, scope)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@After
|
|
86
|
+
fun tearDown() {
|
|
87
|
+
sync.stop()
|
|
88
|
+
idle()
|
|
89
|
+
scope.cancel()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Test
|
|
93
|
+
fun `start sends the current track metadata`() {
|
|
94
|
+
sync.start()
|
|
95
|
+
idle()
|
|
96
|
+
|
|
97
|
+
assertEquals(
|
|
98
|
+
listOf(Triple("Everlong", "Foo Fighters", "The Colour and the Shape")),
|
|
99
|
+
target.metadata,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun `progress is pushed while playing`() {
|
|
105
|
+
sync.start()
|
|
106
|
+
idle()
|
|
107
|
+
val afterStart = target.progress.size
|
|
108
|
+
|
|
109
|
+
idleFor(11_000)
|
|
110
|
+
|
|
111
|
+
assertTrue(
|
|
112
|
+
"expected progress pushes while playing, got ${target.progress.size - afterStart}",
|
|
113
|
+
target.progress.size - afterStart >= 2,
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@Test
|
|
118
|
+
fun `stop from another thread halts the progress loop`() {
|
|
119
|
+
sync.start()
|
|
120
|
+
idle()
|
|
121
|
+
idleFor(6_000)
|
|
122
|
+
assertTrue(target.progress.isNotEmpty())
|
|
123
|
+
|
|
124
|
+
offLooper { sync.stop() }
|
|
125
|
+
idle()
|
|
126
|
+
val atStop = target.progress.size
|
|
127
|
+
|
|
128
|
+
idleFor(30_000)
|
|
129
|
+
|
|
130
|
+
assertEquals(atStop, target.progress.size)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
@Test
|
|
134
|
+
fun `a resume callback arriving after stop starts no progress loop`() {
|
|
135
|
+
sync.start()
|
|
136
|
+
idle()
|
|
137
|
+
player.emitPlayWhenReady(false)
|
|
138
|
+
idle()
|
|
139
|
+
|
|
140
|
+
// The resume lands while listener removal is still queued behind the
|
|
141
|
+
// off-looper stop, so it reaches startProgressTimer on a spent sync.
|
|
142
|
+
offLooper { sync.stop() }
|
|
143
|
+
player.emitPlayWhenReady(true)
|
|
144
|
+
idle()
|
|
145
|
+
val atStop = target.progress.size
|
|
146
|
+
|
|
147
|
+
idleFor(30_000)
|
|
148
|
+
|
|
149
|
+
assertEquals(atStop, target.progress.size)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
@Test
|
|
153
|
+
fun `a track change arriving after stop pushes nothing`() {
|
|
154
|
+
sync.start()
|
|
155
|
+
idle()
|
|
156
|
+
val atStop = target.metadata.size
|
|
157
|
+
|
|
158
|
+
offLooper { sync.stop() }
|
|
159
|
+
player.emitTrackChange(trackMediaItem("Monkey Wrench", "Foo Fighters", "TCATS"))
|
|
160
|
+
idle()
|
|
161
|
+
|
|
162
|
+
assertEquals(atStop, target.metadata.size)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
@Test
|
|
166
|
+
fun `artwork from a local file reaches the receiver while running`() {
|
|
167
|
+
player.emitTrackChange(
|
|
168
|
+
trackMediaItem("Everlong", "Foo Fighters", "TCATS", artworkUri = artworkFile())
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
sync.start()
|
|
172
|
+
|
|
173
|
+
assertTrue("artwork never arrived", awaitTrue { target.artwork.isNotEmpty() })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@Test
|
|
177
|
+
fun `artwork is not fetched after stop`() {
|
|
178
|
+
sync.start()
|
|
179
|
+
idle()
|
|
180
|
+
|
|
181
|
+
// Fired while listener removal is still queued behind the off-looper
|
|
182
|
+
// stop, so the fetch is reached on a spent sync.
|
|
183
|
+
offLooper { sync.stop() }
|
|
184
|
+
player.emitTrackChange(
|
|
185
|
+
trackMediaItem("Monkey Wrench", "Foo Fighters", "TCATS", artworkUri = artworkFile())
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
// Real elapsed time, not looper time: the fetch runs on Dispatchers.IO,
|
|
189
|
+
// so advancing the looper clock proves nothing about whether it ran.
|
|
190
|
+
assertFalse(
|
|
191
|
+
"artwork reached the receiver after stop",
|
|
192
|
+
awaitTrue(2_000) { target.artwork.isNotEmpty() },
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@Test
|
|
197
|
+
fun `start after stop does nothing`() {
|
|
198
|
+
sync.start()
|
|
199
|
+
idle()
|
|
200
|
+
offLooper { sync.stop() }
|
|
201
|
+
idle()
|
|
202
|
+
val atStop = target.progress.size + target.metadata.size
|
|
203
|
+
|
|
204
|
+
sync.start()
|
|
205
|
+
idle()
|
|
206
|
+
idleFor(30_000)
|
|
207
|
+
|
|
208
|
+
assertEquals(atStop, target.progress.size + target.metadata.size)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private fun artworkFile(): Uri {
|
|
212
|
+
val file = File(context().cacheDir, "artwork.png")
|
|
213
|
+
if (!file.exists()) file.writeBytes(PNG_1X1)
|
|
214
|
+
return Uri.fromFile(file)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private class RecordingTarget : MetadataSyncTarget {
|
|
218
|
+
// The artwork fetch runs on Dispatchers.IO while the test thread reads
|
|
219
|
+
// these, so plain ArrayLists would be shared unsynchronised.
|
|
220
|
+
val metadata = CopyOnWriteArrayList<Triple<String?, String?, String?>>()
|
|
221
|
+
val progress = CopyOnWriteArrayList<Pair<Int, Int>>()
|
|
222
|
+
val artwork = CopyOnWriteArrayList<Int>()
|
|
223
|
+
|
|
224
|
+
override fun setMetadata(title: String?, artist: String?, album: String?) {
|
|
225
|
+
metadata += Triple(title, artist, album)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
override fun setProgress(elapsedMs: Int, durationMs: Int) {
|
|
229
|
+
progress += elapsedMs to durationMs
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
override fun setArtwork(imageData: ByteArray, contentType: String) {
|
|
233
|
+
artwork += imageData.size
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Player double exposing exactly what the sync reads: the current item's
|
|
239
|
+
* metadata, position, duration and whether playback is running. Each item
|
|
240
|
+
* carries its own period uid, so replacing it is delivered as a media-item
|
|
241
|
+
* transition rather than a timeline change.
|
|
242
|
+
*/
|
|
243
|
+
private class FakePlayer(looper: Looper) : SimpleBasePlayer(looper) {
|
|
244
|
+
private var item: MediaItem =
|
|
245
|
+
trackMediaItem("Everlong", "Foo Fighters", "The Colour and the Shape")
|
|
246
|
+
private var playing = true
|
|
247
|
+
|
|
248
|
+
fun emitTrackChange(next: MediaItem) {
|
|
249
|
+
item = next
|
|
250
|
+
invalidateState()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
fun emitPlayWhenReady(value: Boolean) {
|
|
254
|
+
playing = value
|
|
255
|
+
invalidateState()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
override fun getState(): State =
|
|
259
|
+
State.Builder()
|
|
260
|
+
.setAvailableCommands(Player.Commands.Builder().addAllCommands().build())
|
|
261
|
+
.setPlaybackState(Player.STATE_READY)
|
|
262
|
+
.setPlayWhenReady(playing, Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST)
|
|
263
|
+
.setPlaylist(
|
|
264
|
+
ImmutableList.of(
|
|
265
|
+
MediaItemData.Builder(item)
|
|
266
|
+
.setMediaItem(item)
|
|
267
|
+
.setDurationUs(300_000_000L)
|
|
268
|
+
.build()
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
.setCurrentMediaItemIndex(0)
|
|
272
|
+
.setContentPositionMs(1_000L)
|
|
273
|
+
.build()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private companion object {
|
|
277
|
+
/**
|
|
278
|
+
* A real 1x1 PNG — Robolectric decodes artwork through ImageIO, so a
|
|
279
|
+
* stub header fails to decode and the fetch reports no artwork.
|
|
280
|
+
*/
|
|
281
|
+
val PNG_1X1: ByteArray = Base64.getDecoder().decode(
|
|
282
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
fun trackMediaItem(
|
|
286
|
+
title: String,
|
|
287
|
+
artist: String,
|
|
288
|
+
album: String,
|
|
289
|
+
artworkUri: Uri? = null,
|
|
290
|
+
): MediaItem =
|
|
291
|
+
MediaItem.Builder()
|
|
292
|
+
.setUri("https://example.invalid/${title.replace(' ', '-')}.mp3")
|
|
293
|
+
.setMediaMetadata(
|
|
294
|
+
MediaMetadata.Builder()
|
|
295
|
+
.setTitle(title)
|
|
296
|
+
.setArtist(artist)
|
|
297
|
+
.setAlbumTitle(album)
|
|
298
|
+
.apply { artworkUri?.let { setArtworkUri(it) } }
|
|
299
|
+
.build()
|
|
300
|
+
)
|
|
301
|
+
.build()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -40,10 +40,16 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
40
40
|
private var _lastVolume: Float = .nan
|
|
41
41
|
private var _lastVolumeEmitted: Float = .nan
|
|
42
42
|
|
|
43
|
+
private var _hasReportedState = false
|
|
44
|
+
|
|
43
45
|
var lastState: RemotePlaybackStateKind {
|
|
44
46
|
stateLock.lock(); defer { stateLock.unlock() }
|
|
45
47
|
return _lastState
|
|
46
48
|
}
|
|
49
|
+
var hasReportedState: Bool {
|
|
50
|
+
stateLock.lock(); defer { stateLock.unlock() }
|
|
51
|
+
return _hasReportedState
|
|
52
|
+
}
|
|
47
53
|
var lastPositionMs: Int64 {
|
|
48
54
|
stateLock.lock(); defer { stateLock.unlock() }
|
|
49
55
|
return _lastPositionMs
|
|
@@ -143,8 +149,15 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
143
149
|
|
|
144
150
|
private func updateState(_ s: RemotePlaybackStateKind) {
|
|
145
151
|
stateLock.lock()
|
|
152
|
+
let previous = _lastState
|
|
146
153
|
_lastState = s
|
|
154
|
+
if s != .idle { _hasReportedState = true }
|
|
147
155
|
stateLock.unlock()
|
|
156
|
+
#if DEBUG
|
|
157
|
+
if previous != s {
|
|
158
|
+
NSLog("[ChromecastSession] receiver state \(previous) -> \(s)")
|
|
159
|
+
}
|
|
160
|
+
#endif
|
|
148
161
|
fireOnEach(stateListeners) { $0(s) }
|
|
149
162
|
}
|
|
150
163
|
|
|
@@ -302,11 +315,14 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
302
315
|
}
|
|
303
316
|
}
|
|
304
317
|
|
|
305
|
-
|
|
306
|
-
func
|
|
307
|
-
func
|
|
308
|
-
func
|
|
309
|
-
func
|
|
318
|
+
// No bitmask flag gates `play` — the mask models pause, not resume.
|
|
319
|
+
func play() { fireRemote("play") { $0.play() } }
|
|
320
|
+
func pause() { fireRemote("pause", requires: kGCKMediaCommandPause) { $0.pause() } }
|
|
321
|
+
func stop() { fireRemote("stop") { $0.stop() } }
|
|
322
|
+
func queueNext() { fireRemote("queueNext", requires: kGCKMediaCommandQueueNext) { $0.queueNextItem() } }
|
|
323
|
+
func queuePrev() {
|
|
324
|
+
fireRemote("queuePrev", requires: kGCKMediaCommandQueuePrevious) { $0.queuePreviousItem() }
|
|
325
|
+
}
|
|
310
326
|
func skipToQueueIndex(index: Int) {
|
|
311
327
|
guard index >= 0 else { return }
|
|
312
328
|
// Resolve the absolute lib index to the receiver's itemId via the full
|
|
@@ -315,13 +331,15 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
315
331
|
// model hasn't synced that slot yet; skip rather than jump blind.
|
|
316
332
|
fireRemote("skipToQueueIndex") { client in
|
|
317
333
|
let itemId = client.mediaQueue.itemID(at: UInt(index))
|
|
318
|
-
guard itemId != kGCKMediaQueueInvalidItemID else { return }
|
|
319
|
-
|
|
334
|
+
guard itemId != kGCKMediaQueueInvalidItemID else { return nil }
|
|
335
|
+
return client.queueJumpToItem(withID: itemId)
|
|
320
336
|
}
|
|
321
337
|
}
|
|
322
338
|
|
|
323
339
|
func setPlaybackRate(_ rate: Float) {
|
|
324
|
-
fireRemote("setPlaybackRate"
|
|
340
|
+
fireRemote("setPlaybackRate", requires: kGCKMediaCommandSetPlaybackRate) {
|
|
341
|
+
$0.setPlaybackRate(rate)
|
|
342
|
+
}
|
|
325
343
|
}
|
|
326
344
|
|
|
327
345
|
func setRepeatMode(_ mode: RemoteRepeatMode) {
|
|
@@ -331,7 +349,7 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
331
349
|
case .single: sdkMode = .single
|
|
332
350
|
case .all: sdkMode = .all
|
|
333
351
|
}
|
|
334
|
-
fireRemote("setRepeatMode") {
|
|
352
|
+
fireRemote("setRepeatMode") { $0.queueSetRepeatMode(sdkMode) }
|
|
335
353
|
}
|
|
336
354
|
|
|
337
355
|
func seekTo(positionMs: Int64) {
|
|
@@ -589,9 +607,21 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
589
607
|
/// `GCKRemoteMediaClient`. Silently skips when the session is closed
|
|
590
608
|
/// or the client is unavailable (log only — transport calls are
|
|
591
609
|
/// fire-and-forget).
|
|
610
|
+
///
|
|
611
|
+
/// `block` returns the `GCKRequest` the SDK scheduled, or `nil` when it
|
|
612
|
+
/// declined to issue one. A returned request gets a delegate so a
|
|
613
|
+
/// receiver-side rejection is reported instead of vanishing — without it
|
|
614
|
+
/// a refused command is indistinguishable from one the receiver honoured.
|
|
615
|
+
///
|
|
616
|
+
/// `requires` names the `GCKMediaStatus` command bitmask flag the receiver
|
|
617
|
+
/// must advertise for this command (`nil` for commands not gated by the
|
|
618
|
+
/// mask). The receiver publishes support per media item, so an item can
|
|
619
|
+
/// accept queue commands while refusing `pause` — worth a warning, since
|
|
620
|
+
/// the command is otherwise dropped in silence.
|
|
592
621
|
private func fireRemote(
|
|
593
622
|
_ label: String,
|
|
594
|
-
|
|
623
|
+
requires command: NSInteger? = nil,
|
|
624
|
+
_ block: @escaping (GCKRemoteMediaClient) -> GCKRequest?
|
|
595
625
|
) {
|
|
596
626
|
if closed { return }
|
|
597
627
|
runOnMain { [weak self] in
|
|
@@ -600,7 +630,28 @@ final class ChromecastSession: NSObject, CastSessionRemotePlayer {
|
|
|
600
630
|
NSLog("[ChromecastSession] \(label): no GCKRemoteMediaClient — session not fully live?")
|
|
601
631
|
return
|
|
602
632
|
}
|
|
603
|
-
|
|
633
|
+
if let command = command,
|
|
634
|
+
let status = client.mediaStatus,
|
|
635
|
+
!status.isMediaCommandSupported(command) {
|
|
636
|
+
NSLog(
|
|
637
|
+
"[ChromecastSession] \(label): receiver does not advertise support for this command; sending anyway"
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
#if DEBUG
|
|
641
|
+
NSLog("[ChromecastSession] \(label): issuing")
|
|
642
|
+
#endif
|
|
643
|
+
// `nil` means this call site declined to issue anything — the only such
|
|
644
|
+
// case is a queue index the receiver's model hasn't synced yet, which is
|
|
645
|
+
// a normal transient, not a fault.
|
|
646
|
+
guard let request = block(client) else {
|
|
647
|
+
#if DEBUG
|
|
648
|
+
NSLog("[ChromecastSession] \(label): no request issued")
|
|
649
|
+
#endif
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
let delegate = TransportRequestDelegate(label: label, owner: self)
|
|
653
|
+
self.retain(delegate)
|
|
654
|
+
request.delegate = delegate
|
|
604
655
|
}
|
|
605
656
|
}
|
|
606
657
|
|
|
@@ -707,6 +758,34 @@ fileprivate final class RequestContinuation: NSObject, GCKRequestDelegate {
|
|
|
707
758
|
}
|
|
708
759
|
}
|
|
709
760
|
|
|
761
|
+
/// Reports the outcome of a fire-and-forget transport request. The caller
|
|
762
|
+
/// doesn't await these, so without a delegate a receiver that refuses a
|
|
763
|
+
/// command leaves no trace at all.
|
|
764
|
+
fileprivate final class TransportRequestDelegate: NSObject, GCKRequestDelegate {
|
|
765
|
+
let label: String
|
|
766
|
+
weak var owner: ChromecastSession?
|
|
767
|
+
|
|
768
|
+
init(label: String, owner: ChromecastSession) {
|
|
769
|
+
self.label = label
|
|
770
|
+
self.owner = owner
|
|
771
|
+
super.init()
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
func requestDidComplete(_ request: GCKRequest) {
|
|
775
|
+
owner?.drop(self)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
func request(_ request: GCKRequest, didFailWithError error: GCKError) {
|
|
779
|
+
NSLog("[ChromecastSession] \(label) failed: \(error.localizedDescription)")
|
|
780
|
+
owner?.drop(self)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
func request(_ request: GCKRequest, didAbortWith abortReason: GCKRequestAbortReason) {
|
|
784
|
+
NSLog("[ChromecastSession] \(label) aborted: \(abortReason.rawValue)")
|
|
785
|
+
owner?.drop(self)
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
710
789
|
/// Bridge `seek(with:)`'s `GCKRequest` into the session's
|
|
711
790
|
/// `onSeekCompleted` listener fan-out. Fire-and-forget — failures are
|
|
712
791
|
/// logged but do not surface as JS errors (the lib has no "seek-
|
|
@@ -45,6 +45,17 @@ protocol CastSessionRemotePlayer: CastSession {
|
|
|
45
45
|
/// Snapshot of most-recent receiver-driven state.
|
|
46
46
|
var lastState: RemotePlaybackStateKind { get }
|
|
47
47
|
|
|
48
|
+
/// `true` once the receiver has reported any state other than `.idle`.
|
|
49
|
+
/// Latches on the first such report and stays set for the session's
|
|
50
|
+
/// lifetime.
|
|
51
|
+
///
|
|
52
|
+
/// State reads use this to decide whether the receiver can answer yet: a
|
|
53
|
+
/// session that has just activated still holds its initial `.idle`, and the
|
|
54
|
+
/// local engine is the better answer until the receiver speaks. Once it
|
|
55
|
+
/// has, the receiver is authoritative — including a later `.idle`, which
|
|
56
|
+
/// means it genuinely has nothing loaded.
|
|
57
|
+
var hasReportedState: Bool { get }
|
|
58
|
+
|
|
48
59
|
/// Most-recent receiver-reported stream position in milliseconds.
|
|
49
60
|
/// `0` before the first status arrives.
|
|
50
61
|
var lastPositionMs: Int64 { get }
|
|
@@ -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
|
|
|
@@ -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
|