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
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
package/website/guide/carplay.md
CHANGED
|
@@ -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.
|