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
@@ -13,6 +13,7 @@ final class AVPlayerEngine: PlayerEngine {
13
13
 
14
14
  var onEvent: (@MainActor (EngineEvent) -> Void)?
15
15
  private weak var callbacks: AVPlayerEngineCallbacks?
16
+ private weak var audioSession: AudioSessionController?
16
17
 
17
18
  let nowPlayingInfoController: NowPlayingInfoController
18
19
  let nowPlayingUpdater: NowPlayingUpdater
@@ -25,25 +26,40 @@ final class AVPlayerEngine: PlayerEngine {
25
26
  private var itemObserver: ItemObserver?
26
27
  private var rate: Float = 1
27
28
  private var skipAvailability = (canNext: false, canPrevious: false)
28
-
29
- var remoteCommands: [RemoteCommand] = [] {
30
- didSet { remoteCommandController.enable(commands: remoteCommands) }
31
- }
32
-
33
- /// - Parameter resolver: Resolves media URLs and now-playing artwork.
29
+ /// The charset of the track last loaded, for its ICY titles.
30
+ private var icyCharset: String?
31
+ private let bufferDuration: Double
32
+
33
+ /// - Parameters:
34
+ /// - resolver: Resolves media URLs and now-playing artwork.
35
+ /// - bufferDuration: 0 lets AVPlayer choose, minimising stalls.
36
+ /// - nowPlayingEnabled: When false the now-playing surface ignores the override and formatter.
34
37
  init(
35
38
  callbacks: AVPlayerEngineCallbacks?,
36
39
  resolver: MediaResolving?,
40
+ audioSession: AudioSessionController,
41
+ networkStatus: (any NetworkStatusProviding)?,
42
+ bufferDuration: Double,
43
+ nowPlayingEnabled: Bool,
44
+ nowPlayingFormatter: ((FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
37
45
  nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
38
46
  ) {
39
47
  self.callbacks = callbacks
48
+ self.audioSession = audioSession
49
+ self.bufferDuration = bufferDuration
40
50
  self.nowPlayingInfoController = nowPlayingInfoController
41
- nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
51
+ nowPlayingUpdater = NowPlayingUpdater(
52
+ nowPlayingInfoController: nowPlayingInfoController,
53
+ networkStatus: networkStatus,
54
+ enabled: nowPlayingEnabled,
55
+ formatter: nowPlayingFormatter,
56
+ )
42
57
  remoteCommandController = RemoteCommandController(callbacks: callbacks)
43
58
  mediaLoader = MediaLoader { [weak resolver] src, track in
44
59
  await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
45
60
  }
46
61
 
62
+ mediaLoader.bufferDuration = bufferDuration
47
63
  mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
48
64
  nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
49
65
  await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
@@ -65,21 +81,6 @@ final class AVPlayerEngine: PlayerEngine {
65
81
  stopObservingItem()
66
82
  }
67
83
 
68
- /// Re-requests the now-playing election, which the system may have declined at link time while
69
- /// the audio session was still inactive.
70
- func reactivateNowPlayingSession() {
71
- nowPlayingInfoController.reactivateSessionIfNeeded()
72
- }
73
-
74
- // MARK: - Buffering
75
-
76
- var bufferDuration: Double = 0 {
77
- didSet {
78
- avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
79
- mediaLoader.bufferDuration = bufferDuration
80
- }
81
- }
82
-
83
84
  // MARK: - The Current Item
84
85
 
85
86
  var hasItem: Bool {
@@ -141,6 +142,7 @@ final class AVPlayerEngine: PlayerEngine {
141
142
  // MARK: - Loading
142
143
 
143
144
  func resolveAndLoad(src: String, track: Track) {
145
+ icyCharset = track.icyCharset
144
146
  mediaLoader.resolveAndLoad(src: src, track: track)
145
147
  }
146
148
 
@@ -221,6 +223,18 @@ final class AVPlayerEngine: PlayerEngine {
221
223
  nowPlayingUpdater.clearNowPlaying()
222
224
  }
223
225
 
226
+ func renderNowPlaying() {
227
+ nowPlayingUpdater.render()
228
+ }
229
+
230
+ func nowPlayingTrackChanged() {
231
+ nowPlayingUpdater.trackChanged()
232
+ }
233
+
234
+ func applyTimedMetadata(_ metadata: TimedMetadata) {
235
+ nowPlayingUpdater.receivedTimedMetadata(metadata)
236
+ }
237
+
224
238
  func updateRemoteRepeatMode(_ mode: RepeatMode) {
225
239
  remoteCommandController.updateRepeatMode(mode)
226
240
  }
@@ -234,6 +248,20 @@ final class AVPlayerEngine: PlayerEngine {
234
248
  remoteCommandController.refreshSkipAvailability()
235
249
  }
236
250
 
251
+ // MARK: - Audio Session
252
+
253
+ func activateAudioSession() {
254
+ audioSession?.activate { [weak self] in self?.nowPlayingInfoController.reactivateSessionIfNeeded() }
255
+ }
256
+
257
+ func cancelAudioSessionRelease() {
258
+ audioSession?.cancelRelease()
259
+ }
260
+
261
+ func scheduleAudioSessionRelease() {
262
+ audioSession?.scheduleRelease()
263
+ }
264
+
237
265
  // MARK: - Observed Events
238
266
 
239
267
  private func playerDidEmit(_ event: PlayerObserver.Event) {
@@ -260,7 +288,7 @@ final class AVPlayerEngine: PlayerEngine {
260
288
  case let .failed(error), let .failedToPlayToEnd(error): reportFailure(error)
261
289
  case .playedToEnd: onEvent?(.playedToEnd)
262
290
  case .stalled: onEvent?(.stalled)
263
- case let .timedMetadata(groups): callbacks?.playerDidReceiveTimedMetadata(groups)
291
+ case let .timedMetadata(groups): reportTimedMetadata(groups)
264
292
  }
265
293
  }
266
294
 
@@ -299,7 +327,15 @@ final class AVPlayerEngine: PlayerEngine {
299
327
  case let .chapterMetadata(groups):
300
328
  callbacks?.playerDidReceiveChapterMetadata(groups)
301
329
  case let .timedMetadata(groups):
302
- callbacks?.playerDidReceiveTimedMetadata(groups)
330
+ reportTimedMetadata(groups)
331
+ }
332
+ }
333
+
334
+ private func reportTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
335
+ for group in groups {
336
+ if let metadata = TimedMetadata.from(items: group.items, icyCharset: icyCharset) {
337
+ onEvent?(.timedMetadata(metadata))
338
+ }
303
339
  }
304
340
  }
305
341
 
@@ -8,7 +8,6 @@ import NitroModules
8
8
  @MainActor
9
9
  protocol AVPlayerEngineCallbacks: RemoteCommandCallbacks {
10
10
  func playerDidReceiveCommonMetadata(_ metadata: [AVMetadataItem])
11
- func playerDidReceiveTimedMetadata(_ metadata: [AVTimedMetadataGroup])
12
11
  func playerDidReceiveChapterMetadata(_ metadata: [AVTimedMetadataGroup])
13
12
  /// The now-playing lines or track changed; carries the metadata JS sees.
14
13
  func playerDidChangeNowPlaying(_ metadata: NowPlayingMetadata)
@@ -11,7 +11,7 @@ final class CarPlayNowPlayingManager {
11
11
  private let interfaceController: CPInterfaceController
12
12
  private let audioBrowser: HybridAudioBrowser
13
13
  /// Read on each use: a re-setup replaces the bridge's player.
14
- private var player: Player? { audioBrowser.getPlayer() }
14
+ private var player: Player? { audioBrowser.player }
15
15
  private let listItem: (Track, ((CPSelectableListItem, @escaping () -> Void) -> Void)?) -> CPListItem
16
16
  /// Pushes a browse destination: the album line's.
17
17
  private let navigate: (_ path: String, _ title: String) -> Void
@@ -131,7 +131,7 @@ final class CarPlayNowPlayingManager {
131
131
  private func refreshButtons() {
132
132
  // A gate hides the custom buttons; the transport controls stay, as a gate never blocks
133
133
  // playback.
134
- let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.carPlayNowPlayingButtons
134
+ let configured = audioBrowser.browseGate.isActive ? [] : audioBrowser.playerOptions.carPlayNowPlayingButtons
135
135
  // The template caps custom buttons at 5 (CPNowPlayingTemplate.h) and says
136
136
  // nothing about overflow — unlike lists (trim) or the tab bar (throw). The
137
137
  // config comes straight from JS, where duplicates and excess are
@@ -319,7 +319,7 @@ final class CarPlayNowPlayingManager {
319
319
  }
320
320
 
321
321
  private func handlePlaybackRateButtonTapped() {
322
- let rates = audioBrowser.playbackRates
322
+ let rates = audioBrowser.playerOptions.playbackRates
323
323
  guard !rates.isEmpty, let player else { return }
324
324
 
325
325
  let currentRate = Double(player.rate)
@@ -349,7 +349,7 @@ final class CarPlayNowPlayingManager {
349
349
  }
350
350
 
351
351
  private func updateUpNextButton() {
352
- CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
352
+ CPNowPlayingTemplate.shared.isUpNextButtonEnabled = audioBrowser.playerOptions.carPlayUpNextButton && (player?.tracks.count ?? 0) > 1
353
353
  }
354
354
 
355
355
  fileprivate func handleUpNextButtonTapped() {
@@ -12,7 +12,7 @@ final class CarPlaySession {
12
12
  let interfaceController: CPInterfaceController
13
13
  let audioBrowser: HybridAudioBrowser
14
14
  /// Read on each use: a re-setup replaces the bridge's player.
15
- var player: Player? { audioBrowser.getPlayer() }
15
+ var player: Player? { audioBrowser.player }
16
16
  let trackSelector: TrackSelector
17
17
  let imageLoader: CarPlayImageLoader
18
18
  let listItemFactory: CarPlayListItemFactory
@@ -58,7 +58,7 @@ final class CarPlaySession {
58
58
  )
59
59
  listItemFactory = CarPlayListItemFactory(
60
60
  isActiveTrack: { [weak audioBrowser] identity in
61
- identity != nil && audioBrowser?.getPlayer()?.currentTrack?.identity == identity
61
+ identity != nil && audioBrowser?.player?.currentTrack?.identity == identity
62
62
  },
63
63
  )
64
64
  listItemFactory.imageLoader = imageLoader
@@ -156,7 +156,7 @@ final class CarPlaySession {
156
156
  // MARK: - Playing Indicators
157
157
 
158
158
  var playingIndicatorLocation: CPListItemPlayingIndicatorLocation {
159
- switch audioBrowser.carPlayPlayingIndicatorLocation {
159
+ switch audioBrowser.playerOptions.carPlayPlayingIndicatorLocation {
160
160
  case .trailing: .trailing
161
161
  case .leading: .leading
162
162
  }
@@ -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.getPlayer() : nil
72
+ HybridAudioBrowser.shared === browser ? browser.player : nil
73
73
  }
74
74
  }
@@ -1,74 +1,27 @@
1
1
  import Foundation
2
- import NitroModules
3
2
 
4
- /// Extension to build RemoteCommands from PlayerCapabilities
5
- extension PlayerCapabilities {
6
- /// Build array of enabled RemoteCommands based on capabilities
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
- // Jump forward/backward
40
- if jumpForward != false {
41
- commands.append(.skipForward(
42
- preferredIntervals: [(forwardJumpInterval ?? backwardJumpInterval) ?? 15],
43
- ))
44
- }
45
- if jumpBackward != false {
46
- commands.append(.skipBackward(
47
- preferredIntervals: [(backwardJumpInterval ?? forwardJumpInterval) ?? 15],
48
- ))
49
- }
50
-
51
- // NOTE: We deliberately do NOT register MPRemoteCommandCenter.likeCommand for the
52
- // `favorite` capability. That MPFeedbackCommand had a single system surface — a
53
- // pre-iOS-15 lock-screen menu shown in place of the previous-track button — which
54
- // Apple silently removed. On the iOS 16+ target it can never be invoked: CarPlay
55
- // uses its own CPNowPlayingButton heart, and Siri "I like this" routes through
56
- // INUpdateMediaAffinityIntent. The `favorite` capability still drives the
57
- // favorite row indicators via setFavoriteEnabled. See issues #67 / #71.
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
- do {
15
- try await browserManager.load(path)
16
- } catch {
17
- await reportNavigationError(error, path: path)
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, and `trackSelector.select` is
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
- try onMainActor { try browserManager.invalidateContentCache(path) }
85
-
86
- // Notify external controllers (CarPlay) that content changed
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 {
@@ -10,7 +10,6 @@ public extension HybridAudioBrowser {
10
10
 
11
11
  func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
12
12
  Promise.async {
13
- // Create player and configure on main actor
14
13
  await MainActor.run { [weak self] in
15
14
  guard let self else { return }
16
15
  // Set the category now, but activate only when playback produces output.
@@ -20,58 +19,34 @@ public extension HybridAudioBrowser {
20
19
  // and un-published first. No-op on first setup.
21
20
  destroyPlayer()
22
21
 
23
- let newEngine = AVPlayerEngine(callbacks: self, resolver: browserManager.requests)
24
- if let buffer = options.ios?.buffer { newEngine.bufferDuration = buffer }
25
- newEngine.nowPlayingUpdater.enabled = options.autoUpdateNowPlayingMetadata ?? true
26
- newEngine.nowPlayingUpdater.formatter = options.nowPlayingMetadataFormatter
27
- newEngine.nowPlayingUpdater.isOnline = networkMonitor.isOnline
28
- let newPlayer = Player(engine: newEngine)
29
- newPlayer.callbacks = self
30
- newPlayer.retry.networkMonitor = networkMonitor
22
+ let newEngine = AVPlayerEngine(
23
+ callbacks: self,
24
+ resolver: browserManager.requests,
25
+ audioSession: audioSession,
26
+ networkStatus: networkMonitor,
27
+ bufferDuration: options.ios?.buffer ?? 0,
28
+ nowPlayingEnabled: options.autoUpdateNowPlayingMetadata ?? true,
29
+ nowPlayingFormatter: options.nowPlayingMetadataFormatter,
30
+ )
31
+ let newPlayer = Player(engine: newEngine, callbacks: self, retry: RetryLoop(networkMonitor: networkMonitor))
31
32
  if let retry = options.retry { newPlayer.retry.updatePolicy(from: retry) }
32
33
  newEngine.nowPlayingUpdater.source = newPlayer
33
34
  engine = newEngine
34
35
  player = newPlayer
35
36
  audioSession.player = newPlayer
36
37
 
37
- // Connectivity fans out here, whether or not JS subscribes: the JS event, the
38
- // now-playing stall line, and reconnecting a stalled stream. The monitor mutates on main.
39
- networkMonitor.onChanged = { [weak self] isOnline in
40
- MainActor.assumeIsolated {
41
- self?.onOnlineChanged(isOnline)
42
- self?.engine?.nowPlayingUpdater.isOnline = isOnline
43
- if isOnline { self?.player?.networkDidReturn() }
44
- }
45
- }
46
-
47
- // `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
48
- // A terminal error keeps the now-playing info (never cleared on error), and next/previous
49
- // stay enabled (gated on queue position, not state). So the
50
- // session stays controllable through errors by default — the behavior Android achieves via
51
- // InterceptingPlayer is already the iOS norm.
38
+ // `options.keepSessionAliveOnError` is ignored: on iOS an error never clears the now-playing
39
+ // info, and next/previous follow the queue, not the state.
52
40
 
53
41
  // The bundled runtime options and initial state are part of the atomic launch
54
42
  // description. Stage the state first so setup options and earlier pre-setup setter
55
43
  // calls resolve by strict last-write-wins, then apply everything to the live player.
56
- if let bundled = options.options {
57
- updateOptions(options: bundled)
58
- } else {
59
- // updateOptions applies remote commands itself; without bundled options, apply the
60
- // defaults (play, pause, next, previous, seekTo) here.
61
- applyRemoteCommands()
62
- }
44
+ if let bundled = options.options { updateOptions(options: bundled) }
63
45
  if let mode = options.repeatMode { pendingRepeatMode = mode }
64
46
  if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
65
47
  applyPendingPlayerState()
66
-
67
- // Push the resolved event cadence unconditionally. `updateOptions` applies
68
- // the interval only when it *differs* from the stored one, but the store
69
- // outlives the player — so on a re-setup with unchanged options the new
70
- // player would come up with no progress timer at all.
71
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
72
- player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
73
-
74
- // Notify listeners that player is ready (e.g., CarPlay)
48
+ // The stored options outlive the player, so a re-setup applies them to the new one.
49
+ applyOptions()
75
50
  playerAndConfiguredBrowser.check()
76
51
  }
77
52
  }
@@ -95,40 +70,26 @@ public extension HybridAudioBrowser {
95
70
 
96
71
  func updateOptions(options: NativeUpdateOptions) {
97
72
  onMainActor {
98
- let previousInterval = playerOptions.progressUpdateEventInterval
99
-
100
- // Update stored options
101
73
  playerOptions.update(from: options)
102
-
103
- // Propagate the favorite flag to the browser so it can hydrate
104
- // row hearts (the `favorite` capability is the single favoriting switch).
105
- browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
106
-
107
- // Apply remote commands to player
108
- applyRemoteCommands()
109
-
110
- // Apply progress update interval if changed
111
- if playerOptions.progressUpdateEventInterval != previousInterval {
112
- player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
113
- }
114
-
74
+ applyOptions()
115
75
  onOptionsChanged(playerOptions.toOptions())
116
76
  playerOptionsChangedEmitter.emit(())
117
77
  }
118
78
  }
119
79
 
120
- /// Converts capabilities to remote commands and applies them to the player
80
+ /// Applies the stored options to the browser, the player and its engine. Idempotent.
121
81
  @MainActor
122
- private func applyRemoteCommands() {
123
- guard let engine else { return }
124
-
125
- let remoteCommands = playerOptions.capabilities.buildRemoteCommands(
126
- forwardJumpInterval: NSNumber(value: playerOptions.forwardJumpInterval),
127
- backwardJumpInterval: NSNumber(value: playerOptions.backwardJumpInterval),
82
+ private func applyOptions() {
83
+ // The `favorite` capability is the one favoriting switch: it also drives the row hearts.
84
+ browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
85
+ engine?.remoteCommandController.enable(
86
+ commands: playerOptions.capabilities.remoteCommands,
87
+ forwardJumpInterval: playerOptions.forwardJumpInterval,
88
+ backwardJumpInterval: playerOptions.backwardJumpInterval,
128
89
  playbackRates: playerOptions.playbackRates,
129
90
  )
130
-
131
- engine.remoteCommands = remoteCommands
91
+ player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
92
+ player?.setPlaybackIntervalEnabled(playerOptions.playbackIntervalEnabled)
132
93
  }
133
94
 
134
95
  func getOptions() throws -> Options {
@@ -137,7 +98,7 @@ public extension HybridAudioBrowser {
137
98
 
138
99
  func setPlaybackIntervalEnabled(enabled: Bool) {
139
100
  onMainActor {
140
- playbackIntervalEnabled = enabled
101
+ playerOptions.playbackIntervalEnabled = enabled
141
102
  player?.setPlaybackIntervalEnabled(enabled)
142
103
  }
143
104
  }
@@ -230,7 +230,9 @@ public extension HybridAudioBrowser {
230
230
  try player.replace(player.queue.currentIndex, updated)
231
231
  // The only emit: a favorite toggle mutates the active track in place, so
232
232
  // onPlaybackActiveTrackChanged stays transition-only.
233
- favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
233
+ let event = FavoriteChangedEvent(track: updated, favorited: favorited)
234
+ onFavoriteChanged(event)
235
+ favoriteChangedEmitter.emit(event)
234
236
  return true
235
237
  }
236
238
  }
@@ -334,64 +336,6 @@ public extension HybridAudioBrowser {
334
336
  }
335
337
 
336
338
  func openOutputPicker() throws {
337
- DispatchQueue.main.async { [weak self] in
338
- guard let self else { return }
339
- guard let windowScene = UIApplication.shared.activeWindowScene,
340
- let window = windowScene.windows.first(where: { $0.isKeyWindow }),
341
- var topController = window.rootViewController
342
- else {
343
- self.logger.error("openOutputPicker: no active window scene / root view controller")
344
- return
345
- }
346
-
347
- // Walk up to the topmost presented view controller
348
- while let presented = topController.presentedViewController {
349
- topController = presented
350
- }
351
-
352
- // AVRoutePickerView has no public API to present its picker
353
- // programmatically, so we add it to the hierarchy and tap its internal
354
- // button. Two requirements that are easy to get wrong:
355
- // 1. The view must be *visible* to the system (a `isHidden` source view
356
- // suppresses the presentation), so we keep it transparent instead.
357
- // 2. The internal button can be nested below the top-level subviews on
358
- // modern iOS, so we search recursively rather than only direct children.
359
- let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
360
- routePicker.alpha = 0.01
361
- topController.view.addSubview(routePicker)
362
-
363
- let button = Self.firstButton(in: routePicker)
364
- self.logger.notice(
365
- "openOutputPicker: hierarchy=\(Self.describeHierarchy(routePicker), privacy: .public), buttonFound=\(button != nil, privacy: .public)",
366
- )
367
-
368
- if let button {
369
- button.sendActions(for: .touchUpInside)
370
- } else {
371
- self.logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
372
- }
373
-
374
- // Keep the source view in the hierarchy long enough for the picker to
375
- // present (on iPad it anchors a popover to this view).
376
- DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
377
- routePicker.removeFromSuperview()
378
- }
379
- }
380
- }
381
-
382
- /// Depth-first search for the first `UIButton` inside a view tree.
383
- private static func firstButton(in view: UIView) -> UIButton? {
384
- if let button = view as? UIButton { return button }
385
- for subview in view.subviews {
386
- if let button = firstButton(in: subview) { return button }
387
- }
388
- return nil
389
- }
390
-
391
- /// Compact description of a view tree, e.g. `AVRoutePickerView[UIButton[UIImageView]]`.
392
- private static func describeHierarchy(_ view: UIView) -> String {
393
- let name = String(describing: type(of: view))
394
- let children = view.subviews.map(describeHierarchy).joined(separator: ", ")
395
- return children.isEmpty ? name : "\(name)[\(children)]"
339
+ DispatchQueue.main.async { OutputPicker.present() }
396
340
  }
397
341
  }