From 4a4d213eb6cb8123c63fc6669be346d16006cf31 Mon Sep 17 00:00:00 2001 From: talksik Date: Sat, 21 Mar 2026 17:59:41 -0700 Subject: [PATCH] feat: autoplay inbound clips --- js/src/features/network-root.tsx | 2 + .../features/particles/autoplay-overlay.tsx | 69 +++++++++++++++++++ .../features/particles/particle-list-view.tsx | 25 ++++++- js/src/hooks/use-download-url.ts | 5 +- js/src/hooks/use-particle.ts | 6 +- js/src/stores/autoplay-store.ts | 22 ++++++ 6 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 js/src/features/particles/autoplay-overlay.tsx create mode 100644 js/src/stores/autoplay-store.ts 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 ? ( +
+
+ ); +} 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 }), +}));