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()
|
||||
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))
|
||||
}
|
||||
return sideLoadedTextTrackList
|
||||
|
||||
@@ -228,7 +228,7 @@ class Source {
|
||||
if (propSrcHeadersArray != null) {
|
||||
if (propSrcHeadersArray.size() > 0) {
|
||||
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 value = if (current.hasKey("value")) current.getString("value") else null
|
||||
if (key != null && value != null) {
|
||||
|
||||
@@ -22,6 +22,7 @@ enum class EventTypes(val eventName: String) {
|
||||
EVENT_BANDWIDTH("onVideoBandwidthUpdate"),
|
||||
EVENT_CONTROLS_VISIBILITY_CHANGE("onControlsVisibilityChange"),
|
||||
EVENT_SEEK("onVideoSeek"),
|
||||
EVENT_SEEK_COMPLETE("onVideoSeekComplete"),
|
||||
EVENT_END("onVideoEnd"),
|
||||
EVENT_FULLSCREEN_WILL_PRESENT("onVideoFullscreenPlayerWillPresent"),
|
||||
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 onVideoPlaybackStateChanged: (isPlaying: Boolean, isSeeking: Boolean) -> Unit
|
||||
lateinit var onVideoSeek: (currentPosition: Long, seekTime: Long) -> Unit
|
||||
lateinit var onVideoSeekComplete: (currentPosition: Long) -> Unit
|
||||
lateinit var onVideoEnd: () -> Unit
|
||||
lateinit var onVideoFullscreenPlayerWillPresent: () -> Unit
|
||||
lateinit var onVideoFullscreenPlayerDidPresent: () -> Unit
|
||||
@@ -174,6 +176,11 @@ class VideoEventEmitter {
|
||||
putDouble("seekTime", seekTime / 1000.0)
|
||||
}
|
||||
}
|
||||
onVideoSeekComplete = { currentPosition ->
|
||||
event.dispatch(EventTypes.EVENT_SEEK_COMPLETE) {
|
||||
putDouble("currentTime", currentPosition / 1000.0)
|
||||
}
|
||||
}
|
||||
onVideoEnd = {
|
||||
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) {
|
||||
fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) =
|
||||
dispatcher.dispatchEvent(object : Event<Event<*>>(surfaceId, viewId) {
|
||||
override fun getEventName() = "top${event.eventName.removePrefix("on")}"
|
||||
override fun getEventData() = Arguments.createMap().apply(paramsSetter ?: {})
|
||||
})
|
||||
fun dispatch(event: EventTypes, paramsSetter: (WritableMap.() -> Unit)? = null) {
|
||||
val eventName = "top${event.eventName.removePrefix("on")}"
|
||||
val eventData = Arguments.createMap().apply(paramsSetter ?: {})
|
||||
dispatcher.dispatchEvent(VideoEvent(surfaceId, viewId, eventName, eventData))
|
||||
}
|
||||
}
|
||||
|
||||
private fun audioTracksToArray(audioTracks: java.util.ArrayList<Track>?): WritableArray =
|
||||
|
||||
@@ -23,6 +23,7 @@ import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
import android.view.accessibility.CaptioningManager;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -224,6 +225,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
*/
|
||||
private boolean isSeeking = false;
|
||||
private long seekPosition = -1;
|
||||
private boolean isSeekInProgress = false;
|
||||
|
||||
// Props from React
|
||||
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) {
|
||||
Timeline.Window window = new Timeline.Window();
|
||||
if(!player.getCurrentTimeline().isEmpty()) {
|
||||
@@ -521,12 +533,21 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
builder.setSingleChoiceItems(speedOptions, selectedSpeedIndex, (dialog, which) -> {
|
||||
selectedSpeedIndex = which;
|
||||
float speed = switch (which) {
|
||||
case 0 -> 0.5f;
|
||||
case 2 -> 1.5f;
|
||||
case 3 -> 2.0f;
|
||||
default -> 1.0f;
|
||||
};
|
||||
float speed;
|
||||
switch (which) {
|
||||
case 0:
|
||||
speed = 0.5f;
|
||||
break;
|
||||
case 1:
|
||||
speed = 1.0f;
|
||||
break;
|
||||
case 2:
|
||||
speed = 1.5f;
|
||||
break;
|
||||
default:
|
||||
speed = 1.0f;
|
||||
break;
|
||||
}
|
||||
setRateModifier(speed);
|
||||
});
|
||||
builder.show();
|
||||
@@ -852,7 +873,8 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
.setBandwidthMeter(bandwidthMeter)
|
||||
.setLoadControl(loadControl)
|
||||
.setMediaSourceFactory(mediaSourceFactory)
|
||||
.build();
|
||||
.build();
|
||||
player.addListener(self);
|
||||
ReactNativeVideoManager.Companion.getInstance().onInstanceCreated(instanceId, player);
|
||||
refreshDebugState();
|
||||
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)) {
|
||||
int playbackState = player.getPlaybackState();
|
||||
boolean playWhenReady = player.getPlayWhenReady();
|
||||
Log.d("ReactExoplayerView", "onEvents: playbackState=" + playbackState + ", playWhenReady=" + playWhenReady);
|
||||
String text = "onStateChanged: playWhenReady=" + playWhenReady + ", playbackState=";
|
||||
eventEmitter.onPlaybackRateChange.invoke(playWhenReady && playbackState == ExoPlayer.STATE_READY ? 1.0f : 0.0f);
|
||||
switch (playbackState) {
|
||||
@@ -1470,6 +1493,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
playerControlView.show();
|
||||
}
|
||||
setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
|
||||
Log.d("ReactExoplayerView", "Player STATE_READY: currentPosition=" + player.getCurrentPosition());
|
||||
if (isSeekInProgress) {
|
||||
handleSeekCompletion();
|
||||
}
|
||||
break;
|
||||
case Player.STATE_ENDED:
|
||||
text += "ended";
|
||||
@@ -1734,6 +1761,7 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
@Override
|
||||
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) {
|
||||
isSeeking = true;
|
||||
seekPosition = newPosition.positionMs;
|
||||
@@ -2249,6 +2277,10 @@ public class ReactExoplayerView extends FrameLayout implements
|
||||
|
||||
public void seekTo(long positionMs) {
|
||||
if (player != null) {
|
||||
Log.d("ReactExoplayerView", "seekTo: positionMs=" + positionMs);
|
||||
isSeekInProgress = true;
|
||||
isSeeking = true;
|
||||
seekPosition = 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`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
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.
|
||||
|
||||
|
||||
NOTE: tracks (`audioTracks`, `textTracks` & `videoTracks`) are not available on the web.
|
||||
|
||||
Payload:
|
||||
|
||||
| Property | Type | Description |
|
||||
@@ -292,7 +295,7 @@ Example:
|
||||
|
||||
### `onPlaybackStateChanged`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Callback function that is called when the playback state changes.
|
||||
|
||||
@@ -463,7 +466,7 @@ Payload: none
|
||||
|
||||
### `onSeek`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'Windows UWP']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'Windows UWP', 'web']} />
|
||||
|
||||
Callback function that is called when a seek completes.
|
||||
|
||||
@@ -604,7 +607,7 @@ Example:
|
||||
|
||||
### `onVolumeChange`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Callback function that is called when the volume of player changes.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ This page shows the list of available methods
|
||||
|
||||
### `dismissFullscreenPlayer`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`dismissFullscreenPlayer(): Promise<void>`
|
||||
|
||||
@@ -17,7 +17,7 @@ Take the player out of fullscreen mode.
|
||||
|
||||
### `pause`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`pause(): Promise<void>`
|
||||
|
||||
@@ -25,7 +25,7 @@ Pause the video.
|
||||
|
||||
### `presentFullscreenPlayer`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`presentFullscreenPlayer(): Promise<void>`
|
||||
|
||||
@@ -40,7 +40,7 @@ On Android, this puts the navigation controls in fullscreen mode. It is not a co
|
||||
|
||||
### `resume`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`resume(): Promise<void>`
|
||||
|
||||
@@ -100,7 +100,7 @@ tolerance is the max distance in milliseconds from the seconds position that's a
|
||||
|
||||
### `setVolume`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`setVolume(value): Promise<void>`
|
||||
|
||||
@@ -108,7 +108,7 @@ This function will change the volume exactly like [volume](./props#volume) prope
|
||||
|
||||
### `getCurrentPosition`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`getCurrentPosition(): Promise<number>`
|
||||
|
||||
@@ -127,7 +127,7 @@ Changing source with this function will overide source provided as props.
|
||||
|
||||
### `setFullScreen`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
`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.
|
||||
|
||||
### `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
|
||||
|
||||
```tsx
|
||||
@@ -188,7 +195,7 @@ Possible values are:
|
||||
|
||||
### `isCodecSupported`
|
||||
|
||||
<PlatformsList types={['Android']} />
|
||||
<PlatformsList types={['Android', 'web']} />
|
||||
|
||||
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`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
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`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'web']} />
|
||||
|
||||
Controls whether the player enters fullscreen on play.
|
||||
See [presentFullscreenPlayer](#presentfullscreenplayer) for details.
|
||||
@@ -316,7 +316,7 @@ If a preferred [fullscreenOrientation](#fullscreenorientation) is set, causes th
|
||||
|
||||
### `fullscreenOrientation`
|
||||
|
||||
<PlatformsList types={['iOS', 'visionOS']} />
|
||||
<PlatformsList types={['iOS', 'visionOS', 'web']} />
|
||||
|
||||
- **all (default)** -
|
||||
- **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
|
||||
|
||||
<PlatformsList types={['Android', 'iOS', 'visionOS', 'Windows UWP']} />
|
||||
|
||||
Example:
|
||||
|
||||
Pass directly the asset to play (deprecated)
|
||||
@@ -818,7 +820,7 @@ Example:
|
||||
|
||||
#### 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.
|
||||
(If it is negative or undefined or null, it is ignored)
|
||||
@@ -1029,7 +1031,7 @@ textTracks={[
|
||||
|
||||
### `showNotificationControls`
|
||||
|
||||
<PlatformsList types={['Android', 'iOS']} />
|
||||
<PlatformsList types={['Android', 'iOS', 'web']} />
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,6 +8,7 @@ It allows to stream video files (m3u, mpd, mp4, ...) inside your react native ap
|
||||
- Exoplayer for android
|
||||
- AVplayer for iOS, tvOS and visionOS
|
||||
- Windows UWP for windows
|
||||
- HTML5 for web
|
||||
- Trick mode support
|
||||
- Subtitles (embeded or side loaded)
|
||||
- DRM support
|
||||
|
||||
@@ -181,3 +181,12 @@ Select RCTVideo-tvOS
|
||||
Run `pod install` in the `visionos` directory of your project
|
||||
|
||||
</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",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"web": "expo start --web",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"windows": "react-native run-windows",
|
||||
@@ -13,6 +14,7 @@
|
||||
"pod-install:newarch": "cd ios && RCT_NEW_ARCH_ENABLED=1 bundle exec pod install && cd .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~3.2.1",
|
||||
"@react-native-picker/picker": "2.7.5",
|
||||
"expo": "^51.0.32",
|
||||
"expo-asset": "~10.0.10",
|
||||
@@ -20,6 +22,8 @@
|
||||
"expo-navigation-bar": "~3.0.7",
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.74.5",
|
||||
"react-dom": "18.2.0",
|
||||
"react-native-web": "~0.19.10",
|
||||
"react-native-windows": "0.74.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -78,6 +78,14 @@ export const srcAllPlatformList = [
|
||||
description: 'another bunny (can be saved)',
|
||||
uri: 'https://rawgit.com/mediaelement/mediaelement-files/master/big_buck_bunny.mp4',
|
||||
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',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {AppRegistry} from 'react-native';
|
||||
import {registerRootComponent} from 'expo';
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -113,6 +113,7 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
@objc var onVideoProgress: RCTDirectEventBlock?
|
||||
@objc var onVideoBandwidthUpdate: RCTDirectEventBlock?
|
||||
@objc var onVideoSeek: RCTDirectEventBlock?
|
||||
@objc var onVideoSeekComplete: RCTDirectEventBlock?
|
||||
@objc var onVideoEnd: RCTDirectEventBlock?
|
||||
@objc var onTimedMetadata: RCTDirectEventBlock?
|
||||
@objc var onVideoAudioBecomingNoisy: RCTDirectEventBlock?
|
||||
@@ -782,34 +783,50 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
_paused = paused
|
||||
}
|
||||
|
||||
|
||||
@objc
|
||||
func setSeek(_ time: NSNumber, _ tolerance: NSNumber) {
|
||||
let item: AVPlayerItem? = _player?.currentItem
|
||||
|
||||
_pendingSeek = true
|
||||
|
||||
guard item != nil, let player = _player, let item, item.status == AVPlayerItem.Status.readyToPlay else {
|
||||
_pendingSeek = true
|
||||
_pendingSeekTime = time.floatValue
|
||||
return
|
||||
}
|
||||
|
||||
RCTPlayerOperations.seek(
|
||||
player: player,
|
||||
playerItem: item,
|
||||
paused: _paused,
|
||||
seekTime: time.floatValue,
|
||||
seekTolerance: tolerance.floatValue
|
||||
) { [weak self] (_: Bool) in
|
||||
guard let self else { return }
|
||||
let wasPaused = _paused
|
||||
let seekTime = CMTimeMakeWithSeconds(Float64(time.floatValue), preferredTimescale: Int32(NSEC_PER_SEC))
|
||||
let toleranceTime = CMTimeMakeWithSeconds(Float64(tolerance.floatValue), preferredTimescale: Int32(NSEC_PER_SEC))
|
||||
|
||||
let currentTimeBeforeSeek = CMTimeGetSeconds(item.currentTime())
|
||||
|
||||
// Call onVideoSeek before starting the seek operation
|
||||
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.setPaused(self._paused)
|
||||
self.onVideoSeek?(["currentTime": NSNumber(value: Float(CMTimeGetSeconds(item.currentTime()))),
|
||||
"seekTime": time,
|
||||
"target": self.reactTag as Any])
|
||||
|
||||
let newCurrentTime = NSNumber(value: Float(CMTimeGetSeconds(item.currentTime())))
|
||||
self.onVideoSeekComplete?(["currentTime": newCurrentTime,
|
||||
"seekTime": time,
|
||||
"target": self.reactTag as Any])
|
||||
|
||||
}
|
||||
|
||||
_pendingSeek = false
|
||||
player.seek(to: seekTime, toleranceBefore: toleranceTime, toleranceAfter: toleranceTime, completionHandler: seekCompletionHandler)
|
||||
}
|
||||
|
||||
@objc
|
||||
@@ -1403,7 +1420,10 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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 textTracks = await RCTVideoUtils.getTextTrackInfo(self._player)
|
||||
guard self._playerItem === _playerItem,
|
||||
self._source?.json === source.json else { return }
|
||||
self.onVideoLoad?(["duration": NSNumber(value: duration),
|
||||
"currentTime": NSNumber(value: Float(CMTimeGetSeconds(_playerItem.currentTime()))),
|
||||
"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(onVideoBandwidthUpdate, RCTDirectEventBlock);
|
||||
RCT_EXPORT_VIEW_PROPERTY(onVideoSeek, RCTDirectEventBlock);
|
||||
RCT_EXPORT_VIEW_PROPERTY(onVideoSeekComplete, RCTDirectEventBlock);
|
||||
RCT_EXPORT_VIEW_PROPERTY(onVideoEnd, RCTDirectEventBlock);
|
||||
RCT_EXPORT_VIEW_PROPERTY(onTimedMetadata, RCTDirectEventBlock);
|
||||
RCT_EXPORT_VIEW_PROPERTY(onVideoAudioBecomingNoisy, RCTDirectEventBlock);
|
||||
|
||||
@@ -4,7 +4,13 @@ import React
|
||||
@objc(RCTVideoManager)
|
||||
class RCTVideoManager: RCTViewManager {
|
||||
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 {
|
||||
|
||||
14
package.json
14
package.json
@@ -3,7 +3,7 @@
|
||||
"version": "6.6.4",
|
||||
"description": "A <Video /> element for react-native",
|
||||
"main": "lib/index",
|
||||
"source": "src/index",
|
||||
"source": "src/index.ts",
|
||||
"react-native": "src/index",
|
||||
"license": "MIT",
|
||||
"author": "Community Contributors",
|
||||
@@ -31,15 +31,18 @@
|
||||
"react": "18.2.0",
|
||||
"react-native": "0.73.2",
|
||||
"react-native-windows": "^0.61.0-0",
|
||||
"release-it": "^16.2.1",
|
||||
"typescript": "5.1.6"
|
||||
"release-it": "^16.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16",
|
||||
"typescript": "5.6.3"
|
||||
},
|
||||
"dependencies": {},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"install": "tsc --noCheck",
|
||||
"lint": "yarn eslint .",
|
||||
"build": "yarn tsc",
|
||||
"prepare": "yarn build",
|
||||
@@ -47,6 +50,7 @@
|
||||
"docs": "yarn --cwd docs build",
|
||||
"release": "release-it",
|
||||
"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-android": "scripts/kotlin-lint.sh",
|
||||
"check-all": "yarn check-android; yarn check-ios; yarn lint",
|
||||
@@ -58,6 +62,8 @@
|
||||
"windows",
|
||||
"src",
|
||||
"lib",
|
||||
"tsconfig.json",
|
||||
"tsconfig.build.json",
|
||||
"react-native-video.podspec",
|
||||
"app.plugin.js",
|
||||
"!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,
|
||||
OnProgressData,
|
||||
OnSeekData,
|
||||
OnSeekCompleteData,
|
||||
OnTextTrackDataChangedData,
|
||||
OnTimedMetadataData,
|
||||
OnVideoAspectRatioData,
|
||||
@@ -45,8 +46,7 @@ import {
|
||||
resolveAssetSourceForVideo,
|
||||
} from './utils';
|
||||
import NativeVideoManager from './specs/NativeVideoManager';
|
||||
import type {VideoSaveData} from './specs/NativeVideoManager';
|
||||
import {CmcdMode, ViewType} from './types';
|
||||
import {type VideoSaveData, CmcdMode, ViewType} from './types';
|
||||
import type {
|
||||
OnLoadData,
|
||||
OnTextTracksData,
|
||||
@@ -98,6 +98,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
onError,
|
||||
onProgress,
|
||||
onSeek,
|
||||
onSeekComplete,
|
||||
onEnd,
|
||||
onBuffer,
|
||||
onBandwidthUpdate,
|
||||
@@ -470,6 +471,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
[onSeek],
|
||||
);
|
||||
|
||||
const onVideoSeekComplete = useCallback(
|
||||
(e: NativeSyntheticEvent<OnSeekCompleteData>) => {
|
||||
onSeekComplete?.(e.nativeEvent);
|
||||
},
|
||||
[onSeekComplete],
|
||||
);
|
||||
|
||||
const onVideoPlaybackStateChanged = useCallback(
|
||||
(e: NativeSyntheticEvent<OnPlaybackStateChangedData>) => {
|
||||
onPlaybackStateChanged?.(e.nativeEvent);
|
||||
@@ -725,10 +733,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
}
|
||||
|
||||
// poster style
|
||||
const baseStyle: StyleProp<ImageStyle> = {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
resizeMode: _posterResizeMode,
|
||||
};
|
||||
const baseStyle: StyleProp<ImageStyle> = [
|
||||
StyleSheet.absoluteFill,
|
||||
{resizeMode: _posterResizeMode},
|
||||
];
|
||||
|
||||
let posterStyle: StyleProp<ImageStyle> = baseStyle;
|
||||
|
||||
@@ -778,10 +786,10 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
]);
|
||||
|
||||
const _style: StyleProp<ViewStyle> = useMemo(
|
||||
() => ({
|
||||
...StyleSheet.absoluteFillObject,
|
||||
...(showPoster ? {display: 'none'} : {}),
|
||||
}),
|
||||
() => [
|
||||
StyleSheet.absoluteFill,
|
||||
showPoster ? {display: 'none'} : undefined,
|
||||
],
|
||||
[showPoster],
|
||||
);
|
||||
|
||||
@@ -812,6 +820,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
|
||||
onVideoError={onError ? onVideoError : undefined}
|
||||
onVideoProgress={onProgress ? onVideoProgress : undefined}
|
||||
onVideoSeek={onSeek ? onVideoSeek : undefined}
|
||||
onVideoSeekComplete={onSeekComplete ? onVideoSeekComplete : undefined}
|
||||
onVideoEnd={onEnd}
|
||||
onVideoBuffer={onBuffer ? onVideoBuffer : undefined}
|
||||
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';
|
||||
export {VideoDecoderProperties} from './VideoDecoderProperties';
|
||||
export * from './types';
|
||||
export type {VideoRef} from './Video';
|
||||
export {Video};
|
||||
export default Video;
|
||||
|
||||
@@ -2,7 +2,7 @@ import {NativeModules} from 'react-native';
|
||||
import type {Int32} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
interface VideoDecoderInfoModuleType {
|
||||
export interface VideoDecoderInfoModuleType {
|
||||
getWidevineLevel: () => Promise<Int32>;
|
||||
isCodecSupported: (
|
||||
mimeType: string,
|
||||
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
Float,
|
||||
UnsafeObject,
|
||||
} from 'react-native/Libraries/Types/CodegenTypes';
|
||||
|
||||
export type VideoSaveData = {
|
||||
uri: string;
|
||||
};
|
||||
import type {VideoSaveData} from '../types/video-ref';
|
||||
|
||||
// @TODO rename to "Spec" when applying new arch
|
||||
export interface VideoManagerType {
|
||||
|
||||
@@ -202,6 +202,12 @@ export type OnSeekData = Readonly<{
|
||||
seekTime: Float;
|
||||
}>;
|
||||
|
||||
export type OnSeekCompleteData = Readonly<{
|
||||
currentTime: number;
|
||||
seekTime: number;
|
||||
target: number;
|
||||
}>;
|
||||
|
||||
export type OnPlaybackStateChangedData = Readonly<{
|
||||
isPlaying: boolean;
|
||||
isSeeking: boolean;
|
||||
@@ -284,12 +290,12 @@ type OnReceiveAdEventData = Readonly<{
|
||||
|
||||
export type OnVideoErrorData = Readonly<{
|
||||
error: Readonly<{
|
||||
errorString?: string; // android
|
||||
errorString?: string; // android | web
|
||||
errorException?: string; // android
|
||||
errorStackTrace?: string; // android
|
||||
errorCode?: string; // android
|
||||
error?: string; // ios
|
||||
code?: Int32; // ios
|
||||
code?: Int32; // ios | web
|
||||
localizedDescription?: string; // ios
|
||||
localizedFailureReason?: string; // ios
|
||||
localizedRecoverySuggestion?: string; // ios
|
||||
@@ -377,6 +383,7 @@ export interface VideoNativeProps extends ViewProps {
|
||||
onVideoProgress?: DirectEventHandler<OnProgressData>;
|
||||
onVideoBandwidthUpdate?: DirectEventHandler<OnBandwidthUpdateData>;
|
||||
onVideoSeek?: DirectEventHandler<OnSeekData>;
|
||||
onVideoSeekComplete?: DirectEventHandler<OnSeekCompleteData>;
|
||||
onVideoEnd?: DirectEventHandler<{}>; // all
|
||||
onVideoAudioBecomingNoisy?: DirectEventHandler<{}>;
|
||||
onVideoFullscreenPlayerWillPresent?: DirectEventHandler<{}>; // ios, android
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
OnPlaybackStateChangedData,
|
||||
OnProgressData,
|
||||
OnSeekData,
|
||||
OnSeekCompleteData,
|
||||
OnTextTrackDataChangedData,
|
||||
OnTimedMetadataData,
|
||||
OnVideoAspectRatioData,
|
||||
@@ -20,6 +21,8 @@ import type {
|
||||
OnVolumeChangeData,
|
||||
} from '../specs/VideoNativeComponent';
|
||||
|
||||
export type * from '../specs/VideoNativeComponent';
|
||||
|
||||
export type AudioTrack = OnAudioTracksData['audioTracks'][number];
|
||||
export type TextTrack = OnTextTracksData['textTracks'][number];
|
||||
export type VideoTrack = OnVideoTracksData['videoTracks'][number];
|
||||
@@ -258,6 +261,7 @@ export interface ReactVideoEvents {
|
||||
onReceiveAdEvent?: (e: OnReceiveAdEventData) => void; //Android, iOS
|
||||
onRestoreUserInterfaceForPictureInPictureStop?: () => void; //iOS
|
||||
onSeek?: (e: OnSeekData) => void; //Android, iOS, Windows UWP
|
||||
onSeekComplete?: (e: OnSeekCompleteData) => void; // iOS
|
||||
onPlaybackStateChanged?: (e: OnPlaybackStateChangedData) => void; // Android, iOS
|
||||
onTimedMetadata?: (e: OnTimedMetadataData) => void; //Android, iOS
|
||||
onAudioTracks?: (e: OnAudioTracksData) => void; // Android
|
||||
|
||||
@@ -7,4 +7,4 @@ export {default as ResizeMode} from './ResizeMode';
|
||||
export {default as TextTrackType} from './TextTrackType';
|
||||
export {default as ViewType} from './ViewType';
|
||||
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,5 +1,4 @@
|
||||
|
||||
{
|
||||
"extends": "./tsconfig",
|
||||
"exclude": ["examples", "lib"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react",
|
||||
"lib": ["esnext"],
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitUseStrict": false,
|
||||
|
||||
Reference in New Issue
Block a user