feat(mobile): Video-Storys in der App (Upload + Wiedergabe)

Merged feature/mobile-video-stories (Gitea #37) auf den seit 02.10.
umgebauten Story-Viewer: Galerie-Picker nimmt Videos bis 30 s, Upload
direkt zu Mux, Viewer spielt READY-Videos ab (Fortschritt aus der echten
Wiedergabe, Weiter bei playToEnd) und zeigt PROCESSING/ERROR. Offene
Kommentar-/Zuschauer-Sheets pausieren das Video wie den Foto-Timer.
Nur JS — expo-video ist seit dem 1.0.6-Build nativ enthalten, also OTA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 14:25:24 +02:00
commit 69e2c1364e
7 files changed
+231 -31

No files matched your search

+92 -16
View File
@@ -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<ImagePicker.ImagePickerAsset | null>(null);
const [error, setError] = useState<string | null>(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 (
<View style={styles.container}>
{asset ? (
<Pressable style={styles.preview} onPress={() => setAsset(null)}>
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
<Text style={styles.previewHint}>{t("story.changePhotoHint")}</Text>
{isVideo ? (
<VideoPreview uri={asset.uri} style={styles.previewImage} />
) : (
<RNImage source={{ uri: asset.uri }} style={styles.previewImage} />
)}
<Text style={styles.previewHint}>{isVideo ? t("story.changeVideoHint") : t("story.changePhotoHint")}</Text>
</Pressable>
) : (
<View style={styles.pickerRow}>
@@ -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<ViewStyle> }) {
const player = useVideoPlayer(uri, (p) => {
p.loop = true;
p.muted = true;
p.play();
});
return <VideoView player={player} style={style} contentFit="contain" nativeControls={false} />;
}
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,
+105 -11
View File
@@ -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() {
))}
</View>
{mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />}
{isVideo ? (
<VideoStory
key={currentStory.id}
status={videoStatus}
playbackId={videoPlaybackId}
paused={isPaused}
onProgress={setProgress}
onEnded={advance}
/>
) : (
mediaUrl && <Image source={{ uri: mediaUrl }} style={styles.media} contentFit="contain" />
)}
<View style={styles.tapZones}>
<Pressable style={styles.tapZoneLeft} onPress={goBack} />
@@ -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 <ReadyVideoStory playbackId={playbackId} {...playerProps} />;
}
return (
<View style={styles.statusBox}>
{status === "ERROR" ? (
<>
<AlertCircle color={colors.alertRed} size={28} />
<Text style={styles.statusLabel}>{t("video.error")}</Text>
</>
) : (
<>
<ActivityIndicator color={colors.marigoldOrange} />
<Text style={styles.statusLabel}>{t("video.processing")}</Text>
</>
)}
</View>
);
}
/**
* 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<VideoStoryProps, "status" | "playbackId"> & { 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 <VideoView player={player} style={styles.media} contentFit="contain" nativeControls={false} />;
}
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 },
+2 -2
View File
@@ -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();
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+6
View File
@@ -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;
+20
View File
@@ -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<void> {
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})`);
}
}