diff --git a/js/mobile/package.json b/js/mobile/package.json
index 0513ca3..2a2aa35 100644
--- a/js/mobile/package.json
+++ b/js/mobile/package.json
@@ -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",
diff --git a/js/mobile/src/features/stream-view/DeletedParticleView.tsx b/js/mobile/src/features/stream-view/DeletedParticleView.tsx
new file mode 100644
index 0000000..f90c750
--- /dev/null
+++ b/js/mobile/src/features/stream-view/DeletedParticleView.tsx
@@ -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 (
+
+
+
+ This particle was deleted
+
+ {deleter ? (
+
+ by {deleter.displayName}
+
+ ) : null}
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx
new file mode 100644
index 0000000..3a25ffc
--- /dev/null
+++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx
@@ -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 = {
+ 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 (
+
+
+
+
+
+
+ {meta.label}
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+
+
+ From {creator.displayName}
+
+ View on desktop
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/MediaParticleView.tsx b/js/mobile/src/features/stream-view/MediaParticleView.tsx
new file mode 100644
index 0000000..a8ee79c
--- /dev/null
+++ b/js/mobile/src/features/stream-view/MediaParticleView.tsx
@@ -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;
+
+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 (
+
+
+ {isAudio ? (
+
+ ) : (
+
+ )}
+
+
+ {isAudio ? "Voice message" : "Video message"}
+
+
+ {isMp4
+ ? "Playback wires up in step 5."
+ : "Recorded on desktop — view there until codecs converge."}
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx
new file mode 100644
index 0000000..70caa4f
--- /dev/null
+++ b/js/mobile/src/features/stream-view/PlaybackPageIndicator.tsx
@@ -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 (
+
+ {Array.from({ length: total }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx
new file mode 100644
index 0000000..6ad10c0
--- /dev/null
+++ b/js/mobile/src/features/stream-view/StreamMetadataHeader.tsx
@@ -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 (
+
+
+
+ {display.initials}
+
+
+
+
+ {display.displayName}
+
+
+
+ {editedAt ? (
+
+ · edited{" "}
+
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx
new file mode 100644
index 0000000..4866531
--- /dev/null
+++ b/js/mobile/src/features/stream-view/StreamView.tsx
@@ -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 (
+
+ );
+ }
+ switch (particle.type) {
+ case "text":
+ return (
+
+ );
+ case "media":
+ return (
+
+ );
+ default:
+ return (
+
+ );
+ }
+ };
+
+ // --- Content guards ---
+ if (children.length === 0) {
+ return (
+
+
+
+ No particles in this stream yet.
+
+
+ Close
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {/* Particle canvas — fills the whole screen, gesture-aware. */}
+
+ {currentParticle ? renderParticle(currentParticle) : null}
+
+
+ {/* 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. */}
+
+
+
+
+
+
+
+
+
+
+ {/* Bottom chrome: paused pill + exit countdown. Sit above the
+ home-indicator safe-area so they aren't visually clipped. */}
+
+ {paused ? (
+
+
+ Paused
+
+
+ ) : null}
+ {exitRemainingMs !== null ? (
+
+
+ Closing in {Math.ceil(exitRemainingMs / 1000)}s
+
+
+ ) : null}
+
+
+
+
+ {/* Safe-area sentinel for top notch — kept outside GestureDetector so
+ iOS's status-bar tap doesn't fight our gestures. */}
+
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/StreamViewScreen.tsx b/js/mobile/src/features/stream-view/StreamViewScreen.tsx
index 7fa0cf1..e400596 100644
--- a/js/mobile/src/features/stream-view/StreamViewScreen.tsx
+++ b/js/mobile/src/features/stream-view/StreamViewScreen.tsx
@@ -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 (
+
+
+
+
+ );
+ }
+
+ if (error || !particle || particle.type !== "stream") {
+ return (
+
+
+
+ {error
+ ? "Couldn't load this stream."
+ : "This stream is no longer available."}
+
+ navigation.goBack()} className="mt-6 px-4 py-2">
+ Close
+
+
+ );
+ }
+
return (
-
-
- Stream view — coming in step 4.
-
- navigation.goBack()} className="mt-6 px-4 py-2">
- Close
-
-
+ navigation.goBack()}
+ />
);
}
diff --git a/js/mobile/src/features/stream-view/TextParticleView.tsx b/js/mobile/src/features/stream-view/TextParticleView.tsx
new file mode 100644
index 0000000..a5592fd
--- /dev/null
+++ b/js/mobile/src/features/stream-view/TextParticleView.tsx
@@ -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;
+
+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 (
+
+
+ {content}
+
+
+ );
+ }
+
+ // 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 (
+
+
+ {content}
+
+
+ );
+}
diff --git a/js/mobile/src/features/stream-view/use-exit-countdown.ts b/js/mobile/src/features/stream-view/use-exit-countdown.ts
new file mode 100644
index 0000000..2d60735
--- /dev/null
+++ b/js/mobile/src/features/stream-view/use-exit-countdown.ts
@@ -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(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;
+}
diff --git a/js/mobile/src/hooks/use-event.ts b/js/mobile/src/hooks/use-event.ts
new file mode 100644
index 0000000..a3ae51f
--- /dev/null
+++ b/js/mobile/src/hooks/use-event.ts
@@ -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(
+ fn: (...args: TArgs) => TReturn,
+): (...args: TArgs) => TReturn {
+ const ref = useRef(fn);
+ useLayoutEffect(() => {
+ ref.current = fn;
+ });
+ return useCallback((...args: TArgs) => ref.current(...args), []);
+}
diff --git a/js/mobile/src/hooks/use-stream-playback.ts b/js/mobile/src/hooks/use-stream-playback.ts
new file mode 100644
index 0000000..5bfe03b
--- /dev/null
+++ b/js/mobile/src/hooks/use-stream-playback.ts
@@ -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(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(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,
+ };
+}
diff --git a/js/mobile/src/hooks/use-suspend-playback.ts b/js/mobile/src/hooks/use-suspend-playback.ts
new file mode 100644
index 0000000..8ca363f
--- /dev/null
+++ b/js/mobile/src/hooks/use-suspend-playback.ts
@@ -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]);
+}
diff --git a/js/mobile/src/stores/playback-pause-store.ts b/js/mobile/src/stores/playback-pause-store.ts
new file mode 100644
index 0000000..cd5ae56
--- /dev/null
+++ b/js/mobile/src/stores/playback-pause-store.ts
@@ -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;
+ add: (id: string, label: string) => void;
+ remove: (id: string) => void;
+}
+
+export const usePlaybackPauseStore = create((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;
diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock
index 28d46a0..d4f0e4b 100644
--- a/js/mobile/yarn.lock
+++ b/js/mobile/yarn.lock
@@ -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"