react-native-audio-browser 0.2.0-next.28 → 0.2.0-next.29
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/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import AVKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import MediaPlayer
|
|
5
|
+
import NitroModules
|
|
6
|
+
import os.log
|
|
7
|
+
|
|
8
|
+
public extension HybridAudioBrowser {
|
|
9
|
+
/// Navigates the in-app browser, reporting a failure as the navigation error. A clear is
|
|
10
|
+
/// synchronous: JS hears it before the navigation that may set the error again.
|
|
11
|
+
internal func navigate(_ path: String, clearError: Bool) {
|
|
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
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@MainActor
|
|
23
|
+
private func reportNavigationError(_ error: Error, path: String) async {
|
|
24
|
+
await navigationError.report(NavigationError.from(error)) { [browserManager] in
|
|
25
|
+
await browserManager.formatted($0, path: path)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// MARK: - Browser Methods
|
|
30
|
+
|
|
31
|
+
func navigatePath(path: String) throws {
|
|
32
|
+
navigate(path, clearError: true)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
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`.
|
|
39
|
+
Task { @MainActor in
|
|
40
|
+
guard let player else { return }
|
|
41
|
+
let result = await trackSelector.select(track: track, player: player)
|
|
42
|
+
switch result {
|
|
43
|
+
case let .play(intent):
|
|
44
|
+
player.execute(intent)
|
|
45
|
+
case .intercepted:
|
|
46
|
+
break
|
|
47
|
+
case let .browse(path):
|
|
48
|
+
navigate(path, clearError: true)
|
|
49
|
+
case .none:
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func search(query: String) throws -> Promise<[Track]> {
|
|
56
|
+
Promise.async { [weak self] in
|
|
57
|
+
guard let self else { return [] }
|
|
58
|
+
let resolved = try await browserManager.search(query)
|
|
59
|
+
return resolved.flattenedChildren ?? []
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func getContent() throws -> ResolvedTrack? {
|
|
64
|
+
onMainActor { browserManager.content }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func getNavigationError() throws -> NavigationError? {
|
|
68
|
+
onMainActor { navigationError.error }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func getFormattedNavigationError() throws -> FormattedNavigationError? {
|
|
72
|
+
onMainActor { navigationError.formatted }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
|
|
76
|
+
/// refresh every displayed template. Not a real path (paths are always
|
|
77
|
+
/// `/…`), so it can't collide with one; kept internal — the public API is
|
|
78
|
+
/// `invalidateAllContent()`.
|
|
79
|
+
internal static let invalidateAllSentinel = "__rnab_invalidate_all__"
|
|
80
|
+
|
|
81
|
+
func notifyContentChanged(path: String) throws {
|
|
82
|
+
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
83
|
+
// 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)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func invalidateAllContent() throws {
|
|
96
|
+
onMainActor { browserManager.clearContentCache() }
|
|
97
|
+
|
|
98
|
+
// Tell external controllers (CarPlay) to refresh every displayed template.
|
|
99
|
+
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
100
|
+
|
|
101
|
+
// Re-query the tabs source too: a callback source can resolve to new
|
|
102
|
+
// titles after an invalidation (e.g. a locale switch). BrowserManager
|
|
103
|
+
// emits tabsChanged only when the result actually differs, and a failed
|
|
104
|
+
// re-query keeps the current tabs. (Android needs no equivalent — its
|
|
105
|
+
// root re-query already runs queryTabs(); the web stub re-queries tabs on
|
|
106
|
+
// the re-navigation.)
|
|
107
|
+
Task {
|
|
108
|
+
_ = try? await browserManager.queryTabs()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Re-resolve the JS-facing current path with the cache cleared.
|
|
112
|
+
Task { @MainActor in
|
|
113
|
+
do {
|
|
114
|
+
try await browserManager.load(browserManager.path, bypassCache: true)
|
|
115
|
+
} catch {
|
|
116
|
+
await reportNavigationError(error, path: browserManager.path)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func setFavorites(favorites: [String]) throws {
|
|
122
|
+
onMainActor { browserManager.setFavorites(favorites) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - Gate
|
|
126
|
+
|
|
127
|
+
func setGate(gate: Gate?, hasResolver: Bool) throws {
|
|
128
|
+
onMainActor { browseGate.set(chrome: gate, hasResolver: hasResolver) }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func clearGate() throws {
|
|
132
|
+
onMainActor { browseGate.clear() }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// MARK: - Car Connection (CarPlay)
|
|
136
|
+
|
|
137
|
+
/// Static because the CarPlay scene can connect before the JS runtime
|
|
138
|
+
/// creates the shared instance (cold start in the car).
|
|
139
|
+
internal nonisolated(unsafe) static var carPlayConnected = false
|
|
140
|
+
|
|
141
|
+
/// Called by the CarPlay scene delegate (via `RNABCarPlayController`) on
|
|
142
|
+
/// scene connect/disconnect — not from controller start/stop, which also
|
|
143
|
+
/// cycle on a JS reload and would emit spurious connection changes.
|
|
144
|
+
internal static func setCarPlayConnected(_ connected: Bool) {
|
|
145
|
+
guard carPlayConnected != connected else { return }
|
|
146
|
+
carPlayConnected = connected
|
|
147
|
+
shared?.onCarConnectedChanged(connected)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func isCarConnected() throws -> Bool {
|
|
151
|
+
HybridAudioBrowser.carPlayConnected
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import AVKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import MediaPlayer
|
|
5
|
+
import NitroModules
|
|
6
|
+
import os.log
|
|
7
|
+
|
|
8
|
+
public extension HybridAudioBrowser {
|
|
9
|
+
// MARK: - Player Setup
|
|
10
|
+
|
|
11
|
+
func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
|
|
12
|
+
Promise.async {
|
|
13
|
+
// Create player and configure on main actor
|
|
14
|
+
await MainActor.run { [weak self] in
|
|
15
|
+
guard let self else { return }
|
|
16
|
+
// Set the category now, but activate only when playback produces output.
|
|
17
|
+
self.audioSession.configure(options.ios)
|
|
18
|
+
|
|
19
|
+
// A re-setup REPLACES the player, so the outgoing one has to be torn down
|
|
20
|
+
// and un-published first. No-op on first setup.
|
|
21
|
+
destroyPlayer()
|
|
22
|
+
|
|
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
|
|
31
|
+
if let retry = options.retry { newPlayer.retry.updatePolicy(from: retry) }
|
|
32
|
+
newEngine.nowPlayingUpdater.source = newPlayer
|
|
33
|
+
engine = newEngine
|
|
34
|
+
player = newPlayer
|
|
35
|
+
audioSession.player = newPlayer
|
|
36
|
+
|
|
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.
|
|
52
|
+
|
|
53
|
+
// The bundled runtime options and initial state are part of the atomic launch
|
|
54
|
+
// description. Stage the state first so setup options and earlier pre-setup setter
|
|
55
|
+
// 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
|
+
}
|
|
63
|
+
if let mode = options.repeatMode { pendingRepeatMode = mode }
|
|
64
|
+
if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
|
|
65
|
+
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)
|
|
75
|
+
playerAndConfiguredBrowser.check()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Applies initial player state staged before the player existed — from setup options or the
|
|
81
|
+
/// imperative setters called pre-setup. Consumed on apply so a later re-setup doesn't replay
|
|
82
|
+
/// stale state.
|
|
83
|
+
@MainActor
|
|
84
|
+
private func applyPendingPlayerState() {
|
|
85
|
+
guard let player else { return }
|
|
86
|
+
if let mode = pendingRepeatMode {
|
|
87
|
+
player.repeatMode = mode
|
|
88
|
+
pendingRepeatMode = nil
|
|
89
|
+
}
|
|
90
|
+
if let intent = pendingPlayWhenReady {
|
|
91
|
+
player.setPlayWhenReady(intent)
|
|
92
|
+
pendingPlayWhenReady = nil
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func updateOptions(options: NativeUpdateOptions) {
|
|
97
|
+
onMainActor {
|
|
98
|
+
let previousInterval = playerOptions.progressUpdateEventInterval
|
|
99
|
+
|
|
100
|
+
// Update stored options
|
|
101
|
+
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
|
+
|
|
115
|
+
onOptionsChanged(playerOptions.toOptions())
|
|
116
|
+
playerOptionsChangedEmitter.emit(())
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/// Converts capabilities to remote commands and applies them to the player
|
|
121
|
+
@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),
|
|
128
|
+
playbackRates: playerOptions.playbackRates,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
engine.remoteCommands = remoteCommands
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func getOptions() throws -> Options {
|
|
135
|
+
onMainActor { playerOptions.toOptions() }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
139
|
+
onMainActor {
|
|
140
|
+
playbackIntervalEnabled = enabled
|
|
141
|
+
player?.setPlaybackIntervalEnabled(enabled)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import AVKit
|
|
3
|
+
import Foundation
|
|
4
|
+
import MediaPlayer
|
|
5
|
+
import NitroModules
|
|
6
|
+
import os.log
|
|
7
|
+
|
|
8
|
+
public extension HybridAudioBrowser {
|
|
9
|
+
// MARK: - Playback Control
|
|
10
|
+
|
|
11
|
+
func load(track: Track) throws {
|
|
12
|
+
try onMainActor {
|
|
13
|
+
guard let player else {
|
|
14
|
+
throw NSError(domain: "AudioBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Player not initialized"])
|
|
15
|
+
}
|
|
16
|
+
player.load(track)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func reset() throws {
|
|
21
|
+
onMainActor { player?.clear() }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func play() throws {
|
|
25
|
+
onMainActor { player?.play() }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func pause() throws {
|
|
29
|
+
onMainActor { player?.pause() }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func togglePlayback() throws {
|
|
33
|
+
onMainActor { player?.togglePlayback() }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func stop() throws {
|
|
37
|
+
onMainActor { player?.stop() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func setPlayWhenReady(playWhenReady: Bool) throws {
|
|
41
|
+
onMainActor {
|
|
42
|
+
// Pre-setup this stages the intent for the player to come up with — never a no-op.
|
|
43
|
+
if let player { player.setPlayWhenReady(playWhenReady) }
|
|
44
|
+
else { pendingPlayWhenReady = playWhenReady }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func getPlayWhenReady() throws -> Bool {
|
|
49
|
+
onMainActor { player?.playWhenReady ?? pendingPlayWhenReady ?? false }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func seekTo(position: Double) throws {
|
|
53
|
+
onMainActor { player?.seek(to: position) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func seekBy(offset: Double) throws {
|
|
57
|
+
onMainActor { player?.seek(by: offset) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func seekToLiveEdge() throws {
|
|
61
|
+
onMainActor { player?.seekToLiveEdge() }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func setVolume(level: Double) throws {
|
|
65
|
+
onMainActor { engine?.volume = Float(level) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func getVolume() throws -> Double {
|
|
69
|
+
onMainActor { Double(engine?.volume ?? 1.0) }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func setRate(rate: Double) throws {
|
|
73
|
+
onMainActor { player?.rate = Float(rate) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func getRate() throws -> Double {
|
|
77
|
+
onMainActor { Double(player?.rate ?? 1.0) }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func getProgress() throws -> Progress {
|
|
81
|
+
onMainActor {
|
|
82
|
+
Progress(
|
|
83
|
+
position: player?.currentTime ?? 0,
|
|
84
|
+
duration: engine?.duration ?? 0,
|
|
85
|
+
buffered: engine?.bufferedPosition ?? 0,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func getPlayback() throws -> Playback {
|
|
91
|
+
onMainActor { player?.getPlayback() ?? Playback(state: .none, error: nil) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func getPlayingState() throws -> PlayingState {
|
|
95
|
+
onMainActor {
|
|
96
|
+
player?.playingState ?? PlayingState(playing: false, buffering: false)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func getRepeatMode() throws -> RepeatMode {
|
|
101
|
+
onMainActor { player?.repeatMode ?? pendingRepeatMode ?? .off }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func setRepeatMode(mode: RepeatMode) throws {
|
|
105
|
+
onMainActor {
|
|
106
|
+
// Pre-setup this stages the mode for the player to come up with — never a no-op.
|
|
107
|
+
if let player { player.repeatMode = mode }
|
|
108
|
+
else { pendingRepeatMode = mode }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func getShuffleEnabled() throws -> Bool {
|
|
113
|
+
onMainActor { player?.shuffleEnabled ?? false }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
func setShuffleEnabled(enabled: Bool) throws {
|
|
117
|
+
onMainActor { player?.shuffleEnabled = enabled }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
func getPlaybackError() throws -> PlaybackError? {
|
|
121
|
+
onMainActor { player?.nitroPlaybackError }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func retry() throws {
|
|
125
|
+
// Re-resolve rather than replay the cached URL: short-lived URLs/tokens
|
|
126
|
+
// may have expired since the failure (matches the internal retry path).
|
|
127
|
+
onMainActor { player?.reloadResolving(startFromCurrentTime: true) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// MARK: - Sleep Timer
|
|
131
|
+
|
|
132
|
+
func getSleepTimer() throws -> SleepTimer {
|
|
133
|
+
onMainActor {
|
|
134
|
+
player?.sleepTimer ?? .first(NullType.null)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func setSleepTimer(seconds: Double, fadeDuration: Double?) throws {
|
|
139
|
+
onMainActor {
|
|
140
|
+
player?.sleepTimerManager.set(seconds: seconds, fadeDuration: fadeDuration)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func setSleepTimerToEndOfTrack() throws {
|
|
145
|
+
onMainActor {
|
|
146
|
+
player?.sleepTimerManager.setToEndOfTrack()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func clearSleepTimer() throws -> Bool {
|
|
151
|
+
onMainActor {
|
|
152
|
+
player?.sleepTimerManager.clear() ?? false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// MARK: - Queue Management
|
|
157
|
+
|
|
158
|
+
func add(tracks: [Track], insertBeforeIndex: Double?) throws {
|
|
159
|
+
try onMainActor {
|
|
160
|
+
guard let player else { return }
|
|
161
|
+
if let index = insertBeforeIndex {
|
|
162
|
+
try player.add(tracks, at: Int(index))
|
|
163
|
+
} else {
|
|
164
|
+
player.add(tracks)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func move(fromIndex: Double, toIndex: Double) throws {
|
|
170
|
+
try onMainActor {
|
|
171
|
+
try player?.move(fromIndex: Int(fromIndex), toIndex: Int(toIndex))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func remove(indexes: [Double]) throws {
|
|
176
|
+
try onMainActor {
|
|
177
|
+
guard let player else { return }
|
|
178
|
+
// Remove in reverse order to maintain index validity
|
|
179
|
+
for index in indexes.sorted().reversed() {
|
|
180
|
+
try player.remove(Int(index))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
func removeUpcomingTracks() throws {
|
|
186
|
+
onMainActor { player?.removeUpcomingTracks() }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
func skip(index: Double, initialPosition: Double?) throws {
|
|
190
|
+
try onMainActor {
|
|
191
|
+
try player?.skipTo(Int(index))
|
|
192
|
+
if let position = initialPosition {
|
|
193
|
+
player?.seek(to: position)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
func skipToNext(initialPosition: Double?) throws {
|
|
199
|
+
onMainActor {
|
|
200
|
+
player?.next()
|
|
201
|
+
if let position = initialPosition {
|
|
202
|
+
player?.seek(to: position)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
func skipToPrevious(initialPosition: Double?) throws {
|
|
208
|
+
onMainActor {
|
|
209
|
+
player?.previous()
|
|
210
|
+
if let position = initialPosition {
|
|
211
|
+
player?.seek(to: position)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func setActiveTrackFavorited(favorited: Bool) throws {
|
|
217
|
+
try favoriteActiveTrack(favorited)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Sets the active track's favorite state: in the favorite set first, so the now-playing
|
|
221
|
+
/// heart (hydrated from it) flips at once, then on the queued track. False when nothing is
|
|
222
|
+
/// playing or the track has no identity.
|
|
223
|
+
@discardableResult
|
|
224
|
+
internal func favoriteActiveTrack(_ favorited: Bool) throws -> Bool {
|
|
225
|
+
try onMainActor {
|
|
226
|
+
guard let player, let track = player.currentTrack, let identity = track.identity else { return false }
|
|
227
|
+
browserManager.setFavorited(identity: identity, favorited: favorited)
|
|
228
|
+
// Back in the queue carrying the flag, since hydration never overrides a track's own value.
|
|
229
|
+
let updated = track.copying(favorited: favorited)
|
|
230
|
+
try player.replace(player.currentIndex, updated)
|
|
231
|
+
// The only emit: a favorite toggle mutates the active track in place, so
|
|
232
|
+
// onPlaybackActiveTrackChanged stays transition-only.
|
|
233
|
+
favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Whether the active (now-playing) track is favorited, per the authoritative
|
|
239
|
+
/// favorite set (via `getActiveTrack`'s hydration).
|
|
240
|
+
internal func isActiveTrackFavorited() -> Bool {
|
|
241
|
+
(try? getActiveTrack())?.favorited ?? false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func toggleActiveTrackFavorited() throws {
|
|
245
|
+
try favoriteActiveTrack(!isActiveTrackFavorited())
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func setQueue(tracks: [Track], startIndex: Double?, startPosition: Double?) throws {
|
|
249
|
+
onMainActor {
|
|
250
|
+
guard let player else { return }
|
|
251
|
+
player.setQueue(
|
|
252
|
+
tracks,
|
|
253
|
+
initialIndex: startIndex.map { Int($0) } ?? 0,
|
|
254
|
+
startPositionMs: startPosition.map { $0 * 1000 },
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
func getQueue() throws -> [Track] {
|
|
260
|
+
onMainActor { player?.tracks ?? [] }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
func getTrack(index: Double) throws -> Track? {
|
|
264
|
+
onMainActor {
|
|
265
|
+
guard let tracks = player?.tracks else { return nil }
|
|
266
|
+
let i = Int(index)
|
|
267
|
+
guard i >= 0, i < tracks.count else { return nil }
|
|
268
|
+
return tracks[i]
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
func getActiveTrackIndex() throws -> Double? {
|
|
273
|
+
onMainActor {
|
|
274
|
+
guard let index = player?.currentIndex, index >= 0 else { return nil }
|
|
275
|
+
return Double(index)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func getActiveTrack() throws -> Track? {
|
|
280
|
+
// Hydrate `favorited` from the authoritative favorite set — the now-playing
|
|
281
|
+
// track is loaded outside the browse cache, so its own flag isn't otherwise
|
|
282
|
+
// kept in sync as favorites change. This is the single source of truth for
|
|
283
|
+
// "is the active track favorited" (now-playing heart, toggle direction, JS).
|
|
284
|
+
onMainActor { player?.currentTrack.map(browserManager.hydrateFavorite) }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// MARK: - Now Playing
|
|
288
|
+
|
|
289
|
+
func updateNowPlaying(update: NowPlayingUpdate?) throws {
|
|
290
|
+
onMainActor { engine?.nowPlayingUpdater.setOverride(update) }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
|
|
294
|
+
onMainActor { engine?.nowPlayingUpdater.flash(update, durationMs: durationMs) }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func clearNowPlayingFlash() throws {
|
|
298
|
+
onMainActor { engine?.nowPlayingUpdater.clearFlash() }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
func getNowPlaying() throws -> NowPlayingMetadata? {
|
|
302
|
+
onMainActor { engine?.nowPlayingUpdater.nowPlaying }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// MARK: - Network
|
|
306
|
+
|
|
307
|
+
func getOnline() throws -> Bool {
|
|
308
|
+
// No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
|
|
309
|
+
// cheap read on the JS thread instead of blocking on the main queue.
|
|
310
|
+
networkMonitor.isOnline
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// MARK: - System Volume
|
|
314
|
+
|
|
315
|
+
func getSystemVolume() throws -> Double {
|
|
316
|
+
AudioSessionController.volume
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
func setSystemVolume(volume _: Double) throws {
|
|
320
|
+
// iOS doesn't provide a public API to set system volume programmatically.
|
|
321
|
+
// Users must adjust volume via hardware buttons or Control Center.
|
|
322
|
+
logger.debug("setSystemVolume is not supported on iOS - volume must be adjusted via hardware buttons or Control Center")
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// MARK: - External Audio Output
|
|
326
|
+
|
|
327
|
+
func getOutput() throws -> Output? {
|
|
328
|
+
AudioSessionController.currentOutput
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func supportsOutputSwitcher() throws -> Bool {
|
|
332
|
+
// iOS can always present the system route picker (AVRoutePickerView).
|
|
333
|
+
true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
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)]"
|
|
396
|
+
}
|
|
397
|
+
}
|