[Video] Add loading state to player (#5149)
parent
76f493c279
commit
2556698427
|
@ -244,7 +244,7 @@ index a951d80..3932535 100644
|
|||
}
|
||||
|
||||
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
index a09fcfe..5eac9e5 100644
|
||||
index a09fcfe..46cbae7 100644
|
||||
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
||||
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
||||
|
@ -256,6 +256,15 @@ index a09fcfe..5eac9e5 100644
|
|||
};
|
||||
/**
|
||||
* Describes the current status of the player.
|
||||
@@ -136,7 +138,7 @@ export type VideoPlayerEvents = {
|
||||
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
|
||||
* - `error`: The player has encountered an error while loading or playing the video.
|
||||
*/
|
||||
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
|
||||
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
|
||||
export type VideoSource = string | {
|
||||
/**
|
||||
* The URI of the video.
|
||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
||||
index cb9ca6d..ed8bb7e 100644
|
||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
||||
|
@ -270,8 +279,21 @@ index cb9ca6d..ed8bb7e 100644
|
|||
}
|
||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
||||
\ No newline at end of file
|
||||
diff --git a/node_modules/expo-video/ios/Enums/PlayerStatus.swift b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
||||
index 6af69ca..189fbbe 100644
|
||||
--- a/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
||||
+++ b/node_modules/expo-video/ios/Enums/PlayerStatus.swift
|
||||
@@ -6,5 +6,8 @@ internal enum PlayerStatus: String, Enumerable {
|
||||
case idle
|
||||
case loading
|
||||
case readyToPlay
|
||||
+ case waitingToPlayAtSpecifiedRate
|
||||
+ case unlikeToKeepUp
|
||||
+ case playbackBufferEmpty
|
||||
case error
|
||||
}
|
||||
diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
|
||||
index 094a8b0..3f00525 100644
|
||||
index 094a8b0..16e7081 100644
|
||||
--- a/node_modules/expo-video/ios/VideoManager.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoManager.swift
|
||||
@@ -12,6 +12,7 @@ class VideoManager {
|
||||
|
@ -427,7 +449,7 @@ index 3315b88..733ab1f 100644
|
|||
if self.appContext != nil {
|
||||
self.emit(event: event, arguments: repeat each arguments)
|
||||
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
index d289e26..ea4d96f 100644
|
||||
index d289e26..7de8cbf 100644
|
||||
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
||||
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
||||
|
@ -464,7 +486,13 @@ index d289e26..ea4d96f 100644
|
|||
}
|
||||
|
||||
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
||||
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
|
||||
@@ -265,23 +271,24 @@ class VideoPlayerObserver {
|
||||
if player.timeControlStatus != .waitingToPlayAtSpecifiedRate && player.status == .readyToPlay && currentItem?.isPlaybackBufferEmpty != true {
|
||||
status = .readyToPlay
|
||||
} else if player.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
||||
- status = .loading
|
||||
+ status = .waitingToPlayAtSpecifiedRate
|
||||
}
|
||||
|
||||
if isPlaying != (player.timeControlStatus == .playing) {
|
||||
isPlaying = player.timeControlStatus == .playing
|
||||
|
@ -472,6 +500,20 @@ index d289e26..ea4d96f 100644
|
|||
}
|
||||
}
|
||||
|
||||
private func onIsBufferEmptyChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
|
||||
if playerItem.isPlaybackBufferEmpty {
|
||||
- status = .loading
|
||||
+ status = .playbackBufferEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange<Bool>) {
|
||||
if !playerItem.isPlaybackLikelyToKeepUp && playerItem.isPlaybackBufferEmpty {
|
||||
- status = .loading
|
||||
+ status = .unlikeToKeepUp
|
||||
} else if playerItem.isPlaybackLikelyToKeepUp {
|
||||
status = .readyToPlay
|
||||
}
|
||||
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
|
||||
}
|
||||
}
|
||||
|
@ -531,7 +573,7 @@ index f4579e4..10c5908 100644
|
|||
}
|
||||
}
|
||||
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
index aaf4b63..f438196 100644
|
||||
index aaf4b63..5ff6b7a 100644
|
||||
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
||||
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
||||
|
@ -543,6 +585,15 @@ index aaf4b63..f438196 100644
|
|||
};
|
||||
|
||||
/**
|
||||
@@ -160,7 +162,7 @@ export type VideoPlayerEvents = {
|
||||
* - `readyToPlay`: The player has loaded enough data to start playing or to continue playback.
|
||||
* - `error`: The player has encountered an error while loading or playing the video.
|
||||
*/
|
||||
-export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error';
|
||||
+export type VideoPlayerStatus = 'idle' | 'loading' | 'readyToPlay' | 'error' | 'waitingToPlayAtSpecifiedRate';
|
||||
|
||||
export type VideoSource =
|
||||
| string
|
||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
||||
index 29fe5db..e1fbf59 100644
|
||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
## `expo-video` Patch
|
||||
|
||||
### `onEnterFullScreen`/`onExitFullScreen`
|
||||
|
||||
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
||||
the tin.
|
||||
|
||||
|
@ -16,3 +17,15 @@ ourselves.
|
|||
Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
|
||||
need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
|
||||
foreground.
|
||||
|
||||
### Additional `statusChange` Events
|
||||
|
||||
`expo-video` uses the `loading` status for a variety of cases where the video is not actually "loading". We're making
|
||||
those status events more specific here, so that we can determine if a video is truly loading or not. These statuses are:
|
||||
|
||||
- `waitingToPlayAtSpecifiedRate`
|
||||
- `unlikelyToKeepUp`
|
||||
- `playbackBufferEmpty`
|
||||
|
||||
It's unlikely we will ever need to pay attention to these statuses, so they are not being include in the TypeScript
|
||||
types.
|
||||
|
|
|
@ -19,6 +19,7 @@ export const sizes = {
|
|||
md: 20,
|
||||
lg: 24,
|
||||
xl: 28,
|
||||
'2xl': 32,
|
||||
}
|
||||
|
||||
export function useCommonSVGProps(props: Props) {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React, {useCallback, useId, useState} from 'react'
|
||||
import React, {useCallback, useEffect, useId, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {VideoPlayerStatus} from 'expo-video'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
@ -10,56 +11,40 @@ import {useGate} from '#/lib/statsig/statsig'
|
|||
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const {_} = useLingui()
|
||||
const {activeSource, activeViewId, setActiveSource, player} =
|
||||
useActiveVideoNative()
|
||||
const viewId = useId()
|
||||
interface Props {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
}
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||
const isActive = embed.playlist === activeSource && activeViewId === viewId
|
||||
export function VideoEmbed({embed}: Props) {
|
||||
const gate = useGate()
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
|
||||
const renderError = useCallback(
|
||||
(error: unknown) => (
|
||||
<VideoError error={error} retry={() => setKey(key + 1)} />
|
||||
),
|
||||
[key],
|
||||
)
|
||||
const gate = useGate()
|
||||
|
||||
const onChangeStatus = (isVisible: boolean) => {
|
||||
if (isVisible) {
|
||||
setActiveSource(embed.playlist, viewId)
|
||||
if (!player.playing) {
|
||||
player.play()
|
||||
}
|
||||
} else if (!isFullscreen) {
|
||||
player.muted = true
|
||||
if (player.playing) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gate('video_view_on_posts')) {
|
||||
return null
|
||||
}
|
||||
|
||||
let aspectRatio = 16 / 9
|
||||
|
||||
if (embed.aspectRatio) {
|
||||
const {width, height} = embed.aspectRatio
|
||||
aspectRatio = width / height
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
if (!gate('video_view_on_posts')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
@ -71,15 +56,103 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||
a.my_xs,
|
||||
]}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<InnerWrapper embed={embed} />
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function InnerWrapper({embed}: Props) {
|
||||
const {_} = useLingui()
|
||||
const {activeSource, activeViewId, setActiveSource, player} =
|
||||
useActiveVideoNative()
|
||||
const viewId = useId()
|
||||
|
||||
const [playerStatus, setPlayerStatus] = useState<VideoPlayerStatus>('loading')
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
const isActive = embed.playlist === activeSource && activeViewId === viewId
|
||||
const isLoading =
|
||||
isActive &&
|
||||
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
||||
playerStatus === 'loading')
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
const timeSub = player.addListener(
|
||||
'timeRemainingChange',
|
||||
secondsRemaining => {
|
||||
setTimeRemaining(secondsRemaining)
|
||||
},
|
||||
)
|
||||
const statusSub = player.addListener(
|
||||
'statusChange',
|
||||
(status, _oldStatus, error) => {
|
||||
setPlayerStatus(status)
|
||||
if (status === 'error') {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
volumeSub.remove()
|
||||
timeSub.remove()
|
||||
statusSub.remove()
|
||||
}
|
||||
}
|
||||
}, [player, isActive])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive && playerStatus !== 'loading') {
|
||||
setPlayerStatus('loading')
|
||||
}
|
||||
}, [isActive, playerStatus])
|
||||
|
||||
const onChangeStatus = (isVisible: boolean) => {
|
||||
if (isFullscreen) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
setActiveSource(embed.playlist, viewId)
|
||||
if (!player.playing) {
|
||||
player.play()
|
||||
}
|
||||
} else {
|
||||
player.muted = true
|
||||
if (player.playing) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
|
||||
{isActive ? (
|
||||
<VideoEmbedInnerNative
|
||||
embed={embed}
|
||||
timeRemaining={timeRemaining}
|
||||
isMuted={isMuted}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
) : null}
|
||||
{!isActive || isLoading ? (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: embed.thumbnail}}
|
||||
alt={embed.alt}
|
||||
|
@ -94,13 +167,24 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
|||
}}
|
||||
label={_(msg`Play video`)}
|
||||
color="secondary">
|
||||
<PlayButtonIcon />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</VisibilityView>
|
||||
</ErrorBoundary>
|
||||
{isLoading ? (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.p_xs,
|
||||
a.absolute,
|
||||
{top: 'auto', left: 'auto'},
|
||||
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||
]}>
|
||||
<Loader size="2xl" style={{color: 'white'}} />
|
||||
</View>
|
||||
) : (
|
||||
<PlayButtonIcon />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
</VisibilityView>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import React, {useCallback, useRef} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeInDown} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
|
@ -22,10 +22,14 @@ export function VideoEmbedInnerNative({
|
|||
embed,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
isMuted,
|
||||
timeRemaining,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
isFullscreen: boolean
|
||||
setIsFullscreen: (isFullscreen: boolean) => void
|
||||
timeRemaining: number
|
||||
isMuted: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {player} = useActiveVideoNative()
|
||||
|
@ -73,7 +77,12 @@ export function VideoEmbedInnerNative({
|
|||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
||||
<VideoControls
|
||||
player={player}
|
||||
enterFullscreen={enterFullscreen}
|
||||
isMuted={isMuted}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -81,40 +90,16 @@ export function VideoEmbedInnerNative({
|
|||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
timeRemaining,
|
||||
isMuted,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
timeRemaining: number
|
||||
isMuted: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
const timeSub = player.addListener(
|
||||
'timeRemainingChange',
|
||||
secondsRemaining => {
|
||||
setTimeRemaining(secondsRemaining)
|
||||
},
|
||||
)
|
||||
const statusSub = player.addListener(
|
||||
'statusChange',
|
||||
(status, _oldStatus, error) => {
|
||||
if (status === 'error') {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
volumeSub.remove()
|
||||
timeSub.remove()
|
||||
statusSub.remove()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
switch (player.status) {
|
||||
|
|
Loading…
Reference in New Issue