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
|
@@ -2,93 +2,105 @@ import Testing
|
|
|
2
2
|
|
|
3
3
|
@testable import AudioBrowserTestable
|
|
4
4
|
|
|
5
|
+
private func criteria(
|
|
6
|
+
mediaName: String? = nil,
|
|
7
|
+
genreNames: [String] = [],
|
|
8
|
+
artistName: String? = nil,
|
|
9
|
+
albumName: String? = nil,
|
|
10
|
+
mediaTypeMode: String? = nil,
|
|
11
|
+
reference: MediaIntentCriteria.Reference = .unknown,
|
|
12
|
+
hasMediaType: Bool = false,
|
|
13
|
+
appName: String? = "Acme Radio",
|
|
14
|
+
) -> MediaIntentCriteria {
|
|
15
|
+
MediaIntentCriteria(
|
|
16
|
+
mediaName: mediaName, genreNames: genreNames, artistName: artistName,
|
|
17
|
+
albumName: albumName, mediaTypeMode: mediaTypeMode,
|
|
18
|
+
reference: reference, hasMediaType: hasMediaType, appName: appName,
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
@Suite("MediaIntentCriteria")
|
|
6
23
|
struct MediaIntentCriteriaTests {
|
|
7
24
|
@Test func emptyEverything_isResume() {
|
|
8
|
-
|
|
9
|
-
#expect(c.isResume)
|
|
25
|
+
#expect(criteria().isResume)
|
|
10
26
|
}
|
|
11
27
|
|
|
12
28
|
@Test func whitespaceQuery_isResume() {
|
|
13
|
-
let c =
|
|
29
|
+
let c = criteria(mediaName: " ")
|
|
30
|
+
#expect(c.query == "")
|
|
14
31
|
#expect(c.isResume)
|
|
15
32
|
}
|
|
16
33
|
|
|
17
34
|
// currentlyPlaying always resumes — "play this" makes the active track play.
|
|
18
35
|
@Test func currentlyPlaying_isResume() {
|
|
19
|
-
#expect(
|
|
36
|
+
#expect(criteria(reference: .currentlyPlaying).isResume)
|
|
20
37
|
// even with a stray query/filter, "play this" is a resume
|
|
21
|
-
#expect(
|
|
38
|
+
#expect(criteria(mediaName: "jazz", genreNames: ["jazz"], reference: .currentlyPlaying, hasMediaType: true).isResume)
|
|
22
39
|
}
|
|
23
40
|
|
|
24
41
|
// .my always searches — "play my favorites" goes to the consumer, never resume.
|
|
25
42
|
@Test func my_isNotResume() {
|
|
26
|
-
#expect(!
|
|
27
|
-
|
|
43
|
+
#expect(!criteria(reference: .my).isResume)
|
|
44
|
+
let namesApp = criteria(mediaName: "Acme", reference: .my)
|
|
45
|
+
#expect(namesApp.matchesAppName)
|
|
46
|
+
#expect(!namesApp.isResume)
|
|
28
47
|
}
|
|
29
48
|
|
|
30
49
|
@Test func anyCriteria_isNotResume() {
|
|
31
|
-
#expect(!
|
|
32
|
-
#expect(!
|
|
33
|
-
#expect(!
|
|
50
|
+
#expect(!criteria(mediaName: "kcrw").isResume)
|
|
51
|
+
#expect(!criteria(genreNames: ["jazz"]).isResume)
|
|
52
|
+
#expect(!criteria(hasMediaType: true).isResume)
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
@Test func queryNamesApp_isResume() {
|
|
37
|
-
let c =
|
|
56
|
+
let c = criteria(mediaName: "Acme", hasMediaType: true)
|
|
57
|
+
#expect(c.matchesAppName)
|
|
38
58
|
#expect(c.isResume)
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
@Test func queryNamesApp_withGenre_isNotResume() {
|
|
42
|
-
|
|
62
|
+
let c = criteria(mediaName: "Acme", genreNames: ["jazz"])
|
|
63
|
+
#expect(c.matchesAppName)
|
|
64
|
+
#expect(!c.isResume)
|
|
43
65
|
}
|
|
44
66
|
|
|
45
67
|
@Test func queryNotApp_isNotResume() {
|
|
46
|
-
#expect(!
|
|
68
|
+
#expect(!criteria(mediaName: "jazz").isResume)
|
|
47
69
|
}
|
|
48
|
-
}
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
artistName: String? = nil,
|
|
56
|
-
albumName: String? = nil,
|
|
57
|
-
mediaTypeMode: String? = nil,
|
|
58
|
-
reference: MediaIntentCriteria.Reference = .unknown,
|
|
59
|
-
hasMediaType: Bool = false,
|
|
60
|
-
appName: String? = "Radio Garden",
|
|
61
|
-
) -> MediaIntentCriteria {
|
|
62
|
-
MediaIntentCriteria.from(
|
|
63
|
-
mediaName: mediaName, genreNames: genreNames, artistName: artistName,
|
|
64
|
-
albumName: albumName, mediaTypeMode: mediaTypeMode,
|
|
65
|
-
reference: reference, hasMediaType: hasMediaType, appName: appName,
|
|
66
|
-
)
|
|
71
|
+
// An empty genre name is still a genre filter: the fact is that genres were given.
|
|
72
|
+
@Test func emptyGenreName_isStillAGenreFilter() {
|
|
73
|
+
let c = criteria(genreNames: [""])
|
|
74
|
+
#expect(c.genre == "")
|
|
75
|
+
#expect(!c.isResume)
|
|
67
76
|
}
|
|
77
|
+
}
|
|
68
78
|
|
|
79
|
+
@Suite("MediaIntentCriteria from intent fields")
|
|
80
|
+
struct MediaIntentCriteriaFromTests {
|
|
69
81
|
// MARK: resume vs search
|
|
70
82
|
|
|
71
83
|
@Test func playAppName_resumes() {
|
|
72
|
-
let c =
|
|
73
|
-
#expect(c.query == "
|
|
84
|
+
let c = criteria(mediaName: "Acme", hasMediaType: true)
|
|
85
|
+
#expect(c.query == "Acme")
|
|
74
86
|
#expect(c.matchesAppName)
|
|
75
87
|
#expect(c.isResume)
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
@Test func emptyIntent_resumes() {
|
|
79
|
-
let c =
|
|
91
|
+
let c = criteria()
|
|
80
92
|
#expect(c.query == "")
|
|
81
93
|
#expect(c.isResume)
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
@Test func playMyFavorites_searches() {
|
|
85
|
-
let c =
|
|
97
|
+
let c = criteria(reference: .my)
|
|
86
98
|
#expect(!c.isResume)
|
|
87
99
|
#expect(c.reference == .my)
|
|
88
100
|
}
|
|
89
101
|
|
|
90
102
|
@Test func stationName_searches() {
|
|
91
|
-
let c =
|
|
103
|
+
let c = criteria(mediaName: "KCRW")
|
|
92
104
|
#expect(c.query == "KCRW")
|
|
93
105
|
#expect(!c.matchesAppName)
|
|
94
106
|
#expect(!c.isResume)
|
|
@@ -99,23 +111,22 @@ struct MediaIntentCriteriaFromTests {
|
|
|
99
111
|
|
|
100
112
|
// "Play jazz" → genre is a FILTER, not a mode. mode stays nil; genre set.
|
|
101
113
|
@Test func genre_isFilterNotMode() {
|
|
102
|
-
let c =
|
|
114
|
+
let c = criteria(genreNames: ["jazz"], hasMediaType: true)
|
|
103
115
|
#expect(c.query == "jazz")
|
|
104
116
|
#expect(c.searchMode == nil)
|
|
105
117
|
#expect(c.genre == "jazz")
|
|
106
|
-
#expect(c.hasGenres)
|
|
107
118
|
#expect(!c.isResume)
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
@Test func multiWordGenre_joined_noMode() {
|
|
111
|
-
let c =
|
|
122
|
+
let c = criteria(mediaName: " ", genreNames: ["classic", "rock"])
|
|
112
123
|
#expect(c.query == "classic rock")
|
|
113
124
|
#expect(c.genre == "classic rock")
|
|
114
125
|
#expect(c.searchMode == nil)
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
@Test func artist_isFilterNotMode() {
|
|
118
|
-
let c =
|
|
129
|
+
let c = criteria(artistName: "Michael Jackson")
|
|
119
130
|
#expect(c.query == "Michael Jackson")
|
|
120
131
|
#expect(c.searchMode == nil)
|
|
121
132
|
#expect(c.artist == "Michael Jackson")
|
|
@@ -123,7 +134,7 @@ struct MediaIntentCriteriaFromTests {
|
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
@Test func albumAndArtist_areFiltersNotMode() {
|
|
126
|
-
let c =
|
|
137
|
+
let c = criteria(mediaName: "Thriller", artistName: "Michael Jackson", albumName: "Thriller")
|
|
127
138
|
#expect(c.searchMode == nil)
|
|
128
139
|
#expect(c.album == "Thriller")
|
|
129
140
|
#expect(c.artist == "Michael Jackson")
|
|
@@ -132,7 +143,7 @@ struct MediaIntentCriteriaFromTests {
|
|
|
132
143
|
|
|
133
144
|
// A vertical mediaType DOES set the mode, and travels with its filters.
|
|
134
145
|
@Test func song_viaMediaTypeMode_setsTitle() {
|
|
135
|
-
let c =
|
|
146
|
+
let c = criteria(mediaName: "Billie Jean", mediaTypeMode: "song")
|
|
136
147
|
#expect(c.query == "Billie Jean")
|
|
137
148
|
#expect(c.searchMode == "song")
|
|
138
149
|
#expect(c.title == "Billie Jean")
|
|
@@ -140,7 +151,7 @@ struct MediaIntentCriteriaFromTests {
|
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
@Test func playlist_viaMediaTypeMode_setsPlaylist() {
|
|
143
|
-
let c =
|
|
154
|
+
let c = criteria(mediaName: "Workout", mediaTypeMode: "playlist")
|
|
144
155
|
#expect(c.query == "Workout")
|
|
145
156
|
#expect(c.searchMode == "playlist")
|
|
146
157
|
#expect(c.playlist == "Workout")
|
|
@@ -149,19 +160,19 @@ struct MediaIntentCriteriaFromTests {
|
|
|
149
160
|
|
|
150
161
|
// "Play jazz station" → vertical wins as mode; genre rides as a filter.
|
|
151
162
|
@Test func station_withGenreFilter() {
|
|
152
|
-
let c =
|
|
163
|
+
let c = criteria(genreNames: ["jazz"], mediaTypeMode: "station", hasMediaType: true)
|
|
153
164
|
#expect(c.searchMode == "station")
|
|
154
165
|
#expect(c.genre == "jazz")
|
|
155
166
|
}
|
|
156
167
|
|
|
157
168
|
@Test func podcast_setsMode() {
|
|
158
|
-
let c =
|
|
169
|
+
let c = criteria(mediaName: "Serial", mediaTypeMode: "podcast", hasMediaType: true)
|
|
159
170
|
#expect(c.searchMode == "podcast")
|
|
160
171
|
#expect(c.query == "Serial")
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
@Test func plainName_hasNoStructuredFields() {
|
|
164
|
-
let c =
|
|
175
|
+
let c = criteria(mediaName: "KCRW")
|
|
165
176
|
#expect(c.searchMode == nil)
|
|
166
177
|
#expect(c.genre == nil)
|
|
167
178
|
#expect(c.artist == nil)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@Suite("NowPlayingButtonRules")
|
|
5
|
+
struct NowPlayingButtonRulesTests {
|
|
6
|
+
@Test func buttons_dropRepeats_keepingFirstOrder() {
|
|
7
|
+
#expect(NowPlayingButtonRules.buttons(["shuffle", "favorite", "shuffle", "repeat"]) == ["shuffle", "favorite", "repeat"])
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@Test func buttons_capAtFive_afterDroppingRepeats() {
|
|
11
|
+
let configured = ["a", "a", "b", "c", "d", "e", "f"]
|
|
12
|
+
#expect(NowPlayingButtonRules.buttons(configured) == ["a", "b", "c", "d", "e"])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test func buttons_empty() {
|
|
16
|
+
#expect(NowPlayingButtonRules.buttons([String]()).isEmpty)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Test func repeatMode_cyclesOffTrackQueue() {
|
|
20
|
+
#expect(NowPlayingButtonRules.repeatMode(after: .off) == .track)
|
|
21
|
+
#expect(NowPlayingButtonRules.repeatMode(after: .track) == .queue)
|
|
22
|
+
#expect(NowPlayingButtonRules.repeatMode(after: .queue) == .off)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@Test func rate_advancesFromAConfiguredRate_andWraps() {
|
|
26
|
+
let rates = [0.5, 1, 1.5, 2]
|
|
27
|
+
#expect(NowPlayingButtonRules.rate(after: 1, in: rates) == 1.5)
|
|
28
|
+
#expect(NowPlayingButtonRules.rate(after: 2, in: rates) == 0.5)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test func rate_matchesAConfiguredRateWithinAHundredth() {
|
|
32
|
+
#expect(NowPlayingButtonRules.rate(after: 1.005, in: [1, 1.5]) == 1.5)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test func rate_offTheList_takesTheFirstAboveIt_elseTheFirst() {
|
|
36
|
+
#expect(NowPlayingButtonRules.rate(after: 1.2, in: [0.5, 1, 1.5]) == 1.5)
|
|
37
|
+
#expect(NowPlayingButtonRules.rate(after: 3, in: [0.5, 1, 1.5]) == 0.5)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test func rate_noRates_isNil() {
|
|
41
|
+
#expect(NowPlayingButtonRules.rate(after: 1, in: []) == nil)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -1395,3 +1395,44 @@ struct MediaStackEffectTests {
|
|
|
1395
1395
|
#expect(eh.appliedTimedMetadata == [metadata])
|
|
1396
1396
|
}
|
|
1397
1397
|
}
|
|
1398
|
+
|
|
1399
|
+
@Suite("Player - now-playing preview")
|
|
1400
|
+
struct NowPlayingPreviewTests {
|
|
1401
|
+
@Test @MainActor
|
|
1402
|
+
func preview_showsTheTappedTrackWithoutLoadingIt() {
|
|
1403
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1404
|
+
let tapped = Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped")
|
|
1405
|
+
|
|
1406
|
+
c.previewNowPlaying(tapped)
|
|
1407
|
+
|
|
1408
|
+
#expect(eh.loadNowPlayingMetadataCalls.map(\.id) == ["tapped"])
|
|
1409
|
+
#expect(eh.resolveAndLoadCalls.isEmpty)
|
|
1410
|
+
#expect(c.currentTrack == nil)
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
@Test @MainActor
|
|
1414
|
+
func endingPreview_restoresTheCurrentTrackAndRendersItsLines() {
|
|
1415
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1416
|
+
loadTrack(c, id: "current")
|
|
1417
|
+
c.previewNowPlaying(Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped"))
|
|
1418
|
+
let rendersBefore = eh.nowPlayingRenderCount
|
|
1419
|
+
|
|
1420
|
+
c.endNowPlayingPreview()
|
|
1421
|
+
|
|
1422
|
+
#expect(eh.loadNowPlayingMetadataCalls.last?.id == "current")
|
|
1423
|
+
#expect(eh.nowPlayingRenderCount == rendersBefore + 1)
|
|
1424
|
+
#expect(eh.clearNowPlayingCallCount == 0)
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
@Test @MainActor
|
|
1428
|
+
func endingPreview_withNothingCurrent_clearsTheSurface() {
|
|
1429
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1430
|
+
c.previewNowPlaying(Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped"))
|
|
1431
|
+
|
|
1432
|
+
c.endNowPlayingPreview()
|
|
1433
|
+
|
|
1434
|
+
#expect(eh.clearNowPlayingCallCount == 1)
|
|
1435
|
+
#expect(eh.loadNowPlayingMetadataCalls.map(\.id) == ["tapped"])
|
|
1436
|
+
#expect(eh.nowPlayingRenderCount == 0)
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
extension Array where Element: Sendable {
|
|
2
|
+
/// `transform` over every element at once, with the results in element order. Cancelling the
|
|
3
|
+
/// caller cancels every transform still running.
|
|
4
|
+
func concurrentMap<T: Sendable>(_ transform: @escaping @MainActor (Element) async -> T) async -> [T] {
|
|
5
|
+
await withTaskGroup(of: (Int, T).self) { group in
|
|
6
|
+
for (index, element) in enumerated() {
|
|
7
|
+
group.addTask {
|
|
8
|
+
let result = await transform(element)
|
|
9
|
+
return (index, result)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
var results: [(Int, T)] = []
|
|
13
|
+
for await result in group {
|
|
14
|
+
results.append(result)
|
|
15
|
+
}
|
|
16
|
+
return results.sorted { $0.0 < $1.0 }.map(\.1)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import os.log
|
|
3
|
-
|
|
4
|
-
private let intentLogger = Logger(subsystem: "com.audiobrowser", category: "MediaIntent")
|
|
5
|
-
|
|
6
|
-
extension HybridAudioBrowser {
|
|
7
|
-
/// Sets the favorite state of the playing track for a Siri intent, through the same path as
|
|
8
|
-
/// the CarPlay favorite button. False when nothing is playing, the playing track has no
|
|
9
|
-
/// identity to favorite, or the change throws.
|
|
10
|
-
@MainActor
|
|
11
|
-
static func favoriteCurrentTrack(_ favorited: Bool) -> Bool {
|
|
12
|
-
guard let browser = shared else { return false }
|
|
13
|
-
do {
|
|
14
|
-
return try browser.favoriteActiveTrack(favorited)
|
|
15
|
-
} catch {
|
|
16
|
-
intentLogger.error("favoriteCurrentTrack(\(favorited)) failed: \(error.localizedDescription)")
|
|
17
|
-
return false
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Intents
|
|
3
|
-
import os.log
|
|
4
|
-
|
|
5
|
-
/// In-app `INAddMediaIntent` handler — "Hey Siri, add this to my favorites."
|
|
6
|
-
/// Adding to the user's collection maps onto the library-managed favorite state
|
|
7
|
-
/// of the **currently playing** track via `setActiveTrackFavorited` — the same
|
|
8
|
-
/// path as the CarPlay favorite button and the `INUpdateMediaAffinityIntent`
|
|
9
|
-
/// "like", so it flips the now-playing heart and fires `onFavoriteChanged` for
|
|
10
|
-
/// the consumer to persist. No extra config beyond the `favorite` capability.
|
|
11
|
-
///
|
|
12
|
-
/// We expose a single flat favorites collection (no named playlists), so the
|
|
13
|
-
/// destination always resolves to the user's library. Adding a *named* item
|
|
14
|
-
/// that isn't currently playing needs the structured-intent consumer callback
|
|
15
|
-
/// and is out of scope here — like the named play-by-search case.
|
|
16
|
-
///
|
|
17
|
-
/// Internal (keeps Intents types out of the generated header), exposed to the
|
|
18
|
-
/// ObjC runtime via `@objc(RNABMediaAddHandler)` and vended from
|
|
19
|
-
/// `RNABAudioBrowser.handlerForIntent(_:)`.
|
|
20
|
-
@objc(RNABMediaAddHandler)
|
|
21
|
-
class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
|
|
22
|
-
private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAddHandler")
|
|
23
|
-
|
|
24
|
-
func handle(
|
|
25
|
-
intent _: INAddMediaIntent,
|
|
26
|
-
completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void,
|
|
27
|
-
) {
|
|
28
|
-
Self.logger.info("Add media — favorite current track")
|
|
29
|
-
|
|
30
|
-
Task { @MainActor in
|
|
31
|
-
let applied = HybridAudioBrowser.favoriteCurrentTrack(true)
|
|
32
|
-
completion(INAddMediaIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Siri runs a resolve phase before `handle`; without a resolution method it
|
|
37
|
-
// errors ("Unable to find implementation of resolution method"). These are
|
|
38
|
-
// *optional* @objc protocol methods, so pin the exact selectors SiriKit
|
|
39
|
-
// dispatches to (otherwise they aren't exposed and are "not found"). We act on
|
|
40
|
-
// the currently-playing track / our single library, so pass both through.
|
|
41
|
-
@objc(resolveMediaItemsForAddMedia:withCompletion:)
|
|
42
|
-
func resolveMediaItems(
|
|
43
|
-
for intent: INAddMediaIntent,
|
|
44
|
-
with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
|
|
45
|
-
) {
|
|
46
|
-
let name = intent.mediaSearch?.mediaName ?? ""
|
|
47
|
-
let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
|
|
48
|
-
completion([.success(with: item)])
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
@objc(resolveMediaDestinationForAddMedia:withCompletion:)
|
|
52
|
-
func resolveMediaDestination(
|
|
53
|
-
for _: INAddMediaIntent,
|
|
54
|
-
with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
|
|
55
|
-
) {
|
|
56
|
-
// One flat favorites collection — always the user's library, no playlists.
|
|
57
|
-
completion(.success(with: INMediaDestination.library))
|
|
58
|
-
}
|
|
59
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Intents
|
|
3
|
-
import os.log
|
|
4
|
-
|
|
5
|
-
/// In-app `INUpdateMediaAffinityIntent` handler — "Hey Siri, I like / don't like
|
|
6
|
-
/// this." Maps the affinity onto the library-managed favorite state of the
|
|
7
|
-
/// **currently playing** track via `setActiveTrackFavorited` — the same path as
|
|
8
|
-
/// the CarPlay favorite button — so it flips the now-playing heart and fires
|
|
9
|
-
/// `onFavoriteChanged` for the consumer to persist. Like → favorited, dislike →
|
|
10
|
-
/// unfavorited. No extra config beyond the `favorite` capability.
|
|
11
|
-
///
|
|
12
|
-
/// Internal (keeps Intents types out of the generated header), exposed to the
|
|
13
|
-
/// ObjC runtime via `@objc(RNABMediaAffinityHandler)` and vended from
|
|
14
|
-
/// `RNABAudioBrowser.handlerForIntent(_:)`.
|
|
15
|
-
///
|
|
16
|
-
/// Affinity for a *named* item ("I like «station»") is out of scope here — that
|
|
17
|
-
/// needs the structured-intent consumer callback.
|
|
18
|
-
@objc(RNABMediaAffinityHandler)
|
|
19
|
-
class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
|
|
20
|
-
private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAffinityHandler")
|
|
21
|
-
|
|
22
|
-
func handle(
|
|
23
|
-
intent: INUpdateMediaAffinityIntent,
|
|
24
|
-
completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
|
|
25
|
-
) {
|
|
26
|
-
let affinity = intent.affinityType
|
|
27
|
-
Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
|
|
28
|
-
|
|
29
|
-
Task { @MainActor in
|
|
30
|
-
let applied = switch affinity {
|
|
31
|
-
case .like: HybridAudioBrowser.favoriteCurrentTrack(true)
|
|
32
|
-
case .dislike: HybridAudioBrowser.favoriteCurrentTrack(false)
|
|
33
|
-
default: false
|
|
34
|
-
}
|
|
35
|
-
completion(INUpdateMediaAffinityIntentResponse(code: applied ? .success : .failure, userActivity: nil))
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Siri runs a resolve phase before `handle`; without a resolution method it
|
|
40
|
-
// errors ("Unable to find implementation of resolution method"). This is an
|
|
41
|
-
// *optional* @objc protocol method, so pin the exact selector SiriKit dispatches
|
|
42
|
-
// to (otherwise it isn't exposed and is "not found"). We act on the currently
|
|
43
|
-
// playing track regardless, so pass the spoken item straight through.
|
|
44
|
-
@objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
|
|
45
|
-
func resolveMediaItems(
|
|
46
|
-
for intent: INUpdateMediaAffinityIntent,
|
|
47
|
-
with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
|
|
48
|
-
) {
|
|
49
|
-
let name = intent.mediaSearch?.mediaName ?? ""
|
|
50
|
-
let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
|
|
51
|
-
completion([.success(with: item)])
|
|
52
|
-
}
|
|
53
|
-
}
|