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:
commit
69e2c1364e
7 files changed
+231
-31
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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})`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user