react-native-audio-browser 0.2.0-next.22 → 0.2.0-next.23

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.
@@ -12,6 +12,10 @@ import os.log
12
12
  /// preconditions are checked up front, the underlying call always gets a
13
13
  /// completion, and every failure is logged, with an optional caller completion
14
14
  /// for call sites that chain on the result.
15
+ ///
16
+ /// A push onto a full stack replaces the top template (`TopTemplateReplacer`).
17
+ /// The stack mutations wait for that replacement to settle before they run,
18
+ /// so none of them lands between its pop and its push.
15
19
  @MainActor
16
20
  extension CPInterfaceController {
17
21
  private static let stackLogger = Logger(subsystem: "com.audiobrowser", category: "TemplateStack")
@@ -26,9 +30,43 @@ extension CPInterfaceController {
26
30
  /// push — taking down the whole app, not just the CarPlay scene.
27
31
  private static let maximumStackDepth = 5
28
32
 
33
+ private static var topReplacerKey: UInt8 = 0
34
+
35
+ private var topReplacer: TopTemplateReplacer {
36
+ if let replacer = objc_getAssociatedObject(self, &Self.topReplacerKey) as? TopTemplateReplacer {
37
+ return replacer
38
+ }
39
+ let replacer = TopTemplateReplacer()
40
+ objc_setAssociatedObject(self, &Self.topReplacerKey, replacer, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
41
+ return replacer
42
+ }
43
+
44
+ private func whenStackSettled(_ mutation: @escaping (CPInterfaceController) -> Void) {
45
+ topReplacer.whenSettled { [weak self] in
46
+ guard let self else { return }
47
+ mutation(self)
48
+ }
49
+ }
50
+
51
+ /// Pushes `template`, skipping the raise case (already in the hierarchy). At
52
+ /// the depth limit it replaces the top template.
53
+ func safePush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
54
+ whenStackSettled { controller in
55
+ let templates = controller.templates
56
+ guard templates.count >= Self.maximumStackDepth, !templates.contains(where: { $0 === template }) else {
57
+ controller.guardedPush(template, animated: animated, completion: completion)
58
+ return
59
+ }
60
+ controller.topReplacer.push(
61
+ template, replacingTopOf: GuardedTemplateStack(controller: controller), animated: animated,
62
+ completion: completion,
63
+ )
64
+ }
65
+ }
66
+
29
67
  /// Pushes `template`, skipping the raise cases (already in the hierarchy,
30
68
  /// stack at the depth limit).
31
- func safePush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
69
+ fileprivate func guardedPush(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
32
70
  guard !templates.contains(where: { $0 === template }) else {
33
71
  Self.stackLogger.warning("push skipped: \(Self.name(of: template)) is already in the stack")
34
72
  completion?(false, nil)
@@ -48,7 +86,7 @@ extension CPInterfaceController {
48
86
  }
49
87
 
50
88
  /// Pops the top template, skipping the raise case (nothing above the root).
51
- func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
89
+ fileprivate func guardedPop(animated: Bool, completion: ((Bool, Error?) -> Void)?) {
52
90
  guard templates.count > 1 else {
53
91
  Self.stackLogger.warning("pop skipped: already at the root template")
54
92
  completion?(false, nil)
@@ -62,41 +100,54 @@ extension CPInterfaceController {
62
100
  }
63
101
  }
64
102
 
103
+ /// Pops the top template, skipping the raise case (nothing above the root).
104
+ func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
105
+ whenStackSettled { controller in
106
+ controller.guardedPop(animated: animated, completion: completion)
107
+ }
108
+ }
109
+
65
110
  /// Pops back to `template`, skipping the raise case (not in the hierarchy).
66
111
  /// A no-op success when it is already the top template.
67
112
  func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
68
- guard templates.contains(where: { $0 === template }) else {
69
- Self.stackLogger.warning("pop(to:) skipped: \(Self.name(of: template)) is not in the stack")
70
- completion?(false, nil)
71
- return
72
- }
73
- guard topTemplate !== template else {
74
- completion?(true, nil)
75
- return
76
- }
77
- pop(to: template, animated: animated) { popped, error in
78
- if let error {
79
- Self.stackLogger.error("pop(to:) \(Self.name(of: template)) failed: \(error.localizedDescription)")
113
+ whenStackSettled { controller in
114
+ guard controller.templates.contains(where: { $0 === template }) else {
115
+ Self.stackLogger.warning("pop(to:) skipped: \(Self.name(of: template)) is not in the stack")
116
+ completion?(false, nil)
117
+ return
118
+ }
119
+ guard controller.topTemplate !== template else {
120
+ completion?(true, nil)
121
+ return
122
+ }
123
+ controller.pop(to: template, animated: animated) { popped, error in
124
+ if let error {
125
+ Self.stackLogger.error("pop(to:) \(Self.name(of: template)) failed: \(error.localizedDescription)")
126
+ }
127
+ completion?(popped, error)
80
128
  }
81
- completion?(popped, error)
82
129
  }
83
130
  }
84
131
 
85
132
  func safePopToRoot(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
86
- popToRootTemplate(animated: animated) { popped, error in
87
- if let error {
88
- Self.stackLogger.error("popToRootTemplate failed: \(error.localizedDescription)")
133
+ whenStackSettled { controller in
134
+ controller.popToRootTemplate(animated: animated) { popped, error in
135
+ if let error {
136
+ Self.stackLogger.error("popToRootTemplate failed: \(error.localizedDescription)")
137
+ }
138
+ completion?(popped, error)
89
139
  }
90
- completion?(popped, error)
91
140
  }
92
141
  }
93
142
 
94
143
  func safeSetRoot(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
95
- setRootTemplate(template, animated: animated) { set, error in
96
- if let error {
97
- Self.stackLogger.error("setRootTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
144
+ whenStackSettled { controller in
145
+ controller.setRootTemplate(template, animated: animated) { set, error in
146
+ if let error {
147
+ Self.stackLogger.error("setRootTemplate \(Self.name(of: template)) failed: \(error.localizedDescription)")
148
+ }
149
+ completion?(set, error)
98
150
  }
99
- completion?(set, error)
100
151
  }
101
152
  }
102
153
 
@@ -124,3 +175,22 @@ extension CPInterfaceController {
124
175
  }
125
176
  }
126
177
  }
178
+
179
+ /// The guarded pop and push a replacement is made of, so a push that still
180
+ /// cannot fit skips and logs rather than raising.
181
+ @MainActor
182
+ private struct GuardedTemplateStack: TemplateStack {
183
+ let controller: CPInterfaceController
184
+
185
+ var templates: [CPTemplate] {
186
+ controller.templates
187
+ }
188
+
189
+ func popTop(completion: @escaping (Bool, Error?) -> Void) {
190
+ controller.guardedPop(animated: false, completion: completion)
191
+ }
192
+
193
+ func push(_ template: CPTemplate, animated: Bool, completion: @escaping (Bool, Error?) -> Void) {
194
+ controller.guardedPush(template, animated: animated, completion: completion)
195
+ }
196
+ }
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+ import os.log
3
+
4
+ /// The stack mutations a top-template replacement is made of:
5
+ /// `CPInterfaceController` in the app, a fake in tests.
6
+ @MainActor
7
+ protocol TemplateStack {
8
+ associatedtype Template: AnyObject
9
+
10
+ /// Root first.
11
+ var templates: [Template] { get }
12
+
13
+ /// Unanimated.
14
+ func popTop(completion: @escaping (Bool, Error?) -> Void)
15
+ func push(_ template: Template, animated: Bool, completion: @escaping (Bool, Error?) -> Void)
16
+ }
17
+
18
+ /// Pushes onto a full template stack by replacing its top template — an
19
+ /// unanimated pop, then the push — and holds back every other stack mutation
20
+ /// until both have completed. See "Template Depth" in
21
+ /// `website/guide/carplay.md`.
22
+ @MainActor
23
+ final class TopTemplateReplacer {
24
+ private let logger = Logger(subsystem: "com.audiobrowser", category: "TemplateStack")
25
+ private var deferred: [() -> Void] = []
26
+
27
+ /// True from the pop until the push completes.
28
+ private(set) var isReplacing = false
29
+
30
+ /// Runs `mutation` now, or — while a replacement is in flight — once it has
31
+ /// settled, after the mutations that arrived before it.
32
+ func whenSettled(_ mutation: @escaping () -> Void) {
33
+ guard isReplacing else {
34
+ mutation()
35
+ return
36
+ }
37
+ deferred.append(mutation)
38
+ }
39
+
40
+ /// Pushes `template` onto `stack` in place of its top template. When the
41
+ /// push fails, the popped template is pushed back, so the user stays where
42
+ /// they were. `completion` reports the push of `template` itself.
43
+ ///
44
+ /// Refuses a stack with nothing above the root to replace, and a call made
45
+ /// while a replacement is in flight: route it through `whenSettled`.
46
+ func push<Stack: TemplateStack>(
47
+ _ template: Stack.Template,
48
+ replacingTopOf stack: Stack,
49
+ animated: Bool,
50
+ completion: ((Bool, Error?) -> Void)? = nil,
51
+ ) {
52
+ let templates = stack.templates
53
+ guard !isReplacing, templates.count > 1, let top = templates.last else {
54
+ logger.error("push skipped: \(Self.name(of: template)) has no template to replace (\(templates.count) templates)")
55
+ completion?(false, nil)
56
+ return
57
+ }
58
+ isReplacing = true
59
+ logger.info("push at the depth limit: replacing the top, \(Self.name(of: top)), with \(Self.name(of: template))")
60
+
61
+ stack.popTop { [self] popped, error in
62
+ guard popped else {
63
+ logger.error("replace abandoned: pop failed, \(Self.name(of: template)) not pushed (\(stack.templates.count) templates)")
64
+ settle(completion, pushed: false, error: error)
65
+ return
66
+ }
67
+ stack.push(template, animated: animated) { [self] pushed, error in
68
+ guard !pushed else {
69
+ settle(completion, pushed: true, error: error)
70
+ return
71
+ }
72
+ logger.error("replace failed: \(Self.name(of: template)) not pushed, restoring \(Self.name(of: top))")
73
+ stack.push(top, animated: false) { [self] restored, _ in
74
+ if !restored {
75
+ logger.error("replace failed: \(Self.name(of: top)) not restored (\(stack.templates.count) templates)")
76
+ }
77
+ settle(completion, pushed: false, error: error)
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ private func settle(_ completion: ((Bool, Error?) -> Void)?, pushed: Bool, error: Error?) {
84
+ // Still replacing here, so a mutation the completion issues queues behind
85
+ // the ones that arrived during the replacement.
86
+ completion?(pushed, error)
87
+ isReplacing = false
88
+ while !isReplacing, !deferred.isEmpty {
89
+ deferred.removeFirst()()
90
+ }
91
+ }
92
+
93
+ private static func name(of template: AnyObject) -> String {
94
+ String(describing: type(of: template))
95
+ }
96
+ }
@@ -0,0 +1,172 @@
1
+ import Foundation
2
+ import Testing
3
+
4
+ @testable import AudioBrowserTestable
5
+
6
+ private final class Page {
7
+ let name: String
8
+ init(_ name: String) { self.name = name }
9
+ }
10
+
11
+ /// A template stack whose mutations complete only when the test steps them,
12
+ /// the way CarPlay's complete on a later run-loop turn.
13
+ @MainActor
14
+ private final class FakeStack: TemplateStack {
15
+ private(set) var templates: [Page]
16
+ private(set) var calls: [String] = []
17
+ var failingPushes: Set<String> = []
18
+ var failsPop = false
19
+ private var pending: [() -> Void] = []
20
+
21
+ init(_ names: String...) {
22
+ templates = names.map(Page.init)
23
+ }
24
+
25
+ var names: [String] {
26
+ templates.map(\.name)
27
+ }
28
+
29
+ func popTop(completion: @escaping (Bool, Error?) -> Void) {
30
+ calls.append("pop")
31
+ pending.append { [self] in
32
+ guard !failsPop else {
33
+ completion(false, nil)
34
+ return
35
+ }
36
+ templates.removeLast()
37
+ completion(true, nil)
38
+ }
39
+ }
40
+
41
+ func push(_ template: Page, animated: Bool, completion: @escaping (Bool, Error?) -> Void) {
42
+ calls.append("push \(template.name)\(animated ? " animated" : "")")
43
+ pending.append { [self] in
44
+ guard !failingPushes.contains(template.name) else {
45
+ completion(false, nil)
46
+ return
47
+ }
48
+ templates.append(template)
49
+ completion(true, nil)
50
+ }
51
+ }
52
+
53
+ func step() {
54
+ pending.removeFirst()()
55
+ }
56
+
57
+ func run() {
58
+ while !pending.isEmpty {
59
+ step()
60
+ }
61
+ }
62
+ }
63
+
64
+ @Suite("TopTemplateReplacer")
65
+ @MainActor
66
+ struct TopTemplateReplacerTests {
67
+ private let replacer = TopTemplateReplacer()
68
+ private let stack = FakeStack("tabs", "A", "B", "C", "D")
69
+
70
+ @Test func replacesTheTopPageAndKeepsTheRest() {
71
+ var result: Bool?
72
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
73
+ stack.run()
74
+
75
+ #expect(stack.names == ["tabs", "A", "B", "C", "E"])
76
+ #expect(stack.calls == ["pop", "push E animated"])
77
+ #expect(result == true)
78
+ #expect(!replacer.isReplacing)
79
+ }
80
+
81
+ @Test func thePushWaitsForThePopToComplete() {
82
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
83
+ #expect(stack.calls == ["pop"])
84
+ stack.step()
85
+ #expect(stack.calls == ["pop", "push E animated"])
86
+ }
87
+
88
+ @Test func aMutationArrivingMidReplacementRunsOnceItHasSettled() {
89
+ var seen: [[String]] = []
90
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
91
+ stack.step()
92
+ replacer.whenSettled { seen.append(stack.names) }
93
+ #expect(seen.isEmpty)
94
+ stack.run()
95
+
96
+ #expect(seen == [["tabs", "A", "B", "C", "E"]])
97
+ }
98
+
99
+ @Test func deferredMutationsRunInArrivalOrderBehindASecondReplacement() {
100
+ var order: [String] = []
101
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true)
102
+ replacer.whenSettled { [replacer, stack] in
103
+ order.append("F")
104
+ replacer.push(Page("F"), replacingTopOf: stack, animated: true)
105
+ }
106
+ replacer.whenSettled { order.append("after F: \(stack.names.last ?? "")") }
107
+ stack.run()
108
+
109
+ #expect(stack.names == ["tabs", "A", "B", "C", "F"])
110
+ #expect(order == ["F", "after F: F"])
111
+ }
112
+
113
+ @Test func aMutationIssuedByTheCompletionQueuesBehindEarlierArrivals() {
114
+ var order: [String] = []
115
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { [replacer] _, _ in
116
+ replacer.whenSettled { order.append("from completion") }
117
+ }
118
+ replacer.whenSettled { order.append("arrived mid-replacement") }
119
+ stack.run()
120
+
121
+ #expect(order == ["arrived mid-replacement", "from completion"])
122
+ }
123
+
124
+ @Test func aFailedPopLeavesTheStackAsItWas() {
125
+ stack.failsPop = true
126
+ var result: Bool?
127
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
128
+ stack.run()
129
+
130
+ #expect(stack.names == ["tabs", "A", "B", "C", "D"])
131
+ #expect(stack.calls == ["pop"])
132
+ #expect(result == false)
133
+ #expect(!replacer.isReplacing)
134
+ }
135
+
136
+ @Test func aFailedPushRestoresThePoppedPage() {
137
+ stack.failingPushes = ["E"]
138
+ let top = stack.templates[4]
139
+ var result: Bool?
140
+ var ranAfter = false
141
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
142
+ replacer.whenSettled { ranAfter = true }
143
+ stack.run()
144
+
145
+ #expect(stack.names == ["tabs", "A", "B", "C", "D"])
146
+ #expect(stack.templates[4] === top)
147
+ #expect(stack.calls == ["pop", "push E animated", "push D"])
148
+ #expect(result == false)
149
+ #expect(ranAfter)
150
+ }
151
+
152
+ @Test func aFailedRestoreStillSettles() {
153
+ stack.failingPushes = ["E", "D"]
154
+ var result: Bool?
155
+ replacer.push(Page("E"), replacingTopOf: stack, animated: true) { pushed, _ in result = pushed }
156
+ stack.run()
157
+
158
+ #expect(stack.names == ["tabs", "A", "B", "C"])
159
+ #expect(result == false)
160
+ #expect(!replacer.isReplacing)
161
+ }
162
+
163
+ @Test func refusesAStackWithNothingAboveTheRoot() {
164
+ let rootOnly = FakeStack("tabs")
165
+ var result: Bool?
166
+ replacer.push(Page("E"), replacingTopOf: rootOnly, animated: true) { pushed, _ in result = pushed }
167
+
168
+ #expect(rootOnly.calls.isEmpty)
169
+ #expect(result == false)
170
+ #expect(!replacer.isReplacing)
171
+ }
172
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.22",
3
+ "version": "0.2.0-next.23",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -601,6 +601,36 @@ Return a browse path to enable the album line for that track, or `undefined` to
601
601
  CarPlay renders the tappable album/artist button as a **separate line built from the track's `album`** (visible as the third metadata line, with a chevron). Tracks without an `album` have no string for CarPlay to turn into a button — the artist line alone is never tappable. Set `album` on any track that should offer album-line navigation. See [Now Playing](/guide/now-playing) for the full field-by-surface rendering matrix.
602
602
  :::
603
603
 
604
+ ## Template Depth
605
+
606
+ CarPlay limits an audio app's template stack to **five templates, the tab bar included** (CarPlay App Programming Guide, p. 15), so the stack is full four pushes in. Now Playing and Up Next each take a slot like any list. A browse tree with a fixed shape (tabs → show → episodes) never gets there; one that can be drilled without end — a place, a place near it, a place near that — does.
607
+
608
+ At the limit, the library replaces the **top** page with the new one, so a push always lands:
609
+
610
+ ```
611
+ [tabs, A, B, C, D] + push E
612
+ [tabs, A, B, C, E]
613
+ ```
614
+
615
+ - **Forward never blocks.** A deeper list, Now Playing and Up Next all open at full depth.
616
+ - **Back skips one page.** From `E` it goes to `C`: `D`, the page the listener came from, is the one that was replaced. Below full depth, back works as usual.
617
+ - **The swap is a pop and a push.** CarPlay can't replace a template in place, so the library pops the top one without animation and then pushes the new one.
618
+
619
+ Each replacement is logged under the `com.audiobrowser` subsystem, category `TemplateStack`:
620
+
621
+ ```
622
+ push at the depth limit: replacing the top, CPListTemplate, with CPListTemplate
623
+ ```
624
+
625
+ To watch for it on the simulator:
626
+
627
+ ```sh
628
+ xcrun simctl spawn booted log stream --level info \
629
+ --predicate 'subsystem == "com.audiobrowser" AND category == "TemplateStack"'
630
+ ```
631
+
632
+ An `error` line in that category means the pop or the push failed; it names the template involved.
633
+
604
634
  ## Testing
605
635
 
606
636
  Test your app in the `CarPlay Simulator.app`, which is part of the [Additional Tools for Xcode](https://developer.apple.com/download/all/?q=additional%20tools%20for%20xcode). Siri voice search works there, so you can exercise the full intent flow without a car. CarPlay support on the iOS Simulator can be a little flaky — if something looks broken, retry or fall back to a physical device for final verification.