react-native-queue-player 1.0.4 → 1.0.7

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 (32) hide show
  1. package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
  2. package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
  3. package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
  4. package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
  5. package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
  6. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
  7. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
  8. package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
  9. package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
  10. package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
  11. package/android/src/main/res/values/strings.xml +0 -3
  12. package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
  13. package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
  14. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
  15. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
  16. package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
  17. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
  18. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
  19. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
  20. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
  21. package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
  22. package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
  23. package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
  24. package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
  25. package/ios/CarPlayCoordinator.swift +10 -0
  26. package/ios/CarPlayQueueBuilder.swift +82 -0
  27. package/ios/CarPlayQueueProvider.swift +73 -0
  28. package/ios/CarPlaySceneDelegate.swift +79 -7
  29. package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
  30. package/ios/Tests/FFTProcessorTests.swift +9 -7
  31. package/ios/TrackPlayer.swift +47 -0
  32. package/package.json +1 -1
@@ -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,14 @@ 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
+
125
141
  coordinator.setCarConnected(true)
126
142
  coordinator.bridge.carConnectCallback()?()
127
143
  }
@@ -138,6 +154,11 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
138
154
  NotificationCenter.default.removeObserver(observer)
139
155
  snapshotObserver = nil
140
156
  }
157
+ if let observer = queueObserver {
158
+ NotificationCenter.default.removeObserver(observer)
159
+ queueObserver = nil
160
+ }
161
+ CPNowPlayingTemplate.shared.remove(self)
141
162
  self.interfaceController = nil
142
163
  self.rootTabBar = nil
143
164
  CarPlayCoordinator.shared.setCarConnected(false)
@@ -188,14 +209,65 @@ internal class CarPlaySceneDelegate: UIResponder, CPTemplateApplicationSceneDele
188
209
  /// nav stack via `pushTemplate(CPNowPlayingTemplate.shared, ...)`
189
210
  /// is the supported path for `com.apple.developer.carplay-audio`
190
211
  /// apps; embedding the same template in a `CPTabBarTemplate` root
191
- /// is NOT — that raises `NSInvalidArgumentException`. Property
192
- /// mutation here is intentionally avoided — earlier iOS 26 sim
193
- /// experiments setting `isUpNextButtonEnabled = false` /
194
- /// `isAlbumArtistButtonEnabled = false` left the template rendering
195
- /// 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.
196
218
  private func configureNowPlayingTemplate() {
197
- _ = CPNowPlayingTemplate.shared
198
- 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")
199
237
  }
200
238
 
201
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
+ }
@@ -0,0 +1,145 @@
1
+ import CarPlay
2
+ import XCTest
3
+ @testable import QueuePlayer
4
+
5
+ /// Coverage for [CarPlayQueueBuilder] — snapshot → Up Next template
6
+ /// conversion, row count + title/detail mapping, truncation at
7
+ /// `CPListTemplate.maximumItemCount`, current-index marking, and the
8
+ /// row-tap skip closure — plus a [CarPlayQueueProvider] round-trip.
9
+ final class CarPlayQueueBuilderTests: XCTestCase {
10
+
11
+ private func entry(
12
+ _ title: String,
13
+ subtitle: String? = nil,
14
+ artworkUrl: String? = nil
15
+ ) -> CarPlayQueueEntry {
16
+ CarPlayQueueEntry(title: title, subtitle: subtitle, artworkUrl: artworkUrl)
17
+ }
18
+
19
+ private func snapshot(
20
+ _ entries: [CarPlayQueueEntry],
21
+ currentIndex: Int
22
+ ) -> CarPlayQueueSnapshot {
23
+ CarPlayQueueSnapshot(entries: entries, currentIndex: currentIndex)
24
+ }
25
+
26
+ // MARK: - Template construction
27
+
28
+ func testBuildQueueTemplateMapsEntriesToRows() {
29
+ let builder = CarPlayQueueBuilder()
30
+ let loader = ArtworkLoader()
31
+ let template = builder.buildQueueTemplate(
32
+ snapshot: snapshot(
33
+ [
34
+ entry("Everlong", subtitle: "Foo Fighters"),
35
+ entry("Monkey Wrench", subtitle: "Foo Fighters"),
36
+ ],
37
+ currentIndex: 0
38
+ ),
39
+ loader: loader,
40
+ skip: { _ in }
41
+ )
42
+ let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
43
+ XCTAssertEqual(items.count, 2)
44
+ XCTAssertEqual(items[0].text, "Everlong")
45
+ XCTAssertEqual(items[0].detailText, "Foo Fighters")
46
+ XCTAssertEqual(items[1].text, "Monkey Wrench")
47
+ }
48
+
49
+ func testBuildQueueTemplateCapsRowsAtMaximumItemCount() {
50
+ let builder = CarPlayQueueBuilder()
51
+ let loader = ArtworkLoader()
52
+ let cap = 5
53
+ let entries = (0..<cap + 4).map { entry("T-\($0)") }
54
+ let template = builder.buildQueueTemplate(
55
+ snapshot: snapshot(entries, currentIndex: 0),
56
+ loader: loader,
57
+ maximumItemCount: cap,
58
+ skip: { _ in }
59
+ )
60
+ let items = template.sections.first?.items ?? []
61
+ XCTAssertEqual(items.count, cap)
62
+ }
63
+
64
+ func testBuildQueueTemplateMarksCurrentIndex() {
65
+ let builder = CarPlayQueueBuilder()
66
+ let loader = ArtworkLoader()
67
+ let template = builder.buildQueueTemplate(
68
+ snapshot: snapshot(
69
+ [entry("A"), entry("B"), entry("C")],
70
+ currentIndex: 1
71
+ ),
72
+ loader: loader,
73
+ skip: { _ in }
74
+ )
75
+ let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
76
+ XCTAssertFalse(items[0].isPlaying)
77
+ XCTAssertTrue(items[1].isPlaying)
78
+ XCTAssertFalse(items[2].isPlaying)
79
+ }
80
+
81
+ func testBuildQueueTemplateMarksNothingWhenIndexOutOfRange() {
82
+ let builder = CarPlayQueueBuilder()
83
+ let loader = ArtworkLoader()
84
+ let template = builder.buildQueueTemplate(
85
+ snapshot: snapshot([entry("A"), entry("B")], currentIndex: -1),
86
+ loader: loader,
87
+ skip: { _ in }
88
+ )
89
+ let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
90
+ XCTAssertTrue(items.allSatisfy { !$0.isPlaying })
91
+ }
92
+
93
+ // MARK: - Row-tap skip
94
+
95
+ func testRowTapInvokesSkipWithRowIndex() {
96
+ let builder = CarPlayQueueBuilder()
97
+ let loader = ArtworkLoader()
98
+ var skippedTo: Int?
99
+ let template = builder.buildQueueTemplate(
100
+ snapshot: snapshot([entry("A"), entry("B"), entry("C")], currentIndex: 0),
101
+ loader: loader,
102
+ skip: { skippedTo = $0 }
103
+ )
104
+ let items = (template.sections.first?.items ?? []).compactMap { $0 as? CPListItem }
105
+ let exp = expectation(description: "row handler completes")
106
+ items[2].handler?(items[2]) { exp.fulfill() }
107
+ wait(for: [exp], timeout: 2)
108
+ XCTAssertEqual(skippedTo, 2)
109
+ }
110
+
111
+ // MARK: - Provider round-trip
112
+
113
+ func testProviderSetSnapshotMakesSnapshotReadable() {
114
+ let provider = CarPlayQueueProvider()
115
+ XCTAssertNil(provider.getSnapshot())
116
+ provider.setSnapshot(
117
+ snapshot([entry("A", subtitle: "sub")], currentIndex: 0)
118
+ )
119
+ let read = provider.getSnapshot()
120
+ XCTAssertEqual(read?.entries.count, 1)
121
+ XCTAssertEqual(read?.entries.first?.title, "A")
122
+ XCTAssertEqual(read?.entries.first?.subtitle, "sub")
123
+ XCTAssertEqual(read?.currentIndex, 0)
124
+ }
125
+
126
+ func testProviderSetSnapshotNilClearsCache() {
127
+ let provider = CarPlayQueueProvider()
128
+ provider.setSnapshot(snapshot([entry("A")], currentIndex: 0))
129
+ provider.setSnapshot(nil)
130
+ XCTAssertNil(provider.getSnapshot())
131
+ }
132
+
133
+ func testProviderSetSnapshotPostsDidUpdateNotification() {
134
+ let provider = CarPlayQueueProvider()
135
+ let exp = expectation(
136
+ forNotification: CarPlayQueueProvider.didUpdateNotification,
137
+ object: provider,
138
+ handler: nil
139
+ )
140
+ DispatchQueue.global().async {
141
+ provider.setSnapshot(self.snapshot([self.entry("A")], currentIndex: 0))
142
+ }
143
+ wait(for: [exp], timeout: 2)
144
+ }
145
+ }
@@ -33,7 +33,7 @@ final class FFTProcessorTests: XCTestCase {
33
33
  samples.withUnsafeBufferPointer { p in
34
34
  proc.ingest(samples: p.baseAddress!, frameCount: n, sampleRate: 48000)
35
35
  }
36
- wait(for: [exp], timeout: 1.0)
36
+ wait(for: [exp], timeout: 5.0)
37
37
  XCTAssertEqual(capturedSampleRate, 48000, accuracy: 0.001)
38
38
  }
39
39
 
@@ -57,7 +57,7 @@ final class FFTProcessorTests: XCTestCase {
57
57
  rest.withUnsafeBufferPointer { p in
58
58
  proc.ingest(samples: p.baseAddress!, frameCount: 256, sampleRate: 44100)
59
59
  }
60
- wait(for: [emitted], timeout: 1.0)
60
+ wait(for: [emitted], timeout: 5.0)
61
61
  XCTAssertEqual(calls, 1)
62
62
  }
63
63
 
@@ -74,7 +74,7 @@ final class FFTProcessorTests: XCTestCase {
74
74
  buffer.withUnsafeBufferPointer { p in
75
75
  proc.ingest(samples: p.baseAddress!, frameCount: 128, sampleRate: 44100)
76
76
  }
77
- wait(for: [exp], timeout: 1.0)
77
+ wait(for: [exp], timeout: 5.0)
78
78
  let samples = try XCTUnwrap(capturedSamples)
79
79
  XCTAssertEqual(samples.size, 128 * MemoryLayout<Float>.size)
80
80
  }
@@ -94,7 +94,7 @@ final class FFTProcessorTests: XCTestCase {
94
94
  buffer.withUnsafeBufferPointer { p in
95
95
  proc.ingest(samples: p.baseAddress!, frameCount: 128, sampleRate: 44100)
96
96
  }
97
- wait(for: [exp], timeout: 1.0)
97
+ wait(for: [exp], timeout: 5.0)
98
98
  XCTAssertTrue(captured)
99
99
  XCTAssertNil(capturedSamples)
100
100
  }
@@ -117,7 +117,7 @@ final class FFTProcessorTests: XCTestCase {
117
117
  samples.withUnsafeBufferPointer { p in
118
118
  proc.ingest(samples: p.baseAddress!, frameCount: n, sampleRate: 48000)
119
119
  }
120
- wait(for: [exp], timeout: 1.0)
120
+ wait(for: [exp], timeout: 5.0)
121
121
  let fft = try XCTUnwrap(capturedFft)
122
122
  let mags = arrayBufferToFloats(fft)
123
123
  let peakBin = mags.indices.max(by: { mags[$0] < mags[$1] }) ?? -1
@@ -205,7 +205,7 @@ final class FFTProcessorTests: XCTestCase {
205
205
  closure(leftIn, leftOut, n, 1, 0, 2)
206
206
  closure(rightIn, rightOut, n, 1, 1, 2)
207
207
 
208
- wait(for: [exp], timeout: 1.0)
208
+ wait(for: [exp], timeout: 5.0)
209
209
  let samples = try XCTUnwrap(capturedSamples)
210
210
  XCTAssertEqual(samples.size, n * MemoryLayout<Float>.size)
211
211
 
@@ -233,7 +233,9 @@ final class FFTProcessorTests: XCTestCase {
233
233
  }
234
234
  let exp = expectation(description: "drained")
235
235
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { exp.fulfill() }
236
- wait(for: [exp], timeout: 1.0)
236
+ // Generous timeout: the 0.1s drain only needs to outlast the 1000ms
237
+ // throttle window; the ceiling is headroom for a loaded CI main queue.
238
+ wait(for: [exp], timeout: 10.0)
237
239
  XCTAssertEqual(calls, 1, "second back-to-back call must be throttled")
238
240
  }
239
241
 
@@ -587,6 +587,22 @@ class TrackPlayer: HybridTrackPlayerSpec {
587
587
  canSkipNext: self.cachedSkipCapability.canSkipNext,
588
588
  canSkipPrevious: self.cachedSkipCapability.canSkipPrevious
589
589
  )
590
+ // Route CarPlay Up Next row taps through the same steps the
591
+ // `skipToIndex` Hybrid runs — engine guard, index validation,
592
+ // and cast routing (a tap during an active cast session jumps
593
+ // the RECEIVER; the local engine stays paused, so a local index
594
+ // write would desync from it) — then the local index write.
595
+ // Set + invoked on main; the scene delegate calls it from the
596
+ // Up Next row handler.
597
+ CarPlayCoordinator.shared.skipToIndexHandler = { [weak self] index in
598
+ guard let self = self else { return }
599
+ guard self.engine != nil else { return }
600
+ guard let target = InputGuards.validQueueIndex(
601
+ Double(index), count: self.tracks.count
602
+ ) else { return }
603
+ if CastTransportRouter.routeSkipToIndex(index: target) { return }
604
+ self.applyNewCurrentIndex(newIndex: target, reason: .userSkipToIndex)
605
+ }
590
606
  // Refresh on every configure() so a header / UA update lands
591
607
  // on the next artwork resolve. Live config — read fresh on
592
608
  // each track-change resolve.
@@ -891,6 +907,8 @@ class TrackPlayer: HybridTrackPlayerSpec {
891
907
  // configure cycle.
892
908
  let coordinator = CarPlayCoordinator.shared
893
909
  coordinator.dataProvider.setSnapshot(nil)
910
+ coordinator.queueProvider.setSnapshot(nil)
911
+ coordinator.skipToIndexHandler = nil
894
912
  coordinator.pending.clear()
895
913
  coordinator.loader.clearCache()
896
914
  // engine.release() drains the underlying AVQueuePlayer's items +
@@ -2089,6 +2107,11 @@ class TrackPlayer: HybridTrackPlayerSpec {
2089
2107
  /// - `destroy` (resets to (false, false) so a final emit lets
2090
2108
  /// subscribers know the player is gone)
2091
2109
  internal func recomputeCapabilities() {
2110
+ // Every queue / current-index / repeat-mode change funnels through here,
2111
+ // so it's the single point that keeps the CarPlay Up Next mirror current
2112
+ // — done before the skip-capability dedup guard below so reorder/metadata
2113
+ // changes (which don't flip skip capability) still refresh the mirror.
2114
+ syncCarPlayQueueMirror()
2092
2115
  let mode = repeatModeAsSkipMode()
2093
2116
  let count = tracks.count
2094
2117
  let cur = currentTrackIndex
@@ -2109,6 +2132,30 @@ class TrackPlayer: HybridTrackPlayerSpec {
2109
2132
  )
2110
2133
  }
2111
2134
 
2135
+ /// Mirror the live queue + current index into the process-wide
2136
+ /// [CarPlayQueueProvider] so the CarPlay Up Next surface renders the
2137
+ /// current playback queue without round-tripping through JS. Driven
2138
+ /// from `recomputeCapabilities()`, which every queue / current-index /
2139
+ /// repeat-mode change funnels through. An empty queue clears the
2140
+ /// mirror to `nil` so the Up Next button hides. Main-thread only
2141
+ /// (queue state is main-bound).
2142
+ internal func syncCarPlayQueueMirror() {
2143
+ guard !self.tracks.isEmpty else {
2144
+ CarPlayCoordinator.shared.queueProvider.setSnapshot(nil)
2145
+ return
2146
+ }
2147
+ let entries = self.tracks.map { track in
2148
+ CarPlayQueueEntry(
2149
+ title: track.title ?? "",
2150
+ subtitle: track.artist ?? track.album,
2151
+ artworkUrl: track.artworkUrl
2152
+ )
2153
+ }
2154
+ CarPlayCoordinator.shared.queueProvider.setSnapshot(
2155
+ CarPlayQueueSnapshot(entries: entries, currentIndex: self.currentTrackIndex)
2156
+ )
2157
+ }
2158
+
2112
2159
  /// Apply a new `currentTrackIndex` and sync the AVQueuePlayer.
2113
2160
  ///
2114
2161
  /// **Forward skip** (newIndex > oldIndex) — surgical trim of items
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-queue-player",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Nitro Modules audio playback library for React Native — gapless, EQ, crossfade, automotive, voice, casting",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/index.d.ts",