Skip to content

Commit 3a76e9c

Browse files
Ty/shadcn-components-2 (#1250)
1 parent 4b7752e commit 3a76e9c

File tree

9 files changed

+767
-2
lines changed

9 files changed

+767
-2
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import * as React from 'react';
2+
import { StoryObj } from '@storybook/react-vite';
3+
import {
4+
AgentSessionProvider,
5+
useMicrophone,
6+
} from '../../.storybook/lk-decorators/AgentSessionProvider';
7+
import { AgentAudioVisualizerGrid, AgentAudioVisualizerGridProps } from '@agents-ui';
8+
9+
export default {
10+
component: AgentAudioVisualizerGrid,
11+
decorators: [AgentSessionProvider],
12+
render: (args: AgentAudioVisualizerGridProps) => {
13+
const audioTrack = useMicrophone();
14+
15+
return <AgentAudioVisualizerGrid {...args} audioTrack={audioTrack} />;
16+
},
17+
args: {
18+
default: 'lg',
19+
state: 'connecting',
20+
radius: 5,
21+
interval: 100,
22+
rowCount: 10,
23+
columnCount: 10,
24+
},
25+
argTypes: {
26+
size: {
27+
options: ['icon', 'sm', 'md', 'lg', 'xl'],
28+
control: { type: 'radio' },
29+
},
30+
state: {
31+
options: [
32+
'idle',
33+
'disconnected',
34+
'pre-connect-buffering',
35+
'connecting',
36+
'initializing',
37+
'listening',
38+
'thinking',
39+
'speaking',
40+
'failed',
41+
],
42+
control: { type: 'radio' },
43+
},
44+
radius: {
45+
control: { type: 'range', min: 1, max: 50, step: 1 },
46+
},
47+
interval: {
48+
control: { type: 'range', min: 1, max: 1000, step: 1 },
49+
},
50+
rowCount: {
51+
control: { type: 'range', min: 1, max: 40, step: 1 },
52+
},
53+
columnCount: {
54+
control: { type: 'range', min: 1, max: 40, step: 1 },
55+
},
56+
className: { control: { type: 'text' } },
57+
},
58+
parameters: {
59+
layout: 'centered',
60+
actions: {
61+
handles: [],
62+
},
63+
},
64+
};
65+
66+
export const Demo1: StoryObj<AgentAudioVisualizerGridProps> = {
67+
args: {
68+
className:
69+
'gap-4 [&_>_*]:size-1 [&_>_*]:rounded-full [&_>_*]:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
70+
},
71+
};
72+
73+
export const Demo2: StoryObj<AgentAudioVisualizerGridProps> = {
74+
args: {
75+
className:
76+
'gap-2 [&_>_*]:w-4 [&_>_*]:h-1 [&_>_*]:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-[#F9B11F] [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_14.8px_2px_#F9B11F]',
77+
},
78+
};
79+
80+
export const Demo3: StoryObj<AgentAudioVisualizerGridProps> = {
81+
args: {
82+
className:
83+
'gap-4 [&_>_*]:size-2 [&_>_*]:rounded-full [&_>_*]:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-[#1F8CF9] [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_14.8px_2px_#1F8CF9]',
84+
transformer: (index: number, rowCount: number, columnCount: number) => {
85+
const rowMidPoint = Math.floor(rowCount / 2);
86+
const distanceFromCenter = Math.sqrt(
87+
Math.pow(rowMidPoint - (index % columnCount), 2) +
88+
Math.pow(rowMidPoint - Math.floor(index / columnCount), 2),
89+
);
90+
91+
return {
92+
opacity: 1 - distanceFromCenter / columnCount,
93+
transform: `scale(${1 - (distanceFromCenter / (columnCount * 2)) * 1.75})`,
94+
};
95+
},
96+
},
97+
};
98+
99+
export const Demo4: StoryObj<AgentAudioVisualizerGridProps> = {
100+
args: {
101+
className:
102+
'gap-x-2.5 gap-y-1 [&_>_*]:w-3 [&_>_*]:h-px [&_>_*]:my-2 [&_>_*]:rotate-45 [&_>_*]:bg-foreground/10 [&_>_*]:rotate-45 [&_>_*]:scale-100 [&_>_[data-lk-highlighted=true]]:bg-[#FFB6C1] [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_8px_2px_rgba(255,182,193,0.4)] [&_>_[data-lk-highlighted=true]]:rotate-[405deg] [&_>_[data-lk-highlighted=true]]:scale-200',
103+
},
104+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { StoryObj } from '@storybook/react-vite';
3+
import {
4+
AgentSessionProvider,
5+
useMicrophone,
6+
} from '../../.storybook/lk-decorators/AgentSessionProvider';
7+
import { AgentAudioVisualizerRadial, AgentAudioVisualizerRadialProps } from '@agents-ui';
8+
9+
export default {
10+
component: AgentAudioVisualizerRadial,
11+
decorators: [AgentSessionProvider],
12+
render: (args: AgentAudioVisualizerRadialProps) => {
13+
const audioTrack = useMicrophone();
14+
15+
return <AgentAudioVisualizerRadial {...args} audioTrack={audioTrack} />;
16+
},
17+
args: {
18+
size: 'lg',
19+
state: 'connecting',
20+
radius: undefined,
21+
},
22+
argTypes: {
23+
size: {
24+
options: ['icon', 'sm', 'md', 'lg', 'xl'],
25+
control: { type: 'radio' },
26+
},
27+
state: {
28+
options: [
29+
'idle',
30+
'disconnected',
31+
'pre-connect-buffering',
32+
'connecting',
33+
'initializing',
34+
'listening',
35+
'thinking',
36+
'speaking',
37+
'failed',
38+
],
39+
control: { type: 'radio' },
40+
},
41+
barCount: {
42+
control: { type: 'range', min: 4, max: 64, step: 4 },
43+
},
44+
radius: {
45+
control: { type: 'range', min: 1, max: 500, step: 1 },
46+
},
47+
className: { control: { type: 'text' } },
48+
},
49+
parameters: {
50+
layout: 'centered',
51+
actions: {
52+
handles: [],
53+
},
54+
},
55+
};
56+
57+
export const Default: StoryObj<AgentAudioVisualizerRadialProps> = {
58+
args: {},
59+
};

packages/shadcn/components/agents-ui/agent-audio-visualizer-bar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar';
1919
import { cn } from '@/lib/utils';
2020

21-
export function cloneSingleChild(
21+
function cloneSingleChild(
2222
children: ReactNode | ReactNode[],
2323
props?: Record<string, unknown>,
2424
key?: unknown,
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import React, {
2+
type ReactNode,
3+
type CSSProperties,
4+
memo,
5+
useMemo,
6+
Children,
7+
cloneElement,
8+
isValidElement,
9+
} from 'react';
10+
import { type VariantProps, cva } from 'class-variance-authority';
11+
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
12+
import {
13+
type AgentState,
14+
type TrackReferenceOrPlaceholder,
15+
useMultibandTrackVolume,
16+
} from '@livekit/components-react';
17+
import { cn } from '@/lib/utils';
18+
import {
19+
type Coordinate,
20+
useAgentAudioVisualizerGridAnimator,
21+
} from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';
22+
23+
function cloneSingleChild(
24+
children: ReactNode | ReactNode[],
25+
props?: Record<string, unknown>,
26+
key?: unknown,
27+
) {
28+
return Children.map(children, (child) => {
29+
// Checking isValidElement is the safe way and avoids a typescript error too.
30+
if (isValidElement(child) && Children.only(children)) {
31+
const childProps = child.props as Record<string, unknown>;
32+
if (childProps.className) {
33+
// make sure we retain classnames of both passed props and child
34+
props ??= {};
35+
props.className = cn(childProps.className as string, props.className as string);
36+
props.style = {
37+
...(childProps.style as CSSProperties),
38+
...(props.style as CSSProperties),
39+
};
40+
}
41+
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
42+
}
43+
return child;
44+
});
45+
}
46+
47+
export const AgentAudioVisualizerGridVariants = cva(
48+
[
49+
'grid',
50+
'[&_>_*]:size-1 [&_>_*]:rounded-full',
51+
'[&_>_*]:bg-foreground/10 [&_>_[data-lk-highlighted=true]]:bg-foreground [&_>_[data-lk-highlighted=true]]:scale-125 [&_>_[data-lk-highlighted=true]]:shadow-[0px_0px_10px_2px_rgba(255,255,255,0.4)]',
52+
],
53+
{
54+
variants: {
55+
size: {
56+
icon: ['gap-[2px] [&_>_*]:size-[4px]'],
57+
sm: ['gap-[4px] [&_>_*]:size-[4px]'],
58+
md: ['gap-[8px] [&_>_*]:size-[8px]'],
59+
lg: ['gap-[8px] [&_>_*]:size-[8px]'],
60+
xl: ['gap-[8px] [&_>_*]:size-[8px]'],
61+
},
62+
},
63+
defaultVariants: {
64+
size: 'md',
65+
},
66+
},
67+
);
68+
69+
export interface GridOptions {
70+
radius?: number;
71+
interval?: number;
72+
rowCount?: number;
73+
columnCount?: number;
74+
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
75+
className?: string;
76+
children?: ReactNode;
77+
}
78+
79+
const sizeDefaults = {
80+
icon: 3,
81+
sm: 5,
82+
md: 5,
83+
lg: 5,
84+
xl: 5,
85+
};
86+
87+
function useGrid(
88+
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',
89+
columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
90+
rowCount = sizeDefaults[size as keyof typeof sizeDefaults],
91+
) {
92+
return useMemo(() => {
93+
const _columnCount = columnCount;
94+
const _rowCount = rowCount ?? columnCount;
95+
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx);
96+
97+
return { columnCount: _columnCount, rowCount: _rowCount, items };
98+
}, [columnCount, rowCount]);
99+
}
100+
101+
interface GridCellProps {
102+
index: number;
103+
state: AgentState;
104+
interval: number;
105+
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
106+
rowCount: number;
107+
columnCount: number;
108+
volumeBands: number[];
109+
highlightedCoordinate: Coordinate;
110+
children: ReactNode;
111+
}
112+
113+
const GridCell = memo(function GridCell({
114+
index,
115+
state,
116+
interval,
117+
transformer,
118+
rowCount,
119+
columnCount,
120+
volumeBands,
121+
highlightedCoordinate,
122+
children,
123+
}: GridCellProps) {
124+
if (state === 'speaking') {
125+
const y = Math.floor(index / columnCount);
126+
const rowMidPoint = Math.floor(rowCount / 2);
127+
const volumeChunks = 1 / (rowMidPoint + 1);
128+
const distanceToMid = Math.abs(rowMidPoint - y);
129+
const threshold = distanceToMid * volumeChunks;
130+
const isHighlighted = volumeBands[index % columnCount] >= threshold;
131+
132+
return cloneSingleChild(children, {
133+
'data-lk-index': index,
134+
'data-lk-highlighted': isHighlighted,
135+
});
136+
}
137+
138+
let transformerStyle: CSSProperties | undefined;
139+
if (transformer) {
140+
transformerStyle = transformer(index, rowCount, columnCount);
141+
}
142+
143+
const isHighlighted =
144+
highlightedCoordinate.x === index % columnCount &&
145+
highlightedCoordinate.y === Math.floor(index / columnCount);
146+
147+
const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100);
148+
149+
return cloneSingleChild(children, {
150+
'data-lk-index': index,
151+
'data-lk-highlighted': isHighlighted,
152+
style: {
153+
transitionProperty: 'all',
154+
transitionDuration: `${transitionDurationInSeconds}s`,
155+
transitionTimingFunction: 'ease-out',
156+
...transformerStyle,
157+
},
158+
});
159+
});
160+
161+
export type AgentAudioVisualizerGridProps = GridOptions & {
162+
state: AgentState;
163+
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
164+
className?: string;
165+
children?: ReactNode;
166+
} & VariantProps<typeof AgentAudioVisualizerGridVariants>;
167+
168+
export function AgentAudioVisualizerGrid({
169+
size = 'md',
170+
state,
171+
radius,
172+
rowCount: _rowCount = 5,
173+
columnCount: _columnCount = 5,
174+
transformer,
175+
interval = 100,
176+
className,
177+
children,
178+
audioTrack,
179+
}: AgentAudioVisualizerGridProps) {
180+
const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount);
181+
const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(
182+
state,
183+
rowCount,
184+
columnCount,
185+
interval,
186+
radius,
187+
);
188+
const volumeBands = useMultibandTrackVolume(audioTrack, {
189+
bands: columnCount,
190+
loPass: 100,
191+
hiPass: 200,
192+
});
193+
194+
return (
195+
<div
196+
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
197+
style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
198+
>
199+
{items.map((idx) => (
200+
<GridCell
201+
key={idx}
202+
index={idx}
203+
state={state}
204+
interval={interval}
205+
transformer={transformer}
206+
rowCount={rowCount}
207+
columnCount={columnCount}
208+
volumeBands={volumeBands}
209+
highlightedCoordinate={highlightedCoordinate}
210+
>
211+
{children ?? <div />}
212+
</GridCell>
213+
))}
214+
</div>
215+
);
216+
}

0 commit comments

Comments
 (0)