react-native-audio-browser 0.2.0-next.34 → 0.2.0-next.35
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/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
- package/ios/CarPlay/CarPlayController.swift +0 -6
- package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
- package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +11 -44
- package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
- package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
- package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
- package/ios/CarPlay/CarPlaySession.swift +8 -10
- package/ios/CarPlay/IntentHandlers.swift +96 -0
- package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
- package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
- package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
- package/ios/CarPlay/RNABAudioBrowser.m +7 -15
- package/ios/Player/Player.swift +19 -0
- package/ios/Tests/ConcurrentMapTests.swift +42 -0
- package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
- package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
- package/ios/Tests/PlayerTests.swift +41 -0
- package/ios/Util/ConcurrentMap.swift +19 -0
- package/package.json +1 -1
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
- package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
|
@@ -26,19 +26,18 @@ extension CarPlaySession {
|
|
|
26
26
|
NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
|
|
27
27
|
)
|
|
28
28
|
}
|
|
29
|
-
|
|
30
|
-
// timeout must not overwrite.
|
|
31
|
-
defer { watchdog.cancel() }
|
|
32
|
-
|
|
29
|
+
let outcome: Result<ResolvedTrack, Error>
|
|
33
30
|
do {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
outcome = try await .success(audioBrowser.browserManager.resolve(path, useCache: true))
|
|
32
|
+
} catch {
|
|
33
|
+
outcome = .failure(error)
|
|
34
|
+
}
|
|
35
|
+
// As soon as the resolve settles: formatting its error is a JS round trip the timeout must
|
|
36
|
+
// not overwrite.
|
|
37
|
+
watchdog.cancel()
|
|
38
|
+
|
|
39
|
+
switch outcome {
|
|
40
|
+
case let .success(resolved):
|
|
42
41
|
guard !Task.isCancelled else { return }
|
|
43
42
|
// An assistant cell is content in itself, even with no rows; a page of empty sections is
|
|
44
43
|
// empty (ADR 0001).
|
|
@@ -48,7 +47,7 @@ extension CarPlaySession {
|
|
|
48
47
|
} else {
|
|
49
48
|
await showNavigationError(.code(.emptyContent), path: path, on: template)
|
|
50
49
|
}
|
|
51
|
-
|
|
50
|
+
case let .failure(error):
|
|
52
51
|
logger.error("Failed to load content for \(path): \(error.localizedDescription)")
|
|
53
52
|
await showNavigationError(NavigationError.from(error), path: path, on: template)
|
|
54
53
|
}
|
|
@@ -23,21 +23,21 @@ extension CarPlaySession {
|
|
|
23
23
|
/// invalidated (e.g. a locale change).
|
|
24
24
|
func refreshAllDisplayedTemplates() async {
|
|
25
25
|
let browserManager = audioBrowser.browserManager
|
|
26
|
-
let
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
})
|
|
26
|
+
let targets = displayedListTemplates().compactMap { template in template.contentPath.map { (template, $0) } }
|
|
27
|
+
let pages = await targets.map(\.1).concurrentMap { [logger] path -> ResolvedTrack? in
|
|
28
|
+
do {
|
|
29
|
+
return try await browserManager.resolve(path, useCache: false)
|
|
30
|
+
} catch {
|
|
31
|
+
if !(error is CancellationError || Task.isCancelled) {
|
|
32
|
+
logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
|
|
33
|
+
}
|
|
34
|
+
return nil
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
guard !Task.isCancelled else { return }
|
|
38
|
+
for ((template, _), page) in zip(targets, pages) {
|
|
39
|
+
guard let page else { continue }
|
|
40
|
+
updateTemplate(template, with: page)
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -39,9 +39,9 @@ extension CarPlaySession {
|
|
|
39
39
|
// A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
|
|
40
40
|
// otherwise it is the empty-content error, like any other failure (ADR 0001).
|
|
41
41
|
guard !tabs.isEmpty else {
|
|
42
|
-
if gated, let chrome = await
|
|
42
|
+
if gated, let chrome = await browseGates(for: [nil])[0] {
|
|
43
43
|
guard isCurrent(build) else { return }
|
|
44
|
-
|
|
44
|
+
reportGates([chrome], reason: .browse)
|
|
45
45
|
interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
|
|
46
46
|
} else {
|
|
47
47
|
await showRootError(.code(.emptyContent), build: build)
|
|
@@ -49,11 +49,9 @@ extension CarPlaySession {
|
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
let chromes = gated ? await
|
|
52
|
+
let chromes = gated ? await browseGates(for: tabs.map(\.path)) : tabs.map { _ in nil }
|
|
53
53
|
guard isCurrent(build) else { return }
|
|
54
|
-
|
|
55
|
-
audioBrowser.onGate(GateEvent(reason: .browse))
|
|
56
|
-
}
|
|
54
|
+
reportGates(chromes, reason: .browse)
|
|
57
55
|
let templates = zip(tabs, chromes).map { tab, chrome in
|
|
58
56
|
chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
|
|
59
57
|
}
|
|
@@ -123,24 +121,24 @@ extension CarPlaySession {
|
|
|
123
121
|
/// request is allowed.
|
|
124
122
|
func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
|
|
125
123
|
let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
|
|
126
|
-
|
|
124
|
+
reportGates([chrome], reason: reason)
|
|
127
125
|
return chrome
|
|
128
126
|
}
|
|
129
127
|
|
|
130
|
-
/// One decision per
|
|
131
|
-
/// caller
|
|
132
|
-
private func
|
|
128
|
+
/// One browse decision per path, made concurrently and returned in path order. Reports
|
|
129
|
+
/// nothing: the caller reports once it knows its build is still current.
|
|
130
|
+
private func browseGates(for paths: [String?]) async -> [Gate?] {
|
|
133
131
|
let browseGate = audioBrowser.browseGate
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
|
|
137
|
-
}
|
|
132
|
+
return await paths.concurrentMap { path in
|
|
133
|
+
await browseGate.decide(NativeGateRequest(reason: .browse, path: path, search: nil))
|
|
138
134
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// Fires `onGate` once for each request the gate gated.
|
|
138
|
+
private func reportGates(_ chromes: [Gate?], reason: GateReason) {
|
|
139
|
+
for _ in chromes.compactMap(\.self) {
|
|
140
|
+
audioBrowser.onGate(GateEvent(reason: reason))
|
|
142
141
|
}
|
|
143
|
-
return chromes
|
|
144
142
|
}
|
|
145
143
|
|
|
146
144
|
/// A tab's content shell, which loads when it first appears.
|
|
@@ -168,15 +166,16 @@ extension CarPlaySession {
|
|
|
168
166
|
func applyTabBarEntry(to template: CPTemplate, for track: Track) {
|
|
169
167
|
template.tabTitle = track.title
|
|
170
168
|
if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
|
|
169
|
+
// A plain symbol: CarPlay tints the tab image itself.
|
|
171
170
|
let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
|
|
172
|
-
if let image =
|
|
171
|
+
if let image = UIImage(systemName: symbolName) {
|
|
173
172
|
template.tabImage = image
|
|
174
173
|
}
|
|
175
174
|
} else if track.artwork != nil || track.artworkSource != nil {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
spawn { [weak template] session in
|
|
176
|
+
let image = await session.imageLoader.artwork(for: track, size: CGSize(width: 24, height: 24))
|
|
177
|
+
guard !Task.isCancelled, let image else { return }
|
|
178
|
+
template?.tabImage = image
|
|
180
179
|
}
|
|
181
180
|
} else {
|
|
182
181
|
// A restamp must not leave an old icon on a now title-only tab.
|
|
@@ -15,7 +15,14 @@ final class CarPlaySession {
|
|
|
15
15
|
var player: Player? { audioBrowser.player }
|
|
16
16
|
let trackSelector: TrackSelector
|
|
17
17
|
let imageLoader: CarPlayImageLoader
|
|
18
|
-
|
|
18
|
+
private(set) lazy var listItemFactory = CarPlayListItemFactory(
|
|
19
|
+
imageLoader: imageLoader,
|
|
20
|
+
playingIndicatorLocation: playingIndicatorLocation,
|
|
21
|
+
isActiveTrack: { [weak self] identity in identity != nil && self?.player?.currentTrack?.identity == identity },
|
|
22
|
+
spawn: { [weak self] work in self?.spawn { _ in await work() } },
|
|
23
|
+
onItemSelected: { [weak self] track, completion in self?.select(track, completion: completion) },
|
|
24
|
+
onNavigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
|
|
25
|
+
)
|
|
19
26
|
private(set) var nowPlaying: CarPlayNowPlayingManager!
|
|
20
27
|
|
|
21
28
|
private var subscriptions: [Subscription] = []
|
|
@@ -56,12 +63,6 @@ final class CarPlaySession {
|
|
|
56
63
|
carTraitCollection: interfaceController.carTraitCollection,
|
|
57
64
|
browserManager: audioBrowser.browserManager,
|
|
58
65
|
)
|
|
59
|
-
listItemFactory = CarPlayListItemFactory(
|
|
60
|
-
isActiveTrack: { [weak audioBrowser] identity in
|
|
61
|
-
identity != nil && audioBrowser?.player?.currentTrack?.identity == identity
|
|
62
|
-
},
|
|
63
|
-
)
|
|
64
|
-
listItemFactory.imageLoader = imageLoader
|
|
65
66
|
nowPlaying = CarPlayNowPlayingManager(
|
|
66
67
|
interfaceController: interfaceController,
|
|
67
68
|
audioBrowser: audioBrowser,
|
|
@@ -70,9 +71,6 @@ final class CarPlaySession {
|
|
|
70
71
|
},
|
|
71
72
|
navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
|
|
72
73
|
)
|
|
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
74
|
}
|
|
77
75
|
|
|
78
76
|
func start() {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Intents
|
|
3
|
+
import os.log
|
|
4
|
+
|
|
5
|
+
/// The library's Siri handlers, vended to the host app through `RNABAudioBrowser.handlerForIntent:`.
|
|
6
|
+
@objc(RNABIntentHandlers)
|
|
7
|
+
public final class IntentHandlers: NSObject {
|
|
8
|
+
/// A handler for `INPlayMediaIntent`, `INUpdateMediaAffinityIntent` and `INAddMediaIntent`;
|
|
9
|
+
/// nil for any other intent.
|
|
10
|
+
@objc(handlerForIntent:)
|
|
11
|
+
public static func handler(for intent: INIntent) -> Any? {
|
|
12
|
+
switch intent {
|
|
13
|
+
case is INPlayMediaIntent: MediaIntentHandler()
|
|
14
|
+
case is INUpdateMediaAffinityIntent, is INAddMediaIntent: FavoriteIntentHandler()
|
|
15
|
+
default: nil
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// "Add this to my favorites" and "I like / don't like this": sets the favorite state of the
|
|
21
|
+
/// playing track, through the same path as the CarPlay favorite button. A named item that is
|
|
22
|
+
/// not playing is out of scope.
|
|
23
|
+
final class FavoriteIntentHandler: NSObject, INAddMediaIntentHandling, INUpdateMediaAffinityIntentHandling {
|
|
24
|
+
private static let logger = Logger(subsystem: "com.audiobrowser", category: "FavoriteIntentHandler")
|
|
25
|
+
|
|
26
|
+
func handle(intent _: INAddMediaIntent, completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void) {
|
|
27
|
+
Self.logger.info("Add media — favorite current track")
|
|
28
|
+
Task { @MainActor in
|
|
29
|
+
let applied = Self.favoriteCurrentTrack(true)
|
|
30
|
+
completion(INAddMediaIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func handle(
|
|
35
|
+
intent: INUpdateMediaAffinityIntent,
|
|
36
|
+
completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
|
|
37
|
+
) {
|
|
38
|
+
let affinity = intent.affinityType
|
|
39
|
+
Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
|
|
40
|
+
Task { @MainActor in
|
|
41
|
+
let applied = switch affinity {
|
|
42
|
+
case .like: Self.favoriteCurrentTrack(true)
|
|
43
|
+
case .dislike: Self.favoriteCurrentTrack(false)
|
|
44
|
+
default: false
|
|
45
|
+
}
|
|
46
|
+
completion(INUpdateMediaAffinityIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Siri fails an intent whose handler has no resolution method. They are optional @objc
|
|
51
|
+
// requirements, so each is pinned to the selector SiriKit calls. The handlers act on the
|
|
52
|
+
// playing track, so the spoken item passes through.
|
|
53
|
+
|
|
54
|
+
@objc(resolveMediaItemsForAddMedia:withCompletion:)
|
|
55
|
+
func resolveMediaItems(
|
|
56
|
+
for intent: INAddMediaIntent,
|
|
57
|
+
with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
|
|
58
|
+
) {
|
|
59
|
+
completion([.success(with: Self.spokenItem(intent.mediaSearch))])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// One flat favorites collection: always the user's library.
|
|
63
|
+
@objc(resolveMediaDestinationForAddMedia:withCompletion:)
|
|
64
|
+
func resolveMediaDestination(
|
|
65
|
+
for _: INAddMediaIntent,
|
|
66
|
+
with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
|
|
67
|
+
) {
|
|
68
|
+
completion(.success(with: INMediaDestination.library))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
|
|
72
|
+
func resolveMediaItems(
|
|
73
|
+
for intent: INUpdateMediaAffinityIntent,
|
|
74
|
+
with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
|
|
75
|
+
) {
|
|
76
|
+
completion([.success(with: Self.spokenItem(intent.mediaSearch))])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private static func spokenItem(_ search: INMediaSearch?) -> INMediaItem {
|
|
80
|
+
let name = search?.mediaName ?? ""
|
|
81
|
+
return INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// False when nothing is playing, the playing track has no identity to favorite, or the
|
|
85
|
+
/// change throws.
|
|
86
|
+
@MainActor
|
|
87
|
+
private static func favoriteCurrentTrack(_ favorited: Bool) -> Bool {
|
|
88
|
+
guard let browser = HybridAudioBrowser.shared else { return false }
|
|
89
|
+
do {
|
|
90
|
+
return try browser.favoriteActiveTrack(favorited)
|
|
91
|
+
} catch {
|
|
92
|
+
logger.error("favoriteCurrentTrack(\(favorited)) failed: \(error.localizedDescription)")
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -3,7 +3,7 @@ import Foundation
|
|
|
3
3
|
/// Normalized "what did the user ask to play", derived from a media intent.
|
|
4
4
|
/// Deliberately free of `Intents` types so the core (`HybridAudioBrowser`)
|
|
5
5
|
/// never imports the Intents framework — the mapping lives in the
|
|
6
|
-
/// `Intents`-aware `
|
|
6
|
+
/// `Intents`-aware `MediaIntentHandler`.
|
|
7
7
|
public struct MediaIntentCriteria: Sendable {
|
|
8
8
|
/// Media-reference axis (mirrors `INMediaReference`). `currentlyPlaying`
|
|
9
9
|
/// routes to native resume and never reaches the consumer; `my` routes to
|
|
@@ -12,19 +12,16 @@ public struct MediaIntentCriteria: Sendable {
|
|
|
12
12
|
|
|
13
13
|
let query: String
|
|
14
14
|
let reference: Reference
|
|
15
|
-
let hasGenres: Bool
|
|
16
15
|
let hasMediaType: Bool
|
|
17
|
-
/// True when `query` is effectively the host app's own name. Siri turns
|
|
18
|
-
///
|
|
19
|
-
///
|
|
20
|
-
/// app-open/resume, not a station search.
|
|
16
|
+
/// True when `query` is effectively the host app's own name. Siri turns "Play «App Name»"
|
|
17
|
+
/// into a search for a word of it ("Name", with a radio media type): an app-open or resume,
|
|
18
|
+
/// not a search.
|
|
21
19
|
let matchesAppName: Bool
|
|
22
20
|
|
|
23
|
-
// Structured search payload, mirroring
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
///
|
|
27
|
-
/// intent's media type — never derived from which filter field is set.
|
|
21
|
+
// Structured search payload, mirroring `SearchParams` in plain strings so this type stays in
|
|
22
|
+
// the SwiftPM target; the funnel builds the Nitro `SearchParams`.
|
|
23
|
+
/// The container-vertical `SearchMode` name, or nil. Comes only from the intent's media type,
|
|
24
|
+
/// never from which filter field is set.
|
|
28
25
|
let searchMode: String?
|
|
29
26
|
let genre: String?
|
|
30
27
|
let artist: String?
|
|
@@ -32,30 +29,40 @@ public struct MediaIntentCriteria: Sendable {
|
|
|
32
29
|
let title: String?
|
|
33
30
|
let playlist: String?
|
|
34
31
|
|
|
32
|
+
/// Builds criteria from the raw fields of a media-search intent.
|
|
33
|
+
///
|
|
34
|
+
/// `mediaTypeMode` is the `SearchMode` string for the intent's container vertical ("station",
|
|
35
|
+
/// "podcast", "song", …), or nil for a filter-only or unclassified type.
|
|
35
36
|
init(
|
|
36
|
-
|
|
37
|
+
mediaName: String?,
|
|
38
|
+
genreNames: [String],
|
|
39
|
+
artistName: String?,
|
|
40
|
+
albumName: String?,
|
|
41
|
+
mediaTypeMode: String?,
|
|
37
42
|
reference: Reference,
|
|
38
|
-
hasGenres: Bool,
|
|
39
43
|
hasMediaType: Bool,
|
|
40
|
-
|
|
41
|
-
searchMode: String? = nil,
|
|
42
|
-
genre: String? = nil,
|
|
43
|
-
artist: String? = nil,
|
|
44
|
-
album: String? = nil,
|
|
45
|
-
title: String? = nil,
|
|
46
|
-
playlist: String? = nil,
|
|
44
|
+
appName: String?,
|
|
47
45
|
) {
|
|
48
|
-
|
|
46
|
+
let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
47
|
+
let trimmedNonEmpty: (String?) -> String? = {
|
|
48
|
+
let t = ($0 ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
49
|
+
return t.isEmpty ? nil : t
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
genre = genreNames.isEmpty ? nil : genreNames.joined(separator: " ")
|
|
53
|
+
artist = trimmedNonEmpty(artistName)
|
|
54
|
+
album = trimmedNonEmpty(albumName)
|
|
55
|
+
// song/playlist carry their spoken name into a dedicated field.
|
|
56
|
+
title = mediaTypeMode == "song" ? (name.isEmpty ? nil : name) : nil
|
|
57
|
+
playlist = mediaTypeMode == "playlist" ? (name.isEmpty ? nil : name) : nil
|
|
58
|
+
|
|
59
|
+
// Always populated, so search works before the API honours `mode`: the spoken name, else a
|
|
60
|
+
// structured value.
|
|
61
|
+
query = name.isEmpty ? (genre ?? artist ?? album ?? title ?? playlist ?? "") : name
|
|
49
62
|
self.reference = reference
|
|
50
|
-
self.hasGenres = hasGenres
|
|
51
63
|
self.hasMediaType = hasMediaType
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
self.genre = genre
|
|
55
|
-
self.artist = artist
|
|
56
|
-
self.album = album
|
|
57
|
-
self.title = title
|
|
58
|
-
self.playlist = playlist
|
|
64
|
+
matchesAppName = Self.queryMatchesAppName(query, appName: appName)
|
|
65
|
+
searchMode = mediaTypeMode
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
/// Resume vs search, by reference:
|
|
@@ -69,61 +76,12 @@ public struct MediaIntentCriteria: Sendable {
|
|
|
69
76
|
case .unknown:
|
|
70
77
|
let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
71
78
|
// "Play «app»": no search term and no other filter → resume.
|
|
72
|
-
if q.isEmpty { return
|
|
79
|
+
if q.isEmpty { return genre == nil && !hasMediaType }
|
|
73
80
|
// "Play «app-name»": resume — unless a real filter signals a search.
|
|
74
|
-
return matchesAppName &&
|
|
81
|
+
return matchesAppName && genre == nil
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
78
|
-
/// Builds criteria from the raw fields of a media-search intent. Pure (no
|
|
79
|
-
/// `Intents`/`Bundle` dependency) so the whole Siri-phrase → search decision
|
|
80
|
-
/// is unit-testable.
|
|
81
|
-
///
|
|
82
|
-
/// `mediaTypeMode` is the already-collapsed `SearchMode` string for the
|
|
83
|
-
/// intent's container vertical (e.g. "station"/"podcast"/"song"), or nil for
|
|
84
|
-
/// a filter-only / unclassified type. `reference` is the mapped axis.
|
|
85
|
-
static func from(
|
|
86
|
-
mediaName: String?,
|
|
87
|
-
genreNames: [String],
|
|
88
|
-
artistName: String?,
|
|
89
|
-
albumName: String?,
|
|
90
|
-
mediaTypeMode: String?,
|
|
91
|
-
reference: Reference,
|
|
92
|
-
hasMediaType: Bool,
|
|
93
|
-
appName: String?,
|
|
94
|
-
) -> MediaIntentCriteria {
|
|
95
|
-
let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
96
|
-
let trimmedNonEmpty: (String?) -> String? = {
|
|
97
|
-
let t = ($0 ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
|
98
|
-
return t.isEmpty ? nil : t
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
let genre = genreNames.isEmpty ? nil : genreNames.joined(separator: " ")
|
|
102
|
-
let artist = trimmedNonEmpty(artistName)
|
|
103
|
-
let album = trimmedNonEmpty(albumName)
|
|
104
|
-
// song/playlist carry their spoken name into a dedicated field.
|
|
105
|
-
let title = mediaTypeMode == "song" ? (name.isEmpty ? nil : name) : nil
|
|
106
|
-
let playlist = mediaTypeMode == "playlist" ? (name.isEmpty ? nil : name) : nil
|
|
107
|
-
|
|
108
|
-
// `query` is always populated so search works even before the API honours
|
|
109
|
-
// `mode`: prefer the spoken name, else fall back to a structured value.
|
|
110
|
-
let query = name.isEmpty ? (genre ?? artist ?? album ?? title ?? playlist ?? "") : name
|
|
111
|
-
|
|
112
|
-
return MediaIntentCriteria(
|
|
113
|
-
query: query,
|
|
114
|
-
reference: reference,
|
|
115
|
-
hasGenres: !genreNames.isEmpty,
|
|
116
|
-
hasMediaType: hasMediaType,
|
|
117
|
-
matchesAppName: queryMatchesAppName(query, appName: appName),
|
|
118
|
-
searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
|
|
119
|
-
genre: genre,
|
|
120
|
-
artist: artist,
|
|
121
|
-
album: album,
|
|
122
|
-
title: title,
|
|
123
|
-
playlist: playlist,
|
|
124
|
-
)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
85
|
/// Whether `query` is effectively the host app's own name. Case- and
|
|
128
86
|
/// diacritic-insensitive.
|
|
129
87
|
///
|
|
@@ -2,18 +2,15 @@ import Foundation
|
|
|
2
2
|
import Intents
|
|
3
3
|
import os.log
|
|
4
4
|
|
|
5
|
-
///
|
|
6
|
-
|
|
7
|
-
/// `@objc(RNABMediaIntentHandler)` and vended from `RNABAudioBrowser.handlerForIntent(_:)`.
|
|
8
|
-
@objc(RNABMediaIntentHandler)
|
|
9
|
-
class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
5
|
+
/// Siri's play-media intent, from the phone or CarPlay voice: plays through `MediaIntentPlayback`.
|
|
6
|
+
final class MediaIntentHandler: NSObject, INPlayMediaIntentHandling {
|
|
10
7
|
private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaIntentHandler")
|
|
11
8
|
|
|
12
9
|
// MARK: - INPlayMediaIntentHandling
|
|
13
10
|
|
|
14
11
|
func handle(intent: INPlayMediaIntent, completion: @escaping @Sendable (INPlayMediaIntentResponse) -> Void) {
|
|
15
12
|
let s = intent.mediaSearch
|
|
16
|
-
let criteria = MediaIntentCriteria
|
|
13
|
+
let criteria = MediaIntentCriteria(
|
|
17
14
|
mediaName: s?.mediaName,
|
|
18
15
|
genreNames: s?.genreNames ?? [],
|
|
19
16
|
artistName: s?.artistName,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/// What CarPlay's Now Playing custom buttons show and do.
|
|
2
|
+
enum NowPlayingButtonRules {
|
|
3
|
+
/// `CPNowPlayingTemplate` takes at most five custom buttons and does not say what happens to
|
|
4
|
+
/// more.
|
|
5
|
+
static let maximumButtonCount = 5
|
|
6
|
+
|
|
7
|
+
/// The configured buttons, first occurrence of each kept, capped at `maximumButtonCount`:
|
|
8
|
+
/// the configuration comes from JS, where duplicates and excess are representable.
|
|
9
|
+
static func buttons<Button: Equatable>(_ configured: [Button]) -> [Button] {
|
|
10
|
+
var buttons: [Button] = []
|
|
11
|
+
for button in configured where !buttons.contains(button) {
|
|
12
|
+
buttons.append(button)
|
|
13
|
+
}
|
|
14
|
+
return Array(buttons.prefix(maximumButtonCount))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// The repeat button cycles off → track → queue → off.
|
|
18
|
+
static func repeatMode(after mode: RepeatMode) -> RepeatMode {
|
|
19
|
+
switch mode {
|
|
20
|
+
case .off: .track
|
|
21
|
+
case .track: .queue
|
|
22
|
+
case .queue: .off
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// The rate after `current` in `rates`: the next one, wrapping, when `current` is one of them;
|
|
27
|
+
/// else the first rate above it, else the first. Nil when there are no rates.
|
|
28
|
+
static func rate(after current: Double, in rates: [Double]) -> Double? {
|
|
29
|
+
guard !rates.isEmpty else { return nil }
|
|
30
|
+
if let index = rates.firstIndex(where: { (current - $0).magnitude < 0.01 }) {
|
|
31
|
+
return rates[(index + 1) % rates.count]
|
|
32
|
+
}
|
|
33
|
+
return rates.first { $0 > current } ?? rates[0]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,23 +1,15 @@
|
|
|
1
1
|
#import "RNABAudioBrowser.h"
|
|
2
2
|
|
|
3
|
+
#if __has_include(<AudioBrowser/AudioBrowser-Swift.h>)
|
|
4
|
+
#import <AudioBrowser/AudioBrowser-Swift.h>
|
|
5
|
+
#elif __has_include("AudioBrowser-Swift.h")
|
|
6
|
+
#import "AudioBrowser-Swift.h"
|
|
7
|
+
#endif
|
|
8
|
+
|
|
3
9
|
@implementation RNABAudioBrowser
|
|
4
10
|
|
|
5
11
|
+ (nullable id)handlerForIntent:(INIntent *)intent {
|
|
6
|
-
|
|
7
|
-
// @objc(...); each conforms to the matching INIntentHandling protocol.
|
|
8
|
-
NSString *handlerClassName = nil;
|
|
9
|
-
if ([intent isKindOfClass:[INPlayMediaIntent class]]) {
|
|
10
|
-
handlerClassName = @"RNABMediaIntentHandler";
|
|
11
|
-
} else if ([intent isKindOfClass:[INUpdateMediaAffinityIntent class]]) {
|
|
12
|
-
handlerClassName = @"RNABMediaAffinityHandler";
|
|
13
|
-
} else if ([intent isKindOfClass:[INAddMediaIntent class]]) {
|
|
14
|
-
handlerClassName = @"RNABMediaAddHandler";
|
|
15
|
-
}
|
|
16
|
-
if (!handlerClassName) {
|
|
17
|
-
return nil;
|
|
18
|
-
}
|
|
19
|
-
Class cls = NSClassFromString(handlerClassName);
|
|
20
|
-
return cls ? [[cls alloc] init] : nil;
|
|
12
|
+
return [RNABIntentHandlers handlerForIntent:intent];
|
|
21
13
|
}
|
|
22
14
|
|
|
23
15
|
@end
|
package/ios/Player/Player.swift
CHANGED
|
@@ -927,6 +927,25 @@ final class Player {
|
|
|
927
927
|
}
|
|
928
928
|
}
|
|
929
929
|
|
|
930
|
+
// MARK: - Now Playing Preview
|
|
931
|
+
|
|
932
|
+
/// Shows `track` on the now-playing surface before it is current, so a tapped track shows at
|
|
933
|
+
/// once while its queue and media resolve.
|
|
934
|
+
func previewNowPlaying(_ track: Track) {
|
|
935
|
+
engine.loadNowPlayingMetadata(for: track)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/// Puts the current track back over a preview that will not play, or clears the surface when
|
|
939
|
+
/// nothing is current.
|
|
940
|
+
func endNowPlayingPreview() {
|
|
941
|
+
guard let currentTrack else {
|
|
942
|
+
engine.clearNowPlaying()
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
engine.loadNowPlayingMetadata(for: currentTrack)
|
|
946
|
+
engine.renderNowPlaying()
|
|
947
|
+
}
|
|
948
|
+
|
|
930
949
|
// MARK: - Progress Updates
|
|
931
950
|
|
|
932
951
|
func setProgressUpdateInterval(_ interval: TimeInterval?) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@Suite("concurrentMap")
|
|
5
|
+
struct ConcurrentMapTests {
|
|
6
|
+
@Test @MainActor
|
|
7
|
+
func keepsElementOrder_whenLaterElementsFinishFirst() async {
|
|
8
|
+
let results = await [30, 20, 10].concurrentMap { delayMs in
|
|
9
|
+
try? await Task.sleep(for: .milliseconds(delayMs))
|
|
10
|
+
return delayMs * 2
|
|
11
|
+
}
|
|
12
|
+
#expect(results == [60, 40, 20])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test @MainActor
|
|
16
|
+
func keepsNilResults_inPlace() async {
|
|
17
|
+
let results = await ["a", "", "c"].concurrentMap { $0.isEmpty ? nil : $0 }
|
|
18
|
+
#expect(results == ["a", nil, "c"])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test @MainActor
|
|
22
|
+
func empty_returnsEmpty() async {
|
|
23
|
+
let results = await [Int]().concurrentMap { $0 }
|
|
24
|
+
#expect(results.isEmpty)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test @MainActor
|
|
28
|
+
func cancellingTheCaller_cancelsEveryTransform() async {
|
|
29
|
+
let task = Task { @MainActor in
|
|
30
|
+
await [1, 2].concurrentMap { _ in
|
|
31
|
+
do {
|
|
32
|
+
try await Task.sleep(for: .seconds(10))
|
|
33
|
+
return false
|
|
34
|
+
} catch {
|
|
35
|
+
return Task.isCancelled
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
task.cancel()
|
|
40
|
+
#expect(await task.value == [true, true])
|
|
41
|
+
}
|
|
42
|
+
}
|