diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx
index 836ca16..5058530 100644
--- a/js/src/features/particles/stream-view.tsx
+++ b/js/src/features/particles/stream-view.tsx
@@ -1,36 +1,432 @@
-import { Particle } from "@/api/types";
-import { useParticleChildren } from "@/hooks/use-particle-children";
+import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+import { useAuthStore } from "@/stores/auth-store";
+import type { Particle } from "@/api/types";
+import { useLiveParticleChildren } from "@/hooks/use-particle";
+import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
+import { ComposeOverlay } from "@/features/compose/compose-overlay";
+import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator";
+import { MediaParticleView } from "@/features/playback/media-particle-view";
+import { TextParticleView } from "@/features/playback/text-particle-view";
+import { FallbackParticleView } from "@/features/playback/fallback-particle-view";
+import { Avatar, AvatarFallback, AvatarGroup, AvatarGroupCount } from "@/components/ui/avatar";
+import ControlsIndicator from "@/features/compose/controls-indicator";
+import { updateStreamPlaybackMarker } from "@/lib/firestore-particles";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { useNetwork } from "@/hooks/use-networks";
-interface StreamViewProps {
- streamParticle: Particle;
- networkId: string;
- particleSegments: string[];
+// --- Playback reducer ---
+
+type PlaybackStatus = "idle" | "playing" | "ended";
+
+interface PlaybackState {
+ currentIndex: number;
+ status: PlaybackStatus;
+ paused: boolean;
}
-export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
- const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
+type PlaybackAction =
+ | { type: "INIT"; particleCount: number, initialIndex?: number }
+ | { type: "NEXT"; particleCount: number }
+ | { type: "PREV" }
+ | { type: "GO_TO"; index: number; particleCount: number }
+ | { type: "PAUSE" }
+ | { type: "RESUME" }
+ | { type: "SYNC_PARTICLES"; particleCount: number };
+
+function playbackReducer(
+ state: PlaybackState,
+ action: PlaybackAction,
+): PlaybackState {
+ switch (action.type) {
+ case "INIT":
+ return {
+ currentIndex: action.initialIndex ?? 0,
+ status: action.particleCount > 0 ? "playing" : "idle",
+ paused: false,
+ };
+ case "NEXT":
+ if (state.currentIndex < action.particleCount - 1) {
+ return { ...state, currentIndex: state.currentIndex + 1, paused: false };
+ }
+ return { ...state, status: "ended", paused: false };
+ case "PREV":
+ if (state.currentIndex > 0) {
+ return {
+ ...state,
+ currentIndex: state.currentIndex - 1,
+ status: "playing",
+ paused: false,
+ };
+ }
+ return state;
+ case "GO_TO":
+ if (action.index >= 0 && action.index < action.particleCount) {
+ return {
+ ...state,
+ currentIndex: action.index,
+ status: "playing",
+ paused: false,
+ };
+ }
+ return state;
+ case "PAUSE":
+ return { ...state, paused: true };
+ case "RESUME":
+ return { ...state, paused: false };
+ case "SYNC_PARTICLES":
+ // Clamp index if particles were removed; don't reset position
+ if (action.particleCount === 0) {
+ return { currentIndex: 0, status: "idle", paused: state.paused };
+ }
+ if (state.status === "ended" && state.currentIndex < action.particleCount - 1) {
+ // New particle appended — resume and advance to it
+ return { ...state, currentIndex: state.currentIndex + 1, status: "playing", paused: false };
+ }
+ if (state.currentIndex >= action.particleCount) {
+ return { ...state, currentIndex: action.particleCount - 1 };
+ }
+ return state;
+ }
+}
+
+const initialState: PlaybackState = {
+ currentIndex: 0,
+ status: "idle",
+ paused: false,
+};
+
+// --- Exit countdown hook ---
+
+const EXIT_DELAY_MS = 5000;
+const EXIT_TICK_MS = 100;
+
+function useExitCountdown(
+ status: PlaybackStatus,
+ composeActive: boolean,
+ onExit: () => void,
+) {
+ const [remainingMs, setRemainingMs] = useState(null);
+
+ const handleExit = useEffectEvent(() => {
+ onExit();
+ });
+
+ // Start/cancel countdown based on playback status
+ useEffect(() => {
+ if (status === "ended") {
+ setRemainingMs(EXIT_DELAY_MS);
+ } else {
+ setRemainingMs(null);
+ }
+ }, [status]);
+
+ // Tick the countdown down (pauses when compose is active)
+ useEffect(() => {
+ if (remainingMs === null || remainingMs <= 0 || composeActive) 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, composeActive]);
+
+ // Navigate once countdown hits zero
+ useEffect(() => {
+ if (remainingMs !== null && remainingMs <= 0) {
+ handleExit();
+ }
+ }, [remainingMs]);
+
+ return remainingMs;
+}
+
+// --- StreamView ---
+
+interface StreamViewProps {
+ streamParticle: Particle & { type: "stream" };
+ path: ParticlePath;
+}
+
+export function StreamView({ path, streamParticle }: StreamViewProps) {
+ const { networkId } = parseParticlePath(path);
+ const navigate = useNavigate();
+ const { children } = useLiveParticleChildren(path, "created_at", "asc");
+
+ const [state, dispatch] = useReducer(playbackReducer, initialState);
+ const [composeActive, setComposeActive] = useState(false);
+ const [progress, setProgress] = useState(0);
+ const hasInitializedRef = useRef(null);
+
+ const handleExitNavigate = useCallback(() => {
+ navigate(`/${networkId}`);
+ }, [navigate, networkId]);
+
+ const exitRemainingMs = useExitCountdown(
+ state.status,
+ composeActive,
+ handleExitNavigate,
+ );
+
+ const userId = useAuthStore((s) => s.user?.id);
+
+ // Reset progress when particle changes
+ useEffect(() => {
+ setProgress(0);
+ }, [state.currentIndex]);
+
+ // Init playback once per stream entry, only after children have loaded
+ useEffect(() => {
+ if (children.length === 0) return;
+ if (hasInitializedRef.current === streamParticle.id) return;
+ hasInitializedRef.current = streamParticle.id;
+
+ const playbackPosition = streamParticle.playback_markers?.[userId ?? ""];
+ let initialIndex = 0;
+
+ if (playbackPosition) {
+ const foundIndex = children.findIndex(
+ (c) => c.created_at.getTime() === playbackPosition.getTime(),
+ );
+ if (foundIndex !== -1) {
+ initialIndex = foundIndex;
+ }
+ }
+
+ dispatch({ type: "INIT", particleCount: children.length, initialIndex });
+ }, [streamParticle.id, userId, children]);
+
+ // Sync on subsequent changes (new particle appended, removed, etc.)
+ useEffect(() => {
+ if (hasInitializedRef.current !== streamParticle.id) return;
+ dispatch({ type: "SYNC_PARTICLES", particleCount: children.length });
+ }, [children.length, streamParticle.id]);
+
+ // Pause/resume playback when compose overlay opens/closes
+ useEffect(() => {
+ if (composeActive) dispatch({ type: "PAUSE" });
+ else dispatch({ type: "RESUME" });
+ }, [composeActive]);
+
+ const next = useCallback(() => {
+ dispatch({ type: "NEXT", particleCount: children.length });
+ }, [children.length]);
+
+ const prev = useCallback(() => {
+ dispatch({ type: "PREV" });
+ }, []);
+
+ const goTo = useCallback(
+ (index: number) => {
+ dispatch({ type: "GO_TO", index, particleCount: children.length });
+ },
+ [children.length],
+ );
+
+ // Click-to-navigate: left 30% = prev, right 70% = next
+ const handlePlaybackClick = useCallback(
+ (e: React.MouseEvent) => {
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = (e.clientX - rect.left) / rect.width;
+ if (x < 0.3) prev();
+ else if (x > 0.7) next();
+ },
+ [prev, next],
+ );
+
+ // Playback keyboard: arrows, escape
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (composeActive) return;
+
+ const target = e.target as HTMLElement;
+ if (
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ switch (e.key) {
+ case "ArrowRight":
+ case "ArrowDown":
+ e.preventDefault();
+ next();
+ break;
+ case "ArrowLeft":
+ case "ArrowUp":
+ e.preventDefault();
+ prev();
+ break;
+ case "Escape":
+ e.preventDefault();
+ navigate(`/${networkId}`);
+ break;
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ },
+ [composeActive, next, prev, navigate, networkId],
+ );
+
+ const currentParticle = children[state.currentIndex] ?? null;
+
+ useEffect(() => {
+ if (!userId || !currentParticle) return;
+
+ const streamDocPath = toFirestoreDocPath(path);
+ updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at);
+ }, [currentParticle?.id, path])
+
+ // Author info from current particle
+ const authorEmail = currentParticle?.created_by_email ?? "";
+ const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? "";
+
+ if (children.length === 0) {
+ return (
+
+
+ No particles in this stream yet
+
+
+
+
+ );
+ }
+
+ // Render particle content inline (replaces ParticleRenderer)
+ function renderParticle(particle: Particle) {
+ switch (particle.type) {
+ case "media":
+ return (
+
+ );
+ case "text":
+ return (
+
+ );
+ default:
+ return ;
+ }
+ }
return (
-
-
- Stream view — {networkId}/{particleSegments.join("/")}
-
+
+ {/* Progress indicator */}
+
- {isLoading &&
Loading stream data...
}
- {error &&
Failed to load stream data
}
-
- {!isLoading && !error && (
-
-
Stream Children:
-
- {children.map((child) => (
- -
- {child.id} ({child.type})
-
- ))}
-
+ {/* Author overlay */}
+ {currentParticle && (
+
+
+
+ {authorInitials}
+
+
+
+ {authorEmail.split("@")[0]}
+
)}
+
+ {/* Main playback area */}
+
+ {currentParticle && (
+
+ {renderParticle(currentParticle)}
+
+ )}
+
+
+
+
+ {/* Bottom overlay: stream info + reply */}
+
+
+
+ Seen by
+
+ {/* Exit countdown */}
+ {exitRemainingMs !== null && (
+
+ Closing in {Math.ceil(exitRemainingMs / 1000)}s
+
+ )}
+
+
+
);
}
+
+// Shows a list of avatars of users who have seen the current particle, based on playback markers in the stream particle.
+const SeenIndicator = ({ stream, currentParticle, networkId }: { stream: Particle & { type: "stream" }, currentParticle: Particle, networkId: string }) => {
+ const network = useNetwork(networkId);
+ const playbackMarkers = stream.playback_markers ?? {};
+
+ const seenUserIds = Object.entries(playbackMarkers)
+ .filter(([_, timestamp]) => timestamp.getTime() >= currentParticle.created_at.getTime())
+ .map(([userId, _]) => userId);
+
+ const seenUserEmails = seenUserIds
+ .map((userId) => network?.humans?.find((h) => h.id === userId)?.email)
+ .filter((email): email is string => !!email);
+
+ if (seenUserIds.length === 0) return null;
+
+ return (
+
+ {seenUserEmails.map((email) => (
+
+
+
+
+ {email.split("@")[0].slice(0, 2)}
+
+
+
+
+ Seen by {email.split("@")[0]}
+
+
+ ))}
+
+ );
+}
diff --git a/js/src/features/playback/ack-button.tsx b/js/src/features/playback/ack-button.tsx
deleted file mode 100644
index ddc79ce..0000000
--- a/js/src/features/playback/ack-button.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import { Heart } from "lucide-react";
-import { useCallback } from "react";
-import type { AckInfo } from "@/api/types";
-import { apiClient } from "@/api/client";
-import { useAuthStore } from "@/stores/auth-store";
-import { useAppStore } from "@/stores/app-store";
-import { cn } from "@/lib/utils";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-
-interface AckButtonProps {
- particleId: string;
- acks: AckInfo[];
-}
-
-function getInitials(email: string): string {
- const prefix = email.split("@")[0];
- const parts = prefix.split(/[._-]/);
- if (parts.length >= 2) {
- return (parts[0][0] + parts[1][0]).toUpperCase();
- }
- return prefix.slice(0, 2).toUpperCase();
-}
-
-export function AckButton({ particleId, acks }: AckButtonProps) {
- const currentEmail = useAuthStore((s) => s.user?.email);
- const ackParticle = useAppStore((s) => s.ackParticle);
- const hasAcked = acks.some((a) => a.email === currentEmail);
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- e.stopPropagation();
- if (hasAcked || !currentEmail) return;
- ackParticle(particleId, currentEmail);
- apiClient.ackParticle(particleId).catch(() => {});
- },
- [hasAcked, currentEmail, particleId, ackParticle],
- );
-
- const displayedAcks = acks.slice(0, 3);
-
- return (
-
-
-
- {displayedAcks.length > 0 && (
-
- {displayedAcks.map((ack) => (
-
-
-
- {getInitials(ack.email)}
-
-
-
- {ack.email}
-
-
- ))}
-
- )}
-
- );
-}
diff --git a/js/src/features/playback/fallback-particle-view.tsx b/js/src/features/playback/fallback-particle-view.tsx
index 234af19..0ef8654 100644
--- a/js/src/features/playback/fallback-particle-view.tsx
+++ b/js/src/features/playback/fallback-particle-view.tsx
@@ -1,5 +1,4 @@
-import type { StreamParticle } from "@/api/types";
-import { getParticleData } from "@/api/types";
+import type { Particle } from "@/api/types";
import {
Card,
CardContent,
@@ -16,7 +15,7 @@ const TYPE_META: Record
= {
};
interface FallbackParticleViewProps {
- particle: StreamParticle;
+ particle: Particle;
}
export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
@@ -28,13 +27,13 @@ export function FallbackParticleView({ particle }: FallbackParticleViewProps) {
const title = (() => {
switch (particle.type) {
case "quest":
- return getParticleData(particle, "quest").title;
+ return particle.properties.title;
case "paper":
- return getParticleData(particle, "paper").title;
+ return particle.properties.title;
case "file":
- return getParticleData(particle, "file").filename;
+ return particle.properties.filename;
case "folder":
- return getParticleData(particle, "folder").name;
+ return particle.properties.name;
default:
return null;
}
diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx
index 26efe40..b2cfd1d 100644
--- a/js/src/features/playback/media-particle-view.tsx
+++ b/js/src/features/playback/media-particle-view.tsx
@@ -1,86 +1,37 @@
import { useEffect, useRef, useState } from "react";
-import type { MediaParticleData, StreamParticle } from "@/api/types";
-import { apiClient } from "@/api/client";
-import { usePlaybackStore } from "@/stores/playback-store";
+import type { Particle } from "@/api/types";
+import { useDownloadUrl } from "@/hooks/use-download-url";
import { Skeleton } from "@/components/ui/skeleton";
import { AudioLevelBars } from "@/components/audio/audio-level-bars";
import { useAudioSource } from "@/components/audio/use-audio-source";
+type MediaParticle = Extract;
+
interface MediaParticleViewProps {
- particle: StreamParticle;
-}
-
-function formatTime(ms: number): string {
- const totalSeconds = Math.floor(ms / 1000);
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = totalSeconds % 60;
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
-}
-
-function DurationPill({
- currentTimeMs,
- totalDurationMs,
-}: {
- currentTimeMs: number;
- totalDurationMs: number;
-}) {
- return (
-
-
- {formatTime(currentTimeMs)} / {formatTime(totalDurationMs)}
-
-
- );
+ particle: MediaParticle;
+ paused: boolean;
+ onEnded: () => void;
+ onProgress?: (ratio: number) => void;
}
export function MediaParticleView({
particle,
+ paused,
+ onEnded,
+ onProgress,
}: MediaParticleViewProps) {
- const cachedUrl = usePlaybackStore(
- (s) => s.downloadUrlCache[particle.id],
- );
- const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
- const next = usePlaybackStore((s) => s.next);
- const paused = usePlaybackStore((s) => s.paused);
- const [error, setError] = useState(null);
+ const { data: url, error } = useDownloadUrl(particle.properties.object_id);
const videoRef = useRef(null);
const audioRef = useRef(null);
- const [currentTimeMs, setCurrentTimeMs] = useState(0);
+ const isAudio = particle.properties.mime_type?.startsWith("audio/");
- const data = particle.data as MediaParticleData;
- const isAudio = data.mime_type?.startsWith("audio/");
+ // WORKAROUND: useAudioSource needs an audio element, but audioRef is only set after mount
+ const [audioEl, setAudioEl] = useState(null);
+ const audioSource = useAudioSource(audioEl);
useEffect(() => {
- if (cachedUrl) {
- return;
- }
-
- let cancelled = false;
- apiClient
- .getParticleDownloadUrl(particle.id)
- .then((downloadUrl) => {
- if (cancelled) return;
- cacheDownloadUrl(particle.id, downloadUrl);
- })
- .catch(() => {
- if (!cancelled) setError("Failed to load media");
- });
-
- return () => {
- cancelled = true;
- };
- }, [particle.id, cacheDownloadUrl]);
-
- // Handle pause/resume
- useEffect(() => {
- var el: HTMLVideoElement | HTMLAudioElement | null = null;
- if (isAudio) {
- el = audioRef.current;
- } else {
- el = videoRef.current;
- }
-
+ const el = isAudio ? audioRef.current : videoRef.current;
if (!el) return;
if (paused) {
@@ -90,17 +41,17 @@ export function MediaParticleView({
console.warn("Playback failed", { particleId: particle.id });
});
}
- }, [paused]);
+ }, [paused, isAudio, particle.id]);
if (error) {
return (
- {error}
+ Failed to load media
);
}
- if (!cachedUrl) {
+ if (!url) {
return ;
}
@@ -108,20 +59,25 @@ export function MediaParticleView({
return (
);
}
@@ -130,19 +86,16 @@ export function MediaParticleView({
);
}
diff --git a/js/src/features/playback/particle-renderer.tsx b/js/src/features/playback/particle-renderer.tsx
deleted file mode 100644
index 88d8107..0000000
--- a/js/src/features/playback/particle-renderer.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useEffect, useRef } from "react";
-import type { StreamParticle } from "@/api/types";
-import { apiClient } from "@/api/client";
-import { useAppStore } from "@/stores/app-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { MediaParticleView } from "./media-particle-view";
-import { TextParticleView } from "./text-particle-view";
-import { FallbackParticleView } from "./fallback-particle-view";
-import { AckButton } from "./ack-button";
-
-interface ParticleRendererProps {
- particle: StreamParticle;
-}
-
-export function ParticleRenderer({
- particle,
-}: ParticleRendererProps) {
- const next = usePlaybackStore((s) => s.next);
- const prev = usePlaybackStore((s) => s.prev);
-
- const markParticlesSeen = useAppStore((s) => s.markParticlesSeen);
- const markedRef = useRef(null);
-
- useEffect(() => {
- if (!particle.seen && markedRef.current !== particle.id) {
- markedRef.current = particle.id;
- markParticlesSeen([particle.id]);
- apiClient.markSeen(particle.id);
- }
- }, [particle.id, particle.seen, markParticlesSeen]);
-
- const handleClick = (e: React.MouseEvent) => {
- const rect = e.currentTarget.getBoundingClientRect();
- const x = (e.clientX - rect.left) / rect.width;
- if (x < 0.3) prev();
- else if (x > 0.7) next();
- };
-
- return (
-
- );
-}
-
-function ParticleContent({ particle }: { particle: StreamParticle }) {
- switch (particle.type) {
- case "media":
- {/* NOTE: it's more robust to re-mount the MediaParticleView when the particle changes, to ensure playback state is well-behaved */ }
- return ;
- case "text":
- return ;
- default:
- return ;
- }
-}
diff --git a/js/src/features/playback/playback-page-indicator.tsx b/js/src/features/playback/playback-page-indicator.tsx
index 86cca38..3246fe2 100644
--- a/js/src/features/playback/playback-page-indicator.tsx
+++ b/js/src/features/playback/playback-page-indicator.tsx
@@ -3,12 +3,14 @@ import { cn } from "@/lib/utils";
interface PlaybackPageIndicatorProps {
total: number;
current: number;
+ progress: number;
onGoTo: (index: number) => void;
}
export function PlaybackPageIndicator({
total,
current,
+ progress,
onGoTo,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
@@ -24,14 +26,29 @@ export function PlaybackPageIndicator({
}}
className="group relative h-3 flex-1"
>
- {/* Track */}
+ {/* Dim track */}
+ {/* Fill */}
+
))}
diff --git a/js/src/features/playback/text-particle-view.tsx b/js/src/features/playback/text-particle-view.tsx
index 51245ea..da8ff2e 100644
--- a/js/src/features/playback/text-particle-view.tsx
+++ b/js/src/features/playback/text-particle-view.tsx
@@ -1,8 +1,25 @@
-import type { StreamParticle, TextParticleData } from "@/api/types";
+import { useEffect, useRef } from "react";
+import type { Particle } from "@/api/types";
import { cn } from "@/lib/utils";
+type TextParticle = Extract
;
+
interface TextParticleViewProps {
- particle: StreamParticle;
+ particle: TextParticle;
+ paused: boolean;
+ onEnded: () => void;
+ onProgress?: (ratio: number) => void;
+}
+
+const WORDS_PER_MINUTE = 200;
+const MIN_DURATION_S = 3;
+const MAX_DURATION_S = 15;
+const TICK_MS = 100;
+
+function computeReadDuration(text: string): number {
+ const wordCount = text.trim().split(/\s+/).length;
+ const seconds = (wordCount / WORDS_PER_MINUTE) * 60;
+ return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
}
function getTextStyle(length: number) {
@@ -12,9 +29,37 @@ function getTextStyle(length: number) {
return { size: "text-lg", weight: "font-normal" };
}
-export function TextParticleView({ particle }: TextParticleViewProps) {
- const data = particle.data as TextParticleData;
- const style = getTextStyle(data.content.length);
+export function TextParticleView({
+ particle,
+ paused,
+ onEnded,
+ onProgress,
+}: TextParticleViewProps) {
+ const style = getTextStyle(particle.properties.content.length);
+ const durationS = computeReadDuration(particle.properties.content);
+ const elapsedRef = useRef(0);
+
+ // Reset elapsed when particle changes
+ useEffect(() => {
+ elapsedRef.current = 0;
+ }, [particle.id]);
+
+ 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]);
return (
@@ -25,7 +70,7 @@ export function TextParticleView({ particle }: TextParticleViewProps) {
style.weight,
)}
>
- {data.content}
+ {particle.properties.content}
);
diff --git a/js/src/features/send/reply-indicator.tsx b/js/src/features/send/reply-indicator.tsx
deleted file mode 100644
index fd65a78..0000000
--- a/js/src/features/send/reply-indicator.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { Video, Mic } from "lucide-react";
-import { useRecordingStore } from "@/stores/recording-store";
-import { cn } from "@/lib/utils";
-
-export function ReplyIndicator() {
- const recordingMode = useRecordingStore((s) => s.recordingMode);
- const setRecordingMode = useRecordingStore((s) => s.setRecordingMode);
-
- return (
-
-
-
- Hold{" "}
-
- `
- {" "}
- to reply · Press{" "}
-
- T
- {" "}
- to text
-
-
- );
-}
diff --git a/js/src/features/send/use-recorder.ts b/js/src/features/send/use-recorder.ts
deleted file mode 100644
index 60dcebc..0000000
--- a/js/src/features/send/use-recorder.ts
+++ /dev/null
@@ -1,191 +0,0 @@
-import { useCallback, useEffect, useRef } from "react";
-import { apiClient } from "@/api/client";
-import { useAppStore } from "@/stores/app-store";
-import { usePlaybackStore } from "@/stores/playback-store";
-import { useRecordingStore } from "@/stores/recording-store";
-
-const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
-const VIDEO_FALLBACK_MIME = "video/webm";
-const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus";
-const AUDIO_FALLBACK_MIME = "audio/webm";
-
-function getMediaMime(mode: "video" | "audio"): string {
- if (mode === "audio") {
- if (MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME))
- return AUDIO_PREFERRED_MIME;
- return AUDIO_FALLBACK_MIME;
- }
- if (MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME))
- return VIDEO_PREFERRED_MIME;
- return VIDEO_FALLBACK_MIME;
-}
-
-export function useRecorder(
- streamId: string | null,
- networkId: string | null,
-) {
- const recorderRef = useRef(null);
- const streamRef = useRef(null);
- const chunksRef = useRef([]);
- const startTimeRef = useRef(0);
- const mimeRef = useRef("");
-
- const recordingMode = useRecordingStore((s) => s.recordingMode);
- const setStatus = useRecordingStore((s) => s.setStatus);
- const setError = useRecordingStore((s) => s.setError);
- const setMediaStream = useRecordingStore((s) => s.setMediaStream);
- const setReviewBlob = useRecordingStore((s) => s.setReviewBlob);
- const resetRecording = useRecordingStore((s) => s.reset);
- const addParticleToStream = useAppStore((s) => s.addParticleToStream);
-
- const stopTracks = useCallback(() => {
- streamRef.current?.getTracks().forEach((t) => t.stop());
- streamRef.current = null;
- recorderRef.current = null;
- chunksRef.current = [];
- setMediaStream(null);
- }, [setMediaStream]);
-
- const confirmSend = useCallback(async () => {
- if (!streamId || !networkId) return;
-
- const { reviewBlob, reviewDurationMs } = useRecordingStore.getState();
- if (!reviewBlob) return;
-
- setStatus("uploading");
-
- try {
- const mimeType = reviewBlob.type || VIDEO_FALLBACK_MIME;
- const fileName = `recording-${Date.now()}.webm`;
-
- const { object_id, upload_url } = await apiClient.prepareUpload({
- network_id: networkId,
- name: fileName,
- content_type: mimeType,
- content_length: reviewBlob.size,
- });
-
- await fetch(upload_url, {
- method: "PUT",
- headers: { "Content-Type": mimeType },
- body: reviewBlob,
- });
-
- await apiClient.confirmUpload(object_id);
-
- const particle = await apiClient.createStreamParticle(streamId, {
- type: "media",
- data: {
- object_id,
- duration_ms: reviewDurationMs,
- mime_type: mimeType,
- },
- });
-
- addParticleToStream(streamId, particle);
-
- const playbackState = usePlaybackStore.getState();
- if (playbackState.streamId === streamId) {
- usePlaybackStore.setState({
- particles: [...playbackState.particles, particle],
- });
- }
-
- resetRecording();
- } catch (err) {
- setError(err instanceof Error ? err.message : "Upload failed");
- }
- }, [streamId, networkId, setStatus, setError, resetRecording, addParticleToStream]);
-
- const startRecording = useCallback(async () => {
- const currentStatus = useRecordingStore.getState().status;
- if (currentStatus !== "idle") return;
-
- try {
- const constraints =
- recordingMode === "video"
- ? { video: true, audio: true }
- : { audio: true };
-
- setStatus("recording");
-
- const mediaStream =
- await navigator.mediaDevices.getUserMedia(constraints);
-
- streamRef.current = mediaStream;
- setMediaStream(mediaStream);
- chunksRef.current = [];
- startTimeRef.current = Date.now();
-
- const mime = getMediaMime(recordingMode);
- mimeRef.current = mime;
- const recorder = new MediaRecorder(mediaStream, { mimeType: mime });
- recorderRef.current = recorder;
-
- recorder.ondataavailable = (e) => {
- if (e.data.size > 0) chunksRef.current.push(e.data);
- };
-
- recorder.onstop = () => {
- const durationMs = Date.now() - startTimeRef.current;
- const blob = new Blob(chunksRef.current, { type: mime });
- stopTracks();
-
- if (blob.size > 0) {
- setReviewBlob(blob, durationMs);
- } else {
- resetRecording();
- }
- };
-
- recorder.start();
- } catch (err) {
- stopTracks();
- setError(
- err instanceof Error ? err.message : "Failed to start recording",
- );
- }
- }, [
- recordingMode,
- setStatus,
- setError,
- setMediaStream,
- setReviewBlob,
- stopTracks,
- resetRecording,
- ]);
-
- const stopRecording = useCallback(() => {
- if (recorderRef.current?.state === "recording") {
- recorderRef.current.stop();
- }
- }, []);
-
- const cancelRecording = useCallback(() => {
- const currentStatus = useRecordingStore.getState().status;
-
- if (currentStatus === "reviewing") {
- resetRecording();
- return;
- }
-
- if (recorderRef.current) {
- recorderRef.current.ondataavailable = null;
- recorderRef.current.onstop = null;
- if (recorderRef.current.state === "recording") {
- recorderRef.current.stop();
- }
- }
- stopTracks();
- resetRecording();
- }, [stopTracks, resetRecording]);
-
- // Cleanup on unmount
- useEffect(() => {
- return () => {
- stopTracks();
- };
- }, [stopTracks]);
-
- return { startRecording, stopRecording, cancelRecording, confirmSend };
-}
diff --git a/js/src/pages/settings-page.tsx b/js/src/features/settings-page.tsx
similarity index 98%
rename from js/src/pages/settings-page.tsx
rename to js/src/features/settings-page.tsx
index bd938d7..a6d76bb 100644
--- a/js/src/pages/settings-page.tsx
+++ b/js/src/features/settings-page.tsx
@@ -59,7 +59,7 @@ function SettingsGroup({
);
}
-export function SettingsPage() {
+export default function SettingsPage() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut);
@@ -68,7 +68,7 @@ export function SettingsPage() {
return (
-
+