feat: autoplay inbound clips
This commit is contained in:
@@ -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:"));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user