diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx
index c7bea20..2a261a7 100644
--- a/js/src/features/network-root.tsx
+++ b/js/src/features/network-root.tsx
@@ -1,6 +1,7 @@
import { useParams } from "react-router-dom";
import { particlePath } from "@/lib/particle-path";
import { ParticleListView } from "@/features/particles/particle-list-view";
+import { AutoplayOverlay } from "@/features/particles/autoplay-overlay";
import ControlsIndicator from "@/features/compose/controls-indicator";
import { ComposeOverlay } from "./compose/compose-overlay";
@@ -15,6 +16,7 @@ export default function NetworkRoot() {
return (
diff --git a/js/src/features/particles/autoplay-overlay.tsx b/js/src/features/particles/autoplay-overlay.tsx
new file mode 100644
index 0000000..12f16ec
--- /dev/null
+++ b/js/src/features/particles/autoplay-overlay.tsx
@@ -0,0 +1,69 @@
+import { useNavigate } from "react-router-dom";
+import { X, Mic, Video } from "lucide-react";
+import { useAutoplayStore } from "@/stores/autoplay-store";
+import { useDownloadUrl } from "@/hooks/use-download-url";
+import { Card } from "@/components/ui/card";
+import { Small } from "@/components/ui/typography";
+
+interface AutoplayOverlayProps {
+ networkId: string;
+}
+
+export function AutoplayOverlay({ networkId }: AutoplayOverlayProps) {
+ const activeParticle = useAutoplayStore((s) => s.activeParticle);
+ const streamId = useAutoplayStore((s) => s.streamId);
+ const stop = useAutoplayStore((s) => s.stop);
+ const { data: url } = useDownloadUrl(activeParticle?.properties.object_id);
+ const navigate = useNavigate();
+
+ if (!activeParticle || !url) return null;
+
+ const isVideo = activeParticle.properties.mime_type?.startsWith("video/");
+ const Icon = isVideo ? Video : Mic;
+
+ const handleClick = () => {
+ stop();
+ if (streamId) {
+ navigate(`/${networkId}/${streamId}`);
+ }
+ };
+
+ return (
+
+
+ {/* Close button */}
+
+
+ {isVideo ? (
+
+ ) : (
+
+
+
+ Playing voice note...
+
+
+
+ )}
+
+
+ );
+}
diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx
index 085c687..88490e0 100644
--- a/js/src/features/particles/particle-list-view.tsx
+++ b/js/src/features/particles/particle-list-view.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from "react";
+import { useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
Radio,
@@ -26,6 +26,7 @@ import { Separator } from "@/components/ui/separator";
import { Progress } from "@/components/ui/progress";
import { Small } from "@/components/ui/typography";
import type { Particle, StreamProperties } from "@/api/types";
+import { useAutoplayStore } from "@/stores/autoplay-store";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
@@ -86,6 +87,28 @@ function StreamRow({
const userId = user?.id ?? "";
const userEmail = user?.email ?? "";
+ // Autoplay: trigger only when latestChild *changes* to a new media particle,
+ // not on initial data load. We track the "settled" id — the first non-null value
+ // we see — and only autoplay on subsequent changes from that baseline.
+ const settledIdRef = useRef(undefined);
+ useEffect(() => {
+ if (!latestChild) return;
+
+ // First real value: record it as baseline, don't autoplay
+ if (settledIdRef.current === undefined) {
+ settledIdRef.current = latestChild.id;
+ return;
+ }
+
+ if (latestChild.id === settledIdRef.current) return;
+ settledIdRef.current = latestChild.id;
+
+ if (latestChild.type !== "media") return;
+ if (latestChild.created_by_email === userEmail) return;
+
+ useAutoplayStore.getState().play(latestChild, particle.id);
+ }, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
diff --git a/js/src/hooks/use-download-url.ts b/js/src/hooks/use-download-url.ts
index 2874e1f..ab6c226 100644
--- a/js/src/hooks/use-download-url.ts
+++ b/js/src/hooks/use-download-url.ts
@@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
-export function useDownloadUrl(objectId: string) {
+export function useDownloadUrl(objectId?: string) {
return useQuery({
queryKey: ["download-url", objectId],
- queryFn: () => apiClient.getParticleDownloadUrl(objectId),
+ queryFn: () => apiClient.getParticleDownloadUrl(objectId!),
+ enabled: !!objectId,
});
}
diff --git a/js/src/hooks/use-particle.ts b/js/src/hooks/use-particle.ts
index f0ef983..5e29b3b 100644
--- a/js/src/hooks/use-particle.ts
+++ b/js/src/hooks/use-particle.ts
@@ -106,14 +106,12 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
const [latestChild, setLatestChild] = useState(null);
const [isLoading, setIsLoading] = useState(true);
- const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
-
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
- collectionPath,
+ toFirestoreChildrenPath(path),
(data) => {
setLatestChild(data);
setIsLoading(false);
@@ -124,7 +122,7 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
);
return unsubscribe;
- }, [collectionPath]);
+ }, [path]);
return { latestChild, isLoading };
}
diff --git a/js/src/stores/autoplay-store.ts b/js/src/stores/autoplay-store.ts
new file mode 100644
index 0000000..b23fbb1
--- /dev/null
+++ b/js/src/stores/autoplay-store.ts
@@ -0,0 +1,22 @@
+import { create } from "zustand";
+import type { Particle } from "@/api/types";
+
+type MediaParticle = Extract;
+
+interface AutoplayState {
+ /** The media particle currently being autoplayed, null when idle */
+ activeParticle: MediaParticle | null;
+ /** The stream this particle belongs to (for click-to-navigate) */
+ streamId: string | null;
+ /** Play a media particle, preempting any currently playing */
+ play: (particle: MediaParticle, streamId: string) => void;
+ /** Stop autoplay and clear state */
+ stop: () => void;
+}
+
+export const useAutoplayStore = create((set) => ({
+ activeParticle: null,
+ streamId: null,
+ play: (particle, streamId) => set({ activeParticle: particle, streamId }),
+ stop: () => set({ activeParticle: null, streamId: null }),
+}));