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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/AirPlayEngine.kt +1 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/CrossfadeEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/GaplessEngine.kt +3 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/MediaItemBuilder.kt +13 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackEngine.kt +8 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +27 -54
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +15 -15
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +52 -23
- package/android/src/main/res/values/strings.xml +0 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonDispatchTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaButtonPreferencesTest.kt +99 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/MediaItemBuilderTest.kt +36 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackEngineInterfaceTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackBrowseTest.kt +8 -12
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackSearchTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +87 -19
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlayerCommandsForTest.kt +9 -18
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -0
- package/ios/CarPlayCoordinator.swift +10 -0
- package/ios/CarPlayQueueBuilder.swift +82 -0
- package/ios/CarPlayQueueProvider.swift +73 -0
- package/ios/CarPlaySceneDelegate.swift +79 -7
- package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
- package/ios/Tests/FFTProcessorTests.swift +9 -7
- package/ios/TrackPlayer.swift +47 -0
- 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`.
|
|
192
|
-
///
|
|
193
|
-
///
|
|
194
|
-
///
|
|
195
|
-
///
|
|
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
|
-
|
|
198
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -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.
|
|
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",
|