react-native-audio-browser 0.2.0-next.33 → 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/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- 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 +13 -46
- 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 +10 -12
- 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/MediaIntentPlayback.swift +1 -1
- package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
- package/ios/CarPlay/RNABAudioBrowser.m +7 -15
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/HybridAudioBrowser+Browser.swift +16 -17
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +15 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +45 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/ConcurrentMapTests.swift +42 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
- package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +136 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/ConcurrentMap.swift +19 -0
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -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
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
|
@@ -109,6 +109,46 @@ final class MockPlayerEngine: PlayerEngine {
|
|
|
109
109
|
func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
|
|
110
110
|
updateSkipAvailabilityCalls.append((canNext, canPrevious))
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
// Now-playing lines and the audio session, in the order the Player drove them
|
|
114
|
+
var nowPlayingRenderCount = 0
|
|
115
|
+
var nowPlayingTrackChangedCount = 0
|
|
116
|
+
var appliedTimedMetadata: [TimedMetadata] = []
|
|
117
|
+
var activateAudioSessionCount = 0
|
|
118
|
+
var cancelAudioSessionReleaseCount = 0
|
|
119
|
+
var scheduleAudioSessionReleaseCount = 0
|
|
120
|
+
/// Called with the name of each of these calls, so a test can interleave them with callbacks.
|
|
121
|
+
var onSeamCall: ((String) -> Void)?
|
|
122
|
+
|
|
123
|
+
func renderNowPlaying() {
|
|
124
|
+
nowPlayingRenderCount += 1
|
|
125
|
+
onSeamCall?("renderNowPlaying")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func nowPlayingTrackChanged() {
|
|
129
|
+
nowPlayingTrackChangedCount += 1
|
|
130
|
+
onSeamCall?("nowPlayingTrackChanged")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func applyTimedMetadata(_ metadata: TimedMetadata) {
|
|
134
|
+
appliedTimedMetadata.append(metadata)
|
|
135
|
+
onSeamCall?("applyTimedMetadata")
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func activateAudioSession() {
|
|
139
|
+
activateAudioSessionCount += 1
|
|
140
|
+
onSeamCall?("activateAudioSession")
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func cancelAudioSessionRelease() {
|
|
144
|
+
cancelAudioSessionReleaseCount += 1
|
|
145
|
+
onSeamCall?("cancelAudioSessionRelease")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func scheduleAudioSessionRelease() {
|
|
149
|
+
scheduleAudioSessionReleaseCount += 1
|
|
150
|
+
onSeamCall?("scheduleAudioSessionRelease")
|
|
151
|
+
}
|
|
112
152
|
}
|
|
113
153
|
|
|
114
154
|
/// A Player over `engine` whose retries reload while `retries` and otherwise give up, keeping
|
|
@@ -146,7 +186,7 @@ final class MockPlayerCallbacks: PlayerCallbacks {
|
|
|
146
186
|
var shuffleEnabledChanges: [Bool] = []
|
|
147
187
|
var errorEvents: [PlaybackErrorEvent] = []
|
|
148
188
|
var playbackIntervalFiredCount = 0
|
|
149
|
-
var
|
|
189
|
+
var timedMetadata: [TimedMetadata] = []
|
|
150
190
|
|
|
151
191
|
/// The order playback, intent and playing-state changes arrived in.
|
|
152
192
|
var publishOrder: [String] = []
|
|
@@ -200,8 +240,9 @@ final class MockPlayerCallbacks: PlayerCallbacks {
|
|
|
200
240
|
errorEvents.append(event)
|
|
201
241
|
}
|
|
202
242
|
|
|
203
|
-
func
|
|
204
|
-
|
|
243
|
+
func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
|
|
244
|
+
publishOrder.append("timedMetadata")
|
|
245
|
+
timedMetadata.append(metadata)
|
|
205
246
|
}
|
|
206
247
|
|
|
207
248
|
var sleepTimerChanges: [SleepTimerState?] = []
|
package/ios/TrackSelector.swift
CHANGED
|
@@ -65,34 +65,42 @@ class TrackSelector {
|
|
|
65
65
|
return .none
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
let
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 2. Has src — single playable track
|
|
76
|
-
if track.src != nil {
|
|
77
|
-
return await handlePlayableTrack(track, player: player)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// 3. Has path — browsable
|
|
81
|
-
if let path {
|
|
68
|
+
let playback: PlannedPlayback
|
|
69
|
+
if let path = track.path, BrowserPathHelper.isContextual(path) {
|
|
70
|
+
playback = await contextualPlayback(path, track: track, player: player)
|
|
71
|
+
} else if track.src != nil {
|
|
72
|
+
playback = .single(track)
|
|
73
|
+
} else if let path = track.path {
|
|
82
74
|
return .browse(path: path)
|
|
75
|
+
} else {
|
|
76
|
+
return .none
|
|
83
77
|
}
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
79
|
+
let event = TrackLoadEvent(track: track, queue: playback.queue, startIndex: Double(playback.index))
|
|
80
|
+
if await browserManager.awaitTrackLoadHandler(event: event) {
|
|
81
|
+
return .intercepted
|
|
82
|
+
}
|
|
83
|
+
return .play(playback.intent)
|
|
87
84
|
}
|
|
88
85
|
|
|
89
86
|
// MARK: - Private Helpers
|
|
90
87
|
|
|
91
|
-
|
|
88
|
+
/// What a selection plays, and the queue and index the track-load handler is shown.
|
|
89
|
+
private struct PlannedPlayback {
|
|
90
|
+
let queue: [Track]
|
|
91
|
+
let index: Int
|
|
92
|
+
let intent: PlaybackIntent
|
|
93
|
+
|
|
94
|
+
static func single(_ track: Track) -> PlannedPlayback {
|
|
95
|
+
PlannedPlayback(queue: [track], index: 0, intent: .loadTrack(track))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private func contextualPlayback(
|
|
92
100
|
_ path: String,
|
|
93
101
|
track: Track,
|
|
94
102
|
player: some TrackSelectionPlayer,
|
|
95
|
-
) async ->
|
|
103
|
+
) async -> PlannedPlayback {
|
|
96
104
|
let parentPath = BrowserPathHelper.stripTrackId(path)
|
|
97
105
|
let trackId = BrowserPathHelper.extractTrackId(path)
|
|
98
106
|
|
|
@@ -110,47 +118,21 @@ class TrackSelector {
|
|
|
110
118
|
}
|
|
111
119
|
if let index = skipIndex {
|
|
112
120
|
logger.debug("Queue already from \(parentPath), skipping to index \(index)")
|
|
113
|
-
|
|
114
|
-
let event = TrackLoadEvent(track: track, queue: queue, startIndex: Double(index))
|
|
115
|
-
if await browserManager.awaitTrackLoadHandler(event: event) {
|
|
116
|
-
return .intercepted
|
|
117
|
-
}
|
|
118
|
-
return .play(.skipTo(index: index))
|
|
121
|
+
return PlannedPlayback(queue: player.tracks, index: index, intent: .skipTo(index: index))
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
// Expand the queue from the contextual path
|
|
123
125
|
do {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
let event = TrackLoadEvent(track: track, queue: tracks, startIndex: Double(startIndex))
|
|
127
|
-
if await browserManager.awaitTrackLoadHandler(event: event) {
|
|
128
|
-
return .intercepted
|
|
129
|
-
}
|
|
130
|
-
return .play(.setQueue(tracks: tracks, startIndex: startIndex, sourcePath: parentPath))
|
|
131
|
-
} else {
|
|
132
|
-
// Fallback: single track
|
|
133
|
-
return await singleTrackResult(track)
|
|
126
|
+
guard let (tracks, startIndex) = try await browserManager.expandQueueFromContextualPath(path) else {
|
|
127
|
+
return .single(track)
|
|
134
128
|
}
|
|
129
|
+
return PlannedPlayback(
|
|
130
|
+
queue: tracks, index: startIndex,
|
|
131
|
+
intent: .setQueue(tracks: tracks, startIndex: startIndex, sourcePath: parentPath),
|
|
132
|
+
)
|
|
135
133
|
} catch {
|
|
136
134
|
logger.error("Error expanding queue: \(error.localizedDescription)")
|
|
137
|
-
|
|
138
|
-
return await singleTrackResult(track)
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
private func handlePlayableTrack(
|
|
143
|
-
_ track: Track,
|
|
144
|
-
player _: some TrackSelectionPlayer,
|
|
145
|
-
) async -> SelectionResult {
|
|
146
|
-
await singleTrackResult(track)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
private func singleTrackResult(_ track: Track) async -> SelectionResult {
|
|
150
|
-
let event = TrackLoadEvent(track: track, queue: [track], startIndex: 0)
|
|
151
|
-
if await browserManager.awaitTrackLoadHandler(event: event) {
|
|
152
|
-
return .intercepted
|
|
135
|
+
return .single(track)
|
|
153
136
|
}
|
|
154
|
-
return .play(.loadTrack(track))
|
|
155
137
|
}
|
|
156
138
|
}
|
|
@@ -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/ios/Util/Emitter.swift
CHANGED
|
@@ -40,13 +40,6 @@ public final class Emitter<T>: @unchecked Sendable {
|
|
|
40
40
|
snapshot = next
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/// Removes all listeners
|
|
44
|
-
public func removeAllListeners() {
|
|
45
|
-
lock.lock()
|
|
46
|
-
defer { lock.unlock() }
|
|
47
|
-
snapshot = []
|
|
48
|
-
}
|
|
49
|
-
|
|
50
43
|
/// Emits an event to all registered listeners
|
|
51
44
|
/// - Parameter event: The event data to emit
|
|
52
45
|
public func emit(_ event: T) {
|
|
@@ -58,13 +51,6 @@ public final class Emitter<T>: @unchecked Sendable {
|
|
|
58
51
|
entry.handler(event)
|
|
59
52
|
}
|
|
60
53
|
}
|
|
61
|
-
|
|
62
|
-
/// Returns the number of registered listeners
|
|
63
|
-
public var listenerCount: Int {
|
|
64
|
-
lock.lock()
|
|
65
|
-
defer { lock.unlock() }
|
|
66
|
-
return snapshot.count
|
|
67
|
-
}
|
|
68
54
|
}
|
|
69
55
|
|
|
70
56
|
/// A listener registration that ends when cancelled or released.
|
|
@@ -2,8 +2,7 @@ import Foundation
|
|
|
2
2
|
import Network
|
|
3
3
|
import os.log
|
|
4
4
|
|
|
5
|
-
///
|
|
6
|
-
/// Notifies listeners when the connection state changes.
|
|
5
|
+
/// The device's connectivity, from NWPathMonitor; `changed` emits each change on the main thread.
|
|
7
6
|
final class NetworkMonitor: @unchecked Sendable {
|
|
8
7
|
// MARK: - Properties
|
|
9
8
|
|
|
@@ -11,25 +10,17 @@ final class NetworkMonitor: @unchecked Sendable {
|
|
|
11
10
|
private let monitor: NWPathMonitor
|
|
12
11
|
private let queue = DispatchQueue(label: "com.audiobrowser.networkmonitor")
|
|
13
12
|
|
|
14
|
-
/// Guards `_isOnline` only
|
|
13
|
+
/// Guards `_isOnline` only; never held across an emit.
|
|
15
14
|
private let lock = NSLock()
|
|
16
15
|
private var _isOnline = false
|
|
17
16
|
|
|
18
|
-
///
|
|
19
|
-
/// lock-protected rather than confined, so callers on the JS thread (Nitro's
|
|
20
|
-
/// `getOnline`) don't have to block on a main-queue hop to read a Bool.
|
|
21
|
-
///
|
|
22
|
-
/// The broadcast is main-confined even though the value isn't — `onChanged`
|
|
23
|
-
/// fans out to the JS bridge, a now-playing re-render and a player reload, so
|
|
24
|
-
/// it must not run under the lock (reentrancy) or off the main actor.
|
|
17
|
+
/// Readable from any thread, so the JS thread's `getOnline` needs no main-queue hop.
|
|
25
18
|
var isOnline: Bool {
|
|
26
19
|
lock.lock()
|
|
27
20
|
defer { lock.unlock() }
|
|
28
21
|
return _isOnline
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
/// Stores a new status and, if it actually changed, broadcasts on main —
|
|
32
|
-
/// outside the lock.
|
|
33
24
|
private func apply(_ newStatus: Bool) {
|
|
34
25
|
lock.lock()
|
|
35
26
|
let changed = _isOnline != newStatus
|
|
@@ -40,28 +31,20 @@ final class NetworkMonitor: @unchecked Sendable {
|
|
|
40
31
|
guard changed else { return }
|
|
41
32
|
logger.notice("Network status changed: \(oldValue) -> \(newStatus)")
|
|
42
33
|
DispatchQueue.main.async { [weak self] in
|
|
43
|
-
self?.
|
|
34
|
+
self?.changed.emit(newStatus)
|
|
44
35
|
}
|
|
45
36
|
}
|
|
46
37
|
|
|
47
|
-
|
|
48
|
-
/// fans out to the JS event bridge, the now-playing re-render (so the offline/online label tracks
|
|
49
|
-
/// connectivity), and the player's reconnect-reload.
|
|
50
|
-
var onChanged: ((Bool) -> Void)?
|
|
38
|
+
let changed = Emitter<Bool>()
|
|
51
39
|
|
|
52
40
|
// MARK: - Initialization
|
|
53
41
|
|
|
54
42
|
init() {
|
|
55
43
|
monitor = NWPathMonitor()
|
|
56
44
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// recovers from that: the handler has already fired, `onChanged` isn't wired
|
|
61
|
-
// until setupPlayer, and the stale value then drives `getOnline()`, the JS
|
|
62
|
-
// event, and RetryLoop's offline parking until the next genuine path
|
|
63
|
-
// change. Seeding first is a best-effort guess; the handler fires shortly
|
|
64
|
-
// after `start` and is the authoritative last writer.
|
|
45
|
+
// Seeded before `start`: a write after it races the handler's, and would clobber a real
|
|
46
|
+
// update with a `currentPath` the monitor hasn't finished evaluating. The handler, which
|
|
47
|
+
// fires shortly after `start`, is the last writer.
|
|
65
48
|
let initialStatus = monitor.currentPath.status == .satisfied
|
|
66
49
|
logger.notice("NetworkMonitor initialized, initial isOnline=\(initialStatus)")
|
|
67
50
|
_isOnline = initialStatus
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import AVKit
|
|
2
|
+
import os.log
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Presents the system audio route picker over the topmost view controller.
|
|
6
|
+
@MainActor
|
|
7
|
+
enum OutputPicker {
|
|
8
|
+
private static let logger = Logger(subsystem: "com.audiobrowser", category: "OutputPicker")
|
|
9
|
+
|
|
10
|
+
static func present() {
|
|
11
|
+
guard let windowScene = UIApplication.shared.activeWindowScene,
|
|
12
|
+
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
13
|
+
var topController = window.rootViewController
|
|
14
|
+
else {
|
|
15
|
+
logger.error("openOutputPicker: no active window scene / root view controller")
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
while let presented = topController.presentedViewController {
|
|
19
|
+
topController = presented
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// AVRoutePickerView has no API to present its picker, so its internal button is tapped. The
|
|
23
|
+
// view must be visible to the system (a hidden source view suppresses the picker), and the
|
|
24
|
+
// button can sit below the top-level subviews.
|
|
25
|
+
let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
26
|
+
routePicker.alpha = 0.01
|
|
27
|
+
topController.view.addSubview(routePicker)
|
|
28
|
+
|
|
29
|
+
if let button = firstButton(in: routePicker) {
|
|
30
|
+
button.sendActions(for: .touchUpInside)
|
|
31
|
+
} else {
|
|
32
|
+
logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// On iPad the picker's popover anchors to this view.
|
|
36
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
37
|
+
routePicker.removeFromSuperview()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static func firstButton(in view: UIView) -> UIButton? {
|
|
42
|
+
if let button = view as? UIButton { return button }
|
|
43
|
+
for subview in view.subviews {
|
|
44
|
+
if let button = firstButton(in: subview) { return button }
|
|
45
|
+
}
|
|
46
|
+
return nil
|
|
47
|
+
}
|
|
48
|
+
}
|
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
|
-
}
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Testing
|
|
3
|
-
|
|
4
|
-
@testable import AudioBrowserTestable
|
|
5
|
-
|
|
6
|
-
@Suite("RemoteCommand")
|
|
7
|
-
struct RemoteCommandTests {
|
|
8
|
-
@Test func removedCommandsAreDisabled() {
|
|
9
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
10
|
-
enabled: [.play, .pause, .next, .previous],
|
|
11
|
-
replacedBy: [.play, .pause],
|
|
12
|
-
)
|
|
13
|
-
#expect(toDisable.map(\.key) == ["nextTrack", "previousTrack"])
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
@Test func retainedCommandsAreNotDisabled() {
|
|
17
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
18
|
-
enabled: [.play, .pause],
|
|
19
|
-
replacedBy: [.play, .pause, .stop],
|
|
20
|
-
)
|
|
21
|
-
#expect(toDisable.isEmpty)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/// A changed jump interval is the same command with a new associated value —
|
|
25
|
-
/// diffing by `==` would report it as removed and disable the button that the
|
|
26
|
-
/// update had just re-enabled.
|
|
27
|
-
@Test func changedJumpIntervalIsNotDisabled() {
|
|
28
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
29
|
-
enabled: [.skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15])],
|
|
30
|
-
replacedBy: [.skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30])],
|
|
31
|
-
)
|
|
32
|
-
#expect(toDisable.isEmpty)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
@Test func changedPlaybackRatesAreNotDisabled() {
|
|
36
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
37
|
-
enabled: [.changePlaybackRate(supportedPlaybackRates: [1.0])],
|
|
38
|
-
replacedBy: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])],
|
|
39
|
-
)
|
|
40
|
-
#expect(toDisable.isEmpty)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
@Test func everyCommandIsDisabledWhenNoneRemain() {
|
|
44
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
45
|
-
enabled: RemoteCommand.all(),
|
|
46
|
-
replacedBy: [],
|
|
47
|
-
)
|
|
48
|
-
#expect(toDisable.count == RemoteCommand.all().count)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/// `key` is the identity the diff above compares; two cases sharing a key would
|
|
52
|
-
/// disable each other.
|
|
53
|
-
@Test func keysAreUnique() {
|
|
54
|
-
let all = RemoteCommand.all()
|
|
55
|
-
#expect(Set(all.map(\.key)).count == all.count)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
@Test func droppedJumpCommandIsStillDisabled() {
|
|
59
|
-
let toDisable = RemoteCommand.commandsToDisable(
|
|
60
|
-
enabled: [.play, .skipForward(preferredIntervals: [15])],
|
|
61
|
-
replacedBy: [.play],
|
|
62
|
-
)
|
|
63
|
-
#expect(toDisable.map(\.key) == ["skipForward"])
|
|
64
|
-
}
|
|
65
|
-
}
|