diff --git a/patches/expo-video+1.2.4.patch b/patches/expo-video+1.2.4.patch index 13fe25ed..bc20fa0e 100644 --- a/patches/expo-video+1.2.4.patch +++ b/patches/expo-video+1.2.4.patch @@ -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(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() + 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, 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) { + if playerItem.isPlaybackBufferEmpty { +- status = .loading ++ status = .playbackBufferEmpty + } + } + + private func onPlayerLikelyToKeepUpChanged(_ playerItem: AVPlayerItem, _ change: NSKeyValueObservedChange) { + 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 diff --git a/patches/expo-video+1.2.4.patch.md b/patches/expo-video+1.2.4.patch.md index 99c14c28..7cd4d363 100644 --- a/patches/expo-video+1.2.4.patch.md +++ b/patches/expo-video+1.2.4.patch.md @@ -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. diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx index 387115d3..e83f96f0 100644 --- a/src/components/icons/common.tsx +++ b/src/components/icons/common.tsx @@ -19,6 +19,7 @@ export const sizes = { md: 20, lg: 24, xl: 28, + '2xl': 32, } export function useCommonSVGProps(props: Props) { diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index a5bc97f8..d10a6fe6 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -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) => ( 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 ( - - {isActive ? ( - - ) : ( - <> - {embed.alt} - - - )} - + ) } +function InnerWrapper({embed}: Props) { + const {_} = useLingui() + const {activeSource, activeViewId, setActiveSource, player} = + useActiveVideoNative() + const viewId = useId() + + const [playerStatus, setPlayerStatus] = useState('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 ( + + {isActive ? ( + + ) : null} + {!isActive || isLoading ? ( + + {embed.alt} + + + ) : null} + + ) +} + function VideoError({retry}: {error: unknown; retry: () => void}) { return ( diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 4fafce1d..3fa15926 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -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="" /> - + ) } @@ -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) {