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 ( + + + ); + } + + return ( + + + ); +} 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 ( + + + ); + } + 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"