[Video] Add loading state to player (#5149)

zio/stable
Hailey 2024-09-04 16:46:01 -07:00 committed by GitHub
parent 76f493c279
commit 2556698427
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 250 additions and 116 deletions

View File

@ -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

View File

@ -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.

View File

@ -19,6 +19,7 @@ export const sizes = {
md: 20,
lg: 24,
xl: 28,
'2xl': 32,
}
export function useCommonSVGProps(props: Props) {

View File

@ -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>
)
}

View File

@ -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) {