[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

@ -5,7 +5,7 @@ index 473f964..f37aff9 100644
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
override val name = "playToEnd"
}
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
+ override val name = "timeRemainingChange"
+ override val arguments = arrayOf(timeRemaining)
@ -32,10 +32,10 @@ index 9905e13..47342ff 100644
setTimeBarInteractive(requireLinearPlayback)
+ setShowSubtitleButton(true)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
@ -144,7 +144,7 @@ index ec3da2a..5a1397a 100644
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { view: VideoView, player: VideoPlayer ->
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
index 58f00af..5ad8237 100644
@ -152,7 +152,7 @@ index 58f00af..5ad8237 100644
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
@@ -1,5 +1,6 @@
package expo.modules.video
+import ProgressTracker
import android.content.Context
import android.view.SurfaceView
@ -162,18 +162,18 @@ index 58f00af..5ad8237 100644
.setLooper(context.mainLooper)
.build()
+ var progressTracker: ProgressTracker? = null
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
var playing by IgnoreSameSet(false) { new, old ->
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
+ addOrRemoveProgressTracker()
}
var uncommittedSource: VideoSource? = source
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
}
override fun close() {
+ this.progressTracker?.remove()
+ this.progressTracker = null
@ -184,7 +184,7 @@ index 58f00af..5ad8237 100644
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
listeners.removeAll { it.get() == videoPlayerListener }
}
- private fun sendEvent(event: PlayerEvent) {
+ fun sendEvent(event: PlayerEvent) {
// Emits to the native listeners
@ -224,7 +224,7 @@ index a951d80..3932535 100644
val onPictureInPictureStop by EventDispatcher<Unit>()
+ val onEnterFullscreen by EventDispatcher()
+ val onExitFullscreen by EventDispatcher()
var willEnterPiP: Boolean = false
var isInFullscreen: Boolean = false
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
@ -234,7 +234,7 @@ index a951d80..3932535 100644
+ onEnterFullscreen(mapOf())
isInFullscreen = true
}
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
@ -242,9 +242,9 @@ index a951d80..3932535 100644
+ this.onExitFullscreen(mapOf())
isInFullscreen = false
}
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 {
@ -409,7 +431,7 @@ index c537a12..e4a918f 100644
+ "onEnterFullscreen",
+ "onExitFullscreen"
)
Prop("player") { (view, player: VideoPlayer?) in
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
index 3315b88..733ab1f 100644
@ -418,7 +440,7 @@ index 3315b88..733ab1f 100644
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
}
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
+ }
@ -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 {
@ -436,7 +458,7 @@ index d289e26..ea4d96f 100644
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
}
// Default implementations for the delegate
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
@ -444,14 +466,14 @@ index d289e26..ea4d96f 100644
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
}
// Wrapper used to store WeakReferences to the observer delegate
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
private var playerVolumeObserver: NSKeyValueObservation?
private var playerCurrentItemObserver: NSKeyValueObservation?
private var playerIsMutedObserver: NSKeyValueObservation?
+ private var playerPeriodicTimeObserver: Any?
// Current player item observers
private var playbackBufferEmptyObserver: NSKeyValueObservation?
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
@ -462,16 +484,36 @@ index d289e26..ea4d96f 100644
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
+ }
}
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
+ addPeriodicTimeObserverIfNeeded()
}
}
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 {
}
}
@ -506,12 +548,12 @@ index f4579e4..10c5908 100644
--- a/node_modules/expo-video/ios/VideoView.swift
+++ b/node_modules/expo-video/ios/VideoView.swift
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
let onPictureInPictureStart = EventDispatcher()
let onPictureInPictureStop = EventDispatcher()
+ let onEnterFullscreen = EventDispatcher()
+ let onExitFullscreen = EventDispatcher()
public override var bounds: CGRect {
didSet {
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
@ -521,7 +563,7 @@ index f4579e4..10c5908 100644
+ onEnterFullscreen()
isFullscreen = true
}
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
if wasPlaying {
self.player?.pointer.play()
@ -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 = {
@ -541,8 +583,17 @@ index aaf4b63..f438196 100644
+
+ timeRemainingChange(timeRemaining: number): void;
};
/**
@@ -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,39 +56,138 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.my_xs,
]}>
<ErrorBoundary renderError={renderError} key={key}>
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
{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>
<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}
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}) {
return (
<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 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) {