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,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
|
|
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,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
|
-
|
|
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 =
|
|
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
|
|
48
|
+
* Creates a lazy emitter for a native callback.
|
|
49
|
+
*
|
|
47
50
|
* @param setter - Function that sets the native callback property
|
|
48
|
-
* @returns
|
|
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
|
-
):
|
|
53
|
-
|
|
54
|
-
return (callback: Callback<T>) => emitter.addListener(callback)
|
|
56
|
+
): LazyNativeEmitter<T> {
|
|
57
|
+
return new LazyNativeEmitter(setter)
|
|
55
58
|
}
|
|
56
59
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
|
|
6
|
+
const native = vi.hoisted(() => {
|
|
7
|
+
const slots = new Map<string, (event: never) => void>()
|
|
8
|
+
return {
|
|
9
|
+
slots,
|
|
10
|
+
browser: new Proxy({} as Record<string, unknown>, {
|
|
11
|
+
get(target, prop: string) {
|
|
12
|
+
// Defaults have to be shaped, not undefined: useDebugState reads
|
|
13
|
+
// `playback.state`, `queue.map(...)` and `progress.position` directly.
|
|
14
|
+
target[prop] ??= vi.fn(() => defaults[prop])
|
|
15
|
+
return target[prop]
|
|
16
|
+
},
|
|
17
|
+
set(target, prop: string, value) {
|
|
18
|
+
if (typeof value === 'function') slots.set(prop, value)
|
|
19
|
+
target[prop] = value
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const defaults: Record<string, unknown> = {
|
|
27
|
+
getPlayback: { state: 'none' },
|
|
28
|
+
getQueue: [],
|
|
29
|
+
getProgress: { position: 0, duration: 0, buffered: 0 },
|
|
30
|
+
getOptions: {},
|
|
31
|
+
getPlayWhenReady: false,
|
|
32
|
+
getShuffle: false,
|
|
33
|
+
getRepeatMode: 'off',
|
|
34
|
+
getOnline: true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
vi.mock('../native', () => ({ nativeBrowser: native.browser }))
|
|
38
|
+
|
|
39
|
+
const { useDebug } = await import('./useDebug')
|
|
40
|
+
|
|
41
|
+
const fire = (slot: string, event: unknown) =>
|
|
42
|
+
act(() => native.slots.get(slot)?.(event as never))
|
|
43
|
+
|
|
44
|
+
let logSpy: ReturnType<typeof vi.spyOn>
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
48
|
+
// Emitters and the log store are module singletons, so both the last value
|
|
49
|
+
// and the entries outlive a test. Pin a baseline rather than letting each
|
|
50
|
+
// test inherit whatever the previous one happened to fire.
|
|
51
|
+
fire('onOnlineChanged', true)
|
|
52
|
+
fire('onPlaybackProgressUpdated', { position: 0, duration: 0, buffered: 0 })
|
|
53
|
+
const { result, unmount } = renderHook(() => useDebug({ enabled: false }))
|
|
54
|
+
act(() => result.current.clear())
|
|
55
|
+
unmount()
|
|
56
|
+
logSpy.mockClear()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
cleanup()
|
|
61
|
+
logSpy.mockRestore()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('useDebug', () => {
|
|
65
|
+
it('records nothing while disabled', () => {
|
|
66
|
+
const { result } = renderHook(() => useDebug({ enabled: false }))
|
|
67
|
+
fire('onOnlineChanged', false)
|
|
68
|
+
expect(result.current.logs).toEqual([])
|
|
69
|
+
expect(logSpy).not.toHaveBeenCalled()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('logs a single initial snapshot on mount', () => {
|
|
73
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
74
|
+
expect(result.current.logs).toHaveLength(1)
|
|
75
|
+
expect(result.current.logs[0]).toMatchObject({
|
|
76
|
+
type: 'initial',
|
|
77
|
+
elapsed: null
|
|
78
|
+
})
|
|
79
|
+
expect(result.current.logs[0]!.message).toContain('online: true')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('logs a change entry naming the field that moved', () => {
|
|
83
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
84
|
+
fire('onOnlineChanged', false)
|
|
85
|
+
|
|
86
|
+
expect(result.current.logs).toHaveLength(2)
|
|
87
|
+
const entry = result.current.logs[1]!
|
|
88
|
+
expect(entry.type).toBe('change')
|
|
89
|
+
expect(entry.message).toContain('online')
|
|
90
|
+
expect(entry.message).toContain('old → true')
|
|
91
|
+
expect(entry.message).toContain('new → false')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('logs nothing when a re-render changes no field', () => {
|
|
95
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
96
|
+
fire('onOnlineChanged', true) // already true
|
|
97
|
+
expect(result.current.logs).toHaveLength(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Progress ticks every second; logging each one would bury every other event.
|
|
101
|
+
it('ignores position drift under two seconds but reports a real seek', () => {
|
|
102
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
103
|
+
|
|
104
|
+
fire('onPlaybackProgressUpdated', { position: 1, duration: 0, buffered: 0 })
|
|
105
|
+
expect(result.current.logs).toHaveLength(1)
|
|
106
|
+
|
|
107
|
+
fire('onPlaybackProgressUpdated', {
|
|
108
|
+
position: 30,
|
|
109
|
+
duration: 0,
|
|
110
|
+
buffered: 0
|
|
111
|
+
})
|
|
112
|
+
expect(result.current.logs).toHaveLength(2)
|
|
113
|
+
expect(result.current.logs[1]!.message).toContain('position')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('clears on demand', () => {
|
|
117
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
118
|
+
fire('onOnlineChanged', false)
|
|
119
|
+
expect(result.current.logs.length).toBeGreaterThan(0)
|
|
120
|
+
|
|
121
|
+
act(() => result.current.clear())
|
|
122
|
+
expect(result.current.logs).toEqual([])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('caps the history and keeps the newest entries', () => {
|
|
126
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
127
|
+
for (let i = 0; i < 120; i++) fire('onOnlineChanged', i % 2 === 0)
|
|
128
|
+
|
|
129
|
+
expect(result.current.logs).toHaveLength(100)
|
|
130
|
+
// 120 alternating flips after the initial snapshot: the last one set online
|
|
131
|
+
// to false (i = 119 is odd), so the newest entry is the tail, not the head.
|
|
132
|
+
expect(result.current.logs.at(-1)!.message).toContain('new → false')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('metadata logging', () => {
|
|
136
|
+
it('is off by default', () => {
|
|
137
|
+
const { result } = renderHook(() => useDebug({ enabled: true }))
|
|
138
|
+
fire('onTrackMetadata', { title: 'Song' })
|
|
139
|
+
expect(result.current.logs.filter((l) => l.type === 'metadata')).toEqual(
|
|
140
|
+
[]
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('records track, timed and chapter events when enabled', () => {
|
|
145
|
+
const { result } = renderHook(() =>
|
|
146
|
+
useDebug({ enabled: true, metadata: true })
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
fire('onTrackMetadata', { title: 'Song', artist: null })
|
|
150
|
+
fire('onTimedMetadata', { title: 'Live' })
|
|
151
|
+
fire('onChapterMetadata', [{}, {}])
|
|
152
|
+
|
|
153
|
+
const entries = result.current.logs.filter((l) => l.type === 'metadata')
|
|
154
|
+
expect(entries).toHaveLength(3)
|
|
155
|
+
expect(entries[0]!.message).toContain('[track] title: "Song"')
|
|
156
|
+
// Null fields are dropped rather than printed as `artist: "null"`.
|
|
157
|
+
expect(entries[0]!.message).not.toContain('artist')
|
|
158
|
+
expect(entries[1]!.message).toContain('[timed]')
|
|
159
|
+
expect(entries[2]!.message).toContain('2 chapters')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('stops recording metadata after unmount', () => {
|
|
163
|
+
const { result, unmount } = renderHook(() =>
|
|
164
|
+
useDebug({ enabled: true, metadata: true })
|
|
165
|
+
)
|
|
166
|
+
const before = result.current.logs.length
|
|
167
|
+
unmount()
|
|
168
|
+
fire('onTrackMetadata', { title: 'Song' })
|
|
169
|
+
expect(result.current.logs).toHaveLength(before)
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('falls back to __DEV__ when enabled is not given', () => {
|
|
174
|
+
;(globalThis as { __DEV__?: boolean }).__DEV__ = false
|
|
175
|
+
try {
|
|
176
|
+
const { result } = renderHook(() => useDebug())
|
|
177
|
+
expect(result.current.logs).toEqual([])
|
|
178
|
+
} finally {
|
|
179
|
+
delete (globalThis as { __DEV__?: boolean }).__DEV__
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
})
|