[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 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 --- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
+++ b/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 = { @@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
@ -256,6 +256,15 @@ index a09fcfe..5eac9e5 100644
}; };
/** /**
* Describes the current status of the player. * 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 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 index cb9ca6d..ed8bb7e 100644
--- a/node_modules/expo-video/build/VideoView.types.d.ts --- a/node_modules/expo-video/build/VideoView.types.d.ts
@ -270,8 +279,21 @@ index cb9ca6d..ed8bb7e 100644
} }
//# sourceMappingURL=VideoView.types.d.ts.map //# sourceMappingURL=VideoView.types.d.ts.map
\ No newline at end of file \ 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 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 --- a/node_modules/expo-video/ios/VideoManager.swift
+++ b/node_modules/expo-video/ios/VideoManager.swift +++ b/node_modules/expo-video/ios/VideoManager.swift
@@ -12,6 +12,7 @@ class VideoManager { @@ -12,6 +12,7 @@ class VideoManager {
@ -427,7 +449,7 @@ index 3315b88..733ab1f 100644
if self.appContext != nil { if self.appContext != nil {
self.emit(event: event, arguments: repeat each arguments) 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 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 --- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift +++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject { @@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
@ -464,7 +486,13 @@ index d289e26..ea4d96f 100644
} }
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) { 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) { if isPlaying != (player.timeControlStatus == .playing) {
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 { @@ -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 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 --- a/node_modules/expo-video/src/VideoPlayer.types.ts
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts +++ b/node_modules/expo-video/src/VideoPlayer.types.ts
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = { @@ -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 diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
index 29fe5db..e1fbf59 100644 index 29fe5db..e1fbf59 100644
--- a/node_modules/expo-video/src/VideoView.types.ts --- a/node_modules/expo-video/src/VideoView.types.ts

View File

@ -3,6 +3,7 @@
## `expo-video` Patch ## `expo-video` Patch
### `onEnterFullScreen`/`onExitFullScreen` ### `onEnterFullScreen`/`onExitFullScreen`
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
the tin. 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 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 need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
foreground. 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, md: 20,
lg: 24, lg: 24,
xl: 28, xl: 28,
'2xl': 32,
} }
export function useCommonSVGProps(props: Props) { 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 {View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' 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 {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary' import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext' import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { interface Props {
const {_} = useLingui() embed: AppBskyEmbedVideo.View
const {activeSource, activeViewId, setActiveSource, player} = }
useActiveVideoNative()
const viewId = useId()
const [isFullscreen, setIsFullscreen] = React.useState(false) export function VideoEmbed({embed}: Props) {
const isActive = embed.playlist === activeSource && activeViewId === viewId const gate = useGate()
const [key, setKey] = useState(0) const [key, setKey] = useState(0)
const renderError = useCallback( const renderError = useCallback(
(error: unknown) => ( (error: unknown) => (
<VideoError error={error} retry={() => setKey(key + 1)} /> <VideoError error={error} retry={() => setKey(key + 1)} />
), ),
[key], [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 let aspectRatio = 16 / 9
if (embed.aspectRatio) { if (embed.aspectRatio) {
const {width, height} = embed.aspectRatio const {width, height} = embed.aspectRatio
aspectRatio = width / height aspectRatio = width / height
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
} }
if (!gate('video_view_on_posts')) {
return null
}
return ( return (
<View <View
style={[ style={[
@ -71,39 +56,138 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.my_xs, a.my_xs,
]}> ]}>
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}> <InnerWrapper embed={embed} />
{isActive ? (
<VideoEmbedInnerNative
embed={embed}
isFullscreen={isFullscreen}
setIsFullscreen={setIsFullscreen}
/>
) : (
<>
<Image
source={{uri: embed.thumbnail}}
alt={embed.alt}
style={a.flex_1}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
<Button
style={[a.absolute, a.inset_0]}
onPress={() => {
setActiveSource(embed.playlist, viewId)
}}
label={_(msg`Play video`)}
color="secondary">
<PlayButtonIcon />
</Button>
</>
)}
</VisibilityView>
</ErrorBoundary> </ErrorBoundary>
</View> </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}
style={a.flex_1}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
<Button
style={[a.absolute, a.inset_0]}
onPress={() => {
setActiveSource(embed.playlist, viewId)
}}
label={_(msg`Play video`)}
color="secondary">
{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>
)
}
function VideoError({retry}: {error: unknown; retry: () => void}) { function VideoError({retry}: {error: unknown; retry: () => void}) {
return ( return (
<VideoFallback.Container> <VideoFallback.Container>

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 {Pressable, View} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated' import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video' import {VideoPlayer, VideoView} from 'expo-video'
@ -22,10 +22,14 @@ export function VideoEmbedInnerNative({
embed, embed,
isFullscreen, isFullscreen,
setIsFullscreen, setIsFullscreen,
isMuted,
timeRemaining,
}: { }: {
embed: AppBskyEmbedVideo.View embed: AppBskyEmbedVideo.View
isFullscreen: boolean isFullscreen: boolean
setIsFullscreen: (isFullscreen: boolean) => void setIsFullscreen: (isFullscreen: boolean) => void
timeRemaining: number
isMuted: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const {player} = useActiveVideoNative() const {player} = useActiveVideoNative()
@ -73,7 +77,12 @@ export function VideoEmbedInnerNative({
} }
accessibilityHint="" accessibilityHint=""
/> />
<VideoControls player={player} enterFullscreen={enterFullscreen} /> <VideoControls
player={player}
enterFullscreen={enterFullscreen}
isMuted={isMuted}
timeRemaining={timeRemaining}
/>
</View> </View>
) )
} }
@ -81,40 +90,16 @@ export function VideoEmbedInnerNative({
function VideoControls({ function VideoControls({
player, player,
enterFullscreen, enterFullscreen,
timeRemaining,
isMuted,
}: { }: {
player: VideoPlayer player: VideoPlayer
enterFullscreen: () => void enterFullscreen: () => void
timeRemaining: number
isMuted: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() 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(() => { const onPressFullscreen = useCallback(() => {
switch (player.status) { switch (player.status) {