import { useIsFocused } from "@react-navigation/native"; import { useEvent } from "expo"; import { useVideoPlayer, VideoView } from "expo-video"; import { useEffect, useEffectEvent, useRef, useState } from "react"; import { ActivityIndicator, AppState, Pressable, View } from "react-native"; import { AppText } from "./AppText"; import { SymbolView } from "./AppSymbol"; import { VideoThumbnailImage } from "./VideoThumbnailImage"; import { useMediaActions, type MediaActionsSource } from "../lib/mediaActions"; import { MediaActionsMenu } from "./MediaActionsMenu"; /** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */ function LoadedMediaVideo(props: { readonly uri: string; readonly resolvePlaybackUri?: () => Promise; readonly playRequested: boolean; readonly paused: boolean; }) { const focused = useIsFocused(); const active = useRef(focused && AppState.currentState === "active"); const fullscreen = useRef(false); const [attempt, setAttempt] = useState(0); // Expo's Android player also reports completed playback as idle. const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending"); const player = useVideoPlayer(null, (player) => { player.staysActiveInBackground = false; player.bufferOptions = { preferredForwardBufferDuration: 5 }; }); const { status } = useEvent(player, "statusChange", { status: player.status }); const loadSource = useEffectEvent(async (signal: AbortSignal) => { const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri; if (signal.aborted) return; if (uri === null) throw new Error("Video unavailable"); player.pause(); await player.replaceAsync({ uri, contentType: "progressive" }); if (!signal.aborted && props.playRequested && active.current) player.play(); }); useEffect(() => { active.current = focused && !props.paused && AppState.currentState === "active"; if (!focused || props.paused || (!active.current && !fullscreen.current)) player.pause(); // Native background handling distinguishes Android's fullscreen activity // from leaving the app; React Native reports both as background. const subscription = AppState.addEventListener("change", (state) => { active.current = focused && !props.paused && state === "active"; if (state === "inactive" || (state === "background" && !fullscreen.current)) player.pause(); }); return () => subscription.remove(); }, [focused, player, props.paused]); useEffect(() => { const controller = new AbortController(); setLoadState("pending"); // A renewed signature is used on Retry, not as a reason to reset the native player. void loadSource(controller.signal).then( () => { if (!controller.signal.aborted) setLoadState("complete"); }, () => { if (!controller.signal.aborted) setLoadState("error"); }, ); return () => controller.abort(); }, [player, props.playRequested, attempt]); return ( { fullscreen.current = true; }} onFullscreenExit={() => { fullscreen.current = false; }} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( Video unavailable setAttempt((value) => value + 1)} className="min-h-11 justify-center px-4" > Retry ) : loadState === "pending" || status === "loading" ? ( ) : null} ); } interface MediaVideoPlayerProps { readonly uri: string | null; readonly resolvePlaybackUri?: () => Promise; readonly name: string; readonly thumbnailKey: string; readonly thumbnailVisible?: boolean; readonly unavailable?: boolean; readonly expanded?: boolean; readonly autoPlay?: boolean; readonly paused?: boolean; readonly actionsSource?: MediaActionsSource; } function MediaVideoPlayerContent(props: MediaVideoPlayerProps) { const mediaActions = useMediaActions(props.actionsSource); const [playbackUri, setPlaybackUri] = useState(props.expanded ? props.uri : null); // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL. if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri); return ( {playbackUri ? ( ) : ( 0 ? "Touch and hold for media actions" : undefined } accessibilityState={{ disabled: props.uri === null || props.unavailable === true }} // Stays pressable so the long-press menu still opens on a failed or unsigned tile. onPress={() => { if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri); }} className="flex-1 items-center justify-center px-4" > {!props.unavailable ? ( ) : null} {props.unavailable ? ( Video unavailable ) : props.uri === null ? ( ) : ( )} {props.uri !== null && !props.unavailable ? ( {props.name} ) : null} )} ); } export function MediaVideoPlayer(props: MediaVideoPlayerProps) { return ; }