react-native-audio-browser 0.2.0-next.33 → 0.2.0-next.34
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/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- 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 +26 -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/EmitterTests.swift +3 -48
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -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/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/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
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
|
}
|
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,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
|
-
}
|