step 4: stream playback experience
This commit is contained in:
@@ -19,9 +19,12 @@
|
||||
"clsx": "^2.1.1",
|
||||
"expo": "~54.0.0",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-video": "~3.0.10",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"firebase": "^12.10.0",
|
||||
"lucide-react-native": "^0.575.0",
|
||||
"nativewind": "^4.1.23",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Trash2 } from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
// How long to linger on a tombstone before auto-advancing. Same cadence as
|
||||
// desktop — a beat long enough to read "this was deleted," not so long it
|
||||
// stalls the stream.
|
||||
const TOMBSTONE_DURATION_MS = 2000;
|
||||
|
||||
interface DeletedParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function DeletedParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: DeletedParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const deleterId =
|
||||
"deleted_by_human_id" in particle ? particle.deleted_by_human_id : undefined;
|
||||
const deleter = deleterId
|
||||
? resolveHumanDisplay(deleterId, network?.humans)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, TOMBSTONE_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Trash2 color="rgba(255,255,255,0.4)" size={28} strokeWidth={1.5} />
|
||||
<Text className="text-white/70 mt-3 text-base font-medium">
|
||||
This particle was deleted
|
||||
</Text>
|
||||
{deleter ? (
|
||||
<Text className="text-white/40 mt-1 text-xs">
|
||||
by {deleter.displayName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import {
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
ScrollText,
|
||||
BookOpen,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
|
||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||
quest: { icon: ScrollText, label: "Quest" },
|
||||
paper: { icon: BookOpen, label: "Paper" },
|
||||
file: { icon: FileIcon, label: "File" },
|
||||
};
|
||||
|
||||
const PLACEHOLDER_DURATION_MS = 5000;
|
||||
|
||||
interface FallbackParticleViewProps {
|
||||
particle: Particle;
|
||||
networkId: string;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
}
|
||||
|
||||
export function FallbackParticleView({
|
||||
particle,
|
||||
networkId,
|
||||
paused,
|
||||
onEnded,
|
||||
}: FallbackParticleViewProps) {
|
||||
const network = useNetwork(networkId);
|
||||
const creator = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
const meta = TYPE_META[particle.type] ?? {
|
||||
icon: HelpCircle,
|
||||
label: particle.type,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
switch (particle.type) {
|
||||
case "quest":
|
||||
return particle.properties.title;
|
||||
case "paper":
|
||||
return particle.properties.title;
|
||||
case "file":
|
||||
return particle.properties.filename;
|
||||
case "folder":
|
||||
return particle.properties.name;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
const timeout = setTimeout(onEnded, PLACEHOLDER_DURATION_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [paused, onEnded, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 w-full max-w-sm rounded-2xl px-5 py-5">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<Icon color="rgba(255,255,255,0.7)" size={22} strokeWidth={1.5} />
|
||||
<View className="flex-1">
|
||||
<Text className="text-white text-base font-semibold">
|
||||
{meta.label}
|
||||
</Text>
|
||||
{title ? (
|
||||
<Text className="text-white/70 text-sm" numberOfLines={2}>
|
||||
{title}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-white/50 mt-4 text-xs">
|
||||
From {creator.displayName}
|
||||
</Text>
|
||||
<Text className="text-white/50 mt-1 text-xs">View on desktop</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { Mic, Video as VideoIcon } from "lucide-react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
|
||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||
|
||||
interface MediaParticleViewProps {
|
||||
particle: MediaParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
}
|
||||
|
||||
// Step 4 placeholder. Step 5 replaces this with `expo-video` playback for MP4
|
||||
// (camera + audio-only mp4) and "View on desktop" for legacy WebM. For now the
|
||||
// view advances on a 5s timer so the rest of the playback shell is exercisable
|
||||
// against existing media particles (which would currently be desktop WebM).
|
||||
const PLACEHOLDER_DURATION_S = 5;
|
||||
const TICK_MS = 100;
|
||||
|
||||
export function MediaParticleView({
|
||||
particle,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: MediaParticleViewProps) {
|
||||
const isAudio = particle.properties.mime_type.startsWith("audio/");
|
||||
const isMp4 =
|
||||
particle.properties.mime_type === "video/mp4" ||
|
||||
particle.properties.mime_type === "audio/mp4";
|
||||
|
||||
useEffect(() => {
|
||||
onProgress(0);
|
||||
}, [particle.id, onProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
let elapsed = 0;
|
||||
const interval = setInterval(() => {
|
||||
elapsed += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsed / PLACEHOLDER_DURATION_S, 1);
|
||||
onProgress(ratio);
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, onEnded, onProgress, particle.id]);
|
||||
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<View className="bg-white/10 h-24 w-24 items-center justify-center rounded-full">
|
||||
{isAudio ? (
|
||||
<Mic color="white" size={36} strokeWidth={1.5} />
|
||||
) : (
|
||||
<VideoIcon color="white" size={36} strokeWidth={1.5} />
|
||||
)}
|
||||
</View>
|
||||
<Text className="text-white mt-6 text-lg font-medium">
|
||||
{isAudio ? "Voice message" : "Video message"}
|
||||
</Text>
|
||||
<Text className="text-white/60 mt-2 text-sm text-center">
|
||||
{isMp4
|
||||
? "Playback wires up in step 5."
|
||||
: "Recorded on desktop — view there until codecs converge."}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from "react";
|
||||
import { View } from "react-native";
|
||||
import Animated, {
|
||||
Easing,
|
||||
cancelAnimation,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
|
||||
interface PlaybackPageIndicatorProps {
|
||||
total: number;
|
||||
current: number;
|
||||
/** 0–1 progress for the active segment. Source ticks at ~100ms. */
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
const SEGMENT_GAP = 3;
|
||||
const SEGMENT_HEIGHT = 2.5;
|
||||
const SMOOTHING_MS = 300;
|
||||
|
||||
/**
|
||||
* Snapchat-style segmented progress bar. Past segments full, future empty,
|
||||
* active segment animated. The 300ms linear smoothing absorbs the 100ms
|
||||
* tick from the particle view source so motion looks continuous at 60fps.
|
||||
*/
|
||||
export function PlaybackPageIndicator({
|
||||
total,
|
||||
current,
|
||||
progress,
|
||||
paused,
|
||||
}: PlaybackPageIndicatorProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-stretch" style={{ gap: SEGMENT_GAP }}>
|
||||
{Array.from({ length: total }).map((_, i) => (
|
||||
<Segment
|
||||
key={i}
|
||||
isActive={i === current}
|
||||
isPast={i < current}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SegmentProps {
|
||||
isActive: boolean;
|
||||
isPast: boolean;
|
||||
progress: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
function Segment({ isActive, isPast, progress, paused }: SegmentProps) {
|
||||
// Each segment owns its own width animation. Past = 1, future = 0,
|
||||
// active = animated toward `progress`. Reanimated keeps the tween on the
|
||||
// UI thread so JS thread stalls (e.g. the 100ms text tick re-render)
|
||||
// can't drop frames here.
|
||||
const fill = useSharedValue(isPast ? 1 : 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPast) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = withTiming(1, { duration: 120, easing: Easing.out(Easing.cubic) });
|
||||
return;
|
||||
}
|
||||
if (!isActive) {
|
||||
cancelAnimation(fill);
|
||||
fill.value = 0;
|
||||
return;
|
||||
}
|
||||
if (paused) {
|
||||
cancelAnimation(fill);
|
||||
return;
|
||||
}
|
||||
fill.value = withTiming(progress, {
|
||||
duration: SMOOTHING_MS,
|
||||
easing: Easing.linear,
|
||||
});
|
||||
}, [isPast, isActive, progress, paused, fill]);
|
||||
|
||||
const fillStyle = useAnimatedStyle(() => ({
|
||||
width: `${Math.min(Math.max(fill.value, 0), 1) * 100}%`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-1 overflow-hidden rounded-full bg-white/30"
|
||||
style={{ height: SEGMENT_HEIGHT }}
|
||||
>
|
||||
<Animated.View
|
||||
className="h-full bg-white/95 rounded-full"
|
||||
style={fillStyle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Text, View } from "react-native";
|
||||
import type { Network, Particle } from "@/api/types";
|
||||
import { resolveHumanDisplay } from "@/lib/humans";
|
||||
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
|
||||
|
||||
interface StreamMetadataHeaderProps {
|
||||
particle: Particle | null;
|
||||
network: Network | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar + display name + relative time. Sits below the segmented bar so the
|
||||
* "who/when" answer is always one glance away — Snapchat-style. Reactions row
|
||||
* is omitted in step 4; the swipe-up reaction sheet (step 5) replaces the
|
||||
* desktop right-edge stack and that's where reaction counts will surface.
|
||||
*/
|
||||
export function StreamMetadataHeader({
|
||||
particle,
|
||||
network,
|
||||
}: StreamMetadataHeaderProps) {
|
||||
if (!particle) return null;
|
||||
const display = resolveHumanDisplay(
|
||||
particle.created_by_human_id,
|
||||
network?.humans,
|
||||
);
|
||||
|
||||
const editedAt =
|
||||
particle.type === "text" ? particle.properties.edited_at : undefined;
|
||||
|
||||
return (
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="bg-white/15 h-9 w-9 items-center justify-center rounded-full">
|
||||
<Text className="text-white text-xs font-semibold">
|
||||
{display.initials}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className="text-white text-sm font-semibold"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{display.displayName}
|
||||
</Text>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<RelativeTimestamp
|
||||
date={particle.created_at}
|
||||
className="text-white/60 text-xs"
|
||||
/>
|
||||
{editedAt ? (
|
||||
<Text className="text-white/40 text-xs">
|
||||
· edited{" "}
|
||||
<RelativeTimestamp date={editedAt} className="text-white/40" />
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Dimensions, Pressable, Text, View } from "react-native";
|
||||
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
} from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
Extrapolation,
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { isParticleDeleted, type Particle } from "@/api/types";
|
||||
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { useNetwork } from "@/hooks/use-networks";
|
||||
import { useStreamPlayback } from "@/hooks/use-stream-playback";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
import {
|
||||
selectIsPaused,
|
||||
usePlaybackPauseStore,
|
||||
} from "@/stores/playback-pause-store";
|
||||
import { PlaybackPageIndicator } from "./PlaybackPageIndicator";
|
||||
import { StreamMetadataHeader } from "./StreamMetadataHeader";
|
||||
import { TextParticleView } from "./TextParticleView";
|
||||
import { MediaParticleView } from "./MediaParticleView";
|
||||
import { DeletedParticleView } from "./DeletedParticleView";
|
||||
import { FallbackParticleView } from "./FallbackParticleView";
|
||||
import { useExitCountdown } from "./use-exit-countdown";
|
||||
|
||||
const SCREEN_HEIGHT = Dimensions.get("window").height;
|
||||
// Tap-zone split: left 28% goes back, right 72% goes forward — matching the
|
||||
// asymmetric "Snapchat thumb-zone" so right-handed taps default to forward.
|
||||
const PREV_ZONE_RATIO = 0.28;
|
||||
// Swipe-down dismiss commit thresholds — either move 1/4 of the screen, or
|
||||
// flick downward fast enough.
|
||||
const DISMISS_DISTANCE = SCREEN_HEIGHT * 0.25;
|
||||
const DISMISS_VELOCITY = 900;
|
||||
|
||||
interface StreamViewProps {
|
||||
streamParticle: Particle & { type: "stream" };
|
||||
path: ParticlePath;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function StreamView({ streamParticle, path, onExit }: StreamViewProps) {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const network = useNetwork(networkId);
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { children, currentParticle, currentIndex, status, next, prev } =
|
||||
useStreamPlayback(streamParticle, path);
|
||||
|
||||
const paused = usePlaybackPauseStore(selectIsPaused);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
// Local hold state drives the "touch-hold" pause suspender. We wrap the JS
|
||||
// setter inside a runOnJS callback dispatched from the worklet thread.
|
||||
const [holdActive, setHoldActive] = useState(false);
|
||||
useSuspendPlayback(holdActive, "touch-hold");
|
||||
|
||||
// Reset progress whenever the active particle changes.
|
||||
useEffect(() => {
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
|
||||
const handleTap = useCallback(
|
||||
(xRatio: number) => {
|
||||
if (xRatio < PREV_ZONE_RATIO) {
|
||||
if (currentIndex <= 0) {
|
||||
// Soft "thud" — nothing to go back to.
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
return;
|
||||
}
|
||||
prev();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
[currentIndex, next, prev],
|
||||
);
|
||||
|
||||
// --- Swipe-down dismiss ---
|
||||
const translateY = useSharedValue(0);
|
||||
const screenWidth = Dimensions.get("window").width;
|
||||
|
||||
const exit = useCallback(() => {
|
||||
onExit();
|
||||
}, [onExit]);
|
||||
|
||||
const pan = Gesture.Pan()
|
||||
.activeOffsetY(15)
|
||||
.failOffsetX([-30, 30])
|
||||
.failOffsetY(-20)
|
||||
.onUpdate((e) => {
|
||||
"worklet";
|
||||
translateY.value = Math.max(0, e.translationY);
|
||||
})
|
||||
.onEnd((e) => {
|
||||
"worklet";
|
||||
if (
|
||||
e.translationY > DISMISS_DISTANCE ||
|
||||
e.velocityY > DISMISS_VELOCITY
|
||||
) {
|
||||
translateY.value = withTiming(SCREEN_HEIGHT, { duration: 220 });
|
||||
runOnJS(exit)();
|
||||
} else {
|
||||
translateY.value = withSpring(0, {
|
||||
damping: 22,
|
||||
stiffness: 220,
|
||||
mass: 0.6,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Tap (advance / regress) ---
|
||||
const tap = Gesture.Tap()
|
||||
.maxDuration(180)
|
||||
.maxDistance(15)
|
||||
.onEnd((e, success) => {
|
||||
"worklet";
|
||||
if (!success) return;
|
||||
const ratio = e.x / screenWidth;
|
||||
runOnJS(handleTap)(ratio);
|
||||
});
|
||||
|
||||
// --- Long-press (hold-to-pause) ---
|
||||
const longPress = Gesture.LongPress()
|
||||
.minDuration(180)
|
||||
.maxDistance(15)
|
||||
.onStart(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(true);
|
||||
})
|
||||
.onTouchesUp(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(false);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
"worklet";
|
||||
runOnJS(setHoldActive)(false);
|
||||
});
|
||||
|
||||
// Pan races the tap+longPress combo: vertical drag activates pan and
|
||||
// cancels the others; otherwise tap and long-press run simultaneously.
|
||||
const composed = Gesture.Race(pan, Gesture.Simultaneous(tap, longPress));
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.5],
|
||||
[1, 0.4],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
const scale = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT],
|
||||
[1, 0.85],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return {
|
||||
transform: [{ translateY: translateY.value }, { scale }],
|
||||
opacity,
|
||||
};
|
||||
});
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const opacity = interpolate(
|
||||
translateY.value,
|
||||
[0, SCREEN_HEIGHT * 0.5],
|
||||
[1, 0.6],
|
||||
Extrapolation.CLAMP,
|
||||
);
|
||||
return { opacity };
|
||||
});
|
||||
|
||||
// --- End-of-stream countdown ---
|
||||
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
||||
|
||||
// --- Render the active particle ---
|
||||
const renderParticle = (particle: Particle) => {
|
||||
if (isParticleDeleted(particle)) {
|
||||
return (
|
||||
<DeletedParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
/>
|
||||
);
|
||||
}
|
||||
switch (particle.type) {
|
||||
case "text":
|
||||
return (
|
||||
<TextParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case "media":
|
||||
return (
|
||||
<MediaParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FallbackParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
networkId={networkId}
|
||||
paused={paused}
|
||||
onEnded={next}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Content guards ---
|
||||
if (children.length === 0) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-base">
|
||||
No particles in this stream yet.
|
||||
</Text>
|
||||
<Pressable onPress={exit} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[{ flex: 1 }, backdropStyle]} className="bg-black">
|
||||
<StatusBar style="light" hidden />
|
||||
<Animated.View style={[{ flex: 1 }, containerStyle]} className="bg-black">
|
||||
<GestureDetector gesture={composed}>
|
||||
<View className="flex-1">
|
||||
{/* Particle canvas — fills the whole screen, gesture-aware. */}
|
||||
<View className="flex-1">
|
||||
{currentParticle ? renderParticle(currentParticle) : null}
|
||||
</View>
|
||||
|
||||
{/* Top chrome: segmented bar + metadata. Painted over the canvas
|
||||
so the canvas can be edge-to-edge but content gets a safe-area
|
||||
gradient to read against. */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0 top-0 h-40 bg-black/40"
|
||||
style={{
|
||||
paddingTop: insets.top,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0"
|
||||
style={{ top: insets.top + 8 }}
|
||||
>
|
||||
<View className="px-3">
|
||||
<PlaybackPageIndicator
|
||||
total={children.length}
|
||||
current={currentIndex}
|
||||
progress={progress}
|
||||
paused={paused}
|
||||
/>
|
||||
</View>
|
||||
<View className="mt-3 px-4">
|
||||
<StreamMetadataHeader
|
||||
particle={currentParticle}
|
||||
network={network ?? null}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
||||
home-indicator safe-area so they aren't visually clipped. */}
|
||||
<View
|
||||
pointerEvents="none"
|
||||
className="absolute inset-x-0 bottom-0 items-center"
|
||||
style={{ paddingBottom: insets.bottom + 16 }}
|
||||
>
|
||||
{paused ? (
|
||||
<View className="bg-white/15 rounded-full px-3 py-1">
|
||||
<Text className="text-white/90 text-xs font-medium">
|
||||
Paused
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{exitRemainingMs !== null ? (
|
||||
<View className="bg-white/15 rounded-full px-3 py-1 mt-2">
|
||||
<Text className="text-white/90 text-xs font-medium">
|
||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* Safe-area sentinel for top notch — kept outside GestureDetector so
|
||||
iOS's status-bar tap doesn't fight our gestures. */}
|
||||
<SafeAreaView edges={["top"]} pointerEvents="none" />
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,48 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import type { RootStackScreenProps } from "@/navigation/types";
|
||||
import { particlePath } from "@/lib/particle-path";
|
||||
import { useLiveParticle } from "@/hooks/use-particle";
|
||||
import { StreamView } from "./StreamView";
|
||||
|
||||
export function StreamViewScreen({
|
||||
navigation,
|
||||
route,
|
||||
}: RootStackScreenProps<"StreamView">) {
|
||||
const { networkId, streamId } = route.params;
|
||||
const streamPath = particlePath(networkId, [streamId]);
|
||||
const { particle, isLoading, error } = useLiveParticle(streamPath);
|
||||
|
||||
if (isLoading && !particle) {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<StatusBar style="light" hidden />
|
||||
<ActivityIndicator color="white" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !particle || particle.type !== "stream") {
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center px-8">
|
||||
<StatusBar style="light" hidden />
|
||||
<Text className="text-white/70 text-center">
|
||||
{error
|
||||
? "Couldn't load this stream."
|
||||
: "This stream is no longer available."}
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/60">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-black items-center justify-center">
|
||||
<Text className="text-white text-base">
|
||||
Stream view — coming in step 4.
|
||||
</Text>
|
||||
<Pressable onPress={() => navigation.goBack()} className="mt-6 px-4 py-2">
|
||||
<Text className="text-white/70">Close</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<StreamView
|
||||
streamParticle={particle}
|
||||
path={streamPath}
|
||||
onExit={() => navigation.goBack()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ScrollView, Text, View } from "react-native";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextParticle = Extract<Particle, { type: "text" }>;
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: TextParticle;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress: (ratio: number) => void;
|
||||
}
|
||||
|
||||
// Mirrors desktop's read-duration math (chars/min ≈ 1000, plus +2s per
|
||||
// link/attachment, clamped 3–15s). Mobile v1 has no attachments and we
|
||||
// don't extract link previews mid-render, so the formula collapses to
|
||||
// a length-only base.
|
||||
const CHARS_PER_MINUTE = 1000;
|
||||
const MIN_DURATION_S = 3;
|
||||
const MAX_DURATION_S = 15;
|
||||
const TICK_MS = 100;
|
||||
const IMMERSIVE_CHAR_LIMIT = 120;
|
||||
|
||||
function computeReadDuration(text: string): number {
|
||||
const base = (text.length / CHARS_PER_MINUTE) * 60;
|
||||
return Math.min(Math.max(base, MIN_DURATION_S), MAX_DURATION_S);
|
||||
}
|
||||
|
||||
function getImmersiveStyle(length: number) {
|
||||
if (length < 30)
|
||||
return { className: "text-5xl font-semibold leading-tight" };
|
||||
if (length < 70)
|
||||
return { className: "text-3xl font-semibold leading-snug" };
|
||||
return { className: "text-2xl font-normal leading-snug" };
|
||||
}
|
||||
|
||||
export function TextParticleView({
|
||||
particle,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: TextParticleViewProps) {
|
||||
const content = particle.properties.content;
|
||||
const durationS = computeReadDuration(content);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
// Reset when the particle changes.
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
onProgress(0);
|
||||
}, [particle.id, onProgress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsedRef.current += TICK_MS / 1000;
|
||||
const ratio = Math.min(elapsedRef.current / durationS, 1);
|
||||
onProgress(ratio);
|
||||
if (ratio >= 1) {
|
||||
clearInterval(interval);
|
||||
onEnded();
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [paused, durationS, onEnded, onProgress, particle.id]);
|
||||
|
||||
// Immersive (short, plain): centered, large type — feels like a lock-screen note.
|
||||
if (content.length < IMMERSIVE_CHAR_LIMIT) {
|
||||
const style = getImmersiveStyle(content.length);
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text
|
||||
className={cn("text-white text-center max-w-xl", style.className)}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Long text: scrollable card so the reader can pace themselves; the
|
||||
// duration timer keeps ticking either way, which is intentional —
|
||||
// long messages should still auto-advance at the 15s cap.
|
||||
return (
|
||||
<View className="flex-1 items-center justify-center px-6 py-20">
|
||||
<ScrollView
|
||||
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||
contentContainerClassName="px-5 py-5"
|
||||
showsVerticalScrollIndicator
|
||||
indicatorStyle="white"
|
||||
>
|
||||
<Text className="text-white text-lg leading-relaxed">{content}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
export const EXIT_DELAY_MS = 5000;
|
||||
export const EXIT_TICK_MS = 100;
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
/**
|
||||
* Returns the remaining ms when the stream has ended, or null otherwise.
|
||||
* Pauses while `paused` is true (compose, hold-to-pause, swipe-down…).
|
||||
*/
|
||||
export function useExitCountdown(
|
||||
status: PlaybackStatus,
|
||||
paused: boolean,
|
||||
onExit: () => void,
|
||||
): number | null {
|
||||
const [remainingMs, setRemainingMs] = useState<number | null>(null);
|
||||
const handleExit = useEvent(onExit);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ended") {
|
||||
setRemainingMs(EXIT_DELAY_MS);
|
||||
} else {
|
||||
setRemainingMs(null);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs === null || remainingMs <= 0 || paused) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemainingMs((prev) => {
|
||||
if (prev === null) return null;
|
||||
const next = prev - EXIT_TICK_MS;
|
||||
return next <= 0 ? 0 : next;
|
||||
});
|
||||
}, EXIT_TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [remainingMs !== null && remainingMs > 0, paused, remainingMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (remainingMs !== null && remainingMs <= 0) {
|
||||
handleExit();
|
||||
}
|
||||
}, [remainingMs, handleExit]);
|
||||
|
||||
return remainingMs;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from "react";
|
||||
|
||||
// Polyfill for React's `useEffectEvent` (canary). The returned function has a
|
||||
// stable identity but always sees the latest closure — exactly what
|
||||
// `useEffectEvent` provides. Stable enough that we use it everywhere we'd
|
||||
// otherwise reach for a ref + .current dance inside an effect.
|
||||
//
|
||||
// Replace with `useEffectEvent` once it ships in stable React. Call sites
|
||||
// don't need to change.
|
||||
export function useEvent<TArgs extends unknown[], TReturn>(
|
||||
fn: (...args: TArgs) => TReturn,
|
||||
): (...args: TArgs) => TReturn {
|
||||
const ref = useRef(fn);
|
||||
useLayoutEffect(() => {
|
||||
ref.current = fn;
|
||||
});
|
||||
return useCallback((...args: TArgs) => ref.current(...args), []);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useLiveParticleChildren } from "@/hooks/use-particle";
|
||||
import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
|
||||
import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
|
||||
import { logError } from "@/lib/errors";
|
||||
import { useEvent } from "@/hooks/use-event";
|
||||
|
||||
// --- Playback reducer (ID-based) ---
|
||||
|
||||
type PlaybackStatus = "idle" | "playing" | "ended";
|
||||
|
||||
interface PlaybackState {
|
||||
currentParticleId: string | null;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
type PlaybackAction =
|
||||
| { type: "INIT"; particleId: string }
|
||||
| { type: "SET_PARTICLE"; particleId: string }
|
||||
| { type: "END" }
|
||||
| { type: "PARTICLE_ADDED"; particleId: string }
|
||||
| {
|
||||
type: "PARTICLE_REMOVED";
|
||||
removedParticleId: string;
|
||||
fallbackParticleId: string | null;
|
||||
};
|
||||
|
||||
const initialState: PlaybackState = {
|
||||
currentParticleId: null,
|
||||
status: "idle",
|
||||
initialized: false,
|
||||
};
|
||||
|
||||
function playbackReducer(
|
||||
state: PlaybackState,
|
||||
action: PlaybackAction,
|
||||
): PlaybackState {
|
||||
switch (action.type) {
|
||||
case "INIT":
|
||||
return {
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
initialized: true,
|
||||
};
|
||||
case "SET_PARTICLE":
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
case "END":
|
||||
return { ...state, status: "ended" };
|
||||
case "PARTICLE_ADDED":
|
||||
if (state.status === "ended") {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.particleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return state;
|
||||
case "PARTICLE_REMOVED":
|
||||
if (action.removedParticleId !== state.currentParticleId) return state;
|
||||
if (action.fallbackParticleId) {
|
||||
return {
|
||||
...state,
|
||||
currentParticleId: action.fallbackParticleId,
|
||||
status: "playing",
|
||||
};
|
||||
}
|
||||
return { ...state, currentParticleId: null, status: "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
const INIT_FALLBACK_TIMEOUT_MS = 5000;
|
||||
|
||||
interface UseStreamPlaybackResult {
|
||||
children: Particle[];
|
||||
currentParticle: Particle | null;
|
||||
currentIndex: number;
|
||||
status: PlaybackStatus;
|
||||
initialized: boolean;
|
||||
next: () => void;
|
||||
prev: () => void;
|
||||
goTo: (index: number) => void;
|
||||
goToParticle: (particleId: string) => void;
|
||||
}
|
||||
|
||||
export function useStreamPlayback(
|
||||
streamParticle: Particle & { type: "stream" },
|
||||
path: ParticlePath,
|
||||
): UseStreamPlaybackResult {
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const [state, dispatch] = useReducer(playbackReducer, initialState);
|
||||
// Track which stream we initialized for, so navigating to a sibling resets cleanly.
|
||||
const initializedForRef = useRef<string | null>(null);
|
||||
|
||||
const onParticleAdded = useCallback((particle: Particle) => {
|
||||
dispatch({ type: "PARTICLE_ADDED", particleId: particle.id });
|
||||
}, []);
|
||||
|
||||
const onParticleRemoved = useEvent(
|
||||
(removed: Particle, updatedChildren: Particle[]) => {
|
||||
const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1);
|
||||
const fallback = updatedChildren[Math.max(0, fallbackIndex)];
|
||||
dispatch({
|
||||
type: "PARTICLE_REMOVED",
|
||||
removedParticleId: removed.id,
|
||||
fallbackParticleId: fallback?.id ?? null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const { children } = useLiveParticleChildren(path, {
|
||||
orderByField: "created_at",
|
||||
orderDirection: "asc",
|
||||
onAdded: onParticleAdded,
|
||||
onRemoved: onParticleRemoved,
|
||||
});
|
||||
|
||||
// Derive current index and particle from ID
|
||||
const currentIndex = useMemo(() => {
|
||||
if (!state.currentParticleId) return -1;
|
||||
return children.findIndex((c) => c.id === state.currentParticleId);
|
||||
}, [children, state.currentParticleId]);
|
||||
|
||||
const currentParticle = currentIndex !== -1 ? children[currentIndex] : null;
|
||||
|
||||
const initFallback = useEvent(() => {
|
||||
if (state.initialized || children.length === 0) return;
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
});
|
||||
|
||||
// --- Init logic: runs on every children change until initialized ---
|
||||
useEffect(() => {
|
||||
if (
|
||||
initializedForRef.current !== null &&
|
||||
initializedForRef.current !== streamParticle.id
|
||||
) {
|
||||
initializedForRef.current = null;
|
||||
}
|
||||
|
||||
if (state.initialized && initializedForRef.current === streamParticle.id)
|
||||
return;
|
||||
|
||||
if (children.length === 0) return;
|
||||
|
||||
const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
|
||||
|
||||
if (!playbackPosition) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: children[0].id });
|
||||
return;
|
||||
}
|
||||
|
||||
const found = children.find(
|
||||
(c) => c.created_at.getTime() > playbackPosition.getTime(),
|
||||
);
|
||||
|
||||
if (found) {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({ type: "INIT", particleId: found.id });
|
||||
return;
|
||||
} else {
|
||||
initializedForRef.current = streamParticle.id;
|
||||
dispatch({
|
||||
type: "INIT",
|
||||
particleId: children[children.length - 1].id,
|
||||
});
|
||||
}
|
||||
|
||||
const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [
|
||||
children,
|
||||
streamParticle.id,
|
||||
streamParticle.playback_markers,
|
||||
userId,
|
||||
state.initialized,
|
||||
initFallback,
|
||||
]);
|
||||
|
||||
// --- Persist playback marker (only advance forward, never backwards) ---
|
||||
const lastPersistedMarkerRef = useRef<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || !state.initialized || !currentParticle) return;
|
||||
|
||||
const currentTime = currentParticle.created_at;
|
||||
const existingMarker =
|
||||
lastPersistedMarkerRef.current ??
|
||||
streamParticle.playback_markers?.[userId];
|
||||
|
||||
if (existingMarker && currentTime.getTime() <= existingMarker.getTime())
|
||||
return;
|
||||
|
||||
lastPersistedMarkerRef.current = currentTime;
|
||||
const streamDocPath = toFirestoreDocPath(path);
|
||||
updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch(
|
||||
(err) => logError(err, { scope: "playback.marker", path }),
|
||||
);
|
||||
// streamParticle.playback_markers is read at effect time; not in deps to
|
||||
// avoid double-writes when the snapshot we just persisted echoes back.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentParticle?.id, state.initialized, userId, path]);
|
||||
|
||||
// --- Navigation callbacks ---
|
||||
const next = useCallback(() => {
|
||||
if (currentIndex === -1) return;
|
||||
if (currentIndex < children.length - 1) {
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex + 1].id,
|
||||
});
|
||||
} else {
|
||||
dispatch({ type: "END" });
|
||||
}
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (currentIndex <= 0) return;
|
||||
dispatch({
|
||||
type: "SET_PARTICLE",
|
||||
particleId: children[currentIndex - 1].id,
|
||||
});
|
||||
}, [children, currentIndex]);
|
||||
|
||||
const goTo = useCallback(
|
||||
(index: number) => {
|
||||
if (index >= 0 && index < children.length) {
|
||||
dispatch({ type: "SET_PARTICLE", particleId: children[index].id });
|
||||
}
|
||||
},
|
||||
[children],
|
||||
);
|
||||
|
||||
// If the particle isn't in `children` yet (e.g. just-created), the live
|
||||
// query will resolve it shortly and the derived index/particle will catch up.
|
||||
const goToParticle = useCallback((particleId: string) => {
|
||||
dispatch({ type: "SET_PARTICLE", particleId });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
children,
|
||||
currentParticle,
|
||||
currentIndex,
|
||||
status: state.status,
|
||||
initialized: state.initialized,
|
||||
next,
|
||||
prev,
|
||||
goTo,
|
||||
goToParticle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useId } from "react";
|
||||
import { usePlaybackPauseStore } from "@/stores/playback-pause-store";
|
||||
|
||||
/**
|
||||
* Suspend stream playback while `active` is true. The hook owns its own
|
||||
* registration id; multiple instances compose. `label` is for devtools only.
|
||||
*/
|
||||
export function useSuspendPlayback(active: boolean, label: string) {
|
||||
const id = useId();
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const { add, remove } = usePlaybackPauseStore.getState();
|
||||
add(id, label);
|
||||
return () => remove(id);
|
||||
}, [active, id, label]);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Single source of truth for "is stream playback paused." Each component that
|
||||
* wants to pause playback registers a unique id via `useSuspendPlayback`; the
|
||||
* label is for devtools only. Playback is paused while any id is registered.
|
||||
*/
|
||||
interface PlaybackPauseState {
|
||||
activeIds: Record<string, string>;
|
||||
add: (id: string, label: string) => void;
|
||||
remove: (id: string) => void;
|
||||
}
|
||||
|
||||
export const usePlaybackPauseStore = create<PlaybackPauseState>((set) => ({
|
||||
activeIds: {},
|
||||
add: (id, label) =>
|
||||
set((s) => ({ activeIds: { ...s.activeIds, [id]: label } })),
|
||||
remove: (id) =>
|
||||
set((s) => {
|
||||
if (!(id in s.activeIds)) return s;
|
||||
const { [id]: _, ...rest } = s.activeIds;
|
||||
return { activeIds: rest };
|
||||
}),
|
||||
}));
|
||||
|
||||
export const selectIsPaused = (s: PlaybackPauseState) =>
|
||||
Object.keys(s.activeIds).length > 0;
|
||||
@@ -3013,6 +3013,11 @@ expo-font@~14.0.11:
|
||||
dependencies:
|
||||
fontfaceobserver "^2.1.0"
|
||||
|
||||
expo-haptics@~15.0.7:
|
||||
version "15.0.8"
|
||||
resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-15.0.8.tgz#f93f895ac5d76fe0c5ac26b3644e1dbb097833f3"
|
||||
integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==
|
||||
|
||||
expo-keep-awake@~15.0.8:
|
||||
version "15.0.8"
|
||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz#911c5effeba9baff2ccde79ef0ff5bf856215f8d"
|
||||
@@ -3053,6 +3058,11 @@ expo-status-bar@~3.0.9:
|
||||
dependencies:
|
||||
react-native-is-edge-to-edge "^1.2.1"
|
||||
|
||||
expo-video@~3.0.10:
|
||||
version "3.0.16"
|
||||
resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-3.0.16.tgz#8160bd33fe2e898519d3c18a404567a30d81d4f2"
|
||||
integrity sha512-H1HlxcHGomZItqisGfW3YL/G9BHtNBfVSimDJcLuyxyU87wFnV8loO9tCjuhufkfh/aTa2sW5BYAjLjg9DvnBQ==
|
||||
|
||||
expo@~54.0.0:
|
||||
version "54.0.34"
|
||||
resolved "https://registry.yarnpkg.com/expo/-/expo-54.0.34.tgz#fb1c90ff9d65d58978198622808c66a2d3b66fcc"
|
||||
@@ -3903,6 +3913,11 @@ lru-cache@^5.1.1:
|
||||
dependencies:
|
||||
yallist "^3.0.2"
|
||||
|
||||
lucide-react-native@^0.575.0:
|
||||
version "0.575.0"
|
||||
resolved "https://registry.yarnpkg.com/lucide-react-native/-/lucide-react-native-0.575.0.tgz#8ce8e555d7c0ebb88cd529966256f803125b74ce"
|
||||
integrity sha512-kdGcjF4Rm1YKuNs3IaW5lDAqVKn9RBj1Fmjt3JBr08PMIXpVV7iL0ICNF/awiPZQicHlx/v9xgyZZS4TAFxDNg==
|
||||
|
||||
makeerror@1.0.12:
|
||||
version "1.0.12"
|
||||
resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a"
|
||||
|
||||
Reference in New Issue
Block a user