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 34b47a0..4045b23 100644 --- a/mobile/app/(app)/story-viewer.tsx +++ b/mobile/app/(app)/story-viewer.tsx @@ -5,7 +5,9 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Image } from "expo-image"; -import { Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native"; +import { useVideoPlayer, VideoView } from "expo-video"; +import { useEventListener } from "expo"; +import { AlertCircle, Clock, Eye, MessageCircle, PawPrint, Trash2, X } from "lucide-react-native"; import { useTRPC } from "../../src/api/trpc"; import { useActivePet } from "../../src/context/ActivePetContext"; import { PetAvatar } from "../../src/components/PetAvatar"; @@ -15,15 +17,15 @@ import { StoryViewersSheet } from "../../src/components/stories/StoryViewersShee import { getMediaUrl } from "../../src/lib/media-url"; import { colors } from "../../src/theme/tokens"; -const STORY_DURATION_MS = 5000; +const IMAGE_STORY_DURATION_MS = 5000; type Sheet = "comments" | "viewers" | "confirm-delete" | null; /** - * Port of web's src/components/stories/StoryViewer.tsx, scoped to photo - * stories (no Mux video story playback — see story-new.tsx). Tap the left - * third to go back a story, the rest to skip ahead; a fixed timer - * auto-advances. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene + * Port of web's src/components/stories/StoryViewer.tsx. Tap the left third + * to go back a story, the rest to skip ahead. Foto-Storys laufen über einen + * festen 5-s-Timer; Video-Storys folgen der echten Wiedergabe (expo-video + * `timeUpdate`) und springen bei `playToEnd` weiter, wie im Web. Pfote, Kommentare (Bottom-Sheet) und — nur für die eigene * Story — die Zuschauerliste und Löschen wie im Web; solange ein Sheet oder * die Lösch-Rückfrage offen ist, hält der Timer an. */ @@ -51,6 +53,7 @@ export default function StoryViewerScreen() { const currentStory = stories[index]; const isPaused = openSheet !== null; + const isVideo = currentStory?.mediaType === "VIDEO"; const recordViewMutation = useMutation(trpc.stories.recordView.mutationOptions()); const deleteMutation = useMutation( @@ -84,6 +87,15 @@ export default function StoryViewerScreen() { ); }; + // Solange ein Video noch verarbeitet wird, Status abfragen (Self-Heal wie VideoPlayer.tsx). + 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 { data: viewers = [] } = useQuery({ ...trpc.stories.getViewers.queryOptions({ storyId: currentStory?.id ?? "" }), enabled: isOwnStory && !!currentStory, @@ -133,9 +145,10 @@ export default function StoryViewerScreen() { setProgress(0); }, [currentStory?.id]); - // Fixed-duration progress bar; pausiert, solange ein Sheet offen ist. + // Fester Timer nur für Foto-Storys; pausiert, solange ein Sheet offen ist. + // Video-Storys steuern Fortschritt und Weiterspringen selbst (ReadyVideoStory). useEffect(() => { - if (!currentStory || isPaused) return; + if (!currentStory || isVideo || isPaused) return; const resumedAt = Date.now(); const elapsedBefore = elapsedRef.current; let rafId: number; @@ -143,7 +156,7 @@ export default function StoryViewerScreen() { function tick() { const elapsed = elapsedBefore + (Date.now() - resumedAt); elapsedRef.current = elapsed; - const pct = Math.min((elapsed / STORY_DURATION_MS) * 100, 100); + const pct = Math.min((elapsed / IMAGE_STORY_DURATION_MS) * 100, 100); setProgress(pct); if (pct < 100) rafId = requestAnimationFrame(tick); else advance(); @@ -151,7 +164,7 @@ export default function StoryViewerScreen() { rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); - }, [currentStory, isPaused, advance]); + }, [currentStory, isVideo, isPaused, advance]); if (!currentStory) return null; @@ -173,7 +186,18 @@ export default function StoryViewerScreen() { ))} - {mediaUrl && } + {isVideo ? ( + + ) : ( + mediaUrl && + )} @@ -278,9 +302,79 @@ export default function StoryViewerScreen() { ); } +type VideoStatusValue = "PROCESSING" | "READY" | "ERROR"; + +interface VideoStoryProps { + status: VideoStatusValue | null | undefined; + playbackId: string | null | undefined; + paused: boolean; + onProgress: (pct: number) => void; + onEnded: () => void; +} + +/** Zeigt den Verarbeitungs-/Fehlerzustand; der Player wird erst bei READY gemountet (wie VideoPlayer.tsx). */ +function VideoStory({ status, playbackId, ...playerProps }: VideoStoryProps) { + const { t } = useTranslation(); + + if (status === "READY" && playbackId) { + return ; + } + + return ( + + {status === "ERROR" ? ( + <> + + {t("video.error")} + + ) : ( + <> + + {t("video.processing")} + + )} + + ); +} + +/** + * Spielt einmal ab (kein Loop) und springt bei `playToEnd` weiter. + * `play()` erst bei `readyToPlay` — vorher ist `duration` 0 und `playToEnd` + * feuerte sofort (Viewer schloss sich nach < 1 s, auf Emulator und Gerät + * beobachtet). Offenes Sheet pausiert das Video wie den Foto-Timer. + */ +function ReadyVideoStory({ playbackId, paused, onProgress, onEnded }: Omit & { playbackId: string }) { + const player = useVideoPlayer(`https://stream.mux.com/${playbackId}.m3u8`, (p) => { + p.loop = false; + p.timeUpdateEventInterval = 0.25; + }); + const pausedRef = useRef(paused); + + useEffect(() => { + pausedRef.current = paused; + if (paused) player.pause(); + else if (player.status === "readyToPlay") player.play(); + }, [player, paused]); + + useEventListener(player, "statusChange", ({ status }) => { + if (status === "readyToPlay" && !pausedRef.current) 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 fdcab74..0f89d54 100644 --- a/mobile/src/components/StoryTray.tsx +++ b/mobile/src/components/StoryTray.tsx @@ -20,8 +20,8 @@ const RING_BOX = getPawRingBox(AVATAR_SIZE); * ("Deine Story" — the followed-pets query never includes the viewer's own * 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 8c10988..62dd9f3 100644 --- a/mobile/src/i18n/locales/de.json +++ b/mobile/src/i18n/locales/de.json @@ -90,7 +90,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 2cb3e4a..8a0e9bc 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -90,7 +90,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})`); + } +}