feat: autoplay inbound clips

This commit is contained in:
talksik
2026-03-21 17:59:41 -07:00
parent 18f587864f
commit 4a4d213eb6
6 changed files with 122 additions and 7 deletions
+2
View File
@@ -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 (
<div className="flex flex-col h-full relative">
<ParticleListView path={path} />
<AutoplayOverlay networkId={networkId!} />
<ComposeOverlay networkId={networkId!} />
<ControlsIndicator type={"new"} />
</div>
@@ -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 (
<Card
size="sm"
className="absolute bottom-4 right-4 z-40 w-52 cursor-pointer shadow-lg"
onClick={handleClick}
>
<div className="relative">
{/* Close button */}
<button
className="absolute top-1 right-1 z-10 rounded-full bg-black/50 p-0.5 text-white hover:bg-black/70"
onClick={(e) => {
e.stopPropagation();
stop();
}}
>
<X className="size-3.5" />
</button>
{isVideo ? (
<video
src={url}
autoPlay
playsInline
onEnded={stop}
className="w-full rounded-t-xl object-cover"
/>
) : (
<div className="flex items-center gap-2 px-3 py-3">
<Icon className="size-4 shrink-0 text-muted-foreground" />
<Small className="truncate text-muted-foreground">
Playing voice note...
</Small>
<audio src={url} autoPlay onEnded={stop} />
</div>
)}
</div>
</Card>
);
}
@@ -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<string | undefined>(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:"));
+3 -2
View File
@@ -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,
});
}
+2 -4
View File
@@ -106,14 +106,12 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
const [latestChild, setLatestChild] = useState<Particle | null>(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 };
}
+22
View File
@@ -0,0 +1,22 @@
import { create } from "zustand";
import type { Particle } from "@/api/types";
type MediaParticle = Extract<Particle, { type: "media" }>;
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<AutoplayState>((set) => ({
activeParticle: null,
streamId: null,
play: (particle, streamId) => set({ activeParticle: particle, streamId }),
stop: () => set({ activeParticle: null, streamId: null }),
}));