238 lines
8.3 KiB
TypeScript
238 lines
8.3 KiB
TypeScript
import React, { useMemo } from 'react';
|
|
import { Track } from 'livekit-client';
|
|
import { AnimatePresence, motion } from 'motion/react';
|
|
import {
|
|
type TrackReference,
|
|
VideoTrack,
|
|
useLocalParticipant,
|
|
useTracks,
|
|
useVoiceAssistant,
|
|
} from '@livekit/components-react';
|
|
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
|
|
import { cn } from '@/lib/shadcn/utils';
|
|
|
|
const MotionContainer = motion.create('div');
|
|
|
|
const ANIMATION_TRANSITION = {
|
|
type: 'spring',
|
|
stiffness: 675,
|
|
damping: 75,
|
|
mass: 1,
|
|
};
|
|
|
|
const classNames = {
|
|
// GRID
|
|
// 2 Columns x 3 Rows
|
|
grid: [
|
|
'h-full w-full',
|
|
'grid gap-x-2 place-content-center',
|
|
'grid-cols-[1fr_1fr] grid-rows-[90px_1fr_90px]',
|
|
],
|
|
// Agent
|
|
// chatOpen: true,
|
|
// hasSecondTile: true
|
|
// layout: Column 1 / Row 1
|
|
// align: x-end y-center
|
|
agentChatOpenWithSecondTile: ['col-start-1 row-start-1', 'self-center justify-self-end'],
|
|
// Agent
|
|
// chatOpen: true,
|
|
// hasSecondTile: false
|
|
// layout: Column 1 / Row 1 / Column-Span 2
|
|
// align: x-center y-center
|
|
agentChatOpenWithoutSecondTile: ['col-start-1 row-start-1', 'col-span-2', 'place-content-center'],
|
|
// Agent
|
|
// chatOpen: false
|
|
// layout: Column 1 / Row 1 / Column-Span 2 / Row-Span 3
|
|
// align: x-center y-center
|
|
agentChatClosed: ['col-start-1 row-start-1', 'col-span-2 row-span-3', 'place-content-center'],
|
|
// Second tile
|
|
// chatOpen: true,
|
|
// hasSecondTile: true
|
|
// layout: Column 2 / Row 1
|
|
// align: x-start y-center
|
|
secondTileChatOpen: ['col-start-2 row-start-1', 'self-center justify-self-start'],
|
|
// Second tile
|
|
// chatOpen: false,
|
|
// hasSecondTile: false
|
|
// layout: Column 2 / Row 2
|
|
// align: x-end y-end
|
|
secondTileChatClosed: ['col-start-2 row-start-3', 'place-content-end'],
|
|
};
|
|
|
|
export function useLocalTrackRef(source: Track.Source) {
|
|
const { localParticipant } = useLocalParticipant();
|
|
const publication = localParticipant.getTrackPublication(source);
|
|
const trackRef = useMemo<TrackReference | undefined>(
|
|
() => (publication ? { source, participant: localParticipant, publication } : undefined),
|
|
[source, publication, localParticipant]
|
|
);
|
|
return trackRef;
|
|
}
|
|
|
|
interface TileLayoutProps {
|
|
chatOpen: boolean;
|
|
}
|
|
|
|
export function TileLayout({ chatOpen }: TileLayoutProps) {
|
|
const {
|
|
state: agentState,
|
|
audioTrack: agentAudioTrack,
|
|
videoTrack: agentVideoTrack,
|
|
} = useVoiceAssistant();
|
|
const [screenShareTrack] = useTracks([Track.Source.ScreenShare]);
|
|
const cameraTrack: TrackReference | undefined = useLocalTrackRef(Track.Source.Camera);
|
|
|
|
const isCameraEnabled = cameraTrack && !cameraTrack.publication.isMuted;
|
|
const isScreenShareEnabled = screenShareTrack && !screenShareTrack.publication.isMuted;
|
|
const hasSecondTile = isCameraEnabled || isScreenShareEnabled;
|
|
|
|
const animationDelay = chatOpen ? 0 : 0.15;
|
|
const isAvatar = agentVideoTrack !== undefined;
|
|
const videoWidth = agentVideoTrack?.publication.dimensions?.width ?? 0;
|
|
const videoHeight = agentVideoTrack?.publication.dimensions?.height ?? 0;
|
|
|
|
return (
|
|
<div className="pointer-events-none fixed inset-x-0 top-8 bottom-32 z-50 md:top-12 md:bottom-40">
|
|
<div className="relative mx-auto h-full max-w-2xl px-4 md:px-0">
|
|
<div className={cn(classNames.grid)}>
|
|
{/* Agent */}
|
|
<div
|
|
className={cn([
|
|
'grid',
|
|
!chatOpen && classNames.agentChatClosed,
|
|
chatOpen && hasSecondTile && classNames.agentChatOpenWithSecondTile,
|
|
chatOpen && !hasSecondTile && classNames.agentChatOpenWithoutSecondTile,
|
|
])}
|
|
>
|
|
<AnimatePresence mode="popLayout">
|
|
{!isAvatar && (
|
|
// Audio Agent
|
|
<MotionContainer
|
|
key="agent"
|
|
layoutId="agent"
|
|
initial={{
|
|
opacity: 0,
|
|
scale: 0,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: chatOpen ? 1 : 4,
|
|
}}
|
|
transition={{
|
|
...ANIMATION_TRANSITION,
|
|
delay: animationDelay,
|
|
}}
|
|
className={cn(
|
|
'bg-background aspect-square h-[90px] rounded-md border border-transparent transition-[border,drop-shadow]',
|
|
chatOpen && 'border-input/50 drop-shadow-lg/10 delay-200'
|
|
)}
|
|
>
|
|
<AgentAudioVisualizerBar
|
|
barCount={5}
|
|
state={agentState}
|
|
audioTrack={agentAudioTrack}
|
|
className={cn('flex h-full items-center justify-center gap-1 px-4 py-2')}
|
|
>
|
|
<span
|
|
className={cn([
|
|
'bg-muted min-h-2.5 w-2.5 rounded-full',
|
|
'origin-center transition-colors duration-250 ease-linear',
|
|
'data-[lk-highlighted=true]:bg-foreground data-[lk-muted=true]:bg-muted',
|
|
])}
|
|
/>
|
|
</AgentAudioVisualizerBar>
|
|
</MotionContainer>
|
|
)}
|
|
|
|
{isAvatar && (
|
|
// Avatar Agent
|
|
<MotionContainer
|
|
key="avatar"
|
|
layoutId="avatar"
|
|
initial={{
|
|
scale: 1,
|
|
opacity: 1,
|
|
maskImage:
|
|
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 20px, transparent 20px)',
|
|
filter: 'blur(20px)',
|
|
}}
|
|
animate={{
|
|
maskImage:
|
|
'radial-gradient(circle, rgba(0, 0, 0, 1) 0, rgba(0, 0, 0, 1) 500px, transparent 500px)',
|
|
filter: 'blur(0px)',
|
|
borderRadius: chatOpen ? 6 : 12,
|
|
}}
|
|
transition={{
|
|
...ANIMATION_TRANSITION,
|
|
delay: animationDelay,
|
|
maskImage: {
|
|
duration: 1,
|
|
},
|
|
filter: {
|
|
duration: 1,
|
|
},
|
|
}}
|
|
className={cn(
|
|
'overflow-hidden bg-black drop-shadow-xl/80',
|
|
chatOpen ? 'h-[90px]' : 'h-auto w-full'
|
|
)}
|
|
>
|
|
<VideoTrack
|
|
width={videoWidth}
|
|
height={videoHeight}
|
|
trackRef={agentVideoTrack}
|
|
className={cn(chatOpen && 'size-[90px] object-cover')}
|
|
/>
|
|
</MotionContainer>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
<div
|
|
className={cn([
|
|
'grid',
|
|
chatOpen && classNames.secondTileChatOpen,
|
|
!chatOpen && classNames.secondTileChatClosed,
|
|
])}
|
|
>
|
|
{/* Camera & Screen Share */}
|
|
<AnimatePresence>
|
|
{((cameraTrack && isCameraEnabled) || (screenShareTrack && isScreenShareEnabled)) && (
|
|
<MotionContainer
|
|
key="camera"
|
|
layout="position"
|
|
layoutId="camera"
|
|
initial={{
|
|
opacity: 0,
|
|
scale: 0,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0,
|
|
}}
|
|
transition={{
|
|
...ANIMATION_TRANSITION,
|
|
delay: animationDelay,
|
|
}}
|
|
className="drop-shadow-lg/20"
|
|
>
|
|
<VideoTrack
|
|
trackRef={cameraTrack || screenShareTrack}
|
|
width={(cameraTrack || screenShareTrack)?.publication.dimensions?.width ?? 0}
|
|
height={(cameraTrack || screenShareTrack)?.publication.dimensions?.height ?? 0}
|
|
className="bg-muted aspect-square w-[90px] rounded-md object-cover"
|
|
/>
|
|
</MotionContainer>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|