1import { useIsFocused } from "@react-navigation/native";
2import { useEvent } from "expo";
3import { useVideoPlayer, VideoView } from "expo-video";
4import { useEffect, useEffectEvent, useRef, useState } from "react";
5import { AppState, Pressable, View } from "react-native";
6
7import { AppActivityIndicator } from "./AppActivityIndicator";
8import { AppText } from "./AppText";
9import { SymbolView } from "./AppSymbol";
10import { VideoThumbnailImage } from "./VideoThumbnailImage";
11import { useMediaActions } from "../state/mediaActions";
12import { type MediaActionsSource } from "../lib/mediaActionsSource";
13import { MediaActionsMenu } from "./MediaActionsMenu";
14
15/** Loads only after Play or opening the viewer. Source replacement never starts playback itself. */
16function LoadedMediaVideo(props: {
17 readonly uri: string;
18 readonly resolvePlaybackUri?: () => Promise<string | null>;
19 readonly playRequested: boolean;
20 readonly paused: boolean;
21}) {
22 const focused = useIsFocused();
23 const active = useRef(focused && AppState.currentState === "active");
24 const fullscreen = useRef(false);
25 const [attempt, setAttempt] = useState(0);
26 // Expo's Android player also reports completed playback as idle.
27 const [loadState, setLoadState] = useState<"pending" | "complete" | "error">("pending");
28 const player = useVideoPlayer(null, (player) => {
29 player.staysActiveInBackground = false;
30 player.bufferOptions = { preferredForwardBufferDuration: 5 };
31 });
32 const { status } = useEvent(player, "statusChange", { status: player.status });
33 const loadSource = useEffectEvent(async (signal: AbortSignal) => {
34 const uri = props.resolvePlaybackUri ? await props.resolvePlaybackUri() : props.uri;
35 if (signal.aborted) return;
36 if (uri === null) throw new Error("Video unavailable");
37 player.pause();
38 await player.replaceAsync({ uri, contentType: "progressive" });
39 if (!signal.aborted && props.playRequested && active.current) player.play();
40 });
41
42 useEffect(() => {
43 active.current = focused && !props.paused && AppState.currentState === "active";
44 if (!focused || props.paused || (!active.current && !fullscreen.current)) player.pause();
45 // Native background handling distinguishes Android's fullscreen activity
46 // from leaving the app; React Native reports both as background.
47 const subscription = AppState.addEventListener("change", (state) => {
48 active.current = focused && !props.paused && state === "active";
49 if (state === "inactive" || (state === "background" && !fullscreen.current)) player.pause();
50 });
51 return () => subscription.remove();
52 }, [focused, player, props.paused]);
53
54 useEffect(() => {
55 const controller = new AbortController();
56 setLoadState("pending");
57 // A renewed signature is used on Retry, not as a reason to reset the native player.
58 void loadSource(controller.signal).then(
59 () => {
60 if (!controller.signal.aborted) setLoadState("complete");
61 },
62 () => {
63 if (!controller.signal.aborted) setLoadState("error");
64 },
65 );
66 return () => controller.abort();
67 }, [player, props.playRequested, attempt]);
68
69 return (
70 <View collapsable={false} style={{ flex: 1 }}>
71 <VideoView
72 player={player}
73 style={{ width: "100%", height: "100%" }}
74 surfaceType="textureView"
75 nativeControls
76 contentFit="contain"
77 fullscreenOptions={{ enable: true }}
78 onFullscreenEnter={() => {
79 fullscreen.current = true;
80 }}
81 onFullscreenExit={() => {
82 fullscreen.current = false;
83 }}
84 allowsPictureInPicture={false}
85 />
86 {loadState === "error" || (loadState === "complete" && status === "error") ? (
87 <View className="absolute inset-0 items-center justify-center gap-2 bg-black px-4">
88 <AppText className="text-center text-sm text-white/80">Video unavailable</AppText>
89 <Pressable
90 accessibilityRole="button"
91 accessibilityLabel="Retry video"
92 onPress={() => setAttempt((value) => value + 1)}
93 className="min-h-11 justify-center px-4"
94 >
95 <AppText className="text-sm text-white">Retry</AppText>
96 </Pressable>
97 </View>
98 ) : loadState === "pending" || status === "loading" ? (
99 <View pointerEvents="none" className="absolute inset-0 items-center justify-center">
100 <AppActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
101 </View>
102 ) : null}
103 </View>
104 );
105}
106
107interface MediaVideoPlayerProps {
108 readonly uri: string | null;
109 readonly resolvePlaybackUri?: () => Promise<string | null>;
110 readonly name: string;
111 readonly thumbnailKey: string;
112 readonly thumbnailVisible?: boolean;
113 readonly unavailable?: boolean;
114 readonly expanded?: boolean;
115 readonly autoPlay?: boolean;
116 readonly paused?: boolean;
117 readonly actionsSource?: MediaActionsSource;
118}
119
120function MediaVideoPlayerContent(props: MediaVideoPlayerProps) {
121 const mediaActions = useMediaActions(props.actionsSource);
122 const [playbackUri, setPlaybackUri] = useState<string | null>(props.expanded ? props.uri : null);
123 // Keep an opened player mounted while signing or reconnecting temporarily has no usable URL.
124 if (playbackUri === null && props.expanded && props.uri !== null) setPlaybackUri(props.uri);
125
126 return (
127 <View
128 collapsable={false}
129 className="overflow-hidden rounded-[10px] bg-black"
130 style={props.expanded ? { flex: 1 } : { width: "100%", maxWidth: 480, aspectRatio: 16 / 9 }}
131 >
132 {playbackUri ? (
133 <LoadedMediaVideo
134 uri={props.uri ?? playbackUri}
135 resolvePlaybackUri={props.resolvePlaybackUri}
136 playRequested={!props.expanded || props.autoPlay === true}
137 paused={(props.paused ?? false) || mediaActions.sharing}
138 />
139 ) : (
140 <MediaActionsMenu media={mediaActions} inModal={props.expanded} style={{ flex: 1 }}>
141 <Pressable
142 accessibilityRole="button"
143 accessibilityLabel={`Play ${props.name}`}
144 accessibilityHint={
145 mediaActions.actions.length > 0 ? "Touch and hold for media actions" : undefined
146 }
147 accessibilityState={{ disabled: props.uri === null || props.unavailable === true }}
148 // Stays pressable so the long-press menu still opens on a failed or unsigned tile.
149 onPress={() => {
150 if (props.uri !== null && !props.unavailable) setPlaybackUri(props.uri);
151 }}
152 className="flex-1 items-center justify-center px-4"
153 >
154 {!props.unavailable ? (
155 <VideoThumbnailImage
156 cacheKey={props.thumbnailKey}
157 source={props.thumbnailVisible === false ? null : props.uri}
158 contentFit="contain"
159 />
160 ) : null}
161 {props.unavailable ? (
162 <AppText className="text-sm text-white/80">Video unavailable</AppText>
163 ) : props.uri === null ? (
164 <AppActivityIndicator color="#ffffff" accessibilityLabel="Loading video" />
165 ) : (
166 <View className="size-12 items-center justify-center rounded-full bg-black/60">
167 <SymbolView name="play" size={28} tintColor="#ffffff" type="monochrome" />
168 </View>
169 )}
170 {props.uri !== null && !props.unavailable ? (
171 <AppText
172 className="absolute right-2 bottom-2 max-w-[75%] rounded bg-black/60 px-2 py-1 text-right text-xs text-white"
173 numberOfLines={1}
174 >
175 {props.name}
176 </AppText>
177 ) : null}
178 </Pressable>
179 </MediaActionsMenu>
180 )}
181 </View>
182 );
183}
184
185export function MediaVideoPlayer(props: MediaVideoPlayerProps) {
186 return <MediaVideoPlayerContent key={props.thumbnailKey} {...props} />;
187}