From 27c019c25ad3fddd345866c88c95f72f96dab0ef Mon Sep 17 00:00:00 2001 From: Jon Taylor Date: Thu, 16 May 2024 17:24:07 +0100 Subject: [PATCH] introducing standalone client UI for examples that use Daily prebuilt --- examples/client/.eslintrc.cjs | 18 + examples/client/.gitignore | 24 + examples/client/README.md | 30 + examples/client/env.example | 2 + examples/client/index.html | 17 + examples/client/package.json | 36 + examples/client/public/favicon.ico | Bin 0 -> 15086 bytes examples/client/src/App.tsx | 142 + examples/client/src/assets/logo.svg | 7 + .../src/components/AudioIndicator/index.tsx | 31 + .../AudioIndicator/styles.module.css | 15 + .../src/components/DeviceSelect/index.tsx | 127 + .../components/DeviceSelect/styles.module.css | 39 + .../client/src/components/Session/agent.tsx | 22 + .../client/src/components/Session/index.tsx | 60 + .../client/src/components/Session/status.tsx | 42 + .../src/components/Session/styles.module.css | 110 + .../src/components/UserMicBubble/index.tsx | 70 + .../UserMicBubble/styles.module.css | 96 + examples/client/src/components/alert.tsx | 27 + examples/client/src/components/button.tsx | 27 + examples/client/src/components/header.tsx | 22 + examples/client/src/components/logo.tsx | 46 + examples/client/src/global.css | 387 ++ examples/client/src/main.tsx | 18 + examples/client/src/utils/daily.js | 0 examples/client/src/vite-env.d.ts | 1 + examples/client/tsconfig.json | 25 + examples/client/tsconfig.node.json | 11 + examples/client/vite.config.ts | 7 + examples/client/yarn.lock | 3384 +++++++++++++++++ 31 files changed, 4843 insertions(+) create mode 100644 examples/client/.eslintrc.cjs create mode 100644 examples/client/.gitignore create mode 100644 examples/client/README.md create mode 100644 examples/client/env.example create mode 100644 examples/client/index.html create mode 100644 examples/client/package.json create mode 100644 examples/client/public/favicon.ico create mode 100644 examples/client/src/App.tsx create mode 100644 examples/client/src/assets/logo.svg create mode 100644 examples/client/src/components/AudioIndicator/index.tsx create mode 100644 examples/client/src/components/AudioIndicator/styles.module.css create mode 100644 examples/client/src/components/DeviceSelect/index.tsx create mode 100644 examples/client/src/components/DeviceSelect/styles.module.css create mode 100644 examples/client/src/components/Session/agent.tsx create mode 100644 examples/client/src/components/Session/index.tsx create mode 100644 examples/client/src/components/Session/status.tsx create mode 100644 examples/client/src/components/Session/styles.module.css create mode 100644 examples/client/src/components/UserMicBubble/index.tsx create mode 100644 examples/client/src/components/UserMicBubble/styles.module.css create mode 100644 examples/client/src/components/alert.tsx create mode 100644 examples/client/src/components/button.tsx create mode 100644 examples/client/src/components/header.tsx create mode 100644 examples/client/src/components/logo.tsx create mode 100644 examples/client/src/global.css create mode 100644 examples/client/src/main.tsx create mode 100644 examples/client/src/utils/daily.js create mode 100644 examples/client/src/vite-env.d.ts create mode 100644 examples/client/tsconfig.json create mode 100644 examples/client/tsconfig.node.json create mode 100644 examples/client/vite.config.ts create mode 100644 examples/client/yarn.lock diff --git a/examples/client/.eslintrc.cjs b/examples/client/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/examples/client/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/examples/client/.gitignore b/examples/client/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/examples/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 000000000..0d6babedd --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/examples/client/env.example b/examples/client/env.example new file mode 100644 index 000000000..a96939628 --- /dev/null +++ b/examples/client/env.example @@ -0,0 +1,2 @@ +VITE_SERVER_URL... #optional: if serving frontend independetely from backend (otherwise relative) +VITE_TRANSPORT_ROOM_URL=... #optional: use the same room each time (vs. creating a new one) diff --git a/examples/client/index.html b/examples/client/index.html new file mode 100644 index 000000000..d966a0a03 --- /dev/null +++ b/examples/client/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Pipecat Demo + + +
+ + + diff --git a/examples/client/package.json b/examples/client/package.json new file mode 100644 index 000000000..e0b20cf35 --- /dev/null +++ b/examples/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "pipecatdemo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@daily-co/daily-js": "^0.64.0", + "@daily-co/daily-react": "^0.19.0", + "class-variance-authority": "^0.7.0", + "lucide-react": "^0.378.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "i": "^0.3.7", + "npm": "^10.8.0", + "typescript": "^5.2.2", + "vite": "^5.2.0", + "vite-plugin-webfont-dl": "^3.9.4" + } +} \ No newline at end of file diff --git a/examples/client/public/favicon.ico b/examples/client/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e74bf0837d4b307a813467f1ee5c0af821a6e543 GIT binary patch literal 15086 zcmeI3X=oK!6vrnfMlC|(hA6E*muf(X`5>)TgBys}Li9s1ipB*hRzFn54MB|{N?j22 zgGCWr6%-LGD1v*W#UiO%wQ5zYag7%DeTg|ezpM8-Z+v<4@)ncVdf=ZsbLTAox%bVT zJLldwPMVYM`JLlI!C$9A|+lP*PzNe@y@>q%NP~v5K% zyMo_Us>vfI%(%2T)rhk{nMTZU@CI3~yY{;RH87IeGG= zj2=B&MvNE{g5kr5%hah;eZ_SU7ks7@nIzq&+p#7d(HS&zkYqO zs8gqoiRT@E&YU?TEn2h)ju+FWO|w+0EYY#!3%2;M;^t3G8=sOf6>ffV`}S?wzkk2c zMSuJD?b4}Jr?C8=K7DHXx^d&ilAD_=hYlU`b8mFe#fIMrKCHO;6O-6*0C0j^b>-p1 zhti`*kFfml_4x5)IdS5IT)A?^j9GsE8As5?23xnd`4f}a@rob+c>TNWKk#?A#_Quq z?azGc$&)8>;pNMh!JzN6{TYjkii)IHuU^u-ckh^hEk5kH{yzAZmzSG4+<*ZCg5#ha z4`VIy1$TVg`CBrc-;NzS%IVXmO`BP+O`0^3wr$(S1Z=Gz`T^g>pe`BwTeog)=7t#= z874*`5RjcacgoePSLO2M%hdul*y01e_+;KmjAZfWotU1UZu~cE)=ai+*%EyAVm-hI zzTk{+`>sz$`x65+Y}im%u3Q<_`^xXWc%R`5pVmKRviLLinm2D=LcfC6;ek9+-i&tJWIC3EJ?k)}EcQy#f+ zx1#4?DE_J?{36j{t+c@Mc9&YuJ^ZGqt`L6ZdZUv1t9op432DQ|b=3 zn>KBd#fulq(xppf$&w|qX3d(o)@oQIzz4qYiEmnJ$*y5nrR#USDm&_*J$ojz zX3dgbOR(3SESCIeg#?pZF#Q zvAjZFrC+~(CRf*>K?BoY?*3oDe!Zz9FMRRhMX$N3vpHjQ5qJONlRO|X!sT$tNs-?< zeE6_gS8U(Dy|ii5CiwfiaN&ZC8Z}Dh&z~Q%Hff2lUdc6d(6#aLfiKo7@l6cB{MVg3 zcg(se_rpC{si>%kXp3KTtYC`|)>ZKttyC31T6?RPi1^p8T{C-&7A;yNGiJgEbZT=OBZR`vZV|gHq5Labno8XjHQep8#itY z3$8KVqRW~Cw)ntTb^AXrFVEa_T3VWP?%esEJsq}>!f*gm1wLojY(|Ckd_E2(#&5y$WVAhd1ordE6Nnn3X`Yr6!jJSZw|Tf>I$2q zCn@5|;gWNxU<_tok9&?*6&ZBUeKT&G!F$i1JyKX$D3c~l3eL;J*)#7(2VHEeoIiiw z%o}#>*kSf2@g3scXT&je>|H{Kc?LFC*q1SA&>$H)bg0=M$@k2L4IAubb4(q3C(%I{ z8!OBKmkF$Nq{2X}PP^-8(!1("idle"); + const [error, setError] = useState(null); + //const [room, setRoom] = useState(null); + + async function start() { + if (!daily) return; + + setState("requesting_agent"); + + const serverUrl = + import.meta.env.VITE_SERVER_URL || import.meta.env.BASE_URL; + + // Request a bot to join your session + let data; + + try { + const res = await fetch(`${serverUrl}/start_bot`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + room_url: import.meta.env.VITE_TRANSPORT_ROOM_URL || null, + }), + }); + + data = await res.json(); + + if (!res.ok) { + setError(data.detail); + setState("error"); + } + } catch (e) { + setError( + `Unable to connect to the server at '${serverUrl}' - is it running?` + ); + setState("error"); + return; + } + + setState("connecting"); + + await daily.join({ + url: data.room_url, + token: data.user_token, + videoSource: false, + startAudioOff: true, + }); + + setState("connected"); + } + + /*async function leave() { + await daily?.leave(); + setState("finished"); + }*/ + + if (state === "error") { + return ( + + {error} + + ); + } + + if (state === "connected") { + return ; + } + + const status_text = { + configuring: "Start", + requesting_agent: "Requesting agent...", + connecting: "Connecting to agent...", + }; + + if (state !== "idle") { + return ( +
+
+

Configure your devices

+

+ Please configure your microphone and speakers below +

+ + +
+
+ ); + } + + return ( +
+
+

Pipecat Simple Chatbot

+

+ Please ensure you microphone and speakers are connected and ready to + go +

+ {import.meta.env.DEV && !import.meta.env.VITE_SERVER_URL && ( +
+ Warning: you have not set a server URL for local development. Please + set VITE_SERVER_URL in{" "} + .env.development.local +
+ )} + +
+
+ ); +} diff --git a/examples/client/src/assets/logo.svg b/examples/client/src/assets/logo.svg new file mode 100644 index 000000000..1b0e20494 --- /dev/null +++ b/examples/client/src/assets/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/client/src/components/AudioIndicator/index.tsx b/examples/client/src/components/AudioIndicator/index.tsx new file mode 100644 index 000000000..f2f1ca3c5 --- /dev/null +++ b/examples/client/src/components/AudioIndicator/index.tsx @@ -0,0 +1,31 @@ +import { + useAudioLevel, + useAudioTrack, + useLocalSessionId, +} from "@daily-co/daily-react"; +import { useCallback, useRef } from "react"; + +import styles from "./styles.module.css"; + +export const AudioIndicatorBar: React.FC = () => { + const localSessionId = useLocalSessionId(); + const audioTrack = useAudioTrack(localSessionId); + + const volRef = useRef(null); + + useAudioLevel( + audioTrack?.persistentTrack, + useCallback((volume) => { + if (volRef.current) + volRef.current.style.width = Math.max(2, volume * 100) + "%"; + }, []) + ); + + return ( +
+
+
+ ); +}; + +export default AudioIndicatorBar; diff --git a/examples/client/src/components/AudioIndicator/styles.module.css b/examples/client/src/components/AudioIndicator/styles.module.css new file mode 100644 index 000000000..79139129a --- /dev/null +++ b/examples/client/src/components/AudioIndicator/styles.module.css @@ -0,0 +1,15 @@ +.bar { + background: var(--color-gray-200); + height: 8px; + width: 100%; + border-radius: 999px; + overflow: hidden; + + > div { + background: var(--color-green-500); + height: 8px; + width: 0px; + border-radius: 999px; + transition: width 0.1s ease; + } +} diff --git a/examples/client/src/components/DeviceSelect/index.tsx b/examples/client/src/components/DeviceSelect/index.tsx new file mode 100644 index 000000000..9d05bdd5c --- /dev/null +++ b/examples/client/src/components/DeviceSelect/index.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect } from "react"; +import { DailyMeetingState } from "@daily-co/daily-js"; +import { useDaily, useDevices } from "@daily-co/daily-react"; +import { Mic, Speaker } from "lucide-react"; +import { AudioIndicatorBar } from "../AudioIndicator"; + +import styles from "./styles.module.css"; +import { Alert } from "../alert"; + +export function DeviceSelect() { + const daily = useDaily(); + const { + currentMic, + hasMicError, + micState, + microphones, + setMicrophone, + currentSpeaker, + speakers, + setSpeaker, + } = useDevices(); + + const handleMicrophoneChange = (value: string) => { + setMicrophone(value); + }; + + const handleSpeakerChange = (value: string) => { + setSpeaker(value); + }; + + useEffect(() => { + if (microphones.length > 0 || !daily || daily.isDestroyed()) return; + const meetingState = daily.meetingState(); + const meetingStatesBeforeJoin: DailyMeetingState[] = [ + "new", + "loading", + "loaded", + ]; + if (meetingStatesBeforeJoin.includes(meetingState)) { + daily.startCamera({ startVideoOff: true, startAudioOff: false }); + } + }, [daily, microphones]); + + return ( +
+ {hasMicError && ( + + {micState === "blocked" ? ( + <> + Please check your browser and system permissions. Make sure that + this app is allowed to access your microphone and refresh the + page. + + ) : micState === "in-use" ? ( + <> + Your microphone is being used by another app. Please close any + other apps using your microphone and restart this app. + + ) : micState === "not-found" ? ( + <> + No microphone seems to be connected. Please connect a microphone. + + ) : micState === "not-supported" ? ( + <> + This app is not supported on your device. Please update your + software or use a different device. + + ) : ( + <> + There seems to be an issue accessing your microphone. Try + restarting the app or consult a system administrator. + + )} + + )} + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+ ); +} + +export default DeviceSelect; diff --git a/examples/client/src/components/DeviceSelect/styles.module.css b/examples/client/src/components/DeviceSelect/styles.module.css new file mode 100644 index 000000000..231a51c70 --- /dev/null +++ b/examples/client/src/components/DeviceSelect/styles.module.css @@ -0,0 +1,39 @@ +.deviceSelect { + display: flex; + flex-flow: column wrap; + width: 100%; + gap: 1rem; +} + +.field { + display: flex; + flex-flow: column wrap; + align-items: flex-start; + gap: 0.5rem; +} + +.label { + font-weight: 600; + font-size: 0.875rem; +} + +.selectContainer { + position: relative; + width: 100%; + + > svg { + position: absolute; + size: 24px; + top: 0; + bottom: 0; + margin: auto 0; + left: 0.75rem; + color: var(--color-gray-400); + } +} + +.deviceSelectField { + width: 100%; + padding-left: 2.875rem; + margin-top: auto; +} diff --git a/examples/client/src/components/Session/agent.tsx b/examples/client/src/components/Session/agent.tsx new file mode 100644 index 000000000..cc7c6a715 --- /dev/null +++ b/examples/client/src/components/Session/agent.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import Status from "./status"; +import styles from "./styles.module.css"; + +import { useParticipantIds } from "@daily-co/daily-react"; + +export const Agent: React.FC = () => { + const participantIds = useParticipantIds({ filter: "remote" }); + + const status = participantIds.length > 0 ? "connected" : "connecting"; + return ( +
+
+
+ User status + Agent status +
+
+ ); +}; + +export default Agent; diff --git a/examples/client/src/components/Session/index.tsx b/examples/client/src/components/Session/index.tsx new file mode 100644 index 000000000..66b6077a9 --- /dev/null +++ b/examples/client/src/components/Session/index.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from "react"; +import { LogOut, Settings } from "lucide-react"; +import { DailyAudio } from "@daily-co/daily-react"; + +import DeviceSelect from "../DeviceSelect"; +import Agent from "./agent"; +import { Button } from "../button"; +import styles from "./styles.module.css"; +import UserMicBubble from "../UserMicBubble"; + +export const Session: React.FC = () => { + const [showDevices, setShowDevices] = useState(false); + const [canTalk, setCanTalk] = useState(false); + const modalRef = useRef(null); + + useEffect(() => { + const current = modalRef.current; + // Backdrop doesn't currently work with dialog open, so we use setModal instead + if (current && showDevices) { + current.inert = true; + current.showModal(); + current.inert = false; + } + return () => current?.close(); + }, [showDevices]); + + return ( + <> + +

Configure devices

+ + +
+ +
+ + + +
+ +
+
+ + +
+
+ + ); +}; + +export default Session; diff --git a/examples/client/src/components/Session/status.tsx b/examples/client/src/components/Session/status.tsx new file mode 100644 index 000000000..cd6b5f831 --- /dev/null +++ b/examples/client/src/components/Session/status.tsx @@ -0,0 +1,42 @@ +import { VariantProps, cva } from "class-variance-authority"; +import styles from "./styles.module.css"; + +const statusVariants = cva(styles.statusIndicator, { + variants: { + variant: { + default: styles.statusDefault, + connecting: styles.statusOrange, + connected: styles.statusGreen, + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +export interface StatusProps + extends React.HTMLAttributes, + VariantProps {} + +const status_text = { + default: "Idle", + connecting: "Connecting", + connected: "Connected", +}; + +export const Status: React.FC = ({ + children, + variant = "default", +}) => { + return ( +
+ {children} +
+ + {status_text[variant as keyof typeof status_text]} +
+
+ ); +}; + +export default Status; diff --git a/examples/client/src/components/Session/styles.module.css b/examples/client/src/components/Session/styles.module.css new file mode 100644 index 000000000..1e09fb5ef --- /dev/null +++ b/examples/client/src/components/Session/styles.module.css @@ -0,0 +1,110 @@ +.footer { + display: flex; + flex-flow: row nowrap; + margin-top: auto; + align-self: flex-end; +} + +.controls { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + gap: 0.75rem; +} + +.agentContainer { + flex: 1; + display: flex; + flex-flow: column wrap; + align-items: center; + justify-content: center; +} + +.agent { + margin-top: auto; + min-width: 420px; + padding: 0.75rem; + border-radius: var(--borderRadius-lg); + border-radius: 24px; + border: 1px solid #e2e8f0; + background: #fff; + box-shadow: 0px 360px 101px 0px rgba(0, 0, 0, 0), + 0px 231px 92px 0px rgba(0, 0, 0, 0), 0px 130px 78px 0px rgba(0, 0, 0, 0.02), + 0px 58px 58px 0px rgba(0, 0, 0, 0.03), 0px 14px 32px 0px rgba(0, 0, 0, 0.03); + background-image: linear-gradient(90deg, white, white), + linear-gradient(0deg, var(--color-gray-300), var(--color-gray-200)); + background-clip: padding-box, border-box; + background-origin: border-box; + border: 1px solid transparent; +} + +.agentWindow { + min-width: 400px; + aspect-ratio: 1; + background: var(--color-gray-200); + border-radius: var(--borderRadius-md); +} + +.agentFooter { + margin: 1.5rem 0 0.75rem 0; + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + width: 100%; + + > :first-child { + border-right: 1px solid var(--color-gray-200); + } +} + +.status { + flex: 1; + line-height: 1; + display: flex; + flex-flow: column wrap; + align-items: center; + gap: 0.5rem; + + > span { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + line-height: 1; + letter-spacing: 0.7px; + } +} + +.statusIndicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 700; + padding: 0.5rem 0.75rem; + border-radius: var(--borderRadius-xs); + + > span { + display: block; + width: 9px; + height: 9px; + border-radius: 9px; + background: red; + } +} + +.statusDefault { + color: var(--color-gray-500); + background: var(--color-gray-100); + + > span { + background: var(--color-gray-400); + } +} +.statusOrange { + color: var(--color-orange-800); + background: var(--color-orange-100); + + > span { + background: var(--color-orange-400); + } +} diff --git a/examples/client/src/components/UserMicBubble/index.tsx b/examples/client/src/components/UserMicBubble/index.tsx new file mode 100644 index 000000000..bede863b7 --- /dev/null +++ b/examples/client/src/components/UserMicBubble/index.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useRef, useState, useEffect } from "react"; + +import { + useAudioLevel, + useAudioTrack, + useLocalSessionId, + useAppMessage, +} from "@daily-co/daily-react"; +import { DailyEventObjectAppMessage } from "@daily-co/daily-js"; +import { Mic, MicOff } from "lucide-react"; +//import { TypewriterEffect } from "../ui/typewriter"; +import styles from "./styles.module.css"; + +const AudioIndicatorBubble: React.FC = () => { + const localSessionId = useLocalSessionId(); + const audioTrack = useAudioTrack(localSessionId); + const volRef = useRef(null); + + useAudioLevel( + audioTrack?.persistentTrack, + useCallback((volume) => { + // this volume number will be between 0 and 1 + // give it a minimum scale of 0.15 to not completely disappear 👻 + if (volRef.current) { + const v = volume * 1.75; + volRef.current.style.transform = `scale(${Math.max(0.1, v)})`; + } + }, []) + ); + + // Your audio track's audio volume visualized in a small circle, + // whose size changes depending on the volume level + return
; +}; + +interface Props { + active: boolean; +} + +export default function UserMicBubble({ active }: Props) { + const [transcription, setTranscription] = useState([]); + + useAppMessage({ + onAppMessage: (e: DailyEventObjectAppMessage) => { + if (e.fromId && e.fromId === "transcription") { + if (e.data.user_id === "" && e.data.is_final) { + setTranscription((t) => [...t, ...e.data.text.split(" ")]); + } + } + }, + }); + + useEffect(() => { + if (active) return; + const t = setTimeout(() => setTranscription([]), 4000); + return () => clearTimeout(t); + }, [active]); + + return ( +
+
+ {active ? : } + {active && } +
+
+
+ ); +} diff --git a/examples/client/src/components/UserMicBubble/styles.module.css b/examples/client/src/components/UserMicBubble/styles.module.css new file mode 100644 index 000000000..d4049a653 --- /dev/null +++ b/examples/client/src/components/UserMicBubble/styles.module.css @@ -0,0 +1,96 @@ +.active::after { + opacity: 1; +} + +.bubbleContainer { + color: #ffffff; + position: relative; + z-index: 20; + display: flex; + flex-direction: column; + margin: auto; +} + +.micIcon { + position: relative; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + width: 120px; + height: 120px; + border-radius: 120px; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto; + z-index: 20; + transition: all 0.5s ease; + border: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%); + outline: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%); + background-color: var(--color-gray-500); + background-image: radial-gradient( + var(--color-gray-300), + var(--color-gray-400) + ); +} + +.micIcon svg { + position: relative; + z-index: 20; + opacity: 0.3; + transition: opacity 0.5s ease; +} + +@keyframes pulse { + 0% { + outline-width: 6px; + @apply outline-teal-500/10; + } + 50% { + outline-width: 24px; + @apply outline-teal-500/50; + } + 100% { + outline-width: 6px; + @apply outline-teal-500/10; + } +} + +.micIconActive { + background-color: var(--color-green-500); + background-image: radial-gradient( + var(--color-green-500), + var(--color-green-600) + ); + border: 6px solid color-mix(in srgb, var(--color-green-200), transparent 60%); + outline: 6px solid color-mix(in srgb, var(--color-green-400), transparent 70%); + animation: pulse 2s infinite ease-in-out; +} + +.micIconActive svg { + opacity: 1; +} + +.transcript { + flex: 0; + align-self: center; + opacity: 0.25; + transition: opacity 1s ease; + transition-delay: 2.5s; +} + +.active .transcript { + opacity: 1; +} + +.volume { + position: absolute; + overflow: hidden; + inset: 0px; + z-index: 0; + border-radius: 999px; + transition: all 0.1s ease; + transform: scale(0); + opacity: 0.4; + background-color: var(--color-green-200); +} diff --git a/examples/client/src/components/alert.tsx b/examples/client/src/components/alert.tsx new file mode 100644 index 000000000..b8d15bc0c --- /dev/null +++ b/examples/client/src/components/alert.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +const alertVariants = cva("alert", { + variants: { + intent: { + info: "alert-info", + danger: "alert-danger", + }, + }, + defaultVariants: { + intent: "info", + }, +}); + +export interface AlertProps + extends React.HTMLAttributes, + VariantProps {} + +export const Alert: React.FC = ({ children, intent, title }) => { + return ( +
+ {title} + {children} +
+ ); +}; diff --git a/examples/client/src/components/button.tsx b/examples/client/src/components/button.tsx new file mode 100644 index 000000000..bda9f4e61 --- /dev/null +++ b/examples/client/src/components/button.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +const buttonVariants = cva("button", { + variants: { + variant: { + primary: "button-primary", + ghost: "button-ghost", + }, + size: { + base: "", + icon: "button-icon", + }, + }, + defaultVariants: { + variant: "primary", + size: "base", + }, +}); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button: React.FC = ({ variant, size, ...props }) => { + return