From 2b285de09e7d8e18a8d5e9612b1cde6158fb1772 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 20 Sep 2026 14:22:51 +0200 Subject: [PATCH] feat(mobile): add video story upload and playback (Gitea #37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the gallery picker to accept videos, uploads them via Mux direct upload (same flow web already uses), and adds PROCESSING/READY/ERROR handling to the story viewer with real playback-driven progress instead of a fixed timer. Fixes a video-viewer bug found during device testing: play() was called before the player reached readyToPlay, leaving duration at 0 and firing playToEnd immediately — the viewer closed within ~1s instead of playing. Now waits for statusChange to readyToPlay before calling play(). Also fixes the story-creation "Story teilen" button sitting flush against gesture navigation on devices without a bottom safe-area inset applied. Co-Authored-By: Claude Sonnet 5 --- mobile/app/(app)/story-new.tsx | 108 ++++++++++++++++--- mobile/app/(app)/story-viewer.tsx | 149 ++++++++++++++++++++++++--- mobile/src/components/StoryTray.tsx | 4 +- mobile/src/i18n/locales/de.json | 4 +- mobile/src/i18n/locales/en.json | 4 +- mobile/src/lib/story-video-limits.ts | 6 ++ mobile/src/lib/upload.ts | 20 ++++ 7 files changed, 261 insertions(+), 34 deletions(-) create mode 100644 mobile/src/lib/story-video-limits.ts diff --git a/mobile/app/(app)/story-new.tsx b/mobile/app/(app)/story-new.tsx index 0d9c09b..9dd252e 100644 --- a/mobile/app/(app)/story-new.tsx +++ b/mobile/app/(app)/story-new.tsx @@ -1,23 +1,39 @@ import { useState, useCallback, useMemo } from "react"; -import { View, Text, Image as RNImage, Pressable, ActivityIndicator, StyleSheet, Alert } from "react-native"; +import { + View, + Text, + Image as RNImage, + Pressable, + ActivityIndicator, + StyleSheet, + Alert, + type StyleProp, + type ViewStyle, +} from "react-native"; import { useRouter } from "expo-router"; import * as ImagePicker from "expo-image-picker"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useVideoPlayer, VideoView } from "expo-video"; import { useTranslation } from "react-i18next"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ImagePlus, Camera } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { useTheme } from "../../src/context/ThemeContext"; -import { uploadImageToSupabase } from "../../src/lib/upload"; +import { uploadImageToSupabase, uploadFileToUrl } from "../../src/lib/upload"; import { resizeForUpload } from "../../src/lib/resize-image"; +import { MAX_STORY_VIDEO_DURATION_SECS } from "../../src/lib/story-video-limits"; import { Button } from "../../src/components/Button"; import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens"; /** - * Photo-only story creation (matches web's own "Phase 2" note in - * stories.ts: "Photo-only in Phase 2" — video stories are a Mux upload - * flow web itself only added later, not carried over here). Single image, - * unlike post-new.tsx's multi-image picker — a story is one photo. + * Photo or short video story creation. The video path mirrors web's + * StoryForm.tsx: stories.createVideoUpload gets a Mux direct-upload URL and + * creates the (PROCESSING) Story row up front, then the raw bytes go + * straight to Mux, never through our server — story-viewer.tsx polls + * getVideoStatus once this screen has already closed, same as web's viewer. + * Single asset, unlike post-new.tsx's multi-image picker — a story is one + * photo or one video. */ export default function NewStoryScreen() { const { t } = useTranslation(); @@ -26,10 +42,17 @@ export default function NewStoryScreen() { const queryClient = useQueryClient(); const { activePetId } = useActivePet(); const { colors } = useTheme(); - const styles = useMemo(() => makeStyles(colors), [colors]); + const insets = useSafeAreaInsets(); + const styles = useMemo(() => makeStyles(colors, insets.bottom), [colors, insets.bottom]); const [asset, setAsset] = useState(null); const [error, setError] = useState(null); + const [isUploadingFile, setIsUploadingFile] = useState(false); + + // expo-image-picker reports `duration` (ms) only for videos, `null`/`undefined` + // for photos — more reliable than trusting a MIME-type string (see the + // Android codec-suffix lesson in web's StoryForm.tsx handleFile()). + const isVideo = asset?.duration != null; const getPresignedUrlMutation = useMutation(trpc.stories.getPresignedUrl.mutationOptions()); @@ -44,15 +67,28 @@ export default function NewStoryScreen() { }) ); + const createVideoUploadMutation = useMutation( + trpc.stories.createVideoUpload.mutationOptions({ + onError: (err) => setError(err.message), + }) + ); + const pickFromGallery = useCallback(async () => { const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!permission.granted) { Alert.alert(t("onboarding.galleryPermissionDenied")); return; } - const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], quality: 0.8 }); - if (!result.canceled) setAsset(result.assets[0]); - }, []); + const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], quality: 0.8 }); + if (result.canceled) return; + + const picked = result.assets[0]; + if (picked.duration != null && picked.duration / 1000 > MAX_STORY_VIDEO_DURATION_SECS) { + Alert.alert(t("story.videoTooLong", { max: MAX_STORY_VIDEO_DURATION_SECS })); + return; + } + setAsset(picked); + }, [t]); const takePhoto = useCallback(async () => { const permission = await ImagePicker.requestCameraPermissionsAsync(); @@ -69,27 +105,50 @@ export default function NewStoryScreen() { setError(null); try { + if (isVideo) { + const { uploadUrl } = await createVideoUploadMutation.mutateAsync({ + petId: activePetId, + aiDisclosure: "NONE", + }); + setIsUploadingFile(true); + await uploadFileToUrl(asset.uri, uploadUrl ?? "", asset.mimeType ?? "video/mp4"); + // Story row already exists (PROCESSING) — story-viewer.tsx polls + // getVideoStatus for it, same as web's viewer. Nothing to await here. + await queryClient.invalidateQueries(trpc.follows.pathFilter()); + await queryClient.invalidateQueries(trpc.stories.pathFilter()); + router.back(); + return; + } + const resized = await resizeForUpload(asset.uri, asset.width, asset.height); const { presignedUrl, key } = await getPresignedUrlMutation.mutateAsync({ petId: activePetId, contentType: resized.mimeType, fileSize: resized.fileSize, }); + setIsUploadingFile(true); await uploadImageToSupabase(resized.uri, presignedUrl, resized.mimeType); createMutation.mutate({ petId: activePetId, storageKey: key, blurhash: null }); } catch { setError(t("onboarding.uploadError")); + } finally { + setIsUploadingFile(false); } - }, [asset, activePetId, getPresignedUrlMutation, createMutation]); + }, [asset, activePetId, isVideo, createVideoUploadMutation, getPresignedUrlMutation, createMutation, queryClient, trpc, router]); - const isSubmitting = getPresignedUrlMutation.isPending || createMutation.isPending; + const isSubmitting = + getPresignedUrlMutation.isPending || createMutation.isPending || createVideoUploadMutation.isPending || isUploadingFile; return ( {asset ? ( setAsset(null)}> - - {t("story.changePhotoHint")} + {isVideo ? ( + + ) : ( + + )} + {isVideo ? t("story.changeVideoHint") : t("story.changePhotoHint")} ) : ( @@ -116,10 +175,27 @@ export default function NewStoryScreen() { ); } -function makeStyles(colors: ColorTokens) { +/** Muted, looping local-file preview — separate component so `useVideoPlayer` isn't called conditionally in the screen body. */ +function VideoPreview({ uri, style }: { uri: string; style: StyleProp }) { + const player = useVideoPlayer(uri, (p) => { + p.loop = true; + p.muted = true; + p.play(); + }); + + return ; +} + +function makeStyles(colors: ColorTokens, bottomInset: number) { const typography = getTypography(colors); return StyleSheet.create({ - container: { flex: 1, padding: 16, gap: 12, backgroundColor: colors.porchWhite }, + container: { + flex: 1, + padding: 16, + paddingBottom: Math.max(16, bottomInset + 12), + gap: 12, + backgroundColor: colors.porchWhite, + }, pickerRow: { flexDirection: "row", gap: 12 }, pickerButton: { flex: 1, diff --git a/mobile/app/(app)/story-viewer.tsx b/mobile/app/(app)/story-viewer.tsx index 85460f3..2449d89 100644 --- a/mobile/app/(app)/story-viewer.tsx +++ b/mobile/app/(app)/story-viewer.tsx @@ -1,25 +1,34 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, Pressable, StyleSheet } from "react-native"; +import { View, Text, Pressable, ActivityIndicator, StyleSheet } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { X } from "lucide-react-native"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEventListener } from "expo"; +import { useTranslation } from "react-i18next"; +import { AlertCircle, X } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { PetAvatar } from "../../src/components/PetAvatar"; import { getMediaUrl } from "../../src/lib/media-url"; import { colors } from "../../src/theme/tokens"; -const STORY_DURATION_MS = 5000; +const IMAGE_STORY_DURATION_MS = 5000; /** - * Port of web's src/components/stories/StoryViewer.tsx, scoped to photo - * stories only (no Mux video story playback — see story-new.tsx). Also - * drops the owner-only viewer-count/list Sheet and the paw-reaction/comment - * overlay (Gitea #29 additions on web) — fast-follows, not core to "view a - * story". Tap the left third to go back a story, the rest to skip ahead; - * a fixed timer still auto-advances either way. + * Port of web's src/components/stories/StoryViewer.tsx. Also drops the + * owner-only viewer-count/list Sheet and the paw-reaction/comment overlay + * (Gitea #29 additions on web) — fast-follows, not core to "view a story". + * Tap the left third to go back a story, the rest to skip ahead. + * + * Image stories: fixed 5s progress timer, same as before. Video stories: + * progress tracks real playback via expo-video's `timeUpdate` event and + * advances on `playToEnd`, mirroring web's player-driven (not timer-driven) + * behavior — see StoryViewer.tsx's handleVideoTimeUpdate/handleVideoEnded. + * Always full-bleed + contentFit="contain" for both media kinds (no + * aspect-ratio-driven box sizing) — matches what web itself does below its + * own `sm` breakpoint, which is the only case that applies on a phone. */ export default function StoryViewerScreen() { const { petId, petName, petAvatarKey } = useLocalSearchParams<{ @@ -39,8 +48,19 @@ export default function StoryViewerScreen() { const advancedRef = useRef(false); const currentStory = stories[index]; + const isVideo = currentStory?.mediaType === "VIDEO"; const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions()); + // Poll while a video story is still processing — same self-heal pattern as + // VideoPlayer.tsx, scoped to just the story currently on screen. + const { data: videoStatusData } = useQuery({ + ...trpc.stories.getVideoStatus.queryOptions({ storyId: currentStory?.id ?? "" }), + enabled: !!currentStory && isVideo && currentStory.videoStatus === "PROCESSING", + refetchInterval: (query) => (query.state.data?.videoStatus === "PROCESSING" ? 3000 : false), + }); + const videoStatus = videoStatusData?.videoStatus ?? currentStory?.videoStatus; + const videoPlaybackId = videoStatusData?.muxPlaybackId ?? currentStory?.muxPlaybackId; + const close = useCallback(() => { queryClient.invalidateQueries(trpc.stories.hasActiveStory.queryOptions({ petId })); queryClient.invalidateQueries(trpc.follows.pathFilter()); @@ -70,16 +90,17 @@ export default function StoryViewerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStory?.id, activePetId]); - // Fixed-duration progress bar, auto-advances/closes when it fills. + // Image stories — fixed duration progress bar, unused for video stories + // (those advance on the player's playToEnd event instead, see VideoStory below). useEffect(() => { - if (!currentStory) return; + if (!currentStory || isVideo) return; setProgress(0); advancedRef.current = false; const startTime = Date.now(); let rafId: number; function tick() { - const pct = Math.min(((Date.now() - startTime) / STORY_DURATION_MS) * 100, 100); + const pct = Math.min(((Date.now() - startTime) / IMAGE_STORY_DURATION_MS) * 100, 100); setProgress(pct); if (pct < 100) rafId = requestAnimationFrame(tick); else advance(); @@ -87,7 +108,16 @@ export default function StoryViewerScreen() { rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - }, [currentStory, advance]); + }, [currentStory, isVideo, advance]); + + // Reset progress/advance-guard when a video story becomes current — the + // effect above skips video stories entirely, so this is video's equivalent. + useEffect(() => { + if (isVideo) { + setProgress(0); + advancedRef.current = false; + } + }, [isVideo, currentStory?.id]); if (!currentStory) return null; @@ -108,7 +138,11 @@ export default function StoryViewerScreen() { ))} - {mediaUrl && } + {isVideo ? ( + + ) : ( + mediaUrl && + )} @@ -127,9 +161,96 @@ export default function StoryViewerScreen() { ); } +type VideoStatusValue = "PROCESSING" | "READY" | "ERROR"; + +/** Branches on video status; only mounts the actual player once READY, mirroring VideoPlayer.tsx's feed-post pattern. */ +function VideoStory({ + status, + playbackId, + onProgress, + onEnded, +}: { + status: VideoStatusValue | null | undefined; + playbackId: string | null | undefined; + onProgress: (pct: number) => void; + onEnded: () => void; +}) { + const { t } = useTranslation(); + + if (status === "READY" && playbackId) { + return ; + } + + return ( + + {status === "ERROR" ? ( + <> + + {t("video.error")} + + ) : ( + <> + + {t("video.processing")} + + )} + + ); +} + +/** + * READY-only player. `loop = false` (unlike VideoPlayer.tsx's feed videos, + * which loop) — a story plays once and advances via `playToEnd`, not a + * fixed timer, same as web's handleVideoEnded. Progress bar is derived from + * real playback position (`timeUpdate`), not a timer either — see + * StoryViewer.tsx's handleVideoTimeUpdate for the web equivalent. + * + * `play()` is deliberately NOT called in the `useVideoPlayer` setup + * callback — calling it before the player reaches `readyToPlay` left + * `duration` at 0, and `playToEnd` fired immediately (observed on both the + * emulator and a real device: playback never visibly started, progress + * stayed at 0, the viewer closed within ~1s of entering the video story). + * Waiting for `statusChange` (plus a mount check for the case where the + * player is already `readyToPlay` by the time the listener attaches) fixed + * it — confirmed by full 15s playback with a real progress bar. + */ +function ReadyVideoStory({ + playbackId, + onProgress, + onEnded, +}: { + playbackId: string; + onProgress: (pct: number) => void; + onEnded: () => void; +}) { + const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => { + p.loop = false; + p.timeUpdateEventInterval = 0.25; + }); + + useEffect(() => { + if (player.status === "readyToPlay") player.play(); + }, [player]); + + useEventListener(player, "statusChange", ({ status }) => { + if (status === "readyToPlay") player.play(); + }); + useEventListener(player, "timeUpdate", ({ currentTime }) => { + if (player.duration > 0) onProgress((currentTime / player.duration) * 100); + }); + useEventListener(player, "playToEnd", () => { + onProgress(100); + onEnded(); + }); + + return ; +} + const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: colors.ink }, media: { flex: 1, width: "100%" }, + statusBox: { flex: 1, width: "100%", alignItems: "center", justifyContent: "center", gap: 8 }, + statusLabel: { color: colors.pureWhite, fontSize: 14 }, tapZones: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, flexDirection: "row" }, tapZoneLeft: { flex: 1 }, tapZoneRight: { flex: 2 }, diff --git a/mobile/src/components/StoryTray.tsx b/mobile/src/components/StoryTray.tsx index 278cb2f..8ff7f77 100644 --- a/mobile/src/components/StoryTray.tsx +++ b/mobile/src/components/StoryTray.tsx @@ -18,8 +18,8 @@ const AVATAR_SIZE = 56; * for the active pet, then followed pets with an active story (each in a * PawRing). No viewer list, reactions, or comments on stories yet (Gitea * #29 on web) — that's a fast-follow, matching how earlier phases trimmed - * AI-disclosure/mentions from ported forms. Photo stories only, no video - * story upload/playback (mirrors mobile's post-creation scope decision). + * AI-disclosure/mentions from ported forms. Photo and video stories both + * play back the same way here — see story-viewer.tsx for the branch. */ export function StoryTray() { const { t } = useTranslation(); diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json index 62cbedc..03bdb57 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -88,7 +88,9 @@ }, "story": { "changePhotoHint": "Antippen, um ein anderes Foto zu wählen", - "shareButton": "Story teilen" + "changeVideoHint": "Antippen, um ein anderes Video zu wählen", + "shareButton": "Story teilen", + "videoTooLong": "Videos für Stories dürfen höchstens {{max}} Sekunden lang sein." }, "milestoneTypes": { "BIRTHDAY": "Geburtstag", diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index 633e6c0..f112937 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -88,7 +88,9 @@ }, "story": { "changePhotoHint": "Tap to choose a different photo", - "shareButton": "Share story" + "changeVideoHint": "Tap to choose a different video", + "shareButton": "Share story", + "videoTooLong": "Story videos can be at most {{max}} seconds long." }, "milestoneTypes": { "BIRTHDAY": "Birthday", diff --git a/mobile/src/lib/story-video-limits.ts b/mobile/src/lib/story-video-limits.ts new file mode 100644 index 0000000..8369c13 --- /dev/null +++ b/mobile/src/lib/story-video-limits.ts @@ -0,0 +1,6 @@ +/** + * Mirrors src/lib/story-video-limits.ts on web — mobile and web don't share + * a package, so this value is duplicated. Keep both in sync manually if the + * limit ever changes. + */ +export const MAX_STORY_VIDEO_DURATION_SECS = 30; diff --git a/mobile/src/lib/upload.ts b/mobile/src/lib/upload.ts index eac9ff7..a3da871 100644 --- a/mobile/src/lib/upload.ts +++ b/mobile/src/lib/upload.ts @@ -20,3 +20,23 @@ export async function uploadImageToSupabase(uri: string, presignedUrl: string, m throw new Error(`Upload fehlgeschlagen (Status ${result.status})`); } } + +/** + * PUTs a local video file directly to a Mux direct-upload URL (from + * `stories.createVideoUpload`). Separate function rather than reusing + * `uploadImageToSupabase` above — same `File.upload` primitive, but a + * different destination (Mux, not Supabase) and no signature-matching + * requirement on Content-Type, so keeping the working photo path untouched. + */ +export async function uploadFileToUrl(uri: string, uploadUrl: string, mimeType: string): Promise { + const file = new File(uri); + const result = await file.upload(uploadUrl, { + httpMethod: "PUT", + mimeType, + headers: { "Content-Type": mimeType }, + }); + + if (result.status < 200 || result.status >= 300) { + throw new Error(`Video-Upload fehlgeschlagen (Status ${result.status})`); + } +}