react-native-audio-browser 0.2.0-next.1 → 0.2.0-next.10
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 +7 -5
- package/android/gradle.properties +1 -1
- 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 +21 -6
- package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +109 -0
- package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +16 -2
- package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +59 -3
- 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 +0 -1
- 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 +46 -0
- package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +113 -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/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 +46 -0
- 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 +10 -3
- 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/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
|
@@ -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
|
|
|
@@ -9,10 +9,11 @@ import NitroModules
|
|
|
9
9
|
/// Note: Method names are prefixed to avoid conflicts with JS callback property names.
|
|
10
10
|
/// All callbacks are called from the main actor since TrackPlayer is @MainActor.
|
|
11
11
|
///
|
|
12
|
-
/// Extends PlaybackCoordinatorCallbacks for the state machine / coordinator callbacks
|
|
13
|
-
///
|
|
12
|
+
/// Extends PlaybackCoordinatorCallbacks for the state machine / coordinator callbacks
|
|
13
|
+
/// and RemoteCommandCallbacks for the remote-command events, then adds metadata,
|
|
14
|
+
/// seek, and configuration callbacks that are TrackPlayer-specific.
|
|
14
15
|
@MainActor
|
|
15
|
-
protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
|
|
16
|
+
protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks, RemoteCommandCallbacks {
|
|
16
17
|
// MARK: - Metadata Events
|
|
17
18
|
|
|
18
19
|
/// Called when common metadata is received.
|
|
@@ -34,32 +35,7 @@ protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
34
35
|
|
|
35
36
|
// MARK: - Remote Control Events
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
func remotePlay()
|
|
39
|
-
|
|
40
|
-
/// Called when pause is triggered remotely.
|
|
41
|
-
func remotePause()
|
|
42
|
-
|
|
43
|
-
/// Called when stop is triggered remotely.
|
|
44
|
-
func remoteStop()
|
|
45
|
-
|
|
46
|
-
/// Called when toggle play/pause is triggered remotely.
|
|
47
|
-
func remotePlayPause()
|
|
48
|
-
|
|
49
|
-
/// Called when next is triggered remotely.
|
|
50
|
-
func remoteNext()
|
|
51
|
-
|
|
52
|
-
/// Called when previous is triggered remotely.
|
|
53
|
-
func remotePrevious()
|
|
54
|
-
|
|
55
|
-
/// Called when jump forward is triggered remotely.
|
|
56
|
-
func remoteJumpForward(interval: Double)
|
|
57
|
-
|
|
58
|
-
/// Called when jump backward is triggered remotely.
|
|
59
|
-
func remoteJumpBackward(interval: Double)
|
|
60
|
-
|
|
61
|
-
/// Called when seek is triggered remotely.
|
|
62
|
-
func remoteSeek(position: Double)
|
|
38
|
+
// The command-center events live in RemoteCommandCallbacks, inherited above.
|
|
63
39
|
|
|
64
40
|
/// Called when change playback position is triggered remotely.
|
|
65
41
|
func remoteChangePlaybackPosition(position: Double)
|
|
@@ -70,15 +46,6 @@ protocol TrackPlayerCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
70
46
|
/// Called when play from search is triggered remotely.
|
|
71
47
|
func remotePlaySearch(query: String)
|
|
72
48
|
|
|
73
|
-
/// Called when repeat mode change is triggered remotely (CarPlay/lock screen).
|
|
74
|
-
func remoteChangeRepeatMode(mode: RepeatMode)
|
|
75
|
-
|
|
76
|
-
/// Called when shuffle mode change is triggered remotely (CarPlay/lock screen).
|
|
77
|
-
func remoteChangeShuffleMode(enabled: Bool)
|
|
78
|
-
|
|
79
|
-
/// Called when playback rate change is triggered remotely (CarPlay/lock screen).
|
|
80
|
-
func remoteChangePlaybackRate(rate: Float)
|
|
81
|
-
|
|
82
49
|
// MARK: - Configuration Events
|
|
83
50
|
|
|
84
51
|
/// Called when options are changed.
|
package/ios/Util/LRUCache.swift
CHANGED
|
@@ -11,7 +11,10 @@ final class LRUCache<Key: Hashable, Value> {
|
|
|
11
11
|
private final class Node {
|
|
12
12
|
let key: Key
|
|
13
13
|
var value: Value
|
|
14
|
-
|
|
14
|
+
/// Weak: with both directions strong, any two adjacent nodes form a reference cycle, so the
|
|
15
|
+
/// whole chain outlives `clear()` and the cache's own deinit. The list is kept alive from
|
|
16
|
+
/// `head` forwards.
|
|
17
|
+
weak var prev: Node?
|
|
15
18
|
var next: Node?
|
|
16
19
|
|
|
17
20
|
init(key: Key, value: Value) {
|