react-native-audio-browser 0.2.0-next.17 → 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.
- package/android/build.gradle +2 -1
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
- package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
- package/android/src/main/res/values/strings.xml +6 -21
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
- package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
- package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
- package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +30 -12
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +16 -19
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
- package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
- package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
- package/lib/module/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/web/NativeAudioBrowser.js +163 -50
- package/lib/module/web/NativeAudioBrowser.js.map +1 -1
- package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
- package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
- package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
- package/lib/module/web/TrackPlayer/Player.js +232 -39
- package/lib/module/web/TrackPlayer/Player.js.map +1 -1
- package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
- package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +30 -12
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +16 -19
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/module/web/http/RequestConfigBuilder.js +110 -27
- package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
- package/lib/module/web/http/followMediaRedirect.js +215 -0
- package/lib/module/web/http/followMediaRedirect.js.map +1 -0
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/lib/typescript/src/types/browser.d.ts +13 -4
- package/lib/typescript/src/types/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/browser.ts +8 -0
- package/src/types/browser.ts +13 -4
- package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
- package/src/web/NativeAudioBrowser.ts +193 -60
- package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
- package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
- package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
- package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
- package/src/web/TrackPlayer/Player.load.test.ts +137 -1
- package/src/web/TrackPlayer/Player.ts +256 -37
- package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
- package/src/web/__tests__/lifecycle.test.ts +345 -0
- package/src/web/__tests__/lifecycleHarness.ts +351 -0
- package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
- package/src/web/__tests__/loadSupersession.test.ts +175 -0
- package/src/web/__tests__/retry.test.ts +169 -0
- package/src/web/browser/BrowserManager.ts +32 -15
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +23 -26
- package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
- package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
- package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
- package/src/web/http/RequestConfigBuilder.test.ts +241 -1
- package/src/web/http/RequestConfigBuilder.ts +140 -33
- package/src/web/http/followMediaRedirect.test.ts +289 -0
- 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 {
|
|
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 {
|
|
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
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
*
|
|
620
|
-
* `current`
|
|
621
|
-
*
|
|
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
|
|
654
|
+
protected override trackToReload(): Track | undefined {
|
|
625
655
|
const index = this.queue.currentIndex
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
//
|
|
702
|
-
|
|
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
|
-
//
|
|
883
|
-
//
|
|
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():
|
|
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
|
-
|
|
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 —
|
|
50
|
+
describe('loadSeekCompleted — the settle after a load', () => {
|
|
51
51
|
it('from loading → ready', () => {
|
|
52
|
-
expect(
|
|
53
|
-
'
|
|
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(
|
|
72
|
+
for (const state of ALL_STATES.filter(
|
|
73
|
+
(s) => s !== 'loading' && s !== 'buffering'
|
|
74
|
+
)) {
|
|
59
75
|
expect(
|
|
60
|
-
nextPlaybackState(state, {
|
|
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', {
|
|
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', {
|
|
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', {
|
|
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', {
|
|
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(
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
if (
|
|
87
|
+
current === 'playing' ||
|
|
76
88
|
current === 'ended' ||
|
|
77
89
|
current === 'stopped' ||
|
|
78
90
|
current === 'error'
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
})
|