feat: autoplay inbound clips
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
|
import { AutoplayOverlay } from "@/features/particles/autoplay-overlay";
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import ControlsIndicator from "@/features/compose/controls-indicator";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
import { ComposeOverlay } from "./compose/compose-overlay";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ export default function NetworkRoot() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="flex flex-col h-full relative">
|
||||||
<ParticleListView path={path} />
|
<ParticleListView path={path} />
|
||||||
|
<AutoplayOverlay networkId={networkId!} />
|
||||||
<ComposeOverlay networkId={networkId!} />
|
<ComposeOverlay networkId={networkId!} />
|
||||||
<ControlsIndicator type={"new"} />
|
<ControlsIndicator type={"new"} />
|
||||||
</div>
|
</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 { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Radio,
|
Radio,
|
||||||
@@ -26,6 +26,7 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Small } from "@/components/ui/typography";
|
import { Small } from "@/components/ui/typography";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
import type { Particle, StreamProperties } from "@/api/types";
|
||||||
|
import { useAutoplayStore } from "@/stores/autoplay-store";
|
||||||
|
|
||||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -86,6 +87,28 @@ function StreamRow({
|
|||||||
const userId = user?.id ?? "";
|
const userId = user?.id ?? "";
|
||||||
const userEmail = user?.email ?? "";
|
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 =
|
const isDM =
|
||||||
particle.visible_to.length === 2 &&
|
particle.visible_to.length === 2 &&
|
||||||
particle.visible_to.every((v) => v.startsWith("human:"));
|
particle.visible_to.every((v) => v.startsWith("human:"));
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
|
|
||||||
export function useDownloadUrl(objectId: string) {
|
export function useDownloadUrl(objectId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["download-url", objectId],
|
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 [latestChild, setLatestChild] = useState<Particle | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
const collectionPath = useMemo(() => toFirestoreChildrenPath(path), [path]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setLatestChild(null);
|
setLatestChild(null);
|
||||||
|
|
||||||
const unsubscribe = subscribeToLatestChild(
|
const unsubscribe = subscribeToLatestChild(
|
||||||
collectionPath,
|
toFirestoreChildrenPath(path),
|
||||||
(data) => {
|
(data) => {
|
||||||
setLatestChild(data);
|
setLatestChild(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -124,7 +122,7 @@ export function useLiveLatestChild(path: ParticlePath): UseLiveLatestChildResult
|
|||||||
);
|
);
|
||||||
|
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [collectionPath]);
|
}, [path]);
|
||||||
|
|
||||||
return { latestChild, isLoading };
|
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