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