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,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 are no-ops;
13
- * the StateFlows are driveable via the public `_`-prefixed backing
14
- * fields; [mediaSessionPlayer] is injectable so bind tests can assert the
15
- * MediaSession swaps to it.
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
 
@@ -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
- func play() { fireRemote("play") { _ = $0.play() } }
306
- func pause() { fireRemote("pause") { _ = $0.pause() } }
307
- func stop() { fireRemote("stop") { _ = $0.stop() } }
308
- func queueNext() { fireRemote("queueNext") { _ = $0.queueNextItem() } }
309
- func queuePrev() { fireRemote("queuePrev") { _ = $0.queuePreviousItem() } }
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
- _ = client.queueJumpToItem(withID: itemId)
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") { _ = $0.setPlaybackRate(rate) }
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") { _ = $0.queueSetRepeatMode(sdkMode) }
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
- _ block: @escaping (GCKRemoteMediaClient) -> Void
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
- block(client)
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