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.
- package/android/src/main/java/com/margelo/nitro/queueplayer/BufferStateTranslator.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/CarConnectionState.kt +46 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/Equalizer.kt +1 -3
- package/android/src/main/java/com/margelo/nitro/queueplayer/HeadlessJsMediaService.kt +2 -4
- package/android/src/main/java/com/margelo/nitro/queueplayer/LookaheadCacheWriter.kt +7 -9
- package/android/src/main/java/com/margelo/nitro/queueplayer/MilestoneTracker.kt +1 -2
- package/android/src/main/java/com/margelo/nitro/queueplayer/NotificationProvider.kt +6 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackService.kt +33 -25
- package/android/src/main/java/com/margelo/nitro/queueplayer/PlaybackServiceCallback.kt +16 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/ProgressEmissionGate.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueMutationArithmetic.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/QueueSkipArithmetic.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/RemoteCommandDeduper.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayer.kt +9 -0
- package/android/src/main/java/com/margelo/nitro/queueplayer/SleepTimerCore.kt +1 -1
- package/android/src/main/java/com/margelo/nitro/queueplayer/TrackPlayer.kt +40 -27
- package/android/src/main/java/com/margelo/nitro/queueplayer/TransportStateTranslator.kt +2 -2
- package/android/src/main/res/values/strings.xml +0 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/AudioPipelineRenderersFactoryTest.kt +2 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/AvrcpMetadataTest.kt +1 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/BrowseCallbackRegistryTest.kt +1 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineLifecycleTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/CrossfadeEngineReplayGainTest.kt +3 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerEngineTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerKtTest.kt +1 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/EqualizerLegacyEngineTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/FFTProcessorTeeTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineLifecycleTest.kt +0 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/GaplessEngineReplayGainTest.kt +3 -8
- package/android/src/test/java/com/margelo/nitro/queueplayer/HeadlessJsMediaServiceTest.kt +2 -4
- 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 +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/MilestoneTrackerTest.kt +1 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/NowPlayingFormatExtractorTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchAwareAudioProcessorChainTest.kt +1 -1
- package/android/src/test/java/com/margelo/nitro/queueplayer/PitchCorrectionTest.kt +2 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceCallbackTest.kt +148 -7
- package/android/src/test/java/com/margelo/nitro/queueplayer/PlaybackServiceLifecycleTest.kt +2 -0
- package/android/src/test/java/com/margelo/nitro/queueplayer/ProgressEmissionGateTest.kt +1 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/SessionCommandForwardingPlayerTest.kt +58 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerEventsTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerMutationTest.kt +2 -4
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPitchCorrectionTest.kt +1 -2
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerPlaybackModeTest.kt +2 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerReplayGainTest.kt +1 -3
- package/android/src/test/java/com/margelo/nitro/queueplayer/TrackPlayerTransportTest.kt +2 -5
- package/android/src/test/java/com/margelo/nitro/queueplayer/VisualizerKtTest.kt +1 -2
- package/ios/AVPlayerItemQueueItemId.swift +2 -2
- package/ios/AVQueueBuilder.swift +12 -14
- package/ios/CarPlayCoordinator.swift +38 -0
- package/ios/CarPlayQueueBuilder.swift +82 -0
- package/ios/CarPlayQueueProvider.swift +73 -0
- package/ios/CarPlaySceneDelegate.swift +81 -7
- package/ios/CrossfadeEngine.swift +4 -5
- package/ios/InputGuards.swift +3 -3
- package/ios/LookaheadCache.swift +4 -6
- package/ios/LookaheadCachePrefetcher.swift +1 -1
- package/ios/MilestoneTracker.swift +1 -2
- package/ios/PlaybackErrorMapping.swift +6 -7
- package/ios/PlayerStateDerivation.swift +1 -2
- package/ios/ProgressEmissionGate.swift +1 -1
- package/ios/QueueMutationArithmetic.swift +2 -2
- package/ios/QueueSkipArithmetic.swift +8 -12
- package/ios/Siri/PlayMediaIntentHandler.swift +1 -5
- package/ios/SleepTimerCore.swift +1 -1
- package/ios/Tests/AudioTapProviderReplayGainTests.swift +5 -9
- package/ios/Tests/CarPlayBridgeTests.swift +2 -6
- package/ios/Tests/CarPlayBrowseBuilderTests.swift +1 -2
- package/ios/Tests/CarPlayCoordinatorTests.swift +35 -0
- package/ios/Tests/CarPlayQueueBuilderTests.swift +145 -0
- package/ios/Tests/EQProcessorTests.swift +1 -4
- package/ios/Tests/EQTapTests.swift +1 -2
- package/ios/Tests/EqualizerAudioMixProviderTests.swift +3 -4
- package/ios/Tests/EqualizerHybridTests.swift +1 -3
- package/ios/Tests/FFTProcessorTests.swift +10 -10
- package/ios/Tests/GaplessEngineLifecycleTests.swift +0 -2
- package/ios/Tests/LookaheadCacheEvictionTests.swift +1 -4
- package/ios/Tests/MetadataReaderTests.swift +0 -2
- package/ios/Tests/MilestoneTrackerTests.swift +1 -3
- package/ios/Tests/NowPlayingFormatExtractorTests.swift +3 -4
- package/ios/Tests/PlaybackErrorMappingTests.swift +0 -3
- package/ios/Tests/PlaybackModeStateMachineTests.swift +5 -9
- package/ios/Tests/PlayerStateDerivationTests.swift +1 -2
- package/ios/Tests/ProgressEmissionGateTests.swift +1 -3
- package/ios/Tests/QueueMutationArithmeticTests.swift +0 -3
- package/ios/Tests/RemoteCommandsTests.swift +2 -5
- package/ios/Tests/ReplayGainExtractorTests.swift +0 -5
- package/ios/Tests/SkipCapabilityTests.swift +4 -7
- package/ios/Tests/VoiceDonationTests.swift +4 -4
- package/ios/TrackPlayer.swift +52 -2
- package/lib/typescript/TrackPlayer.nitro.d.ts +17 -0
- package/lib/typescript/TrackPlayer.nitro.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.cpp +5 -0
- package/nitrogen/generated/android/c++/JHybridTrackPlayerSpec.hpp +1 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/queueplayer/HybridTrackPlayerSpec.kt +4 -0
- package/nitrogen/generated/ios/c++/HybridTrackPlayerSpecSwift.hpp +8 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridTrackPlayerSpec_cxx.swift +12 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridTrackPlayerSpec.hpp +1 -0
- package/package.json +1 -1
- 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).
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
35
|
-
///
|
|
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 {
|
package/ios/AVQueueBuilder.swift
CHANGED
|
@@ -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
|
|
7
|
-
///
|
|
8
|
-
///
|
|
9
|
-
///
|
|
10
|
-
///
|
|
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
|
|
139
|
-
///
|
|
140
|
-
///
|
|
141
|
-
///
|
|
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
|
|
175
|
-
///
|
|
176
|
-
///
|
|
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`.
|
|
190
|
-
///
|
|
191
|
-
///
|
|
192
|
-
///
|
|
193
|
-
///
|
|
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
|
-
|
|
196
|
-
|
|
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
|
|
36
|
-
///
|
|
37
|
-
///
|
|
38
|
-
///
|
|
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
|
package/ios/InputGuards.swift
CHANGED
|
@@ -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
|
|
20
|
-
///
|
|
21
|
-
///
|
|
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
|
package/ios/LookaheadCache.swift
CHANGED
|
@@ -810,13 +810,11 @@ public enum LookaheadCacheError: Error {
|
|
|
810
810
|
}
|
|
811
811
|
|
|
812
812
|
/// Abstraction over `LookaheadCache.download` so the prefetcher
|
|
813
|
-
/// can
|
|
814
|
-
///
|
|
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)
|
|
818
|
-
///
|
|
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
|
|
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)
|
|
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: -
|
|
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
|
|
87
|
-
// parallel surface
|
|
88
|
-
// stringValue)
|
|
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
|
-
///
|
|
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
|
-
///
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
3
|
-
///
|
|
4
|
-
///
|
|
5
|
-
/// `-cxx-interoperability-mode=default` settings
|
|
6
|
-
///
|
|
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
|
|
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
|
|
20
|
-
///
|
|
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).
|
|
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 ?? ""
|
package/ios/SleepTimerCore.swift
CHANGED
|
@@ -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
|
|
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
|
///
|