react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.11
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/README.md +22 -18
- package/android/build.gradle +13 -10
- package/android/gradle.properties +2 -2
- package/android/src/main/AndroidManifest.xml +3 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +76 -21
- package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
- package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
- package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
- package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
- package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
- package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
- package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
- package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
- package/android/src/main/java/com/audiobrowser/player/Player.kt +30 -8
- package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
- package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +2 -1
- package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +67 -11
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
- package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
- package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
- package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
- package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
- package/android/src/test/java/com/audiobrowser/TestFixtures.kt +52 -1
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +167 -8
- package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
- package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
- package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
- package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
- package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
- package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
- package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
- package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
- package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
- package/ios/Browser/BrowserManager.swift +2 -27
- package/ios/Browser/SearchDrillIn.swift +1 -1
- package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
- package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
- package/ios/HybridAudioBrowser.swift +87 -56
- package/ios/Model/NitroTypeStubs.swift +18 -2
- package/ios/Model/RemoteCommand.swift +18 -0
- package/ios/Model/TrackPlayerError.swift +2 -2
- package/ios/Option/SessionCategories.swift +3 -2
- package/ios/Player/MediaIntentCriteria.swift +4 -4
- package/ios/Player/MediaLoader.swift +3 -1
- package/ios/Player/PlaybackCoordinator.swift +4 -1
- package/ios/Player/PlaybackErrorHandler.swift +4 -4
- package/ios/Player/QueueManager.swift +10 -3
- package/ios/Player/RetryManager.swift +79 -18
- package/ios/Player/ShuffleOrder.swift +10 -0
- package/ios/Player/SleepTimerManager.swift +43 -15
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
- package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
- package/ios/Tests/LRUCacheTests.swift +111 -0
- package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
- package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
- package/ios/Tests/MediaLoaderTests.swift +10 -13
- package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
- package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
- package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
- package/ios/Tests/QueueManagerTests.swift +50 -4
- package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
- package/ios/Tests/RemoteCommandTests.swift +67 -0
- package/ios/Tests/RetryManagerTests.swift +89 -0
- package/ios/Tests/SectionScopeTests.swift +2 -2
- package/ios/Tests/SleepTimerManagerTests.swift +22 -19
- package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
- package/ios/Tests/Support/TestTypes.swift +1 -1
- package/ios/Tests/TabBarEntriesTests.swift +7 -7
- package/ios/TrackPlayer.swift +6 -1
- package/ios/TrackPlayerCallbacks.swift +5 -38
- package/ios/Util/LRUCache.swift +4 -1
- package/lib/commonjs/AudioBrowser.js +0 -11
- package/lib/commonjs/AudioBrowser.js.map +1 -1
- package/lib/commonjs/CLAUDE.md +52 -0
- package/lib/commonjs/features/browser.js +1 -1
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/features/carConnection.js +1 -1
- package/lib/commonjs/features/equalizer.js +1 -1
- package/lib/commonjs/features/errors.js +3 -3
- package/lib/commonjs/features/favorites.js +1 -1
- package/lib/commonjs/features/gate.js +16 -1
- package/lib/commonjs/features/gate.js.map +1 -1
- package/lib/commonjs/features/metadata.js +20 -3
- package/lib/commonjs/features/metadata.js.map +1 -1
- package/lib/commonjs/features/network.js +1 -1
- package/lib/commonjs/features/nowPlaying.js +1 -1
- package/lib/commonjs/features/output.js +1 -1
- package/lib/commonjs/features/playback/playWhenReady.js +1 -1
- package/lib/commonjs/features/playback/playing.js +1 -1
- package/lib/commonjs/features/playback/progress.js +1 -1
- package/lib/commonjs/features/playback/state.js +1 -1
- package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
- package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/commonjs/features/playback/volume.js +1 -1
- package/lib/commonjs/features/player/options.js +48 -34
- package/lib/commonjs/features/player/options.js.map +1 -1
- package/lib/commonjs/features/player/setup.js +15 -8
- package/lib/commonjs/features/player/setup.js.map +1 -1
- package/lib/commonjs/features/player/validateOptions.js +21 -0
- package/lib/commonjs/features/player/validateOptions.js.map +1 -0
- package/lib/commonjs/features/queue/activeTrack.js +1 -1
- package/lib/commonjs/features/queue/queue.js +2 -2
- package/lib/commonjs/features/queue/repeatMode.js +1 -1
- package/lib/commonjs/features/queue/shuffle.js +1 -1
- package/lib/commonjs/features/remoteControls.js +13 -92
- package/lib/commonjs/features/remoteControls.js.map +1 -1
- package/lib/commonjs/features/sleepTimer.js +13 -1
- package/lib/commonjs/features/sleepTimer.js.map +1 -1
- package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
- package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
- package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
- package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +1 -1
- package/lib/module/AudioBrowser.js +1 -1
- package/lib/module/AudioBrowser.js.map +1 -1
- package/lib/module/CLAUDE.md +52 -0
- package/lib/module/features/browser.js +1 -1
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/features/carConnection.js +1 -1
- package/lib/module/features/equalizer.js +1 -1
- package/lib/module/features/errors.js +3 -3
- package/lib/module/features/favorites.js +1 -1
- package/lib/module/features/gate.js +16 -1
- package/lib/module/features/gate.js.map +1 -1
- package/lib/module/features/metadata.js +20 -3
- package/lib/module/features/metadata.js.map +1 -1
- package/lib/module/features/network.js +1 -1
- package/lib/module/features/nowPlaying.js +1 -1
- package/lib/module/features/output.js +1 -1
- package/lib/module/features/playback/playWhenReady.js +1 -1
- package/lib/module/features/playback/playing.js +1 -1
- package/lib/module/features/playback/progress.js +1 -1
- package/lib/module/features/playback/state.js +1 -1
- package/lib/module/features/playback/trackPlaybackTime.js +0 -5
- package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
- package/lib/module/features/playback/volume.js +1 -1
- package/lib/module/features/player/options.js +47 -32
- package/lib/module/features/player/options.js.map +1 -1
- package/lib/module/features/player/setup.js +14 -7
- package/lib/module/features/player/setup.js.map +1 -1
- package/lib/module/features/player/validateOptions.js +17 -0
- package/lib/module/features/player/validateOptions.js.map +1 -0
- package/lib/module/features/queue/activeTrack.js +1 -1
- package/lib/module/features/queue/queue.js +2 -2
- package/lib/module/features/queue/repeatMode.js +1 -1
- package/lib/module/features/queue/shuffle.js +1 -1
- package/lib/module/features/remoteControls.js +13 -92
- package/lib/module/features/remoteControls.js.map +1 -1
- package/lib/module/features/sleepTimer.js +13 -1
- package/lib/module/features/sleepTimer.js.map +1 -1
- package/lib/module/test-utils/reactNativeStub.js +58 -0
- package/lib/module/test-utils/reactNativeStub.js.map +1 -0
- package/lib/module/utils/LazyNativeEmitter.js +8 -5
- package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +1 -1
- package/lib/typescript/src/AudioBrowser.d.ts +0 -1
- package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
- package/lib/typescript/src/features/carConnection.d.ts +1 -1
- package/lib/typescript/src/features/equalizer.d.ts +1 -1
- package/lib/typescript/src/features/errors.d.ts +3 -3
- package/lib/typescript/src/features/favorites.d.ts +1 -1
- package/lib/typescript/src/features/gate.d.ts +18 -2
- package/lib/typescript/src/features/gate.d.ts.map +1 -1
- package/lib/typescript/src/features/metadata.d.ts +20 -3
- package/lib/typescript/src/features/metadata.d.ts.map +1 -1
- package/lib/typescript/src/features/network.d.ts +1 -1
- package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
- package/lib/typescript/src/features/output.d.ts +1 -1
- package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
- package/lib/typescript/src/features/playback/playing.d.ts +1 -1
- package/lib/typescript/src/features/playback/progress.d.ts +1 -1
- package/lib/typescript/src/features/playback/state.d.ts +1 -1
- package/lib/typescript/src/features/playback/volume.d.ts +1 -1
- package/lib/typescript/src/features/player/options.d.ts +87 -37
- package/lib/typescript/src/features/player/options.d.ts.map +1 -1
- package/lib/typescript/src/features/player/setup.d.ts +14 -4
- package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
- package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
- package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
- package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
- package/lib/typescript/src/features/queue/queue.d.ts +2 -2
- package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
- package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
- package/lib/typescript/src/features/remoteControls.d.ts +23 -22
- package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
- package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
- package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
- package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
- package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
- package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
- package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
- package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
- package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
- package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
- package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
- package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
- package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
- package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
- package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
- package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
- package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
- package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
- package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
- package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
- package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
- package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
- package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
- package/package.json +12 -4
- package/src/AudioBrowser.ts +1 -1
- package/src/CLAUDE.md +52 -0
- package/src/features/battery.test.ts +79 -0
- package/src/features/browser.ts +1 -1
- package/src/features/carConnection.ts +1 -1
- package/src/features/equalizer.ts +1 -1
- package/src/features/errors.ts +3 -3
- package/src/features/favorites.ts +1 -1
- package/src/features/gate.ts +16 -1
- package/src/features/hook-wiring.test.ts +254 -0
- package/src/features/metadata.ts +20 -3
- package/src/features/network.ts +1 -1
- package/src/features/nowPlaying.ts +1 -1
- package/src/features/output.ts +1 -1
- package/src/features/playback/playWhenReady.ts +1 -1
- package/src/features/playback/playing.ts +1 -1
- package/src/features/playback/progress.test.ts +169 -0
- package/src/features/playback/progress.ts +1 -1
- package/src/features/playback/state.ts +1 -1
- package/src/features/playback/trackPlaybackTime.test.ts +16 -6
- package/src/features/playback/trackPlaybackTime.ts +0 -5
- package/src/features/playback/volume.ts +1 -1
- package/src/features/player/options.ts +99 -57
- package/src/features/player/setup.test.ts +12 -4
- package/src/features/player/setup.ts +19 -9
- package/src/features/player/validateOptions.ts +19 -0
- package/src/features/queue/activeTrack.ts +1 -1
- package/src/features/queue/queue.ts +2 -2
- package/src/features/queue/repeatMode.ts +1 -1
- package/src/features/queue/shuffle.ts +1 -1
- package/src/features/remoteControls.ts +13 -92
- package/src/features/sleepTimer.test.ts +172 -0
- package/src/features/sleepTimer.ts +9 -1
- package/src/nitro-stub-drift.test.ts +237 -0
- package/src/public-surface.test.ts +107 -0
- package/src/specs/audio-browser.nitro.ts +1 -1
- package/src/test-utils/reactNativeStub.ts +60 -0
- package/src/utils/LazyNativeEmitter.ts +9 -6
- package/src/utils/useDebug.test.ts +182 -0
- package/src/utils/useNativeUpdatedValue.test.ts +103 -0
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/browser/BrowserManager.ts +1 -1
- package/ios/Model/MediaURL.swift +0 -67
- package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
- package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
- package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
- package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import AVFoundation
|
|
1
2
|
import Foundation
|
|
2
3
|
#if canImport(NitroModules)
|
|
3
4
|
import NitroModules
|
|
@@ -122,30 +123,89 @@ class RetryManager {
|
|
|
122
123
|
|
|
123
124
|
// MARK: - Error Classification
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
/// HTTP statuses worth another attempt. Same set as Android's
|
|
127
|
+
/// `RetryLoadErrorHandlingPolicy.RETRYABLE_HTTP_STATUS_CODES`, so a flaky
|
|
128
|
+
/// server behaves the same on both platforms.
|
|
129
|
+
private static let retryableHTTPStatusCodes: Set<Int> = [408, 429]
|
|
130
|
+
|
|
131
|
+
/// AVFoundation failures a retry cannot fix: the media is unusable rather
|
|
132
|
+
/// than the transport unreliable. Everything not listed falls through to
|
|
133
|
+
/// retry — `AVErrorUnknown` especially, which is the wrapper AVFoundation
|
|
134
|
+
/// puts around opaque CoreMedia transport failures.
|
|
135
|
+
private static let fatalAVErrorCodes: Set<Int> = [
|
|
136
|
+
AVError.fileFormatNotRecognized.rawValue,
|
|
137
|
+
AVError.failedToParse.rawValue,
|
|
138
|
+
AVError.decodeFailed.rawValue,
|
|
139
|
+
AVError.contentIsProtected.rawValue,
|
|
140
|
+
AVError.contentIsNotAuthorized.rawValue,
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
/// Whether another attempt could plausibly succeed.
|
|
144
|
+
///
|
|
145
|
+
/// Mirrors Android's `classifyError`, including its default: an unrecognized
|
|
146
|
+
/// failure is treated as *transient* and retried, bounded by the retry
|
|
147
|
+
/// budgets, rather than being terminal. Defaulting the other way made
|
|
148
|
+
/// `retry: true` largely inert on iOS, because AVFoundation reports most
|
|
149
|
+
/// stream failures as opaque `CoreMediaErrorDomain` / `AVFoundationErrorDomain`
|
|
150
|
+
/// errors rather than `URLError`.
|
|
151
|
+
///
|
|
152
|
+
/// - Parameter httpStatusCode: The status from the item's error log, when the
|
|
153
|
+
/// caller has one. `AVPlayerItem.error` never carries it — a 404 arrives as
|
|
154
|
+
/// an opaque CoreMedia error — so this is the only reliable HTTP signal and
|
|
155
|
+
/// it outranks the error itself.
|
|
156
|
+
func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
|
|
157
|
+
if let status = httpStatusCode {
|
|
158
|
+
let retryable = Self.retryableHTTPStatusCodes.contains(status) || (500 ... 599).contains(status)
|
|
159
|
+
logger.debug("HTTP \(status) \(retryable ? "is" : "not") retryable")
|
|
160
|
+
return retryable
|
|
161
|
+
}
|
|
162
|
+
|
|
126
163
|
guard let error else { return false }
|
|
127
164
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
165
|
+
// Walk the underlying-error chain: AVFoundation routinely wraps the real
|
|
166
|
+
// cause (a URLError, or a CoreMedia code) in an AVError.
|
|
167
|
+
for candidate in Self.errorChain(error) {
|
|
168
|
+
if let urlError = candidate as? URLError {
|
|
169
|
+
switch urlError.code {
|
|
170
|
+
// Transient network errors - safe to retry
|
|
171
|
+
case .timedOut,
|
|
172
|
+
.networkConnectionLost,
|
|
173
|
+
.notConnectedToInternet,
|
|
174
|
+
.cannotConnectToHost,
|
|
175
|
+
.cannotFindHost,
|
|
176
|
+
.dnsLookupFailed:
|
|
177
|
+
logger.debug("URLError is retryable: code=\(urlError.code.rawValue)")
|
|
178
|
+
return true
|
|
179
|
+
default:
|
|
180
|
+
logger.debug("URLError not retryable: code=\(urlError.code.rawValue)")
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let nsError = candidate as NSError
|
|
186
|
+
if nsError.domain == AVFoundationErrorDomain,
|
|
187
|
+
Self.fatalAVErrorCodes.contains(nsError.code)
|
|
188
|
+
{
|
|
189
|
+
logger.debug("AVError not retryable: code=\(nsError.code)")
|
|
141
190
|
return false
|
|
142
191
|
}
|
|
143
192
|
}
|
|
144
193
|
|
|
145
|
-
// Unknown errors - don't retry
|
|
146
194
|
let nsError = error as NSError
|
|
147
|
-
logger.debug("
|
|
148
|
-
return
|
|
195
|
+
logger.debug("Treating as transient: domain=\(nsError.domain), code=\(nsError.code)")
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// The error and its `NSUnderlyingError` ancestors, outermost first.
|
|
200
|
+
private static func errorChain(_ error: Error) -> [Error] {
|
|
201
|
+
var chain: [Error] = []
|
|
202
|
+
var current: Error? = error
|
|
203
|
+
// Bounded: a malformed cycle must not spin here.
|
|
204
|
+
while let error = current, chain.count < 8 {
|
|
205
|
+
chain.append(error)
|
|
206
|
+
current = (error as NSError).userInfo[NSUnderlyingErrorKey] as? Error
|
|
207
|
+
}
|
|
208
|
+
return chain
|
|
149
209
|
}
|
|
150
210
|
|
|
151
211
|
// MARK: - Retry Management
|
|
@@ -170,7 +230,8 @@ class RetryManager {
|
|
|
170
230
|
return "max retry duration (\(maxRetryDuration)s)"
|
|
171
231
|
}
|
|
172
232
|
if !hasPlayed, isOnline, let start = firstOnlineRetryTime,
|
|
173
|
-
now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
|
|
233
|
+
now.timeIntervalSince(start) >= firstConnectMaxRetryDuration
|
|
234
|
+
{
|
|
174
235
|
return "first-connect retry duration (\(firstConnectMaxRetryDuration)s)"
|
|
175
236
|
}
|
|
176
237
|
return nil
|
|
@@ -39,6 +39,16 @@ struct ShuffleOrder {
|
|
|
39
39
|
indexInShuffled = Self.buildIndexInShuffled(from: shuffled)
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/// Creates a shuffle order for the given length with `firstIndex` pinned to shuffle position 0.
|
|
43
|
+
///
|
|
44
|
+
/// Playback starts on that track, so leaving it wherever the permutation put it would end the
|
|
45
|
+
/// queue as soon as it is reached — on average after half the queue, and immediately whenever it
|
|
46
|
+
/// lands last.
|
|
47
|
+
init(length: Int, firstIndex: Int) {
|
|
48
|
+
self.init(length: length)
|
|
49
|
+
reshuffle(keepingFirst: firstIndex)
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
/// Creates a shuffle order with pre-defined indices
|
|
43
53
|
init(shuffledIndices: [Int]) {
|
|
44
54
|
shuffled = shuffledIndices
|
|
@@ -7,15 +7,51 @@ import Foundation
|
|
|
7
7
|
/// Type alias for the Nitro SleepTimer variant type
|
|
8
8
|
typealias SleepTimerState = SleepTimer
|
|
9
9
|
|
|
10
|
+
/// A scheduled job that has not run yet. `cancel()` is nonisolated so `deinit`
|
|
11
|
+
/// — always nonisolated in Swift 6 — can tear pending work down.
|
|
12
|
+
protocol SleepTimerJob: AnyObject {
|
|
13
|
+
func cancel()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
extension DispatchWorkItem: SleepTimerJob {}
|
|
17
|
+
|
|
18
|
+
/// Runs work after a delay. Injected into `SleepTimerManager` so tests can
|
|
19
|
+
/// drive the clock instead of racing it: production schedules on the main
|
|
20
|
+
/// queue, tests hand in a scheduler they advance by hand.
|
|
21
|
+
@MainActor
|
|
22
|
+
protocol SleepTimerScheduling {
|
|
23
|
+
func schedule(
|
|
24
|
+
after delay: TimeInterval,
|
|
25
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
26
|
+
) -> any SleepTimerJob
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
struct MainQueueSleepTimerScheduler: SleepTimerScheduling {
|
|
30
|
+
func schedule(
|
|
31
|
+
after delay: TimeInterval,
|
|
32
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
33
|
+
) -> any SleepTimerJob {
|
|
34
|
+
let job = DispatchWorkItem { MainActor.assumeIsolated { work() } }
|
|
35
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: job)
|
|
36
|
+
return job
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
/// Manages sleep timer functionality for the audio player.
|
|
11
41
|
/// Supports both time-based timers and end-of-track timers.
|
|
12
42
|
@MainActor
|
|
13
43
|
class SleepTimerManager: SleepTimerHandling {
|
|
14
44
|
// MARK: - Properties
|
|
15
45
|
|
|
46
|
+
private let scheduler: any SleepTimerScheduling
|
|
47
|
+
|
|
16
48
|
// nonisolated(unsafe) for deinit cleanup — deinit is always nonisolated in Swift 6.
|
|
17
|
-
private nonisolated(unsafe) var sleepTimerJob:
|
|
18
|
-
private nonisolated(unsafe) var fadeJob:
|
|
49
|
+
private nonisolated(unsafe) var sleepTimerJob: (any SleepTimerJob)?
|
|
50
|
+
private nonisolated(unsafe) var fadeJob: (any SleepTimerJob)?
|
|
51
|
+
|
|
52
|
+
init(scheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler()) {
|
|
53
|
+
self.scheduler = scheduler
|
|
54
|
+
}
|
|
19
55
|
|
|
20
56
|
/// The time when playback should stop (seconds since epoch), or -1 if inactive
|
|
21
57
|
private(set) var sleepTimerTime: TimeInterval = -1 {
|
|
@@ -94,23 +130,15 @@ class SleepTimerManager: SleepTimerHandling {
|
|
|
94
130
|
func set(seconds: TimeInterval, fadeDuration: TimeInterval? = nil) {
|
|
95
131
|
cancelSleepTimerJob()
|
|
96
132
|
onFadeCancel?()
|
|
97
|
-
let job = DispatchWorkItem { [weak self] in
|
|
98
|
-
MainActor.assumeIsolated {
|
|
99
|
-
self?.complete()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
sleepTimerJob = job
|
|
103
133
|
sleepTimerTime = Date().timeIntervalSince1970 + seconds
|
|
104
|
-
|
|
134
|
+
sleepTimerJob = scheduler.schedule(after: seconds) { [weak self] in
|
|
135
|
+
self?.complete()
|
|
136
|
+
}
|
|
105
137
|
if let fadeDuration, fadeDuration > 0 {
|
|
106
138
|
let fade = min(fadeDuration, seconds)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
self?.onFadeStart?(fade)
|
|
110
|
-
}
|
|
139
|
+
fadeJob = scheduler.schedule(after: seconds - fade) { [weak self] in
|
|
140
|
+
self?.onFadeStart?(fade)
|
|
111
141
|
}
|
|
112
|
-
self.fadeJob = fadeJob
|
|
113
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + (seconds - fade), execute: fadeJob)
|
|
114
142
|
}
|
|
115
143
|
if let state = get() { onChanged?(state) }
|
|
116
144
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#if canImport(NitroModules)
|
|
2
|
+
import NitroModules
|
|
3
|
+
#endif
|
|
4
|
+
|
|
5
|
+
/// The callbacks `RemoteCommandController` invokes when the system delivers a
|
|
6
|
+
/// remote command (lock screen, Control Center, CarPlay, headset).
|
|
7
|
+
///
|
|
8
|
+
/// Split out of `TrackPlayerCallbacks` — which the controller does not otherwise
|
|
9
|
+
/// use — so the controller depends only on what it calls. `TrackPlayerCallbacks`
|
|
10
|
+
/// extends this protocol, so conformers implement both at once.
|
|
11
|
+
@MainActor protocol RemoteCommandCallbacks: AnyObject {
|
|
12
|
+
/// Called when play is triggered remotely.
|
|
13
|
+
func remotePlay()
|
|
14
|
+
|
|
15
|
+
/// Called when pause is triggered remotely.
|
|
16
|
+
func remotePause()
|
|
17
|
+
|
|
18
|
+
/// Called when stop is triggered remotely.
|
|
19
|
+
func remoteStop()
|
|
20
|
+
|
|
21
|
+
/// Called when toggle play/pause is triggered remotely.
|
|
22
|
+
func remotePlayPause()
|
|
23
|
+
|
|
24
|
+
/// Called when next is triggered remotely.
|
|
25
|
+
func remoteNext()
|
|
26
|
+
|
|
27
|
+
/// Called when previous is triggered remotely.
|
|
28
|
+
func remotePrevious()
|
|
29
|
+
|
|
30
|
+
/// Called when jump forward is triggered remotely.
|
|
31
|
+
func remoteJumpForward(interval: Double)
|
|
32
|
+
|
|
33
|
+
/// Called when jump backward is triggered remotely.
|
|
34
|
+
func remoteJumpBackward(interval: Double)
|
|
35
|
+
|
|
36
|
+
/// Called when seek is triggered remotely.
|
|
37
|
+
func remoteSeek(position: Double)
|
|
38
|
+
|
|
39
|
+
/// Called when repeat mode change is triggered remotely (CarPlay/lock screen).
|
|
40
|
+
func remoteChangeRepeatMode(mode: RepeatMode)
|
|
41
|
+
|
|
42
|
+
/// Called when shuffle mode change is triggered remotely (CarPlay/lock screen).
|
|
43
|
+
func remoteChangeShuffleMode(enabled: Bool)
|
|
44
|
+
|
|
45
|
+
/// Called when playback rate change is triggered remotely (CarPlay/lock screen).
|
|
46
|
+
func remoteChangePlaybackRate(rate: Float)
|
|
47
|
+
}
|
|
@@ -46,7 +46,7 @@ class RemoteCommandController {
|
|
|
46
46
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "RemoteCommandController")
|
|
47
47
|
private var center: MPRemoteCommandCenter
|
|
48
48
|
|
|
49
|
-
weak var callbacks:
|
|
49
|
+
weak var callbacks: RemoteCommandCallbacks?
|
|
50
50
|
|
|
51
51
|
var commandTargetPointers: [String: Any] = [:]
|
|
52
52
|
private var enabledCommands: [RemoteCommand] = []
|
|
@@ -72,7 +72,7 @@ class RemoteCommandController {
|
|
|
72
72
|
*/
|
|
73
73
|
init(
|
|
74
74
|
remoteCommandCenter: MPRemoteCommandCenter = MPRemoteCommandCenter.shared(),
|
|
75
|
-
callbacks:
|
|
75
|
+
callbacks: RemoteCommandCallbacks? = nil,
|
|
76
76
|
) {
|
|
77
77
|
center = remoteCommandCenter
|
|
78
78
|
self.callbacks = callbacks
|
|
@@ -112,13 +112,16 @@ class RemoteCommandController {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
func enable(commands: [RemoteCommand]) {
|
|
115
|
-
let commandsToDisable =
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
let commandsToDisable = RemoteCommand.commandsToDisable(
|
|
116
|
+
enabled: enabledCommands, replacedBy: commands,
|
|
117
|
+
)
|
|
118
118
|
|
|
119
119
|
enabledCommands = commands
|
|
120
|
-
|
|
120
|
+
// Disable first. The key diff already makes the two sets disjoint, so this
|
|
121
|
+
// is belt-and-braces: it keeps a disable from tearing down a command the
|
|
122
|
+
// enable pass just configured should the two ever overlap again.
|
|
121
123
|
disable(commands: commandsToDisable)
|
|
124
|
+
commands.forEach { self.enable(command: $0) }
|
|
122
125
|
}
|
|
123
126
|
|
|
124
127
|
func disable(commands: [RemoteCommand]) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@testable import AudioBrowserTestable
|
|
5
|
+
|
|
6
|
+
@Suite("LRUCache")
|
|
7
|
+
struct LRUCacheTests {
|
|
8
|
+
/// A class so the tests can observe release through a weak reference.
|
|
9
|
+
private final class Box {
|
|
10
|
+
let id: Int
|
|
11
|
+
init(_ id: Int) { self.id = id }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Test func evictsTheLeastRecentlyUsed() {
|
|
15
|
+
let cache = LRUCache<String, Int>(maxSize: 2)
|
|
16
|
+
cache.set("a", value: 1)
|
|
17
|
+
cache.set("b", value: 2)
|
|
18
|
+
cache.set("c", value: 3)
|
|
19
|
+
|
|
20
|
+
#expect(cache.get("a") == nil)
|
|
21
|
+
#expect(cache.get("b") == 2)
|
|
22
|
+
#expect(cache.get("c") == 3)
|
|
23
|
+
#expect(cache.count == 2)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// A read is a use: it must move the entry off the eviction end.
|
|
27
|
+
@Test func readingPromotesAnEntry() {
|
|
28
|
+
let cache = LRUCache<String, Int>(maxSize: 2)
|
|
29
|
+
cache.set("a", value: 1)
|
|
30
|
+
cache.set("b", value: 2)
|
|
31
|
+
_ = cache.get("a")
|
|
32
|
+
cache.set("c", value: 3)
|
|
33
|
+
|
|
34
|
+
#expect(cache.get("a") == 1)
|
|
35
|
+
#expect(cache.get("b") == nil)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
@Test func removeDropsOnlyItsOwnEntry() {
|
|
39
|
+
let cache = LRUCache<String, Int>(maxSize: 3)
|
|
40
|
+
cache.set("a", value: 1)
|
|
41
|
+
cache.set("b", value: 2)
|
|
42
|
+
cache.set("c", value: 3)
|
|
43
|
+
|
|
44
|
+
cache.remove("b")
|
|
45
|
+
|
|
46
|
+
#expect(cache.get("b") == nil)
|
|
47
|
+
#expect(cache.get("a") == 1)
|
|
48
|
+
#expect(cache.get("c") == 3)
|
|
49
|
+
#expect(cache.count == 2)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// The nodes form a doubly-linked list. If both directions were strong, adjacent nodes would
|
|
53
|
+
/// retain each other and clearing would drop only the dictionary and the two ends — the chain,
|
|
54
|
+
/// and everything it holds, would survive (#96).
|
|
55
|
+
@Test func clearReleasesEveryNode() {
|
|
56
|
+
let cache = LRUCache<String, Box>(maxSize: 10)
|
|
57
|
+
weak var first: Box?
|
|
58
|
+
weak var middle: Box?
|
|
59
|
+
weak var last: Box?
|
|
60
|
+
|
|
61
|
+
do {
|
|
62
|
+
let a = Box(1), b = Box(2), c = Box(3)
|
|
63
|
+
(first, middle, last) = (a, b, c)
|
|
64
|
+
cache.set("a", value: a)
|
|
65
|
+
cache.set("b", value: b)
|
|
66
|
+
cache.set("c", value: c)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#expect(first != nil, "the cache should still hold its entries")
|
|
70
|
+
|
|
71
|
+
cache.clear()
|
|
72
|
+
|
|
73
|
+
#expect(first == nil)
|
|
74
|
+
#expect(middle == nil)
|
|
75
|
+
#expect(last == nil)
|
|
76
|
+
#expect(cache.count == 0)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/// Same cycle, reached by dropping the cache itself rather than clearing it.
|
|
80
|
+
@Test func droppingTheCacheReleasesEveryNode() {
|
|
81
|
+
weak var first: Box?
|
|
82
|
+
weak var last: Box?
|
|
83
|
+
|
|
84
|
+
do {
|
|
85
|
+
let cache = LRUCache<String, Box>(maxSize: 10)
|
|
86
|
+
let a = Box(1), b = Box(2)
|
|
87
|
+
(first, last) = (a, b)
|
|
88
|
+
cache.set("a", value: a)
|
|
89
|
+
cache.set("b", value: b)
|
|
90
|
+
#expect(first != nil)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#expect(first == nil)
|
|
94
|
+
#expect(last == nil)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Eviction has always unlinked the node it drops; this pins that down alongside the rest.
|
|
98
|
+
@Test func evictionReleasesTheEvictedNode() {
|
|
99
|
+
let cache = LRUCache<String, Box>(maxSize: 1)
|
|
100
|
+
weak var evicted: Box?
|
|
101
|
+
|
|
102
|
+
do {
|
|
103
|
+
let a = Box(1)
|
|
104
|
+
evicted = a
|
|
105
|
+
cache.set("a", value: a)
|
|
106
|
+
cache.set("b", value: Box(2))
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#expect(evicted == nil)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -246,7 +246,9 @@ struct ResetTests {
|
|
|
246
246
|
c.capture(position: 25.0)
|
|
247
247
|
|
|
248
248
|
// Let the (immediately-cancelled, itemless) seek completion drain.
|
|
249
|
-
for _ in 0 ..< 20 {
|
|
249
|
+
for _ in 0 ..< 20 {
|
|
250
|
+
await Task.yield()
|
|
251
|
+
}
|
|
250
252
|
|
|
251
253
|
#expect(spy.calls.isEmpty)
|
|
252
254
|
#expect(c.pendingTime == 25.0)
|
|
@@ -57,12 +57,12 @@ struct MediaIntentCriteriaFromTests {
|
|
|
57
57
|
mediaTypeMode: String? = nil,
|
|
58
58
|
reference: MediaIntentCriteria.Reference = .unknown,
|
|
59
59
|
hasMediaType: Bool = false,
|
|
60
|
-
appName: String? = "Radio Garden"
|
|
60
|
+
appName: String? = "Radio Garden",
|
|
61
61
|
) -> MediaIntentCriteria {
|
|
62
62
|
MediaIntentCriteria.from(
|
|
63
63
|
mediaName: mediaName, genreNames: genreNames, artistName: artistName,
|
|
64
64
|
albumName: albumName, mediaTypeMode: mediaTypeMode,
|
|
65
|
-
reference: reference, hasMediaType: hasMediaType, appName: appName
|
|
65
|
+
reference: reference, hasMediaType: hasMediaType, appName: appName,
|
|
66
66
|
)
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -123,8 +123,7 @@ struct ResolveAndLoadTests {
|
|
|
123
123
|
}
|
|
124
124
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
await Task.yield()
|
|
126
|
+
await loader.mediaResolverTask?.value
|
|
128
127
|
|
|
129
128
|
#expect(resolverCalled == true)
|
|
130
129
|
}
|
|
@@ -136,12 +135,9 @@ struct ResolveAndLoadTests {
|
|
|
136
135
|
}
|
|
137
136
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
138
137
|
|
|
139
|
-
// The
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
await Task.yield()
|
|
143
|
-
if !spy.playbackErrors.isEmpty { break }
|
|
144
|
-
}
|
|
138
|
+
// The task awaits the resolver then hops back to the main actor, so the
|
|
139
|
+
// delegate call has landed by the time the task itself completes.
|
|
140
|
+
await loader.mediaResolverTask?.value
|
|
145
141
|
|
|
146
142
|
#expect(spy.playbackErrors.count == 1)
|
|
147
143
|
if case let .invalidSourceUrl(url) = spy.playbackErrors.first {
|
|
@@ -166,8 +162,8 @@ struct ResolveAndLoadTests {
|
|
|
166
162
|
// Immediately start second — should cancel first
|
|
167
163
|
loader.resolveAndLoad(src: "https://example.com/second.mp3")
|
|
168
164
|
|
|
169
|
-
//
|
|
170
|
-
|
|
165
|
+
// Await the surviving task; the cancelled one bailed before its resolver.
|
|
166
|
+
await loader.mediaResolverTask?.value
|
|
171
167
|
|
|
172
168
|
// Only the second resolver should have run (first was cancelled before calling resolver)
|
|
173
169
|
#expect(callCount == 1)
|
|
@@ -188,10 +184,11 @@ struct CancelAllTests {
|
|
|
188
184
|
return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
189
185
|
}
|
|
190
186
|
loader.resolveAndLoad(src: "https://example.com/audio.mp3")
|
|
187
|
+
// cancelAll() drops the handle, so grab it first to await the point where
|
|
188
|
+
// the callback would have fired.
|
|
189
|
+
let resolve = loader.mediaResolverTask
|
|
191
190
|
loader.cancelAll()
|
|
192
|
-
|
|
193
|
-
// Wait for what would have been the callback
|
|
194
|
-
try? await Task.sleep(for: .milliseconds(100))
|
|
191
|
+
await resolve?.value
|
|
195
192
|
|
|
196
193
|
#expect(spy.preparedItems.isEmpty)
|
|
197
194
|
#expect(spy.playbackErrors.isEmpty)
|
|
@@ -120,7 +120,7 @@ struct TransitionTests {
|
|
|
120
120
|
@MainActor
|
|
121
121
|
private func makeRetrying() -> (
|
|
122
122
|
PlaybackCoordinator, MockRetryHandling, MockPlaybackCoordinatorCallbacks,
|
|
123
|
-
MockPlaybackEffectHandler
|
|
123
|
+
MockPlaybackEffectHandler,
|
|
124
124
|
) {
|
|
125
125
|
let retryHandler = MockRetryHandling()
|
|
126
126
|
retryHandler.isRetryableResult = true
|
|
@@ -743,7 +743,7 @@ struct NaturalEndPlayIntentTests {
|
|
|
743
743
|
@Suite("PlaybackCoordinator - healthy playback resets retry budget")
|
|
744
744
|
struct HealthyPlaybackRetryResetTests {
|
|
745
745
|
@Test @MainActor
|
|
746
|
-
func sustainedPlayback_resetsRetryBudget() async
|
|
746
|
+
func sustainedPlayback_resetsRetryBudget() async {
|
|
747
747
|
let retryHandler = MockRetryHandling()
|
|
748
748
|
let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
|
|
749
749
|
let coordinator = PlaybackCoordinator(
|
|
@@ -754,7 +754,7 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
754
754
|
coordinator.healthyPlaybackDuration = 0.01
|
|
755
755
|
|
|
756
756
|
coordinator.transition(.avPlayerPlaying)
|
|
757
|
-
|
|
757
|
+
await coordinator.healthyPlaybackTask?.value
|
|
758
758
|
|
|
759
759
|
#expect(retryHandler.resetCallCount == 1)
|
|
760
760
|
}
|
|
@@ -762,7 +762,7 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
762
762
|
/// resetRetry() cancels a pending retry — firing the refill mid-episode
|
|
763
763
|
/// would kill an in-flight reconnect silently (stuck .playing forever).
|
|
764
764
|
@Test @MainActor
|
|
765
|
-
func refill_skipsWhileRetryIsPending() async
|
|
765
|
+
func refill_skipsWhileRetryIsPending() async {
|
|
766
766
|
let retryHandler = MockRetryHandling()
|
|
767
767
|
retryHandler.isRetryableResult = true
|
|
768
768
|
retryHandler.attemptRetryDelayNs = 1_000_000_000
|
|
@@ -776,14 +776,14 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
776
776
|
|
|
777
777
|
coordinator.transition(.avPlayerPlaying)
|
|
778
778
|
errorHandler.handleError(URLError(.timedOut), context: .playback)
|
|
779
|
-
|
|
779
|
+
await coordinator.healthyPlaybackTask?.value
|
|
780
780
|
|
|
781
781
|
#expect(retryHandler.resetCallCount == 0)
|
|
782
782
|
#expect(errorHandler.pendingRetryTask != nil)
|
|
783
783
|
}
|
|
784
784
|
|
|
785
785
|
@Test @MainActor
|
|
786
|
-
func playbackInterruptedBeforeThreshold_doesNotReset() async
|
|
786
|
+
func playbackInterruptedBeforeThreshold_doesNotReset() async {
|
|
787
787
|
let retryHandler = MockRetryHandling()
|
|
788
788
|
let errorHandler = PlaybackErrorHandler(retryHandler: retryHandler)
|
|
789
789
|
let coordinator = PlaybackCoordinator(
|
|
@@ -795,9 +795,12 @@ struct HealthyPlaybackRetryResetTests {
|
|
|
795
795
|
coordinator.healthyPlaybackDuration = 0.5
|
|
796
796
|
|
|
797
797
|
coordinator.transition(.avPlayerPlaying)
|
|
798
|
+
let refill = coordinator.healthyPlaybackTask
|
|
798
799
|
coordinator.transition(.avPlayerWaiting) // stalls before the threshold
|
|
799
800
|
|
|
800
|
-
|
|
801
|
+
// Leaving .playing cancels the refill; awaiting the cancelled task is the
|
|
802
|
+
// deterministic way to reach the point where it would have fired.
|
|
803
|
+
await refill?.value
|
|
801
804
|
|
|
802
805
|
#expect(retryHandler.resetCallCount == 0)
|
|
803
806
|
}
|
|
@@ -14,9 +14,11 @@ private final class MockRetryHandler: RetryHandling {
|
|
|
14
14
|
var attemptRetryResult = false
|
|
15
15
|
var attemptRetryCallCount = 0
|
|
16
16
|
var lastStartFromCurrentTime: Bool?
|
|
17
|
+
var lastHTTPStatusCode: Int??
|
|
17
18
|
var resetCallCount = 0
|
|
18
19
|
|
|
19
|
-
func isRetryable(_ error: Error?) -> Bool {
|
|
20
|
+
func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
|
|
21
|
+
lastHTTPStatusCode = .some(httpStatusCode)
|
|
20
22
|
guard let error else { return false }
|
|
21
23
|
let code = (error as NSError).code
|
|
22
24
|
return retryableErrors.contains(code)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@testable import AudioBrowserTestable
|
|
5
|
+
|
|
6
|
+
@MainActor
|
|
7
|
+
@Suite("PlayerTimeObserver")
|
|
8
|
+
struct PlayerTimeObserverTests {
|
|
9
|
+
/// An AVPlayer retains its time observers, so one left registered outlives both this object and
|
|
10
|
+
/// the caller's interest in it (#96).
|
|
11
|
+
@Test func unregisteringDropsTheToken() {
|
|
12
|
+
// Held: `avPlayer` is weak, so a temporary is gone before the next line.
|
|
13
|
+
let player = AVPlayer()
|
|
14
|
+
let observer = PlayerTimeObserver(onAudioDidStart: {})
|
|
15
|
+
observer.avPlayer = player
|
|
16
|
+
|
|
17
|
+
observer.registerForBoundaryTimeEvents()
|
|
18
|
+
#expect(observer.boundaryTimeStartObserverToken != nil)
|
|
19
|
+
|
|
20
|
+
observer.unregisterForBoundaryTimeEvents()
|
|
21
|
+
#expect(observer.boundaryTimeStartObserverToken == nil)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Swapping players has to unregister from the outgoing one — it is a different AVPlayer, and
|
|
25
|
+
/// the token cannot be removed from it afterwards.
|
|
26
|
+
@Test func swappingPlayersUnregistersFromThePrevious() {
|
|
27
|
+
let first = AVPlayer()
|
|
28
|
+
let second = AVPlayer()
|
|
29
|
+
let observer = PlayerTimeObserver(onAudioDidStart: {})
|
|
30
|
+
observer.avPlayer = first
|
|
31
|
+
observer.registerForBoundaryTimeEvents()
|
|
32
|
+
|
|
33
|
+
observer.avPlayer = second
|
|
34
|
+
|
|
35
|
+
#expect(observer.boundaryTimeStartObserverToken == nil)
|
|
36
|
+
}
|
|
37
|
+
}
|