diff --git a/app/components/AiButton.tsx b/app/components/AiButton.tsx new file mode 100644 index 00000000..7b9ec813 --- /dev/null +++ b/app/components/AiButton.tsx @@ -0,0 +1,66 @@ +import { useRoomContext } from '~/hooks/useRoomContext' +import type { ClientMessage, User } from '~/types/Messages' +import { AiPushToTalkButtion } from './AiPushToTalkButton' +import { Button } from './Button' +import { Trigger } from './Dialog' +import { InviteAiDialog } from './InviteAiDialog' +import { RecordAiVoiceActivity } from './RecordAiVoiceActivity' + +function RemoveAiButton() { + const { + room: { websocket }, + } = useRoomContext() + return ( + + ) +} + +export function AiButton(props: { recordActivity: (user: User) => void }) { + const { + room: { + roomState: { + ai: { connectionPending, error }, + users, + }, + }, + } = useRoomContext() + + const aiUser = users.find((u) => u.id === 'ai') + + return ( + <> + {error && {error}} + {aiUser ? ( + <> + + + + + ) : ( + + + + + + )} + + ) +} diff --git a/app/components/AiPushToTalkButton.tsx b/app/components/AiPushToTalkButton.tsx new file mode 100644 index 00000000..de25741b --- /dev/null +++ b/app/components/AiPushToTalkButton.tsx @@ -0,0 +1,137 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { switchMap } from 'rxjs' +import { useStateObservable, useSubscribedState } from '~/hooks/rxjsHooks' +import { useRoomContext } from '~/hooks/useRoomContext' +import type { ClientMessage } from '~/types/Messages' +import { playSound } from '~/utils/playSound' +import { inaudibleAudioTrack$ } from '~/utils/rxjs/inaudibleAudioTrack$' +import { Button } from './Button' + +function useButtonIsHeldDown({ + key, + disabled, +}: { + key: string + disabled: boolean +}) { + const [keyIsHeldDown, setKeyIsHeldDown] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + const button = buttonRef.current + let timeout = -1 + const setTrue = () => { + if (!disabled) { + setKeyIsHeldDown(true) + clearTimeout(timeout) + } + } + const setFalse = () => { + timeout = window.setTimeout(() => { + setKeyIsHeldDown(false) + }, 200) + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === key.toLowerCase()) { + setTrue() + } + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === key.toLowerCase()) { + setFalse() + } + } + + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + document.addEventListener('blur', setFalse) + button?.addEventListener('pointerdown', setTrue) + button?.addEventListener('pointerup', setFalse) + + return () => { + clearTimeout(timeout) + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + document.removeEventListener('blur', setFalse) + button?.removeEventListener('pointerdown', setTrue) + button?.removeEventListener('pointerup', setFalse) + } + }, [disabled, key]) + + return [keyIsHeldDown, buttonRef] as const +} + +export function AiPushToTalkButtion() { + const { + peer, + room: { + websocket, + roomState: { + ai: { controllingUser }, + }, + }, + userMedia: { turnMicOn, publicAudioTrack$ }, + } = useRoomContext() + const hasControl = controllingUser === websocket.id + const disabled = !hasControl && controllingUser !== undefined + const [holdingTalkButton, talkButtonRef] = useButtonIsHeldDown({ + key: 'a', + disabled, + }) + + const holdingTalkButton$ = useStateObservable(holdingTalkButton) + const audioTrack$ = useMemo( + () => + holdingTalkButton$.pipe( + switchMap((talking) => + talking ? publicAudioTrack$ : inaudibleAudioTrack$ + ) + ), + [holdingTalkButton$, publicAudioTrack$] + ) + + const pushedAiAudioTrack$ = useMemo( + () => peer.pushTrack(audioTrack$), + [audioTrack$, peer] + ) + + const pushedAiAudioTrack = useSubscribedState(pushedAiAudioTrack$) + + useEffect(() => { + if (holdingTalkButton && pushedAiAudioTrack) { + turnMicOn() + console.log('🤖 Requesting ai control') + websocket.send( + JSON.stringify({ + type: 'requestAiControl', + track: pushedAiAudioTrack, + } satisfies ClientMessage) + ) + } else { + console.log('🤖 Relinquishing ai control!') + websocket.send( + JSON.stringify({ + type: 'relenquishAiControl', + } satisfies ClientMessage) + ) + } + }, [holdingTalkButton, pushedAiAudioTrack, turnMicOn, websocket]) + + useEffect(() => { + if (controllingUser !== undefined) { + playSound('aiReady') + } + }, [controllingUser]) + + return ( + + ) +} diff --git a/app/components/Button.tsx b/app/components/Button.tsx index 67e872fc..c23ae586 100644 --- a/app/components/Button.tsx +++ b/app/components/Button.tsx @@ -6,23 +6,23 @@ import { cn } from '~/utils/style' const displayTypeMap = { primary: [ 'text-white', - 'bg-orange-500 hover:bg-orange-600', - 'border-orange-500 hover:border-orange-600', + 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700 active:bg-orange-800', + 'border-orange-500 hover:border-orange-600 active:border-orange-700 active:border-orange-800', ], secondary: [ 'text-zinc-900 dark:text-zinc-100', - 'bg-zinc-200 hover:bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600', + 'bg-zinc-200 hover:bg-zinc-300 dark:bg-zinc-700 dark:hover:bg-zinc-600 active:bg-zinc-400 dark:active:bg-zinc-700', 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600', ], ghost: [ - 'text-white hover:text-zinc-900', + 'text-white dark:text-zinc-800 hover:text-zinc-900', 'bg-transparent hover:bg-white', 'border-transparent hover:border-white', ], danger: [ 'text-white', - 'bg-red-600 hover:bg-red-700', - 'border-red-600 hover:border-red-700', + 'bg-red-600 hover:bg-red-700 active:bg-red-800', + 'border-red-600 hover:border-red-700 active:border-red-800', ], } diff --git a/app/components/InviteAiDialog.tsx b/app/components/InviteAiDialog.tsx new file mode 100644 index 00000000..cdd783ae --- /dev/null +++ b/app/components/InviteAiDialog.tsx @@ -0,0 +1,87 @@ +import { useSearchParams } from '@remix-run/react' +import { useState, type ReactNode } from 'react' +import { useRoomContext } from '~/hooks/useRoomContext' +import type { ClientMessage } from '~/types/Messages' +import { Button } from './Button' +import { Dialog, DialogContent, DialogOverlay, Portal } from './Dialog' + +export function InviteAiDialog(props: { children?: ReactNode }) { + const [open, setOpen] = useState(false) + + const { + room: { websocket }, + } = useRoomContext() + + const [params] = useSearchParams() + + const instructions = params.get('instructions') + const voice = params.get('voice') + + return ( + + {props.children} + + + +
{ + e.preventDefault() + websocket.send( + JSON.stringify({ + type: 'enableAi', + ...Object.fromEntries(new FormData(e.currentTarget)), + } satisfies ClientMessage) + ) + setOpen(false) + }} + > +
+
+ +
+ +
+