| 1 | import { useIsFocused } from "@react-navigation/native"; |
| 2 | import { videoMimeType } from "@t3tools/shared/video"; |
| 3 | import { useEffect, useRef, useState } from "react"; |
| 4 | import { Keyboard, Modal, Pressable, View } from "react-native"; |
| 5 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 6 | |
| 7 | import { AppActivityIndicator } from "./AppActivityIndicator"; |
| 8 | import { loadLocalAttachmentPreview } from "../lib/localAttachmentPreview"; |
| 9 | import { useMediaActions } from "../state/mediaActions"; |
| 10 | import { type MediaActionsSource } from "../lib/mediaActionsSource"; |
| 11 | import { |
| 12 | mediaVideoPreviewUri, |
| 13 | mediaVideoThumbnailKey, |
| 14 | type LocalVideoPreviewSource, |
| 15 | type MediaVideoPreviewSource, |
| 16 | type VideoPreviewSource, |
| 17 | } from "../lib/videoPreviewSource"; |
| 18 | import { useAssetUrlState, useRefreshAssetUrl } from "../state/assets"; |
| 19 | import { usePreparedConnection } from "../state/session"; |
| 20 | import { AppText } from "./AppText"; |
| 21 | import { SymbolView } from "./AppSymbol"; |
| 22 | import { MediaActionsMenu } from "./MediaActionsMenu"; |
| 23 | import { MediaSourceCaption } from "./MediaSourceCaption"; |
| 24 | import { MediaVideoPlayer } from "./MediaVideoPlayer"; |
| 25 | |
| 26 | export type { VideoPreviewSource } from "../lib/videoPreviewSource"; |
| 27 | |
| 28 | interface PlaybackState { |
| 29 | readonly uri: string | null; |
| 30 | readonly resolvePlaybackUri?: () => Promise<string | null>; |
| 31 | readonly unavailable: boolean; |
| 32 | readonly error: string | null; |
| 33 | readonly actionsSource: MediaActionsSource | undefined; |
| 34 | } |
| 35 | |
| 36 | function useMediaPlayback(source: MediaVideoPreviewSource): PlaybackState { |
| 37 | const environmentId = "environmentId" in source ? source.environmentId : null; |
| 38 | const resource = "resource" in source ? source.resource : null; |
| 39 | const connection = usePreparedConnection(environmentId); |
| 40 | const asset = useAssetUrlState(environmentId, resource); |
| 41 | const refreshAssetUrl = useRefreshAssetUrl(environmentId, resource); |
| 42 | const uri = mediaVideoPreviewUri(source, asset._tag === "Success" ? asset.url : null); |
| 43 | return { |
| 44 | uri, |
| 45 | ...(resource !== null |
| 46 | ? { resolvePlaybackUri: async () => mediaVideoPreviewUri(source, await refreshAssetUrl()) } |
| 47 | : {}), |
| 48 | unavailable: |
| 49 | uri === null && |
| 50 | environmentId !== null && |
| 51 | (connection._tag === "None" || asset._tag === "Failure"), |
| 52 | error: null, |
| 53 | actionsSource: source.actionsSource, |
| 54 | }; |
| 55 | } |
| 56 | |
| 57 | function useLocalPlayback(source: LocalVideoPreviewSource): PlaybackState { |
| 58 | const { attachment } = source; |
| 59 | const [uri, setUri] = useState<string | null>(null); |
| 60 | const [error, setError] = useState<string | null>(null); |
| 61 | // Only a different file needs a new lease; a metadata update on the same |
| 62 | // draft must not dispose the file Android is still playing. |
| 63 | const attachmentRef = useRef(attachment); |
| 64 | attachmentRef.current = attachment; |
| 65 | const { id: attachmentId, fileUri } = attachment; |
| 66 | useEffect(() => { |
| 67 | setUri(null); |
| 68 | setError(null); |
| 69 | const controller = new AbortController(); |
| 70 | const loading = loadLocalAttachmentPreview(attachmentRef.current, controller.signal); |
| 71 | void loading.then( |
| 72 | (file) => { |
| 73 | if (file === null) return; |
| 74 | if (controller.signal.aborted) file.dispose(); |
| 75 | else setUri(file.uri); |
| 76 | }, |
| 77 | (cause: unknown) => { |
| 78 | if (!controller.signal.aborted) { |
| 79 | setError(cause instanceof Error ? cause.message : "Could not load this video."); |
| 80 | } |
| 81 | }, |
| 82 | ); |
| 83 | return () => { |
| 84 | controller.abort(); |
| 85 | void loading.then( |
| 86 | (file) => file?.dispose(), |
| 87 | () => undefined, |
| 88 | ); |
| 89 | }; |
| 90 | }, [attachmentId, fileUri]); |
| 91 | const mimeType = videoMimeType(attachment) ?? attachment.mimeType; |
| 92 | return { |
| 93 | uri, |
| 94 | unavailable: error !== null, |
| 95 | error, |
| 96 | actionsSource: uri === null ? undefined : { name: attachment.name, mimeType, uri }, |
| 97 | }; |
| 98 | } |
| 99 | |
| 100 | function MediaPreviewModal(props: { |
| 101 | readonly source: MediaVideoPreviewSource; |
| 102 | readonly onRequestClose: () => void; |
| 103 | }) { |
| 104 | return ( |
| 105 | <OpenVideoPreviewModal |
| 106 | name={props.source.name} |
| 107 | thumbnailKey={mediaVideoThumbnailKey(props.source)} |
| 108 | playback={useMediaPlayback(props.source)} |
| 109 | onRequestClose={props.onRequestClose} |
| 110 | /> |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | function LocalPreviewModal(props: { |
| 115 | readonly source: LocalVideoPreviewSource; |
| 116 | readonly onRequestClose: () => void; |
| 117 | }) { |
| 118 | return ( |
| 119 | <OpenVideoPreviewModal |
| 120 | name={props.source.attachment.name} |
| 121 | thumbnailKey={`local:${props.source.attachment.id}`} |
| 122 | playback={useLocalPlayback(props.source)} |
| 123 | onRequestClose={props.onRequestClose} |
| 124 | /> |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | function OpenVideoPreviewModal(props: { |
| 129 | readonly name: string; |
| 130 | readonly thumbnailKey: string; |
| 131 | readonly playback: PlaybackState; |
| 132 | readonly onRequestClose: () => void; |
| 133 | }) { |
| 134 | const { playback } = props; |
| 135 | const insets = useSafeAreaInsets(); |
| 136 | const mediaActions = useMediaActions(playback.actionsSource, props.onRequestClose); |
| 137 | |
| 138 | useEffect(() => Keyboard.dismiss(), []); |
| 139 | return ( |
| 140 | <Modal |
| 141 | visible |
| 142 | animationType="fade" |
| 143 | presentationStyle="fullScreen" |
| 144 | onRequestClose={props.onRequestClose} |
| 145 | statusBarTranslucent |
| 146 | navigationBarTranslucent |
| 147 | > |
| 148 | <View |
| 149 | className="flex-1 bg-black" |
| 150 | style={{ paddingTop: insets.top, paddingBottom: insets.bottom }} |
| 151 | > |
| 152 | <View className="min-h-14 flex-row items-center gap-3 pl-4 pr-2"> |
| 153 | <AppText className="flex-1 font-t3-medium text-base text-white" numberOfLines={2}> |
| 154 | {props.name} |
| 155 | </AppText> |
| 156 | <MediaActionsMenu media={mediaActions} inModal /> |
| 157 | <Pressable |
| 158 | accessibilityRole="button" |
| 159 | accessibilityLabel="Close video" |
| 160 | onPress={props.onRequestClose} |
| 161 | className="size-12 items-center justify-center" |
| 162 | > |
| 163 | <SymbolView name="xmark" size={20} tintColor="#ffffff" type="monochrome" /> |
| 164 | </Pressable> |
| 165 | </View> |
| 166 | <MediaSourceCaption source={mediaActions.title} /> |
| 167 | {playback.uri === null && !playback.unavailable ? ( |
| 168 | <View className="flex-1 items-center justify-center gap-3 px-6"> |
| 169 | <AppActivityIndicator color="#ffffff" /> |
| 170 | <AppText className="text-sm text-white/80">Loading video...</AppText> |
| 171 | </View> |
| 172 | ) : ( |
| 173 | <MediaVideoPlayer |
| 174 | uri={playback.uri} |
| 175 | resolvePlaybackUri={playback.resolvePlaybackUri} |
| 176 | name={props.name} |
| 177 | thumbnailKey={props.thumbnailKey} |
| 178 | unavailable={playback.unavailable} |
| 179 | paused={mediaActions.sharing} |
| 180 | expanded |
| 181 | autoPlay |
| 182 | /> |
| 183 | )} |
| 184 | {playback.error ? ( |
| 185 | <AppText accessibilityRole="alert" className="px-4 pb-3 text-sm text-white/80"> |
| 186 | {playback.error} |
| 187 | </AppText> |
| 188 | ) : null} |
| 189 | <Pressable |
| 190 | accessibilityRole="button" |
| 191 | accessibilityLabel="Save or share video" |
| 192 | disabled={playback.uri === null || mediaActions.sharing} |
| 193 | onPress={mediaActions.share} |
| 194 | className="mx-4 my-3 min-h-12 items-center justify-center rounded-xl bg-white/15 px-4" |
| 195 | > |
| 196 | <AppText className="font-t3-medium text-base text-white"> |
| 197 | {mediaActions.sharing ? "Opening share sheet..." : "Save or share video"} |
| 198 | </AppText> |
| 199 | </Pressable> |
| 200 | </View> |
| 201 | </Modal> |
| 202 | ); |
| 203 | } |
| 204 | |
| 205 | export function VideoPreviewModal(props: { |
| 206 | readonly source: VideoPreviewSource | null; |
| 207 | readonly onRequestClose: () => void; |
| 208 | }) { |
| 209 | const isFocused = useIsFocused(); |
| 210 | const hasSource = props.source !== null; |
| 211 | useEffect(() => { |
| 212 | if (!isFocused && hasSource) props.onRequestClose(); |
| 213 | }, [isFocused, hasSource, props.onRequestClose]); |
| 214 | const { source } = props; |
| 215 | if (source === null || !isFocused) return null; |
| 216 | return source.type === "local" ? ( |
| 217 | <LocalPreviewModal |
| 218 | key={`local:${source.attachment.id}:${source.attachment.fileUri}`} |
| 219 | source={source} |
| 220 | onRequestClose={props.onRequestClose} |
| 221 | /> |
| 222 | ) : ( |
| 223 | <MediaPreviewModal |
| 224 | key={mediaVideoThumbnailKey(source)} |
| 225 | source={source} |
| 226 | onRequestClose={props.onRequestClose} |
| 227 | /> |
| 228 | ); |
| 229 | } |