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
@@ -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
  ///
@@ -29,14 +29,52 @@ final class CarPlayCoordinator {
29
29
  static let shared = CarPlayCoordinator()
30
30
 
31
31
  let dataProvider: CarPlayBrowseDataProvider
32
+ let queueProvider: CarPlayQueueProvider
32
33
  let bridge: CarPlayBridge
33
34
  let pending: PendingBrowseRequests
34
35
  let loader: ArtworkLoader
35
36
 
36
37
  private init() {
37
38
  self.dataProvider = CarPlayBrowseDataProvider()
39
+ self.queueProvider = CarPlayQueueProvider()
38
40
  self.bridge = CarPlayBridge()
39
41
  self.pending = PendingBrowseRequests()
40
42
  self.loader = ArtworkLoader()
41
43
  }
44
+
45
+ // MARK: - Queue skip
46
+
47
+ /// Skip playback to a queue index. `TrackPlayer` registers this at
48
+ /// configure; the CarPlay scene delegate invokes it when the user
49
+ /// taps a row in the Up Next list. Set and called on the main thread
50
+ /// only, so no lock is needed.
51
+ var skipToIndexHandler: ((Int) -> Void)?
52
+
53
+ // MARK: - Connection state
54
+
55
+ /// Whether the CarPlay scene is currently connected. Written by
56
+ /// [CarPlaySceneDelegate] at scene attach / detach — the same points that
57
+ /// fire the `onCarConnect` / `onCarDisconnect` callbacks — and read by the
58
+ /// `isCarConnected()` Hybrid getter. Scene callbacks land on the main
59
+ /// queue; the getter can be read from any Nitro thread, so the flag is
60
+ /// lock-guarded.
61
+ ///
62
+ /// A media app has a single CarPlay template scene, so this is a plain
63
+ /// attached/detached flag rather than a connection count (the Android side
64
+ /// counts because its headless controllers — Auto / Wear / Assistant — can
65
+ /// overlap).
66
+ private let connectionLock = NSLock()
67
+ private var carConnectedFlag = false
68
+
69
+ var isCarConnected: Bool {
70
+ connectionLock.lock()
71
+ defer { connectionLock.unlock() }
72
+ return carConnectedFlag
73
+ }
74
+
75
+ func setCarConnected(_ connected: Bool) {
76
+ connectionLock.lock()
77
+ defer { connectionLock.unlock() }
78
+ carConnectedFlag = connected
79
+ }
42
80
  }
@@ -0,0 +1,82 @@
1
+ import CarPlay
2
+ import UIKit
3
+
4
+ /// Convert a [CarPlayQueueSnapshot] into the CarPlay "Up Next" list
5
+ /// template shown when the user taps the Now Playing queue button.
6
+ /// Single-section `CPListTemplate` — one `CPListItem` per queue entry,
7
+ /// with the currently-playing entry marked via `isPlaying`.
8
+ ///
9
+ /// Audio-app list cap: CarPlay reports the runtime row limit via
10
+ /// `CPListTemplate.maximumItemCount`. Over-cap queues are truncated to
11
+ /// the first N rows; the current entry's index still maps 1:1 to the
12
+ /// list row as long as it falls inside the cap.
13
+ ///
14
+ /// Stateless helper. The owning scene delegate pushes the returned
15
+ /// template onto the interface controller.
16
+ final class CarPlayQueueBuilder {
17
+
18
+ /// Maximum rows per Up Next list. Reads the SDK property so the cap
19
+ /// tracks whatever the running CarPlay framework enforces.
20
+ static var maximumItemCount: Int { CPListTemplate.maximumItemCount }
21
+
22
+ /// Build the Up Next `CPListTemplate` from [snapshot]. Each row's
23
+ /// tap handler invokes [skip] with that row's queue index, then the
24
+ /// caller pops back to Now Playing. The entry at
25
+ /// `snapshot.currentIndex` is marked `isPlaying`. Rows beyond
26
+ /// [maximumItemCount] are dropped.
27
+ func buildQueueTemplate(
28
+ snapshot: CarPlayQueueSnapshot,
29
+ loader: ArtworkLoader,
30
+ maximumItemCount: Int = CarPlayQueueBuilder.maximumItemCount,
31
+ skip: @escaping (Int) -> Void
32
+ ) -> CPListTemplate {
33
+ let cap = max(0, maximumItemCount)
34
+ let visible = snapshot.entries.prefix(cap)
35
+ let items = visible.enumerated().map { index, entry in
36
+ makeQueueItem(
37
+ entry: entry,
38
+ index: index,
39
+ isCurrent: index == snapshot.currentIndex,
40
+ loader: loader,
41
+ skip: skip
42
+ )
43
+ }
44
+ return CPListTemplate(
45
+ title: upNextTitle,
46
+ sections: [CPListSection(items: items)]
47
+ )
48
+ }
49
+
50
+ /// Build a single Up Next row. Text = entry title, detail = subtitle.
51
+ /// Tap fires [skip] with [index]. The current row shows the playing
52
+ /// indicator; artwork loads asynchronously via [loader].
53
+ func makeQueueItem(
54
+ entry: CarPlayQueueEntry,
55
+ index: Int,
56
+ isCurrent: Bool,
57
+ loader: ArtworkLoader,
58
+ skip: @escaping (Int) -> Void
59
+ ) -> CPListItem {
60
+ let listItem = CPListItem(
61
+ text: entry.title,
62
+ detailText: entry.subtitle,
63
+ image: nil
64
+ )
65
+ listItem.isPlaying = isCurrent
66
+ if let artworkUrl = entry.artworkUrl, !artworkUrl.isEmpty {
67
+ loader.load(uri: artworkUrl) { [weak listItem] image in
68
+ guard let listItem = listItem, let image = image else { return }
69
+ listItem.setImage(image)
70
+ }
71
+ }
72
+ listItem.handler = { _, completion in
73
+ skip(index)
74
+ completion()
75
+ }
76
+ return listItem
77
+ }
78
+
79
+ private var upNextTitle: String {
80
+ NSLocalizedString("Up Next", comment: "CarPlay queue list title")
81
+ }
82
+ }
@@ -0,0 +1,73 @@
1
+ import Foundation
2
+
3
+ /// One queue entry mirrored for the CarPlay Up Next surface. A plain
4
+ /// value type decoupled from the Nitro `TrackItem` — the mirror holds
5
+ /// only what the list template renders, so the CarPlay scene reads
6
+ /// display data without reaching back into the Hybrid.
7
+ struct CarPlayQueueEntry {
8
+ let title: String
9
+ let subtitle: String?
10
+ let artworkUrl: String?
11
+ }
12
+
13
+ /// Immutable mirror of the live play queue for the CarPlay Up Next
14
+ /// list. `currentIndex` is the index of the playing track, or -1 when
15
+ /// nothing is current. Snapshot replacement is atomic (see
16
+ /// [CarPlayQueueProvider]).
17
+ struct CarPlayQueueSnapshot {
18
+ let entries: [CarPlayQueueEntry]
19
+ let currentIndex: Int
20
+ }
21
+
22
+ /// Native cache for the most-recent [CarPlayQueueSnapshot] pushed by
23
+ /// `TrackPlayer` whenever the queue or current index changes. The
24
+ /// CarPlay scene delegate reads from this cache to build the Up Next
25
+ /// list so a queue-button tap does not have to round-trip through JS.
26
+ ///
27
+ /// Snapshot replacement is atomic: a single `NSLock` guards the stored
28
+ /// reference. Concurrent readers see either the prior snapshot in full
29
+ /// or the new one in full, never a torn mix.
30
+ ///
31
+ /// Update notifications: a `Notification` named
32
+ /// [CarPlayQueueProvider.didUpdateNotification] fires on the main queue
33
+ /// after every `setSnapshot`. The scene delegate observes it to
34
+ /// re-evaluate the Up Next button's enabled state as the queue changes.
35
+ final class CarPlayQueueProvider {
36
+
37
+ /// Notification posted on the main queue after `setSnapshot`.
38
+ static let didUpdateNotification = Notification.Name(
39
+ "com.margelo.nitro.queueplayer.CarPlayQueueProvider.didUpdate"
40
+ )
41
+
42
+ /// Replace the cached snapshot. Pass `nil` to clear. Posts
43
+ /// [didUpdateNotification] on the main queue (dispatched to main if
44
+ /// the caller is off-main) so observers re-evaluate against the new
45
+ /// snapshot on a stable thread.
46
+ func setSnapshot(_ snapshot: CarPlayQueueSnapshot?) {
47
+ lock.lock()
48
+ self.snapshot = snapshot
49
+ lock.unlock()
50
+
51
+ let notify = {
52
+ NotificationCenter.default.post(
53
+ name: CarPlayQueueProvider.didUpdateNotification,
54
+ object: self
55
+ )
56
+ }
57
+ if Thread.isMainThread {
58
+ notify()
59
+ } else {
60
+ DispatchQueue.main.async(execute: notify)
61
+ }
62
+ }
63
+
64
+ /// Read the current snapshot, or `nil` if none has been set.
65
+ func getSnapshot() -> CarPlayQueueSnapshot? {
66
+ lock.lock()
67
+ defer { lock.unlock() }
68
+ return snapshot
69
+ }
70
+
71
+ private var snapshot: CarPlayQueueSnapshot?
72
+ private let lock = NSLock()
73
+ }
@@ -52,11 +52,19 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
52
52
  /// delegate doesn't leak past its scene's lifetime.
53
53
  private var snapshotObserver: NSObjectProtocol?
54
54
 
55
+ /// Queue-mirror observer. Fires when the play queue or current index
56
+ /// changes so the Up Next button's enabled state re-evaluates.
57
+ /// Removed on scene disconnect.
58
+ private var queueObserver: NSObjectProtocol?
59
+
55
60
  /// Cached scene-construction template builder. Stateless; the
56
61
  /// live tab-bar reference above is what survives across snapshot
57
62
  /// updates.
58
63
  private let builder = CarPlayBrowseBuilder()
59
64
 
65
+ /// Stateless builder for the Up Next queue list template.
66
+ private let queueBuilder = CarPlayQueueBuilder()
67
+
60
68
  // MARK: - CPTemplateApplicationSceneDelegate
61
69
 
62
70
  public func templateApplicationScene(
@@ -122,6 +130,15 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
122
130
  self?.refreshRootTemplate()
123
131
  }
124
132
 
133
+ queueObserver = NotificationCenter.default.addObserver(
134
+ forName: CarPlayQueueProvider.didUpdateNotification,
135
+ object: coordinator.queueProvider,
136
+ queue: .main
137
+ ) { [weak self] _ in
138
+ self?.refreshUpNextButton()
139
+ }
140
+
141
+ coordinator.setCarConnected(true)
125
142
  coordinator.bridge.carConnectCallback()?()
126
143
  }
127
144
 
@@ -137,8 +154,14 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
137
154
  NotificationCenter.default.removeObserver(observer)
138
155
  snapshotObserver = nil
139
156
  }
157
+ if let observer = queueObserver {
158
+ NotificationCenter.default.removeObserver(observer)
159
+ queueObserver = nil
160
+ }
161
+ CPNowPlayingTemplate.shared.remove(self)
140
162
  self.interfaceController = nil
141
163
  self.rootTabBar = nil
164
+ CarPlayCoordinator.shared.setCarConnected(false)
142
165
  CarPlayCoordinator.shared.bridge.carDisconnectCallback()?()
143
166
  // Pending entries from this scene's session are stale — the
144
167
  // next scene attach starts from a fresh snapshot read, so
@@ -186,14 +209,65 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
186
209
  /// nav stack via `pushTemplate(CPNowPlayingTemplate.shared, ...)`
187
210
  /// is the supported path for `com.apple.developer.carplay-audio`
188
211
  /// apps; embedding the same template in a `CPTabBarTemplate` root
189
- /// is NOT — that raises `NSInvalidArgumentException`. Property
190
- /// mutation here is intentionally avoided — earlier iOS 26 sim
191
- /// experiments setting `isUpNextButtonEnabled = false` /
192
- /// `isAlbumArtistButtonEnabled = false` left the template rendering
193
- /// blank; baseline defaults work.
212
+ /// is NOT — that raises `NSInvalidArgumentException`.
213
+ ///
214
+ /// Registers self as a `CPNowPlayingTemplateObserver` so the Up Next
215
+ /// button tap routes into [nowPlayingTemplateUpNextButtonTapped].
216
+ /// Enables only the Up Next flag + title (when the mirrored queue is
217
+ /// non-empty); every other Now Playing button keeps baseline defaults.
194
218
  private func configureNowPlayingTemplate() {
195
- _ = CPNowPlayingTemplate.shared
196
- NSLog("[RNQP-CarPlay] CPNowPlayingTemplate.shared touched")
219
+ CPNowPlayingTemplate.shared.add(self)
220
+ refreshUpNextButton()
221
+ NSLog("[RNQP-CarPlay] CPNowPlayingTemplate.shared configured")
222
+ }
223
+
224
+ /// Enable the Up Next button when the mirrored queue has entries.
225
+ /// Re-run from the queue-mirror observer so the button reflects the
226
+ /// live queue.
227
+ private func refreshUpNextButton() {
228
+ let hasQueue = !(
229
+ CarPlayCoordinator.shared.queueProvider.getSnapshot()?.entries.isEmpty ?? true
230
+ )
231
+ CPNowPlayingTemplate.shared.isUpNextButtonEnabled = hasQueue
232
+ CPNowPlayingTemplate.shared.upNextTitle = upNextTitle
233
+ }
234
+
235
+ private var upNextTitle: String {
236
+ NSLocalizedString("Up Next", comment: "CarPlay queue button title")
197
237
  }
198
238
 
199
239
  }
240
+
241
+ // MARK: - CPNowPlayingTemplateObserver
242
+
243
+ @available(iOS 14.0, *)
244
+ extension CarPlaySceneDelegate: CPNowPlayingTemplateObserver {
245
+
246
+ /// Build the Up Next list from the mirrored queue snapshot and push
247
+ /// it. Each row taps back to Now Playing after routing the skip
248
+ /// through the coordinator's `skipToIndexHandler`, which `TrackPlayer`
249
+ /// registers. No-op when no queue is mirrored yet.
250
+ func nowPlayingTemplateUpNextButtonTapped(
251
+ _ nowPlayingTemplate: CPNowPlayingTemplate
252
+ ) {
253
+ guard let controller = interfaceController else { return }
254
+ // Only push from the Now Playing surface — guards against a
255
+ // spurious double-push if the tap re-fires while the list is up.
256
+ guard controller.topTemplate === CPNowPlayingTemplate.shared else { return }
257
+ guard let snapshot = CarPlayCoordinator.shared.queueProvider.getSnapshot(),
258
+ !snapshot.entries.isEmpty else {
259
+ return
260
+ }
261
+ let list = queueBuilder.buildQueueTemplate(
262
+ snapshot: snapshot,
263
+ loader: CarPlayCoordinator.shared.loader,
264
+ skip: { [weak self] index in
265
+ CarPlayCoordinator.shared.skipToIndexHandler?(index)
266
+ // Return to Now Playing — pop the Up Next list off the nav
267
+ // stack; the Now Playing surface is already beneath it.
268
+ self?.interfaceController?.popTemplate(animated: true) { _, _ in }
269
+ }
270
+ )
271
+ controller.pushTemplate(list, animated: true) { _, _ in }
272
+ }
273
+ }
@@ -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
  ///