react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29
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/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension CarPlaySession {
|
|
5
|
+
/// A row or tile was tapped. CarPlay is released at once, so the list never locks: a browse
|
|
6
|
+
/// pushes its destination and fills it in, and playback owns its loading state on Now Playing.
|
|
7
|
+
func select(_ track: Track, completion: @escaping () -> Void) {
|
|
8
|
+
logger.info("Selected track: \(track.title)")
|
|
9
|
+
// A tap queued before `end()`: the next run owns the screen.
|
|
10
|
+
guard !ended else {
|
|
11
|
+
completion()
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if track.src != nil, let identity = track.identity, player?.currentTrack?.identity == identity {
|
|
16
|
+
try? audioBrowser.play()
|
|
17
|
+
nowPlaying.showNowPlaying()
|
|
18
|
+
completion()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// A playable track always lands on Now Playing, so it is pushed now with the tapped
|
|
23
|
+
// track's fields: the queue expansion and media resolve may both hit the network.
|
|
24
|
+
if track.src != nil {
|
|
25
|
+
audioBrowser.engine?.loadNowPlayingMetadata(for: track)
|
|
26
|
+
nowPlaying.prepare(for: track)
|
|
27
|
+
nowPlaying.showNowPlaying()
|
|
28
|
+
}
|
|
29
|
+
completion()
|
|
30
|
+
|
|
31
|
+
spawn { session in
|
|
32
|
+
guard let player = session.player else { return }
|
|
33
|
+
let result = await session.trackSelector.select(track: track, player: player)
|
|
34
|
+
guard !Task.isCancelled else {
|
|
35
|
+
if track.src != nil { session.restoreNowPlaying() }
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
switch result {
|
|
39
|
+
case let .play(intent):
|
|
40
|
+
session.player?.execute(intent)
|
|
41
|
+
session.nowPlaying.showNowPlaying(popToFront: false)
|
|
42
|
+
case .intercepted:
|
|
43
|
+
session.nowPlaying.showNowPlaying(popToFront: false)
|
|
44
|
+
case let .browse(path):
|
|
45
|
+
await session.navigate(to: path, title: track.title)
|
|
46
|
+
case .none:
|
|
47
|
+
break
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Puts the current track back on the now-playing surface, over a tapped one that won't play.
|
|
53
|
+
private func restoreNowPlaying() {
|
|
54
|
+
guard let engine = audioBrowser.engine else { return }
|
|
55
|
+
if let current = player?.currentTrack {
|
|
56
|
+
engine.loadNowPlayingMetadata(for: current)
|
|
57
|
+
engine.nowPlayingUpdater.render()
|
|
58
|
+
} else {
|
|
59
|
+
engine.clearNowPlaying()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/// Pushes `path`'s page as an empty, spinning template that fills in once it appears; or the
|
|
64
|
+
/// Browse Gate's page when the gate gates it; or selects the tab that is that path.
|
|
65
|
+
func navigate(to path: String, title: String) async {
|
|
66
|
+
// A push lands asynchronously, so a double tap is caught by `navigatingPaths` until the
|
|
67
|
+
// pushed template appears.
|
|
68
|
+
guard interfaceController.topTemplate?.carPlayInfo?.path != path, !navigatingPaths.contains(path) else { return }
|
|
69
|
+
navigatingPaths.insert(path)
|
|
70
|
+
var pushing = false
|
|
71
|
+
defer { if !pushing { navigatingPaths.remove(path) } }
|
|
72
|
+
|
|
73
|
+
let chrome = audioBrowser.browseGate.isActive ? await gate(.browse, path: path) : nil
|
|
74
|
+
guard !Task.isCancelled else { return }
|
|
75
|
+
let template: CPTemplate
|
|
76
|
+
if let chrome {
|
|
77
|
+
template = makeGatePage(chrome, path: path, tab: nil)
|
|
78
|
+
} else if selectTab(owning: path) {
|
|
79
|
+
return
|
|
80
|
+
} else {
|
|
81
|
+
template = CPListTemplate.loading(title: title, path: path, loadingTitle: config.carPlayLoadingTitle)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
pushing = true
|
|
85
|
+
interfaceController.safePush(template, animated: true) { [weak self] pushed, error in
|
|
86
|
+
guard !pushed else { return }
|
|
87
|
+
// It will never appear to clear the guard (e.g. at the depth limit).
|
|
88
|
+
self?.navigatingPaths.remove(path)
|
|
89
|
+
if let error { self?.logger.error("pushTemplate failed for \(path): \(error.localizedDescription)") }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Switches to the tab whose path this is, popping any pushed stack first, instead of pushing
|
|
94
|
+
/// a second copy of the tab into another tab's stack. False when no tab has the path, when
|
|
95
|
+
/// the root is not a tab bar, or before iOS 17, which cannot select a tab.
|
|
96
|
+
private func selectTab(owning path: String) -> Bool {
|
|
97
|
+
guard #available(iOS 17.0, *),
|
|
98
|
+
let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
|
|
99
|
+
let tab = tabBar.templates.first(where: { $0.contentPath == path })
|
|
100
|
+
else { return false }
|
|
101
|
+
let isPushed = interfaceController.templates.count > 1
|
|
102
|
+
if tabBar.selectedTemplate === tab, !isPushed { return true }
|
|
103
|
+
guard isPushed else {
|
|
104
|
+
tabBar.select(tab)
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
interfaceController.safePopToRoot(animated: true) { popped, _ in
|
|
108
|
+
// After a failed pop the selection would land under the pushed stack.
|
|
109
|
+
guard popped else { return }
|
|
110
|
+
tabBar.select(tab)
|
|
111
|
+
}
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension CarPlaySession {
|
|
5
|
+
/// Loads a content template's page when it first appears, the moment CarPlay applies updates
|
|
6
|
+
/// (an update made straight after a push is dropped).
|
|
7
|
+
func loadContentIfNeeded(for template: CPListTemplate) {
|
|
8
|
+
guard let path = template.contentPath, template.sections.isEmpty else { return }
|
|
9
|
+
spawn { await $0.loadContent(for: path, into: template) }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// Resolves `path` into a loading template, showing a centered message when the page is
|
|
13
|
+
/// empty, fails, or outlasts `resolveTimeout`.
|
|
14
|
+
func loadContent(for path: String, into template: CPListTemplate) async {
|
|
15
|
+
let templateId = ObjectIdentifier(template)
|
|
16
|
+
guard !loadingTemplates.contains(templateId) else { return }
|
|
17
|
+
loadingTemplates.insert(templateId)
|
|
18
|
+
defer { loadingTemplates.remove(templateId) }
|
|
19
|
+
|
|
20
|
+
let timeout = resolveTimeout
|
|
21
|
+
let watchdog = Task { @MainActor [weak self] in
|
|
22
|
+
do { try await Task.sleep(for: timeout) } catch { return }
|
|
23
|
+
guard let self, template.sections.isEmpty else { return }
|
|
24
|
+
logger.error("loadContent: resolve timed out for \(path)")
|
|
25
|
+
await showNavigationError(
|
|
26
|
+
NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
// Cancelled as soon as the resolve settles: formatting its error is a JS round trip the
|
|
30
|
+
// timeout must not overwrite.
|
|
31
|
+
defer { watchdog.cancel() }
|
|
32
|
+
|
|
33
|
+
do {
|
|
34
|
+
let resolved: ResolvedTrack
|
|
35
|
+
do {
|
|
36
|
+
resolved = try await audioBrowser.browserManager.resolve(path, useCache: true)
|
|
37
|
+
} catch {
|
|
38
|
+
watchdog.cancel()
|
|
39
|
+
throw error
|
|
40
|
+
}
|
|
41
|
+
watchdog.cancel()
|
|
42
|
+
guard !Task.isCancelled else { return }
|
|
43
|
+
// An assistant cell is content in itself, even with no rows; a page of empty sections is
|
|
44
|
+
// empty (ADR 0001).
|
|
45
|
+
let hasChildren = resolved.normalizedSections?.contains { !$0.children.isEmpty } ?? false
|
|
46
|
+
if hasChildren || resolved.carPlaySiriListButton != nil {
|
|
47
|
+
updateTemplate(template, with: resolved)
|
|
48
|
+
} else {
|
|
49
|
+
await showNavigationError(.code(.emptyContent), path: path, on: template)
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
logger.error("Failed to load content for \(path): \(error.localizedDescription)")
|
|
53
|
+
await showNavigationError(NavigationError.from(error), path: path, on: template)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func updateTemplate(_ template: CPListTemplate, with resolvedTrack: ResolvedTrack) {
|
|
58
|
+
template.updateSections(listItemFactory.createSections(from: resolvedTrack))
|
|
59
|
+
template.assistantCellConfiguration = resolvedTrack.carPlaySiriListButton.map { position in
|
|
60
|
+
CPAssistantCellConfiguration(
|
|
61
|
+
position: position == .top ? .top : .bottom, visibility: .always, assistantAction: .playMedia,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Shows a navigation error, as the app's `formatNavigationError` formats it, as the
|
|
67
|
+
/// template's centered message (ADR 0001).
|
|
68
|
+
private func showNavigationError(_ error: NavigationError, path: String, on template: CPListTemplate) async {
|
|
69
|
+
let formatted = await audioBrowser.browserManager.formatted(error, path: path)
|
|
70
|
+
guard !Task.isCancelled else { return }
|
|
71
|
+
let title = formatted.title.isEmpty ? "Couldn't load" : formatted.title
|
|
72
|
+
if isTabRoot(template) {
|
|
73
|
+
// A tab root cannot be popped, so its message is set in place; it renders when the tab
|
|
74
|
+
// is selected.
|
|
75
|
+
if #available(iOS 18.4, *) {
|
|
76
|
+
template.showsSpinnerWhileEmpty = false
|
|
77
|
+
}
|
|
78
|
+
template.setMessage(title, subtitle: formatted.message)
|
|
79
|
+
template.updateSections([])
|
|
80
|
+
} else {
|
|
81
|
+
// A pushed template's empty view renders reliably only as its initial state, so it is
|
|
82
|
+
// replaced. The message has no path: backing out and re-tapping retries.
|
|
83
|
+
interfaceController.safeReplaceTop(
|
|
84
|
+
template, with: CPListTemplate.message(title, subtitle: formatted.message, listTitle: template.title), animated: false,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private func isTabRoot(_ template: CPTemplate) -> Bool {
|
|
90
|
+
(interfaceController.rootTemplate as? CPTabBarTemplate)?.templates.contains { $0 === template } ?? false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension CarPlaySession {
|
|
5
|
+
/// Re-resolves `path`, bypassing the cache, into every displayed template showing it.
|
|
6
|
+
func refreshContent(for path: String) async {
|
|
7
|
+
do {
|
|
8
|
+
let resolved = try await audioBrowser.browserManager.resolve(path, useCache: false)
|
|
9
|
+
guard !Task.isCancelled else { return }
|
|
10
|
+
refreshDisplayedTemplates(for: path, with: resolved)
|
|
11
|
+
} catch {
|
|
12
|
+
logger.error("Failed to refresh content for \(path): \(error.localizedDescription)")
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func refreshDisplayedTemplates(for path: String, with content: ResolvedTrack) {
|
|
17
|
+
for template in displayedListTemplates() where template.contentPath == path {
|
|
18
|
+
updateTemplate(template, with: content)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Re-resolves every displayed template at once, bypassing the cache: all content was
|
|
23
|
+
/// invalidated (e.g. a locale change).
|
|
24
|
+
func refreshAllDisplayedTemplates() async {
|
|
25
|
+
let browserManager = audioBrowser.browserManager
|
|
26
|
+
let loads = displayedListTemplates().compactMap { template in
|
|
27
|
+
template.contentPath.map { path in
|
|
28
|
+
(template, Task<ResolvedTrack?, Never> { @MainActor [logger] in
|
|
29
|
+
do {
|
|
30
|
+
return try await browserManager.resolve(path, useCache: false)
|
|
31
|
+
} catch {
|
|
32
|
+
logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
|
|
33
|
+
return nil
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for load in loads {
|
|
39
|
+
guard let resolved = await load.1.value, !Task.isCancelled else { continue }
|
|
40
|
+
updateTemplate(load.0, with: resolved)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
extension CarPlaySession {
|
|
5
|
+
/// The tabs the tab bar shows: a disabled tab hides, as `CPTabBarTemplate` has no disabled
|
|
6
|
+
/// state (Android Auto filters the same way).
|
|
7
|
+
static func visibleTabs(_ tabs: [Track]) -> [Track] {
|
|
8
|
+
tabs.filter { $0.disabled != true }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
func buildInitialInterface() async {
|
|
12
|
+
let build = beginRootBuild()
|
|
13
|
+
if let tabs = audioBrowser.browserManager.tabs, !tabs.isEmpty {
|
|
14
|
+
await showTabBar(tabs)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
// The first loading root predates the config; re-show it with the app's loading title.
|
|
18
|
+
if let loadingTitle = config.carPlayLoadingTitle {
|
|
19
|
+
interfaceController.safeSetRoot(CPListTemplate.loading(title: nil, path: nil, loadingTitle: loadingTitle), animated: false)
|
|
20
|
+
}
|
|
21
|
+
do {
|
|
22
|
+
let tabs = try await audioBrowser.browserManager.queryTabs()
|
|
23
|
+
guard isCurrent(build) else { return }
|
|
24
|
+
await showTabBar(tabs)
|
|
25
|
+
} catch {
|
|
26
|
+
logger.error("Failed to query tabs: \(error.localizedDescription)")
|
|
27
|
+
await showRootError(NavigationError.from(error), build: build)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/// Builds the tab bar. While the Browse Gate is active each tab is decided on its own: a
|
|
32
|
+
/// gated tab becomes its gate page, an allowed one shows content.
|
|
33
|
+
func showTabBar(_ allTabs: [Track]) async {
|
|
34
|
+
let build = beginRootBuild()
|
|
35
|
+
builtTabs = allTabs
|
|
36
|
+
let tabs = Array(Self.visibleTabs(allTabs).prefix(CPTabBarTemplate.maximumTabCount))
|
|
37
|
+
let gated = audioBrowser.browseGate.isActive
|
|
38
|
+
|
|
39
|
+
// A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
|
|
40
|
+
// otherwise it is the empty-content error, like any other failure (ADR 0001).
|
|
41
|
+
guard !tabs.isEmpty else {
|
|
42
|
+
if gated, let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: .browse, path: nil, search: nil)) {
|
|
43
|
+
guard isCurrent(build) else { return }
|
|
44
|
+
audioBrowser.onGate(GateEvent(reason: .browse))
|
|
45
|
+
interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
|
|
46
|
+
} else {
|
|
47
|
+
await showRootError(.code(.emptyContent), build: build)
|
|
48
|
+
}
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let chromes = gated ? await gates(for: tabs) : tabs.map { _ in nil }
|
|
53
|
+
guard isCurrent(build) else { return }
|
|
54
|
+
for _ in chromes.compactMap(\.self) {
|
|
55
|
+
audioBrowser.onGate(GateEvent(reason: .browse))
|
|
56
|
+
}
|
|
57
|
+
let templates = zip(tabs, chromes).map { tab, chrome in
|
|
58
|
+
chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if gated, let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
|
|
62
|
+
tabBar.templates.count == templates.count
|
|
63
|
+
{
|
|
64
|
+
// An equal-count swap keeps the selected tab.
|
|
65
|
+
tabBar.updateTemplates(templates)
|
|
66
|
+
} else {
|
|
67
|
+
interfaceController.safeSetRoot(CPTabBarTemplate(templates: templates), animated: true)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Tabs fill lazily on first appearance. The first one is on screen already, and a template
|
|
71
|
+
// swapped in by `updateTemplates` may never report appearing.
|
|
72
|
+
let eager = gated ? templates : Array(templates.prefix(1))
|
|
73
|
+
for template in eager {
|
|
74
|
+
guard let path = template.contentPath else { continue }
|
|
75
|
+
await loadContent(for: path, into: template)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// The tabs changed: restamp their entries in place when the paths are the same, else
|
|
80
|
+
/// rebuild, once the user is back at the root.
|
|
81
|
+
func tabsChanged(_ tabs: [Track]) {
|
|
82
|
+
guard !TabBarEntries.same(builtTabs, tabs) else { return }
|
|
83
|
+
let visible = Self.visibleTabs(tabs)
|
|
84
|
+
if !audioBrowser.browseGate.isActive,
|
|
85
|
+
let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
|
|
86
|
+
tabBar.templates.count == min(visible.count, CPTabBarTemplate.maximumTabCount),
|
|
87
|
+
zip(tabBar.templates, visible).allSatisfy({ $0.contentPath == $1.path })
|
|
88
|
+
{
|
|
89
|
+
for (template, tab) in zip(tabBar.templates, visible) {
|
|
90
|
+
applyTabBarEntry(to: template, for: tab)
|
|
91
|
+
}
|
|
92
|
+
builtTabs = tabs
|
|
93
|
+
needsTabRebuild = false
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
needsTabRebuild = true
|
|
97
|
+
_ = rebuildTabsIfAtRoot()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Rebuilds the tab bar if a rebuild is due and the user is at the root. True when it did,
|
|
101
|
+
/// so the caller can skip work on a template about to be replaced.
|
|
102
|
+
func rebuildTabsIfAtRoot() -> Bool {
|
|
103
|
+
guard needsTabRebuild, interfaceController.templates.count <= 1 else { return false }
|
|
104
|
+
needsTabRebuild = false
|
|
105
|
+
spawn { await $0.showTabBar($0.audioBrowser.browserManager.tabs ?? []) }
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// The gate was set or cleared: pushed content must not stay reachable behind it, and a
|
|
110
|
+
/// clear returns to the root.
|
|
111
|
+
func gateChanged() {
|
|
112
|
+
let tabs = audioBrowser.browserManager.tabs ?? []
|
|
113
|
+
if tabs.isEmpty, !audioBrowser.browseGate.isActive {
|
|
114
|
+
spawn { await $0.buildInitialInterface() }
|
|
115
|
+
} else {
|
|
116
|
+
interfaceController.safePopToRoot(animated: false)
|
|
117
|
+
spawn { await $0.showTabBar(tabs) }
|
|
118
|
+
}
|
|
119
|
+
nowPlaying.refresh()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// The chrome to show if the Browse Gate gates this request, reporting it to JS; nil when the
|
|
123
|
+
/// request is allowed.
|
|
124
|
+
func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
|
|
125
|
+
let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
|
|
126
|
+
if chrome != nil { audioBrowser.onGate(GateEvent(reason: reason)) }
|
|
127
|
+
return chrome
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// One decision per tab, made concurrently and returned in tab order. Reports nothing: the
|
|
131
|
+
/// caller fires `onGate` once it knows its build is still current.
|
|
132
|
+
private func gates(for tabs: [Track]) async -> [Gate?] {
|
|
133
|
+
let browseGate = audioBrowser.browseGate
|
|
134
|
+
let decisions = tabs.map { tab in
|
|
135
|
+
Task<Gate?, Never> { @MainActor in
|
|
136
|
+
await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
var chromes: [Gate?] = []
|
|
140
|
+
for decision in decisions {
|
|
141
|
+
await chromes.append(decision.value)
|
|
142
|
+
}
|
|
143
|
+
return chromes
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/// A tab's content shell, which loads when it first appears.
|
|
147
|
+
private func makeTabTemplate(for tab: Track) -> CPListTemplate {
|
|
148
|
+
let template = CPListTemplate.loading(title: tab.title, path: tab.path, loadingTitle: config.carPlayLoadingTitle)
|
|
149
|
+
applyTabBarEntry(to: template, for: tab)
|
|
150
|
+
return template
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// The gate page: a list template, because the audio entitlement does not allow
|
|
154
|
+
/// `CPInformationTemplate` (the tab bar raises on one), showing the chrome as its centered
|
|
155
|
+
/// empty view. No action button: a gate withholds content, and the app offers any
|
|
156
|
+
/// "subscribe" in its own UI.
|
|
157
|
+
func makeGatePage(_ chrome: Gate, path: String?, tab: Track?) -> CPListTemplate {
|
|
158
|
+
let template = CPListTemplate.message(
|
|
159
|
+
chrome.title, subtitle: chrome.message, listTitle: chrome.title,
|
|
160
|
+
info: CarPlayTemplateInfo(path: path, kind: .gate),
|
|
161
|
+
)
|
|
162
|
+
if let tab { applyTabBarEntry(to: template, for: tab) }
|
|
163
|
+
return template
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Stamps a template's tab-bar title and image: an SF Symbol, loaded artwork, or none. Tab
|
|
167
|
+
/// icons are 24 pt (CarPlay Developer Guide).
|
|
168
|
+
func applyTabBarEntry(to template: CPTemplate, for track: Track) {
|
|
169
|
+
template.tabTitle = track.title
|
|
170
|
+
if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
|
|
171
|
+
let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
|
|
172
|
+
if let image = imageLoader.sfSymbolImage(symbolName) {
|
|
173
|
+
template.tabImage = image
|
|
174
|
+
}
|
|
175
|
+
} else if track.artwork != nil || track.artworkSource != nil {
|
|
176
|
+
imageLoader.loadArtwork(for: track, size: CGSize(width: 24, height: 24)) { [weak template] image in
|
|
177
|
+
Task { @MainActor in
|
|
178
|
+
if let image { template?.tabImage = image }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
// A restamp must not leave an old icon on a now title-only tab.
|
|
183
|
+
template.tabImage = nil
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/// Shows a navigation error as the root: the app's formatting of it at path "/" (ADR 0001).
|
|
188
|
+
func showRootError(_ error: NavigationError, build: UInt) async {
|
|
189
|
+
let formatted = await audioBrowser.browserManager.formatted(error, path: "/")
|
|
190
|
+
guard isCurrent(build) else { return }
|
|
191
|
+
interfaceController.safeSetRoot(CPListTemplate.message(formatted.title, subtitle: formatted.message), animated: true)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import CarPlay
|
|
2
|
+
import NitroModules
|
|
3
|
+
import os.log
|
|
4
|
+
|
|
5
|
+
/// One CarPlay run, from the moment the player and a configured browser exist until the scene
|
|
6
|
+
/// stops or the bridge is replaced. It owns its subscriptions and tasks: `end()` cancels them,
|
|
7
|
+
/// so nothing a stopped run started can paint over the next one.
|
|
8
|
+
@MainActor
|
|
9
|
+
final class CarPlaySession {
|
|
10
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlaySession")
|
|
11
|
+
|
|
12
|
+
let interfaceController: CPInterfaceController
|
|
13
|
+
let audioBrowser: HybridAudioBrowser
|
|
14
|
+
/// Read on each use: a re-setup replaces the bridge's player.
|
|
15
|
+
var player: Player? { audioBrowser.getPlayer() }
|
|
16
|
+
let trackSelector: TrackSelector
|
|
17
|
+
let imageLoader: CarPlayImageLoader
|
|
18
|
+
let listItemFactory: CarPlayListItemFactory
|
|
19
|
+
private(set) var nowPlaying: CarPlayNowPlayingManager!
|
|
20
|
+
|
|
21
|
+
private var subscriptions: [Subscription] = []
|
|
22
|
+
private var tasks: [UUID: Task<Void, Never>] = [:]
|
|
23
|
+
private(set) var ended = false
|
|
24
|
+
|
|
25
|
+
/// Paths whose pushed template has not appeared yet; guards a double tap, since
|
|
26
|
+
/// `topTemplate` only shows a push once it lands.
|
|
27
|
+
var navigatingPaths: Set<String> = []
|
|
28
|
+
|
|
29
|
+
/// Templates with a content load in flight, so a re-appearing one doesn't start another.
|
|
30
|
+
var loadingTemplates: Set<ObjectIdentifier> = []
|
|
31
|
+
|
|
32
|
+
/// Set when the tabs change while the user is browsing: rebuilding the tab bar would tear
|
|
33
|
+
/// down the pushed stack, so it waits until they are back at the root.
|
|
34
|
+
var needsTabRebuild = false
|
|
35
|
+
|
|
36
|
+
/// The tabs the current root was built from; their change event, which the browser delivers
|
|
37
|
+
/// after the build has begun, has nothing left to change.
|
|
38
|
+
var builtTabs: [Track]?
|
|
39
|
+
|
|
40
|
+
/// Bumped by every root build (initial interface, tab bar, root error). A build that
|
|
41
|
+
/// suspended and finds it bumped has been superseded, and leaves the root alone.
|
|
42
|
+
private var rootBuildGeneration: UInt = 0
|
|
43
|
+
|
|
44
|
+
/// How long a destination may spin before it shows an error.
|
|
45
|
+
let resolveTimeout: Duration = .seconds(15)
|
|
46
|
+
|
|
47
|
+
var config: BrowserConfig {
|
|
48
|
+
audioBrowser.browserManager.config
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
init(interfaceController: CPInterfaceController, audioBrowser: HybridAudioBrowser) {
|
|
52
|
+
self.interfaceController = interfaceController
|
|
53
|
+
self.audioBrowser = audioBrowser
|
|
54
|
+
trackSelector = TrackSelector(browserManager: audioBrowser.browserManager)
|
|
55
|
+
imageLoader = CarPlayImageLoader(
|
|
56
|
+
carTraitCollection: interfaceController.carTraitCollection,
|
|
57
|
+
browserManager: audioBrowser.browserManager,
|
|
58
|
+
)
|
|
59
|
+
listItemFactory = CarPlayListItemFactory(
|
|
60
|
+
isActiveTrack: { [weak audioBrowser] identity in
|
|
61
|
+
identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
listItemFactory.imageLoader = imageLoader
|
|
65
|
+
nowPlaying = CarPlayNowPlayingManager(
|
|
66
|
+
interfaceController: interfaceController,
|
|
67
|
+
audioBrowser: audioBrowser,
|
|
68
|
+
listItem: { [weak self] track, handler in
|
|
69
|
+
self?.listItemFactory.createListItem(for: track, handler: handler) ?? CPListItem(text: track.title, detailText: nil)
|
|
70
|
+
},
|
|
71
|
+
navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
|
|
72
|
+
)
|
|
73
|
+
listItemFactory.onItemSelected = { [weak self] track, completion in self?.select(track, completion: completion) }
|
|
74
|
+
listItemFactory.onNavigate = { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } }
|
|
75
|
+
listItemFactory.playingIndicatorLocation = playingIndicatorLocation
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func start() {
|
|
79
|
+
let browserManager = audioBrowser.browserManager
|
|
80
|
+
subscriptions = [
|
|
81
|
+
browserManager.tabsChanged.subscribeOnMain { [weak self] in self?.tabsChanged($0) },
|
|
82
|
+
browserManager.contentChanged.subscribeOnMain { [weak self] content in
|
|
83
|
+
guard let content else { return }
|
|
84
|
+
self?.refreshDisplayedTemplates(for: content.path, with: content)
|
|
85
|
+
},
|
|
86
|
+
audioBrowser.playerOptionsChangedEmitter.subscribeOnMain { [weak self] in self?.playerOptionsChanged() },
|
|
87
|
+
audioBrowser.externalContentChangedEmitter.subscribeOnMain { [weak self] path in
|
|
88
|
+
guard let self else { return }
|
|
89
|
+
if path == HybridAudioBrowser.invalidateAllSentinel {
|
|
90
|
+
spawn { await $0.refreshAllDisplayedTemplates() }
|
|
91
|
+
} else {
|
|
92
|
+
spawn { await $0.refreshContent(for: path) }
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
audioBrowser.browseGate.changed.subscribeOnMain { [weak self] _ in self?.gateChanged() },
|
|
96
|
+
audioBrowser.activeTrackChangedEmitter.subscribeOnMain { [weak self] _ in self?.updatePlayingIndicators() },
|
|
97
|
+
]
|
|
98
|
+
nowPlaying.start()
|
|
99
|
+
spawn { await $0.buildInitialInterface() }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func end() {
|
|
103
|
+
ended = true
|
|
104
|
+
for task in tasks.values {
|
|
105
|
+
task.cancel()
|
|
106
|
+
}
|
|
107
|
+
tasks = [:]
|
|
108
|
+
subscriptions = []
|
|
109
|
+
nowPlaying.end()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// Runs `work` as a task this session owns, cancelled by `end()`.
|
|
113
|
+
func spawn(_ work: @escaping @MainActor (CarPlaySession) async -> Void) {
|
|
114
|
+
// A handler queued before `end()` can still call in; the next run owns the screen.
|
|
115
|
+
guard !ended else { return }
|
|
116
|
+
let id = UUID()
|
|
117
|
+
tasks[id] = Task { [weak self] in
|
|
118
|
+
guard let self else { return }
|
|
119
|
+
await work(self)
|
|
120
|
+
tasks[id] = nil
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARK: - Root Builds
|
|
125
|
+
|
|
126
|
+
/// Starts a root build; pass the result to `isCurrent(_:)` after every suspension.
|
|
127
|
+
func beginRootBuild() -> UInt {
|
|
128
|
+
builtTabs = nil
|
|
129
|
+
rootBuildGeneration &+= 1
|
|
130
|
+
return rootBuildGeneration
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func isCurrent(_ build: UInt) -> Bool {
|
|
134
|
+
build == rootBuildGeneration && !Task.isCancelled
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MARK: - Template Events
|
|
138
|
+
|
|
139
|
+
func templateDidAppear(_ template: CPTemplate) {
|
|
140
|
+
// Popping back from Now Playing reveals the tab bar, not a list; both repaint the playing
|
|
141
|
+
// indicator, which may have changed while the list was covered.
|
|
142
|
+
guard template is CPListTemplate || template is CPTabBarTemplate else { return }
|
|
143
|
+
if let path = template.carPlayInfo?.path { navigatingPaths.remove(path) }
|
|
144
|
+
if rebuildTabsIfAtRoot() { return }
|
|
145
|
+
updatePlayingIndicators()
|
|
146
|
+
if let list = template as? CPListTemplate { loadContentIfNeeded(for: list) }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
func templateDidDisappear(_ template: CPTemplate) {
|
|
150
|
+
// Whether popping Now Playing reports the list appearing or only Now Playing leaving is a
|
|
151
|
+
// platform detail; both repaint the indicator.
|
|
152
|
+
guard template is CPNowPlayingTemplate else { return }
|
|
153
|
+
updatePlayingIndicators()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// MARK: - Playing Indicators
|
|
157
|
+
|
|
158
|
+
var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
|
|
159
|
+
switch audioBrowser.carPlayPlayingIndicatorLocation {
|
|
160
|
+
case .trailing: .trailing
|
|
161
|
+
case .leading: .leading
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private func playerOptionsChanged() {
|
|
166
|
+
// The factory's copy is the last-applied location: sweep only on a real change.
|
|
167
|
+
let location = playingIndicatorLocation
|
|
168
|
+
guard listItemFactory.playingIndicatorLocation != location else { return }
|
|
169
|
+
listItemFactory.playingIndicatorLocation = location
|
|
170
|
+
updatePlayingIndicators()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/// Updates every displayed list item's playing flag and indicator location.
|
|
174
|
+
func updatePlayingIndicators() {
|
|
175
|
+
let location = playingIndicatorLocation
|
|
176
|
+
for template in displayedListTemplates() {
|
|
177
|
+
var changed = false
|
|
178
|
+
for section in template.sections {
|
|
179
|
+
for case let item as CPListItem in section.items {
|
|
180
|
+
guard let identity = item.carPlayItemInfo?.identity else { continue }
|
|
181
|
+
let isPlaying = player?.currentTrack?.identity == identity
|
|
182
|
+
if item.isPlaying != isPlaying {
|
|
183
|
+
item.isPlaying = isPlaying
|
|
184
|
+
changed = true
|
|
185
|
+
}
|
|
186
|
+
if item.playingIndicatorLocation != location {
|
|
187
|
+
item.playingIndicatorLocation = location
|
|
188
|
+
changed = true
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// A flag set on a displayed item does not reliably reach the car screen; re-pushing the
|
|
193
|
+
// sections re-serializes every item, so it is done only when something changed.
|
|
194
|
+
if changed { template.updateSections(template.sections) }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// The list templates on screen or one tap away: the tab roots, then the pushed stack.
|
|
199
|
+
func displayedListTemplates() -> [CPListTemplate] {
|
|
200
|
+
var templates: [CPListTemplate] = []
|
|
201
|
+
let tabRoots = (interfaceController.rootTemplate as? CPTabBarTemplate)?.templates ?? []
|
|
202
|
+
for case let list as CPListTemplate in tabRoots + interfaceController.templates
|
|
203
|
+
where !templates.contains(where: { $0 === list })
|
|
204
|
+
{
|
|
205
|
+
templates.append(list)
|
|
206
|
+
}
|
|
207
|
+
return templates
|
|
208
|
+
}
|
|
209
|
+
}
|