react-native-queue-player 1.0.3 → 1.0.6

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 (103) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +1 -1
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/CarConnectionState.kt +46 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +1 -3
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +2 -4
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +7 -9
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +1 -2
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +33 -25
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +16 -1
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +1 -1
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +1 -1
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +1 -1
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +1 -1
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +40 -27
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +2 -2
  18. package/android/src/main/res/values/strings.xml +0 -3
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +2 -5
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +1 -4
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +1 -2
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +3 -7
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +1 -2
  25. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -3
  26. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +2 -3
  27. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +1 -2
  28. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +0 -2
  29. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +3 -8
  30. package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +2 -4
  31. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
  32. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
  33. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +2 -3
  34. package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +1 -3
  35. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +1 -2
  36. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  37. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +2 -2
  38. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +148 -7
  39. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
  40. package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +1 -3
  41. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
  42. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +1 -2
  43. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -4
  44. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +1 -2
  45. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +2 -3
  46. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +1 -3
  47. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +2 -5
  48. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -2
  49. package/ios/AVPlayerItemQueueItemId.swift +2 -2
  50. package/ios/AVQueueBuilder.swift +12 -14
  51. package/ios/CarPlayCoordinator.swift +38 -0
  52. package/ios/CarPlayQueueBuilder.swift +82 -0
  53. package/ios/CarPlayQueueProvider.swift +73 -0
  54. package/ios/CarPlaySceneDelegate.swift +81 -7
  55. package/ios/CrossfadeEngine.swift +4 -5
  56. package/ios/InputGuards.swift +3 -3
  57. package/ios/LookaheadCache.swift +4 -6
  58. package/ios/LookaheadCachePrefetcher.swift +1 -1
  59. package/ios/MilestoneTracker.swift +1 -2
  60. package/ios/PlaybackErrorMapping.swift +6 -7
  61. package/ios/PlayerStateDerivation.swift +1 -2
  62. package/ios/ProgressEmissionGate.swift +1 -1
  63. package/ios/QueueMutationArithmetic.swift +2 -2
  64. package/ios/QueueSkipArithmetic.swift +8 -12
  65. package/ios/Siri/PlayMediaIntentHandler.swift +1 -5
  66. package/ios/SleepTimerCore.swift +1 -1
  67. package/ios/Tests/AudioTapProviderReplayGainTests.swift +5 -9
  68. package/ios/Tests/CarPlayBridgeTests.swift +2 -6
  69. package/ios/Tests/CarPlayBrowseBuilderTests.swift +1 -2
  70. package/ios/Tests/CarPlayCoordinatorTests.swift +35 -0
  71. package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
  72. package/ios/Tests/EQProcessorTests.swift +1 -4
  73. package/ios/Tests/EQTapTests.swift +1 -2
  74. package/ios/Tests/EqualizerAudioMixProviderTests.swift +3 -4
  75. package/ios/Tests/EqualizerHybridTests.swift +1 -3
  76. package/ios/Tests/FFTProcessorTests.swift +10 -10
  77. package/ios/Tests/GaplessEngineLifecycleTests.swift +0 -2
  78. package/ios/Tests/LookaheadCacheEvictionTests.swift +1 -4
  79. package/ios/Tests/MetadataReaderTests.swift +0 -2
  80. package/ios/Tests/MilestoneTrackerTests.swift +1 -3
  81. package/ios/Tests/NowPlayingFormatExtractorTests.swift +3 -4
  82. package/ios/Tests/PlaybackErrorMappingTests.swift +0 -3
  83. package/ios/Tests/PlaybackModeStateMachineTests.swift +5 -9
  84. package/ios/Tests/PlayerStateDerivationTests.swift +1 -2
  85. package/ios/Tests/ProgressEmissionGateTests.swift +1 -3
  86. package/ios/Tests/QueueMutationArithmeticTests.swift +0 -3
  87. package/ios/Tests/RemoteCommandsTests.swift +2 -5
  88. package/ios/Tests/ReplayGainExtractorTests.swift +0 -5
  89. package/ios/Tests/SkipCapabilityTests.swift +4 -7
  90. package/ios/Tests/VoiceDonationTests.swift +4 -4
  91. package/ios/TrackPlayer.swift +52 -2
  92. package/lib/typescript/TrackPlayer.nitro.d.ts +17 -0
  93. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  94. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  95. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  96. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +4 -0
  97. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
  98. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  99. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
  100. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  101. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  102. package/package.json +1 -1
  103. package/src/TrackPlayer.nitro.ts +18 -0
@@ -16,9 +16,7 @@ import org.robolectric.annotation.Config
16
16
  /**
17
17
  * Wiring coverage for the `Equalizer` HybridObject — engine
18
18
  * selection, JS-facing surface routing through the synchronous
19
- * `*Internal` core, listener registry behaviour. The audible
20
- * effect is verified at the integration layer via demo Maestro
21
- * once the engine is hooked to ExoPlayer's audio session.
19
+ * `*Internal` core, listener registry behaviour.
22
20
  */
23
21
  @RunWith(RobolectricTestRunner::class)
24
22
  @Config(sdk = [34])
@@ -13,9 +13,8 @@ import org.robolectric.annotation.Config
13
13
  * State-mirror coverage for [EqualizerLegacyEngine] on the API <28 path.
14
14
  * Robolectric's audio-effect shadow doesn't model the hardware-side
15
15
  * band centre frequencies + gain range; the band-mapping + dB↔mB
16
- * conversion are the test-reachable invariants. Real audible effect
17
- * verified at user-led API 26 emulator playbook (legacy API
18
- * behaviour varies per OEM).
16
+ * conversion are the invariants this covers. Legacy AudioEffect
17
+ * behaviour varies per OEM.
19
18
  */
20
19
  @RunWith(RobolectricTestRunner::class)
21
20
  @Config(sdk = [27])
@@ -19,8 +19,7 @@ import kotlin.math.sin
19
19
  * Unit coverage for [FFTProcessorTee] — buffer ingestion across PCM
20
20
  * encodings, intervalMs throttling, includeSamples flag, and
21
21
  * multi-channel mixdown. The Tee callback contract is mocked
22
- * directly; integration with Media3's audio chain is verified at the
23
- * Maestro layer (no Robolectric harness for ExoPlayer audio output).
22
+ * directly.
24
23
  */
25
24
  @RunWith(RobolectricTestRunner::class)
26
25
  @Config(sdk = [34])
@@ -15,8 +15,6 @@ import org.robolectric.annotation.Config
15
15
  /**
16
16
  * Lifecycle smoke coverage for [GaplessEngine] — empty-state
17
17
  * sentinels and the idempotent / delegate-clearing release contract.
18
- * Behavioural coverage of transport / queue mutation runs through
19
- * the existing TrackPlayer integration suites + Maestro flows.
20
18
  */
21
19
  @RunWith(RobolectricTestRunner::class)
22
20
  @Config(sdk = [34])
@@ -19,10 +19,8 @@ import org.robolectric.annotation.Config
19
19
  * Real Media3 metadata dispatch is not emulated by Robolectric
20
20
  * without playback, so the listener body composes its work through
21
21
  * the engine-internal `handleReplayGainParse` helper which these
22
- * tests drive directly with synthetic [ReplayGainData]. The
23
- * pure-math piece of the pipeline is covered in [ReplayGainGainTest];
24
- * end-to-end propagation lands in the demo app's `replaygain`
25
- * Maestro suite. This file covers the no-data baseline,
22
+ * tests drive directly with synthetic [ReplayGainData].
23
+ * This file covers the no-data baseline,
26
24
  * mode-change-without-data behaviour, and the
27
25
  * `handleReplayGainParse` cache + recompute contract.
28
26
  */
@@ -147,8 +145,5 @@ class GaplessEngineReplayGainTest {
147
145
 
148
146
  // The cache-clear-on-transition path lives inline in the
149
147
  // `Player.Listener.onMediaItemTransition` body. Robolectric does not
150
- // drive that callback without real playback, so the contract is
151
- // covered at the integration layer by `RNQP-Demo/src/tests/suites/
152
- // replaygain.ts` (steps `cross-track-active-updates` +
153
- // `skip-next-mid-playback-resets-from-stale-cache`).
148
+ // drive that callback without real playback.
154
149
  }
@@ -21,11 +21,9 @@ import org.robolectric.annotation.Config
21
21
  * surface: the default no-task `getTaskConfig` contract and the
22
22
  * process-wide wake-lock helper.
23
23
  *
24
- * The full `onStartCommand` chain (which routes through Media3's
24
+ * The full `onStartCommand` chain routes through Media3's
25
25
  * `MediaLibraryService.onStartCommand` and into native code that
26
- * Robolectric can't satisfy) is exercised end-to-end by
27
- * [PlaybackServiceLifecycleTest] (which subclasses into the new
28
- * `HeadlessJsMediaService` base) plus the Maestro demo cycle.
26
+ * Robolectric can't satisfy, so it is out of scope here.
29
27
  */
30
28
  @RunWith(RobolectricTestRunner::class)
31
29
  @Config(sdk = [34])
@@ -179,6 +179,7 @@ class MediaButtonDispatchTest {
179
179
  override fun onTransportCommand() { transportCalls++ }
180
180
  override fun onSkipToNextCommand(): Boolean { skipNextCmdCalls++; return true }
181
181
  override fun onSkipToPreviousCommand(): Boolean { skipPreviousCmdCalls++; return true }
182
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
182
183
  override fun getCachedSkipCapability(): SkipCapability =
183
184
  SkipCapability(canSkipPrevious = true, canSkipNext = true)
184
185
  override fun onMediaPlay() { playCalls++ }
@@ -0,0 +1,99 @@
1
+ package com.margelo.nitro.queueplayer
2
+
3
+ import android.content.Intent
4
+ import androidx.media3.common.Player
5
+ import androidx.media3.common.util.UnstableApi
6
+ import androidx.media3.session.CommandButton
7
+ import androidx.media3.session.MediaSession
8
+ import androidx.test.core.app.ApplicationProvider
9
+ import org.junit.After
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.Robolectric
16
+ import org.robolectric.RobolectricTestRunner
17
+ import org.robolectric.android.controller.ServiceController
18
+ import org.robolectric.annotation.Config
19
+
20
+ /**
21
+ * Robolectric coverage for the media button preferences the notification
22
+ * compact view + Android Auto control row consume, produced by
23
+ * [PlaybackService.buildMediaButtonPreferences] and read back off the
24
+ * session's public `mediaButtonPreferences`.
25
+ *
26
+ * The lib supplies only the back / forward buttons. Play/pause belongs to
27
+ * Media3's central slot: `DefaultMediaNotificationProvider` synthesizes a
28
+ * state-aware play/pause from `COMMAND_PLAY_PAUSE`, and Android Auto fills
29
+ * the empty central slot the same way — so a lib-supplied `SLOT_CENTRAL`
30
+ * button is dropped on the notification and forces a static, non-toggling
31
+ * icon on Android Auto.
32
+ */
33
+ @RunWith(RobolectricTestRunner::class)
34
+ @Config(sdk = [34])
35
+ @UnstableApi
36
+ class MediaButtonPreferencesTest {
37
+
38
+ private lateinit var controller: ServiceController<PlaybackService>
39
+ private lateinit var service: PlaybackService
40
+ private lateinit var session: MediaSession
41
+
42
+ @Before
43
+ fun setUp() {
44
+ controller = Robolectric.buildService(PlaybackService::class.java).create()
45
+ service = controller.get()
46
+ val intent = Intent(
47
+ ApplicationProvider.getApplicationContext<android.content.Context>(),
48
+ PlaybackService::class.java,
49
+ ).setAction(PlaybackService.ACTION_LOCAL_BIND)
50
+ val binder = service.onBind(intent) as PlaybackService.LocalBinder
51
+ session = binder.session
52
+ }
53
+
54
+ @After
55
+ fun tearDown() {
56
+ controller.destroy()
57
+ }
58
+
59
+ @Test
60
+ fun `track mode supplies previous plus next and leaves play-pause to Media3`() {
61
+ service.applyRemoteControls(RemoteSkipMode.TRACK, 15_000L, 15_000L)
62
+ val prefs = session.mediaButtonPreferences
63
+ assertNoLibPlayPause(prefs)
64
+ assertTrue(
65
+ "previous occupies the back slot",
66
+ prefs.any { it.slots.contains(CommandButton.SLOT_BACK) },
67
+ )
68
+ assertTrue(
69
+ "next occupies the forward slot",
70
+ prefs.any { it.slots.contains(CommandButton.SLOT_FORWARD) },
71
+ )
72
+ }
73
+
74
+ @Test
75
+ fun `interval mode supplies skip-back plus skip-forward and leaves play-pause to Media3`() {
76
+ service.applyRemoteControls(RemoteSkipMode.INTERVAL, 15_000L, 10_000L)
77
+ val prefs = session.mediaButtonPreferences
78
+ assertNoLibPlayPause(prefs)
79
+ assertTrue(
80
+ "skip-back occupies the back slot",
81
+ prefs.any { it.slots.contains(CommandButton.SLOT_BACK) },
82
+ )
83
+ assertTrue(
84
+ "skip-forward occupies the forward slot",
85
+ prefs.any { it.slots.contains(CommandButton.SLOT_FORWARD) },
86
+ )
87
+ }
88
+
89
+ private fun assertNoLibPlayPause(prefs: List<CommandButton>) {
90
+ assertFalse(
91
+ "play/pause is Media3's central-slot button, never supplied by the lib",
92
+ prefs.any { it.playerCommand == Player.COMMAND_PLAY_PAUSE },
93
+ )
94
+ assertFalse(
95
+ "the lib never claims SLOT_CENTRAL — Media3 owns it",
96
+ prefs.any { it.slots.contains(CommandButton.SLOT_CENTRAL) },
97
+ )
98
+ }
99
+ }
@@ -26,11 +26,10 @@ import org.robolectric.annotation.Config
26
26
  * both produce items — the file/remote routing difference is a
27
27
  * DataSource.Factory concern, not a MediaItem concern).
28
28
  *
29
- * Factory-building is exercised by type only (the internal
29
+ * Factory-building is checked by type only (the internal
30
30
  * `DefaultHttpDataSource.Factory` is configured via a builder chain
31
31
  * whose effective state is hard to read back without reflection —
32
- * the factory is wired into ExoPlayer at construction time and
33
- * exercised implicitly through the TrackPlayer lifecycle tests).
32
+ * the factory is wired into ExoPlayer at construction time).
34
33
  *
35
34
  * @Config sdk=34 — Robolectric 4.11.1's max supported SDK.
36
35
  */
@@ -5,9 +5,7 @@ import org.junit.Test
5
5
 
6
6
  /**
7
7
  * Unit tests for [MilestoneTracker] — the pure 25/50/75/90% crossing
8
- * logic behind `onPlaybackMilestone`. Native wiring (progress tick,
9
- * seek, track-change reset) is covered by the `playback-milestones`
10
- * demo suite.
8
+ * logic behind `onPlaybackMilestone`.
11
9
  */
12
10
  class MilestoneTrackerTest {
13
11
 
@@ -7,8 +7,7 @@ import org.junit.Assert.assertEquals
7
7
  import org.junit.Test
8
8
 
9
9
  /**
10
- * Pure-mapping coverage for [NowPlayingFormatExtractor]. The full
11
- * `extract(player:)` path is exercised through demo suites; these
10
+ * Pure-mapping coverage for [NowPlayingFormatExtractor]. These
12
11
  * tests pin the codec / container tables so the JS-visible enum can't
13
12
  * drift without a test failure.
14
13
  */
@@ -17,7 +17,7 @@ import org.robolectric.annotation.Config
17
17
  /**
18
18
  * Routing coverage for [PitchAwareAudioProcessorChain]: which stretcher is
19
19
  * actually stretching for each [PitchCorrectionMode] and speed. The native
20
- * stretch itself is covered on-device; these assert only the mode gate, which
20
+ * stretch itself runs on-device; these assert only the mode gate, which
21
21
  * runs without the native library.
22
22
  *
23
23
  * Note Signalsmith is intentionally ALWAYS active on the float path (so Media3's
@@ -10,8 +10,8 @@ import org.robolectric.annotation.Config
10
10
  * Unit coverage for [PitchCorrection.playbackParameters], the pure mapping
11
11
  * from a [PitchCorrectionMode] + speed to Media3 [androidx.media3.common.PlaybackParameters].
12
12
  *
13
- * Engine wiring (which player the params land on) is covered by the
14
- * auto-suite `pitch-correction.ts` step.
13
+ * Engine wiring (which player the params land on) is out of scope for
14
+ * this pure test.
15
15
  */
16
16
  @RunWith(RobolectricTestRunner::class)
17
17
  @Config(sdk = [34])
@@ -70,6 +70,9 @@ class PlaybackServiceCallbackTest {
70
70
  controllerInfo = MediaSession.ControllerInfo.createTestOnlyControllerInfo(
71
71
  "test-controller", 0, 0, 0, 0, false, android.os.Bundle.EMPTY, false
72
72
  )
73
+ // Process-wide count; reset so headless connect/disconnect assertions
74
+ // start from a known-disconnected state regardless of test order.
75
+ CarConnectionState.reset()
73
76
  }
74
77
 
75
78
  @After
@@ -87,8 +90,7 @@ class PlaybackServiceCallbackTest {
87
90
 
88
91
  // Controller-driven `Player.Command` interception (skip-next /
89
92
  // skip-previous veto, transport `system`-reason stamping) now lives
90
- // on [SessionCommandForwardingPlayer]; its routing is covered in
91
- // SessionCommandForwardingPlayerTest. This suite keeps the
93
+ // on [SessionCommandForwardingPlayer]. This suite keeps the
92
94
  // callback's connect / resumption / available-commands surface.
93
95
 
94
96
  @Test
@@ -270,9 +272,7 @@ class PlaybackServiceCallbackTest {
270
272
  // The per-controller iteration body (session.setAvailableCommands)
271
273
  // requires a live MediaController that has completed handshake,
272
274
  // which is not reachable from Robolectric without standing up a
273
- // full MediaController.Builder.buildAsync() flow. End-to-end
274
- // coverage of the lock-screen-greying behaviour lives in the
275
- // Maestro suites for repeat-mode + queue-boundary scenarios.
275
+ // full MediaController.Builder.buildAsync() flow.
276
276
  }
277
277
 
278
278
  // --- TrackPlayer-side handler implementation
@@ -375,6 +375,76 @@ class PlaybackServiceCallbackTest {
375
375
  }
376
376
  }
377
377
 
378
+ @Test
379
+ fun `TrackPlayer onSeekToMediaItemCommand jumps to the tapped index and stamps reasons`() {
380
+ val player = newConfiguredPlayer()
381
+ try {
382
+ player.tracks = listOf(
383
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
384
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
385
+ TrackItem("c", "https://example.com/c.m4a", null, null, null, null, null, null),
386
+ )
387
+ player.currentTrackIndex = 0
388
+
389
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(2)
390
+
391
+ assertTrue(
392
+ "an Android Auto queue-row tap is queue-aware and reports interception",
393
+ intercepted
394
+ )
395
+ assertEquals(2, player.currentTrackIndex)
396
+ assertEquals(StateChangeReason.SYSTEM, player.pendingStateChangeReason)
397
+ assertEquals(
398
+ TrackChangeReason.USER_SKIP_TO_INDEX, player.pendingTrackChangeReason
399
+ )
400
+ } finally {
401
+ tearDownPlayer(player)
402
+ }
403
+ }
404
+
405
+ @Test
406
+ fun `TrackPlayer onSeekToMediaItemCommand declines an out-of-range index and leaves the current index`() {
407
+ val player = newConfiguredPlayer()
408
+ try {
409
+ player.tracks = listOf(
410
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
411
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
412
+ )
413
+ player.currentTrackIndex = 1
414
+
415
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(5)
416
+
417
+ assertFalse(
418
+ "an out-of-range tap is declined so the default routing no-ops it",
419
+ intercepted
420
+ )
421
+ assertEquals(1, player.currentTrackIndex)
422
+ } finally {
423
+ tearDownPlayer(player)
424
+ }
425
+ }
426
+
427
+ @Test
428
+ fun `TrackPlayer onSeekToMediaItemCommand on the current index is handled and keeps the index`() {
429
+ val player = newConfiguredPlayer()
430
+ try {
431
+ player.tracks = listOf(
432
+ TrackItem("a", "https://example.com/a.m4a", null, null, null, null, null, null),
433
+ TrackItem("b", "https://example.com/b.m4a", null, null, null, null, null, null),
434
+ )
435
+ player.currentTrackIndex = 1
436
+
437
+ val intercepted = player.mediaSessionCommandHandler.onSeekToMediaItemCommand(1)
438
+
439
+ // Tapping the playing row behaves like skipToIndex(current): handled,
440
+ // index unchanged, the engine restarts the track from the top.
441
+ assertTrue(intercepted)
442
+ assertEquals(1, player.currentTrackIndex)
443
+ } finally {
444
+ tearDownPlayer(player)
445
+ }
446
+ }
447
+
378
448
  // --- Voice / browse-leaf routing through onAddMediaItems / onSetMediaItems
379
449
 
380
450
  @Test
@@ -615,6 +685,76 @@ class PlaybackServiceCallbackTest {
615
685
  assertEquals(0, fakeEnv.headlessConnectPackages.size)
616
686
  }
617
687
 
688
+ // --- isCarConnected: the process-wide flag TrackPlayer.isCarConnected() reads
689
+
690
+ @Test
691
+ fun `CarConnectionState is disconnected with no controller attached`() {
692
+ assertFalse(CarConnectionState.isConnected())
693
+ }
694
+
695
+ @Test
696
+ fun `headless connect marks connected, last disconnect marks disconnected`() {
697
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
698
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
699
+ cb.onConnect(session, gearhead)
700
+ assertTrue("connected after the first headless controller", CarConnectionState.isConnected())
701
+ cb.onDisconnected(session, gearhead)
702
+ assertFalse("disconnected after the last controller drops", CarConnectionState.isConnected())
703
+ }
704
+
705
+ @Test
706
+ fun `stays connected while any headless controller remains`() {
707
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
708
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
709
+ val wear = controllerFor("com.google.android.wearable.app")
710
+ cb.onConnect(session, gearhead)
711
+ cb.onConnect(session, wear)
712
+ cb.onDisconnected(session, gearhead)
713
+ assertTrue("still connected with one controller remaining", CarConnectionState.isConnected())
714
+ cb.onDisconnected(session, wear)
715
+ assertFalse("disconnected once the last controller drops", CarConnectionState.isConnected())
716
+ }
717
+
718
+ @Test
719
+ fun `non-headless controller leaves the flag disconnected`() {
720
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
721
+ val phone = controllerFor("com.example.regular.app")
722
+ cb.onConnect(session, phone)
723
+ assertFalse(CarConnectionState.isConnected())
724
+ cb.onDisconnected(session, phone)
725
+ assertFalse(CarConnectionState.isConnected())
726
+ }
727
+
728
+ @Test
729
+ fun `untracked headless disconnect does not mark connected`() {
730
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
731
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
732
+ // A stray disconnect for a controller that never connected (service-
733
+ // restart edge) must not mark connected; the count floors at zero.
734
+ cb.onDisconnected(session, gearhead)
735
+ assertFalse(CarConnectionState.isConnected())
736
+ }
737
+
738
+ @Test
739
+ fun `a stray disconnect floors the count so a later connect still reads connected`() {
740
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
741
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
742
+ cb.onConnect(session, gearhead)
743
+ cb.onDisconnected(session, gearhead)
744
+ // Extra (stray) disconnect: the count must floor at zero, not go negative.
745
+ cb.onDisconnected(session, gearhead)
746
+ assertFalse("disconnected after the real drop", CarConnectionState.isConnected())
747
+ // If the stray disconnect had driven the count to -1, this connect would
748
+ // land it back at 0 and read disconnected.
749
+ cb.onConnect(session, gearhead)
750
+ assertTrue("connected again after a fresh attach", CarConnectionState.isConnected())
751
+ }
752
+
753
+ private fun controllerFor(packageName: String): MediaSession.ControllerInfo =
754
+ MediaSession.ControllerInfo.createTestOnlyControllerInfo(
755
+ packageName, 0, 0, 0, 0, false, android.os.Bundle.EMPTY, false
756
+ )
757
+
618
758
  @Test
619
759
  fun `becoming-noisy receiver unregisters in onDestroy`() {
620
760
  val handler = RecordingHandler()
@@ -689,10 +829,11 @@ class PlaybackServiceCallbackTest {
689
829
  return skipPreviousHandled
690
830
  }
691
831
 
832
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
833
+
692
834
  override fun getCachedSkipCapability(): SkipCapability = capability
693
835
 
694
- // Raw-key paths are exercised in MediaButtonDispatchTest; this
695
- // suite covers the controller-driven callback routing only.
836
+ // This suite covers the controller-driven callback routing only.
696
837
  override fun onMediaPlay() {}
697
838
  override fun onMediaPause() {}
698
839
  override fun onMediaTogglePlayPause() {}
@@ -300,6 +300,8 @@ class PlaybackServiceLifecycleTest {
300
300
  return true
301
301
  }
302
302
 
303
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean = false
304
+
303
305
  override fun getCachedSkipCapability(): SkipCapability =
304
306
  SkipCapability(canSkipPrevious = true, canSkipNext = true)
305
307
 
@@ -6,9 +6,7 @@ import org.junit.Test
6
6
 
7
7
  /**
8
8
  * Unit tests for [ProgressEmissionGate] — the pure throttle behind the
9
- * configurable / background-reduced `onProgress` cadence. Native wiring (the
10
- * progress tick + the ProcessLifecycleOwner observer) is covered by
11
- * [TrackPlayerProgressThrottleTest] and the progress demo suites.
9
+ * configurable / background-reduced `onProgress` cadence.
12
10
  *
13
11
  * Most tests pass `samplerPeriodMs = 0.0` to exercise exact interval semantics;
14
12
  * the jitter + quantization tests pass the production `500` to verify the
@@ -135,6 +135,37 @@ class SessionCommandForwardingPlayerTest {
135
135
  }
136
136
  }
137
137
 
138
+ // --- Controller seek-to-queue-item (Android Auto queue-row tap): queue-aware veto
139
+ // (COMMAND_SEEK_TO_MEDIA_ITEM routes through onSeekToMediaItemCommand rather than
140
+ // the raw per-item seek, so the lib's current index + track-change reason land.)
141
+
142
+ @Test
143
+ fun `controller seek-to-queue-item runs queue-aware skip and vetoes the default seek when handled`() {
144
+ val wrapped = RecordingPlayer()
145
+ val handler = RecordingHandler(seekToMediaItemHandled = true)
146
+ val wrapper = newWrapper(wrapped, handler)
147
+
148
+ asController { wrapper.handleSeek(2, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
149
+
150
+ assertEquals("seek-to-queue-item routes into onSeekToMediaItemCommand", 1, handler.seekToMediaItemCalls)
151
+ assertEquals("the tapped queue index is forwarded", 2, handler.lastSeekToMediaItemIndex)
152
+ assertTrue("a handled queue tap vetoes the default seek", wrapped.seekCommands.isEmpty())
153
+ assertEquals(0, handler.transportCalls)
154
+ }
155
+
156
+ @Test
157
+ fun `controller seek-to-queue-item falls through to the default seek when not handled`() {
158
+ val wrapped = RecordingPlayer()
159
+ val handler = RecordingHandler(seekToMediaItemHandled = false)
160
+ val wrapper = newWrapper(wrapped, handler)
161
+
162
+ asController { wrapper.handleSeek(2, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
163
+
164
+ assertEquals(1, handler.seekToMediaItemCalls)
165
+ assertEquals("a declined queue tap delegates one seek to the wrapped player",
166
+ listOf(Player.COMMAND_SEEK_TO_MEDIA_ITEM), wrapped.seekCommands)
167
+ }
168
+
138
169
  // --- Controller transport seeks: stamp system then delegate
139
170
  // (SEEK_BACK / SEEK_FORWARD are recomputed to the runtime interval — see the
140
171
  // interval-skip tests above — so they are not part of this pass-through set.)
@@ -144,7 +175,6 @@ class SessionCommandForwardingPlayerTest {
144
175
  val transportSeeks = listOf(
145
176
  Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
146
177
  Player.COMMAND_SEEK_TO_DEFAULT_POSITION,
147
- Player.COMMAND_SEEK_TO_MEDIA_ITEM,
148
178
  )
149
179
  for (command in transportSeeks) {
150
180
  val wrapped = RecordingPlayer()
@@ -319,6 +349,21 @@ class SessionCommandForwardingPlayerTest {
319
349
  }
320
350
  }
321
351
 
352
+ @Test
353
+ fun `non-controller seek-to-queue-item delegates straight through without consulting the handler`() {
354
+ val wrapped = RecordingPlayer()
355
+ val handler = RecordingHandler(seekToMediaItemHandled = true)
356
+ val wrapper = newWrapper(wrapped, handler)
357
+
358
+ // No controller-request scope: models the JS bridge / engine driving
359
+ // the player directly, which must never re-enter the skip path.
360
+ wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM)
361
+
362
+ assertEquals("internal seek never consults onSeekToMediaItemCommand", 0, handler.seekToMediaItemCalls)
363
+ assertEquals("internal seek delegates straight to the wrapped player",
364
+ listOf(Player.COMMAND_SEEK_TO_MEDIA_ITEM), wrapped.seekCommands)
365
+ }
366
+
322
367
  @Test
323
368
  fun `non-controller transport delegates straight through without consulting the handler`() {
324
369
  val wrapped = RecordingPlayer()
@@ -344,16 +389,18 @@ class SessionCommandForwardingPlayerTest {
344
389
 
345
390
  asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_NEXT) }
346
391
  asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_PREVIOUS) }
392
+ asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_TO_MEDIA_ITEM) }
347
393
  asController { wrapper.handleSeek(C_INDEX, 0L, Player.COMMAND_SEEK_BACK) }
348
394
  asController { wrapper.handleSetPlayWhenReady(true) }
349
395
  asController { wrapper.handleSetPlaybackParameters(PlaybackParameters(1.25f)) }
350
396
 
351
- // With no handler the skip cannot be vetoed, so all five delegate.
397
+ // With no handler the skip / queue-tap cannot be vetoed, so all delegate.
352
398
  // SEEK_BACK is recomputed to the interval and forwarded as an in-item seek.
353
399
  assertEquals(
354
400
  listOf(
355
401
  Player.COMMAND_SEEK_TO_NEXT,
356
402
  Player.COMMAND_SEEK_TO_PREVIOUS,
403
+ Player.COMMAND_SEEK_TO_MEDIA_ITEM,
357
404
  Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
358
405
  ),
359
406
  wrapped.seekCommands
@@ -482,10 +529,13 @@ class SessionCommandForwardingPlayerTest {
482
529
  private class RecordingHandler(
483
530
  private val skipNextHandled: Boolean = true,
484
531
  private val skipPreviousHandled: Boolean = true,
532
+ private val seekToMediaItemHandled: Boolean = true,
485
533
  ) : PlaybackService.MediaSessionCommandHandler {
486
534
  var transportCalls = 0
487
535
  var skipNextCalls = 0
488
536
  var skipPreviousCalls = 0
537
+ var seekToMediaItemCalls = 0
538
+ var lastSeekToMediaItemIndex = Int.MIN_VALUE
489
539
 
490
540
  override fun onTransportCommand() {
491
541
  transportCalls++
@@ -501,6 +551,12 @@ class SessionCommandForwardingPlayerTest {
501
551
  return skipPreviousHandled
502
552
  }
503
553
 
554
+ override fun onSeekToMediaItemCommand(mediaItemIndex: Int): Boolean {
555
+ seekToMediaItemCalls++
556
+ lastSeekToMediaItemIndex = mediaItemIndex
557
+ return seekToMediaItemHandled
558
+ }
559
+
504
560
  override fun getCachedSkipCapability(): SkipCapability =
505
561
  SkipCapability(canSkipPrevious = true, canSkipNext = true)
506
562
 
@@ -348,8 +348,7 @@ class TrackPlayerEventsTest {
348
348
  //
349
349
  // The measured silence gap rides on onTrackChange's 4th arg only for a
350
350
  // natural gapless auto-advance. Every other transition carries `null`.
351
- // The positive numeric value (a nanoTime delta) is a native measurement
352
- // verified on-device by the `gapless-album` demo suite.
351
+ // The positive numeric value is a native nanoTime delta.
353
352
 
354
353
  @Test
355
354
  fun `handleMediaItemTransition PLAYLIST_CHANGED reports null transitionGapMs`() {
@@ -20,10 +20,8 @@ import org.robolectric.annotation.Config
20
20
  * ExoPlayer timeline synchronisation (`mediaItemCount`,
21
21
  * `currentMediaItemIndex` where applicable).
22
22
  *
23
- * Arithmetic edge cases are covered more exhaustively in
24
- * [QueueMutationArithmeticTest]; these tests focus on wiring
25
- * correctness — that the arithmetic result is actually applied
26
- * to both state structures in concert.
23
+ * These tests focus on wiring correctness that the arithmetic
24
+ * result is actually applied to both state structures in concert.
27
25
  */
28
26
  @RunWith(RobolectricTestRunner::class)
29
27
  @Config(sdk = [34])
@@ -19,8 +19,7 @@ import org.robolectric.annotation.Config
19
19
  * `initHybrid()` → JNI which the Robolectric runtime doesn't load. This
20
20
  * file pins the default and the synchronous read path
21
21
  * ([TrackPlayer.getPitchCorrectionMode]) against the canonical state
22
- * field; end-to-end Promise dispatch + engine wiring is exercised through
23
- * the demo `pitch-correction` suite.
22
+ * field.
24
23
  */
25
24
  @RunWith(RobolectricTestRunner::class)
26
25
  @Config(sdk = [34])
@@ -9,9 +9,8 @@ import org.robolectric.annotation.Config
9
9
 
10
10
  /**
11
11
  * `setPlaybackMode` / `getPlaybackMode` imperative spec coverage —
12
- * the engine-swap path is exercised at the integration layer
13
- * (Maestro 1+1 cycle) since Robolectric can't fully bind the
14
- * service + ExoPlayer to verify a real swap.
12
+ * the engine-swap path can't be fully bound under Robolectric, which
13
+ * can't stand up the service + ExoPlayer to verify a real swap.
15
14
  */
16
15
  @RunWith(RobolectricTestRunner::class)
17
16
  @Config(sdk = [34])
@@ -20,9 +20,7 @@ import org.robolectric.annotation.Config
20
20
  * [ReplayGainConfig.setMode] then asks the active engine to
21
21
  * recompute; this file pins the synchronous read path
22
22
  * ([TrackPlayer.getReplayGainMode]) round-trips with
23
- * [ReplayGainConfig], and the mode write is itself covered by
24
- * [ReplayGainConfigTest]. End-to-end Promise dispatch is exercised
25
- * through the demo `replaygain` Maestro suite.
23
+ * [ReplayGainConfig].
26
24
  */
27
25
  @RunWith(RobolectricTestRunner::class)
28
26
  @Config(sdk = [34])