- Videos kept looping forever after scrolling off-screen (expo-video has no visibility awareness). New useVisiblePostIds hook tracks FlatList viewability and pauses VideoPlayer once a post scrolls away, wired into both the feed and the pet-profile post list. - Pet edit screen couldn't change breed or set birthday/adoption date, even though the backend (pets.update) already supported all three — web already used them. Extracted BreedPicker out of PetCreateForm into a shared component, added it plus TT-MM-JJJJ date fields (same validation pattern as the health forms) to edit.tsx. Its BreedPicker nests a ScrollView, so edit.tsx's outer ScrollView was swapped for a FlatList to avoid the same nested-scroll gesture conflict fixed earlier for onboarding/pet.tsx. - Pet profile's own avatar was a plain, non-tappable Image. Tapping it now opens the existing /image-viewer (same pinch-to-zoom viewer post images already use) when the pet has an avatar set. - milestones.tsx and milestone-new.tsx still imported the static light-only colors/typography tokens instead of useTheme() — switched to the established makeStyles(colors) pattern.
101 lines
3.4 KiB
TypeScript
101 lines
3.4 KiB
TypeScript
import { useEffect } from "react";
|
|
import { View, Text, ActivityIndicator, StyleSheet } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { AlertCircle } from "lucide-react-native";
|
|
import { useVideoPlayer, VideoView } from "expo-video";
|
|
import { useTRPC } from "../api/trpc";
|
|
import { colors, radii, typography } from "../theme/tokens";
|
|
|
|
type Status = "PROCESSING" | "READY" | "ERROR";
|
|
|
|
type Props = {
|
|
postId: string;
|
|
/** Initial status from the feed/profile query — avoids a redundant fetch when already READY */
|
|
initialStatus: Status;
|
|
initialPlaybackId?: string | null;
|
|
width: number;
|
|
/** False once the post has scrolled off-screen — pauses playback instead of looping forever unseen (found.md). Defaults true for call sites without viewability tracking (single-post screens). */
|
|
isVisible?: boolean;
|
|
};
|
|
|
|
/**
|
|
* Port of web's src/components/video/VideoCard.tsx. Mux serves HLS, which
|
|
* expo-video plays natively (AVPlayer/ExoPlayer under the hood) — no
|
|
* separate player SDK needed the way web needs @mux/mux-player-react.
|
|
*/
|
|
export function VideoPlayer({ postId, initialStatus, initialPlaybackId, width, isVisible = true }: Props) {
|
|
const { t } = useTranslation();
|
|
const trpc = useTRPC();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Poll while PROCESSING — stop once READY or ERROR
|
|
const { data } = useQuery({
|
|
...trpc.videos.getByPostId.queryOptions({ postId }),
|
|
enabled: initialStatus === "PROCESSING",
|
|
refetchInterval: (query) => (query.state.data?.status === "PROCESSING" ? 5000 : false),
|
|
});
|
|
|
|
const status = data?.status ?? initialStatus;
|
|
const playbackId = data?.muxPlaybackId ?? initialPlaybackId;
|
|
|
|
useEffect(() => {
|
|
if (status === "READY" && initialStatus === "PROCESSING") {
|
|
queryClient.invalidateQueries(trpc.feed.pathFilter());
|
|
queryClient.invalidateQueries(trpc.posts.pathFilter());
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [status, initialStatus]);
|
|
|
|
if (status === "PROCESSING") {
|
|
return (
|
|
<View style={[styles.box, { width }]}>
|
|
<ActivityIndicator color={colors.marigoldOrange} />
|
|
<Text style={styles.label}>{t("video.processing")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (status === "ERROR" || !playbackId) {
|
|
return (
|
|
<View style={[styles.box, { width }]}>
|
|
<AlertCircle color={colors.alertRed} size={28} />
|
|
<Text style={styles.label}>{t("video.error")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return <ReadyVideo playbackId={playbackId} width={width} isVisible={isVisible} />;
|
|
}
|
|
|
|
function ReadyVideo({ playbackId, width, isVisible }: { playbackId: string; width: number; isVisible: boolean }) {
|
|
const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => {
|
|
p.loop = true;
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) player.pause();
|
|
}, [isVisible, player]);
|
|
|
|
return (
|
|
<VideoView
|
|
player={player}
|
|
style={{ width, aspectRatio: 16 / 9, borderRadius: radii.md, backgroundColor: colors.ink }}
|
|
nativeControls
|
|
contentFit="contain"
|
|
/>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
box: {
|
|
aspectRatio: 16 / 9,
|
|
borderRadius: radii.md,
|
|
backgroundColor: colors.ink,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
},
|
|
label: { ...typography.label, textTransform: "none", letterSpacing: 0, color: colors.pureWhite },
|
|
});
|