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.
Files changed (290) hide show
  1. package/README.md +22 -18
  2. package/android/build.gradle +13 -10
  3. package/android/gradle.properties +2 -2
  4. package/android/src/main/AndroidManifest.xml +3 -1
  5. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +76 -21
  6. package/android/src/main/java/com/audiobrowser/AudioBrowserLifecycleModule.kt +38 -0
  7. package/android/src/main/java/com/audiobrowser/AudioBrowserPackage.kt +18 -2
  8. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +48 -0
  9. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +1 -2
  10. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +1 -2
  11. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +6 -7
  12. package/android/src/main/java/com/audiobrowser/http/HttpClient.kt +20 -3
  13. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +11 -11
  14. package/android/src/main/java/com/audiobrowser/player/CapabilityControls.kt +111 -58
  15. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +9 -5
  16. package/android/src/main/java/com/audiobrowser/player/MediaSessionCommandManager.kt +218 -252
  17. package/android/src/main/java/com/audiobrowser/player/NowPlayingUpdater.kt +0 -1
  18. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +2 -4
  19. package/android/src/main/java/com/audiobrowser/player/Player.kt +30 -8
  20. package/android/src/main/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicy.kt +2 -1
  21. package/android/src/main/java/com/audiobrowser/player/TransformingDataSource.kt +2 -1
  22. package/android/src/main/java/com/audiobrowser/tls/AiaCertChaser.kt +67 -11
  23. package/android/src/main/java/com/audiobrowser/tls/AiaChasingExtendedTrustManager.kt +168 -0
  24. package/android/src/main/java/com/audiobrowser/tls/AiaChasingTrustManager.kt +58 -14
  25. package/android/src/main/java/com/audiobrowser/tls/AiaTls.kt +35 -7
  26. package/android/src/main/java/com/audiobrowser/tls/CachingCertificateFetcher.kt +278 -59
  27. package/android/src/main/java/com/audiobrowser/util/ArtworkVariant.kt +7 -9
  28. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -7
  29. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +87 -0
  30. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +52 -1
  31. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +6 -0
  32. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +167 -8
  33. package/android/src/test/java/com/audiobrowser/browser/JsonArtworkTest.kt +2 -1
  34. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +2 -4
  35. package/android/src/test/java/com/audiobrowser/player/CapabilityControlsTest.kt +100 -56
  36. package/android/src/test/java/com/audiobrowser/player/MediaSessionSlotMappingTest.kt +45 -0
  37. package/android/src/test/java/com/audiobrowser/player/PlaybackErrorClassifierTest.kt +3 -4
  38. package/android/src/test/java/com/audiobrowser/player/RetryLoadErrorHandlingPolicyTest.kt +3 -3
  39. package/android/src/test/java/com/audiobrowser/tls/AiaCertChaserTest.kt +197 -7
  40. package/android/src/test/java/com/audiobrowser/tls/AiaChasingTrustManagerTest.kt +325 -4
  41. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherCallSiteTest.kt +322 -0
  42. package/android/src/test/java/com/audiobrowser/tls/CachingCertificateFetcherTest.kt +83 -0
  43. package/ios/Browser/BrowserManager.swift +2 -27
  44. package/ios/Browser/SearchDrillIn.swift +1 -1
  45. package/ios/CarPlay/RNABMediaAddHandler.swift +6 -6
  46. package/ios/CarPlay/RNABMediaAffinityHandler.swift +2 -2
  47. package/ios/CarPlay/RNABMediaIntentHandler.swift +16 -16
  48. package/ios/HybridAudioBrowser.swift +87 -56
  49. package/ios/Model/NitroTypeStubs.swift +18 -2
  50. package/ios/Model/RemoteCommand.swift +18 -0
  51. package/ios/Model/TrackPlayerError.swift +2 -2
  52. package/ios/Option/SessionCategories.swift +3 -2
  53. package/ios/Player/MediaIntentCriteria.swift +4 -4
  54. package/ios/Player/MediaLoader.swift +3 -1
  55. package/ios/Player/PlaybackCoordinator.swift +4 -1
  56. package/ios/Player/PlaybackErrorHandler.swift +4 -4
  57. package/ios/Player/QueueManager.swift +10 -3
  58. package/ios/Player/RetryManager.swift +79 -18
  59. package/ios/Player/ShuffleOrder.swift +10 -0
  60. package/ios/Player/SleepTimerManager.swift +43 -15
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +47 -0
  62. package/ios/RemoteCommand/RemoteCommandController.swift +9 -6
  63. package/ios/Tests/LRUCacheTests.swift +111 -0
  64. package/ios/Tests/LoadSeekCoordinatorTests.swift +3 -1
  65. package/ios/Tests/MediaIntentCriteriaTests.swift +2 -2
  66. package/ios/Tests/MediaLoaderTests.swift +10 -13
  67. package/ios/Tests/PlaybackCoordinatorTests.swift +10 -7
  68. package/ios/Tests/PlaybackErrorHandlerTests.swift +3 -1
  69. package/ios/Tests/PlayerTimeObserverTests.swift +37 -0
  70. package/ios/Tests/QueueManagerTests.swift +50 -4
  71. package/ios/Tests/RemoteCommandControllerTests.swift +156 -0
  72. package/ios/Tests/RemoteCommandTests.swift +67 -0
  73. package/ios/Tests/RetryManagerTests.swift +89 -0
  74. package/ios/Tests/SectionScopeTests.swift +2 -2
  75. package/ios/Tests/SleepTimerManagerTests.swift +22 -19
  76. package/ios/Tests/Support/ManualSleepTimerScheduler.swift +47 -0
  77. package/ios/Tests/Support/TestTypes.swift +1 -1
  78. package/ios/Tests/TabBarEntriesTests.swift +7 -7
  79. package/ios/TrackPlayer.swift +6 -1
  80. package/ios/TrackPlayerCallbacks.swift +5 -38
  81. package/ios/Util/LRUCache.swift +4 -1
  82. package/lib/commonjs/AudioBrowser.js +0 -11
  83. package/lib/commonjs/AudioBrowser.js.map +1 -1
  84. package/lib/commonjs/CLAUDE.md +52 -0
  85. package/lib/commonjs/features/browser.js +1 -1
  86. package/lib/commonjs/features/browser.js.map +1 -1
  87. package/lib/commonjs/features/carConnection.js +1 -1
  88. package/lib/commonjs/features/equalizer.js +1 -1
  89. package/lib/commonjs/features/errors.js +3 -3
  90. package/lib/commonjs/features/favorites.js +1 -1
  91. package/lib/commonjs/features/gate.js +16 -1
  92. package/lib/commonjs/features/gate.js.map +1 -1
  93. package/lib/commonjs/features/metadata.js +20 -3
  94. package/lib/commonjs/features/metadata.js.map +1 -1
  95. package/lib/commonjs/features/network.js +1 -1
  96. package/lib/commonjs/features/nowPlaying.js +1 -1
  97. package/lib/commonjs/features/output.js +1 -1
  98. package/lib/commonjs/features/playback/playWhenReady.js +1 -1
  99. package/lib/commonjs/features/playback/playing.js +1 -1
  100. package/lib/commonjs/features/playback/progress.js +1 -1
  101. package/lib/commonjs/features/playback/state.js +1 -1
  102. package/lib/commonjs/features/playback/trackPlaybackTime.js +0 -6
  103. package/lib/commonjs/features/playback/trackPlaybackTime.js.map +1 -1
  104. package/lib/commonjs/features/playback/volume.js +1 -1
  105. package/lib/commonjs/features/player/options.js +48 -34
  106. package/lib/commonjs/features/player/options.js.map +1 -1
  107. package/lib/commonjs/features/player/setup.js +15 -8
  108. package/lib/commonjs/features/player/setup.js.map +1 -1
  109. package/lib/commonjs/features/player/validateOptions.js +21 -0
  110. package/lib/commonjs/features/player/validateOptions.js.map +1 -0
  111. package/lib/commonjs/features/queue/activeTrack.js +1 -1
  112. package/lib/commonjs/features/queue/queue.js +2 -2
  113. package/lib/commonjs/features/queue/repeatMode.js +1 -1
  114. package/lib/commonjs/features/queue/shuffle.js +1 -1
  115. package/lib/commonjs/features/remoteControls.js +13 -92
  116. package/lib/commonjs/features/remoteControls.js.map +1 -1
  117. package/lib/commonjs/features/sleepTimer.js +13 -1
  118. package/lib/commonjs/features/sleepTimer.js.map +1 -1
  119. package/lib/commonjs/test-utils/reactNativeStub.js +65 -0
  120. package/lib/commonjs/test-utils/reactNativeStub.js.map +1 -0
  121. package/lib/commonjs/utils/LazyNativeEmitter.js +8 -5
  122. package/lib/commonjs/utils/LazyNativeEmitter.js.map +1 -1
  123. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  124. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  125. package/lib/commonjs/web/browser/BrowserManager.js +1 -1
  126. package/lib/module/AudioBrowser.js +1 -1
  127. package/lib/module/AudioBrowser.js.map +1 -1
  128. package/lib/module/CLAUDE.md +52 -0
  129. package/lib/module/features/browser.js +1 -1
  130. package/lib/module/features/browser.js.map +1 -1
  131. package/lib/module/features/carConnection.js +1 -1
  132. package/lib/module/features/equalizer.js +1 -1
  133. package/lib/module/features/errors.js +3 -3
  134. package/lib/module/features/favorites.js +1 -1
  135. package/lib/module/features/gate.js +16 -1
  136. package/lib/module/features/gate.js.map +1 -1
  137. package/lib/module/features/metadata.js +20 -3
  138. package/lib/module/features/metadata.js.map +1 -1
  139. package/lib/module/features/network.js +1 -1
  140. package/lib/module/features/nowPlaying.js +1 -1
  141. package/lib/module/features/output.js +1 -1
  142. package/lib/module/features/playback/playWhenReady.js +1 -1
  143. package/lib/module/features/playback/playing.js +1 -1
  144. package/lib/module/features/playback/progress.js +1 -1
  145. package/lib/module/features/playback/state.js +1 -1
  146. package/lib/module/features/playback/trackPlaybackTime.js +0 -5
  147. package/lib/module/features/playback/trackPlaybackTime.js.map +1 -1
  148. package/lib/module/features/playback/volume.js +1 -1
  149. package/lib/module/features/player/options.js +47 -32
  150. package/lib/module/features/player/options.js.map +1 -1
  151. package/lib/module/features/player/setup.js +14 -7
  152. package/lib/module/features/player/setup.js.map +1 -1
  153. package/lib/module/features/player/validateOptions.js +17 -0
  154. package/lib/module/features/player/validateOptions.js.map +1 -0
  155. package/lib/module/features/queue/activeTrack.js +1 -1
  156. package/lib/module/features/queue/queue.js +2 -2
  157. package/lib/module/features/queue/repeatMode.js +1 -1
  158. package/lib/module/features/queue/shuffle.js +1 -1
  159. package/lib/module/features/remoteControls.js +13 -92
  160. package/lib/module/features/remoteControls.js.map +1 -1
  161. package/lib/module/features/sleepTimer.js +13 -1
  162. package/lib/module/features/sleepTimer.js.map +1 -1
  163. package/lib/module/test-utils/reactNativeStub.js +58 -0
  164. package/lib/module/test-utils/reactNativeStub.js.map +1 -0
  165. package/lib/module/utils/LazyNativeEmitter.js +8 -5
  166. package/lib/module/utils/LazyNativeEmitter.js.map +1 -1
  167. package/lib/module/web/NativeAudioBrowser.js +1 -1
  168. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  169. package/lib/module/web/browser/BrowserManager.js +1 -1
  170. package/lib/typescript/src/AudioBrowser.d.ts +0 -1
  171. package/lib/typescript/src/AudioBrowser.d.ts.map +1 -1
  172. package/lib/typescript/src/features/carConnection.d.ts +1 -1
  173. package/lib/typescript/src/features/equalizer.d.ts +1 -1
  174. package/lib/typescript/src/features/errors.d.ts +3 -3
  175. package/lib/typescript/src/features/favorites.d.ts +1 -1
  176. package/lib/typescript/src/features/gate.d.ts +18 -2
  177. package/lib/typescript/src/features/gate.d.ts.map +1 -1
  178. package/lib/typescript/src/features/metadata.d.ts +20 -3
  179. package/lib/typescript/src/features/metadata.d.ts.map +1 -1
  180. package/lib/typescript/src/features/network.d.ts +1 -1
  181. package/lib/typescript/src/features/nowPlaying.d.ts +1 -1
  182. package/lib/typescript/src/features/output.d.ts +1 -1
  183. package/lib/typescript/src/features/playback/playWhenReady.d.ts +1 -1
  184. package/lib/typescript/src/features/playback/playing.d.ts +1 -1
  185. package/lib/typescript/src/features/playback/progress.d.ts +1 -1
  186. package/lib/typescript/src/features/playback/state.d.ts +1 -1
  187. package/lib/typescript/src/features/playback/volume.d.ts +1 -1
  188. package/lib/typescript/src/features/player/options.d.ts +87 -37
  189. package/lib/typescript/src/features/player/options.d.ts.map +1 -1
  190. package/lib/typescript/src/features/player/setup.d.ts +14 -4
  191. package/lib/typescript/src/features/player/setup.d.ts.map +1 -1
  192. package/lib/typescript/src/features/player/validateOptions.d.ts +9 -0
  193. package/lib/typescript/src/features/player/validateOptions.d.ts.map +1 -0
  194. package/lib/typescript/src/features/queue/activeTrack.d.ts +1 -1
  195. package/lib/typescript/src/features/queue/queue.d.ts +2 -2
  196. package/lib/typescript/src/features/queue/repeatMode.d.ts +1 -1
  197. package/lib/typescript/src/features/queue/shuffle.d.ts +1 -1
  198. package/lib/typescript/src/features/remoteControls.d.ts +23 -22
  199. package/lib/typescript/src/features/remoteControls.d.ts.map +1 -1
  200. package/lib/typescript/src/features/sleepTimer.d.ts +1 -1
  201. package/lib/typescript/src/features/sleepTimer.d.ts.map +1 -1
  202. package/lib/typescript/src/test-utils/reactNativeStub.d.ts +36 -0
  203. package/lib/typescript/src/test-utils/reactNativeStub.d.ts.map +1 -0
  204. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts +8 -4
  205. package/lib/typescript/src/utils/LazyNativeEmitter.d.ts.map +1 -1
  206. package/nitrogen/generated/android/AudioBrowser+autolinking.cmake +1 -1
  207. package/nitrogen/generated/android/c++/JAndroidOptions.hpp +10 -10
  208. package/nitrogen/generated/android/c++/JFunc_void_Options.hpp +5 -5
  209. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +11 -11
  210. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.hpp +1 -1
  211. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +5 -5
  212. package/nitrogen/generated/android/c++/JNativeUpdateOptions.hpp +5 -5
  213. package/nitrogen/generated/android/c++/JNitroAndroidUpdateOptions.hpp +10 -10
  214. package/nitrogen/generated/android/c++/JOptions.hpp +5 -5
  215. package/nitrogen/generated/android/c++/{JNotificationButton.hpp → JRemoteButton.hpp} +19 -19
  216. package/nitrogen/generated/android/c++/JRemoteButtonLayout.hpp +86 -0
  217. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.cpp +26 -0
  218. package/nitrogen/generated/android/c++/JVariant_NullType_RemoteButtonLayout.hpp +75 -0
  219. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/AndroidOptions.kt +3 -3
  220. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/HybridAudioBrowserSpec.kt +1 -1
  221. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NitroAndroidUpdateOptions.kt +3 -3
  222. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{NotificationButton.kt → RemoteButton.kt} +3 -3
  223. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/RemoteButtonLayout.kt +44 -0
  224. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/{Variant_NullType_NotificationButtonLayout.kt → Variant_NullType_RemoteButtonLayout.kt} +9 -9
  225. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +36 -51
  226. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +6 -6
  227. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +8 -8
  228. package/nitrogen/generated/ios/swift/AndroidOptions.swift +12 -12
  229. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec.swift +1 -1
  230. package/nitrogen/generated/ios/swift/HybridAudioBrowserSpec_cxx.swift +2 -2
  231. package/nitrogen/generated/ios/swift/NitroAndroidUpdateOptions.swift +12 -12
  232. package/nitrogen/generated/ios/swift/{NotificationButton.swift → RemoteButton.swift} +6 -6
  233. package/nitrogen/generated/ios/swift/RemoteButtonLayout.swift +57 -0
  234. package/nitrogen/generated/ios/swift/{Variant_NullType_NotificationButtonLayout.swift → Variant_NullType_RemoteButtonLayout.swift} +3 -3
  235. package/nitrogen/generated/shared/c++/AndroidOptions.hpp +8 -8
  236. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.cpp +1 -1
  237. package/nitrogen/generated/shared/c++/HybridAudioBrowserSpec.hpp +1 -1
  238. package/nitrogen/generated/shared/c++/NitroAndroidUpdateOptions.hpp +8 -8
  239. package/nitrogen/generated/shared/c++/{NotificationButton.hpp → RemoteButton.hpp} +19 -19
  240. package/nitrogen/generated/shared/c++/RemoteButtonLayout.hpp +94 -0
  241. package/package.json +12 -4
  242. package/src/AudioBrowser.ts +1 -1
  243. package/src/CLAUDE.md +52 -0
  244. package/src/features/battery.test.ts +79 -0
  245. package/src/features/browser.ts +1 -1
  246. package/src/features/carConnection.ts +1 -1
  247. package/src/features/equalizer.ts +1 -1
  248. package/src/features/errors.ts +3 -3
  249. package/src/features/favorites.ts +1 -1
  250. package/src/features/gate.ts +16 -1
  251. package/src/features/hook-wiring.test.ts +254 -0
  252. package/src/features/metadata.ts +20 -3
  253. package/src/features/network.ts +1 -1
  254. package/src/features/nowPlaying.ts +1 -1
  255. package/src/features/output.ts +1 -1
  256. package/src/features/playback/playWhenReady.ts +1 -1
  257. package/src/features/playback/playing.ts +1 -1
  258. package/src/features/playback/progress.test.ts +169 -0
  259. package/src/features/playback/progress.ts +1 -1
  260. package/src/features/playback/state.ts +1 -1
  261. package/src/features/playback/trackPlaybackTime.test.ts +16 -6
  262. package/src/features/playback/trackPlaybackTime.ts +0 -5
  263. package/src/features/playback/volume.ts +1 -1
  264. package/src/features/player/options.ts +99 -57
  265. package/src/features/player/setup.test.ts +12 -4
  266. package/src/features/player/setup.ts +19 -9
  267. package/src/features/player/validateOptions.ts +19 -0
  268. package/src/features/queue/activeTrack.ts +1 -1
  269. package/src/features/queue/queue.ts +2 -2
  270. package/src/features/queue/repeatMode.ts +1 -1
  271. package/src/features/queue/shuffle.ts +1 -1
  272. package/src/features/remoteControls.ts +13 -92
  273. package/src/features/sleepTimer.test.ts +172 -0
  274. package/src/features/sleepTimer.ts +9 -1
  275. package/src/nitro-stub-drift.test.ts +237 -0
  276. package/src/public-surface.test.ts +107 -0
  277. package/src/specs/audio-browser.nitro.ts +1 -1
  278. package/src/test-utils/reactNativeStub.ts +60 -0
  279. package/src/utils/LazyNativeEmitter.ts +9 -6
  280. package/src/utils/useDebug.test.ts +182 -0
  281. package/src/utils/useNativeUpdatedValue.test.ts +103 -0
  282. package/src/web/NativeAudioBrowser.ts +1 -1
  283. package/src/web/browser/BrowserManager.ts +1 -1
  284. package/ios/Model/MediaURL.swift +0 -67
  285. package/nitrogen/generated/android/c++/JNotificationButtonLayout.hpp +0 -94
  286. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.cpp +0 -26
  287. package/nitrogen/generated/android/c++/JVariant_NullType_NotificationButtonLayout.hpp +0 -75
  288. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NotificationButtonLayout.kt +0 -50
  289. package/nitrogen/generated/ios/swift/NotificationButtonLayout.swift +0 -92
  290. package/nitrogen/generated/shared/c++/NotificationButtonLayout.hpp +0 -102
@@ -0,0 +1,172 @@
1
+ /** @vitest-environment happy-dom */
2
+
3
+ import { renderHook, act, cleanup } from '@testing-library/react'
4
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
5
+ import type { SleepTimer } from './sleepTimer'
6
+
7
+ const native = vi.hoisted(() => ({
8
+ getSleepTimer: vi.fn<() => SleepTimer>(() => null),
9
+ // The single callback slot `NativeUpdatedValue` claims at module load.
10
+ onSleepTimerChanged: undefined as ((timer: SleepTimer) => void) | undefined
11
+ }))
12
+
13
+ vi.mock('../native', () => ({ nativeBrowser: native }))
14
+
15
+ const { onSleepTimerChanged, useSleepTimer, useSleepTimerActive } =
16
+ await import('./sleepTimer')
17
+
18
+ /** Push a value the way native would. */
19
+ const fire = (timer: SleepTimer) =>
20
+ act(() => native.onSleepTimerChanged?.(timer))
21
+
22
+ /**
23
+ * The emitter is a module-level singleton, so `lastValue` outlives each test.
24
+ * `undefined` is its pre-native state and distinct from `null`, which is a real
25
+ * value meaning "no timer" — the distinction the re-sync gate turns on.
26
+ */
27
+ beforeEach(() => {
28
+ onSleepTimerChanged.lastValue = undefined
29
+ native.getSleepTimer.mockReturnValue(null)
30
+ })
31
+ afterEach(cleanup)
32
+
33
+ describe('useSleepTimerActive', () => {
34
+ it('reads the getter when nothing has fired yet', () => {
35
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 60_000 })
36
+ const { result } = renderHook(() => useSleepTimerActive())
37
+ expect(result.current).toBe(true)
38
+ })
39
+
40
+ it('tracks timers being set and cleared', () => {
41
+ const { result } = renderHook(() => useSleepTimerActive())
42
+ expect(result.current).toBe(false)
43
+ fire({ time: Date.now() + 60_000 })
44
+ expect(result.current).toBe(true)
45
+ fire(null)
46
+ expect(result.current).toBe(false)
47
+ })
48
+
49
+ it('treats an end-of-track timer as active', () => {
50
+ const { result } = renderHook(() => useSleepTimerActive())
51
+ fire({ sleepWhenPlayedToEnd: true })
52
+ expect(result.current).toBe(true)
53
+ })
54
+
55
+ // The regression from f7a25040. The getter ran during render and saw a live
56
+ // timer; the clear landed before the effect subscribed. Without the re-sync
57
+ // the UI shows a running timer that is already gone — permanently, since
58
+ // nothing reads it again until the next native event.
59
+ it('re-syncs a clear that landed between the render read and the effect', () => {
60
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 60_000 })
61
+ onSleepTimerChanged.lastValue = null
62
+ const { result } = renderHook(() => useSleepTimerActive())
63
+ expect(result.current).toBe(false)
64
+ })
65
+
66
+ // The gate is `!== undefined`, not truthiness: `undefined` means native has
67
+ // never spoken, so the getter's answer must stand.
68
+ it('keeps the getter value when native has not fired at all', () => {
69
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 60_000 })
70
+ const { result } = renderHook(() => useSleepTimerActive())
71
+ expect(result.current).toBe(true)
72
+ })
73
+
74
+ it('stops tracking after unmount', () => {
75
+ const { result, unmount } = renderHook(() => useSleepTimerActive())
76
+ unmount()
77
+ fire({ time: Date.now() + 60_000 })
78
+ expect(result.current).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('useSleepTimer', () => {
83
+ it('is undefined when no timer is set', () => {
84
+ const { result } = renderHook(() => useSleepTimer())
85
+ expect(result.current).toBeUndefined()
86
+ })
87
+
88
+ it('reports secondsLeft for a time-based timer', () => {
89
+ vi.useFakeTimers()
90
+ try {
91
+ vi.setSystemTime(1_000_000_000_000)
92
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 30_000 })
93
+ const { result } = renderHook(() => useSleepTimer())
94
+ expect(result.current).toEqual({
95
+ time: 1_000_000_030_000,
96
+ secondsLeft: 30
97
+ })
98
+ } finally {
99
+ vi.useRealTimers()
100
+ }
101
+ })
102
+
103
+ it('passes an end-of-track timer through without secondsLeft', () => {
104
+ const { result } = renderHook(() => useSleepTimer())
105
+ fire({ sleepWhenPlayedToEnd: true })
106
+ expect(result.current).toEqual({ sleepWhenPlayedToEnd: true })
107
+ })
108
+
109
+ it('counts down as time passes', () => {
110
+ vi.useFakeTimers()
111
+ try {
112
+ vi.setSystemTime(1_000_000_000_000)
113
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 5_000 })
114
+ const { result } = renderHook(() => useSleepTimer())
115
+ expect(result.current).toMatchObject({ secondsLeft: 5 })
116
+ act(() => {
117
+ vi.advanceTimersByTime(2_000)
118
+ })
119
+ expect(result.current).toMatchObject({ secondsLeft: 3 })
120
+ act(() => {
121
+ vi.advanceTimersByTime(3_000)
122
+ })
123
+ expect(result.current).toMatchObject({ secondsLeft: 0 })
124
+ } finally {
125
+ vi.useRealTimers()
126
+ }
127
+ })
128
+
129
+ it('stops the countdown once it reaches zero', () => {
130
+ vi.useFakeTimers()
131
+ try {
132
+ vi.setSystemTime(1_000_000_000_000)
133
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 2_000 })
134
+ const { result } = renderHook(() => useSleepTimer())
135
+ act(() => {
136
+ vi.advanceTimersByTime(2_000)
137
+ })
138
+ expect(result.current).toMatchObject({ secondsLeft: 0 })
139
+ // Clamped at zero rather than running negative.
140
+ act(() => {
141
+ vi.advanceTimersByTime(10_000)
142
+ })
143
+ expect(result.current).toMatchObject({ secondsLeft: 0 })
144
+ } finally {
145
+ vi.useRealTimers()
146
+ }
147
+ })
148
+
149
+ it('does not tick while inactive', () => {
150
+ vi.useFakeTimers()
151
+ try {
152
+ vi.setSystemTime(1_000_000_000_000)
153
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 60_000 })
154
+ const { result } = renderHook(() => useSleepTimer({ inactive: true }))
155
+ expect(result.current).toMatchObject({ secondsLeft: 60 })
156
+ act(() => {
157
+ vi.advanceTimersByTime(10_000)
158
+ })
159
+ expect(result.current).toMatchObject({ secondsLeft: 60 })
160
+ } finally {
161
+ vi.useRealTimers()
162
+ }
163
+ })
164
+
165
+ // Same race as useSleepTimerActive, and the reason both hooks carry the gate.
166
+ it('re-syncs a clear that landed between the render read and the effect', () => {
167
+ native.getSleepTimer.mockReturnValue({ time: Date.now() + 60_000 })
168
+ onSleepTimerChanged.lastValue = null
169
+ const { result } = renderHook(() => useSleepTimer())
170
+ expect(result.current).toBeUndefined()
171
+ })
172
+ })
@@ -86,7 +86,7 @@ export function clearSleepTimer(): boolean {
86
86
  /**
87
87
  * Subscribes to sleep timer changes.
88
88
  * @param callback - Called when the sleep timer state changes
89
- * @returns Cleanup function to unsubscribe
89
+ * @returns An emitter — subscribe with `addListener(callback)`, which returns a cleanup function
90
90
  */
91
91
  export const onSleepTimerChanged = NativeUpdatedValue.emitterize<SleepTimer>(
92
92
  (cb) => (nativeBrowser.onSleepTimerChanged = cb)
@@ -104,6 +104,11 @@ export function useSleepTimerActive(): boolean {
104
104
  const [isActive, setIsActive] = useState(() => getSleepTimer() !== null)
105
105
 
106
106
  useEffect(() => {
107
+ // Re-sync before subscribing: a change between the initial read above and this effect would
108
+ // otherwise be missed for good. `undefined` means nothing has fired yet, whereas `null` is a
109
+ // real value — the timer being cleared — so it has to re-sync too.
110
+ const { lastValue } = onSleepTimerChanged
111
+ if (lastValue !== undefined) setIsActive(lastValue !== null)
107
112
  return onSleepTimerChanged.addListener((timer) => {
108
113
  setIsActive(timer !== null)
109
114
  })
@@ -137,6 +142,9 @@ export function useSleepTimer(params?: {
137
142
  const time = state && 'time' in state ? state.time : undefined
138
143
 
139
144
  useEffect(() => {
145
+ // Re-sync before subscribing — see useSleepTimerActive.
146
+ const { lastValue } = onSleepTimerChanged
147
+ if (lastValue !== undefined) setState(addSecondsLeft(lastValue))
140
148
  return onSleepTimerChanged.addListener((event) => {
141
149
  setState(addSecondsLeft(event))
142
150
  })
@@ -0,0 +1,237 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { describe, it, expect } from 'vitest'
4
+
5
+ /**
6
+ * Drift guard for `ios/Model/NitroTypeStubs.swift`.
7
+ *
8
+ * The generated Nitro Swift types are `typealias`es onto C++ structs
9
+ * (`margelo.nitro.audiobrowser.*`), so SwiftPM cannot compile them without the
10
+ * whole Nitro C++ core — which is why the SPM test target substitutes
11
+ * hand-written stubs behind `#if !canImport(NitroModules)`. The cost of that
12
+ * trick is that the stubs can disagree with the real types, and when they do
13
+ * `swift test` still passes while the app build breaks. That has already
14
+ * happened once (1d2edade, "restore the SPM test-stub build after the artwork
15
+ * variants change").
16
+ *
17
+ * Neither native CI job can catch it: the Swift job only ever sees the stubs,
18
+ * and the app build only ever sees the generated types. This test is the one
19
+ * place both files are readable at once, so the check lives here.
20
+ *
21
+ * The rule is subset-with-exact-types. A stub may declare FEWER properties than
22
+ * the real type — shared `ios/` sources can only use what the stub declares, so
23
+ * an omission is safe. What is not safe is a property the real type spells
24
+ * differently: `var id: String` against a generated `id: String?` compiles under
25
+ * test and fails to compile in the app.
26
+ */
27
+ const ROOT = process.cwd()
28
+ const STUBS = join(ROOT, 'ios', 'Model', 'NitroTypeStubs.swift')
29
+ const GENERATED = join(ROOT, 'nitrogen', 'generated', 'ios', 'swift')
30
+
31
+ /**
32
+ * Stubbed types with no generated counterpart, and why. Anything else missing
33
+ * from `nitrogen/generated/ios/swift/` is a stub for a type that no longer
34
+ * exists — the loudest kind of drift.
35
+ */
36
+ const NOT_GENERATED: Record<string, string> = {
37
+ // Nitro core type (`nitro::NullType`), shipped by NitroModules rather than
38
+ // emitted per-project, so there is no file of ours to compare against.
39
+ NullType: 'provided by NitroModules core'
40
+ }
41
+
42
+ type Struct = { kind: 'struct'; members: Map<string, string> }
43
+ type Enum = { kind: 'enum'; members: Map<string, string> }
44
+ type Shape = Struct | Enum
45
+
46
+ /** Splits a parameter/case list on top-level commas, ignoring `[]`, `()`, `<>`. */
47
+ function splitTopLevel(text: string): string[] {
48
+ const parts: string[] = []
49
+ let depth = 0
50
+ let current = ''
51
+ for (const char of text) {
52
+ if (char === '[' || char === '(' || char === '<') depth++
53
+ else if (char === ']' || char === ')' || char === '>') depth--
54
+ if (char === ',' && depth === 0) {
55
+ parts.push(current)
56
+ current = ''
57
+ } else current += char
58
+ }
59
+ if (current.trim()) parts.push(current)
60
+ return parts.map((part) => part.trim()).filter(Boolean)
61
+ }
62
+
63
+ /** `label: Type` → [label, Type]. Splits on the FIRST colon (`[String: String]`). */
64
+ function splitLabelled(part: string): [string, string] | null {
65
+ const colon = part.indexOf(':')
66
+ if (colon < 0) return null
67
+ const label = part.slice(0, colon).trim()
68
+ // A generated init can carry both an argument label and a parameter name
69
+ // (`for value: String`); the property name is the last word before the colon.
70
+ const name = label.split(/\s+/).pop()!
71
+ return [name, part.slice(colon + 1).trim()]
72
+ }
73
+
74
+ const DECL =
75
+ /^\s*(?:(?:public|@frozen|indirect|final)\s+)*(struct|enum)\s+([A-Za-z_]\w*)\s*(?::[^{]*)?\{/
76
+
77
+ /** Parses every top-level `struct`/`enum` in a Swift file into its member map. */
78
+ function parseSwiftDecls(source: string): Map<string, Shape> {
79
+ const shapes = new Map<string, Shape>()
80
+ const lines = source.split('\n')
81
+
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const decl = DECL.exec(lines[i]!)
84
+ if (!decl) continue
85
+ const keyword = decl[1]!
86
+ const name = decl[2]!
87
+ const members = new Map<string, string>()
88
+ let depth = 1
89
+
90
+ for (let j = i + 1; j < lines.length && depth > 0; j++) {
91
+ const body = lines[j]!.replace(/\/\/.*$/, '')
92
+
93
+ if (depth === 1) {
94
+ // Stored property: `var name: Type`, but not a computed one (`{` after).
95
+ const prop =
96
+ /^\s*(?:public\s+)?var\s+([A-Za-z_]\w*)\s*:\s*([^={]+?)\s*(?:=|$)/.exec(
97
+ body
98
+ )
99
+ if (prop && !/\{\s*$/.test(body)) members.set(prop[1]!, prop[2]!.trim())
100
+
101
+ // `case a`, `case a(T)`, `case a, b`
102
+ const enumCase = /^\s*case\s+(.+)$/.exec(body)
103
+ if (enumCase && keyword === 'enum') {
104
+ for (const one of splitTopLevel(enumCase[1]!)) {
105
+ const withPayload = /^([A-Za-z_]\w*)\s*\((.*)\)$/.exec(one)
106
+ if (withPayload)
107
+ members.set(withPayload[1]!, withPayload[2]!.trim())
108
+ else if (/^[A-Za-z_]\w*$/.test(one)) members.set(one, '')
109
+ }
110
+ }
111
+ }
112
+
113
+ for (const char of body) {
114
+ if (char === '{') depth++
115
+ else if (char === '}') depth--
116
+ }
117
+ }
118
+
119
+ shapes.set(name, {
120
+ kind: keyword === 'struct' ? 'struct' : 'enum',
121
+ members
122
+ } as Shape)
123
+ }
124
+ return shapes
125
+ }
126
+
127
+ /**
128
+ * Reads the real shape of a generated type. Nitro emits three forms: a struct
129
+ * (memberwise `init`), a string-backed enum (`init?(fromString:)`), and a
130
+ * variant (a plain `enum` with `first`/`second`/… payload cases).
131
+ */
132
+ function parseGenerated(name: string): Shape | null {
133
+ let source: string
134
+ try {
135
+ source = readFileSync(join(GENERATED, `${name}.swift`), 'utf8')
136
+ } catch {
137
+ return null
138
+ }
139
+
140
+ // Variant enums are declared outright, so the generic parser sees them.
141
+ const declared = parseSwiftDecls(source).get(name)
142
+ if (declared && declared.members.size > 0) return declared
143
+
144
+ // String-backed enum. Read the case names off the `stringValue` switch, which
145
+ // spells them out (`case .get: return "GET"`), rather than deriving them from
146
+ // the JS values — nitrogen's casing rule is not ours to reimplement.
147
+ if (source.includes('var stringValue: String')) {
148
+ const members = new Map<string, string>()
149
+ for (const match of source.matchAll(/case\s+\.(\w+)\s*:/g))
150
+ members.set(match[1]!, '')
151
+ return { kind: 'enum', members }
152
+ }
153
+
154
+ const init = /^\s*init\((.*)\)\s*\{/m.exec(source)
155
+ if (init) {
156
+ const members = new Map<string, string>()
157
+ for (const part of splitTopLevel(init[1]!)) {
158
+ const labelled = splitLabelled(part)
159
+ if (labelled) members.set(labelled[0], labelled[1])
160
+ }
161
+ return { kind: 'struct', members }
162
+ }
163
+
164
+ return null
165
+ }
166
+
167
+ /**
168
+ * Sugared and desugared spellings are the same Swift type — nitrogen emits
169
+ * `Dictionary<String, String>` where the stubs write `[String: String]`.
170
+ */
171
+ function normalizeType(type: string): string {
172
+ let previous: string
173
+ let current = type.replace(/\s+/g, ' ').trim()
174
+ do {
175
+ previous = current
176
+ current = current
177
+ .replace(/\bDictionary<([^<>]+),\s*([^<>]+)>/g, '[$1: $2]')
178
+ .replace(/\bArray<([^<>]+)>/g, '[$1]')
179
+ .replace(/\bOptional<([^<>]+)>/g, '$1?')
180
+ } while (current !== previous)
181
+ return current
182
+ }
183
+
184
+ describe('NitroTypeStubs mirror the generated Nitro types', () => {
185
+ const stubs = parseSwiftDecls(readFileSync(STUBS, 'utf8'))
186
+
187
+ it('parses the stub file', () => {
188
+ // A parser that silently matches nothing would make every check below pass.
189
+ expect(stubs.size).toBeGreaterThan(20)
190
+ expect(stubs.get('Track')?.members.get('src')).toBe('String?')
191
+ })
192
+
193
+ it('stubs only types that Nitro actually generates', () => {
194
+ const orphans = [...stubs.keys()].filter(
195
+ (name) => !(name in NOT_GENERATED) && parseGenerated(name) == null
196
+ )
197
+ expect(
198
+ orphans,
199
+ `Stubbed with no generated counterpart in nitrogen/generated/ios/swift — the type was ` +
200
+ `renamed or removed from the Nitro spec, so the stub is standing in for nothing:\n` +
201
+ orphans.join('\n')
202
+ ).toEqual([])
203
+ })
204
+
205
+ it('declares every stubbed property with the generated type', () => {
206
+ const drift: string[] = []
207
+
208
+ for (const [name, stub] of stubs) {
209
+ if (name in NOT_GENERATED) continue
210
+ const real = parseGenerated(name)
211
+ if (real == null) continue // reported by the previous test
212
+
213
+ for (const [member, stubType] of stub.members) {
214
+ const realType = real.members.get(member)
215
+ const what = real.kind === 'enum' ? 'case' : 'property'
216
+ if (realType == null) {
217
+ drift.push(
218
+ `${name}.${member}: stub declares a ${what} the generated type does not have`
219
+ )
220
+ } else if (normalizeType(realType) !== normalizeType(stubType)) {
221
+ drift.push(
222
+ `${name}.${member}: stub \`${stubType || '(no payload)'}\`, ` +
223
+ `generated \`${realType || '(no payload)'}\``
224
+ )
225
+ }
226
+ }
227
+ }
228
+
229
+ expect(
230
+ drift,
231
+ `ios/Model/NitroTypeStubs.swift disagrees with nitrogen/generated/ios/swift. ` +
232
+ `\`swift test\` compiles against the stub and will not catch this; the app build ` +
233
+ `compiles against the generated type and will fail. Fix the stub to match:\n` +
234
+ drift.join('\n')
235
+ ).toEqual([])
236
+ })
237
+ })
@@ -0,0 +1,107 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, relative, resolve } from 'node:path'
3
+ import { describe, it, expect } from 'vitest'
4
+
5
+ /**
6
+ * Guard on what `import { … } from 'react-native-audio-browser'` can reach.
7
+ *
8
+ * `@internal` hides a value from the published types but not from the bundle:
9
+ * Metro resolves `"react-native": "src/index"` and compiles the source, where
10
+ * `stripInternal` never ran. Types are exempt — they have no runtime form.
11
+ * `CLAUDE.md` in this directory has the full rule.
12
+ */
13
+ const SRC = join(process.cwd(), 'src')
14
+ const ENTRY = join(SRC, 'index.ts')
15
+
16
+ const VALUE_KINDS = new Set([
17
+ 'const',
18
+ 'let',
19
+ 'var',
20
+ 'function',
21
+ 'class',
22
+ 'enum'
23
+ ])
24
+
25
+ function resolveModule(fromFile: string, spec: string): string | undefined {
26
+ const base = resolve(dirname(fromFile), spec.replace(/\.tsx?$/, ''))
27
+ return [`${base}.ts`, `${base}.tsx`, join(base, 'index.ts')].find(existsSync)
28
+ }
29
+
30
+ interface Export {
31
+ name: string
32
+ file: string
33
+ internal: boolean
34
+ }
35
+
36
+ /** Walks the `export *` graph from `entry`, collecting every reachable value export. */
37
+ function collectValueExports(entry: string): Export[] {
38
+ const found: Export[] = []
39
+ const seen = new Set<string>()
40
+
41
+ const visit = (file: string) => {
42
+ if (seen.has(file)) return
43
+ seen.add(file)
44
+ const source = readFileSync(file, 'utf8')
45
+
46
+ for (const [, spec] of source.matchAll(/^export \* from ['"](.+?)['"]/gm)) {
47
+ const target = resolveModule(file, spec!)
48
+ if (target) visit(target)
49
+ }
50
+
51
+ const declaration =
52
+ /^export (?:declare )?(?:async )?(?:abstract )?(const|let|var|function|class|enum) (\w+)/gm
53
+ for (const match of source.matchAll(declaration)) {
54
+ if (!VALUE_KINDS.has(match[1]!)) continue
55
+ // The JSDoc block, if any, is whatever sits directly above the export.
56
+ const preceding = source.slice(0, match.index)
57
+ const doc = /\/\*\*(?:(?!\*\/)[\s\S])*\*\/\s*$/.exec(preceding)?.[0] ?? ''
58
+ found.push({
59
+ name: match[2]!,
60
+ file: relative(SRC, file),
61
+ internal: /@internal\b/.test(doc)
62
+ })
63
+ }
64
+ }
65
+
66
+ visit(entry)
67
+ return found
68
+ }
69
+
70
+ describe('public surface', () => {
71
+ const exports = collectValueExports(ENTRY)
72
+
73
+ it('reaches the feature modules at all (the walker still works)', () => {
74
+ // Cheap canary: if the barrel structure changes shape and the walker stops
75
+ // resolving, every other assertion here would pass vacuously.
76
+ expect(exports.map((e) => e.name)).toContain('trackPlaybackTime')
77
+ expect(exports.length).toBeGreaterThan(100)
78
+ })
79
+
80
+ it('exposes no value marked @internal', () => {
81
+ const leaked = exports
82
+ .filter((e) => e.internal)
83
+ .map((e) => `${e.name} (${e.file})`)
84
+ expect(
85
+ leaked,
86
+ 'Reachable from the package root at runtime despite @internal. Move each ' +
87
+ 'to a module the barrels skip, and import it directly:\n' +
88
+ leaked.join('\n')
89
+ ).toEqual([])
90
+ })
91
+
92
+ it('exposes no __-prefixed test hook', () => {
93
+ const hooks = exports
94
+ .filter((e) => e.name.startsWith('__'))
95
+ .map((e) => `${e.name} (${e.file})`)
96
+ expect(
97
+ hooks,
98
+ 'Test-only exports ship to consumers. Drive the unit through a seam it ' +
99
+ 'already has — a mocked native slot, say:\n' +
100
+ hooks.join('\n')
101
+ ).toEqual([])
102
+ })
103
+
104
+ it('does not re-export the raw Nitro object', () => {
105
+ expect(exports.map((e) => e.name)).not.toContain('nativeBrowser')
106
+ })
107
+ })
@@ -115,7 +115,7 @@ export interface AudioBrowser extends HybridObject<{
115
115
  tabs: Track[] | undefined
116
116
  navigatePath(path: string): void
117
117
  navigateTrack(track: Track): void
118
- onSearch(query: string): Promise<Track[]>
118
+ search(query: string): Promise<Track[]>
119
119
  getContent(): ResolvedTrack | undefined
120
120
  onPathChanged: (path: string) => void
121
121
  onContentChanged: (content: ResolvedTrack | undefined) => void
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Stands in for `react-native` under vitest, via the alias in `vitest.config.ts`.
3
+ *
4
+ * Not a convenience: `react-native/index.js` is Flow-typed (`import typeof ...`)
5
+ * and Rollup cannot parse it, so any test that reaches a module importing from
6
+ * `'react-native'` fails to load at all. Jest consumers get this from
7
+ * `@react-native/jest-preset`; vitest has no equivalent.
8
+ *
9
+ * Keep it to the surface the library actually touches. Two symbols so far —
10
+ * `AppState` (usePolledProgress) and `Image.resolveAssetSource`. Anything a test
11
+ * needs to drive should get an explicit control here rather than a cast at the
12
+ * call site.
13
+ *
14
+ * @internal — never imported by production code, only aliased in by the runner.
15
+ */
16
+
17
+ export type AppStateStatus = 'active' | 'background' | 'inactive'
18
+
19
+ type ChangeListener = (status: AppStateStatus) => void
20
+
21
+ const changeListeners = new Set<ChangeListener>()
22
+
23
+ export const AppState = {
24
+ currentState: 'active' as AppStateStatus,
25
+
26
+ // `type` is widened to string so the guard stays live: real AppState also
27
+ // emits 'memoryWarning' and 'focus', and silently treating one of those as a
28
+ // 'change' subscription would be a confusing way to find out this stub is thin.
29
+ addEventListener(type: string, listener: ChangeListener) {
30
+ if (type !== 'change') throw new Error(`AppState stub: unhandled "${type}"`)
31
+ changeListeners.add(listener)
32
+ return { remove: () => changeListeners.delete(listener) }
33
+ }
34
+ }
35
+
36
+ /** Move the app to the foreground or background and notify subscribers. */
37
+ export function setAppState(status: AppStateStatus): void {
38
+ AppState.currentState = status
39
+ // Deleting from a Set mid-iteration is safe, so a listener that removes itself
40
+ // during the callback needs no defensive copy.
41
+ for (const listener of changeListeners) listener(status)
42
+ }
43
+
44
+ /**
45
+ * Drop every subscription and return to the foreground. Call between tests —
46
+ * the stub is a module singleton, so state outlives an individual test.
47
+ */
48
+ export function resetAppState(): void {
49
+ changeListeners.clear()
50
+ AppState.currentState = 'active'
51
+ }
52
+
53
+ /** Number of live `change` subscriptions, for asserting cleanup. */
54
+ export function appStateListenerCount(): number {
55
+ return changeListeners.size
56
+ }
57
+
58
+ export const Image = {
59
+ resolveAssetSource: (source: unknown) => source
60
+ }
@@ -12,9 +12,11 @@ type Callback<T> = (arg: T) => void
12
12
  *
13
13
  * @example
14
14
  * ```typescript
15
- * export const onRemotePlay = LazyEmitter.emitterize<void>(
15
+ * export const onRemotePlay = LazyNativeEmitter.emitterize<void>(
16
16
  * (cb) => (nativeBrowser.onRemotePlay = cb)
17
17
  * )
18
+ *
19
+ * const unsubscribe = onRemotePlay.addListener(() => console.log('play'))
18
20
  * ```
19
21
  */
20
22
  export class LazyNativeEmitter<T> {
@@ -43,14 +45,15 @@ export class LazyNativeEmitter<T> {
43
45
  }
44
46
 
45
47
  /**
46
- * Creates a lazy emitter subscription function for a native callback.
48
+ * Creates a lazy emitter for a native callback.
49
+ *
47
50
  * @param setter - Function that sets the native callback property
48
- * @returns A subscription function that returns an unsubscribe function
51
+ * @returns The emitter instance — subscribe with `addListener`, which returns
52
+ * an unsubscribe function
49
53
  */
50
54
  static emitterize<T>(
51
55
  setter: (callback: (data: T) => void) => void
52
- ): (callback: Callback<T>) => () => void {
53
- const emitter = new LazyNativeEmitter(setter)
54
- return (callback: Callback<T>) => emitter.addListener(callback)
56
+ ): LazyNativeEmitter<T> {
57
+ return new LazyNativeEmitter(setter)
55
58
  }
56
59
  }