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
|
@@ -74,6 +74,52 @@ struct NextTests {
|
|
|
74
74
|
#expect(q.currentIndex == 0)
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/// The start track must be first in the shuffle order, not wherever the permutation put it —
|
|
78
|
+
/// otherwise auto-advance ends the queue as soon as it is reached (#96).
|
|
79
|
+
@Test func shuffle_startTrackLeadsTheOrder() {
|
|
80
|
+
for start in 0 ..< 6 {
|
|
81
|
+
let q = QueueManager()
|
|
82
|
+
q.setQueue(tracks("a", "b", "c", "d", "e", "f"), initialIndex: start)
|
|
83
|
+
q.shuffleEnabled = true
|
|
84
|
+
|
|
85
|
+
#expect(q.shuffleOrder.isFirst(start))
|
|
86
|
+
#expect(!q.isLastInPlaybackOrder)
|
|
87
|
+
#expect(q.nextTracks.count == 5)
|
|
88
|
+
#expect(q.previousTracks.isEmpty)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Same hazard on enabling shuffle mid-queue: the existing order has the current track wherever
|
|
93
|
+
/// it landed, so without re-pinning, turning shuffle on can immediately report end-of-queue.
|
|
94
|
+
@Test func shuffle_enablingMidQueueLeadsFromCurrent() {
|
|
95
|
+
for start in 0 ..< 6 {
|
|
96
|
+
let q = QueueManager()
|
|
97
|
+
q.setQueue(tracks("a", "b", "c", "d", "e", "f"))
|
|
98
|
+
_ = try? q.skipTo(start)
|
|
99
|
+
|
|
100
|
+
q.shuffleEnabled = true
|
|
101
|
+
|
|
102
|
+
#expect(q.shuffleOrder.isFirst(start))
|
|
103
|
+
#expect(!q.isLastInPlaybackOrder)
|
|
104
|
+
#expect(q.nextTracks.count == 5)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Re-pinning happens on enable only — flipping it off and on again must not renege on the
|
|
109
|
+
/// order mid-listen, and a redundant `= true` must not reshuffle under the listener.
|
|
110
|
+
@Test func shuffle_redundantEnableKeepsTheOrder() {
|
|
111
|
+
let q = QueueManager()
|
|
112
|
+
q.setQueue(tracks("a", "b", "c", "d", "e", "f"))
|
|
113
|
+
q.shuffleEnabled = true
|
|
114
|
+
let order = q.shuffleOrder.shuffled
|
|
115
|
+
|
|
116
|
+
q.shuffleEnabled = true
|
|
117
|
+
#expect(q.shuffleOrder.shuffled == order)
|
|
118
|
+
|
|
119
|
+
q.shuffleEnabled = false
|
|
120
|
+
#expect(q.shuffleOrder.shuffled == order)
|
|
121
|
+
}
|
|
122
|
+
|
|
77
123
|
@Test func shuffle_visitsAllTracks() {
|
|
78
124
|
let q = QueueManager()
|
|
79
125
|
q.setQueue(tracks("a", "b", "c", "d"))
|
|
@@ -767,13 +813,13 @@ struct ComputedPropertyTests {
|
|
|
767
813
|
let q = QueueManager()
|
|
768
814
|
q.setQueue(tracks("a", "b", "c", "d"))
|
|
769
815
|
q.shuffleEnabled = true
|
|
770
|
-
let nextFromProp = q.nextTracks.map
|
|
816
|
+
let nextFromProp = q.nextTracks.map { $0.id! }
|
|
771
817
|
// Navigate forward only as many times as nextTracks reports
|
|
772
818
|
// (nextTracks does not include wrapped tracks)
|
|
773
819
|
var nextFromNav: [String] = []
|
|
774
820
|
for _ in 0 ..< nextFromProp.count {
|
|
775
821
|
_ = q.next()
|
|
776
|
-
nextFromNav.append(q.currentTrack!.id)
|
|
822
|
+
nextFromNav.append(q.currentTrack!.id!)
|
|
777
823
|
}
|
|
778
824
|
#expect(nextFromProp == nextFromNav)
|
|
779
825
|
}
|
|
@@ -786,12 +832,12 @@ struct ComputedPropertyTests {
|
|
|
786
832
|
for _ in 0 ..< 3 {
|
|
787
833
|
_ = q.next()
|
|
788
834
|
}
|
|
789
|
-
let prevFromProp = q.previousTracks.map
|
|
835
|
+
let prevFromProp = q.previousTracks.map { $0.id! }
|
|
790
836
|
// Navigate backward the same number of times
|
|
791
837
|
var prevFromNav: [String] = []
|
|
792
838
|
for _ in 0 ..< prevFromProp.count {
|
|
793
839
|
_ = q.previous()
|
|
794
|
-
prevFromNav.insert(q.currentTrack!.id
|
|
840
|
+
prevFromNav.insert(q.currentTrack!.id!, at: 0)
|
|
795
841
|
}
|
|
796
842
|
#expect(prevFromProp == prevFromNav)
|
|
797
843
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import MediaPlayer
|
|
3
|
+
import Testing
|
|
4
|
+
|
|
5
|
+
@testable import AudioBrowserTestable
|
|
6
|
+
|
|
7
|
+
/// Exercises the controller against the real `MPRemoteCommandCenter`.
|
|
8
|
+
///
|
|
9
|
+
/// `MPRemoteCommandCenter`'s `init()` is unavailable, so `shared()` — a
|
|
10
|
+
/// process-wide singleton — is the only center available. The suite is
|
|
11
|
+
/// `.serialized` because of that shared state, and every test resets the
|
|
12
|
+
/// commands it touches through `reset()`.
|
|
13
|
+
@Suite("RemoteCommandController", .serialized)
|
|
14
|
+
@MainActor
|
|
15
|
+
struct RemoteCommandControllerTests {
|
|
16
|
+
final class SpyCallbacks: RemoteCommandCallbacks {
|
|
17
|
+
var events: [String] = []
|
|
18
|
+
func remotePlay() { events.append("play") }
|
|
19
|
+
func remotePause() { events.append("pause") }
|
|
20
|
+
func remoteStop() { events.append("stop") }
|
|
21
|
+
func remotePlayPause() { events.append("playPause") }
|
|
22
|
+
func remoteNext() { events.append("next") }
|
|
23
|
+
func remotePrevious() { events.append("previous") }
|
|
24
|
+
func remoteJumpForward(interval: Double) { events.append("jumpForward:\(interval)") }
|
|
25
|
+
func remoteJumpBackward(interval: Double) { events.append("jumpBackward:\(interval)") }
|
|
26
|
+
func remoteSeek(position: Double) { events.append("seek:\(position)") }
|
|
27
|
+
func remoteChangeRepeatMode(mode: RepeatMode) { events.append("repeat:\(mode)") }
|
|
28
|
+
func remoteChangeShuffleMode(enabled: Bool) { events.append("shuffle:\(enabled)") }
|
|
29
|
+
func remoteChangePlaybackRate(rate: Float) { events.append("rate:\(rate)") }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Clears every command on the shared center so a test starts from a known
|
|
33
|
+
/// state regardless of what ran before it.
|
|
34
|
+
private func makeController() -> (RemoteCommandController, SpyCallbacks) {
|
|
35
|
+
let center = MPRemoteCommandCenter.shared()
|
|
36
|
+
let reset = RemoteCommandController(remoteCommandCenter: center)
|
|
37
|
+
reset.enable(commands: RemoteCommand.all())
|
|
38
|
+
reset.disableAll()
|
|
39
|
+
|
|
40
|
+
let spy = SpyCallbacks()
|
|
41
|
+
return (RemoteCommandController(remoteCommandCenter: center, callbacks: spy), spy)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// The reported bug: a changed jump interval used to disable the very command
|
|
45
|
+
/// it had just re-enabled, removing the skip button from the lock screen,
|
|
46
|
+
/// Control Center and CarPlay while `enabledCommands` still reported it on.
|
|
47
|
+
@Test func changedJumpIntervalKeepsCommandEnabled() {
|
|
48
|
+
let (controller, _) = makeController()
|
|
49
|
+
let center = MPRemoteCommandCenter.shared()
|
|
50
|
+
|
|
51
|
+
controller.enable(commands: [
|
|
52
|
+
.play, .skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15]),
|
|
53
|
+
])
|
|
54
|
+
#expect(center.skipForwardCommand.isEnabled)
|
|
55
|
+
|
|
56
|
+
controller.enable(commands: [
|
|
57
|
+
.play, .skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30]),
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
#expect(center.skipForwardCommand.isEnabled)
|
|
61
|
+
#expect(center.skipBackwardCommand.isEnabled)
|
|
62
|
+
#expect(controller.commandTargetPointers["skipForward"] != nil)
|
|
63
|
+
#expect(controller.commandTargetPointers["skipBackward"] != nil)
|
|
64
|
+
#expect(center.skipForwardCommand.preferredIntervals == [30])
|
|
65
|
+
#expect(center.skipBackwardCommand.preferredIntervals == [30])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Test func changedPlaybackRatesKeepCommandEnabled() {
|
|
69
|
+
let (controller, _) = makeController()
|
|
70
|
+
let center = MPRemoteCommandCenter.shared()
|
|
71
|
+
|
|
72
|
+
controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0])])
|
|
73
|
+
controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])])
|
|
74
|
+
|
|
75
|
+
#expect(center.changePlaybackRateCommand.isEnabled)
|
|
76
|
+
#expect(controller.commandTargetPointers["changePlaybackRate"] != nil)
|
|
77
|
+
#expect(center.changePlaybackRateCommand.supportedPlaybackRates == [1.0, 1.5, 2.0])
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Test func removedCommandIsDisabledAndUntargeted() {
|
|
81
|
+
let (controller, _) = makeController()
|
|
82
|
+
let center = MPRemoteCommandCenter.shared()
|
|
83
|
+
|
|
84
|
+
controller.enable(commands: [.play, .pause, .next])
|
|
85
|
+
#expect(center.nextTrackCommand.isEnabled)
|
|
86
|
+
|
|
87
|
+
controller.enable(commands: [.play, .pause])
|
|
88
|
+
|
|
89
|
+
#expect(!center.nextTrackCommand.isEnabled)
|
|
90
|
+
#expect(controller.commandTargetPointers["nextTrack"] == nil)
|
|
91
|
+
#expect(center.playCommand.isEnabled)
|
|
92
|
+
#expect(controller.commandTargetPointers["play"] != nil)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@Test func disableAllClearsEveryTarget() {
|
|
96
|
+
let (controller, _) = makeController()
|
|
97
|
+
let center = MPRemoteCommandCenter.shared()
|
|
98
|
+
|
|
99
|
+
controller.enable(commands: [.play, .pause, .skipForward(preferredIntervals: [15])])
|
|
100
|
+
controller.disableAll()
|
|
101
|
+
|
|
102
|
+
#expect(controller.commandTargetPointers.isEmpty)
|
|
103
|
+
#expect(!center.playCommand.isEnabled)
|
|
104
|
+
#expect(!center.pauseCommand.isEnabled)
|
|
105
|
+
#expect(!center.skipForwardCommand.isEnabled)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Re-enabling the same set must not accumulate handlers: `enableRemoteCommand`
|
|
109
|
+
/// removes the previous target before adding the new one.
|
|
110
|
+
@Test func reEnablingKeepsOneTargetPerCommand() {
|
|
111
|
+
let (controller, _) = makeController()
|
|
112
|
+
|
|
113
|
+
controller.enable(commands: [.play, .pause])
|
|
114
|
+
let first = controller.commandTargetPointers["play"]
|
|
115
|
+
controller.enable(commands: [.play, .pause])
|
|
116
|
+
let second = controller.commandTargetPointers["play"]
|
|
117
|
+
|
|
118
|
+
#expect(controller.commandTargetPointers.count == 2)
|
|
119
|
+
#expect(first as AnyObject? !== second as AnyObject?)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// `setSkipAvailability` greys out next/previous when the queue has nowhere to
|
|
123
|
+
/// go, and the state must survive a later `enable(commands:)`.
|
|
124
|
+
@Test func skipAvailabilitySurvivesReEnable() {
|
|
125
|
+
let (controller, _) = makeController()
|
|
126
|
+
let center = MPRemoteCommandCenter.shared()
|
|
127
|
+
|
|
128
|
+
controller.enable(commands: [.next, .previous])
|
|
129
|
+
controller.setSkipAvailability(canNext: false, canPrevious: true)
|
|
130
|
+
#expect(!center.nextTrackCommand.isEnabled)
|
|
131
|
+
#expect(center.previousTrackCommand.isEnabled)
|
|
132
|
+
|
|
133
|
+
controller.enable(commands: [.next, .previous, .play])
|
|
134
|
+
|
|
135
|
+
#expect(!center.nextTrackCommand.isEnabled)
|
|
136
|
+
#expect(center.previousTrackCommand.isEnabled)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@Test func shuffleAndRepeatStateSurviveCommandCenterSwitch() {
|
|
140
|
+
let (controller, _) = makeController()
|
|
141
|
+
let center = MPRemoteCommandCenter.shared()
|
|
142
|
+
|
|
143
|
+
controller.enable(commands: [.changeShuffleMode, .changeRepeatMode])
|
|
144
|
+
controller.updateShuffleMode(true)
|
|
145
|
+
controller.updateRepeatMode(.queue)
|
|
146
|
+
|
|
147
|
+
// Same center — switchCommandCenter no-ops, but the restore path is the
|
|
148
|
+
// interesting part: state is re-applied from the stored values.
|
|
149
|
+
controller.switchCommandCenter(center)
|
|
150
|
+
|
|
151
|
+
#expect(center.changeShuffleModeCommand.currentShuffleType == .items)
|
|
152
|
+
#expect(center.changeRepeatModeCommand.currentRepeatType == .all)
|
|
153
|
+
#expect(center.changeShuffleModeCommand.isEnabled)
|
|
154
|
+
#expect(controller.commandTargetPointers["changeShuffleMode"] != nil)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@testable import AudioBrowserTestable
|
|
5
|
+
|
|
6
|
+
@Suite("RemoteCommand")
|
|
7
|
+
struct RemoteCommandTests {
|
|
8
|
+
@Test func removedCommandsAreDisabled() {
|
|
9
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
10
|
+
enabled: [.play, .pause, .next, .previous],
|
|
11
|
+
replacedBy: [.play, .pause],
|
|
12
|
+
)
|
|
13
|
+
#expect(toDisable.map(\.key) == ["nextTrack", "previousTrack"])
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Test func retainedCommandsAreNotDisabled() {
|
|
17
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
18
|
+
enabled: [.play, .pause],
|
|
19
|
+
replacedBy: [.play, .pause, .stop],
|
|
20
|
+
)
|
|
21
|
+
#expect(toDisable.isEmpty)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// A changed jump interval is the same command with a new associated value —
|
|
25
|
+
/// diffing by `==` would report it as removed and disable the button that the
|
|
26
|
+
/// update had just re-enabled.
|
|
27
|
+
@Test func changedJumpIntervalIsNotDisabled() {
|
|
28
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
29
|
+
enabled: [.skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15])],
|
|
30
|
+
replacedBy: [.skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30])],
|
|
31
|
+
)
|
|
32
|
+
#expect(toDisable.isEmpty)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test func changedPlaybackRatesAreNotDisabled() {
|
|
36
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
37
|
+
enabled: [.changePlaybackRate(supportedPlaybackRates: [1.0])],
|
|
38
|
+
replacedBy: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])],
|
|
39
|
+
)
|
|
40
|
+
#expect(toDisable.isEmpty)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@Test func everyCommandIsDisabledWhenNoneRemain() {
|
|
44
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
45
|
+
enabled: RemoteCommand.all(),
|
|
46
|
+
replacedBy: [],
|
|
47
|
+
)
|
|
48
|
+
#expect(toDisable.count == RemoteCommand.all().count)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/// `key` is the identity used both for the diff above and for the
|
|
52
|
+
/// `commandTargetPointers` slot each command's handler lives in. Two cases
|
|
53
|
+
/// sharing a key would silently share one slot: enabling one would remove the
|
|
54
|
+
/// other's handler.
|
|
55
|
+
@Test func keysAreUnique() {
|
|
56
|
+
let all = RemoteCommand.all()
|
|
57
|
+
#expect(Set(all.map(\.key)).count == all.count)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Test func droppedJumpCommandIsStillDisabled() {
|
|
61
|
+
let toDisable = RemoteCommand.commandsToDisable(
|
|
62
|
+
enabled: [.play, .skipForward(preferredIntervals: [15])],
|
|
63
|
+
replacedBy: [.play],
|
|
64
|
+
)
|
|
65
|
+
#expect(toDisable.map(\.key) == ["skipForward"])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import AVFoundation
|
|
1
2
|
import Foundation
|
|
2
3
|
import Testing
|
|
3
4
|
|
|
@@ -87,3 +88,91 @@ struct RetryManagerBudgetTests {
|
|
|
87
88
|
#expect(manager.hasPlayed == true)
|
|
88
89
|
}
|
|
89
90
|
}
|
|
91
|
+
|
|
92
|
+
/// Which failures earn another attempt. The reference is Android's
|
|
93
|
+
/// `RetryLoadErrorHandlingPolicy.classifyError`: same HTTP set, and the same
|
|
94
|
+
/// default of treating an unrecognized failure as transient. Defaulting the
|
|
95
|
+
/// other way made `retry: true` largely inert on iOS, since AVFoundation
|
|
96
|
+
/// reports most stream failures as opaque CoreMedia errors, not `URLError`.
|
|
97
|
+
@Suite("RetryManager error classification")
|
|
98
|
+
@MainActor
|
|
99
|
+
struct RetryManagerClassificationTests {
|
|
100
|
+
private func makeManager() -> RetryManager {
|
|
101
|
+
let manager = RetryManager()
|
|
102
|
+
manager.updatePolicy(from: .first(true))
|
|
103
|
+
return manager
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// `AVPlayerItem.error` never carries the status, so the caller reads it off
|
|
107
|
+
/// the item's error log and passes it in. It outranks the opaque error.
|
|
108
|
+
@Test(arguments: [408, 429, 500, 502, 503, 504])
|
|
109
|
+
func retryableHTTPStatus_retries(status: Int) {
|
|
110
|
+
let opaque = NSError(domain: "CoreMediaErrorDomain", code: -12660)
|
|
111
|
+
#expect(makeManager().isRetryable(opaque, httpStatusCode: status) == true)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// A retry cannot conjure content that isn't there, whatever the error says.
|
|
115
|
+
@Test(arguments: [400, 401, 403, 404, 410, 451])
|
|
116
|
+
func permanentHTTPStatus_doesNotRetry(status: Int) {
|
|
117
|
+
let transient = URLError(.timedOut)
|
|
118
|
+
#expect(makeManager().isRetryable(transient, httpStatusCode: status) == false)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
@Test func transientURLError_retries() {
|
|
122
|
+
#expect(makeManager().isRetryable(URLError(.networkConnectionLost), httpStatusCode: nil) == true)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test func fatalURLError_doesNotRetry() {
|
|
126
|
+
#expect(makeManager().isRetryable(URLError(.badURL), httpStatusCode: nil) == false)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/// The regression this suite exists for: an opaque CoreMedia failure with no
|
|
130
|
+
/// status to read used to be terminal, so a station that dropped mid-song
|
|
131
|
+
/// never came back on iOS while Android reconnected.
|
|
132
|
+
@Test func opaqueCoreMediaError_retries() {
|
|
133
|
+
let error = NSError(domain: "CoreMediaErrorDomain", code: -12939)
|
|
134
|
+
#expect(makeManager().isRetryable(error, httpStatusCode: nil) == true)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// `AVErrorUnknown` is the wrapper AVFoundation puts around transport
|
|
138
|
+
/// failures, so it must stay retryable even though it is an AVError.
|
|
139
|
+
@Test func avErrorUnknown_retries() {
|
|
140
|
+
let error = NSError(domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue)
|
|
141
|
+
#expect(makeManager().isRetryable(error, httpStatusCode: nil) == true)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@Test(arguments: [
|
|
145
|
+
AVError.fileFormatNotRecognized.rawValue,
|
|
146
|
+
AVError.failedToParse.rawValue,
|
|
147
|
+
AVError.decodeFailed.rawValue,
|
|
148
|
+
])
|
|
149
|
+
func unusableMedia_doesNotRetry(code: Int) {
|
|
150
|
+
let error = NSError(domain: AVFoundationErrorDomain, code: code)
|
|
151
|
+
#expect(makeManager().isRetryable(error, httpStatusCode: nil) == false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/// AVFoundation wraps the real cause rather than surfacing it, which is why
|
|
155
|
+
/// the classifier walks `NSUnderlyingError` instead of inspecting only the
|
|
156
|
+
/// outermost error.
|
|
157
|
+
@Test func underlyingURLError_isFound() {
|
|
158
|
+
let manager = makeManager()
|
|
159
|
+
let wrapped = NSError(
|
|
160
|
+
domain: AVFoundationErrorDomain,
|
|
161
|
+
code: AVError.unknown.rawValue,
|
|
162
|
+
userInfo: [NSUnderlyingErrorKey: URLError(.badURL)],
|
|
163
|
+
)
|
|
164
|
+
#expect(manager.isRetryable(wrapped, httpStatusCode: nil) == false)
|
|
165
|
+
|
|
166
|
+
let transient = NSError(
|
|
167
|
+
domain: AVFoundationErrorDomain,
|
|
168
|
+
code: AVError.unknown.rawValue,
|
|
169
|
+
userInfo: [NSUnderlyingErrorKey: URLError(.dnsLookupFailed)],
|
|
170
|
+
)
|
|
171
|
+
#expect(manager.isRetryable(transient, httpStatusCode: nil) == true)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Nothing to classify: no error and no status stays terminal.
|
|
175
|
+
@Test func nilError_doesNotRetry() {
|
|
176
|
+
#expect(makeManager().isRetryable(nil, httpStatusCode: nil) == false)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -7,12 +7,12 @@ struct SectionScopeTests {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
private func runSrcs(_ section: SectionScope.Section?) -> [String?]? {
|
|
10
|
-
guard case .run(
|
|
10
|
+
guard case let .run(tracks) = section else { return nil }
|
|
11
11
|
return tracks.map(\.src)
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
private func imageRowSrcs(_ section: SectionScope.Section?) -> [String?]? {
|
|
15
|
-
guard case .imageRow(
|
|
15
|
+
guard case let .imageRow(items) = section else { return nil }
|
|
16
16
|
return items.map(\.src)
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
import Foundation
|
|
3
3
|
import Testing
|
|
4
4
|
|
|
5
|
-
// Timing tests
|
|
6
|
-
//
|
|
7
|
-
// run the manager's scheduled jobs.
|
|
5
|
+
// Timing tests drive a ManualSleepTimerScheduler rather than the main queue:
|
|
6
|
+
// every deadline below is virtual, so nothing races the real clock.
|
|
8
7
|
|
|
9
8
|
@Suite("SleepTimerManager - fade scheduling")
|
|
10
9
|
struct SleepTimerManagerTests {
|
|
11
10
|
@Test @MainActor
|
|
12
|
-
func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel()
|
|
13
|
-
let
|
|
11
|
+
func fadeFiresBeforeDeadline_completionDoesNotEmitFadeCancel() {
|
|
12
|
+
let clock = ManualSleepTimerScheduler()
|
|
13
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
14
14
|
var fadeStarts: [TimeInterval] = []
|
|
15
15
|
var completes = 0
|
|
16
16
|
var cancels = 0
|
|
@@ -20,35 +20,37 @@ struct SleepTimerManagerTests {
|
|
|
20
20
|
// Attach after set() — replacing a timer emits a defensive onFadeCancel.
|
|
21
21
|
manager.onFadeCancel = { cancels += 1 }
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
clock.advance(to: 0.05) // before the fade window
|
|
24
24
|
#expect(fadeStarts.isEmpty)
|
|
25
25
|
#expect(manager.get() != nil)
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
clock.advance(to: 0.22) // inside the fade window
|
|
28
28
|
#expect(fadeStarts == [0.15])
|
|
29
29
|
#expect(completes == 0)
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
clock.advance(to: 0.4) // past the deadline
|
|
32
32
|
#expect(completes == 1)
|
|
33
33
|
#expect(cancels == 0)
|
|
34
34
|
#expect(manager.get() == nil)
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
@Test @MainActor
|
|
38
|
-
func fadeLongerThanTimer_clampsAndStartsImmediately()
|
|
39
|
-
let
|
|
38
|
+
func fadeLongerThanTimer_clampsAndStartsImmediately() {
|
|
39
|
+
let clock = ManualSleepTimerScheduler()
|
|
40
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
40
41
|
var fadeStarts: [TimeInterval] = []
|
|
41
42
|
manager.onFadeStart = { fadeStarts.append($0) }
|
|
42
43
|
|
|
43
44
|
manager.set(seconds: 0.2, fadeDuration: 5)
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
clock.advance(to: 0.1) // fade should have begun
|
|
46
47
|
#expect(fadeStarts == [0.2])
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
@Test @MainActor
|
|
50
|
-
func clear_emitsFadeCancel_andCancelsScheduledFade()
|
|
51
|
-
let
|
|
51
|
+
func clear_emitsFadeCancel_andCancelsScheduledFade() {
|
|
52
|
+
let clock = ManualSleepTimerScheduler()
|
|
53
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
52
54
|
var fadeStarts = 0
|
|
53
55
|
manager.set(seconds: 0.2, fadeDuration: 0.15)
|
|
54
56
|
var cancels = 0
|
|
@@ -58,13 +60,13 @@ struct SleepTimerManagerTests {
|
|
|
58
60
|
#expect(manager.clear())
|
|
59
61
|
#expect(cancels == 1)
|
|
60
62
|
|
|
61
|
-
|
|
63
|
+
clock.advance(to: 0.3) // well past the would-be deadline
|
|
62
64
|
#expect(fadeStarts == 0)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
@Test @MainActor
|
|
66
68
|
func replacingTimer_emitsFadeCancel() {
|
|
67
|
-
let manager = SleepTimerManager()
|
|
69
|
+
let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
|
|
68
70
|
manager.set(seconds: 60, fadeDuration: 10)
|
|
69
71
|
var cancels = 0
|
|
70
72
|
manager.onFadeCancel = { cancels += 1 }
|
|
@@ -76,7 +78,7 @@ struct SleepTimerManagerTests {
|
|
|
76
78
|
|
|
77
79
|
@Test @MainActor
|
|
78
80
|
func switchingToEndOfTrack_emitsFadeCancel() {
|
|
79
|
-
let manager = SleepTimerManager()
|
|
81
|
+
let manager = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
|
|
80
82
|
manager.set(seconds: 60, fadeDuration: 10)
|
|
81
83
|
var cancels = 0
|
|
82
84
|
manager.onFadeCancel = { cancels += 1 }
|
|
@@ -88,8 +90,9 @@ struct SleepTimerManagerTests {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
@Test @MainActor
|
|
91
|
-
func noFadeDuration_schedulesNoFade()
|
|
92
|
-
let
|
|
93
|
+
func noFadeDuration_schedulesNoFade() {
|
|
94
|
+
let clock = ManualSleepTimerScheduler()
|
|
95
|
+
let manager = SleepTimerManager(scheduler: clock)
|
|
93
96
|
var fadeStarts = 0
|
|
94
97
|
var completes = 0
|
|
95
98
|
manager.onFadeStart = { _ in fadeStarts += 1 }
|
|
@@ -97,7 +100,7 @@ struct SleepTimerManagerTests {
|
|
|
97
100
|
|
|
98
101
|
manager.set(seconds: 0.1)
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
clock.advance(to: 0.2)
|
|
101
104
|
#expect(fadeStarts == 0)
|
|
102
105
|
#expect(completes == 1)
|
|
103
106
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// A job handed back by `ManualSleepTimerScheduler`.
|
|
5
|
+
private final class ManualJob: SleepTimerJob {
|
|
6
|
+
nonisolated(unsafe) var isCancelled = false
|
|
7
|
+
func cancel() { isCancelled = true }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// Deterministic stand-in for the main-queue scheduler: jobs fire only when the
|
|
11
|
+
/// test advances the clock, so an assertion can never lose a race against a
|
|
12
|
+
/// real deadline on a loaded CI runner.
|
|
13
|
+
@MainActor
|
|
14
|
+
final class ManualSleepTimerScheduler: SleepTimerScheduling {
|
|
15
|
+
private struct Entry {
|
|
16
|
+
let deadline: TimeInterval
|
|
17
|
+
let job: ManualJob
|
|
18
|
+
let work: @MainActor () -> Void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private var entries: [Entry] = []
|
|
22
|
+
private(set) var now: TimeInterval = 0
|
|
23
|
+
|
|
24
|
+
func schedule(
|
|
25
|
+
after delay: TimeInterval,
|
|
26
|
+
_ work: @escaping @Sendable @MainActor () -> Void,
|
|
27
|
+
) -> any SleepTimerJob {
|
|
28
|
+
let job = ManualJob()
|
|
29
|
+
entries.append(Entry(deadline: now + delay, job: job, work: work))
|
|
30
|
+
return job
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Moves the virtual clock to `time`, firing every due job in deadline order.
|
|
34
|
+
/// Jobs run one at a time so a job that schedules or cancels another (the
|
|
35
|
+
/// fade landing before completion) sees the same ordering it would in
|
|
36
|
+
/// production.
|
|
37
|
+
func advance(to time: TimeInterval) {
|
|
38
|
+
now = time
|
|
39
|
+
while let next = entries
|
|
40
|
+
.filter({ !$0.job.isCancelled && $0.deadline <= now })
|
|
41
|
+
.min(by: { $0.deadline < $1.deadline })
|
|
42
|
+
{
|
|
43
|
+
entries.removeAll { $0.job === next.job }
|
|
44
|
+
next.work()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -209,7 +209,7 @@ final class MockRetryHandling: RetryHandling {
|
|
|
209
209
|
var attemptRetryDelayNs: UInt64 = 0
|
|
210
210
|
var resetCallCount = 0
|
|
211
211
|
|
|
212
|
-
func isRetryable(_: Error?) -> Bool { isRetryableResult }
|
|
212
|
+
func isRetryable(_: Error?, httpStatusCode _: Int?) -> Bool { isRetryableResult }
|
|
213
213
|
func attemptRetry(startFromCurrentTime _: Bool) async -> Bool {
|
|
214
214
|
if attemptRetryDelayNs > 0 {
|
|
215
215
|
try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
|
|
@@ -5,11 +5,11 @@ import Testing
|
|
|
5
5
|
struct TabBarEntriesTests {
|
|
6
6
|
private func tab(
|
|
7
7
|
_ title: String, url: String, artwork: String? = nil,
|
|
8
|
-
artworkSource: ImageSource? = nil, artist: String? = nil
|
|
8
|
+
artworkSource: ImageSource? = nil, artist: String? = nil,
|
|
9
9
|
) -> Track {
|
|
10
10
|
Track(
|
|
11
11
|
id: url, url: url, title: title, artist: artist, artwork: artwork.map { .first($0) },
|
|
12
|
-
artworkSource: artworkSource
|
|
12
|
+
artworkSource: artworkSource,
|
|
13
13
|
)
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -22,7 +22,7 @@ struct TabBarEntriesTests {
|
|
|
22
22
|
#expect(
|
|
23
23
|
!TabBarEntries.same(
|
|
24
24
|
[tab("Explore", url: "/explore")],
|
|
25
|
-
[tab("Verkennen", url: "/explore")]
|
|
25
|
+
[tab("Verkennen", url: "/explore")],
|
|
26
26
|
))
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -30,7 +30,7 @@ struct TabBarEntriesTests {
|
|
|
30
30
|
#expect(
|
|
31
31
|
!TabBarEntries.same(
|
|
32
32
|
[tab("Explore", url: "/explore")],
|
|
33
|
-
[tab("Explore", url: "/discover")]
|
|
33
|
+
[tab("Explore", url: "/discover")],
|
|
34
34
|
))
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -38,7 +38,7 @@ struct TabBarEntriesTests {
|
|
|
38
38
|
#expect(
|
|
39
39
|
!TabBarEntries.same(
|
|
40
40
|
[tab("Explore", url: "/explore", artwork: "sf:globe")],
|
|
41
|
-
[tab("Explore", url: "/explore", artwork: "sf:map")]
|
|
41
|
+
[tab("Explore", url: "/explore", artwork: "sf:map")],
|
|
42
42
|
))
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -46,7 +46,7 @@ struct TabBarEntriesTests {
|
|
|
46
46
|
#expect(
|
|
47
47
|
!TabBarEntries.same(
|
|
48
48
|
[tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/1.png"))],
|
|
49
|
-
[tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/2.png"))]
|
|
49
|
+
[tab("Explore", url: "/explore", artworkSource: ImageSource(uri: "https://a/2.png"))],
|
|
50
50
|
))
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -65,7 +65,7 @@ struct TabBarEntriesTests {
|
|
|
65
65
|
#expect(
|
|
66
66
|
TabBarEntries.same(
|
|
67
67
|
[tab("Explore", url: "/explore", artist: "a")],
|
|
68
|
-
[tab("Explore", url: "/explore", artist: "b")]
|
|
68
|
+
[tab("Explore", url: "/explore", artist: "b")],
|
|
69
69
|
))
|
|
70
70
|
}
|
|
71
71
|
}
|
package/ios/TrackPlayer.swift
CHANGED
|
@@ -463,6 +463,11 @@ class TrackPlayer {
|
|
|
463
463
|
|
|
464
464
|
func destroy() {
|
|
465
465
|
clear()
|
|
466
|
+
// An AVPlayer retains its time observers: one left registered is still attached when the
|
|
467
|
+
// player is released, and outlives this object.
|
|
468
|
+
playerTimeObserver.unregisterForBoundaryTimeEvents()
|
|
469
|
+
playerObserver.stopObserving()
|
|
470
|
+
stopObservingAVPlayerItem()
|
|
466
471
|
remoteCommandController.disableAll()
|
|
467
472
|
}
|
|
468
473
|
|
|
@@ -753,7 +758,7 @@ extension TrackPlayer {
|
|
|
753
758
|
repeatMode: repeatMode.persistedString,
|
|
754
759
|
shuffleEnabled: shuffleEnabled,
|
|
755
760
|
playbackSpeed: rate,
|
|
756
|
-
)
|
|
761
|
+
),
|
|
757
762
|
)
|
|
758
763
|
}
|
|
759
764
|
|