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<string | null>; 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 ( <View collapsable={false} style={{ flex: 1 }}> <VideoView player={player} style={{ width: "100%", height: "100%" }} surfaceType="textureView" nativeControls contentFit="contain" fullscreenOptions={{ enable: true }} onFullscreenEnter={() => { fullscreen.current = true; }} onFullscreenExit={() => { fullscreen.current = false; }} allowsPictureInPicture={false} /> {loadState === "error" || (loadState === "complete" && status === "error") ? ( <View className="absolute inset-0 items-center justify-center gap-2 bg-black px-4"> <AppText className="text-center text-sm text-white/80">Video unavailable</AppText> <Pressable accessibilityRole="button" accessibilityLabel="Retry video" onPress={() => setAttempt((value) => value + 1)} className="min-h-11 justify-center px-4" > <AppText className="text-sm text-white">Retry</AppText> </Pressable> </View> ) : loadState === "pending" || status === "loading" ? ( <View pointerEvents="none" className="absolute inset-0 items-center justify-center"> <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> </View> ) : null} </View> );} interface MediaVideoPlayerProps { readonly uri: string | null; readonly resolvePlaybackUri?: () => Promise<string | null>; 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<string | null>(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 ( <View collapsable={false} className="overflow-hidden rounded-[10px] bg-black" style={props.expanded ? { flex: 1 } : { width: "100%", maxWidth: 480, aspectRatio: 16 / 9 }} > {playbackUri ? ( <LoadedMediaVideo uri={props.uri ?? playbackUri} resolvePlaybackUri={props.resolvePlaybackUri} playRequested={!props.expanded || props.autoPlay === true} paused={(props.paused ?? false) || mediaActions.sharing} /> ) : ( <MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}> <Pressable accessibilityRole="button" accessibilityLabel={`Play ${props.name}`} accessibilityHint={ mediaActions.actions.length > 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 ? ( <VideoThumbnailImage cacheKey={props.thumbnailKey} source={props.thumbnailVisible === false ? null : props.uri} contentFit="contain" /> ) : null} {props.unavailable ? ( <AppText className="text-sm text-white/80">Video unavailable</AppText> ) : props.uri === null ? ( <ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" /> ) : ( <View className="size-12 items-center justify-center rounded-full bg-black/60"> <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" /> </View> )} {props.uri !== null && !props.unavailable ? ( <AppText className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white" numberOfLines={1} > {props.name} </AppText> ) : null} </Pressable> </MediaActionsMenu> )} </View> );} export function MediaVideoPlayer(props: MediaVideoPlayerProps) { return <MediaVideoPlayerContent key={props.thumbnailKey} {...props} />;}
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<string | null>;
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 (
<View collapsable={false} style={{ flex: 1 }}>
<VideoView
player={player}
style={{ width: "100%", height: "100%" }}
surfaceType="textureView"
nativeControls
contentFit="contain"
fullscreenOptions={{ enable: true }}
onFullscreenEnter={() => {
fullscreen.current = true;
}}
onFullscreenExit={() => {
fullscreen.current = false;
}}
allowsPictureInPicture={false}
/>
{loadState === "error" || (loadState === "complete" && status === "error") ? (
<View className="absolute inset-0 items-center justify-center gap-2 bg-black px-4">
<AppText className="text-center text-sm text-white/80">Video unavailable</AppText>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry video"
onPress={() => setAttempt((value) => value + 1)}
className="min-h-11 justify-center px-4"
>
<AppText className="text-sm text-white">Retry</AppText>
</Pressable>
</View>
) : loadState === "pending" || status === "loading" ? (
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
</View>
) : null}
</View>
);
}
interface MediaVideoPlayerProps {
readonly uri: string | null;
readonly resolvePlaybackUri?: () => Promise<string | null>;
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<string | null>(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 (
<View
collapsable={false}
className="overflow-hidden rounded-[10px] bg-black"
style={props.expanded ? { flex: 1 } : { width: "100%", maxWidth: 480, aspectRatio: 16 / 9 }}
>
{playbackUri ? (
<LoadedMediaVideo
uri={props.uri ?? playbackUri}
resolvePlaybackUri={props.resolvePlaybackUri}
playRequested={!props.expanded || props.autoPlay === true}
paused={(props.paused ?? false) || mediaActions.sharing}
/>
) : (
<MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Play ${props.name}`}
accessibilityHint={
mediaActions.actions.length > 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 ? (
<VideoThumbnailImage
cacheKey={props.thumbnailKey}
source={props.thumbnailVisible === false ? null : props.uri}
contentFit="contain"
/>
) : null}
{props.unavailable ? (
<AppText className="text-sm text-white/80">Video unavailable</AppText>
) : props.uri === null ? (
<ActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
) : (
<View className="size-12 items-center justify-center rounded-full bg-black/60">
<SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" />
</View>
)}
{props.uri !== null && !props.unavailable ? (
<AppText
className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white"
numberOfLines={1}
>
{props.name}
</AppText>
) : null}
</Pressable>
</MediaActionsMenu>
)}
</View>
);
}
export function MediaVideoPlayer(props: MediaVideoPlayerProps) {
return <MediaVideoPlayerContent key={props.thumbnailKey} {...props} />;
}