fix(voice): keep LiveKit TTS tracks alive
This commit is contained in:
+196
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
|
||||
import {
|
||||
type AgentState,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
useMultibandTrackVolume,
|
||||
} from '@livekit/components-react';
|
||||
import { useAgentAudioVisualizerBarAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-bar';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
function cloneSingleChild(
|
||||
children: ReactNode | ReactNode[],
|
||||
props?: Record<string, unknown>,
|
||||
key?: unknown
|
||||
) {
|
||||
return Children.map(children, (child) => {
|
||||
// Checking isValidElement is the safe way and avoids a typescript error too.
|
||||
if (isValidElement(child) && Children.only(children)) {
|
||||
const childProps = child.props as Record<string, unknown>;
|
||||
if (childProps.className) {
|
||||
// make sure we retain classnames of both passed props and child
|
||||
props ??= {};
|
||||
props.className = cn(childProps.className as string, props.className as string);
|
||||
props.style = {
|
||||
...(childProps.style as CSSProperties),
|
||||
...(props.style as CSSProperties),
|
||||
};
|
||||
}
|
||||
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
export const AgentAudioVisualizerBarVariants = cva(
|
||||
[
|
||||
'relative flex items-center justify-center',
|
||||
'*:rounded-full *:transition-colors *:duration-250 *:ease-linear',
|
||||
'*:bg-transparent *:data-[lk-highlighted=true]:bg-current',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
icon: ['h-[24px] gap-[2px]', '*:w-[4px] *:min-h-[4px]'],
|
||||
sm: ['h-[56px] gap-[4px]', '*:w-[8px] *:min-h-[8px]'],
|
||||
md: ['h-[112px] gap-[8px]', '*:w-[16px] *:min-h-[16px]'],
|
||||
lg: ['h-[224px] gap-[16px]', '*:w-[32px] *:min-h-[32px]'],
|
||||
xl: ['h-[448px] gap-[32px]', '*:w-[64px] *:min-h-[64px]'],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the AgentAudioVisualizerBar component.
|
||||
*/
|
||||
export interface AgentAudioVisualizerBarProps {
|
||||
/**
|
||||
* The size of the visualizer.
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/**
|
||||
* The current state of the agent. Determines the animation pattern.
|
||||
* @defaultValue 'connecting'
|
||||
*/
|
||||
state?: AgentState;
|
||||
/**
|
||||
* The number of bars to display in the visualizer.
|
||||
* If not provided, defaults based on size: 3 for 'icon'/'sm', 5 for others.
|
||||
*/
|
||||
barCount?: number;
|
||||
/**
|
||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
||||
*/
|
||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
||||
/**
|
||||
* Additional CSS class names to apply to the container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Custom children to render as bars. Each child receives data-lk-index,
|
||||
* data-lk-highlighted, and style props for height.
|
||||
*/
|
||||
children?: ReactNode | ReactNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A bar-style audio visualizer that responds to agent state and audio levels.
|
||||
* Displays animated bars that react to the current agent state (connecting, thinking, speaking, etc.)
|
||||
* and audio volume when speaking.
|
||||
*
|
||||
* @extends ComponentProps<'div'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentAudioVisualizerBar
|
||||
* size="md"
|
||||
* state="speaking"
|
||||
* audioTrack={agentAudioTrack}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentAudioVisualizerBar({
|
||||
size = 'md',
|
||||
state = 'connecting',
|
||||
barCount,
|
||||
audioTrack,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AgentAudioVisualizerBarProps &
|
||||
VariantProps<typeof AgentAudioVisualizerBarVariants> &
|
||||
ComponentProps<'div'>) {
|
||||
const _barCount = useMemo(() => {
|
||||
if (barCount) {
|
||||
return barCount;
|
||||
}
|
||||
switch (size) {
|
||||
case 'icon':
|
||||
case 'sm':
|
||||
return 3;
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}, [barCount, size]);
|
||||
|
||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
||||
bands: _barCount,
|
||||
loPass: 100,
|
||||
hiPass: 200,
|
||||
});
|
||||
|
||||
const sequencerInterval = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
return 2000 / _barCount;
|
||||
case 'initializing':
|
||||
return 2000;
|
||||
case 'listening':
|
||||
return 500;
|
||||
case 'thinking':
|
||||
return 150;
|
||||
default:
|
||||
return 1000;
|
||||
}
|
||||
}, [state, _barCount]);
|
||||
|
||||
const highlightedIndices = useAgentAudioVisualizerBarAnimator(
|
||||
state,
|
||||
_barCount,
|
||||
sequencerInterval
|
||||
);
|
||||
|
||||
const bands = useMemo(
|
||||
() => (state === 'speaking' ? volumeBands : new Array(_barCount).fill(0)),
|
||||
[state, volumeBands, _barCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn(AgentAudioVisualizerBarVariants({ size }), className)} {...props}>
|
||||
{bands.map((band: number, idx: number) =>
|
||||
children ? (
|
||||
<React.Fragment key={idx}>
|
||||
{cloneSingleChild(children, {
|
||||
'data-lk-index': idx,
|
||||
'data-lk-highlighted': highlightedIndices.includes(idx),
|
||||
style: { height: `${band * 100}%` },
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div
|
||||
key={idx}
|
||||
data-lk-index={idx}
|
||||
data-lk-highlighted={highlightedIndices.includes(idx)}
|
||||
style={{ height: `${band * 100}%` }}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
Children,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { LocalAudioTrack, RemoteAudioTrack } from 'livekit-client';
|
||||
import {
|
||||
type AgentState,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
useMultibandTrackVolume,
|
||||
} from '@livekit/components-react';
|
||||
import {
|
||||
type Coordinate,
|
||||
useAgentAudioVisualizerGridAnimator,
|
||||
} from '@/hooks/agents-ui/use-agent-audio-visualizer-grid';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
function cloneSingleChild(
|
||||
children: ReactNode | ReactNode[],
|
||||
props?: Record<string, unknown>,
|
||||
key?: unknown
|
||||
) {
|
||||
return Children.map(children, (child) => {
|
||||
// Checking isValidElement is the safe way and avoids a typescript error too.
|
||||
if (isValidElement(child) && Children.only(children)) {
|
||||
const childProps = child.props as Record<string, unknown>;
|
||||
if (childProps.className) {
|
||||
// make sure we retain classnames of both passed props and child
|
||||
props ??= {};
|
||||
props.className = cn(childProps.className as string, props.className as string);
|
||||
props.style = {
|
||||
...(childProps.style as CSSProperties),
|
||||
...(props.style as CSSProperties),
|
||||
};
|
||||
}
|
||||
return cloneElement(child, { ...props, key: key ? String(key) : undefined });
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
export const AgentAudioVisualizerGridVariants = cva(
|
||||
[
|
||||
'grid',
|
||||
'*: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)]',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
icon: ['gap-[2px] *:size-[4px]'],
|
||||
sm: ['gap-[4px] *:size-[4px]'],
|
||||
md: ['gap-[8px] *:size-[8px]'],
|
||||
lg: ['gap-[8px] *:size-[8px]'],
|
||||
xl: ['gap-[8px] *:size-[8px]'],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Configuration options for the grid visualizer.
|
||||
*/
|
||||
export interface GridOptions {
|
||||
/**
|
||||
* The radius for the animation spread effect.
|
||||
*/
|
||||
radius?: number;
|
||||
/**
|
||||
* The interval in milliseconds between animation frames.
|
||||
* @defaultValue 100
|
||||
*/
|
||||
interval?: number;
|
||||
/**
|
||||
* The number of rows in the grid.
|
||||
* @defaultValue 5
|
||||
*/
|
||||
rowCount?: number;
|
||||
/**
|
||||
* The number of columns in the grid.
|
||||
* @defaultValue 5
|
||||
*/
|
||||
columnCount?: number;
|
||||
/**
|
||||
* A function to transform the style of each grid cell based on its position.
|
||||
* Receives the cell index, row count, and column count as arguments.
|
||||
*/
|
||||
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
|
||||
/**
|
||||
* Additional CSS class names to apply to the container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Custom children to render as grid cells.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const sizeDefaults = {
|
||||
icon: 3,
|
||||
sm: 5,
|
||||
md: 5,
|
||||
lg: 5,
|
||||
xl: 5,
|
||||
};
|
||||
|
||||
function useGrid(
|
||||
size: VariantProps<typeof AgentAudioVisualizerGridVariants>['size'] = 'md',
|
||||
columnCount = sizeDefaults[size as keyof typeof sizeDefaults],
|
||||
rowCount = sizeDefaults[size as keyof typeof sizeDefaults]
|
||||
) {
|
||||
return useMemo(() => {
|
||||
const _columnCount = columnCount;
|
||||
const _rowCount = rowCount ?? columnCount;
|
||||
const items = new Array(_columnCount * _rowCount).fill(0).map((_, idx) => idx);
|
||||
|
||||
return { columnCount: _columnCount, rowCount: _rowCount, items };
|
||||
}, [columnCount, rowCount]);
|
||||
}
|
||||
|
||||
interface GridCellProps {
|
||||
index: number;
|
||||
state: AgentState;
|
||||
interval: number;
|
||||
transformer?: (index: number, rowCount: number, columnCount: number) => CSSProperties;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
volumeBands: number[];
|
||||
highlightedCoordinate: Coordinate;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const GridCell = memo(function GridCell({
|
||||
index,
|
||||
state,
|
||||
interval,
|
||||
transformer,
|
||||
rowCount,
|
||||
columnCount,
|
||||
volumeBands,
|
||||
highlightedCoordinate,
|
||||
children,
|
||||
}: GridCellProps) {
|
||||
if (state === 'speaking') {
|
||||
const y = Math.floor(index / columnCount);
|
||||
const rowMidPoint = Math.floor(rowCount / 2);
|
||||
const volumeChunks = 1 / (rowMidPoint + 1);
|
||||
const distanceToMid = Math.abs(rowMidPoint - y);
|
||||
const threshold = distanceToMid * volumeChunks;
|
||||
const isHighlighted = (volumeBands[index % columnCount] ?? 0) >= threshold;
|
||||
|
||||
return cloneSingleChild(children, {
|
||||
'data-lk-index': index,
|
||||
'data-lk-highlighted': isHighlighted,
|
||||
});
|
||||
}
|
||||
|
||||
let transformerStyle: CSSProperties | undefined;
|
||||
if (transformer) {
|
||||
transformerStyle = transformer(index, rowCount, columnCount);
|
||||
}
|
||||
|
||||
const isHighlighted =
|
||||
highlightedCoordinate.x === index % columnCount &&
|
||||
highlightedCoordinate.y === Math.floor(index / columnCount);
|
||||
|
||||
const transitionDurationInSeconds = interval / (isHighlighted ? 1000 : 100);
|
||||
|
||||
return cloneSingleChild(children, {
|
||||
'data-lk-index': index,
|
||||
'data-lk-highlighted': isHighlighted,
|
||||
style: {
|
||||
transitionProperty: 'all',
|
||||
transitionDuration: `${transitionDurationInSeconds}s`,
|
||||
transitionTimingFunction: 'ease-out',
|
||||
...transformerStyle,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the AgentAudioVisualizerGrid component.
|
||||
*/
|
||||
export type AgentAudioVisualizerGridProps = GridOptions & {
|
||||
/**
|
||||
* The size of the visualizer.
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/**
|
||||
* The current state of the agent. Determines the animation pattern.
|
||||
* @defaultValue 'connecting'
|
||||
*/
|
||||
state?: AgentState;
|
||||
/**
|
||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
||||
*/
|
||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
||||
/**
|
||||
* Additional CSS class names to apply to the container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Custom children to render as grid cells. Each child receives data-lk-index
|
||||
* and data-lk-highlighted props.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
} & VariantProps<typeof AgentAudioVisualizerGridVariants>;
|
||||
|
||||
/**
|
||||
* A grid-style audio visualizer that responds to agent state and audio levels.
|
||||
* Displays an animated grid of cells that react to the current agent state
|
||||
* and audio volume when speaking.
|
||||
*
|
||||
* @extends ComponentProps<'div'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentAudioVisualizerGrid
|
||||
* size="md"
|
||||
* state="speaking"
|
||||
* rowCount={5}
|
||||
* columnCount={5}
|
||||
* audioTrack={agentAudioTrack}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentAudioVisualizerGrid({
|
||||
size = 'md',
|
||||
state = 'connecting',
|
||||
radius,
|
||||
rowCount: _rowCount = 5,
|
||||
columnCount: _columnCount = 5,
|
||||
transformer,
|
||||
interval = 100,
|
||||
className,
|
||||
children,
|
||||
audioTrack,
|
||||
style,
|
||||
...props
|
||||
}: AgentAudioVisualizerGridProps & ComponentProps<'div'>) {
|
||||
const { columnCount, rowCount, items } = useGrid(size, _columnCount, _rowCount);
|
||||
const highlightedCoordinate = useAgentAudioVisualizerGridAnimator(
|
||||
state,
|
||||
rowCount,
|
||||
columnCount,
|
||||
interval,
|
||||
radius
|
||||
);
|
||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
||||
bands: columnCount,
|
||||
loPass: 100,
|
||||
hiPass: 200,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(AgentAudioVisualizerGridVariants({ size }), className)}
|
||||
style={{ ...style, gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
|
||||
{...props}
|
||||
>
|
||||
{items.map((idx) => (
|
||||
<GridCell
|
||||
key={idx}
|
||||
index={idx}
|
||||
state={state}
|
||||
interval={interval}
|
||||
transformer={transformer}
|
||||
rowCount={rowCount}
|
||||
columnCount={columnCount}
|
||||
volumeBands={volumeBands}
|
||||
highlightedCoordinate={highlightedCoordinate}
|
||||
>
|
||||
{children ?? <div />}
|
||||
</GridCell>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
'use client';
|
||||
|
||||
import { type ComponentProps, useMemo } from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { type LocalAudioTrack, type RemoteAudioTrack } from 'livekit-client';
|
||||
import {
|
||||
type AgentState,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
useMultibandTrackVolume,
|
||||
} from '@livekit/components-react';
|
||||
import { useAgentAudioVisualizerRadialAnimator } from '@/hooks/agents-ui/use-agent-audio-visualizer-radial';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
export const AgentAudioVisualizerRadialVariants = cva(
|
||||
[
|
||||
'relative flex items-center justify-center',
|
||||
'[&_[data-lk-index]]:absolute [&_[data-lk-index]]:top-1/2 [&_[data-lk-index]]:left-1/2 [&_[data-lk-index]]:origin-bottom [&_[data-lk-index]]:-translate-x-1/2',
|
||||
'[&_[data-lk-index]]:rounded-full [&_[data-lk-index]]:transition-colors [&_[data-lk-index]]:duration-150 [&_[data-lk-index]]:ease-linear [&_[data-lk-index]]:bg-transparent [&_[data-lk-index]]:data-[lk-highlighted=true]:bg-current',
|
||||
'has-data-[lk-state=connecting]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=connecting]:[&_[data-lk-index]]:bg-current/10',
|
||||
'has-data-[lk-state=initializing]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=initializing]:[&_[data-lk-index]]:bg-current/10',
|
||||
'has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300 has-data-[lk-state=listening]:[&_[data-lk-index]]:bg-current/10 has-data-[lk-state=listening]:[&_[data-lk-index]]:duration-300',
|
||||
'has-data-[lk-state=thinking]:animate-spin has-data-[lk-state=thinking]:[animation-duration:5s] has-data-[lk-state=thinking]:[&_[data-lk-index]]:bg-current',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
icon: ['h-[24px] gap-[2px]'],
|
||||
sm: ['h-[56px] gap-[4px]'],
|
||||
md: ['h-[112px] gap-[8px]'],
|
||||
lg: ['h-[224px] gap-[16px]'],
|
||||
xl: ['h-[448px] gap-[32px]'],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the AgentAudioVisualizerRadial component.
|
||||
*/
|
||||
export interface AgentAudioVisualizerRadialProps {
|
||||
/**
|
||||
* The size of the visualizer.
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: 'icon' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
/**
|
||||
* The current state of the agent. Determines the animation pattern.
|
||||
* @defaultValue 'connecting'
|
||||
*/
|
||||
state?: AgentState;
|
||||
/**
|
||||
* The radius (distance from center) for the radial bars.
|
||||
* If not provided, defaults based on size.
|
||||
*/
|
||||
radius?: number;
|
||||
/**
|
||||
* The number of bars to display around the circle.
|
||||
* Should be divisible by 4 for optimal visual results.
|
||||
* If not provided, defaults to 12 for 'icon'/'sm', 24 for others.
|
||||
*/
|
||||
barCount?: number;
|
||||
/**
|
||||
* The audio track to visualize. Can be a local/remote audio track or a track reference.
|
||||
*/
|
||||
audioTrack?: LocalAudioTrack | RemoteAudioTrack | TrackReferenceOrPlaceholder;
|
||||
/**
|
||||
* Additional CSS class names to apply to the container.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A radial (circular) audio visualizer that responds to agent state and audio levels.
|
||||
* Displays animated bars arranged in a circle that react to the current agent state
|
||||
* and audio volume when speaking.
|
||||
*
|
||||
* @extends ComponentProps<'div'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentAudioVisualizerRadial
|
||||
* size="lg"
|
||||
* state="speaking"
|
||||
* barCount={24}
|
||||
* audioTrack={agentAudioTrack}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentAudioVisualizerRadial({
|
||||
size = 'md',
|
||||
state = 'connecting',
|
||||
radius,
|
||||
barCount,
|
||||
audioTrack,
|
||||
className,
|
||||
...props
|
||||
}: AgentAudioVisualizerRadialProps &
|
||||
ComponentProps<'div'> &
|
||||
VariantProps<typeof AgentAudioVisualizerRadialVariants>) {
|
||||
const _barCount = useMemo(() => {
|
||||
if (barCount) {
|
||||
return barCount;
|
||||
}
|
||||
switch (size) {
|
||||
case 'icon':
|
||||
case 'sm':
|
||||
return 12;
|
||||
default:
|
||||
return 24;
|
||||
}
|
||||
}, [barCount, size]);
|
||||
|
||||
const volumeBands = useMultibandTrackVolume(audioTrack, {
|
||||
bands: _barCount,
|
||||
loPass: 100,
|
||||
hiPass: 200,
|
||||
});
|
||||
|
||||
const sequencerInterval = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'connecting':
|
||||
case 'listening':
|
||||
return 500;
|
||||
case 'initializing':
|
||||
return 250;
|
||||
case 'thinking':
|
||||
return Infinity;
|
||||
default:
|
||||
return 1000;
|
||||
}
|
||||
}, [state, _barCount]);
|
||||
|
||||
const distanceFromCenter = useMemo(() => {
|
||||
if (radius) {
|
||||
return radius;
|
||||
}
|
||||
switch (size) {
|
||||
case 'icon':
|
||||
return 6;
|
||||
case 'xl':
|
||||
return 128;
|
||||
case 'lg':
|
||||
return 64;
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
default:
|
||||
return 32;
|
||||
}
|
||||
}, [size, radius]);
|
||||
|
||||
if (_barCount % 4 !== 0) {
|
||||
console.warn('barCount should be divisible by 4 for optimal visual results');
|
||||
}
|
||||
|
||||
const highlightedIndices = useAgentAudioVisualizerRadialAnimator(
|
||||
state,
|
||||
_barCount,
|
||||
sequencerInterval
|
||||
);
|
||||
const bands = useMemo(
|
||||
() => (audioTrack ? volumeBands : new Array(_barCount).fill(0)),
|
||||
[audioTrack, volumeBands, _barCount]
|
||||
);
|
||||
|
||||
const dotSize = useMemo(() => {
|
||||
return (distanceFromCenter * Math.PI) / _barCount;
|
||||
}, [distanceFromCenter, _barCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(AgentAudioVisualizerRadialVariants({ size }), 'relative', className)}
|
||||
{...props}
|
||||
>
|
||||
{bands.map((band, idx) => {
|
||||
const angle = (idx / _barCount) * Math.PI * 2;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${_barCount}-${idx}`}
|
||||
data-lk-state={state}
|
||||
className="absolute top-1/2 left-1/2 h-1 w-1 -translate-x-1/2 -translate-y-1/2"
|
||||
style={{
|
||||
transformOrigin: 'center',
|
||||
transform: `rotate(${angle}rad) translateY(${distanceFromCenter}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-lk-index={idx}
|
||||
data-lk-highlighted={highlightedIndices.includes(idx)}
|
||||
style={{
|
||||
width: dotSize,
|
||||
minHeight: dotSize,
|
||||
height: state === 'speaking' ? `${dotSize * 10 * band}px` : 0,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import { type Ref } from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { type MotionProps, motion } from 'motion/react';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const motionAnimationProps = {
|
||||
variants: {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.1,
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: 'linear' as const,
|
||||
},
|
||||
},
|
||||
visible: {
|
||||
opacity: [0.5, 1],
|
||||
scale: [1, 1.2],
|
||||
transition: {
|
||||
type: 'spring' as const,
|
||||
bounce: 0,
|
||||
duration: 0.5,
|
||||
repeat: Infinity,
|
||||
repeatType: 'mirror' as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
initial: 'hidden',
|
||||
animate: 'visible',
|
||||
exit: 'hidden',
|
||||
};
|
||||
|
||||
const agentChatIndicatorVariants = cva('bg-muted-foreground inline-block size-2.5 rounded-full', {
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'size-2.5',
|
||||
md: 'size-4',
|
||||
lg: 'size-6',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Props for the AgentChatIndicator component.
|
||||
*/
|
||||
export interface AgentChatIndicatorProps extends MotionProps {
|
||||
/**
|
||||
* The size of the indicator dot.
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/**
|
||||
* Additional CSS class names to apply to the indicator.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Allows getting a ref to the component instance.\nOnce the component unmounts, React will set `ref.current` to `null`\n(or call the ref with `null` if you passed a callback ref).\n@see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
|
||||
*/
|
||||
ref?: Ref<HTMLSpanElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An animated indicator that shows the agent is processing or thinking.
|
||||
* Displays as a pulsing dot, typically used in chat interfaces.
|
||||
*
|
||||
* @extends ComponentProps<'span'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* {agentState === 'thinking' && <AgentChatIndicator size="md" />}
|
||||
* ```
|
||||
*/
|
||||
export function AgentChatIndicator({
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: AgentChatIndicatorProps & VariantProps<typeof agentChatIndicatorVariants>) {
|
||||
return (
|
||||
<motion.span
|
||||
{...motionAnimationProps}
|
||||
transition={{ duration: 0.1, ease: 'linear' as const }}
|
||||
className={cn(agentChatIndicatorVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import { type AgentState, type ReceivedMessage } from '@livekit/components-react';
|
||||
import { AgentChatIndicator } from '@/components/agents-ui/agent-chat-indicator';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message';
|
||||
|
||||
/**
|
||||
* Props for the AgentChatTranscript component.
|
||||
*/
|
||||
export interface AgentChatTranscriptProps {
|
||||
/**
|
||||
* The current state of the agent. When 'thinking', displays a loading indicator.
|
||||
*/
|
||||
agentState?: AgentState;
|
||||
/**
|
||||
* Array of messages to display in the transcript.
|
||||
* @defaultValue []
|
||||
*/
|
||||
messages?: ReceivedMessage[];
|
||||
/**
|
||||
* Additional CSS class names to apply to the conversation container.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat transcript component that displays a conversation between the user and agent.
|
||||
* Shows messages with timestamps and origin indicators, plus a thinking indicator
|
||||
* when the agent is processing.
|
||||
*
|
||||
* @extends ComponentProps<'div'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentChatTranscript
|
||||
* agentState={agentState}
|
||||
* messages={chatMessages}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentChatTranscript({
|
||||
agentState,
|
||||
messages = [],
|
||||
className,
|
||||
...props
|
||||
}: AgentChatTranscriptProps) {
|
||||
return (
|
||||
<Conversation className={className} {...props}>
|
||||
<ConversationContent>
|
||||
{messages.map((receivedMessage) => {
|
||||
const { id, timestamp, from, message } = receivedMessage;
|
||||
const locale = navigator?.language ?? 'en-US';
|
||||
const messageOrigin = from?.isLocal ? 'user' : 'assistant';
|
||||
const time = new Date(timestamp);
|
||||
const title = time.toLocaleTimeString(locale, { timeStyle: 'full' });
|
||||
|
||||
return (
|
||||
<Message key={id} title={title} from={messageOrigin}>
|
||||
<MessageContent>
|
||||
<MessageResponse>{message}</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
<AnimatePresence>
|
||||
{agentState === 'thinking' && <AgentChatIndicator size="sm" />}
|
||||
</AnimatePresence>
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
);
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
'use client';
|
||||
|
||||
import { type ComponentProps, useEffect, useRef, useState } from 'react';
|
||||
import { Track } from 'livekit-client';
|
||||
import { Loader, MessageSquareTextIcon, SendHorizontal } from 'lucide-react';
|
||||
import { motion } from 'motion/react';
|
||||
import { useChat } from '@livekit/components-react';
|
||||
import { AgentDisconnectButton } from '@/components/agents-ui/agent-disconnect-button';
|
||||
import { AgentTrackControl } from '@/components/agents-ui/agent-track-control';
|
||||
import {
|
||||
AgentTrackToggle,
|
||||
agentTrackToggleVariants,
|
||||
} from '@/components/agents-ui/agent-track-toggle';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Toggle } from '@/components/ui/toggle';
|
||||
import {
|
||||
type UseInputControlsProps,
|
||||
useInputControls,
|
||||
usePublishPermissions,
|
||||
} from '@/hooks/agents-ui/use-agent-control-bar';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const TOGGLE_VARIANT_1 = [
|
||||
'[&_[data-state=off]]:bg-accent [&_[data-state=off]]:hover:bg-foreground/10',
|
||||
'[&_[data-state=off]_~_button]:bg-accent [&_[data-state=off]_~_button]:hover:bg-foreground/10',
|
||||
'[&_[data-state=off]]:border-border [&_[data-state=off]]:hover:border-foreground/12',
|
||||
'[&_[data-state=off]_~_button]:border-border [&_[data-state=off]_~_button]:hover:border-foreground/12',
|
||||
'[&_[data-state=off]]:text-destructive [&_[data-state=off]]:hover:text-destructive [&_[data-state=off]]:focus:text-destructive',
|
||||
'[&_[data-state=off]]:focus-visible:ring-foreground/12 [&_[data-state=off]]:focus-visible:border-ring',
|
||||
'dark:[&_[data-state=off]_~_button]:bg-accent dark:[&_[data-state=off]_~_button:hover]:bg-foreground/10',
|
||||
];
|
||||
|
||||
const TOGGLE_VARIANT_2 = [
|
||||
'data-[state=off]:bg-accent data-[state=off]:hover:bg-foreground/10',
|
||||
'data-[state=off]:border-border data-[state=off]:hover:border-foreground/12',
|
||||
'data-[state=off]:focus-visible:border-ring data-[state=off]:focus-visible:ring-foreground/12',
|
||||
'data-[state=off]:text-foreground data-[state=off]:hover:text-foreground data-[state=off]:focus:text-foreground',
|
||||
'data-[state=on]:bg-blue-500/20 data-[state=on]:hover:bg-blue-500/30',
|
||||
'data-[state=on]:border-blue-700/10 data-[state=on]:text-blue-700 data-[state=on]:ring-blue-700/30',
|
||||
'data-[state=on]:focus-visible:border-blue-700/50',
|
||||
'dark:data-[state=on]:bg-blue-500/20 dark:data-[state=on]:text-blue-300',
|
||||
];
|
||||
|
||||
const MOTION_PROPS = {
|
||||
variants: {
|
||||
hidden: {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
visible: {
|
||||
height: 'auto',
|
||||
opacity: 1,
|
||||
marginBottom: 12,
|
||||
},
|
||||
},
|
||||
initial: 'hidden',
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: 'easeOut',
|
||||
},
|
||||
};
|
||||
|
||||
interface AgentChatInputProps {
|
||||
chatOpen: boolean;
|
||||
onSend?: (message: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AgentChatInput({ chatOpen, onSend = async () => {}, className }: AgentChatInputProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [message, setMessage] = useState<string>('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
await onSend(message);
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = isSending || message.trim().length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (chatOpen) return;
|
||||
// when not disabled refocus on input
|
||||
inputRef.current?.focus();
|
||||
}, [chatOpen]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className={cn('mb-3 flex grow items-end gap-2 rounded-md pl-1 text-sm', className)}
|
||||
>
|
||||
<textarea
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
disabled={!chatOpen}
|
||||
placeholder="Type something..."
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="field-sizing-content max-h-16 min-h-8 flex-1 py-2 [scrollbar-width:thin] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
variant={isDisabled ? 'secondary' : 'default'}
|
||||
title={isSending ? 'Sending...' : 'Send'}
|
||||
className="self-end disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSending ? <Loader className="animate-spin" /> : <SendHorizontal />}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for which controls to display in the AgentControlBar.
|
||||
*/
|
||||
export interface AgentControlBarControls {
|
||||
/**
|
||||
* Whether to show the leave/disconnect button.
|
||||
* @defaultValue true
|
||||
*/
|
||||
leave?: boolean;
|
||||
/**
|
||||
* Whether to show the camera toggle control.
|
||||
* @defaultValue true (if camera publish permission is granted)
|
||||
*/
|
||||
camera?: boolean;
|
||||
/**
|
||||
* Whether to show the microphone toggle control.
|
||||
* @defaultValue true (if microphone publish permission is granted)
|
||||
*/
|
||||
microphone?: boolean;
|
||||
/**
|
||||
* Whether to show the screen share toggle control.
|
||||
* @defaultValue true (if screen share publish permission is granted)
|
||||
*/
|
||||
screenShare?: boolean;
|
||||
/**
|
||||
* Whether to show the chat toggle control.
|
||||
* @defaultValue true (if data publish permission is granted)
|
||||
*/
|
||||
chat?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentControlBarProps extends UseInputControlsProps {
|
||||
/**
|
||||
* The visual style of the control bar.
|
||||
* @default 'default'
|
||||
*/
|
||||
variant?: 'default' | 'outline' | 'livekit';
|
||||
/**
|
||||
* This takes an object with the following keys: `leave`, `microphone`, `screenShare`, `camera`, `chat`.
|
||||
* Each key maps to a boolean value that determines whether the control is displayed.
|
||||
*
|
||||
* @default
|
||||
* {
|
||||
* leave: true,
|
||||
* microphone: true,
|
||||
* screenShare: true,
|
||||
* camera: true,
|
||||
* chat: true,
|
||||
* }
|
||||
*/
|
||||
controls?: AgentControlBarControls;
|
||||
/**
|
||||
* Whether to save user choices.
|
||||
* @default true
|
||||
*/
|
||||
saveUserChoices?: boolean;
|
||||
/**
|
||||
* Whether the agent is connected to a session.
|
||||
* @default false
|
||||
*/
|
||||
isConnected?: boolean;
|
||||
/**
|
||||
* Whether the chat input interface is open.
|
||||
* @default false
|
||||
*/
|
||||
isChatOpen?: boolean;
|
||||
/**
|
||||
* The callback for when the user disconnects.
|
||||
*/
|
||||
onDisconnect?: () => void;
|
||||
/**
|
||||
* The callback for when the chat is opened or closed.
|
||||
*/
|
||||
onIsChatOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* The callback for when a device error occurs.
|
||||
*/
|
||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A control bar specifically designed for voice assistant interfaces.
|
||||
* Provides controls for microphone, camera, screen share, chat, and disconnect.
|
||||
* Includes an expandable chat input for text-based interaction with the agent.
|
||||
*
|
||||
* @extends ComponentProps<'div'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentControlBar
|
||||
* variant="livekit"
|
||||
* isConnected={true}
|
||||
* onDisconnect={() => handleDisconnect()}
|
||||
* controls={{
|
||||
* microphone: true,
|
||||
* camera: true,
|
||||
* screenShare: false,
|
||||
* chat: true,
|
||||
* leave: true,
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentControlBar({
|
||||
variant = 'default',
|
||||
controls,
|
||||
isChatOpen = false,
|
||||
isConnected = false,
|
||||
saveUserChoices = true,
|
||||
onDisconnect,
|
||||
onDeviceError,
|
||||
onIsChatOpenChange,
|
||||
className,
|
||||
...props
|
||||
}: AgentControlBarProps & ComponentProps<'div'>) {
|
||||
const { send } = useChat();
|
||||
const publishPermissions = usePublishPermissions();
|
||||
const [isChatOpenUncontrolled, setIsChatOpenUncontrolled] = useState(isChatOpen);
|
||||
const {
|
||||
micTrackRef,
|
||||
cameraToggle,
|
||||
microphoneToggle,
|
||||
screenShareToggle,
|
||||
handleAudioDeviceChange,
|
||||
handleVideoDeviceChange,
|
||||
handleMicrophoneDeviceSelectError,
|
||||
handleCameraDeviceSelectError,
|
||||
} = useInputControls({ onDeviceError, saveUserChoices });
|
||||
|
||||
const handleSendMessage = async (message: string) => {
|
||||
await send(message);
|
||||
};
|
||||
|
||||
const visibleControls = {
|
||||
leave: controls?.leave ?? true,
|
||||
microphone: controls?.microphone ?? publishPermissions.microphone,
|
||||
screenShare: controls?.screenShare ?? publishPermissions.screenShare,
|
||||
camera: controls?.camera ?? publishPermissions.camera,
|
||||
chat: controls?.chat ?? publishPermissions.data,
|
||||
};
|
||||
|
||||
const isEmpty = Object.values(visibleControls).every((value) => !value);
|
||||
|
||||
if (isEmpty) {
|
||||
console.warn('AgentControlBar: `visibleControls` contains only false values.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Voice assistant controls"
|
||||
className={cn(
|
||||
'bg-background border-input/50 dark:border-muted flex flex-col border p-3 drop-shadow-md/3',
|
||||
variant === 'livekit' ? 'rounded-[31px]' : 'rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<motion.div
|
||||
{...MOTION_PROPS}
|
||||
inert={!(isChatOpen || isChatOpenUncontrolled)}
|
||||
animate={isChatOpen || isChatOpenUncontrolled ? 'visible' : 'hidden'}
|
||||
className="border-input/50 flex w-full items-start overflow-hidden border-b"
|
||||
>
|
||||
<AgentChatInput
|
||||
chatOpen={isChatOpen || isChatOpenUncontrolled}
|
||||
onSend={handleSendMessage}
|
||||
className={cn(variant === 'livekit' && '[&_button]:rounded-full')}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<div className="flex grow gap-1">
|
||||
{/* Toggle Microphone */}
|
||||
{visibleControls.microphone && (
|
||||
<AgentTrackControl
|
||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||
kind="audioinput"
|
||||
aria-label="Toggle microphone"
|
||||
source={Track.Source.Microphone}
|
||||
pressed={microphoneToggle.enabled}
|
||||
disabled={microphoneToggle.pending}
|
||||
audioTrack={micTrackRef}
|
||||
onPressedChange={microphoneToggle.toggle}
|
||||
onActiveDeviceChange={handleAudioDeviceChange}
|
||||
onMediaDeviceError={handleMicrophoneDeviceSelectError}
|
||||
className={cn(
|
||||
variant === 'livekit' && [
|
||||
TOGGLE_VARIANT_1,
|
||||
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
|
||||
]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toggle Camera */}
|
||||
{visibleControls.camera && (
|
||||
<AgentTrackControl
|
||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||
kind="videoinput"
|
||||
aria-label="Toggle camera"
|
||||
source={Track.Source.Camera}
|
||||
pressed={cameraToggle.enabled}
|
||||
pending={cameraToggle.pending}
|
||||
disabled={cameraToggle.pending}
|
||||
onPressedChange={cameraToggle.toggle}
|
||||
onMediaDeviceError={handleCameraDeviceSelectError}
|
||||
onActiveDeviceChange={handleVideoDeviceChange}
|
||||
className={cn(
|
||||
variant === 'livekit' && [
|
||||
TOGGLE_VARIANT_1,
|
||||
'rounded-full [&_button:first-child]:rounded-l-full [&_button:last-child]:rounded-r-full',
|
||||
]
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toggle Screen Share */}
|
||||
{visibleControls.screenShare && (
|
||||
<AgentTrackToggle
|
||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||
aria-label="Toggle screen share"
|
||||
source={Track.Source.ScreenShare}
|
||||
pressed={screenShareToggle.enabled}
|
||||
disabled={screenShareToggle.pending}
|
||||
onPressedChange={screenShareToggle.toggle}
|
||||
className={cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full'])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toggle Transcript */}
|
||||
{visibleControls.chat && (
|
||||
<Toggle
|
||||
variant={variant === 'outline' ? 'outline' : 'default'}
|
||||
pressed={isChatOpen || isChatOpenUncontrolled}
|
||||
aria-label="Toggle transcript"
|
||||
onPressedChange={(state) => {
|
||||
if (!onIsChatOpenChange) setIsChatOpenUncontrolled(state);
|
||||
else onIsChatOpenChange(state);
|
||||
}}
|
||||
className={agentTrackToggleVariants({
|
||||
variant: variant === 'outline' ? 'outline' : 'default',
|
||||
className: cn(variant === 'livekit' && [TOGGLE_VARIANT_2, 'rounded-full']),
|
||||
})}
|
||||
>
|
||||
<MessageSquareTextIcon />
|
||||
</Toggle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disconnect */}
|
||||
{visibleControls.leave && (
|
||||
<AgentDisconnectButton
|
||||
onClick={onDisconnect}
|
||||
disabled={!isConnected}
|
||||
className={cn(
|
||||
variant === 'livekit' &&
|
||||
'bg-destructive/10 dark:bg-destructive/10 text-destructive hover:bg-destructive/20 dark:hover:bg-destructive/20 focus:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/4 rounded-full font-mono text-xs font-bold tracking-wider'
|
||||
)}
|
||||
>
|
||||
<span className="hidden md:inline">END CALL</span>
|
||||
<span className="inline md:hidden">END</span>
|
||||
</AgentDisconnectButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { PhoneOffIcon } from 'lucide-react';
|
||||
import { useSessionContext } from '@livekit/components-react';
|
||||
import { Button, buttonVariants } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
export interface AgentDisconnectButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AgentDisconnectButton({
|
||||
icon,
|
||||
size = 'default',
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: AgentDisconnectButtonProps) {
|
||||
const { end } = useSessionContext();
|
||||
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
end();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="destructive" size={size} onClick={handleClick} {...props}>
|
||||
{icon ?? <PhoneOffIcon />}
|
||||
{children ?? <span className={cn(size?.includes('icon') && 'sr-only')}>END CALL</span>}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { Room } from 'livekit-client';
|
||||
import {
|
||||
RoomAudioRenderer,
|
||||
type RoomAudioRendererProps,
|
||||
SessionProvider,
|
||||
type SessionProviderProps,
|
||||
type UseSessionReturn,
|
||||
} from '@livekit/components-react';
|
||||
|
||||
/**
|
||||
* Props for the AgentSessionProvider component.
|
||||
* Combines SessionProviderProps with RoomAudioRendererProps.
|
||||
*/
|
||||
export type AgentSessionProviderProps = SessionProviderProps &
|
||||
RoomAudioRendererProps & {
|
||||
/**
|
||||
* The room to provide.
|
||||
*/
|
||||
room?: Room;
|
||||
/**
|
||||
* The volume to set for the audio renderer.
|
||||
*/
|
||||
volume?: number;
|
||||
/**
|
||||
* Whether to mute the audio renderer.
|
||||
*/
|
||||
muted?: boolean;
|
||||
/**
|
||||
* The session to provide.
|
||||
*/
|
||||
session: UseSessionReturn;
|
||||
/**
|
||||
* The children to render.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* A provider component for agent sessions that wraps SessionProvider
|
||||
* and includes RoomAudioRenderer for audio playback.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentSessionProvider session={agentSession}>
|
||||
* <AgentControlBar />
|
||||
* <AgentChatTranscript />
|
||||
* </AgentSessionProvider>
|
||||
* ```
|
||||
*/
|
||||
export function AgentSessionProvider({
|
||||
session,
|
||||
children,
|
||||
...roomAudioRendererProps
|
||||
}: AgentSessionProviderProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
{children}
|
||||
<RoomAudioRenderer {...roomAudioRendererProps} />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';
|
||||
import {
|
||||
type TrackReferenceOrPlaceholder,
|
||||
useMaybeRoomContext,
|
||||
useMediaDeviceSelect,
|
||||
} from '@livekit/components-react';
|
||||
import { AgentAudioVisualizerBar } from '@/components/agents-ui/agent-audio-visualizer-bar';
|
||||
import { AgentTrackToggle } from '@/components/agents-ui/agent-track-toggle';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { toggleVariants } from '@/components/ui/toggle';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
const selectVariants = cva(
|
||||
[
|
||||
'rounded-l-none shadow-none pl-2 ',
|
||||
'text-foreground hover:text-muted-foreground',
|
||||
'peer-data-[state=on]/track:bg-muted peer-data-[state=on]/track:hover:bg-foreground/10',
|
||||
'peer-data-[state=off]/track:text-destructive',
|
||||
'peer-data-[state=off]/track:focus-visible:border-destructive peer-data-[state=off]/track:focus-visible:ring-destructive/30',
|
||||
'[&_svg]:opacity-100',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'border-none',
|
||||
'peer-data-[state=off]/track:bg-destructive/10',
|
||||
'peer-data-[state=off]/track:hover:bg-destructive/15',
|
||||
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
|
||||
|
||||
'dark:peer-data-[state=on]/track:bg-accent',
|
||||
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
|
||||
'dark:peer-data-[state=off]/track:bg-destructive/10',
|
||||
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
|
||||
],
|
||||
outline: [
|
||||
'border border-l-0',
|
||||
'peer-data-[state=off]/track:border-destructive/20',
|
||||
'peer-data-[state=off]/track:bg-destructive/10',
|
||||
'peer-data-[state=off]/track:hover:bg-destructive/15',
|
||||
'peer-data-[state=off]/track:[&_svg]:!text-destructive',
|
||||
'peer-data-[state=on]/track:hover:border-foreground/12',
|
||||
|
||||
'dark:peer-data-[state=off]/track:bg-destructive/10',
|
||||
'dark:peer-data-[state=off]/track:hover:bg-destructive/15',
|
||||
'dark:peer-data-[state=on]/track:bg-accent',
|
||||
'dark:peer-data-[state=on]/track:hover:bg-foreground/10',
|
||||
],
|
||||
},
|
||||
size: {
|
||||
default: 'w-[180px]',
|
||||
sm: 'w-auto',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for the TrackDeviceSelect component. */
|
||||
type TrackDeviceSelectProps = React.ComponentProps<typeof SelectTrigger> &
|
||||
VariantProps<typeof selectVariants> & {
|
||||
/**
|
||||
* The size of the select.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
size?: 'default' | 'sm';
|
||||
/**
|
||||
* The variant of the select.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
variant?: 'default' | 'outline' | null;
|
||||
/**
|
||||
* The type of media device (audioinput or videoinput).
|
||||
*/
|
||||
kind: MediaDeviceKind;
|
||||
/**
|
||||
* The track source to control (Microphone, Camera, or ScreenShare).
|
||||
*/
|
||||
track?: LocalAudioTrack | LocalVideoTrack | undefined;
|
||||
/**
|
||||
* Whether to request permissions for the media device.
|
||||
*/
|
||||
requestPermissions?: boolean;
|
||||
/**
|
||||
* Callback when a media device error occurs.
|
||||
*/
|
||||
onMediaDeviceError?: (error: Error) => void;
|
||||
/**
|
||||
* Callback when the device list changes.
|
||||
*/
|
||||
onDeviceListChange?: (devices: MediaDeviceInfo[]) => void;
|
||||
/**
|
||||
* Callback when the active device changes.
|
||||
*/
|
||||
onActiveDeviceChange?: (deviceId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A select component for selecting a media device.
|
||||
*
|
||||
* @extends ComponentProps<'button'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TrackDeviceSelect
|
||||
* size="sm"
|
||||
* variant="outline"
|
||||
* kind="audioinput"
|
||||
* track={micTrackRef}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
function TrackDeviceSelect({
|
||||
kind,
|
||||
track,
|
||||
size = 'default',
|
||||
variant = 'default',
|
||||
className,
|
||||
requestPermissions = false,
|
||||
onMediaDeviceError,
|
||||
onDeviceListChange,
|
||||
onActiveDeviceChange,
|
||||
...props
|
||||
}: TrackDeviceSelectProps) {
|
||||
const room = useMaybeRoomContext();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [requestPermissionsState, setRequestPermissionsState] = useState(requestPermissions);
|
||||
const { devices, activeDeviceId, setActiveMediaDevice } = useMediaDeviceSelect({
|
||||
room,
|
||||
kind,
|
||||
track,
|
||||
requestPermissions: requestPermissionsState,
|
||||
onError: onMediaDeviceError,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
onDeviceListChange?.(devices);
|
||||
}, [devices, onDeviceListChange]);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setOpen(open);
|
||||
if (open) {
|
||||
setRequestPermissionsState(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActiveDeviceChange = (deviceId: string) => {
|
||||
setActiveMediaDevice(deviceId);
|
||||
onActiveDeviceChange?.(deviceId);
|
||||
};
|
||||
|
||||
const filteredDevices = useMemo(() => devices.filter((d) => d.deviceId !== ''), [devices]);
|
||||
|
||||
if (filteredDevices.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
open={open}
|
||||
value={activeDeviceId}
|
||||
onOpenChange={handleOpenChange}
|
||||
onValueChange={handleActiveDeviceChange}
|
||||
>
|
||||
<SelectTrigger className={cn(selectVariants({ size, variant }), className)} {...props}>
|
||||
{size !== 'sm' && (
|
||||
<SelectValue className="font-mono text-sm" placeholder={`Select a ${kind}`} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{filteredDevices.map((device) => (
|
||||
<SelectItem key={device.deviceId} value={device.deviceId} className="font-mono text-xs">
|
||||
{device.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AgentTrackControl component.
|
||||
*/
|
||||
export type AgentTrackControlProps = VariantProps<typeof toggleVariants> & {
|
||||
/**
|
||||
* The type of media device (audioinput or videoinput).
|
||||
*/
|
||||
kind: MediaDeviceKind;
|
||||
/**
|
||||
* The track source to control (Microphone, Camera, or ScreenShare).
|
||||
*/
|
||||
source: 'camera' | 'microphone' | 'screen_share';
|
||||
/**
|
||||
* Whether the track is currently enabled/published.
|
||||
*/
|
||||
pressed?: boolean;
|
||||
/**
|
||||
* Whether the control is in a pending/loading state.
|
||||
*/
|
||||
pending?: boolean;
|
||||
/**
|
||||
* Whether the control is disabled.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Additional CSS class names to apply to the container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The audio track reference for visualization (only for microphone).
|
||||
*/
|
||||
audioTrack?: TrackReferenceOrPlaceholder;
|
||||
/**
|
||||
* Callback when the pressed state changes.
|
||||
*/
|
||||
onPressedChange?: (pressed: boolean) => void;
|
||||
/**
|
||||
* Callback when a media device error occurs.
|
||||
*/
|
||||
onMediaDeviceError?: (error: Error) => void;
|
||||
/**
|
||||
* Callback when the active device changes.
|
||||
*/
|
||||
onActiveDeviceChange?: (deviceId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A combined track toggle and device selector control.
|
||||
* Includes a toggle button and a dropdown to select the active device.
|
||||
* For microphone tracks, displays an audio visualizer.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentTrackControl
|
||||
* kind="audioinput"
|
||||
* source={Track.Source.Microphone}
|
||||
* pressed={isMicEnabled}
|
||||
* audioTrack={micTrackRef}
|
||||
* onPressedChange={(pressed) => setMicEnabled(pressed)}
|
||||
* onActiveDeviceChange={(deviceId) => setMicDevice(deviceId)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentTrackControl({
|
||||
kind,
|
||||
variant = 'default',
|
||||
source,
|
||||
pressed,
|
||||
pending,
|
||||
disabled,
|
||||
className,
|
||||
audioTrack,
|
||||
onPressedChange,
|
||||
onMediaDeviceError,
|
||||
onActiveDeviceChange,
|
||||
}: AgentTrackControlProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-0 rounded-md',
|
||||
variant === 'outline' && 'shadow-xs [&_button]:shadow-none',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AgentTrackToggle
|
||||
variant={variant ?? 'default'}
|
||||
source={source}
|
||||
pressed={pressed}
|
||||
pending={pending}
|
||||
disabled={disabled}
|
||||
onPressedChange={onPressedChange}
|
||||
className="peer/track group/track focus:z-10 has-[.audiovisualizer]:w-auto has-[.audiovisualizer]:px-3 has-[~_button]:rounded-r-none has-[~_button]:border-r-0 has-[~_button]:pr-2 has-[~_button]:pl-3"
|
||||
>
|
||||
{audioTrack && (
|
||||
<AgentAudioVisualizerBar
|
||||
size="icon"
|
||||
barCount={3}
|
||||
state={pressed ? 'speaking' : 'disconnected'}
|
||||
audioTrack={pressed ? audioTrack : undefined}
|
||||
className="audiovisualizer flex h-6 w-auto items-center justify-center gap-0.5"
|
||||
>
|
||||
<span
|
||||
className={cn([
|
||||
'h-full w-0.5 origin-center',
|
||||
'group-data-[state=on]/track:bg-foreground group-data-[state=off]/track:bg-destructive',
|
||||
'data-lk-muted:bg-muted',
|
||||
])}
|
||||
/>
|
||||
</AgentAudioVisualizerBar>
|
||||
)}
|
||||
</AgentTrackToggle>
|
||||
{kind && (
|
||||
<TrackDeviceSelect
|
||||
size="sm"
|
||||
kind={kind}
|
||||
variant={variant}
|
||||
requestPermissions={false}
|
||||
onMediaDeviceError={onMediaDeviceError}
|
||||
onActiveDeviceChange={onActiveDeviceChange}
|
||||
className={cn([
|
||||
'relative',
|
||||
'before:bg-border before:absolute before:inset-y-0 before:left-0 before:my-2.5 before:w-px has-[~_button]:before:content-[""]',
|
||||
!pressed && 'before:bg-destructive/20',
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { type ComponentProps, Fragment } from 'react';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Track } from 'livekit-client';
|
||||
import {
|
||||
LoaderIcon,
|
||||
MicIcon,
|
||||
MicOffIcon,
|
||||
MonitorOffIcon,
|
||||
MonitorUpIcon,
|
||||
VideoIcon,
|
||||
VideoOffIcon,
|
||||
} from 'lucide-react';
|
||||
import { Toggle, toggleVariants } from '@/components/ui/toggle';
|
||||
import { cn } from '@/lib/shadcn/utils';
|
||||
|
||||
export const agentTrackToggleVariants = cva(['size-9'], {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive',
|
||||
'data-[state=off]:hover:bg-destructive/15',
|
||||
'data-[state=off]:focus-visible:ring-destructive/30',
|
||||
'data-[state=on]:bg-accent data-[state=on]:text-accent-foreground',
|
||||
'data-[state=on]:hover:bg-foreground/10',
|
||||
],
|
||||
outline: [
|
||||
'data-[state=off]:bg-destructive/10 data-[state=off]:text-destructive data-[state=off]:border-destructive/20',
|
||||
'data-[state=off]:hover:bg-destructive/15 data-[state=off]:hover:text-destructive',
|
||||
'data-[state=off]:focus:text-destructive',
|
||||
'data-[state=off]:focus-visible:border-destructive data-[state=off]:focus-visible:ring-destructive/30',
|
||||
'data-[state=on]:hover:bg-foreground/10 data-[state=on]:hover:border-foreground/12',
|
||||
'dark:data-[state=on]:hover:bg-foreground/10',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
function getSourceIcon(source: Track.Source, enabled: boolean, pending = false) {
|
||||
if (pending) {
|
||||
return LoaderIcon;
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case Track.Source.Microphone:
|
||||
return enabled ? MicIcon : MicOffIcon;
|
||||
case Track.Source.Camera:
|
||||
return enabled ? VideoIcon : VideoOffIcon;
|
||||
case Track.Source.ScreenShare:
|
||||
return enabled ? MonitorUpIcon : MonitorOffIcon;
|
||||
default:
|
||||
return Fragment;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the AgentTrackToggle component.
|
||||
*/
|
||||
export type AgentTrackToggleProps = VariantProps<typeof toggleVariants> &
|
||||
ComponentProps<'button'> & {
|
||||
/**
|
||||
* The variant of the toggle.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
variant?: 'default' | 'outline';
|
||||
/**
|
||||
* The track source to toggle (Microphone, Camera, or ScreenShare).
|
||||
*/
|
||||
source: 'camera' | 'microphone' | 'screen_share';
|
||||
/**
|
||||
* Whether the toggle is in a pending/loading state.
|
||||
* When true, displays a loading spinner icon.
|
||||
* @defaultValue false
|
||||
*/
|
||||
pending?: boolean;
|
||||
/**
|
||||
* Whether the toggle is currently pressed/enabled.
|
||||
* @defaultValue false
|
||||
*/
|
||||
pressed?: boolean;
|
||||
/**
|
||||
* The default pressed state when uncontrolled.
|
||||
* @defaultValue false
|
||||
*/
|
||||
defaultPressed?: boolean;
|
||||
/**
|
||||
* Callback fired when the pressed state changes.
|
||||
*/
|
||||
onPressedChange?: (pressed: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A toggle button for controlling track publishing state.
|
||||
* Displays appropriate icons based on the track source and state.
|
||||
*
|
||||
* @extends ComponentProps<'button'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <AgentTrackToggle
|
||||
* source={Track.Source.Microphone}
|
||||
* pressed={isMicEnabled}
|
||||
* onPressedChange={(pressed) => setMicEnabled(pressed)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AgentTrackToggle({
|
||||
size = 'default',
|
||||
variant = 'default',
|
||||
source,
|
||||
pending = false,
|
||||
pressed = false,
|
||||
defaultPressed = false,
|
||||
className,
|
||||
onPressedChange,
|
||||
...props
|
||||
}: AgentTrackToggleProps) {
|
||||
const IconComponent = getSourceIcon(source as Track.Source, pressed ?? false, pending);
|
||||
|
||||
return (
|
||||
<Toggle
|
||||
size={size}
|
||||
variant={variant}
|
||||
pressed={pressed}
|
||||
defaultPressed={defaultPressed}
|
||||
aria-label={`Toggle ${source}`}
|
||||
onPressedChange={onPressedChange}
|
||||
className={cn(
|
||||
agentTrackToggleVariants({
|
||||
variant: variant ?? 'default',
|
||||
className,
|
||||
})
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconComponent className={cn(pending && 'animate-spin')} />
|
||||
{props.children}
|
||||
</Toggle>
|
||||
);
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { type ComponentProps } from 'react';
|
||||
import { Room } from 'livekit-client';
|
||||
import { useEnsureRoom, useStartAudio } from '@livekit/components-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* Props for the StartAudioButton component.
|
||||
*/
|
||||
export interface StartAudioButtonProps extends ComponentProps<'button'> {
|
||||
/**
|
||||
* The size of the button.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
|
||||
/**
|
||||
* The variant of the button.
|
||||
* @defaultValue 'default'
|
||||
*/
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
/**
|
||||
* The LiveKit room instance. If not provided, uses the room from context.
|
||||
*/
|
||||
room?: Room;
|
||||
/**
|
||||
* The label text to display on the button.
|
||||
*/
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A button that allows users to start audio playback.
|
||||
* Required for browsers that block autoplay of audio.
|
||||
* Only renders when audio playback is blocked.
|
||||
*
|
||||
* @extends ComponentProps<'button'>
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <StartAudioButton label="Click to allow audio playback" />
|
||||
* ```
|
||||
*/
|
||||
export function StartAudioButton({
|
||||
size = 'default',
|
||||
variant = 'default',
|
||||
label,
|
||||
room,
|
||||
...props
|
||||
}: StartAudioButtonProps) {
|
||||
const roomEnsured = useEnsureRoom(room);
|
||||
const { mergedProps } = useStartAudio({ room: roomEnsured, props });
|
||||
|
||||
return (
|
||||
<Button size={size} variant={variant} {...mergedProps}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user