react-native-queue-player 1.0.2 → 1.0.4

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 (99) hide show
  1. package/android/src/main/cpp/CMakeLists.txt +11 -0
  2. package/android/src/main/cpp/airplay_jni.cpp +5 -2
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +1 -1
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/CarConnectionState.kt +46 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +1 -3
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +2 -4
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +7 -9
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +1 -2
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +5 -6
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +8 -1
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +1 -1
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +1 -1
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +1 -1
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +29 -6
  16. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +51 -27
  17. package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +2 -2
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +2 -5
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +1 -4
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +1 -2
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +3 -7
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +1 -2
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -3
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +2 -3
  25. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +1 -2
  26. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +0 -2
  27. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +3 -8
  28. package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +2 -4
  29. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +2 -3
  30. package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +1 -3
  31. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +1 -2
  32. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  33. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +2 -2
  34. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +76 -7
  35. package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +1 -3
  36. package/android/src/test/java/com/margelo/nitro/queueplayer/SleepTimerCoreTest.kt +41 -0
  37. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +1 -2
  38. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -4
  39. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +1 -2
  40. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +2 -3
  41. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +1 -3
  42. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +2 -5
  43. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -2
  44. package/ios/AVPlayerItemQueueItemId.swift +2 -2
  45. package/ios/AVQueueBuilder.swift +12 -14
  46. package/ios/AudioCategoryMapping.swift +1 -2
  47. package/ios/CarPlayCoordinator.swift +28 -0
  48. package/ios/CarPlaySceneDelegate.swift +2 -0
  49. package/ios/CrossfadeEngine.swift +4 -5
  50. package/ios/InputGuards.swift +3 -3
  51. package/ios/LookaheadCache.swift +4 -6
  52. package/ios/LookaheadCachePrefetcher.swift +1 -1
  53. package/ios/MilestoneTracker.swift +1 -2
  54. package/ios/PlaybackErrorMapping.swift +6 -7
  55. package/ios/PlayerStateDerivation.swift +1 -2
  56. package/ios/ProgressEmissionGate.swift +1 -1
  57. package/ios/QueueMutationArithmetic.swift +2 -2
  58. package/ios/QueueSkipArithmetic.swift +8 -12
  59. package/ios/Siri/PlayMediaIntentHandler.swift +1 -5
  60. package/ios/SleepTimerCore.swift +26 -6
  61. package/ios/Tests/AudioTapProviderReplayGainTests.swift +5 -9
  62. package/ios/Tests/CarPlayBridgeTests.swift +2 -6
  63. package/ios/Tests/CarPlayBrowseBuilderTests.swift +1 -2
  64. package/ios/Tests/CarPlayCoordinatorTests.swift +35 -0
  65. package/ios/Tests/EQProcessorTests.swift +1 -4
  66. package/ios/Tests/EQTapTests.swift +1 -2
  67. package/ios/Tests/EqualizerAudioMixProviderTests.swift +3 -4
  68. package/ios/Tests/EqualizerHybridTests.swift +1 -3
  69. package/ios/Tests/FFTProcessorTests.swift +9 -8
  70. package/ios/Tests/GaplessEngineLifecycleTests.swift +0 -2
  71. package/ios/Tests/LookaheadCacheEvictionTests.swift +1 -4
  72. package/ios/Tests/MetadataReaderTests.swift +0 -2
  73. package/ios/Tests/MilestoneTrackerTests.swift +1 -3
  74. package/ios/Tests/NowPlayingFormatExtractorTests.swift +3 -4
  75. package/ios/Tests/PlaybackErrorMappingTests.swift +0 -3
  76. package/ios/Tests/PlaybackModeStateMachineTests.swift +5 -9
  77. package/ios/Tests/PlayerStateDerivationTests.swift +1 -2
  78. package/ios/Tests/ProgressEmissionGateTests.swift +1 -3
  79. package/ios/Tests/QueueMutationArithmeticTests.swift +0 -3
  80. package/ios/Tests/RemoteCommandsTests.swift +2 -5
  81. package/ios/Tests/ReplayGainExtractorTests.swift +0 -5
  82. package/ios/Tests/SkipCapabilityTests.swift +4 -7
  83. package/ios/Tests/SleepTimerCoreTests.swift +39 -0
  84. package/ios/Tests/VoiceDonationTests.swift +4 -4
  85. package/ios/TrackPlayer.swift +47 -22
  86. package/lib/typescript/TrackPlayer.nitro.d.ts +17 -0
  87. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  88. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  89. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +4 -0
  91. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
  92. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  93. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
  94. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  95. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  96. package/package.json +1 -1
  97. package/src/TrackPlayer.nitro.ts +18 -0
  98. package/ios/InterruptionEventMapping.swift +0 -51
  99. package/ios/Tests/InterruptionEventMappingTests.swift +0 -91
@@ -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])
@@ -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
@@ -615,6 +615,76 @@ class PlaybackServiceCallbackTest {
615
615
  assertEquals(0, fakeEnv.headlessConnectPackages.size)
616
616
  }
617
617
 
618
+ // --- isCarConnected: the process-wide flag TrackPlayer.isCarConnected() reads
619
+
620
+ @Test
621
+ fun `CarConnectionState is disconnected with no controller attached`() {
622
+ assertFalse(CarConnectionState.isConnected())
623
+ }
624
+
625
+ @Test
626
+ fun `headless connect marks connected, last disconnect marks disconnected`() {
627
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
628
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
629
+ cb.onConnect(session, gearhead)
630
+ assertTrue("connected after the first headless controller", CarConnectionState.isConnected())
631
+ cb.onDisconnected(session, gearhead)
632
+ assertFalse("disconnected after the last controller drops", CarConnectionState.isConnected())
633
+ }
634
+
635
+ @Test
636
+ fun `stays connected while any headless controller remains`() {
637
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
638
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
639
+ val wear = controllerFor("com.google.android.wearable.app")
640
+ cb.onConnect(session, gearhead)
641
+ cb.onConnect(session, wear)
642
+ cb.onDisconnected(session, gearhead)
643
+ assertTrue("still connected with one controller remaining", CarConnectionState.isConnected())
644
+ cb.onDisconnected(session, wear)
645
+ assertFalse("disconnected once the last controller drops", CarConnectionState.isConnected())
646
+ }
647
+
648
+ @Test
649
+ fun `non-headless controller leaves the flag disconnected`() {
650
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
651
+ val phone = controllerFor("com.example.regular.app")
652
+ cb.onConnect(session, phone)
653
+ assertFalse(CarConnectionState.isConnected())
654
+ cb.onDisconnected(session, phone)
655
+ assertFalse(CarConnectionState.isConnected())
656
+ }
657
+
658
+ @Test
659
+ fun `untracked headless disconnect does not mark connected`() {
660
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
661
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
662
+ // A stray disconnect for a controller that never connected (service-
663
+ // restart edge) must not mark connected; the count floors at zero.
664
+ cb.onDisconnected(session, gearhead)
665
+ assertFalse(CarConnectionState.isConnected())
666
+ }
667
+
668
+ @Test
669
+ fun `a stray disconnect floors the count so a later connect still reads connected`() {
670
+ val cb = PlaybackServiceCallback(RecordingEnvironment(service))
671
+ val gearhead = controllerFor("com.google.android.projection.gearhead")
672
+ cb.onConnect(session, gearhead)
673
+ cb.onDisconnected(session, gearhead)
674
+ // Extra (stray) disconnect: the count must floor at zero, not go negative.
675
+ cb.onDisconnected(session, gearhead)
676
+ assertFalse("disconnected after the real drop", CarConnectionState.isConnected())
677
+ // If the stray disconnect had driven the count to -1, this connect would
678
+ // land it back at 0 and read disconnected.
679
+ cb.onConnect(session, gearhead)
680
+ assertTrue("connected again after a fresh attach", CarConnectionState.isConnected())
681
+ }
682
+
683
+ private fun controllerFor(packageName: String): MediaSession.ControllerInfo =
684
+ MediaSession.ControllerInfo.createTestOnlyControllerInfo(
685
+ packageName, 0, 0, 0, 0, false, android.os.Bundle.EMPTY, false
686
+ )
687
+
618
688
  @Test
619
689
  fun `becoming-noisy receiver unregisters in onDestroy`() {
620
690
  val handler = RecordingHandler()
@@ -691,8 +761,7 @@ class PlaybackServiceCallbackTest {
691
761
 
692
762
  override fun getCachedSkipCapability(): SkipCapability = capability
693
763
 
694
- // Raw-key paths are exercised in MediaButtonDispatchTest; this
695
- // suite covers the controller-driven callback routing only.
764
+ // This suite covers the controller-driven callback routing only.
696
765
  override fun onMediaPlay() {}
697
766
  override fun onMediaPause() {}
698
767
  override fun onMediaTogglePlayPause() {}
@@ -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
@@ -111,6 +111,8 @@ class SleepTimerCoreTest {
111
111
  assertTrue(converted.stateChanged)
112
112
  assertFalse(t.isEndOfTrack)
113
113
  assertTrue(t.isActive)
114
+ // Deadline lands AT the boundary (tail=50s), not 1s past it.
115
+ assertEquals(2000L + 50_000L, t.deadlineEpochMs)
114
116
  }
115
117
 
116
118
  @Test
@@ -122,4 +124,43 @@ class SleepTimerCoreTest {
122
124
  assertNull(t.deadlineEpochMs)
123
125
  assertNull(t.remainingSeconds(0))
124
126
  }
127
+
128
+ @Test
129
+ fun `end-of-track fires a pause at the track-end signal`() {
130
+ val t = SleepTimerCore()
131
+ t.armEndOfTrack()
132
+ // Fires at the real track-end signal, before any tail conversion and even
133
+ // when the duration was never known (never ticked).
134
+ val fired = t.fireAtTrackEnd()
135
+ assertTrue(fired.pauseNow)
136
+ assertTrue(fired.stateChanged)
137
+ assertFalse(t.isActive)
138
+ }
139
+
140
+ @Test
141
+ fun `end-of-track signal fires after tail conversion`() {
142
+ val t = SleepTimerCore()
143
+ t.armEndOfTrack()
144
+ t.tick(1000, trackDurationSec = 200.0, trackPositionSec = 150.0) // converts to fade
145
+ val fired = t.fireAtTrackEnd()
146
+ assertTrue(fired.pauseNow)
147
+ assertFalse(t.isActive)
148
+ }
149
+
150
+ @Test
151
+ fun `fireAtTrackEnd is idempotent`() {
152
+ val t = SleepTimerCore()
153
+ t.armEndOfTrack()
154
+ t.fireAtTrackEnd()
155
+ assertFalse(t.fireAtTrackEnd().pauseNow)
156
+ }
157
+
158
+ @Test
159
+ fun `fireAtTrackEnd ignores a duration timer`() {
160
+ val t = SleepTimerCore()
161
+ t.armDuration(seconds = 60.0, nowMs = 0)
162
+ val fired = t.fireAtTrackEnd()
163
+ assertFalse(fired.pauseNow)
164
+ assertTrue(t.isActive)
165
+ }
125
166
  }
@@ -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])
@@ -155,8 +155,7 @@ class TrackPlayerTransportTest {
155
155
  fun `setPlaybackSpeedInternal round-trips through getPlaybackSpeed and applies the pitch mode`() {
156
156
  // Pitch is lib-owned, driven by the pitch-correction mode. Under the
157
157
  // default voice mode the engine holds pitch at 1.0 while the speed
158
- // changes (pitch preserved). The full none/voice/music mapping is
159
- // covered by PitchCorrectionTest.
158
+ // changes (pitch preserved).
160
159
  player.setPlaybackSpeedInternal(1.5)
161
160
  assertEquals(1.5, player.getPlaybackSpeed(), 0.001)
162
161
  assertEquals(1.0f, player.player!!.playbackParameters.pitch, 0.001f)
@@ -194,9 +193,7 @@ class TrackPlayerTransportTest {
194
193
  assertEquals(RepeatMode.TRACK, player.repeatModeState)
195
194
  }
196
195
 
197
- // shuffleQueue() is covered end-to-end via the demo suite
198
- // shuffle.ts steps and by exercising shuffleQueueInternal in the
199
- // sibling tests below.
196
+ // The sibling tests below exercise shuffleQueueInternal.
200
197
 
201
198
  // --- basic play/pause/stop no-op
202
199
 
@@ -19,8 +19,7 @@ import java.util.concurrent.CopyOnWriteArrayList
19
19
  /**
20
20
  * Wiring coverage for the [Visualizer] HybridObject — subscriber
21
21
  * refcount, attach/detach lifecycle against a [PlaybackEngine],
22
- * engine swap behaviour. Audio-thread frame emission is covered by
23
- * [FFTProcessorTeeTest]; this test exercises only the Hybrid surface
22
+ * engine swap behaviour. This test exercises only the Hybrid surface
24
23
  * and the PlaybackEngine attach contract.
25
24
  */
26
25
  @RunWith(RobolectricTestRunner::class)
@@ -31,8 +31,8 @@ extension AVPlayerItem {
31
31
  /// Lib-internal identity for this AVPlayerItem. Set once at
32
32
  /// construction by `AVQueueBuilder.makeItem`; never mutated
33
33
  /// after. nil only for AVPlayerItems that did not flow through
34
- /// the lib's queue construction path (e.g. raw makeItemRaw
35
- /// XCTest fixtures).
34
+ /// the lib's queue construction path (e.g. built directly via
35
+ /// `makeItemRaw`).
36
36
  var queueItemId: String? {
37
37
  get { objc_getAssociatedObject(self, &queueItemIdKey) as? String }
38
38
  set {
@@ -3,13 +3,11 @@ import AVFoundation
3
3
  /// Builder utility for `[AVPlayerItem]` arrays, with HTTP-header +
4
4
  /// user-agent injection on remote URLs.
5
5
  ///
6
- /// The enum is `public` only so XCTest targets can reach the `Raw`-suffix
7
- /// helpers through plain `import QueuePlayer` (see NOTES.md §6 for the
8
- /// Swift C++ interop reason we can't rely on `@testable import`).
9
- /// The `Raw` helpers themselves are gated behind `@_spi(QueuePlayerTests)`
10
- /// so consumers who don't opt into the SPI never see them — the API
11
- /// surface consumers care about is the Nitrogen-integrated TrackPlayer
12
- /// methods, not this builder directly.
6
+ /// The `Raw`-suffix helpers take primitive arguments so they stay clear
7
+ /// of the Swift C++ interop boundary. They are gated behind
8
+ /// `@_spi(QueuePlayerTests)` so consumers who don't opt into the SPI
9
+ /// never see them the API surface consumers care about is the
10
+ /// Nitrogen-integrated TrackPlayer methods, not this builder directly.
13
11
  ///
14
12
  /// Constructs AVPlayerItem instances from TrackItem descriptors,
15
13
  /// applying the PlayerConfig's custom HTTP headers + user agent.
@@ -135,10 +133,10 @@ public enum AVQueueBuilder {
135
133
  item.preferredForwardBufferDuration = 0.0
136
134
  }
137
135
 
138
- /// Plain-Swift item builder. Takes primitive arguments so it is directly
139
- /// callable from XCTest targets without crossing the Swift ↔ C++ interop
140
- /// boundary; `makeItem(for:, config:)` is the thin adapter that unpacks
141
- /// the Nitrogen-generated `TrackItem` + `PlayerConfig` and delegates here.
136
+ /// Plain-Swift item builder. Takes primitive arguments so it stays clear
137
+ /// of the Swift ↔ C++ interop boundary; `makeItem(for:, config:)` is the
138
+ /// thin adapter that unpacks the Nitrogen-generated `TrackItem` +
139
+ /// `PlayerConfig` and delegates here.
142
140
  ///
143
141
  /// Gated behind `@_spi(QueuePlayerTests)` so it stays off the library's
144
142
  /// consumer-facing public surface.
@@ -171,9 +169,9 @@ public enum AVQueueBuilder {
171
169
  return item
172
170
  }
173
171
 
174
- /// Plain-Swift merging core — takes primitive arguments so it is
175
- /// directly callable from XCTest targets without crossing the Swift↔C++
176
- /// interop boundary. `makeItemRaw(urlString:headers:userAgent:)` below
172
+ /// Plain-Swift merging core — takes primitive arguments so it stays
173
+ /// clear of the Swift↔C++ interop boundary.
174
+ /// `makeItemRaw(urlString:headers:userAgent:)` below
177
175
  /// calls this, and `makeItem(for:, config:)` unpacks its `PlayerConfig`
178
176
  /// argument into the same primitive pair and calls `makeItemRaw`.
179
177
  ///
@@ -4,8 +4,7 @@ import AVFoundation
4
4
  /// to the AVAudioSession `Mode` + `CategoryOptions` used during
5
5
  /// `AudioSession.activate()`.
6
6
  ///
7
- /// Pure function; no instance state. Mirrors `PlaybackErrorMapping`
8
- /// + `InterruptionEventMapping`.
7
+ /// Pure function; no instance state. Mirrors `PlaybackErrorMapping`.
9
8
  ///
10
9
  /// `audioContentType == .speech` triggers two effects:
11
10
  /// 1. AVAudioSession mode flips to `.spokenAudio` (per Apple HIG —
@@ -39,4 +39,32 @@ final class CarPlayCoordinator {
39
39
  self.pending = PendingBrowseRequests()
40
40
  self.loader = ArtworkLoader()
41
41
  }
42
+
43
+ // MARK: - Connection state
44
+
45
+ /// Whether the CarPlay scene is currently connected. Written by
46
+ /// [CarPlaySceneDelegate] at scene attach / detach — the same points that
47
+ /// fire the `onCarConnect` / `onCarDisconnect` callbacks — and read by the
48
+ /// `isCarConnected()` Hybrid getter. Scene callbacks land on the main
49
+ /// queue; the getter can be read from any Nitro thread, so the flag is
50
+ /// lock-guarded.
51
+ ///
52
+ /// A media app has a single CarPlay template scene, so this is a plain
53
+ /// attached/detached flag rather than a connection count (the Android side
54
+ /// counts because its headless controllers — Auto / Wear / Assistant — can
55
+ /// overlap).
56
+ private let connectionLock = NSLock()
57
+ private var carConnectedFlag = false
58
+
59
+ var isCarConnected: Bool {
60
+ connectionLock.lock()
61
+ defer { connectionLock.unlock() }
62
+ return carConnectedFlag
63
+ }
64
+
65
+ func setCarConnected(_ connected: Bool) {
66
+ connectionLock.lock()
67
+ defer { connectionLock.unlock() }
68
+ carConnectedFlag = connected
69
+ }
42
70
  }
@@ -122,6 +122,7 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
122
122
  self?.refreshRootTemplate()
123
123
  }
124
124
 
125
+ coordinator.setCarConnected(true)
125
126
  coordinator.bridge.carConnectCallback()?()
126
127
  }
127
128
 
@@ -139,6 +140,7 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
139
140
  }
140
141
  self.interfaceController = nil
141
142
  self.rootTabBar = nil
143
+ CarPlayCoordinator.shared.setCarConnected(false)
142
144
  CarPlayCoordinator.shared.bridge.carDisconnectCallback()?()
143
145
  // Pending entries from this scene's session are stale — the
144
146
  // next scene attach starts from a fresh snapshot read, so
@@ -32,11 +32,10 @@ import Foundation
32
32
  /// hard-cuts (no fade) for that transition. Mid-fade route
33
33
  /// change cancels the fade synchronously.
34
34
  ///
35
- /// Runtime fade execution (multi-second `setVolumeRamp` schedule
36
- /// + standby promotion to leading at fade-end) is exercised at
37
- /// the demo-app integration layer; the engine's deterministic
38
- /// primitives (curve math, sub-ramp count, route classification,
39
- /// boundary fade-vs-cut decision) are unit-tested.
35
+ /// Runtime fade execution is a multi-second `setVolumeRamp` schedule
36
+ /// plus standby promotion to leading at fade-end, built on
37
+ /// deterministic primitives (curve math, sub-ramp count, route
38
+ /// classification, boundary fade-vs-cut decision).
40
39
  final class CrossfadeEngine: PlaybackEngine {
41
40
 
42
41
  // MARK: - Construction
@@ -16,9 +16,9 @@ import Foundation
16
16
  /// the public API surface (matches the documented contract +
17
17
  /// Android parity).
18
18
  ///
19
- /// Helpers live here as pure functions so they can be exercised by
20
- /// XCTest without instantiating a `TrackPlayer` (which depends on
21
- /// AVQueuePlayer + AVAudioSession + Nitrogen-generated types).
19
+ /// Helpers live here as pure functions, decoupled from `TrackPlayer`
20
+ /// (which depends on AVQueuePlayer + AVAudioSession + Nitrogen-generated
21
+ /// types).
22
22
  ///
23
23
  /// Gated behind `@_spi(QueuePlayerTests)` so the helpers stay off
24
24
  /// the library's consumer-facing public surface — same SPI scope
@@ -810,13 +810,11 @@ public enum LookaheadCacheError: Error {
810
810
  }
811
811
 
812
812
  /// Abstraction over `LookaheadCache.download` so the prefetcher
813
- /// can be unit-tested with a stub conformer that records
814
- /// invocations without touching `URLSession`. Production code uses
815
- /// `LookaheadCache` itself (which conforms below).
813
+ /// can accept an alternate conformer without touching `URLSession`.
814
+ /// Production code uses `LookaheadCache` itself (which conforms below).
816
815
  ///
817
- /// Gated behind `@_spi(QueuePlayerTests)` only consumer is the
818
- /// test stub. Production callers reach `LookaheadCache` directly
819
- /// via `LookaheadCachePrefetcher.init(cache:)`.
816
+ /// Gated behind `@_spi(QueuePlayerTests)`. Production callers reach
817
+ /// `LookaheadCache` directly via `LookaheadCachePrefetcher.init(cache:)`.
820
818
  @_spi(QueuePlayerTests)
821
819
  public protocol LookaheadCacheDownloader: AnyObject {
822
820
  func download(track: TrackItem, headers: [String: String]?) async throws
@@ -148,7 +148,7 @@ public final class LookaheadCachePrefetcher {
148
148
  }
149
149
 
150
150
  /// SPI initializer — accepts any `LookaheadCacheDownloader`
151
- /// conformer so XCTest targets can inject a stub. Off the
151
+ /// conformer so callers can inject an alternate. Off the
152
152
  /// consumer-facing surface; production calls reach the concrete
153
153
  /// `LookaheadCache`-typed init above.
154
154
  @_spi(QueuePlayerTests)
@@ -1,6 +1,5 @@
1
1
  /// Tracks fixed playback-progress milestones (25/50/75/90%) for the
2
- /// current track playthrough. Pure + deterministic (no AVFoundation), so
3
- /// it unit-tests directly.
2
+ /// current track playthrough. Pure + deterministic (no AVFoundation).
4
3
  ///
5
4
  /// A threshold emits only when forward playback crosses it: `seek` moves
6
5
  /// the baseline WITHOUT emitting (so thresholds a seek jumps over are
@@ -77,24 +77,23 @@ public enum PlaybackErrorMapping {
77
77
  return result
78
78
  }
79
79
 
80
- // MARK: - Test-friendly "Raw" surface
80
+ // MARK: - String-based "Raw" surface
81
81
  //
82
82
  // NOTES.md §6: Swift's `-cxx-interoperability-mode=default` hides
83
83
  // members of functions whose signatures touch Nitrogen-generated
84
84
  // C++-backed types when viewed from a different Swift module.
85
85
  // `classify` / `isTransient` reference `PlaybackErrorCode` — a
86
- // Nitrogen type — so XCTest can't reach them directly. Provide a
87
- // parallel surface that takes/returns Strings (the enum's
88
- // stringValue) for unit-test access. Production code uses the
89
- // typed surface above; tests use the Raw surface.
86
+ // Nitrogen type — so a different module can't reach them directly.
87
+ // This parallel surface takes/returns Strings (the enum's
88
+ // stringValue). Production code uses the typed surface above.
90
89
 
91
- /// Test-only string-returning variant of `classify`.
90
+ /// String-returning variant of `classify`.
92
91
  @_spi(QueuePlayerTests)
93
92
  public static func classifyRaw(_ underlying: NSError?) -> String {
94
93
  return classify(underlying).stringValue
95
94
  }
96
95
 
97
- /// Test-only string-input variant of `isTransient`.
96
+ /// String-input variant of `isTransient`.
98
97
  @_spi(QueuePlayerTests)
99
98
  public static func isTransientRaw(_ codeString: String) -> Bool {
100
99
  guard let code = PlaybackErrorCode(fromString: codeString) else { return false }
@@ -4,8 +4,7 @@ import AVFoundation
4
4
  /// status. Shared by BOTH engines (gapless + crossfade) via
5
5
  /// `PlaybackEngine.timeControlStatus`, so the two report identical states for
6
6
  /// identical situations — no per-engine special-casing for consumers. Mirrors
7
- /// Android's `TransportStateTranslator`; pure + deterministic, so it unit-tests
8
- /// directly.
7
+ /// Android's `TransportStateTranslator`; pure + deterministic.
9
8
  ///
10
9
  /// iOS has no native "ended" player state (unlike Android's `STATE_ENDED`), so
11
10
  /// `reachedQueueEnd` is a latch that `TrackPlayer` sets when the queue drains