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
|
@@ -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,
|
|
@@ -69,6 +69,6 @@ enum MediaIntentPlayback {
|
|
|
69
69
|
/// destroyed player.
|
|
70
70
|
@MainActor
|
|
71
71
|
private static func livePlayer(of browser: HybridAudioBrowser) -> Player? {
|
|
72
|
-
HybridAudioBrowser.shared === browser ? browser.
|
|
72
|
+
HybridAudioBrowser.shared === browser ? browser.player : nil
|
|
73
73
|
}
|
|
74
74
|
}
|
|
@@ -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
|
|
@@ -1,74 +1,27 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
-
import NitroModules
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/// All capabilities are enabled by default - only false values disable them
|
|
8
|
-
func buildRemoteCommands(
|
|
9
|
-
forwardJumpInterval: NSNumber?,
|
|
10
|
-
backwardJumpInterval: NSNumber?,
|
|
11
|
-
playbackRates: [Double],
|
|
12
|
-
) -> [RemoteCommand] {
|
|
13
|
-
var commands: [RemoteCommand] = []
|
|
14
|
-
|
|
15
|
-
// Play/Pause/Stop
|
|
16
|
-
if play != false {
|
|
17
|
-
commands.append(.play)
|
|
18
|
-
}
|
|
19
|
-
if pause != false {
|
|
20
|
-
commands.append(.pause)
|
|
21
|
-
}
|
|
22
|
-
if stop != false {
|
|
23
|
-
commands.append(.stop)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Navigation
|
|
27
|
-
if skipToNext != false {
|
|
28
|
-
commands.append(.next)
|
|
29
|
-
}
|
|
30
|
-
if skipToPrevious != false {
|
|
31
|
-
commands.append(.previous)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Seeking
|
|
35
|
-
if seekTo != false {
|
|
36
|
-
commands.append(.changePlaybackPosition)
|
|
37
|
-
}
|
|
3
|
+
#if canImport(NitroModules)
|
|
4
|
+
import NitroModules
|
|
5
|
+
#endif
|
|
38
6
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// Mode controls
|
|
60
|
-
if shuffleMode != false {
|
|
61
|
-
commands.append(.changeShuffleMode)
|
|
62
|
-
}
|
|
63
|
-
if repeatMode != false {
|
|
64
|
-
commands.append(.changeRepeatMode)
|
|
65
|
-
}
|
|
66
|
-
if playbackRate != false {
|
|
67
|
-
commands.append(.changePlaybackRate(
|
|
68
|
-
supportedPlaybackRates: playbackRates.map { NSNumber(value: $0) },
|
|
69
|
-
))
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return commands
|
|
7
|
+
extension PlayerCapabilities {
|
|
8
|
+
/// The remote commands the capabilities enable: each one unless set to false. `favorite`
|
|
9
|
+
/// enables none: no iOS 16+ surface invokes `likeCommand`; CarPlay has its own heart button and
|
|
10
|
+
/// Siri its affinity intent.
|
|
11
|
+
var remoteCommands: Set<RemoteCommand> {
|
|
12
|
+
let enabled: [(Bool?, RemoteCommand)] = [
|
|
13
|
+
(play, .play),
|
|
14
|
+
(pause, .pause),
|
|
15
|
+
(stop, .stop),
|
|
16
|
+
(skipToNext, .next),
|
|
17
|
+
(skipToPrevious, .previous),
|
|
18
|
+
(seekTo, .changePlaybackPosition),
|
|
19
|
+
(jumpForward, .skipForward),
|
|
20
|
+
(jumpBackward, .skipBackward),
|
|
21
|
+
(shuffleMode, .changeShuffleMode),
|
|
22
|
+
(repeatMode, .changeRepeatMode),
|
|
23
|
+
(playbackRate, .changePlaybackRate),
|
|
24
|
+
]
|
|
25
|
+
return Set(enabled.compactMap { $0.0 == false ? nil : $0.1 })
|
|
73
26
|
}
|
|
74
27
|
}
|
|
@@ -4,22 +4,6 @@ import NitroModules
|
|
|
4
4
|
|
|
5
5
|
/// Extension to make Nitro Track work with AVPlayer
|
|
6
6
|
extension Track {
|
|
7
|
-
/// The URL for AVPlayer playback (uses src for playback URL)
|
|
8
|
-
var playbackUrl: URL? {
|
|
9
|
-
guard let src else { return nil }
|
|
10
|
-
return URL(string: src)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/// Whether this is a local file URL
|
|
14
|
-
var isLocalFile: Bool {
|
|
15
|
-
playbackUrl?.isFileURL ?? false
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/// The source type (file or stream) based on the URL scheme
|
|
19
|
-
var sourceType: SourceType {
|
|
20
|
-
isLocalFile ? .file : .stream
|
|
21
|
-
}
|
|
22
|
-
|
|
23
7
|
/// Builds an `ImageSource` from the track's artwork fields, skipping SF Symbol strings.
|
|
24
8
|
var artworkImageSource: ImageSource? {
|
|
25
9
|
if let source = artworkSource {
|
|
@@ -10,12 +10,16 @@ public extension HybridAudioBrowser {
|
|
|
10
10
|
/// synchronous: JS hears it before the navigation that may set the error again.
|
|
11
11
|
internal func navigate(_ path: String, clearError: Bool) {
|
|
12
12
|
if clearError { onMainActor { navigationError.clear() } }
|
|
13
|
-
Task { @MainActor in
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
Task { @MainActor in await loadPath(path) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Loads `path` into the browser, reporting a failure as the navigation error.
|
|
17
|
+
@MainActor
|
|
18
|
+
private func loadPath(_ path: String) async {
|
|
19
|
+
do {
|
|
20
|
+
try await browserManager.load(path)
|
|
21
|
+
} catch {
|
|
22
|
+
await reportNavigationError(error, path: path)
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -33,9 +37,7 @@ public extension HybridAudioBrowser {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
func navigateTrack(track: Track) throws {
|
|
36
|
-
// @MainActor: `player` is main-confined
|
|
37
|
-
// MainActor anyway — a bare Task read the reference off the cooperative
|
|
38
|
-
// pool, racing the assignment in `setupPlayer`.
|
|
40
|
+
// @MainActor: `player` is main-confined.
|
|
39
41
|
Task { @MainActor in
|
|
40
42
|
guard let player else { return }
|
|
41
43
|
let result = await trackSelector.select(track: track, player: player)
|
|
@@ -81,15 +83,12 @@ public extension HybridAudioBrowser {
|
|
|
81
83
|
func notifyContentChanged(path: String) throws {
|
|
82
84
|
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
83
85
|
// must pass the container path, or the invalidation silently hits nothing.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
externalContentChangedEmitter.emit(path)
|
|
88
|
-
|
|
89
|
-
// Re-resolve the path if it's the current browser path
|
|
90
|
-
if onMainActor({ browserManager.path }) == path {
|
|
91
|
-
navigate(path, clearError: false)
|
|
86
|
+
let isCurrentPath = try onMainActor {
|
|
87
|
+
try browserManager.invalidateContentCache(path)
|
|
88
|
+
return browserManager.path == path
|
|
92
89
|
}
|
|
90
|
+
externalContentChangedEmitter.emit(path)
|
|
91
|
+
if isCurrentPath { navigate(path, clearError: false) }
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
func invalidateAllContent() throws {
|