react-native-audio-browser 0.2.0-next.18 → 0.2.0-next.19

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 (66) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  3. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  4. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  5. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  6. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  7. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  8. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  9. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  10. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  11. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  12. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  13. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  14. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  15. package/lib/commonjs/web/browser/BrowserManager.js +24 -4
  16. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  17. package/lib/commonjs/web/browser/SearchManager.js +12 -12
  18. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  19. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  20. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  21. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  22. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  23. package/lib/module/web/NativeAudioBrowser.js +163 -50
  24. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  25. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  26. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  27. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  28. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  29. package/lib/module/web/TrackPlayer/Player.js +232 -39
  30. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  31. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  32. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  33. package/lib/module/web/browser/BrowserManager.js +24 -4
  34. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  35. package/lib/module/web/browser/SearchManager.js +12 -12
  36. package/lib/module/web/browser/SearchManager.js.map +1 -1
  37. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  38. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  39. package/lib/module/web/http/followMediaRedirect.js +215 -0
  40. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  41. package/lib/typescript/src/types/browser.d.ts +13 -4
  42. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/types/browser.ts +13 -4
  45. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  46. package/src/web/NativeAudioBrowser.ts +193 -60
  47. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  48. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  49. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  50. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  51. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  52. package/src/web/TrackPlayer/Player.ts +256 -37
  53. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  54. package/src/web/__tests__/lifecycle.test.ts +345 -0
  55. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  56. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  57. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  58. package/src/web/__tests__/retry.test.ts +169 -0
  59. package/src/web/browser/BrowserManager.ts +26 -4
  60. package/src/web/browser/SearchManager.ts +16 -16
  61. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  62. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  63. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  64. package/src/web/http/RequestConfigBuilder.ts +140 -33
  65. package/src/web/http/followMediaRedirect.test.ts +289 -0
  66. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -42,7 +42,12 @@ import type {
42
42
  AudioBrowser as AudioBrowserSpec,
43
43
  Output
44
44
  } from '../specs/audio-browser.nitro'
45
- import type { ResolvedTrack, Track, TrackLoadEvent } from '../types'
45
+ import type {
46
+ ResolvedTrack,
47
+ Track,
48
+ TrackLoadEvent,
49
+ TransformableRequestConfig
50
+ } from '../types'
46
51
  import type { NativeBrowserConfiguration } from '../types/browser-native'
47
52
  import { getTrackIdentity } from '../utils/getTrackIdentity'
48
53
  import { BrowserManager } from './browser/BrowserManager'
@@ -50,8 +55,12 @@ import { classifyTrackNavigation } from './browser/classifyTrackNavigation'
50
55
  import { FavoriteManager } from './browser/FavoriteManager'
51
56
  import { NavigationErrorManager } from './browser/NavigationErrorManager'
52
57
  import { SearchManager } from './browser/SearchManager'
58
+ import { followMediaRedirect } from './http/followMediaRedirect'
53
59
  import { HttpClient } from './http/HttpClient'
54
- import { RequestConfigBuilder } from './http/RequestConfigBuilder'
60
+ import {
61
+ RequestConfigBuilder,
62
+ type ResolvedMediaRequest
63
+ } from './http/RequestConfigBuilder'
55
64
  import { NowPlayingManager } from './player/NowPlayingManager'
56
65
  import { OptionsManager } from './player/OptionsManager'
57
66
  import { RemoteCommandController } from './player/RemoteCommandController'
@@ -96,7 +105,6 @@ export class NativeAudioBrowser
96
105
  private remoteCommands: RemoteCommandController
97
106
 
98
107
  // Player state
99
- private currentLoadId = 0
100
108
  private progressTimer = new PlaybackTimer()
101
109
  private intervalTimer = new PlaybackTimer()
102
110
  private _online: boolean =
@@ -306,6 +314,11 @@ export class NativeAudioBrowser
306
314
 
307
315
  if (newState.state === 'error' && newState.error) {
308
316
  this.onPlaybackError({ error: newState.error })
317
+ } else if (oldState.state === 'error') {
318
+ // Leaving the error state clears it, as it does natively. The getter
319
+ // already reads undefined — consumers subscribe to the event, so without
320
+ // this a stale error outlives the next successful load.
321
+ this.onPlaybackError({ error: undefined })
309
322
  }
310
323
  }
311
324
 
@@ -610,71 +623,102 @@ export class NativeAudioBrowser
610
623
  * Supports the transform callback for URL manipulation.
611
624
  * Mirrors Android's MediaFactory.getMediaRequestConfig behavior.
612
625
  */
613
- private async resolveMediaUrl(src: string): Promise<string> {
614
- const { request, media } = this.browserManager.configuration
615
- return RequestConfigBuilder.resolveMediaUrl(src, request, media)
626
+ private async resolveMediaRequest(
627
+ src: string,
628
+ track: Track
629
+ ): Promise<ResolvedMediaRequest> {
630
+ // The *resolved* request layer, not `configuration.request`: a
631
+ // resolver-only config would otherwise reach media with no baseUrl and no
632
+ // headers. Browse, search and browse artwork all read it this way too.
633
+ //
634
+ // Caught, because unlike those three this one cannot fail the load: a
635
+ // resolver hiccup — a token refresh timing out — would otherwise take down
636
+ // a track whose `src` is already absolute and public and needed no layer at
637
+ // all. Native degrades the same way, `getMediaRequestConfig` returning null
638
+ // and the original URL playing on.
639
+ let request: TransformableRequestConfig | undefined
640
+ try {
641
+ request = await this.browserManager.resolvedRequestConfig()
642
+ } catch (e) {
643
+ console.error('Failed to resolve the request layer for media', e)
644
+ }
645
+ const { media } = this.browserManager.configuration
646
+ return RequestConfigBuilder.resolveMediaRequest(src, request, media, track)
616
647
  }
617
648
 
618
649
  /**
619
- * Reload from the queue's own entry, whose `src` is still unresolved —
620
- * `current` holds the resolved track, and re-feeding it into `load()` would
621
- * re-resolve the resolved URL (double-applying a `transform`) and replace
622
- * the queue entry with it.
650
+ * The queue's own entry, so the queue stays the source of truth for what is
651
+ * playing. `current` preserves the track as queued — the resolved URL is
652
+ * passed to `load()` separately — so either is safe to re-feed.
623
653
  */
624
- protected override reloadCurrent(): void {
654
+ protected override trackToReload(): Track | undefined {
625
655
  const index = this.queue.currentIndex
626
- const sourceTrack =
627
- index !== undefined ? this.queue.getTrack(index) : undefined
628
- if (sourceTrack) {
629
- this.load(sourceTrack)
630
- } else {
631
- super.reloadCurrent()
632
- }
656
+ const queued = index !== undefined ? this.queue.getTrack(index) : undefined
657
+ // The queue entry wins, so it stays the source of truth for what is
658
+ // playing; `current` is the fallback for a player loaded without one.
659
+ return queued ?? super.trackToReload()
633
660
  }
634
661
 
635
- load(track: Track, callback?: (track: Track) => void): void {
636
- const element = this.requireElement()
637
-
638
- // Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
639
- this.nowPlayingManager.clearNowPlayingOverride()
640
-
641
- // Match Android: load() modifies the queue.
642
- // If queue is empty, add the track. If queue has items, replace at currentIndex.
662
+ /**
663
+ * Makes `track` the queue's current entry, emitting the change.
664
+ *
665
+ * Matches Android, where `load()` modifies the queue: an empty queue takes
666
+ * the track as its only entry, a populated one has its current entry
667
+ * replaced.
668
+ */
669
+ private syncQueueTo(track: Track): void {
643
670
  if (this.queue.length === 0) {
644
671
  this.queue.setTracks([track])
645
672
  this.queue.currentIndex = 0
646
673
  this.emitQueueChanged()
647
- } else if (
674
+ return
675
+ }
676
+ if (
648
677
  this.queue.currentIndex !== undefined &&
649
678
  this.queue.getTrack(this.queue.currentIndex) !== track
650
679
  ) {
651
680
  this.queue.replaceTrack(this.queue.currentIndex, track)
652
681
  this.emitQueueChanged()
653
682
  }
683
+ }
654
684
 
685
+ /**
686
+ * Everything native does in its media-item transition, all of it before any
687
+ * network work: announce the new track, publish its now-playing metadata,
688
+ * cut the outgoing one, and report `loading`.
689
+ *
690
+ * Android's `onMediaItemTransition` fires at `prepare()` and does exactly
691
+ * this, with its LOADING following from `onEvents`; resolution happens later
692
+ * still, inside `TransformingDataSource.open()`. Doing it here rather than
693
+ * after the URL resolves is what gives the resolution window a state, and
694
+ * keeps `activeTrack` ahead of `loading` — the order a consumer that reads
695
+ * `getActiveTrack()` on the loading edge depends on.
696
+ */
697
+ private transitionTo(track: Track): void {
698
+ const element = this.requireElement()
655
699
  const lastTrack = this.current
656
700
  const lastPosition = element.currentTime
657
701
  const lastIndex = this.queue.lastIndex
658
702
  const currentIndex = this.queue.currentIndex
659
703
 
660
- // Set loading flag early so seekTo() calls during async URL resolution
661
- // are captured as pending seeks rather than silently dropped
662
- this._loadInProgress = true
663
- this._pendingSeek = undefined
664
-
665
- // Resolve the media URL before loading (async but we don't await)
666
- const loadId = ++this.currentLoadId
667
- const doLoad = async () => {
668
- const resolvedTrack: Track = track.src
669
- ? { ...track, src: await this.resolveMediaUrl(track.src) }
670
- : track
671
-
672
- // A newer load() was called while resolving — discard this stale result
673
- if (loadId !== this.currentLoadId) {
674
- return
675
- }
676
-
677
- super.load(resolvedTrack, (loadedTrack) => {
704
+ // A re-prepare of the item already current is not a transition, and must
705
+ // not be announced as one: ExoPlayer's `prepare()` does not re-fire
706
+ // `onMediaItemTransition` for an unchanged media item, so a consumer that
707
+ // resets on a track change would have its UI wiped by every retry. The
708
+ // `lastTrack === track` payload was the tell.
709
+ const isTransition = this.current !== track
710
+
711
+ // `current` first, so `getActiveTrack()` already reads the new track inside
712
+ // a handler and so `getNowPlaying()` publishes the new track's metadata. It
713
+ // also stops `getActiveTrackIndex()` — which moved with the queue already,
714
+ // synchronously — from disagreeing with it for the length of the
715
+ // resolution.
716
+ this.current = track
717
+
718
+ // Separately guarded: a throwing consumer handler must not skip the
719
+ // publish, and neither may fail the load.
720
+ if (isTransition) {
721
+ try {
678
722
  this.onPlaybackActiveTrackChanged({
679
723
  lastTrack,
680
724
  lastPosition,
@@ -682,24 +726,113 @@ export class NativeAudioBrowser
682
726
  index: currentIndex,
683
727
  track
684
728
  })
729
+ } catch (error) {
730
+ console.error('Failed to announce the active track change:', error)
731
+ }
685
732
 
686
- // Update now playing metadata
733
+ try {
734
+ // Once, here: duration reaches the media session through
735
+ // `updateProgress()` on each tick, so only metadata needs publishing.
736
+ // Android publishes from the same transition handler that announces.
687
737
  const nowPlaying = this.getNowPlaying()
688
738
  if (nowPlaying) {
689
739
  this.publishNowPlaying(nowPlaying)
690
740
  }
741
+ } catch (error) {
742
+ console.error('Failed to publish now-playing metadata:', error)
743
+ }
744
+ }
691
745
 
692
- // Call the provided callback if any
693
- if (callback) {
694
- callback(loadedTrack)
695
- }
696
- })
746
+ // Stop the outgoing track now rather than when Shaka eventually gets a URL.
747
+ // Announcing the new track while the old one is still audible is worse than
748
+ // the silence, and native has no such window — prepare() cuts the old item
749
+ // at the moment it announces. This adds no teardown: `player.load()`
750
+ // already unloads first (shaka player.js:1757-1762). It only moves when.
751
+ // Verified in a real browser to emit no `pause`, so no state comes of it.
752
+ this.requirePlayer()
753
+ .unload()
754
+ .catch((error: unknown) =>
755
+ console.error('Failed to unload the previous track:', error)
756
+ )
757
+
758
+ // Loading covers the resolution too. Shaka dispatches its own `loading`
759
+ // once `player.load()` is called — after resolution — and the state setter
760
+ // drops that as a no-op. The `buffering` that follows is Shaka's to report.
761
+ this.dispatch({ type: 'trackLoading' })
762
+ }
763
+
764
+ load(track: Track, callback?: (track: Track) => void): void {
765
+ // Before the queue is touched, so a load before `setupPlayer()` throws
766
+ // rather than half-applying.
767
+ this.requireElement()
768
+
769
+ // Clear now playing override when track changes (matches Android's PlayerListener.onMediaItemTransition)
770
+ this.nowPlayingManager.clearNowPlayingOverride()
771
+
772
+ this.syncQueueTo(track)
773
+
774
+ // Begun here, at the call, so a newer load supersedes this one immediately
775
+ // rather than once its own URL has resolved. Both continuations below carry
776
+ // it, as does `super.load()`.
777
+ const attempt = this.beginLoadAttempt()
778
+
779
+ // Loading ends being stopped. `dispatch()` drops every event while
780
+ // `_isStopped`, to keep Shaka's teardown noise off the stopped state, so
781
+ // the `trackLoading` in `transitionTo` would be swallowed on the stop → play
782
+ // path and the resolution window would report nothing. `super.load()` clears
783
+ // this too, but only after the URL resolves.
784
+ this._isStopped = false
785
+
786
+ // Set loading flag early so seekTo() calls during async URL resolution
787
+ // are captured as pending seeks rather than silently dropped
788
+ this._loadInProgress = true
789
+ this._pendingSeek = undefined
790
+
791
+ this.transitionTo(track)
792
+
793
+ // Resolve the media URL before loading (async but we don't await)
794
+ const doLoad = async () => {
795
+ const resolvedRequest = track.src
796
+ ? await this.resolveMediaRequest(track.src, track)
797
+ : undefined
798
+ // Web-only, and forced: a progressive file plays via `mediaElement.src`,
799
+ // which cannot carry headers. Native sends them with the media request
800
+ // and follows the redirect inline, so it needs none of this.
801
+ const resolvedMedia = resolvedRequest
802
+ ? await followMediaRedirect(
803
+ resolvedRequest.path ?? track.src!,
804
+ resolvedRequest.headers,
805
+ { mediaLayerHeaders: resolvedRequest.mediaLayerHeaders }
806
+ )
807
+ : undefined
808
+
809
+ // Something took over while the URL was resolving — discard this result
810
+ if (!attempt.isCurrent) return
811
+
812
+ super.load(
813
+ track,
814
+ (loadedTrack) => {
815
+ // Call the provided callback if any
816
+ if (callback) {
817
+ callback(loadedTrack)
818
+ }
819
+ },
820
+ { headers: resolvedMedia?.headers, src: resolvedMedia?.src, attempt }
821
+ )
697
822
  }
698
823
 
699
824
  // Execute async load without blocking, with error handling
700
825
  doLoad().catch((error: unknown) => {
701
- // Only the active load may surface a resolution failure.
702
- if (loadId !== this.currentLoadId) return
826
+ // A backstop with no reachable trigger today, kept because an unhandled
827
+ // rejection would be worse than a dead branch. Every await inside
828
+ // `doLoad` catches internally; `shaka.Player.load` is `async`, so it
829
+ // rejects rather than throwing and its rejection is handled against the
830
+ // attempt inside `Player.load`; and `this.player` is only ever assigned,
831
+ // so `requirePlayer()` cannot start succeeding and then fail mid-load.
832
+ //
833
+ // No attempt check either, for the same reason it would be dead: nothing
834
+ // awaits between the guard above and `super.load()`, so the attempt
835
+ // cannot have changed. Add an await there and this needs the check back.
703
836
  this._loadInProgress = false
704
837
  this._pendingSeek = undefined
705
838
  console.error('Error loading track:', error)
@@ -879,10 +1012,8 @@ export class NativeAudioBrowser
879
1012
  }
880
1013
 
881
1014
  override stop(): void {
882
- // Invalidate any in-flight load: its post-await continuation would call
883
- // super.load(), whose first line re-arms the player (_isStopped = false)
884
- // and revives the state out of Stopped.
885
- this.currentLoadId++
1015
+ // `Player.stop()` cancels the load attempt, so an in-flight load's
1016
+ // continuation cannot re-arm the player and revive it out of Stopped.
886
1017
  super.stop()
887
1018
  this.clearSleepTimerIfFading()
888
1019
  }
@@ -903,18 +1034,20 @@ export class NativeAudioBrowser
903
1034
  /**
904
1035
  * Override to check for sleep timer when track ends
905
1036
  */
906
- protected onTrackEnded(): void {
1037
+ protected onTrackEnded(): boolean {
907
1038
  // Check if sleep timer is set to end on track completion
908
1039
  if (this.sleepTimer.sleepWhenPlayedToEnd) {
909
1040
  console.log('Sleep timer triggered on track end, pausing playback')
910
1041
  this.sleepTimer.clear()
911
1042
  this.pause()
912
1043
  this.onSleepTimerChanged(null)
913
- return
1044
+ // Nothing is loading, so the caller must still report the end — the
1045
+ // element has stopped and its own `pause` is swallowed as end-of-track.
1046
+ return false
914
1047
  }
915
1048
 
916
1049
  // Otherwise proceed with normal track end behavior
917
- super.onTrackEnded()
1050
+ return super.onTrackEnded()
918
1051
  }
919
1052
 
920
1053
  // MARK: Queue management
@@ -0,0 +1,29 @@
1
+ /**
2
+ * One attempt at loading a track, from the call that asked for it through to
3
+ * the continuations that finish it.
4
+ *
5
+ * A load on web spans two async boundaries — resolving the URL, then Shaka's
6
+ * own `load()` — and each has a continuation that must do nothing if something
7
+ * else has taken over in the meantime. Handing every continuation the same
8
+ * token turns that into one question, asked the same way in each place.
9
+ *
10
+ * Supersession is registered where it happens: {@link Player.beginLoadAttempt}
11
+ * cancels the previous attempt the moment a new one starts, and `stop()`
12
+ * cancels without starting one. Two integer generations used to stand in for
13
+ * this, incremented at different depths — the outer one at the call, the inner
14
+ * one only once the URL had resolved — so a load superseded during that gap
15
+ * still ran its own success path.
16
+ */
17
+ export class LoadAttempt {
18
+ private cancelled = false
19
+
20
+ /** Abandons this attempt; every continuation holding it will bail. */
21
+ cancel(): void {
22
+ this.cancelled = true
23
+ }
24
+
25
+ /** Whether this attempt still owns the player. */
26
+ get isCurrent(): boolean {
27
+ return !this.cancelled
28
+ }
29
+ }
@@ -47,17 +47,49 @@ describe('nextPlaybackState', () => {
47
47
  })
48
48
  })
49
49
 
50
- describe('loadSeekCompleted — only transitions from loading', () => {
50
+ describe('loadSeekCompleted — the settle after a load', () => {
51
51
  it('from loading → ready', () => {
52
- expect(nextPlaybackState('loading', { type: 'loadSeekCompleted' })).toBe(
53
- 'ready'
54
- )
52
+ expect(
53
+ nextPlaybackState('loading', {
54
+ type: 'loadSeekCompleted',
55
+ playWhenReady: false
56
+ })
57
+ ).toBe('ready')
58
+ })
59
+
60
+ it('from buffering → ready', () => {
61
+ // every load now passes through buffering, so that is where the settle
62
+ // usually arrives
63
+ expect(
64
+ nextPlaybackState('buffering', {
65
+ type: 'loadSeekCompleted',
66
+ playWhenReady: false
67
+ })
68
+ ).toBe('ready')
55
69
  })
56
70
 
57
71
  it('from any other state → suppressed', () => {
58
- for (const state of ALL_STATES.filter((s) => s !== 'loading')) {
72
+ for (const state of ALL_STATES.filter(
73
+ (s) => s !== 'loading' && s !== 'buffering'
74
+ )) {
59
75
  expect(
60
- nextPlaybackState(state, { type: 'loadSeekCompleted' })
76
+ nextPlaybackState(state, {
77
+ type: 'loadSeekCompleted',
78
+ playWhenReady: false
79
+ })
80
+ ).toBeNull()
81
+ }
82
+ })
83
+
84
+ it('suppressed while playWhenReady — ready is a transient before playing', () => {
85
+ // Android suppresses STATE_READY the same way; emitting it flashes a
86
+ // settled, non-loading state mid-startup
87
+ for (const state of ['loading', 'buffering'] as const) {
88
+ expect(
89
+ nextPlaybackState(state, {
90
+ type: 'loadSeekCompleted',
91
+ playWhenReady: true
92
+ })
61
93
  ).toBeNull()
62
94
  }
63
95
  })
@@ -103,7 +135,10 @@ describe('nextPlaybackState', () => {
103
135
 
104
136
  it('from playing → suppressed', () => {
105
137
  expect(
106
- nextPlaybackState('playing', { type: 'bufferingSufficient' })
138
+ nextPlaybackState('playing', {
139
+ type: 'bufferingSufficient',
140
+ playWhenReady: false
141
+ })
107
142
  ).toBeNull()
108
143
  })
109
144
 
@@ -112,27 +147,39 @@ describe('nextPlaybackState', () => {
112
147
  // the UI is rendering with nothing recovered. Same guard as iOS/Android.
113
148
  it('from error → suppressed (preserves the error)', () => {
114
149
  expect(
115
- nextPlaybackState('error', { type: 'bufferingSufficient' })
150
+ nextPlaybackState('error', {
151
+ type: 'bufferingSufficient',
152
+ playWhenReady: false
153
+ })
116
154
  ).toBeNull()
117
155
  })
118
156
 
119
157
  it('from ended → suppressed', () => {
120
158
  expect(
121
- nextPlaybackState('ended', { type: 'bufferingSufficient' })
159
+ nextPlaybackState('ended', {
160
+ type: 'bufferingSufficient',
161
+ playWhenReady: false
162
+ })
122
163
  ).toBeNull()
123
164
  })
124
165
 
125
166
  it('from stopped → suppressed', () => {
126
167
  expect(
127
- nextPlaybackState('stopped', { type: 'bufferingSufficient' })
168
+ nextPlaybackState('stopped', {
169
+ type: 'bufferingSufficient',
170
+ playWhenReady: false
171
+ })
128
172
  ).toBeNull()
129
173
  })
130
174
 
131
175
  it('from any other state → ready', () => {
132
176
  for (const state of ALL_STATES.filter((s) => !suppressed.includes(s))) {
133
- expect(nextPlaybackState(state, { type: 'bufferingSufficient' })).toBe(
134
- 'ready'
135
- )
177
+ expect(
178
+ nextPlaybackState(state, {
179
+ type: 'bufferingSufficient',
180
+ playWhenReady: false
181
+ })
182
+ ).toBe('ready')
136
183
  }
137
184
  })
138
185
  })
@@ -15,6 +15,12 @@ import type { PlaybackState } from '../../features'
15
15
  * | `paused` | element `pause` |
16
16
  * | `trackEndedNaturally`| element `ended` |
17
17
  *
18
+ * `trackLoading` is left to Shaka rather than also dispatched when `load()` is
19
+ * called. Dispatching it up front would give the URL-resolution window a state
20
+ * to report, but it would put `loading` ahead of
21
+ * `onPlaybackActiveTrackChanged`, and native emits those the other way round —
22
+ * see the note in `NativeAudioBrowser.load`.
23
+ *
18
24
  * Deliberate *commands* (stop, error) set their terminal state directly rather
19
25
  * than flowing through here — they are intentions, not observations. The two
20
26
  * iOS-only events `trackUnloaded` and `audioFrameDecoded` are omitted: web has
@@ -24,11 +30,11 @@ import type { PlaybackState } from '../../features'
24
30
  export type PlaybackEvent =
25
31
  | { type: 'trackLoading' }
26
32
  | { type: 'trackEndedNaturally' }
27
- | { type: 'loadSeekCompleted' }
33
+ | { type: 'loadSeekCompleted'; playWhenReady: boolean }
28
34
  | { type: 'paused'; hasAsset: boolean }
29
35
  | { type: 'waiting' }
30
36
  | { type: 'playing' }
31
- | { type: 'bufferingSufficient' }
37
+ | { type: 'bufferingSufficient'; playWhenReady: boolean }
32
38
 
33
39
  /**
34
40
  * The next playback state for an event from the current state, or `null` to
@@ -56,7 +62,12 @@ export function nextPlaybackState(
56
62
  return 'playing'
57
63
  case 'loadSeekCompleted':
58
64
  // The settle after a load; meaningless from anything but a fresh load.
59
- return current === 'loading' ? 'ready' : null
65
+ // `buffering` counts as one: a load now always passes through it.
66
+ if (current !== 'loading' && current !== 'buffering') return null
67
+ // Suppressed while the intent is to play, as Android suppresses
68
+ // STATE_READY there — it is a transient before `playing`, and emitting it
69
+ // flashes a settled, non-loading state mid-startup.
70
+ return event.playWhenReady ? null : 'ready'
60
71
  case 'paused':
61
72
  // A stopped player owns its state; a stray pause must not disturb it.
62
73
  if (current === 'stopped') return null
@@ -72,11 +83,16 @@ export function nextPlaybackState(
72
83
  // it would silently clear the error the UI is rendering (Shaka's
73
84
  // post-error unload emits buffering events without the stopped dispatch
74
85
  // gate); ended/stopped own their state until an explicit load or play.
75
- return current === 'playing' ||
86
+ if (
87
+ current === 'playing' ||
76
88
  current === 'ended' ||
77
89
  current === 'stopped' ||
78
90
  current === 'error'
79
- ? null
80
- : 'ready'
91
+ ) {
92
+ return null
93
+ }
94
+ // Same suppression as `loadSeekCompleted`: while the intent is to play,
95
+ // `playing` is the next state worth reporting.
96
+ return event.playWhenReady ? null : 'ready'
81
97
  }
82
98
  }
@@ -23,8 +23,10 @@ class TestPlayer extends Player {
23
23
 
24
24
  class TestQueuePlayer extends QueuePlayer {
25
25
  endedCount = 0
26
- protected onTrackEnded(): void {
26
+ /** Stands in for a handler that advances, so `ended` stays suppressed. */
27
+ protected onTrackEnded(): boolean {
27
28
  this.endedCount++
29
+ return true
28
30
  }
29
31
  forceStopped(stopped: boolean): void {
30
32
  this._isStopped = stopped
@@ -61,7 +63,7 @@ describe('Player.dispatch', () => {
61
63
  const player = new TestPlayer()
62
64
  player.forceState('playing')
63
65
 
64
- player.emit({ type: 'bufferingSufficient' })
66
+ player.emit({ type: 'bufferingSufficient', playWhenReady: false })
65
67
 
66
68
  expect(player.read().state).toBe('playing')
67
69
  })
@@ -70,7 +72,7 @@ describe('Player.dispatch', () => {
70
72
  const player = new TestPlayer()
71
73
  player.forceState('loading')
72
74
 
73
- player.emit({ type: 'loadSeekCompleted' })
75
+ player.emit({ type: 'loadSeekCompleted', playWhenReady: false })
74
76
 
75
77
  expect(player.read().state).toBe('ready')
76
78
  })