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.
Files changed (35) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  4. package/ios/CarPlay/CarPlaySession.swift +3 -3
  5. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  6. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  7. package/ios/Extension/Track+AVPlayer.swift +0 -16
  8. package/ios/HybridAudioBrowser+Browser.swift +16 -17
  9. package/ios/HybridAudioBrowser+Options.swift +27 -66
  10. package/ios/HybridAudioBrowser+Player.swift +4 -60
  11. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  12. package/ios/HybridAudioBrowser.swift +36 -99
  13. package/ios/Model/NitroTypeStubs.swift +15 -0
  14. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  15. package/ios/Model/RemoteCommand.swift +6 -78
  16. package/ios/Player/NowPlayingUpdater.swift +13 -7
  17. package/ios/Player/Player.swift +26 -10
  18. package/ios/Player/PlayerCallbacks.swift +1 -4
  19. package/ios/Player/PlayerEngine.swift +17 -0
  20. package/ios/Player/RetryLoop.swift +5 -1
  21. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  22. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  23. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  24. package/ios/Tests/EmitterTests.swift +3 -48
  25. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  26. package/ios/Tests/PlayerTests.swift +95 -17
  27. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  28. package/ios/Tests/Support/TestTypes.swift +44 -3
  29. package/ios/TrackSelector.swift +34 -52
  30. package/ios/Util/Emitter.swift +0 -14
  31. package/ios/Util/NetworkMonitor.swift +8 -25
  32. package/ios/Util/OutputPicker.swift +48 -0
  33. package/package.json +1 -1
  34. package/ios/Model/SourceType.swift +0 -6
  35. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -65,34 +65,42 @@ class TrackSelector {
65
65
  return .none
66
66
  }
67
67
 
68
- let path = track.path
69
-
70
- // 1. Contextual path (playable-only track with queue context)
71
- if let path, BrowserPathHelper.isContextual(path) {
72
- return await handleContextualPath(path, track: track, player: player)
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
- // 4. Neither
86
- return .none
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
- private func handleContextualPath(
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 -> SelectionResult {
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
- let queue = player.tracks
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
- if let expanded = try await browserManager.expandQueueFromContextualPath(path) {
125
- let (tracks, startIndex) = expanded
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
- // Fallback to single track
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
  }
@@ -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
- /// Monitors network connectivity state using NWPathMonitor.
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. Never held across `onChanged` — see `apply`.
13
+ /// Guards `_isOnline` only; never held across an emit.
15
14
  private let lock = NSLock()
16
15
  private var _isOnline = false
17
16
 
18
- /// Current network connectivity state. Readable from any thread: the value is
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?.onChanged?(newStatus)
34
+ self?.changed.emit(newStatus)
44
35
  }
45
36
  }
46
37
 
47
- /// Invoked on every connectivity change. Wired centrally in `HybridAudioBrowser.setupPlayer` and
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
- // Seed BEFORE `start`. `start` publishes `self` to the monitor queue, so any
58
- // write after it races the handler's locked write — and would clobber a real
59
- // update with a `currentPath` the monitor hasn't finished evaluating. Nothing
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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.33",
3
+ "version": "0.2.0-next.34",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -1,6 +0,0 @@
1
- import Foundation
2
-
3
- public enum SourceType {
4
- case stream
5
- case file
6
- }
@@ -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
- }