react-native-audio-browser 0.2.0-next.8 → 0.2.0-next.9
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/AudioBrowser.kt +33 -8
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +46 -0
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +68 -0
- package/ios/HybridAudioBrowser.swift +78 -48
- package/lib/commonjs/features/player/options.js +1 -1
- package/lib/commonjs/features/player/options.js.map +1 -1
- package/lib/module/features/player/options.js +1 -1
- package/lib/module/features/player/options.js.map +1 -1
- package/lib/typescript/src/features/player/options.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/player/options.ts +1 -1
- package/src/public-surface.test.ts +8 -1
|
@@ -90,6 +90,7 @@ import kotlin.coroutines.suspendCoroutine
|
|
|
90
90
|
import kotlinx.coroutines.CancellationException
|
|
91
91
|
import kotlinx.coroutines.Job
|
|
92
92
|
import kotlinx.coroutines.MainScope
|
|
93
|
+
import kotlinx.coroutines.cancel
|
|
93
94
|
import kotlinx.coroutines.cancelChildren
|
|
94
95
|
import kotlinx.coroutines.delay
|
|
95
96
|
import kotlinx.coroutines.launch
|
|
@@ -100,10 +101,37 @@ import timber.log.Timber
|
|
|
100
101
|
@DoNotStrip
|
|
101
102
|
class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
102
103
|
|
|
103
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Called by Nitro when the JS object is destroyed (incl. JS runtime reloads).
|
|
106
|
+
*
|
|
107
|
+
* The Service outlives this object, so everything registered in `init` or on connect has to be
|
|
108
|
+
* undone here. Left alone, the running [Player] goes on invoking callbacks into a dead JSI
|
|
109
|
+
* runtime, and every reload strands another ServiceConnection and lifecycle observer, each
|
|
110
|
+
* retaining a BrowserManager.
|
|
111
|
+
*/
|
|
104
112
|
override fun dispose() {
|
|
105
113
|
systemVolumeMonitor.destroy()
|
|
106
114
|
outputMonitor.destroy()
|
|
115
|
+
|
|
116
|
+
connectedService?.let { service ->
|
|
117
|
+
service.player.setCallbacks(null)
|
|
118
|
+
service.player.browser = null
|
|
119
|
+
service.onBatteryWarningPendingChanged = null
|
|
120
|
+
}
|
|
121
|
+
connectedService = null
|
|
122
|
+
mediaBrowserFuture = null
|
|
123
|
+
|
|
124
|
+
serviceBinding.unbind(this)
|
|
125
|
+
|
|
126
|
+
// Only when still ours: on a reload the replacement instance may already own the slot.
|
|
127
|
+
if (carConnectionTarget === onCarConnectedChanged) carConnectionTarget = null
|
|
128
|
+
|
|
129
|
+
// Drop queued work first, then post the removal — LifecycleRegistry is main-thread only,
|
|
130
|
+
// which is why the observer is added the same way.
|
|
131
|
+
handler.removeCallbacksAndMessages(null)
|
|
132
|
+
handler.post { ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver) }
|
|
133
|
+
|
|
134
|
+
mainScope.cancel()
|
|
107
135
|
super.dispose()
|
|
108
136
|
}
|
|
109
137
|
|
|
@@ -150,6 +178,8 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
150
178
|
private var mediaBrowserFuture: ListenableFuture<MediaBrowser>? = null
|
|
151
179
|
private var setupOptions = PlayerSetupOptions()
|
|
152
180
|
private var connectedService: Service? = null
|
|
181
|
+
|
|
182
|
+
private val serviceBinding = ServiceBinding(context)
|
|
153
183
|
private var setupPromise: ((Unit) -> Unit)? = null
|
|
154
184
|
|
|
155
185
|
// Initial player state staged before the player exists — from setup options or the imperative
|
|
@@ -244,7 +274,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
244
274
|
try {
|
|
245
275
|
Timber.d("Attempting to auto-bind to existing AudioBrowserService from AudioBrowser")
|
|
246
276
|
val intent = Intent(context, Service::class.java)
|
|
247
|
-
val bound =
|
|
277
|
+
val bound = serviceBinding.bind(intent, this@AudioBrowser)
|
|
248
278
|
Timber.d("Auto-bind result: $bound")
|
|
249
279
|
|
|
250
280
|
if (!bound) {
|
|
@@ -811,12 +841,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
|
|
|
811
841
|
// Service not connected yet, bind to service
|
|
812
842
|
suspendCoroutine<Unit> { continuation ->
|
|
813
843
|
Timber.d("Binding to AudioBrowserService")
|
|
814
|
-
val bound =
|
|
815
|
-
context.bindService(
|
|
816
|
-
Intent(context, Service::class.java),
|
|
817
|
-
this@AudioBrowser,
|
|
818
|
-
Context.BIND_AUTO_CREATE,
|
|
819
|
-
)
|
|
844
|
+
val bound = serviceBinding.bind(Intent(context, Service::class.java), this@AudioBrowser)
|
|
820
845
|
|
|
821
846
|
if (!bound) {
|
|
822
847
|
continuation.resumeWithException(
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
package com.audiobrowser
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.ServiceConnection
|
|
6
|
+
import timber.log.Timber
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Exactly-once bookkeeping for a [ServiceConnection].
|
|
10
|
+
*
|
|
11
|
+
* `bindService` may be called from more than one place — an auto-bind at construction and again
|
|
12
|
+
* from setup — but the binding must be released exactly once, and only if one was ever taken:
|
|
13
|
+
* [Context.unbindService] throws when the connection was never registered. Kept apart from the
|
|
14
|
+
* caller so the rule is one object's job, and so it can be tested without a JSI runtime.
|
|
15
|
+
*/
|
|
16
|
+
internal class ServiceBinding(private val context: Context) {
|
|
17
|
+
|
|
18
|
+
@Volatile
|
|
19
|
+
var isBound: Boolean = false
|
|
20
|
+
private set
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Binds if not already bound, and returns whether a binding is held afterwards. A second call
|
|
24
|
+
* while bound is a no-op: Android would otherwise count the extra bind and expect a matching
|
|
25
|
+
* unbind.
|
|
26
|
+
*/
|
|
27
|
+
fun bind(intent: Intent, connection: ServiceConnection): Boolean {
|
|
28
|
+
if (isBound) return true
|
|
29
|
+
val bound = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
30
|
+
if (bound) isBound = true
|
|
31
|
+
return bound
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Releases the binding if one is held. Safe to call repeatedly, and safe when the service has
|
|
36
|
+
* already died — [onServiceDisconnected][ServiceConnection.onServiceDisconnected] means the
|
|
37
|
+
* service went away, not that the binding did, so the unbind is still required.
|
|
38
|
+
*/
|
|
39
|
+
fun unbind(connection: ServiceConnection) {
|
|
40
|
+
if (!isBound) return
|
|
41
|
+
isBound = false
|
|
42
|
+
// Throws if the binding is somehow already gone, which is the outcome we wanted anyway.
|
|
43
|
+
runCatching { context.unbindService(connection) }
|
|
44
|
+
.onFailure { Timber.w(it, "unbindService failed") }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
package com.audiobrowser
|
|
2
|
+
|
|
3
|
+
import android.content.ComponentName
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.content.ServiceConnection
|
|
6
|
+
import android.os.IBinder
|
|
7
|
+
import org.junit.Assert.assertEquals
|
|
8
|
+
import org.junit.Assert.assertFalse
|
|
9
|
+
import org.junit.Assert.assertTrue
|
|
10
|
+
import org.junit.Test
|
|
11
|
+
import org.junit.runner.RunWith
|
|
12
|
+
import org.robolectric.RobolectricTestRunner
|
|
13
|
+
import org.robolectric.RuntimeEnvironment
|
|
14
|
+
import org.robolectric.Shadows.shadowOf
|
|
15
|
+
|
|
16
|
+
// Robolectric: bindService/unbindService only exist under the framework, and ShadowApplication
|
|
17
|
+
// records both sides — which is precisely the leak being guarded against.
|
|
18
|
+
@RunWith(RobolectricTestRunner::class)
|
|
19
|
+
class ServiceBindingTest {
|
|
20
|
+
|
|
21
|
+
private val app = RuntimeEnvironment.getApplication()
|
|
22
|
+
private val intent = Intent(app, Service::class.java)
|
|
23
|
+
|
|
24
|
+
private val connection =
|
|
25
|
+
object : ServiceConnection {
|
|
26
|
+
override fun onServiceConnected(name: ComponentName?, service: IBinder?) = Unit
|
|
27
|
+
|
|
28
|
+
override fun onServiceDisconnected(name: ComponentName?) = Unit
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Both the auto-bind at construction and setup's bind reach the same object. */
|
|
32
|
+
@Test
|
|
33
|
+
fun bindingTwiceTakesOnlyOneBinding() {
|
|
34
|
+
val binding = ServiceBinding(app)
|
|
35
|
+
|
|
36
|
+
assertTrue(binding.bind(intent, connection))
|
|
37
|
+
assertTrue(binding.bind(intent, connection))
|
|
38
|
+
|
|
39
|
+
assertEquals(1, shadowOf(app).boundServiceConnections.size)
|
|
40
|
+
|
|
41
|
+
binding.unbind(connection)
|
|
42
|
+
|
|
43
|
+
assertTrue(shadowOf(app).boundServiceConnections.isEmpty())
|
|
44
|
+
assertEquals(1, shadowOf(app).unboundServiceConnections.size)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** dispose() must not throw, and Android throws when unbinding a connection it never had. */
|
|
48
|
+
@Test
|
|
49
|
+
fun unbindWithoutBindIsANoOp() {
|
|
50
|
+
val binding = ServiceBinding(app)
|
|
51
|
+
|
|
52
|
+
binding.unbind(connection)
|
|
53
|
+
|
|
54
|
+
assertFalse(binding.isBound)
|
|
55
|
+
assertTrue(shadowOf(app).unboundServiceConnections.isEmpty())
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun unbindIsIdempotent() {
|
|
60
|
+
val binding = ServiceBinding(app)
|
|
61
|
+
binding.bind(intent, connection)
|
|
62
|
+
|
|
63
|
+
binding.unbind(connection)
|
|
64
|
+
binding.unbind(connection)
|
|
65
|
+
|
|
66
|
+
assertEquals(1, shadowOf(app).unboundServiceConnections.size)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -16,6 +16,10 @@ import os.log
|
|
|
16
16
|
/// Only safe when the caller blocks until the MainActor work completes (DispatchQueue.main.sync).
|
|
17
17
|
private struct UncheckedSendableBox<T>: @unchecked Sendable { let value: T }
|
|
18
18
|
|
|
19
|
+
/// `@unchecked Sendable` because it must be: the spec's requirements are all
|
|
20
|
+
/// nonisolated and Nitro calls them synchronously on the JS thread, so the class
|
|
21
|
+
/// can't be `@MainActor`. Isolation is therefore per-property, and `@unchecked`
|
|
22
|
+
/// means the compiler won't tell you when a new one needs it.
|
|
19
23
|
public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
20
24
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
|
|
21
25
|
|
|
@@ -32,6 +36,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
32
36
|
|
|
33
37
|
// MARK: - Private Properties
|
|
34
38
|
|
|
39
|
+
/// Main-confined by convention, not annotation: `deinit` reads it as a
|
|
40
|
+
/// safety net and can't hop. Reach it from `onMainActor` / `@MainActor`.
|
|
35
41
|
private var player: TrackPlayer?
|
|
36
42
|
private let networkMonitor = NetworkMonitor()
|
|
37
43
|
private let playbackStateStore = PlaybackStateStore()
|
|
@@ -43,27 +49,31 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
43
49
|
private var mediaServicesResetObserver: NSObjectProtocol?
|
|
44
50
|
/// Re-applies our resolved audio-session category. Captured at setup so a
|
|
45
51
|
/// media-services reset (which clears the category) can restore it.
|
|
46
|
-
private var applyAudioSessionCategory: () -> Void = {}
|
|
47
|
-
private var nowPlayingOverride: NowPlayingUpdate?
|
|
52
|
+
@MainActor private var applyAudioSessionCategory: () -> Void = {}
|
|
53
|
+
@MainActor private var nowPlayingOverride: NowPlayingUpdate?
|
|
48
54
|
/// When false, the now-playing surface uses the raw track fields (override + formatter ignored).
|
|
49
|
-
private var nowPlayingMetadataEnabled = true
|
|
55
|
+
@MainActor private var nowPlayingMetadataEnabled = true
|
|
50
56
|
/// Customizes the now-playing title/subtitle from the track + live timed metadata + playback state.
|
|
51
|
-
private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
52
|
-
/// Keep the media session controllable through a terminal error (see `keepSessionAliveOnError`).
|
|
53
|
-
private var keepSessionAliveOnError = true
|
|
57
|
+
@MainActor private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
54
58
|
/// Initial player state staged before the player exists — from setup options or the imperative
|
|
55
59
|
/// setters called pre-setup. Strict last-write-wins; consumed when the player comes up.
|
|
56
|
-
private var pendingPlayWhenReady: Bool?
|
|
57
|
-
private var pendingRepeatMode: RepeatMode?
|
|
60
|
+
@MainActor private var pendingPlayWhenReady: Bool?
|
|
61
|
+
@MainActor private var pendingRepeatMode: RepeatMode?
|
|
58
62
|
/// Latest live timed (ICY/ID3) metadata, passed to the formatter. Cleared on track change.
|
|
59
|
-
private var latestTimedMetadata: TimedMetadata?
|
|
60
|
-
private let playerOptions = PlayerUpdateOptions()
|
|
63
|
+
@MainActor private var latestTimedMetadata: TimedMetadata?
|
|
64
|
+
@MainActor private let playerOptions = PlayerUpdateOptions()
|
|
61
65
|
|
|
62
66
|
/// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
|
|
63
|
-
var playbackRates: [Double] { playerOptions.playbackRates }
|
|
64
|
-
var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
|
|
65
|
-
var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
|
|
66
|
-
|
|
67
|
+
@MainActor var playbackRates: [Double] { playerOptions.playbackRates }
|
|
68
|
+
@MainActor var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
|
|
69
|
+
@MainActor var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
|
|
70
|
+
playerOptions.carPlayNowPlayingButtons
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Written from the JS thread, the cooperative pool and MainActor, with a
|
|
74
|
+
/// `didSet` that calls back into Nitro. MainActor-isolated so the compiler
|
|
75
|
+
/// requires an `onMainActor` hop at every access.
|
|
76
|
+
@MainActor private var lastNavigationError: NavigationError? {
|
|
67
77
|
didSet {
|
|
68
78
|
// Skip if both nil (no real change)
|
|
69
79
|
guard oldValue != nil || lastNavigationError != nil else { return }
|
|
@@ -71,7 +81,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
71
81
|
}
|
|
72
82
|
}
|
|
73
83
|
|
|
74
|
-
|
|
84
|
+
/// Isolated for the same reason as ``lastNavigationError``.
|
|
85
|
+
@MainActor private var lastFormattedNavigationError: FormattedNavigationError? {
|
|
75
86
|
didSet {
|
|
76
87
|
// Skip if both nil (no real change)
|
|
77
88
|
guard oldValue != nil || lastFormattedNavigationError != nil else { return }
|
|
@@ -156,6 +167,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
156
167
|
androidControllerOfflineError: nil, carPlayLoadingTitle: nil, resolveAlbumUrl: nil, formatNavigationError: nil,
|
|
157
168
|
) {
|
|
158
169
|
didSet {
|
|
170
|
+
// Copied here, on the setter's thread: the Task below outlives this call,
|
|
171
|
+
// and Nitro can reassign `configuration` — a C++ struct holding
|
|
172
|
+
// std::string/std::function — from the JS thread while it runs.
|
|
173
|
+
let configuredPath = configuration.path
|
|
159
174
|
onMainActor { browserManager.config = BrowserConfig(from: configuration) }
|
|
160
175
|
|
|
161
176
|
// Query tabs and navigate to initial path after config is set (matches Kotlin behavior)
|
|
@@ -166,12 +181,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
166
181
|
Task {
|
|
167
182
|
let tabs = try? await browserManager.queryTabs()
|
|
168
183
|
// Navigate to configured path, first tab, or "/"
|
|
169
|
-
let initialPath =
|
|
184
|
+
let initialPath = configuredPath ?? tabs?.first?.url ?? "/"
|
|
170
185
|
// Clear error before navigation (matches Kotlin clearNavigationError())
|
|
171
|
-
|
|
172
|
-
lastNavigationError = nil
|
|
173
|
-
lastFormattedNavigationError = nil
|
|
174
|
-
}
|
|
186
|
+
clearNavigationError()
|
|
175
187
|
do {
|
|
176
188
|
try await browserManager.navigate(initialPath)
|
|
177
189
|
} catch {
|
|
@@ -370,7 +382,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
370
382
|
private func handleNavigationError(_ error: Error, path: String) {
|
|
371
383
|
let navError = NavigationError.from(error)
|
|
372
384
|
|
|
373
|
-
lastNavigationError = navError
|
|
385
|
+
onMainActor { lastNavigationError = navError }
|
|
374
386
|
|
|
375
387
|
// Format the error (async if using JS callback, sync for defaults)
|
|
376
388
|
let defaultFormatted = navError.defaultFormatted()
|
|
@@ -380,27 +392,40 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
380
392
|
// The result callbacks are @Sendable: a plain closure would inherit MainActor
|
|
381
393
|
// isolation from this block, and Nitro resolves its promises synchronously on the
|
|
382
394
|
// JS thread — Swift 6.2's dynamic isolation check then traps on closure entry.
|
|
383
|
-
//
|
|
395
|
+
// They therefore hop back explicitly, like every other writer.
|
|
384
396
|
DispatchQueue.main.async { [weak self] in
|
|
385
397
|
formatter(params)
|
|
386
398
|
.then { @Sendable [weak self] customDisplay in
|
|
387
|
-
self?.
|
|
399
|
+
self?.setFormattedNavigationError(customDisplay ?? defaultFormatted)
|
|
388
400
|
}
|
|
389
401
|
.catch { @Sendable [weak self] _ in
|
|
390
|
-
self?.
|
|
402
|
+
self?.setFormattedNavigationError(defaultFormatted)
|
|
391
403
|
}
|
|
392
404
|
}
|
|
393
405
|
} else {
|
|
394
|
-
|
|
406
|
+
setFormattedNavigationError(defaultFormatted)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/// Callable from any thread — the Nitro promise callbacks below resolve on
|
|
411
|
+
/// the JS thread.
|
|
412
|
+
private func setFormattedNavigationError(_ formatted: FormattedNavigationError?) {
|
|
413
|
+
onMainActor { lastFormattedNavigationError = formatted }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// Synchronous on purpose: the `didSet`s notify JS, which has to happen
|
|
417
|
+
/// before the navigation that may set them again.
|
|
418
|
+
private func clearNavigationError() {
|
|
419
|
+
onMainActor {
|
|
420
|
+
lastNavigationError = nil
|
|
421
|
+
lastFormattedNavigationError = nil
|
|
395
422
|
}
|
|
396
423
|
}
|
|
397
424
|
|
|
398
425
|
// MARK: - Browser Methods
|
|
399
426
|
|
|
400
427
|
public func navigatePath(path: String) throws {
|
|
401
|
-
|
|
402
|
-
lastNavigationError = nil
|
|
403
|
-
lastFormattedNavigationError = nil
|
|
428
|
+
clearNavigationError()
|
|
404
429
|
Task {
|
|
405
430
|
do {
|
|
406
431
|
try await browserManager.navigate(path)
|
|
@@ -411,7 +436,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
411
436
|
}
|
|
412
437
|
|
|
413
438
|
public func navigateTrack(track: Track) throws {
|
|
414
|
-
|
|
439
|
+
// @MainActor: `player` is main-confined, and `trackSelector.select` is
|
|
440
|
+
// MainActor anyway — a bare Task read the reference off the cooperative
|
|
441
|
+
// pool, racing the assignment in `setupPlayer`.
|
|
442
|
+
Task { @MainActor in
|
|
415
443
|
guard let player else { return }
|
|
416
444
|
let result = await trackSelector.select(track: track, player: player)
|
|
417
445
|
switch result {
|
|
@@ -445,9 +473,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
445
473
|
}
|
|
446
474
|
|
|
447
475
|
private func navigateToBrowsableUrl(_ url: String) {
|
|
448
|
-
|
|
449
|
-
lastNavigationError = nil
|
|
450
|
-
lastFormattedNavigationError = nil
|
|
476
|
+
clearNavigationError()
|
|
451
477
|
Task {
|
|
452
478
|
do {
|
|
453
479
|
try await browserManager.navigate(url)
|
|
@@ -470,11 +496,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
470
496
|
}
|
|
471
497
|
|
|
472
498
|
public func getNavigationError() throws -> NavigationError? {
|
|
473
|
-
lastNavigationError
|
|
499
|
+
onMainActor { lastNavigationError }
|
|
474
500
|
}
|
|
475
501
|
|
|
476
502
|
public func getFormattedNavigationError() throws -> FormattedNavigationError? {
|
|
477
|
-
lastFormattedNavigationError
|
|
503
|
+
onMainActor { lastFormattedNavigationError }
|
|
478
504
|
}
|
|
479
505
|
|
|
480
506
|
/// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
|
|
@@ -733,16 +759,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
733
759
|
self?.onSleepTimerChanged(state)
|
|
734
760
|
}
|
|
735
761
|
|
|
736
|
-
// Now-playing: whether to manage metadata, the optional formatter that owns the
|
|
737
|
-
// lines
|
|
762
|
+
// Now-playing: whether to manage metadata, and the optional formatter that owns the
|
|
763
|
+
// rendered lines.
|
|
738
764
|
nowPlayingMetadataEnabled = options.autoUpdateNowPlayingMetadata ?? true
|
|
739
765
|
nowPlayingMetadataFormatter = options.nowPlayingMetadataFormatter
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
// (
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
|
|
766
|
+
// `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
|
|
767
|
+
// A terminal error resolves the playback state to *paused*
|
|
768
|
+
// (PlaybackCoordinator.applySideEffects), the now-playing info is retained (never cleared
|
|
769
|
+
// on error), and next/previous stay enabled (gated on queue position, not state). So the
|
|
770
|
+
// session stays controllable through errors by default — the behavior Android achieves via
|
|
771
|
+
// InterceptingPlayer is already the iOS norm.
|
|
746
772
|
|
|
747
773
|
// The bundled runtime options and initial state are part of the atomic launch
|
|
748
774
|
// description. Stage the state first so setup options and earlier pre-setup setter
|
|
@@ -819,7 +845,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
819
845
|
}
|
|
820
846
|
|
|
821
847
|
public func getOptions() throws -> Options {
|
|
822
|
-
playerOptions.toOptions()
|
|
848
|
+
onMainActor { playerOptions.toOptions() }
|
|
823
849
|
}
|
|
824
850
|
|
|
825
851
|
public func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
@@ -1121,11 +1147,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1121
1147
|
/// metadata can't stomp it mid-flash. Reverted by a NATIVE timer — JS
|
|
1122
1148
|
/// timers pause with a backgrounded host on Android, and the lock screen
|
|
1123
1149
|
/// is exactly the backgrounded case — and cleared early on track change.
|
|
1124
|
-
private var nowPlayingFlash: NowPlayingUpdate?
|
|
1125
|
-
private var nowPlayingFlashRevert: DispatchWorkItem?
|
|
1150
|
+
@MainActor private var nowPlayingFlash: NowPlayingUpdate?
|
|
1151
|
+
@MainActor private var nowPlayingFlashRevert: DispatchWorkItem?
|
|
1126
1152
|
|
|
1127
1153
|
/// Pending debounced audio-session release (see `playerShouldReleaseSession`).
|
|
1128
|
-
private var sessionReleaseWork: DispatchWorkItem?
|
|
1154
|
+
@MainActor private var sessionReleaseWork: DispatchWorkItem?
|
|
1129
1155
|
|
|
1130
1156
|
public func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
|
|
1131
1157
|
onMainActor {
|
|
@@ -1151,6 +1177,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1151
1177
|
}
|
|
1152
1178
|
}
|
|
1153
1179
|
|
|
1180
|
+
@MainActor
|
|
1154
1181
|
private func cancelNowPlayingFlash() {
|
|
1155
1182
|
nowPlayingFlashRevert?.cancel()
|
|
1156
1183
|
nowPlayingFlashRevert = nil
|
|
@@ -1354,8 +1381,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
1354
1381
|
guard let self else { return }
|
|
1355
1382
|
// Re-apply our category (the reset cleared it), then have the player
|
|
1356
1383
|
// recreate itself and reconnect the current stream.
|
|
1357
|
-
self.
|
|
1358
|
-
|
|
1384
|
+
self.onMainActor {
|
|
1385
|
+
self.applyAudioSessionCategory()
|
|
1386
|
+
self.player?.handleMediaServicesReset()
|
|
1387
|
+
}
|
|
1359
1388
|
}
|
|
1360
1389
|
}
|
|
1361
1390
|
|
|
@@ -1586,6 +1615,7 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
|
|
|
1586
1615
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
|
|
1587
1616
|
}
|
|
1588
1617
|
|
|
1618
|
+
@MainActor
|
|
1589
1619
|
private func cancelSessionRelease() {
|
|
1590
1620
|
sessionReleaseWork?.cancel()
|
|
1591
1621
|
sessionReleaseWork = nil
|
|
@@ -8,9 +8,9 @@ exports.onOptionsChanged = void 0;
|
|
|
8
8
|
exports.updateOptions = updateOptions;
|
|
9
9
|
exports.useOptions = useOptions;
|
|
10
10
|
var _native = require("../../native");
|
|
11
|
-
var _validateOptions = require("./validateOptions");
|
|
12
11
|
var _NativeUpdatedValue = require("../../utils/NativeUpdatedValue");
|
|
13
12
|
var _useNativeUpdatedValue = require("../../utils/useNativeUpdatedValue");
|
|
13
|
+
var _validateOptions = require("./validateOptions");
|
|
14
14
|
// MARK: - Types
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["_native","require","
|
|
1
|
+
{"version":3,"names":["_native","require","_NativeUpdatedValue","_useNativeUpdatedValue","_validateOptions","updateOptions","options","validateIOSUpdateOptions","ios","nativeBrowser","getOptions","onOptionsChanged","exports","NativeUpdatedValue","emitterize","cb","useOptions","useNativeUpdatedValue"],"sourceRoot":"../../../../src","sources":["features/player/options.ts"],"mappings":";;;;;;;;;AAEA,IAAAA,OAAA,GAAAC,OAAA;AACA,IAAAC,mBAAA,GAAAD,OAAA;AACA,IAAAE,sBAAA,GAAAF,OAAA;AACA,IAAAG,gBAAA,GAAAH,OAAA;AAEA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgGA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAaA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA4CA;AACA;AACA;AACA;;AA4EA;AACA;AACA;AACA;;AAOA;AACA;AACA;AACA;;AA6BA;AACA;AACA;AACA;;AAOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAuDA;AACA;AACA;AACA;;AAwCA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASI,aAAaA,CAACC,OAAsB,EAAQ;EAC1D,IAAAC,yCAAwB,EAACD,OAAO,CAACE,GAAG,CAAC;EACrCC,qBAAa,CAACJ,aAAa,CAACC,OAAO,CAAC;AACtC;;AAEA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASI,UAAUA,CAAA,EAAY;EACpC,OAAOD,qBAAa,CAACC,UAAU,CAAC,CAAC;AACnC;;AAEA;;AAEA;AACA;AACA;AACA;AACA;AACO,MAAMC,gBAAgB,GAAAC,OAAA,CAAAD,gBAAA,GAAGE,sCAAkB,CAACC,UAAU,CAC1DC,EAAE,IAAMN,qBAAa,CAACE,gBAAgB,GAAGI,EAC5C,CAAC;;AAED;;AAEA;AACA;AACA;AACA;AACO,SAASC,UAAUA,CAAA,EAAY;EACpC,OAAO,IAAAC,4CAAqB,EAACP,UAAU,EAAEC,gBAAgB,CAAC;AAC5D","ignoreList":[]}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
import { nativeBrowser } from '../../native';
|
|
4
|
-
import { validateIOSUpdateOptions } from './validateOptions';
|
|
5
4
|
import { NativeUpdatedValue } from '../../utils/NativeUpdatedValue';
|
|
6
5
|
import { useNativeUpdatedValue } from '../../utils/useNativeUpdatedValue';
|
|
6
|
+
import { validateIOSUpdateOptions } from './validateOptions';
|
|
7
7
|
|
|
8
8
|
// MARK: - Types
|
|
9
9
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["nativeBrowser","
|
|
1
|
+
{"version":3,"names":["nativeBrowser","NativeUpdatedValue","useNativeUpdatedValue","validateIOSUpdateOptions","updateOptions","options","ios","getOptions","onOptionsChanged","emitterize","cb","useOptions"],"sourceRoot":"../../../../src","sources":["features/player/options.ts"],"mappings":";;AAEA,SAASA,aAAa,QAAQ,cAAc;AAC5C,SAASC,kBAAkB,QAAQ,gCAAgC;AACnE,SAASC,qBAAqB,QAAQ,mCAAmC;AACzE,SAASC,wBAAwB,QAAQ,mBAAmB;;AAE5D;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAgGA;AACA;AACA;AACA;AACA;;AAQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAaA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAMA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AA4CA;AACA;AACA;AACA;;AA4EA;AACA;AACA;AACA;;AAOA;AACA;AACA;AACA;;AA6BA;AACA;AACA;AACA;;AAOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAuDA;AACA;AACA;AACA;;AAwCA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASC,aAAaA,CAACC,OAAsB,EAAQ;EAC1DF,wBAAwB,CAACE,OAAO,CAACC,GAAG,CAAC;EACrCN,aAAa,CAACI,aAAa,CAACC,OAAO,CAAC;AACtC;;AAEA;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASE,UAAUA,CAAA,EAAY;EACpC,OAAOP,aAAa,CAACO,UAAU,CAAC,CAAC;AACnC;;AAEA;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,gBAAgB,GAAGP,kBAAkB,CAACQ,UAAU,CAC1DC,EAAE,IAAMV,aAAa,CAACQ,gBAAgB,GAAGE,EAC5C,CAAC;;AAED;;AAEA;AACA;AACA;AACA;AACA,OAAO,SAASC,UAAUA,CAAA,EAAY;EACpC,OAAOT,qBAAqB,CAACK,UAAU,EAAEC,gBAAgB,CAAC;AAC5D","ignoreList":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../../../src/features/player/options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AACjD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../../../src/features/player/options.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { FavoriteConfig } from '../../types'
|
|
2
2
|
import type { CarPlayNowPlayingButton } from '../../types/browser'
|
|
3
3
|
import { nativeBrowser } from '../../native'
|
|
4
|
-
import { validateIOSUpdateOptions } from './validateOptions'
|
|
5
4
|
import { NativeUpdatedValue } from '../../utils/NativeUpdatedValue'
|
|
6
5
|
import { useNativeUpdatedValue } from '../../utils/useNativeUpdatedValue'
|
|
6
|
+
import { validateIOSUpdateOptions } from './validateOptions'
|
|
7
7
|
|
|
8
8
|
// MARK: - Types
|
|
9
9
|
|
|
@@ -27,7 +27,14 @@ import { describe, it, expect } from 'vitest'
|
|
|
27
27
|
const SRC = join(process.cwd(), 'src')
|
|
28
28
|
const ENTRY = join(SRC, 'index.ts')
|
|
29
29
|
|
|
30
|
-
const VALUE_KINDS = new Set([
|
|
30
|
+
const VALUE_KINDS = new Set([
|
|
31
|
+
'const',
|
|
32
|
+
'let',
|
|
33
|
+
'var',
|
|
34
|
+
'function',
|
|
35
|
+
'class',
|
|
36
|
+
'enum'
|
|
37
|
+
])
|
|
31
38
|
|
|
32
39
|
function resolveModule(fromFile: string, spec: string): string | undefined {
|
|
33
40
|
const base = resolve(dirname(fromFile), spec.replace(/\.tsx?$/, ''))
|