Compare commits
75 Commits
ivan/on-se
...
loewy/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c0669190f | |||
| 452a9748d5 | |||
| b81dae8878 | |||
| ba3dea885d | |||
| a1579eebad | |||
| 42608bd8d1 | |||
| 04682d02c7 | |||
| ec3cb15984 | |||
| ff6c4316cf | |||
| dfe2957087 | |||
| d7563cfd93 | |||
| 69a9159a43 | |||
| c6bd3e3ee4 | |||
| 2d953c4c46 | |||
| 7a6afd52a3 | |||
| d7977241c9 | |||
| 921ead0f05 | |||
| 20397d32e6 | |||
| e3900e794d | |||
| 4dc7bf465f | |||
| e5f182cda9 | |||
| 9138c3249d | |||
| 7a1d0e8b10 | |||
| 9cbba8f95e | |||
| 2cfb26d51f | |||
| 4f18e9b238 | |||
| bd64379837 | |||
| 47151c7119 | |||
| a16275b003 | |||
| 56c129aa4f | |||
| 68cbe3c4b1 | |||
| 81e864c0e1 | |||
| 0e9ac4d125 | |||
| 9191a06600 | |||
| dc61c3efea | |||
| 11f480f206 | |||
| 9619e7517b | |||
| 3ddc6e931a | |||
| 6b5831dc1c | |||
| 3fc002f3fd | |||
| edb5c6bcfa | |||
| 5bc975b2c9 | |||
| d79b5c9a83 | |||
| f72b44d4df | |||
| d2ab22b99f | |||
| 2dcde42fd6 | |||
| c7a45d421b | |||
| f0db0a6868 | |||
| 01b3322e03 | |||
| 13beae1401 | |||
| f3deabd75e | |||
| d69729dc04 | |||
|
|
6768c22139 | ||
|
|
2b369df57d | ||
|
|
8542c8f7d1 | ||
|
|
fc5b2d4563 | ||
|
|
ffb4631854 | ||
|
|
29cf7c97c3 | ||
|
|
491ed77a32 | ||
|
|
5b199b52b4 | ||
|
|
9d19157654 | ||
|
|
3dabf5f16f | ||
|
|
e610a274d5 | ||
|
|
27880f5212 | ||
|
|
39dd30b762 | ||
|
|
edf5d0c613 | ||
|
|
975fc2f303 | ||
|
|
aa85d71b87 | ||
|
|
cce24cd829 | ||
|
|
cad63d465d | ||
|
|
f5fa063bc0 | ||
|
|
c6abcdeb2f | ||
|
|
a72ab331dc | ||
|
|
fa126de97f | ||
|
|
ca2452edb6 |
38
.gitea/workflows/check-js.yaml
Normal file
38
.gitea/workflows/check-js.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
name: Check JS
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- railbird-v6
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
typescript:
|
||||||
|
name: Check TS (tsc)
|
||||||
|
runs-on: nixos-x86_64-linux
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: nix shell nixpkgs#nodejs_22 nixpkgs#yarn -c bash -e {0}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install node_modules and build package
|
||||||
|
run: yarn install --immutable
|
||||||
|
- name: Check TypeScript
|
||||||
|
run: yarn tsc
|
||||||
|
- name: Test web HLS
|
||||||
|
run: yarn test:web
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Lint JS (eslint, prettier)
|
||||||
|
runs-on: nixos-x86_64-linux
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: nix shell nixpkgs#nodejs_22 nixpkgs#yarn -c bash -e {0}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install node_modules and build package
|
||||||
|
run: yarn install --immutable
|
||||||
|
- name: Run ESLint
|
||||||
|
run: yarn lint
|
||||||
|
- name: Verify auto-fix produces no changes
|
||||||
|
run: yarn lint --fix && git diff --exit-code HEAD
|
||||||
@@ -24,7 +24,7 @@ class SideLoadedTextTrackList {
|
|||||||
}
|
}
|
||||||
val sideLoadedTextTrackList = SideLoadedTextTrackList()
|
val sideLoadedTextTrackList = SideLoadedTextTrackList()
|
||||||
for (i in 0 until src.size()) {
|
for (i in 0 until src.size()) {
|
||||||
val textTrack: ReadableMap = src.getMap(i)
|
val textTrack: ReadableMap = src.getMap(i) ?: continue
|
||||||
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))
|
sideLoadedTextTrackList.tracks.add(SideLoadedTextTrack.parse(textTrack))
|
||||||
}
|
}
|
||||||
return sideLoadedTextTrackList
|
return sideLoadedTextTrackList
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class Source {
|
|||||||
if (propSrcHeadersArray != null) {
|
if (propSrcHeadersArray != null) {
|
||||||
if (propSrcHeadersArray.size() > 0) {
|
if (propSrcHeadersArray.size() > 0) {
|
||||||
for (i in 0 until propSrcHeadersArray.size()) {
|
for (i in 0 until propSrcHeadersArray.size()) {
|
||||||
val current = propSrcHeadersArray.getMap(i)
|
val current = propSrcHeadersArray.getMap(i) ?: continue
|
||||||
val key = if (current.hasKey("key")) current.getString("key") else null
|
val key = if (current.hasKey("key")) current.getString("key") else null
|
||||||
val value = if (current.hasKey("value")) current.getString("value") else null
|
val value = if (current.hasKey("value")) current.getString("value") else null
|
||||||
if (key != null && value != null) {
|
if (key != null && value != null) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ enum class EventTypes(val eventName: String) {
|
|||||||
EVENT_BANDWIDTH("onVideoBandwidthUpdate"),
|
EVENT_BANDWIDTH("onVideoBandwidthUpdate"),
|
||||||
EVENT_CONTROLS_VISIBILITY_CHANGE("onControlsVisibilityChange"),
|
EVENT_CONTROLS_VISIBILITY_CHANGE("onControlsVisibilityChange"),
|
||||||
EVENT_SEEK("onVideoSeek"),
|
EVENT_SEEK("onVideoSeek"),
|
||||||
|
EVENT_SEEK_COMPLETE("onVideoSeekComplete"),
|
||||||
EVENT_END("onVideoEnd"),
|
EVENT_END("onVideoEnd"),
|
||||||
EVENT_FULLSCREEN_WILL_PRESENT("onVideoFullscreenPlayerWillPresent"),
|
EVENT_FULLSCREEN_WILL_PRESENT("onVideoFullscreenPlayerWillPresent"),
|
||||||
EVENT_FULLSCREEN_DID_PRESENT("onVideoFullscreenPlayerDidPresent"),
|
EVENT_FULLSCREEN_DID_PRESENT("onVideoFullscreenPlayerDidPresent"),
|
||||||
@@ -71,6 +72,7 @@ class VideoEventEmitter {
|
|||||||
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
|
lateinit var onVideoBandwidthUpdate: (bitRateEstimate: Long, height: Int, width: Int, trackId: String?) -> Unit
|
||||||
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
|
lateinit var onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
|
||||||
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
|
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
|
||||||
|
lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit
|
||||||
lateinit var onVideoEnd: () -> Unit
|
lateinit var onVideoEnd: () -> Unit
|
||||||
lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit
|
lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit
|
||||||
lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit
|
lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit
|
||||||
@@ -174,6 +176,11 @@ class VideoEventEmitter {
|
|||||||
putDouble("seekTime", seekTime / 1000.0)
|
putDouble("seekTime", seekTime / 1000.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onVideoSeekComplete = { currentPosition ->
|
||||||
|
event.dispatch(EventTypes.EVENT_SEEK_COMPLETE) {
|
||||||
|
putDouble("currentTime", currentPosition / 1000.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
onVideoEnd = {
|
onVideoEnd = {
|
||||||
event.dispatch(EventTypes.EVENT_END)
|
event.dispatch(EventTypes.EVENT_END)
|
||||||
}
|
}
|
||||||
@@ -281,12 +288,22 @@ class VideoEventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class VideoEvent(
|
||||||
|
surfaceId: Int,
|
||||||
|
viewId: Int,
|
||||||
|
private val name: String,
|
||||||
|
private val data: WritableMap?
|
||||||
|
) : Event<VideoEvent>(surfaceId, viewId) {
|
||||||
|
override fun getEventName() = name
|
||||||
|
override fun getEventData() = data
|
||||||
|
}
|
||||||
|
|
||||||
private class EventBuilder(private val surfaceId: Int, private val viewId: Int, private val dispatcher: EventDispatcher) {
|
private class EventBuilder(private val surfaceId: Int, private val viewId: Int, private val dispatcher: EventDispatcher) {
|
||||||
fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) =
|
fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) {
|
||||||
dispatcher.dispatchEvent(object : Event<Event<*>>(surfaceId, viewId) {
|
val eventName = "top${event.eventName.removePrefix("on")}"
|
||||||
override fun getEventName() = "top${event.eventName.removePrefix("on")}"
|
val eventData = Arguments.createMap().apply(paramsSetter ?: {})
|
||||||
override fun getEventData() = Arguments.createMap().apply(paramsSetter ?: {})
|
dispatcher.dispatchEvent(VideoEvent(surfaceId, viewId, eventName, eventData))
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioTracksToArray(audioTracks: java.util.ArrayList<Track>?): WritableArray =
|
private fun audioTracksToArray(audioTracks: java.util.ArrayList<Track>?): WritableArray =
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import android.os.IBinder;
|
|||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.accessibility.CaptioningManager;
|
import android.view.accessibility.CaptioningManager;
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
@@ -224,6 +225,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
*/
|
*/
|
||||||
private boolean isSeeking = false;
|
private boolean isSeeking = false;
|
||||||
private long seekPosition = -1;
|
private long seekPosition = -1;
|
||||||
|
private boolean isSeekInProgress = false;
|
||||||
|
|
||||||
// Props from React
|
// Props from React
|
||||||
private Source source = new Source();
|
private Source source = new Source();
|
||||||
@@ -303,6 +305,16 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private void handleSeekCompletion() {
|
||||||
|
if (player != null && player.getPlaybackState() == Player.STATE_READY && isSeekInProgress) {
|
||||||
|
Log.d("ReactExoplayerView", "handleSeekCompletion: currentPosition=" + player.getCurrentPosition());
|
||||||
|
eventEmitter.onVideoSeekComplete.invoke(player.getCurrentPosition());
|
||||||
|
isSeeking = false;
|
||||||
|
seekPosition = -1;
|
||||||
|
isSeekInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) {
|
public double getPositionInFirstPeriodMsForCurrentWindow(long currentPosition) {
|
||||||
Timeline.Window window = new Timeline.Window();
|
Timeline.Window window = new Timeline.Window();
|
||||||
if(!player.getCurrentTimeline().isEmpty()) {
|
if(!player.getCurrentTimeline().isEmpty()) {
|
||||||
@@ -521,12 +533,21 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
|
|
||||||
builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> {
|
builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> {
|
||||||
selectedSpeedIndex = which;
|
selectedSpeedIndex = which;
|
||||||
float speed = switch (which) {
|
float speed;
|
||||||
case 0 -> 0.5f;
|
switch (which) {
|
||||||
case 2 -> 1.5f;
|
case 0:
|
||||||
case 3 -> 2.0f;
|
speed = 0.5f;
|
||||||
default -> 1.0f;
|
break;
|
||||||
};
|
case 1:
|
||||||
|
speed = 1.0f;
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
speed = 1.5f;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
speed = 1.0f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
setRateModifier(speed);
|
setRateModifier(speed);
|
||||||
});
|
});
|
||||||
builder.show();
|
builder.show();
|
||||||
@@ -853,6 +874,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
.setLoadControl(loadControl)
|
.setLoadControl(loadControl)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.build();
|
.build();
|
||||||
|
player.addListener(self);
|
||||||
ReactNativeVideoManager.Companion.getInstance().onInstanceCreated(instanceId, player);
|
ReactNativeVideoManager.Companion.getInstance().onInstanceCreated(instanceId, player);
|
||||||
refreshDebugState();
|
refreshDebugState();
|
||||||
player.addListener(self);
|
player.addListener(self);
|
||||||
@@ -1437,6 +1459,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
|
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED) || events.contains(Player.EVENT_PLAY_WHEN_READY_CHANGED)) {
|
||||||
int playbackState = player.getPlaybackState();
|
int playbackState = player.getPlaybackState();
|
||||||
boolean playWhenReady = player.getPlayWhenReady();
|
boolean playWhenReady = player.getPlayWhenReady();
|
||||||
|
Log.d("ReactExoplayerView", "onEvents: playbackState=" + playbackState + ", playWhenReady=" + playWhenReady);
|
||||||
String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
|
String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
|
||||||
eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
|
eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
|
||||||
switch (playbackState) {
|
switch (playbackState) {
|
||||||
@@ -1470,6 +1493,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
playerControlView.show();
|
playerControlView.show();
|
||||||
}
|
}
|
||||||
setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
|
setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
|
||||||
|
Log.d("ReactExoplayerView", "Player STATE_READY: currentPosition=" + player.getCurrentPosition());
|
||||||
|
if (isSeekInProgress) {
|
||||||
|
handleSeekCompletion();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case Player.STATE_ENDED:
|
case Player.STATE_ENDED:
|
||||||
text += "ended";
|
text += "ended";
|
||||||
@@ -1734,6 +1761,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) {
|
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, @Player.DiscontinuityReason int reason) {
|
||||||
|
Log.d("ReactExoplayerView", "onPositionDiscontinuity: reason=" + reason + ", oldPosition=" + oldPosition.positionMs + ", newPosition=" + newPosition.positionMs);
|
||||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||||
isSeeking = true;
|
isSeeking = true;
|
||||||
seekPosition = newPosition.positionMs;
|
seekPosition = newPosition.positionMs;
|
||||||
@@ -2249,6 +2277,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
|||||||
|
|
||||||
public void seekTo(long positionMs) {
|
public void seekTo(long positionMs) {
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
|
Log.d("ReactExoplayerView", "seekTo: positionMs=" + positionMs);
|
||||||
|
isSeekInProgress = true;
|
||||||
|
isSeeking = true;
|
||||||
|
seekPosition = positionMs;
|
||||||
player.seekTo(positionMs);
|
player.seekTo(positionMs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
docs/bun.lockb
BIN
docs/bun.lockb
Binary file not shown.
@@ -103,7 +103,7 @@ Note: On Android, you must set the [reportBandwidth](#reportbandwidth) prop to e
|
|||||||
|
|
||||||
### `onBuffer`
|
### `onBuffer`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
Callback function that is called when the player buffers.
|
Callback function that is called when the player buffers.
|
||||||
|
|
||||||
@@ -219,6 +219,9 @@ Payload: none
|
|||||||
|
|
||||||
Callback function that is called when the media is loaded and ready to play.
|
Callback function that is called when the media is loaded and ready to play.
|
||||||
|
|
||||||
|
|
||||||
|
NOTE: tracks (`audioTracks`, `textTracks` & `videoTracks`) are not available on the web.
|
||||||
|
|
||||||
Payload:
|
Payload:
|
||||||
|
|
||||||
| Property | Type | Description |
|
| Property | Type | Description |
|
||||||
@@ -292,7 +295,7 @@ Example:
|
|||||||
|
|
||||||
### `onPlaybackStateChanged`
|
### `onPlaybackStateChanged`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||||
|
|
||||||
Callback function that is called when the playback state changes.
|
Callback function that is called when the playback state changes.
|
||||||
|
|
||||||
@@ -463,7 +466,7 @@ Payload: none
|
|||||||
|
|
||||||
### `onSeek`
|
### `onSeek`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS', 'Windows UWP']} />
|
<PlatformsList types={['Android', 'iOS', 'Windows UWP', 'web']} />
|
||||||
|
|
||||||
Callback function that is called when a seek completes.
|
Callback function that is called when a seek completes.
|
||||||
|
|
||||||
@@ -604,7 +607,7 @@ Example:
|
|||||||
|
|
||||||
### `onVolumeChange`
|
### `onVolumeChange`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||||
|
|
||||||
Callback function that is called when the volume of player changes.
|
Callback function that is called when the volume of player changes.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ This page shows the list of available methods
|
|||||||
|
|
||||||
### `dismissFullscreenPlayer`
|
### `dismissFullscreenPlayer`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`dismissFullscreenPlayer(): Promise<void>`
|
`dismissFullscreenPlayer(): Promise<void>`
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ Take the player out of fullscreen mode.
|
|||||||
|
|
||||||
### `pause`
|
### `pause`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`pause(): Promise<void>`
|
`pause(): Promise<void>`
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ Pause the video.
|
|||||||
|
|
||||||
### `presentFullscreenPlayer`
|
### `presentFullscreenPlayer`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`presentFullscreenPlayer(): Promise<void>`
|
`presentFullscreenPlayer(): Promise<void>`
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ On Android, this puts the navigation controls in fullscreen mode. It is not a co
|
|||||||
|
|
||||||
### `resume`
|
### `resume`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`resume(): Promise<void>`
|
`resume(): Promise<void>`
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ tolerance is the max distance in milliseconds from the seconds position that's a
|
|||||||
|
|
||||||
### `setVolume`
|
### `setVolume`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`setVolume(value): Promise<void>`
|
`setVolume(value): Promise<void>`
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ This function will change the volume exactly like [volume](./props#volume) prope
|
|||||||
|
|
||||||
### `getCurrentPosition`
|
### `getCurrentPosition`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`getCurrentPosition(): Promise<number>`
|
`getCurrentPosition(): Promise<number>`
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ Changing source with this function will overide source provided as props.
|
|||||||
|
|
||||||
### `setFullScreen`
|
### `setFullScreen`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
`setFullScreen(fullscreen): Promise<void>`
|
`setFullScreen(fullscreen): Promise<void>`
|
||||||
|
|
||||||
@@ -137,6 +137,13 @@ On iOS, this displays the video in a fullscreen view controller with controls.
|
|||||||
|
|
||||||
On Android, this puts the navigation controls in fullscreen mode. It is not a complete fullscreen implementation, so you will still need to apply a style that makes the width and height match your screen dimensions to get a fullscreen video.
|
On Android, this puts the navigation controls in fullscreen mode. It is not a complete fullscreen implementation, so you will still need to apply a style that makes the width and height match your screen dimensions to get a fullscreen video.
|
||||||
|
|
||||||
|
### `nativeHtmlVideoRef`
|
||||||
|
|
||||||
|
<PlatformsList types={['web']} />
|
||||||
|
|
||||||
|
A ref to the underlying html video element. This can be used if you need to integrate a 3d party, web only video library (like hls.js, shaka, video.js...).
|
||||||
|
|
||||||
|
|
||||||
### Example Usage
|
### Example Usage
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -188,7 +195,7 @@ Possible values are:
|
|||||||
|
|
||||||
### `isCodecSupported`
|
### `isCodecSupported`
|
||||||
|
|
||||||
<PlatformsList types={['Android']} />
|
<PlatformsList types={['Android', 'web']} />
|
||||||
|
|
||||||
Indicates whether the provided codec is supported level supported by device.
|
Indicates whether the provided codec is supported level supported by device.
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ When playing an HLS live stream with a `EXT-X-PROGRAM-DATE-TIME` tag configured,
|
|||||||
|
|
||||||
### `controls`
|
### `controls`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||||
|
|
||||||
Determines whether to show player controls.
|
Determines whether to show player controls.
|
||||||
|
|
||||||
@@ -300,7 +300,7 @@ Whether this video view should be focusable with a non-touch input device, eg. r
|
|||||||
|
|
||||||
### `fullscreen`
|
### `fullscreen`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||||
|
|
||||||
Controls whether the player enters fullscreen on play.
|
Controls whether the player enters fullscreen on play.
|
||||||
See [presentFullscreenPlayer](#presentfullscreenplayer) for details.
|
See [presentFullscreenPlayer](#presentfullscreenplayer) for details.
|
||||||
@@ -316,7 +316,7 @@ If a preferred [fullscreenOrientation](#fullscreenorientation) is set, causes th
|
|||||||
|
|
||||||
### `fullscreenOrientation`
|
### `fullscreenOrientation`
|
||||||
|
|
||||||
<PlatformsList types={['iOS', 'visionOS']} />
|
<PlatformsList types={['iOS', 'visionOS', 'web']} />
|
||||||
|
|
||||||
- **all (default)** -
|
- **all (default)** -
|
||||||
- **landscape**
|
- **landscape**
|
||||||
@@ -707,6 +707,8 @@ The docs for this prop are incomplete and will be updated as each option is inve
|
|||||||
|
|
||||||
> ⚠️ on iOS, you file name must not contain spaces eg. `my video.mp4` will not work, use `my-video.mp4` instead
|
> ⚠️ on iOS, you file name must not contain spaces eg. `my video.mp4` will not work, use `my-video.mp4` instead
|
||||||
|
|
||||||
|
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
Pass directly the asset to play (deprecated)
|
Pass directly the asset to play (deprecated)
|
||||||
@@ -818,7 +820,7 @@ Example:
|
|||||||
|
|
||||||
#### Start playback at a specific point in time
|
#### Start playback at a specific point in time
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
Provide an optional `startPosition` for video. Value is in milliseconds. If the `cropStart` prop is applied, it will be applied from that point forward.
|
Provide an optional `startPosition` for video. Value is in milliseconds. If the `cropStart` prop is applied, it will be applied from that point forward.
|
||||||
(If it is negative or undefined or null, it is ignored)
|
(If it is negative or undefined or null, it is ignored)
|
||||||
@@ -1029,7 +1031,7 @@ textTracks={[
|
|||||||
|
|
||||||
### `showNotificationControls`
|
### `showNotificationControls`
|
||||||
|
|
||||||
<PlatformsList types={['Android', 'iOS']} />
|
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||||
|
|
||||||
Controls whether to show media controls in the notification area.
|
Controls whether to show media controls in the notification area.
|
||||||
For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
|
For Android each Video component will have its own notification controls and for iOS only one notification control will be shown for the last Active Video component.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap
|
|||||||
- Exoplayer for android
|
- Exoplayer for android
|
||||||
- AVplayer for iOS, tvOS and visionOS
|
- AVplayer for iOS, tvOS and visionOS
|
||||||
- Windows UWP for windows
|
- Windows UWP for windows
|
||||||
|
- HTML5 for web
|
||||||
- Trick mode support
|
- Trick mode support
|
||||||
- Subtitles (embeded or side loaded)
|
- Subtitles (embeded or side loaded)
|
||||||
- DRM support
|
- DRM support
|
||||||
|
|||||||
@@ -181,3 +181,12 @@ Select RCTVideo-tvOS
|
|||||||
Run `pod install` in the `visionos` directory of your project
|
Run `pod install` in the `visionos` directory of your project
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>web</summary>
|
||||||
|
|
||||||
|
Nothing to do, everything should work out of the box.
|
||||||
|
|
||||||
|
Note that only basic video support is present, no hls/dash or ads/drm for now.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"web": "expo start --web",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"windows": "react-native run-windows",
|
"windows": "react-native run-windows",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .."
|
"pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~3.2.1",
|
||||||
"@react-native-picker/picker": "2.7.5",
|
"@react-native-picker/picker": "2.7.5",
|
||||||
"expo": "^51.0.32",
|
"expo": "^51.0.32",
|
||||||
"expo-asset": "~10.0.10",
|
"expo-asset": "~10.0.10",
|
||||||
@@ -20,6 +22,8 @@
|
|||||||
"expo-navigation-bar": "~3.0.7",
|
"expo-navigation-bar": "~3.0.7",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.74.5",
|
"react-native": "0.74.5",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"react-native-web": "~0.19.10",
|
||||||
"react-native-windows": "0.74.19"
|
"react-native-windows": "0.74.19"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export const srcAllPlatformList = [
|
|||||||
description: 'another bunny (can be saved)',
|
description: 'another bunny (can be saved)',
|
||||||
uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4',
|
uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4',
|
||||||
headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'},
|
headers: {referer: 'www.github.com', 'User-Agent': 'react.native.video'},
|
||||||
|
metadata: {
|
||||||
|
title: 'Custom Title',
|
||||||
|
subtitle: 'Custom Subtitle',
|
||||||
|
artist: 'Custom Artist',
|
||||||
|
description: 'Custom Description',
|
||||||
|
imageUri:
|
||||||
|
'https://pbs.twimg.com/profile_images/1498641868397191170/6qW2XkuI_400x400.png',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'sintel with subtitles',
|
description: 'sintel with subtitles',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {AppRegistry} from 'react-native';
|
import {registerRootComponent} from 'expo';
|
||||||
import VideoPlayer from './VideoPlayer';
|
import VideoPlayer from './VideoPlayer';
|
||||||
import {name as appName} from '../app.json';
|
|
||||||
|
|
||||||
AppRegistry.registerComponent(appName, () => VideoPlayer);
|
registerRootComponent(VideoPlayer);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,22 @@ enum RCTVideoAssetsUtils {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func getDuration(asset: AVAsset) async -> CMTime? {
|
||||||
|
if #available(iOS 15, tvOS 15, visionOS 1.0, *) {
|
||||||
|
return try? await asset.load(.duration)
|
||||||
|
} else {
|
||||||
|
#if !os(visionOS)
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
asset.loadValuesAsynchronously(forKeys: ["duration"]) {
|
||||||
|
var error: NSError?
|
||||||
|
let status = asset.statusOfValue(forKey: "duration", error: &error)
|
||||||
|
continuation.resume(returning: status == .loaded ? asset.duration : nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RCTVideoUtils
|
// MARK: - RCTVideoUtils
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||||
@objc var onVideoBandwidthUpdate: RCTDirectEventBlock?
|
@objc var onVideoBandwidthUpdate: RCTDirectEventBlock?
|
||||||
@objc var onVideoSeek: RCTDirectEventBlock?
|
@objc var onVideoSeek: RCTDirectEventBlock?
|
||||||
|
@objc var onVideoSeekComplete: RCTDirectEventBlock?
|
||||||
@objc var onVideoEnd: RCTDirectEventBlock?
|
@objc var onVideoEnd: RCTDirectEventBlock?
|
||||||
@objc var onTimedMetadata: RCTDirectEventBlock?
|
@objc var onTimedMetadata: RCTDirectEventBlock?
|
||||||
@objc var onVideoAudioBecomingNoisy: RCTDirectEventBlock?
|
@objc var onVideoAudioBecomingNoisy: RCTDirectEventBlock?
|
||||||
@@ -782,34 +783,50 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
_paused = paused
|
_paused = paused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func setSeek(_ time: NSNumber, _ tolerance: NSNumber) {
|
func setSeek(_ time: NSNumber, _ tolerance: NSNumber) {
|
||||||
let item: AVPlayerItem? = _player?.currentItem
|
let item: AVPlayerItem? = _player?.currentItem
|
||||||
|
|
||||||
_pendingSeek = true
|
|
||||||
|
|
||||||
guard item != nil, let player = _player, let item, item.status == AVPlayerItem.Status.readyToPlay else {
|
guard item != nil, let player = _player, let item, item.status == AVPlayerItem.Status.readyToPlay else {
|
||||||
|
_pendingSeek = true
|
||||||
_pendingSeekTime = time.floatValue
|
_pendingSeekTime = time.floatValue
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
RCTPlayerOperations.seek(
|
let wasPaused = _paused
|
||||||
player: player,
|
let seekTime = CMTimeMakeWithSeconds(Float64(time.floatValue), preferredTimescale: Int32(NSEC_PER_SEC))
|
||||||
playerItem: item,
|
let toleranceTime = CMTimeMakeWithSeconds(Float64(tolerance.floatValue), preferredTimescale: Int32(NSEC_PER_SEC))
|
||||||
paused: _paused,
|
|
||||||
seekTime: time.floatValue,
|
let currentTimeBeforeSeek = CMTimeGetSeconds(item.currentTime())
|
||||||
seekTolerance: tolerance.floatValue
|
|
||||||
) { [weak self] (_: Bool) in
|
// Call onVideoSeek before starting the seek operation
|
||||||
guard let self else { return }
|
let currentTime = NSNumber(value: Float(currentTimeBeforeSeek))
|
||||||
|
self.onVideoSeek?(["currentTime": currentTime,
|
||||||
|
"seekTime": time,
|
||||||
|
"target": self.reactTag])
|
||||||
|
|
||||||
|
_pendingSeek = true
|
||||||
|
|
||||||
|
let seekCompletionHandler: (Bool) -> Void = { [weak self] finished in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
self._pendingSeek = false
|
||||||
|
|
||||||
|
guard finished else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
self._playerObserver.addTimeObserverIfNotSet()
|
self._playerObserver.addTimeObserverIfNotSet()
|
||||||
self.setPaused(self._paused)
|
self.setPaused(self._paused)
|
||||||
self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))),
|
|
||||||
|
let newCurrentTime = NSNumber(value: Float(CMTimeGetSeconds(item.currentTime())))
|
||||||
|
self.onVideoSeekComplete?(["currentTime": newCurrentTime,
|
||||||
"seekTime": time,
|
"seekTime": time,
|
||||||
"target": self.reactTag as Any])
|
"target": self.reactTag as Any])
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_pendingSeek = false
|
player.seek(to: seekTime, toleranceBefore: toleranceTime, toleranceAfter: toleranceTime, completionHandler: seekCompletionHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
@@ -1403,7 +1420,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
}
|
}
|
||||||
|
|
||||||
if onVideoLoad != nil, self._videoLoadStarted {
|
if onVideoLoad != nil, self._videoLoadStarted {
|
||||||
var duration = Float(CMTimeGetSeconds(_playerItem.asset.duration))
|
let assetDuration = await RCTVideoAssetsUtils.getDuration(asset: _playerItem.asset)
|
||||||
|
guard self._playerItem === _playerItem,
|
||||||
|
self._source?.json === source.json else { return }
|
||||||
|
var duration = Float(CMTimeGetSeconds(assetDuration ?? .invalid))
|
||||||
|
|
||||||
if duration.isNaN || duration == 0 {
|
if duration.isNaN || duration == 0 {
|
||||||
// This is a safety check for live video.
|
// This is a safety check for live video.
|
||||||
@@ -1432,6 +1452,8 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
|||||||
|
|
||||||
let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player)
|
let audioTracks = await RCTVideoUtils.getAudioTrackInfo(self._player)
|
||||||
let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
|
let textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
|
||||||
|
guard self._playerItem === _playerItem,
|
||||||
|
self._source?.json === source.json else { return }
|
||||||
self.onVideoLoad?(["duration": NSNumber(value: duration),
|
self.onVideoLoad?(["duration": NSNumber(value: duration),
|
||||||
"currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))),
|
"currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))),
|
||||||
"canPlayReverse": NSNumber(value: _playerItem.canPlayReverse),
|
"canPlayReverse": NSNumber(value: _playerItem.canPlayReverse),
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ RCT_EXPORT_VIEW_PROPERTY(onVideoError, RCTDirectEventBlock);
|
|||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoProgress, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoProgress, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoBandwidthUpdate, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoBandwidthUpdate, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoSeek, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoSeek, RCTDirectEventBlock);
|
||||||
|
RCT_EXPORT_VIEW_PROPERTY(onVideoSeekComplete, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoEnd, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoEnd, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onTimedMetadata, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onTimedMetadata, RCTDirectEventBlock);
|
||||||
RCT_EXPORT_VIEW_PROPERTY(onVideoAudioBecomingNoisy, RCTDirectEventBlock);
|
RCT_EXPORT_VIEW_PROPERTY(onVideoAudioBecomingNoisy, RCTDirectEventBlock);
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import React
|
|||||||
@objc(RCTVideoManager)
|
@objc(RCTVideoManager)
|
||||||
class RCTVideoManager: RCTViewManager {
|
class RCTVideoManager: RCTViewManager {
|
||||||
override func view() -> UIView {
|
override func view() -> UIView {
|
||||||
return RCTVideo(eventDispatcher: (RCTBridge.current().eventDispatcher() as! RCTEventDispatcher))
|
// `RCTBridge.current()` returns nil under bridgeless (New Architecture), and it
|
||||||
|
// is nonnull-annotated, so Swift emits an unconditional nil-check trap around
|
||||||
|
// the call -- optional chaining does not avoid it. Every <Video> mount killed
|
||||||
|
// the app. RCTVideo only stores this dispatcher and never reads it (events go
|
||||||
|
// out through the RCT_EXPORT_VIEW_PROPERTY direct event blocks), so skip the
|
||||||
|
// lookup entirely.
|
||||||
|
return RCTVideo(eventDispatcher: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func methodQueue() -> DispatchQueue {
|
func methodQueue() -> DispatchQueue {
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -3,7 +3,7 @@
|
|||||||
"version": "6.6.4",
|
"version": "6.6.4",
|
||||||
"description": "A <Video /> element for react-native",
|
"description": "A <Video /> element for react-native",
|
||||||
"main": "lib/index",
|
"main": "lib/index",
|
||||||
"source": "src/index",
|
"source": "src/index.ts",
|
||||||
"react-native": "src/index",
|
"react-native": "src/index",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Community Contributors",
|
"author": "Community Contributors",
|
||||||
@@ -31,15 +31,18 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-native": "0.73.2",
|
"react-native": "0.73.2",
|
||||||
"react-native-windows": "^0.61.0-0",
|
"react-native-windows": "^0.61.0-0",
|
||||||
"release-it": "^16.2.1",
|
"release-it": "^16.2.1"
|
||||||
"typescript": "5.1.6"
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hls.js": "^1.6.16",
|
||||||
|
"typescript": "5.6.3"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "*",
|
"react": "*",
|
||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"install": "tsc --noCheck",
|
||||||
"lint": "yarn eslint .",
|
"lint": "yarn eslint .",
|
||||||
"build": "yarn tsc",
|
"build": "yarn tsc",
|
||||||
"prepare": "yarn build",
|
"prepare": "yarn build",
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
"docs": "yarn --cwd docs build",
|
"docs": "yarn --cwd docs build",
|
||||||
"release": "release-it",
|
"release": "release-it",
|
||||||
"test": "echo no test available",
|
"test": "echo no test available",
|
||||||
|
"test:web": "yarn tsc -p test/tsconfig.json && node --test test/web-hls.test.js",
|
||||||
"check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh",
|
"check-ios": "scripts/swift-format.sh && scripts/swift-lint.sh && scripts/clang-format.sh",
|
||||||
"check-android": "scripts/kotlin-lint.sh",
|
"check-android": "scripts/kotlin-lint.sh",
|
||||||
"check-all": "yarn check-android; yarn check-ios; yarn lint",
|
"check-all": "yarn check-android; yarn check-ios; yarn lint",
|
||||||
@@ -58,6 +62,8 @@
|
|||||||
"windows",
|
"windows",
|
||||||
"src",
|
"src",
|
||||||
"lib",
|
"lib",
|
||||||
|
"tsconfig.json",
|
||||||
|
"tsconfig.build.json",
|
||||||
"react-native-video.podspec",
|
"react-native-video.podspec",
|
||||||
"app.plugin.js",
|
"app.plugin.js",
|
||||||
"!android/build",
|
"!android/build",
|
||||||
|
|||||||
13
shell.nix
Normal file
13
shell.nix
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{pkgs ? import <nixpkgs> {}}:
|
||||||
|
pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
nodejs-18_x
|
||||||
|
nodePackages.yarn
|
||||||
|
bun
|
||||||
|
eslint_d
|
||||||
|
prettierd
|
||||||
|
jdk11
|
||||||
|
(jdt-language-server.override { jdk = jdk11; })
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
OnPlaybackStateChangedData,
|
OnPlaybackStateChangedData,
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
OnSeekData,
|
OnSeekData,
|
||||||
|
OnSeekCompleteData,
|
||||||
OnTextTrackDataChangedData,
|
OnTextTrackDataChangedData,
|
||||||
OnTimedMetadataData,
|
OnTimedMetadataData,
|
||||||
OnVideoAspectRatioData,
|
OnVideoAspectRatioData,
|
||||||
@@ -45,8 +46,7 @@ import {
|
|||||||
resolveAssetSourceForVideo,
|
resolveAssetSourceForVideo,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
import NativeVideoManager from './specs/NativeVideoManager';
|
import NativeVideoManager from './specs/NativeVideoManager';
|
||||||
import type {VideoSaveData} from './specs/NativeVideoManager';
|
import {type VideoSaveData, CmcdMode, ViewType} from './types';
|
||||||
import {CmcdMode, ViewType} from './types';
|
|
||||||
import type {
|
import type {
|
||||||
OnLoadData,
|
OnLoadData,
|
||||||
OnTextTracksData,
|
OnTextTracksData,
|
||||||
@@ -98,6 +98,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
onError,
|
onError,
|
||||||
onProgress,
|
onProgress,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onSeekComplete,
|
||||||
onEnd,
|
onEnd,
|
||||||
onBuffer,
|
onBuffer,
|
||||||
onBandwidthUpdate,
|
onBandwidthUpdate,
|
||||||
@@ -470,6 +471,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
[onSeek],
|
[onSeek],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onVideoSeekComplete = useCallback(
|
||||||
|
(e: NativeSyntheticEvent<OnSeekCompleteData>) => {
|
||||||
|
onSeekComplete?.(e.nativeEvent);
|
||||||
|
},
|
||||||
|
[onSeekComplete],
|
||||||
|
);
|
||||||
|
|
||||||
const onVideoPlaybackStateChanged = useCallback(
|
const onVideoPlaybackStateChanged = useCallback(
|
||||||
(e: NativeSyntheticEvent<OnPlaybackStateChangedData>) => {
|
(e: NativeSyntheticEvent<OnPlaybackStateChangedData>) => {
|
||||||
onPlaybackStateChanged?.(e.nativeEvent);
|
onPlaybackStateChanged?.(e.nativeEvent);
|
||||||
@@ -725,10 +733,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// poster style
|
// poster style
|
||||||
const baseStyle: StyleProp<ImageStyle> = {
|
const baseStyle: StyleProp<ImageStyle> = [
|
||||||
...StyleSheet.absoluteFillObject,
|
StyleSheet.absoluteFill,
|
||||||
resizeMode: _posterResizeMode,
|
{resizeMode: _posterResizeMode},
|
||||||
};
|
];
|
||||||
|
|
||||||
let posterStyle: StyleProp<ImageStyle> = baseStyle;
|
let posterStyle: StyleProp<ImageStyle> = baseStyle;
|
||||||
|
|
||||||
@@ -778,10 +786,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const _style: StyleProp<ViewStyle> = useMemo(
|
const _style: StyleProp<ViewStyle> = useMemo(
|
||||||
() => ({
|
() => [
|
||||||
...StyleSheet.absoluteFillObject,
|
StyleSheet.absoluteFill,
|
||||||
...(showPoster ? {display: 'none'} : {}),
|
showPoster ? {display: 'none'} : undefined,
|
||||||
}),
|
],
|
||||||
[showPoster],
|
[showPoster],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -812,6 +820,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
|||||||
onVideoError={onError ? onVideoError : undefined}
|
onVideoError={onError ? onVideoError : undefined}
|
||||||
onVideoProgress={onProgress ? onVideoProgress : undefined}
|
onVideoProgress={onProgress ? onVideoProgress : undefined}
|
||||||
onVideoSeek={onSeek ? onVideoSeek : undefined}
|
onVideoSeek={onSeek ? onVideoSeek : undefined}
|
||||||
|
onVideoSeekComplete={onSeekComplete ? onVideoSeekComplete : undefined}
|
||||||
onVideoEnd={onEnd}
|
onVideoEnd={onEnd}
|
||||||
onVideoBuffer={onBuffer ? onVideoBuffer : undefined}
|
onVideoBuffer={onBuffer ? onVideoBuffer : undefined}
|
||||||
onVideoPlaybackStateChanged={
|
onVideoPlaybackStateChanged={
|
||||||
|
|||||||
542
src/Video.web.tsx
Normal file
542
src/Video.web.tsx
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
type RefObject,
|
||||||
|
} from 'react';
|
||||||
|
import Hls from 'hls.js';
|
||||||
|
import type {VideoRef, ReactVideoProps, VideoMetadata} from './types';
|
||||||
|
import {
|
||||||
|
createWebPlaybackErrorReporter,
|
||||||
|
describeHlsError,
|
||||||
|
describeMediaError,
|
||||||
|
isAppleWebKit,
|
||||||
|
selectWebHlsPlaybackMode,
|
||||||
|
type WebHlsPlaybackMode,
|
||||||
|
} from './webHls';
|
||||||
|
|
||||||
|
const HLS_MIME = 'application/vnd.apple.mpegurl';
|
||||||
|
|
||||||
|
// `poster` is either a (deprecated) plain URI string or a poster object whose
|
||||||
|
// `source` follows the RN image-source shape. The DOM only takes a URL string.
|
||||||
|
const resolvePosterUri = (
|
||||||
|
poster: ReactVideoProps['poster'],
|
||||||
|
): string | undefined => {
|
||||||
|
if (typeof poster === 'string') {
|
||||||
|
return poster;
|
||||||
|
}
|
||||||
|
const source = poster?.source;
|
||||||
|
return typeof source === 'object' && source !== null ? source.uri : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
source,
|
||||||
|
paused,
|
||||||
|
muted,
|
||||||
|
volume,
|
||||||
|
rate,
|
||||||
|
repeat,
|
||||||
|
controls,
|
||||||
|
showNotificationControls = false,
|
||||||
|
poster,
|
||||||
|
fullscreen,
|
||||||
|
fullscreenAutorotate,
|
||||||
|
fullscreenOrientation,
|
||||||
|
onBuffer,
|
||||||
|
onLoad,
|
||||||
|
onProgress,
|
||||||
|
onPlaybackRateChange,
|
||||||
|
onError,
|
||||||
|
onReadyForDisplay,
|
||||||
|
onSeek,
|
||||||
|
onSeekComplete,
|
||||||
|
onVolumeChange,
|
||||||
|
onEnd,
|
||||||
|
onPlaybackStateChanged,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const nativeRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
const isSeeking = useRef(false);
|
||||||
|
const seek = useCallback(
|
||||||
|
async (time: number, _tolerance?: number) => {
|
||||||
|
if (isNaN(time)) {
|
||||||
|
throw new Error('Specified time is not a number');
|
||||||
|
}
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
console.warn('Video Component is not mounted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
time = Math.max(0, Math.min(time, nativeRef.current.duration));
|
||||||
|
nativeRef.current.currentTime = time;
|
||||||
|
onSeek?.({seekTime: time, currentTime: nativeRef.current.currentTime});
|
||||||
|
},
|
||||||
|
[onSeek],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nativeRef.current.pause();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// play() rejects if it is called before the stream has data (common while
|
||||||
|
// hls.js is still parsing the manifest) or if autoplay is denied. Neither
|
||||||
|
// is fatal, so swallow it rather than surfacing an unhandled rejection.
|
||||||
|
nativeRef.current.play()?.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVolume = useCallback((vol: number) => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nativeRef.current.volume = Math.max(0, Math.min(vol, 100)) / 100;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCurrentPosition = useCallback(async () => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
throw new Error('Video Component is not mounted');
|
||||||
|
}
|
||||||
|
return nativeRef.current.currentTime;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unsupported = useCallback(() => {
|
||||||
|
throw new Error('This is unsupported on the web');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Stock this in a ref to not invalidate memoization when those changes.
|
||||||
|
const fsPrefs = useRef({
|
||||||
|
fullscreenAutorotate,
|
||||||
|
fullscreenOrientation,
|
||||||
|
});
|
||||||
|
fsPrefs.current = {
|
||||||
|
fullscreenOrientation,
|
||||||
|
fullscreenAutorotate,
|
||||||
|
};
|
||||||
|
const setFullScreen = useCallback(
|
||||||
|
(
|
||||||
|
newVal: boolean,
|
||||||
|
orientation?: ReactVideoProps['fullscreenOrientation'],
|
||||||
|
autorotate?: boolean,
|
||||||
|
) => {
|
||||||
|
orientation ??= fsPrefs.current.fullscreenOrientation;
|
||||||
|
autorotate ??= fsPrefs.current.fullscreenAutorotate;
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
const browserOrientation = screen.orientation as
|
||||||
|
| {
|
||||||
|
lock?: (orientation: 'landscape' | 'portrait') => Promise<void>;
|
||||||
|
unlock?: () => void;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
try {
|
||||||
|
if (newVal) {
|
||||||
|
await nativeRef.current?.requestFullscreen({
|
||||||
|
navigationUI: 'hide',
|
||||||
|
});
|
||||||
|
if (orientation === 'all' || !orientation || autorotate) {
|
||||||
|
if (typeof browserOrientation?.unlock === 'function') {
|
||||||
|
browserOrientation.unlock();
|
||||||
|
}
|
||||||
|
} else if (typeof browserOrientation?.lock === 'function') {
|
||||||
|
await browserOrientation.lock(orientation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
if (typeof browserOrientation?.unlock === 'function') {
|
||||||
|
browserOrientation.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Changing fullscreen status without a button click is not allowed so it throws.
|
||||||
|
// Some browsers also used to throw when locking screen orientation was not supported.
|
||||||
|
console.error('Could not toggle fullscreen/screen lock status', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFullScreen(
|
||||||
|
fullscreen || false,
|
||||||
|
fullscreenOrientation,
|
||||||
|
fullscreenAutorotate,
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
setFullScreen,
|
||||||
|
fullscreen,
|
||||||
|
fullscreenAutorotate,
|
||||||
|
fullscreenOrientation,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const presentFullscreenPlayer = useCallback(
|
||||||
|
() => setFullScreen(true),
|
||||||
|
[setFullScreen],
|
||||||
|
);
|
||||||
|
const dismissFullscreenPlayer = useCallback(
|
||||||
|
() => setFullScreen(false),
|
||||||
|
[setFullScreen],
|
||||||
|
);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
seek,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
setVolume,
|
||||||
|
getCurrentPosition,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
dismissFullscreenPlayer,
|
||||||
|
setFullScreen,
|
||||||
|
save: unsupported,
|
||||||
|
restoreUserInterfaceForPictureInPictureStopCompleted: unsupported,
|
||||||
|
nativeHtmlVideoRef: nativeRef,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
seek,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
unsupported,
|
||||||
|
setVolume,
|
||||||
|
getCurrentPosition,
|
||||||
|
nativeRef,
|
||||||
|
presentFullscreenPlayer,
|
||||||
|
dismissFullscreenPlayer,
|
||||||
|
setFullScreen,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (paused) {
|
||||||
|
pause();
|
||||||
|
} else {
|
||||||
|
resume();
|
||||||
|
}
|
||||||
|
}, [paused, pause, resume]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (volume === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVolume(volume);
|
||||||
|
}, [volume, setVolume]);
|
||||||
|
|
||||||
|
// we use a ref to prevent triggerring the useEffect when the component rerender with a non-stable `onPlaybackStateChanged`.
|
||||||
|
const playbackStateRef = useRef(onPlaybackStateChanged);
|
||||||
|
playbackStateRef.current = onPlaybackStateChanged;
|
||||||
|
useEffect(() => {
|
||||||
|
// Not sure about how to do this but we want to wait for nativeRef to be initialized
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set play state to the player's value (if autoplay is denied)
|
||||||
|
// This is useful if our UI is in a play state but autoplay got denied so
|
||||||
|
// the video is actaully in a paused state.
|
||||||
|
playbackStateRef.current?.({
|
||||||
|
isPlaying: !nativeRef.current.paused,
|
||||||
|
isSeeking: isSeeking.current,
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!nativeRef.current || rate === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nativeRef.current.playbackRate = rate;
|
||||||
|
}, [rate]);
|
||||||
|
|
||||||
|
// `onError` is typically a fresh closure on every render. Keep it in a ref so
|
||||||
|
// the source effect below can depend on the URI alone -- callers pass
|
||||||
|
// `source={{uri}}` as a new object literal each render, so keying the effect
|
||||||
|
// on anything object-shaped would tear down and reload the stream constantly.
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
const playbackErrorReporterRef =
|
||||||
|
useRef<ReturnType<typeof createWebPlaybackErrorReporter>>();
|
||||||
|
if (!playbackErrorReporterRef.current) {
|
||||||
|
playbackErrorReporterRef.current = createWebPlaybackErrorReporter(
|
||||||
|
(error) => onErrorRef.current?.({error}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const playbackDiagnosticsRef = useRef<{
|
||||||
|
mode: WebHlsPlaybackMode;
|
||||||
|
nativeHlsSupport: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const uri = source?.uri as string | undefined;
|
||||||
|
const sourceType = source?.type;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = nativeRef.current;
|
||||||
|
if (!video || !uri) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playbackErrorReporterRef.current?.reset();
|
||||||
|
const nativeHlsSupport = video.canPlayType(HLS_MIME);
|
||||||
|
const mode = selectWebHlsPlaybackMode(uri, sourceType, {
|
||||||
|
hlsJsSupported: Hls.isSupported(),
|
||||||
|
nativeHlsSupport,
|
||||||
|
preferNativeHls: isAppleWebKit(navigator.userAgent, navigator.vendor),
|
||||||
|
});
|
||||||
|
playbackDiagnosticsRef.current = {mode, nativeHlsSupport};
|
||||||
|
|
||||||
|
if (mode === 'native') {
|
||||||
|
video.src = uri;
|
||||||
|
return () => {
|
||||||
|
playbackErrorReporterRef.current?.reset();
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
|
// hls.js surfaces plenty of recoverable warnings; only fatal errors are
|
||||||
|
// worth propagating as a video error.
|
||||||
|
if (!data.fatal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorString = `${describeHlsError(
|
||||||
|
data,
|
||||||
|
)}; mode=hls.js; nativeHlsSupport=${nativeHlsSupport || 'none'}`;
|
||||||
|
playbackErrorReporterRef.current?.addHls(
|
||||||
|
errorString,
|
||||||
|
`hls.js/${data.type}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
hls.loadSource(uri);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
playbackErrorReporterRef.current?.reset();
|
||||||
|
hls.destroy();
|
||||||
|
};
|
||||||
|
}, [sourceType, uri]);
|
||||||
|
|
||||||
|
useMediaSession(source?.metadata, nativeRef, showNotificationControls);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={nativeRef}
|
||||||
|
muted={muted}
|
||||||
|
autoPlay={!paused}
|
||||||
|
controls={controls}
|
||||||
|
loop={repeat}
|
||||||
|
playsInline
|
||||||
|
poster={resolvePosterUri(poster)}
|
||||||
|
onCanPlay={() => onBuffer?.({isBuffering: false})}
|
||||||
|
onWaiting={() => onBuffer?.({isBuffering: true})}
|
||||||
|
onRateChange={() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPlaybackRateChange?.({
|
||||||
|
playbackRate: nativeRef.current?.playbackRate,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDurationChange={() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLoad?.({
|
||||||
|
currentTime: nativeRef.current.currentTime,
|
||||||
|
duration: nativeRef.current.duration,
|
||||||
|
videoTracks: [],
|
||||||
|
textTracks: [],
|
||||||
|
audioTracks: [],
|
||||||
|
naturalSize: {
|
||||||
|
width: nativeRef.current.videoWidth,
|
||||||
|
height: nativeRef.current.videoHeight,
|
||||||
|
orientation: 'landscape',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onTimeUpdate={() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onProgress?.({
|
||||||
|
currentTime: nativeRef.current.currentTime,
|
||||||
|
playableDuration: nativeRef.current.buffered.length
|
||||||
|
? nativeRef.current.buffered.end(
|
||||||
|
nativeRef.current.buffered.length - 1,
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
|
seekableDuration: 0,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onLoadedData={() => onReadyForDisplay?.()}
|
||||||
|
onError={() => {
|
||||||
|
const video = nativeRef.current;
|
||||||
|
if (!video?.error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diagnostics = playbackDiagnosticsRef.current;
|
||||||
|
const mediaError = describeMediaError({
|
||||||
|
currentSrc: video.currentSrc,
|
||||||
|
networkState: video.networkState,
|
||||||
|
readyState: video.readyState,
|
||||||
|
error: video.error,
|
||||||
|
playbackMode: diagnostics?.mode,
|
||||||
|
nativeHlsSupport: diagnostics?.nativeHlsSupport,
|
||||||
|
});
|
||||||
|
playbackErrorReporterRef.current?.addMedia(
|
||||||
|
mediaError,
|
||||||
|
video.error.code,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onLoadedMetadata={() => {
|
||||||
|
if (source?.startPosition) {
|
||||||
|
seek(source.startPosition / 1000);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPlay={() =>
|
||||||
|
onPlaybackStateChanged?.({
|
||||||
|
isPlaying: true,
|
||||||
|
isSeeking: isSeeking.current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onPause={() =>
|
||||||
|
onPlaybackStateChanged?.({
|
||||||
|
isPlaying: false,
|
||||||
|
isSeeking: isSeeking.current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onSeeking={() => (isSeeking.current = true)}
|
||||||
|
onSeeked={() => {
|
||||||
|
isSeeking.current = false;
|
||||||
|
const currentTime = nativeRef.current?.currentTime ?? 0;
|
||||||
|
onSeekComplete?.({
|
||||||
|
currentTime,
|
||||||
|
seekTime: currentTime,
|
||||||
|
target: 0,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onVolumeChange={() => {
|
||||||
|
if (!nativeRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onVolumeChange?.({volume: nativeRef.current.volume});
|
||||||
|
}}
|
||||||
|
onEnded={onEnd}
|
||||||
|
style={videoStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoStyle = {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
// Use 'fill' instead of 'contain' to force video to match container dimensions.
|
||||||
|
// This works around browsers miscalculating intrinsic dimensions from rotation matrices.
|
||||||
|
objectFit: 'fill',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
} satisfies React.CSSProperties;
|
||||||
|
|
||||||
|
const useMediaSession = (
|
||||||
|
metadata: VideoMetadata | undefined,
|
||||||
|
nativeRef: RefObject<HTMLVideoElement>,
|
||||||
|
showNotification: boolean,
|
||||||
|
) => {
|
||||||
|
const isPlaying = nativeRef.current ? !nativeRef.current.paused : false;
|
||||||
|
const progress = nativeRef.current?.currentTime ?? 0;
|
||||||
|
const duration = Number.isFinite(nativeRef.current?.duration)
|
||||||
|
? nativeRef.current?.duration
|
||||||
|
: undefined;
|
||||||
|
const playbackRate = nativeRef.current?.playbackRate ?? 1;
|
||||||
|
|
||||||
|
const enabled = 'mediaSession' in navigator && showNotification;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: metadata?.title,
|
||||||
|
artist: metadata?.artist,
|
||||||
|
artwork: metadata?.imageUri ? [{src: metadata.imageUri}] : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [enabled, metadata]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekTo = (time: number) => {
|
||||||
|
if (nativeRef.current) {
|
||||||
|
nativeRef.current.currentTime = time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekRelative = (offset: number) => {
|
||||||
|
if (nativeRef.current) {
|
||||||
|
nativeRef.current.currentTime = nativeRef.current.currentTime + offset;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mediaActions: [
|
||||||
|
MediaSessionAction,
|
||||||
|
MediaSessionActionHandler | null,
|
||||||
|
][] = [
|
||||||
|
['play', () => nativeRef.current?.play()],
|
||||||
|
['pause', () => nativeRef.current?.pause()],
|
||||||
|
[
|
||||||
|
'seekbackward',
|
||||||
|
(evt: MediaSessionActionDetails) =>
|
||||||
|
seekRelative(evt.seekOffset ? -evt.seekOffset : -10),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'seekforward',
|
||||||
|
(evt: MediaSessionActionDetails) =>
|
||||||
|
seekRelative(evt.seekOffset ? evt.seekOffset : 10),
|
||||||
|
],
|
||||||
|
['seekto', (evt: MediaSessionActionDetails) => seekTo(evt.seekTime!)],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [action, handler] of mediaActions) {
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setActionHandler(action, handler);
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [enabled, nativeRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled) {
|
||||||
|
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
|
||||||
|
}
|
||||||
|
}, [isPlaying, enabled]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (enabled && duration !== undefined) {
|
||||||
|
navigator.mediaSession.setPositionState({
|
||||||
|
position: Math.min(progress, duration),
|
||||||
|
duration,
|
||||||
|
playbackRate: playbackRate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [progress, duration, playbackRate, enabled]);
|
||||||
|
};
|
||||||
|
|
||||||
|
Video.displayName = 'Video';
|
||||||
|
export default Video;
|
||||||
34
src/VideoDecoderProperties.web.ts
Normal file
34
src/VideoDecoderProperties.web.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
import type {VideoDecoderInfoModuleType} from './specs/NativeVideoDecoderInfoModule';
|
||||||
|
|
||||||
|
const canPlay = (codec: string): boolean => {
|
||||||
|
// most chrome based browser (and safari I think) supports matroska but reports they do not.
|
||||||
|
// for those browsers, only check the codecs and not the container.
|
||||||
|
if (navigator.userAgent.search('Firefox') === -1) {
|
||||||
|
codec = codec.replace('video/x-matroska', 'video/mp4');
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!MediaSource.isTypeSupported(codec);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoDecoderProperties = {
|
||||||
|
async getWidevineLevel() {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async isCodecSupported(
|
||||||
|
mimeType: string,
|
||||||
|
_width: number,
|
||||||
|
_height: number,
|
||||||
|
): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||||
|
// TODO: Figure out if we can get hardware support information
|
||||||
|
return canPlay(mimeType) ? 'software' : 'unsupported';
|
||||||
|
},
|
||||||
|
|
||||||
|
async isHEVCSupported(): Promise<'unsupported' | 'hardware' | 'software'> {
|
||||||
|
// Just a dummy vidoe mime type codec with HEVC to check.
|
||||||
|
return canPlay('video/x-matroska; codecs="hvc1.1.4.L96.BO"')
|
||||||
|
? 'software'
|
||||||
|
: 'unsupported';
|
||||||
|
},
|
||||||
|
} satisfies VideoDecoderInfoModuleType;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import Video from './Video';
|
import Video from './Video';
|
||||||
export {VideoDecoderProperties} from './VideoDecoderProperties';
|
export {VideoDecoderProperties} from './VideoDecoderProperties';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export type {VideoRef} from './Video';
|
|
||||||
export {Video};
|
export {Video};
|
||||||
export default Video;
|
export default Video;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
|
|||||||
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
|
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
|
||||||
|
|
||||||
// @TODO rename to "Spec" when applying new arch
|
// @TODO rename to "Spec" when applying new arch
|
||||||
interface VideoDecoderInfoModuleType {
|
export interface VideoDecoderInfoModuleType {
|
||||||
getWidevineLevel: () => Promise<Int32>;
|
getWidevineLevel: () => Promise<Int32>;
|
||||||
isCodecSupported: (
|
isCodecSupported: (
|
||||||
mimeType: string,
|
mimeType: string,
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import type {
|
|||||||
Float,
|
Float,
|
||||||
UnsafeObject,
|
UnsafeObject,
|
||||||
} from 'react-native/Libraries/Types/CodegenTypes';
|
} from 'react-native/Libraries/Types/CodegenTypes';
|
||||||
|
import type {VideoSaveData} from '../types/video-ref';
|
||||||
export type VideoSaveData = {
|
|
||||||
uri: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// @TODO rename to "Spec" when applying new arch
|
// @TODO rename to "Spec" when applying new arch
|
||||||
export interface VideoManagerType {
|
export interface VideoManagerType {
|
||||||
|
|||||||
@@ -202,6 +202,12 @@ export type OnSeekData = Readonly<{
|
|||||||
seekTime: Float;
|
seekTime: Float;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type OnSeekCompleteData = Readonly<{
|
||||||
|
currentTime: number;
|
||||||
|
seekTime: number;
|
||||||
|
target: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type OnPlaybackStateChangedData = Readonly<{
|
export type OnPlaybackStateChangedData = Readonly<{
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
isSeeking: boolean;
|
isSeeking: boolean;
|
||||||
@@ -284,12 +290,12 @@ type OnReceiveAdEventData = Readonly<{
|
|||||||
|
|
||||||
export type OnVideoErrorData = Readonly<{
|
export type OnVideoErrorData = Readonly<{
|
||||||
error: Readonly<{
|
error: Readonly<{
|
||||||
errorString?: string; // android
|
errorString?: string; // android | web
|
||||||
errorException?: string; // android
|
errorException?: string; // android
|
||||||
errorStackTrace?: string; // android
|
errorStackTrace?: string; // android
|
||||||
errorCode?: string; // android
|
errorCode?: string; // android
|
||||||
error?: string; // ios
|
error?: string; // ios
|
||||||
code?: Int32; // ios
|
code?: Int32; // ios | web
|
||||||
localizedDescription?: string; // ios
|
localizedDescription?: string; // ios
|
||||||
localizedFailureReason?: string; // ios
|
localizedFailureReason?: string; // ios
|
||||||
localizedRecoverySuggestion?: string; // ios
|
localizedRecoverySuggestion?: string; // ios
|
||||||
@@ -377,6 +383,7 @@ export interface VideoNativeProps extends ViewProps {
|
|||||||
onVideoProgress?: DirectEventHandler<OnProgressData>;
|
onVideoProgress?: DirectEventHandler<OnProgressData>;
|
||||||
onVideoBandwidthUpdate?: DirectEventHandler<OnBandwidthUpdateData>;
|
onVideoBandwidthUpdate?: DirectEventHandler<OnBandwidthUpdateData>;
|
||||||
onVideoSeek?: DirectEventHandler<OnSeekData>;
|
onVideoSeek?: DirectEventHandler<OnSeekData>;
|
||||||
|
onVideoSeekComplete?: DirectEventHandler<OnSeekCompleteData>;
|
||||||
onVideoEnd?: DirectEventHandler<{}>; // all
|
onVideoEnd?: DirectEventHandler<{}>; // all
|
||||||
onVideoAudioBecomingNoisy?: DirectEventHandler<{}>;
|
onVideoAudioBecomingNoisy?: DirectEventHandler<{}>;
|
||||||
onVideoFullscreenPlayerWillPresent?: DirectEventHandler<{}>; // ios, android
|
onVideoFullscreenPlayerWillPresent?: DirectEventHandler<{}>; // ios, android
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
OnPlaybackStateChangedData,
|
OnPlaybackStateChangedData,
|
||||||
OnProgressData,
|
OnProgressData,
|
||||||
OnSeekData,
|
OnSeekData,
|
||||||
|
OnSeekCompleteData,
|
||||||
OnTextTrackDataChangedData,
|
OnTextTrackDataChangedData,
|
||||||
OnTimedMetadataData,
|
OnTimedMetadataData,
|
||||||
OnVideoAspectRatioData,
|
OnVideoAspectRatioData,
|
||||||
@@ -20,6 +21,8 @@ import type {
|
|||||||
OnVolumeChangeData,
|
OnVolumeChangeData,
|
||||||
} from '../specs/VideoNativeComponent';
|
} from '../specs/VideoNativeComponent';
|
||||||
|
|
||||||
|
export type * from '../specs/VideoNativeComponent';
|
||||||
|
|
||||||
export type AudioTrack = OnAudioTracksData['audioTracks'][number];
|
export type AudioTrack = OnAudioTracksData['audioTracks'][number];
|
||||||
export type TextTrack = OnTextTracksData['textTracks'][number];
|
export type TextTrack = OnTextTracksData['textTracks'][number];
|
||||||
export type VideoTrack = OnVideoTracksData['videoTracks'][number];
|
export type VideoTrack = OnVideoTracksData['videoTracks'][number];
|
||||||
@@ -258,6 +261,7 @@ export interface ReactVideoEvents {
|
|||||||
onReceiveAdEvent?: (e: OnReceiveAdEventData) => void; //Android, iOS
|
onReceiveAdEvent?: (e: OnReceiveAdEventData) => void; //Android, iOS
|
||||||
onRestoreUserInterfaceForPictureInPictureStop?: () => void; //iOS
|
onRestoreUserInterfaceForPictureInPictureStop?: () => void; //iOS
|
||||||
onSeek?: (e: OnSeekData) => void; //Android, iOS, Windows UWP
|
onSeek?: (e: OnSeekData) => void; //Android, iOS, Windows UWP
|
||||||
|
onSeekComplete?: (e: OnSeekCompleteData) => void; // iOS
|
||||||
onPlaybackStateChanged?: (e: OnPlaybackStateChangedData) => void; // Android, iOS
|
onPlaybackStateChanged?: (e: OnPlaybackStateChangedData) => void; // Android, iOS
|
||||||
onTimedMetadata?: (e: OnTimedMetadataData) => void; //Android, iOS
|
onTimedMetadata?: (e: OnTimedMetadataData) => void; //Android, iOS
|
||||||
onAudioTracks?: (e: OnAudioTracksData) => void; // Android
|
onAudioTracks?: (e: OnAudioTracksData) => void; // Android
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ export {default as ResizeMode} from './ResizeMode';
|
|||||||
export {default as TextTrackType} from './TextTrackType';
|
export {default as TextTrackType} from './TextTrackType';
|
||||||
export {default as ViewType} from './ViewType';
|
export {default as ViewType} from './ViewType';
|
||||||
export * from './video';
|
export * from './video';
|
||||||
export * from '../specs/VideoNativeComponent';
|
export * from './video-ref';
|
||||||
|
|||||||
21
src/types/video-ref.ts
Normal file
21
src/types/video-ref.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type {RefObject} from 'react';
|
||||||
|
|
||||||
|
export type VideoSaveData = {
|
||||||
|
uri: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface VideoRef {
|
||||||
|
seek: (time: number, tolerance?: number) => void;
|
||||||
|
resume: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
presentFullscreenPlayer: () => void;
|
||||||
|
dismissFullscreenPlayer: () => void;
|
||||||
|
restoreUserInterfaceForPictureInPictureStopCompleted: (
|
||||||
|
restore: boolean,
|
||||||
|
) => void;
|
||||||
|
save: (options: object) => Promise<VideoSaveData>;
|
||||||
|
setVolume: (volume: number) => void;
|
||||||
|
getCurrentPosition: () => Promise<number>;
|
||||||
|
setFullScreen: (fullScreen: boolean) => void;
|
||||||
|
nativeHtmlVideoRef?: RefObject<HTMLVideoElement>; // web only
|
||||||
|
}
|
||||||
248
src/webHls.ts
Normal file
248
src/webHls.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
export type WebHlsPlaybackMode = 'hls.js' | 'native';
|
||||||
|
|
||||||
|
export interface WebHlsPlaybackCapabilities {
|
||||||
|
hlsJsSupported: boolean;
|
||||||
|
nativeHlsSupport: string;
|
||||||
|
preferNativeHls: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HlsErrorDetails {
|
||||||
|
type: string;
|
||||||
|
details: string;
|
||||||
|
reason?: string;
|
||||||
|
error?: {message?: string};
|
||||||
|
response?: {
|
||||||
|
code?: number;
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
data?: unknown;
|
||||||
|
};
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaElementDiagnostics {
|
||||||
|
currentSrc: string;
|
||||||
|
networkState: number;
|
||||||
|
readyState: number;
|
||||||
|
error: {code: number; message?: string} | null;
|
||||||
|
playbackMode?: WebHlsPlaybackMode;
|
||||||
|
nativeHlsSupport?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebVideoError {
|
||||||
|
errorString: string;
|
||||||
|
code?: number;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HLS_SOURCE_PATTERN = /\.m3u8(\?|#|$)/i;
|
||||||
|
const HLS_SOURCE_TYPES = new Set([
|
||||||
|
'application/vnd.apple.mpegurl',
|
||||||
|
'application/x-mpegurl',
|
||||||
|
'hls',
|
||||||
|
'm3u8',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MEDIA_ERROR_NAMES: Record<number, string> = {
|
||||||
|
1: 'MEDIA_ERR_ABORTED',
|
||||||
|
2: 'MEDIA_ERR_NETWORK',
|
||||||
|
3: 'MEDIA_ERR_DECODE',
|
||||||
|
4: 'MEDIA_ERR_SRC_NOT_SUPPORTED',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isHlsSource = (uri: string, sourceType?: string): boolean => {
|
||||||
|
const normalizedType = sourceType?.split(';', 1)[0]?.trim().toLowerCase();
|
||||||
|
return (
|
||||||
|
HLS_SOURCE_PATTERN.test(uri) ||
|
||||||
|
(normalizedType !== undefined && HLS_SOURCE_TYPES.has(normalizedType))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isAppleWebKit = (userAgent: string, vendor: string): boolean =>
|
||||||
|
/AppleWebKit/i.test(userAgent) && /Apple/i.test(vendor);
|
||||||
|
|
||||||
|
export const selectWebHlsPlaybackMode = (
|
||||||
|
uri: string,
|
||||||
|
sourceType: string | undefined,
|
||||||
|
{
|
||||||
|
hlsJsSupported,
|
||||||
|
nativeHlsSupport,
|
||||||
|
preferNativeHls,
|
||||||
|
}: WebHlsPlaybackCapabilities,
|
||||||
|
): WebHlsPlaybackMode => {
|
||||||
|
if (!isHlsSource(uri, sourceType)) {
|
||||||
|
return 'native';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferNativeHls && nativeHlsSupport) {
|
||||||
|
return 'native';
|
||||||
|
}
|
||||||
|
|
||||||
|
return hlsJsSupported ? 'hls.js' : 'native';
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactText = (value: string): string =>
|
||||||
|
value.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const limitedText = (value: string, maxLength = 160): string => {
|
||||||
|
const compact = compactText(value);
|
||||||
|
return compact.length > maxLength
|
||||||
|
? `${compact.slice(0, maxLength)}…`
|
||||||
|
: compact;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAFE_RESPONSE_MESSAGES = new Set([
|
||||||
|
'Playlist does not exist',
|
||||||
|
'Video stream not found',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const sanitizeDiagnosticUrl = (value: string): string => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
url.username = '';
|
||||||
|
url.password = '';
|
||||||
|
url.search = url.search ? '?<redacted>' : '';
|
||||||
|
url.hash = url.hash ? '#<redacted>' : '';
|
||||||
|
return url.toString().replaceAll('%3Credacted%3E', '<redacted>');
|
||||||
|
} catch {
|
||||||
|
return value.replace(/[?#].*$/, '?<redacted>');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sanitizeHlsText = (value: string, data: HlsErrorDetails): string => {
|
||||||
|
let sanitized = value;
|
||||||
|
for (const url of [data.response?.url, data.url]) {
|
||||||
|
if (url) {
|
||||||
|
sanitized = sanitized.replaceAll(url, sanitizeDiagnosticUrl(url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limitedText(sanitized);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeHlsError = (data: HlsErrorDetails): string => {
|
||||||
|
const parts = [`hls.js fatal ${data.type}: ${data.details}`];
|
||||||
|
|
||||||
|
if (data.reason && data.reason !== data.details) {
|
||||||
|
parts.push(`reason=${sanitizeHlsText(data.reason, data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.error?.message && data.error.message !== data.reason) {
|
||||||
|
parts.push(`message=${sanitizeHlsText(data.error.message, data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = data.response;
|
||||||
|
if (response) {
|
||||||
|
if (response.code !== undefined) {
|
||||||
|
const statusText = response.text ? ` ${limitedText(response.text)}` : '';
|
||||||
|
parts.push(`HTTP ${response.code}${statusText}`);
|
||||||
|
}
|
||||||
|
if (typeof response.data === 'string' && response.data.trim()) {
|
||||||
|
const responseBody = compactText(response.data);
|
||||||
|
if (SAFE_RESPONSE_MESSAGES.has(responseBody)) {
|
||||||
|
parts.push(`response=${responseBody}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`responseBodyLength=${response.data.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (response.url) {
|
||||||
|
parts.push(`url=${sanitizeDiagnosticUrl(response.url)}`);
|
||||||
|
}
|
||||||
|
} else if (data.url) {
|
||||||
|
parts.push(`url=${sanitizeDiagnosticUrl(data.url)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('; ');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createWebPlaybackErrorReporter = (
|
||||||
|
report: (error: WebVideoError) => void,
|
||||||
|
debounceMs = 50,
|
||||||
|
) => {
|
||||||
|
let hlsError: string | undefined;
|
||||||
|
let hlsDomain: string | undefined;
|
||||||
|
let mediaError: string | undefined;
|
||||||
|
let mediaCode: number | undefined;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let reported = false;
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timeout !== undefined) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = () => {
|
||||||
|
if (reported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = undefined;
|
||||||
|
reported = true;
|
||||||
|
const errorString = [hlsError, mediaError].filter(Boolean).join('; ');
|
||||||
|
report({
|
||||||
|
errorString,
|
||||||
|
code: mediaCode,
|
||||||
|
domain: hlsError
|
||||||
|
? mediaError
|
||||||
|
? 'hls.js/HTMLMediaElement'
|
||||||
|
: hlsDomain || 'hls.js'
|
||||||
|
: 'HTMLMediaElement',
|
||||||
|
});
|
||||||
|
}, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
clearTimer();
|
||||||
|
hlsError = undefined;
|
||||||
|
hlsDomain = undefined;
|
||||||
|
mediaError = undefined;
|
||||||
|
mediaCode = undefined;
|
||||||
|
reported = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addHls(errorString: string, domain: string) {
|
||||||
|
if (reported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hlsError = errorString;
|
||||||
|
hlsDomain = domain;
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
addMedia(errorString: string, code: number) {
|
||||||
|
if (reported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mediaError = errorString;
|
||||||
|
mediaCode = code;
|
||||||
|
schedule();
|
||||||
|
},
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const describeMediaError = (media: MediaElementDiagnostics): string => {
|
||||||
|
const code = media.error?.code ?? 0;
|
||||||
|
const errorName = MEDIA_ERROR_NAMES[code] ?? 'MEDIA_ERR_UNKNOWN';
|
||||||
|
const parts = [`HTMLMediaElement ${errorName} (${code})`];
|
||||||
|
const message = media.error?.message?.trim();
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
parts.push(`message=${limitedText(message)}`);
|
||||||
|
}
|
||||||
|
if (media.playbackMode) {
|
||||||
|
parts.push(`mode=${media.playbackMode}`);
|
||||||
|
}
|
||||||
|
if (media.nativeHlsSupport !== undefined) {
|
||||||
|
parts.push(`nativeHlsSupport=${media.nativeHlsSupport || 'none'}`);
|
||||||
|
}
|
||||||
|
if (media.currentSrc) {
|
||||||
|
parts.push(`src=${sanitizeDiagnosticUrl(media.currentSrc)}`);
|
||||||
|
}
|
||||||
|
parts.push(`networkState=${media.networkState}`);
|
||||||
|
parts.push(`readyState=${media.readyState}`);
|
||||||
|
|
||||||
|
return parts.join('; ');
|
||||||
|
};
|
||||||
1
test/.gitignore
vendored
Normal file
1
test/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
lib/
|
||||||
9
test/tsconfig.json
Normal file
9
test/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": false,
|
||||||
|
"rootDir": "../src",
|
||||||
|
"outDir": "./lib"
|
||||||
|
},
|
||||||
|
"include": ["../src/webHls.ts"]
|
||||||
|
}
|
||||||
228
test/web-hls.test.js
Normal file
228
test/web-hls.test.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createWebPlaybackErrorReporter,
|
||||||
|
describeHlsError,
|
||||||
|
describeMediaError,
|
||||||
|
isAppleWebKit,
|
||||||
|
isHlsSource,
|
||||||
|
sanitizeDiagnosticUrl,
|
||||||
|
selectWebHlsPlaybackMode,
|
||||||
|
} = require('./lib/webHls');
|
||||||
|
|
||||||
|
test('recognizes HLS playlist URLs with query strings', () => {
|
||||||
|
assert.equal(isHlsSource('https://api.example/playlist/42.m3u8?vod=1'), true);
|
||||||
|
assert.equal(isHlsSource('https://cdn.example/video.mp4'), false);
|
||||||
|
assert.equal(
|
||||||
|
isHlsSource('https://api.example/playlist/42', 'application/x-mpegURL'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses hls.js in Chromium even when native HLS support reports maybe', () => {
|
||||||
|
const userAgent =
|
||||||
|
'Mozilla/5.0 Chrome/146.0.0.0 Safari/537.36 AppleWebKit/537.36';
|
||||||
|
assert.equal(isAppleWebKit(userAgent, 'Google Inc.'), false);
|
||||||
|
assert.equal(
|
||||||
|
selectWebHlsPlaybackMode(
|
||||||
|
'https://api.example/playlist/42.m3u8',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
hlsJsSupported: true,
|
||||||
|
nativeHlsSupport: 'maybe',
|
||||||
|
preferNativeHls: isAppleWebKit(userAgent, 'Google Inc.'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'hls.js',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses hls.js for an extensionless HLS source with an explicit type', () => {
|
||||||
|
assert.equal(
|
||||||
|
selectWebHlsPlaybackMode(
|
||||||
|
'https://api.example/playlist/42',
|
||||||
|
'application/vnd.apple.mpegurl; charset=utf-8',
|
||||||
|
{
|
||||||
|
hlsJsSupported: true,
|
||||||
|
nativeHlsSupport: '',
|
||||||
|
preferNativeHls: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'hls.js',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves native HLS in Apple WebKit browsers', () => {
|
||||||
|
const userAgent =
|
||||||
|
'Mozilla/5.0 Version/18.6 Safari/605.1.15 AppleWebKit/605.1.15';
|
||||||
|
assert.equal(isAppleWebKit(userAgent, 'Apple Computer, Inc.'), true);
|
||||||
|
assert.equal(
|
||||||
|
selectWebHlsPlaybackMode(
|
||||||
|
'https://api.example/playlist/42.m3u8',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
hlsJsSupported: true,
|
||||||
|
nativeHlsSupport: 'maybe',
|
||||||
|
preferNativeHls: isAppleWebKit(userAgent, 'Apple Computer, Inc.'),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'native',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to native playback when hls.js is unavailable', () => {
|
||||||
|
assert.equal(
|
||||||
|
selectWebHlsPlaybackMode(
|
||||||
|
'https://api.example/playlist/42.m3u8',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
hlsJsSupported: false,
|
||||||
|
nativeHlsSupport: 'probably',
|
||||||
|
preferNativeHls: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
'native',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
selectWebHlsPlaybackMode('https://cdn.example/video.mp4', undefined, {
|
||||||
|
hlsJsSupported: true,
|
||||||
|
nativeHlsSupport: '',
|
||||||
|
preferNativeHls: false,
|
||||||
|
}),
|
||||||
|
'native',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('includes fatal HLS network and response details', () => {
|
||||||
|
assert.equal(
|
||||||
|
describeHlsError({
|
||||||
|
type: 'networkError',
|
||||||
|
details: 'manifestParsingError',
|
||||||
|
reason: 'no EXTM3U delimiter',
|
||||||
|
error: {message: 'manifest parsing failed'},
|
||||||
|
response: {
|
||||||
|
code: 200,
|
||||||
|
text: 'OK',
|
||||||
|
data: 'Playlist does not exist',
|
||||||
|
url: 'https://api.example/playlist/42.m3u8',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'hls.js fatal networkError: manifestParsingError; ' +
|
||||||
|
'reason=no EXTM3U delimiter; message=manifest parsing failed; HTTP 200 OK; ' +
|
||||||
|
'response=Playlist does not exist; ' +
|
||||||
|
'url=https://api.example/playlist/42.m3u8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redacts URL credentials, query values, and unknown response bodies', () => {
|
||||||
|
assert.equal(
|
||||||
|
sanitizeDiagnosticUrl(
|
||||||
|
'https://user:password@cdn.example/42.m3u8?token=secret#private',
|
||||||
|
),
|
||||||
|
'https://cdn.example/42.m3u8?<redacted>#<redacted>',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeHlsError({
|
||||||
|
type: 'networkError',
|
||||||
|
details: 'manifestLoadError',
|
||||||
|
response: {
|
||||||
|
code: 403,
|
||||||
|
data: 'internal credential: secret',
|
||||||
|
url: 'https://cdn.example/42.m3u8?token=secret',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'hls.js fatal networkError: manifestLoadError; HTTP 403; ' +
|
||||||
|
'responseBodyLength=27; url=https://cdn.example/42.m3u8?<redacted>',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
describeHlsError({
|
||||||
|
type: 'keySystemError',
|
||||||
|
details: 'licenseRequestFailed',
|
||||||
|
error: {
|
||||||
|
message:
|
||||||
|
'License request failed (https://license.example/key?token=secret)',
|
||||||
|
},
|
||||||
|
url: 'https://license.example/key?token=secret',
|
||||||
|
}),
|
||||||
|
'hls.js fatal keySystemError: licenseRequestFailed; ' +
|
||||||
|
'message=License request failed (https://license.example/key?<redacted>); ' +
|
||||||
|
'url=https://license.example/key?<redacted>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const wait = (milliseconds) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
|
||||||
|
for (const order of ['hls-first', 'media-first']) {
|
||||||
|
test(`combines HLS and media errors once when ${order}`, async () => {
|
||||||
|
const reports = [];
|
||||||
|
const reporter = createWebPlaybackErrorReporter(
|
||||||
|
(error) => reports.push(error),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
const addHls = () =>
|
||||||
|
reporter.addHls(
|
||||||
|
'hls.js fatal mediaError: bufferAppendError',
|
||||||
|
'hls.js/mediaError',
|
||||||
|
);
|
||||||
|
const addMedia = () =>
|
||||||
|
reporter.addMedia('HTMLMediaElement MEDIA_ERR_DECODE (3)', 3);
|
||||||
|
|
||||||
|
if (order === 'hls-first') {
|
||||||
|
addHls();
|
||||||
|
addMedia();
|
||||||
|
} else {
|
||||||
|
addMedia();
|
||||||
|
addHls();
|
||||||
|
}
|
||||||
|
await wait(15);
|
||||||
|
|
||||||
|
assert.deepEqual(reports, [
|
||||||
|
{
|
||||||
|
errorString:
|
||||||
|
'hls.js fatal mediaError: bufferAppendError; ' +
|
||||||
|
'HTMLMediaElement MEDIA_ERR_DECODE (3)',
|
||||||
|
code: 3,
|
||||||
|
domain: 'hls.js/HTMLMediaElement',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reset cancels a pending source error and permits the next source', async () => {
|
||||||
|
const reports = [];
|
||||||
|
const reporter = createWebPlaybackErrorReporter(
|
||||||
|
(error) => reports.push(error),
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
reporter.addHls('stale source failure', 'hls.js/networkError');
|
||||||
|
reporter.reset();
|
||||||
|
reporter.addMedia('current source failure', 4);
|
||||||
|
await wait(15);
|
||||||
|
|
||||||
|
assert.deepEqual(reports, [
|
||||||
|
{
|
||||||
|
errorString: 'current source failure',
|
||||||
|
code: 4,
|
||||||
|
domain: 'HTMLMediaElement',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names media error code 4 and records the selected playback path', () => {
|
||||||
|
assert.equal(
|
||||||
|
describeMediaError({
|
||||||
|
currentSrc: 'https://api.example/playlist/42.m3u8',
|
||||||
|
networkState: 3,
|
||||||
|
readyState: 0,
|
||||||
|
error: {code: 4},
|
||||||
|
playbackMode: 'native',
|
||||||
|
nativeHlsSupport: 'maybe',
|
||||||
|
}),
|
||||||
|
'HTMLMediaElement MEDIA_ERR_SRC_NOT_SUPPORTED (4); mode=native; ' +
|
||||||
|
'nativeHlsSupport=maybe; src=https://api.example/playlist/42.m3u8; ' +
|
||||||
|
'networkState=3; readyState=0',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig",
|
"extends": "./tsconfig",
|
||||||
"exclude": ["examples", "lib"]
|
"exclude": ["examples", "lib"]
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"lib": ["esnext"],
|
"lib": ["esnext"],
|
||||||
"module": "CommonJS",
|
"module": "Node16",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "Node16",
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
"noImplicitUseStrict": false,
|
"noImplicitUseStrict": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user