react-native-queue-player 1.0.3 → 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 (92) 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/PlaybackService.kt +5 -6
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +8 -1
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +1 -1
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +1 -1
  11. package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +1 -1
  12. package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +1 -1
  13. package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +1 -1
  14. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +21 -27
  15. package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +2 -2
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +2 -5
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +1 -4
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +1 -2
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +3 -7
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +1 -2
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -3
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +2 -3
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +1 -2
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +0 -2
  25. package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +3 -8
  26. package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +2 -4
  27. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +2 -3
  28. package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +1 -3
  29. package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +1 -2
  30. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
  31. package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +2 -2
  32. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +76 -7
  33. package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +1 -3
  34. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +1 -2
  35. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -4
  36. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +1 -2
  37. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +2 -3
  38. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +1 -3
  39. package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +2 -5
  40. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -2
  41. package/ios/AVPlayerItemQueueItemId.swift +2 -2
  42. package/ios/AVQueueBuilder.swift +12 -14
  43. package/ios/CarPlayCoordinator.swift +28 -0
  44. package/ios/CarPlaySceneDelegate.swift +2 -0
  45. package/ios/CrossfadeEngine.swift +4 -5
  46. package/ios/InputGuards.swift +3 -3
  47. package/ios/LookaheadCache.swift +4 -6
  48. package/ios/LookaheadCachePrefetcher.swift +1 -1
  49. package/ios/MilestoneTracker.swift +1 -2
  50. package/ios/PlaybackErrorMapping.swift +6 -7
  51. package/ios/PlayerStateDerivation.swift +1 -2
  52. package/ios/ProgressEmissionGate.swift +1 -1
  53. package/ios/QueueMutationArithmetic.swift +2 -2
  54. package/ios/QueueSkipArithmetic.swift +8 -12
  55. package/ios/Siri/PlayMediaIntentHandler.swift +1 -5
  56. package/ios/SleepTimerCore.swift +1 -1
  57. package/ios/Tests/AudioTapProviderReplayGainTests.swift +5 -9
  58. package/ios/Tests/CarPlayBridgeTests.swift +2 -6
  59. package/ios/Tests/CarPlayBrowseBuilderTests.swift +1 -2
  60. package/ios/Tests/CarPlayCoordinatorTests.swift +35 -0
  61. package/ios/Tests/EQProcessorTests.swift +1 -4
  62. package/ios/Tests/EQTapTests.swift +1 -2
  63. package/ios/Tests/EqualizerAudioMixProviderTests.swift +3 -4
  64. package/ios/Tests/EqualizerHybridTests.swift +1 -3
  65. package/ios/Tests/FFTProcessorTests.swift +1 -3
  66. package/ios/Tests/GaplessEngineLifecycleTests.swift +0 -2
  67. package/ios/Tests/LookaheadCacheEvictionTests.swift +1 -4
  68. package/ios/Tests/MetadataReaderTests.swift +0 -2
  69. package/ios/Tests/MilestoneTrackerTests.swift +1 -3
  70. package/ios/Tests/NowPlayingFormatExtractorTests.swift +3 -4
  71. package/ios/Tests/PlaybackErrorMappingTests.swift +0 -3
  72. package/ios/Tests/PlaybackModeStateMachineTests.swift +5 -9
  73. package/ios/Tests/PlayerStateDerivationTests.swift +1 -2
  74. package/ios/Tests/ProgressEmissionGateTests.swift +1 -3
  75. package/ios/Tests/QueueMutationArithmeticTests.swift +0 -3
  76. package/ios/Tests/RemoteCommandsTests.swift +2 -5
  77. package/ios/Tests/ReplayGainExtractorTests.swift +0 -5
  78. package/ios/Tests/SkipCapabilityTests.swift +4 -7
  79. package/ios/Tests/VoiceDonationTests.swift +4 -4
  80. package/ios/TrackPlayer.swift +5 -2
  81. package/lib/typescript/TrackPlayer.nitro.d.ts +17 -0
  82. package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
  83. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
  84. package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
  85. package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +4 -0
  86. package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
  87. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
  88. package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
  89. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
  90. package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
  91. package/package.json +1 -1
  92. package/src/TrackPlayer.nitro.ts +18 -0
@@ -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
@@ -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
  ///
@@ -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
@@ -1,6 +1,6 @@
1
1
  /// Throttles the JS `onProgress` fan-out to a configurable cadence, with an
2
2
  /// optional reduced rate while the app is backgrounded. Pure + deterministic
3
- /// (the caller supplies a monotonic timestamp), so it unit-tests directly.
3
+ /// (the caller supplies a monotonic timestamp).
4
4
  ///
5
5
  /// This gates ONLY the JS emission — the native 2 Hz sampler keeps running, so
6
6
  /// milestone detection and buffer-state recomputation stay full-rate. A tick
@@ -1,10 +1,10 @@
1
1
  import Foundation
2
2
 
3
3
  /// Pure-Swift index-shift arithmetic for queue mutations. Extracted
4
- /// from `TrackPlayer.swift` so the shift semantics are unit-testable
4
+ /// from `TrackPlayer.swift` so the shift semantics stand alone,
5
5
  /// without an AVQueuePlayer. Mirrors
6
6
  /// `android/.../QueueMutationArithmetic.kt` 1:1 — same function
7
- /// shapes, same input/output semantics, same test matrix.
7
+ /// shapes, same input/output semantics.
8
8
  ///
9
9
  /// The helper only computes the new `currentTrackIndex` and (for
10
10
  /// `removeFromQueue`) which original indices apply after dedup /
@@ -1,23 +1,19 @@
1
1
  /// Pure-Swift skip-index arithmetic. Extracted from `TrackPlayer.swift`
2
- /// so the logic can be unit-tested without crossing the Swift ↔ C++
3
- /// interop boundary — Nitrogen-generated types (`TrackItem`, `RepeatMode`)
4
- /// don't export their members to importing modules under Swift's current
5
- /// `-cxx-interoperability-mode=default` settings, which makes `@testable
6
- /// import QueuePlayer` useless for touching anything that reads Nitro
7
- /// types. By taking primitive arguments, these helpers stay reachable
8
- /// from the XCTest bundle via plain `import QueuePlayer`.
2
+ /// so the logic stays clear of the Swift ↔ C++ interop boundary —
3
+ /// Nitrogen-generated types (`TrackItem`, `RepeatMode`) don't export
4
+ /// their members to importing modules under Swift's current
5
+ /// `-cxx-interoperability-mode=default` settings. These helpers take
6
+ /// primitive arguments instead.
9
7
  ///
10
8
  /// `TrackPlayer.computeNextIndex` / `.computePreviousIndex` instance
11
9
  /// methods are thin adapters that map the Nitrogen `RepeatMode` enum
12
10
  /// onto `QueueSkipRepeatMode` and call through here.
13
11
 
14
12
  /// Repeat-mode enum expressed in plain Swift. Value order (0/1/2)
15
- /// matches `RepeatModeKind` in the nitro.ts spec, so a test that
16
- /// asserts the raw integer is robust against accidental reordering
17
- /// in the Nitrogen spec.
13
+ /// matches `RepeatModeKind` in the nitro.ts spec.
18
14
  ///
19
- /// Gated behind `@_spi(QueuePlayerTests)` so the XCTest-only helper
20
- /// surface stays off the library's consumer-facing API.
15
+ /// Gated behind `@_spi(QueuePlayerTests)` so the helper surface stays
16
+ /// off the library's consumer-facing API.
21
17
  @_spi(QueuePlayerTests)
22
18
  public enum QueueSkipRepeatMode: Int {
23
19
  case off = 0
@@ -189,11 +189,7 @@ internal final class PlayMediaIntentHandler: NSObject, INPlayMediaIntentHandling
189
189
  /// Map `INPlayMediaIntent.mediaSearch` into the lib's
190
190
  /// `MediaSearchRequest` shape so the consumer's JS resolver sees
191
191
  /// the same wire format regardless of voice surface (SiriKit on
192
- /// iOS, Google Assistant on Android). Coverage lives at the
193
- /// Maestro integration layer (`e2e/manual/siri-cold-start.md`
194
- /// S1–S5) — the return type's cxx-interop bridging blocks
195
- /// `@testable import` from constructing or comparing
196
- /// `MediaSearchRequest` values in XCTest.
192
+ /// iOS, Google Assistant on Android).
197
193
  static func buildRequest(from intent: INPlayMediaIntent) -> MediaSearchRequest {
198
194
  let mediaSearch = intent.mediaSearch
199
195
  let mediaName = mediaSearch?.mediaName ?? ""
@@ -1,7 +1,7 @@
1
1
  import Foundation
2
2
 
3
3
  /// Pure state machine for the sleep timer — no player, volume, or timer
4
- /// dependencies, so it is unit-testable in isolation. The owning `TrackPlayer`
4
+ /// dependencies, so it stands alone. The owning `TrackPlayer`
5
5
  /// drives it from a repeating native timer and applies the returned decision
6
6
  /// (volume fade, pause) to the engine.
7
7
  ///
@@ -3,19 +3,15 @@ import XCTest
3
3
  @testable import QueuePlayer
4
4
 
5
5
  /// Unit tests for the ReplayGain mode + gain-math paths in
6
- /// `AudioTapProvider`. The metadata-extraction async path + per-item
7
- /// recompute walk are covered at the integration layer (auto-suite
8
- /// `replaygain.ts` step). These tests drive the pure
6
+ /// `AudioTapProvider`. These tests drive the pure
9
7
  /// `computeLinearGain(data:mode:)` static helper plus the mode
10
8
  /// getter/setter round-trip.
11
9
  final class AudioTapProviderReplayGainTests: XCTestCase {
12
10
 
13
- // Mode getter / setter round-trip is exercised at the integration
14
- // layer (`replaygain.ts` auto-suite) `getReplayGainMode`'s
15
- // `ReplayGainMode` return type touches the Swift↔C++ interop
16
- // graph, so direct equality from `@testable` XCTest can't observe
17
- // it (NOTES.md §6). The pure gain-math matrix below is the unit
18
- // surface; mode propagation lands in the integration tests.
11
+ // `getReplayGainMode`'s `ReplayGainMode` return type touches the
12
+ // Swift↔C++ interop graph, so direct equality from `@testable`
13
+ // XCTest can't observe it (NOTES.md §6). The pure gain-math matrix
14
+ // below is the unit surface.
19
15
 
20
16
  // MARK: - computeLinearGain — strict mode + selection
21
17
 
@@ -2,9 +2,7 @@ import XCTest
2
2
  @testable import QueuePlayer
3
3
 
4
4
  /// `CarPlayBridge` tests cover only the registration/dispose/clear
5
- /// state machine. Promise-await dispatch is exercised by the
6
- /// production scene delegate + browse builder via integration paths;
7
- /// the unit suite stays focused on the slot semantics. The
5
+ /// state machine the slot semantics. The
8
6
  /// Android-side `BrowseCallbackRegistry` exposes suspending
9
7
  /// `await*Callback()` helpers because its dispatch sites coroutine-
10
8
  /// poll for cold-start race recovery; iOS uses NotificationCenter
@@ -18,9 +16,7 @@ final class CarPlayBridgeTests: XCTestCase {
18
16
  // The browse / playFromId slot reads return a Promise of a
19
17
  // Nitrogen-bridged type, which Swift cxx-interop excludes from
20
18
  // `@testable import`. Their slots use the identical `Slot<>`
21
- // machinery asserted here for connect/disconnect, and their
22
- // dispatch is covered at the integration layer (scene delegate +
23
- // browse builder).
19
+ // machinery asserted here for connect/disconnect.
24
20
  XCTAssertNil(bridge.carConnectCallback())
25
21
  XCTAssertNil(bridge.carDisconnectCallback())
26
22
  }
@@ -5,8 +5,7 @@ import XCTest
5
5
  /// Coverage for [CarPlayBrowseBuilder] — snapshot → template
6
6
  /// conversion, item count + accessory mapping, pagination against
7
7
  /// `CPListTemplate.maximumItemCount`. Direct method calls on the
8
- /// builder; the scene delegate's full lifecycle is covered by the
9
- /// CarPlay Sim manual playbook.
8
+ /// builder; the scene delegate's full lifecycle is out of scope here.
10
9
  final class CarPlayBrowseBuilderTests: XCTestCase {
11
10
 
12
11
  private func item(
@@ -0,0 +1,35 @@
1
+ import XCTest
2
+ @testable import QueuePlayer
3
+
4
+ /// `CarPlayCoordinator` connection-flag tests. The flag is written by the
5
+ /// CarPlay scene delegate at scene attach / detach; this suite locks the
6
+ /// get/set contract the `isCarConnected()` getter reads back.
7
+ final class CarPlayCoordinatorTests: XCTestCase {
8
+
9
+ override func setUp() {
10
+ super.setUp()
11
+ // Shared process-wide singleton — start from a known-disconnected state
12
+ // regardless of test order.
13
+ CarPlayCoordinator.shared.setCarConnected(false)
14
+ }
15
+
16
+ override func tearDown() {
17
+ // Shared process-wide singleton — leave it disconnected so it can't
18
+ // leak a connected state into another suite.
19
+ CarPlayCoordinator.shared.setCarConnected(false)
20
+ super.tearDown()
21
+ }
22
+
23
+ func testCarConnectedFlagReflectsSetter() {
24
+ let coordinator = CarPlayCoordinator.shared
25
+
26
+ coordinator.setCarConnected(false)
27
+ XCTAssertFalse(coordinator.isCarConnected, "disconnected after setCarConnected(false)")
28
+
29
+ coordinator.setCarConnected(true)
30
+ XCTAssertTrue(coordinator.isCarConnected, "connected after setCarConnected(true)")
31
+
32
+ coordinator.setCarConnected(false)
33
+ XCTAssertFalse(coordinator.isCarConnected, "disconnected again after setCarConnected(false)")
34
+ }
35
+ }