initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-09-16 01:54:29 +02:00
padre 9d013a7c87
commit 6d1dd42e6d
Se han modificado 44 ficheros con 1719 adiciones y 11509 borrados

3
client/.env.example Archivo normal
Ver fichero

@@ -0,0 +1,3 @@
NEXT_PUBLIC_SOCKET_SERVER_URL=http://localhost:8000
NEXT_PUBLIC_ICE_SERVERS=["stun:stun.l.google.com:19302","stun:global.stun.twilio.com:3478","stun:stun1.l.google.com:19302","stun:stun2.l.google.com:19302"]
NEXT_PUBLIC_APP_NAME=VideoPeersJS

3
client/.env.local Archivo normal
Ver fichero

@@ -0,0 +1,3 @@
NEXT_PUBLIC_SOCKET_SERVER_URL=http://localhost:8000
NEXT_PUBLIC_ICE_SERVERS=["stun:stun.l.google.com:19302","stun:global.stun.twilio.com:3478","stun:stun1.l.google.com:19302","stun:stun2.l.google.com:19302"]
NEXT_PUBLIC_APP_NAME=VideoPeersJS

6
client/.gitignore vendido
Ver fichero

@@ -25,12 +25,12 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
*.lock
*-lock.json

8876
client/package-lock.json generado

La diferencia del archivo ha sido suprimido porque es demasiado grande Cargar Diff

Ver fichero

@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "videopeersjs-client",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -9,23 +9,25 @@
"lint": "next lint"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.16",
"@mui/material": "^5.14.17",
"@vercel/analytics": "^1.1.1",
"next": "14.0.2",
"react": "^18",
"react-dom": "^18",
"react-player": "^2.13.0",
"socket.io-client": "^4.7.2"
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^6.1.0",
"@mui/material": "^6.1.0",
"@vercel/analytics": "^1.3.1",
"framer-motion": "^11.5.4",
"lucide-react": "^0.439.0",
"next": "^14.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-player": "^2.16.0",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.2",
"postcss": "^8",
"sass": "^1.69.5",
"tailwindcss": "^3.3.0"
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.9",
"postcss": "^8.4.45",
"sass": "^1.78.0",
"tailwindcss": "^3.4.10"
}
}

Archivo binario no mostrado.

Antes

Anchura:  |  Altura:  |  Tamaño: 25 KiB

Después

Anchura:  |  Altura:  |  Tamaño: 3.7 KiB

58
client/public/fonts/README.md Archivo normal
Ver fichero

@@ -0,0 +1,58 @@
# Fuentes Locales - VideoPeersJS
Este directorio contiene todas las fuentes descargadas localmente para el proyecto VideoPeersJS, eliminando la dependencia de Google Fonts y mejorando el rendimiento.
## Estructura de Fuentes
### Inter
- **Archivo**: `inter/`
- **Pesos**: 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold)
- **Uso**: Fuente principal para interfaces y textos de cuerpo
- **Formato**: WOFF2 (optimizado para web)
### Poppins
- **Archivo**: `poppins/`
- **Pesos**: 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold)
- **Uso**: Fuente secundaria para elementos especiales
- **Formato**: WOFF2 (optimizado para web)
### Josefin Sans
- **Archivo**: `josefin-sans/`
- **Pesos**: 400 (Regular), 500 (Medium), 600 (SemiBold)
- **Uso**: Fuente para títulos y displays
- **Formato**: WOFF2 (optimizado para web)
### Space Grotesk
- **Archivo**: `space-grotesk/`
- **Pesos**: 400 (Regular), 500 (Medium), 600 (SemiBold)
- **Uso**: Fuente decorativa y elementos especiales
- **Formato**: WOFF2 (optimizado para web)
## Beneficios de Fuentes Locales
1. **Rendimiento Mejorado**: No hay dependencias externas, carga más rápida
2. **Confiabilidad**: No depende de la disponibilidad de Google Fonts
3. **Privacidad**: No se envían datos a terceros
4. **Control Total**: Optimización y personalización completa
5. **Offline**: Funciona sin conexión a internet
## Uso en CSS
Las fuentes se importan automáticamente en `src/styles/globals.css`:
```css
@import './fonts/inter.css';
@import './fonts/poppins.css';
@import './fonts/josefin-sans.css';
@import './fonts/space-grotesk.css';
```
## Clases de Tailwind Disponibles
- `.font-body` - Inter (fuente principal)
- `.font-accent` - Poppins (elementos especiales)
- `.font-display` - Space Grotesk y Josefin Sans (títulos)
## Tamaño Total
Aproximadamente ~80KB total para todas las fuentes en formato WOFF2 comprimido.

Archivo binario no mostrado.

Archivo binario no mostrado.

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/inter/v13/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuGKfAZ9hiA.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Archivo binario no mostrado.

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/josefinsans/v25/Qw3FZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_DjQXME.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/josefinsans/v25/Qw3FZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_LjQXME.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/josefinsans/v25/Qw3FZQNVED7rKGKxtqIqX5E-AVSJrOCfjY46_GbQXME.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Archivo binario no mostrado.

Archivo binario no mostrado.

Archivo binario no mostrado.

Archivo binario no mostrado.

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/spacegrotesk/v13/V8mQoQDjQSkFtoMM3T6r8E7mPbF4C0i-3qLWA-XEqhZQjOTm.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/spacegrotesk/v13/V8mQoQDjQSkFtoMM3T6r8E7mPbF4C0i-3qLWEuXEqhZQjOTm.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Ver fichero

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>That’s an error.</ins>
<p>The requested URL <code>/s/spacegrotesk/v13/V8mQoQDjQSkFtoMM3T6r8E7mPbF4C0i-3qLWZOXEqhZQjOTm.woff2</code> was not found on this server. <ins>That’s all we know.</ins>

Ver fichero

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Antes

Anchura:  |  Altura:  |  Tamaño: 1.3 KiB

Ver fichero

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

Antes

Anchura:  |  Altura:  |  Tamaño: 629 B

Ver fichero

@@ -1,28 +1,77 @@
// components/CallButtons.jsx
import MicOffIcon from '@mui/icons-material/MicOff';
import KeyboardVoiceIcon from '@mui/icons-material/KeyboardVoice';
import VideocamIcon from '@mui/icons-material/Videocam';
import VideocamOffIcon from '@mui/icons-material/VideocamOff';
import CallEndIcon from '@mui/icons-material/CallEnd';
import { motion } from 'framer-motion';
import { Mic, MicOff, Video, VideoOff, PhoneOff } from 'lucide-react';
const CallHandleButtons = ({ isAudioMute, isVideoOnHold, onToggleAudio, onToggleVideo, onEndCall }) => {
const buttonVariants = {
hover: { scale: 1.1 },
tap: { scale: 0.95 }
};
return (
<motion.div
className='fixed bottom-8 left-1/2 transform -translate-x-1/2 z-30'
initial={{ opacity: 0, y: 100 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className='flex items-center space-x-4 bg-black/20 backdrop-blur-lg rounded-2xl p-4 border border-white/10'>
{/* Audio Toggle Button */}
<motion.button
variants={buttonVariants}
whileHover="hover"
whileTap="tap"
onClick={onToggleAudio}
className={`w-14 h-14 rounded-full flex items-center justify-center transition-all duration-200 ${
isAudioMute
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-white/20 hover:bg-white/30 text-white border border-white/20'
}`}
title={isAudioMute ? 'Unmute microphone' : 'Mute microphone'}
>
{isAudioMute ? (
<MicOff className="h-6 w-6" />
) : (
<Mic className="h-6 w-6" />
)}
</motion.button>
{/* Video Toggle Button */}
<motion.button
variants={buttonVariants}
whileHover="hover"
whileTap="tap"
onClick={onToggleVideo}
className={`w-14 h-14 rounded-full flex items-center justify-center transition-all duration-200 ${
isVideoOnHold
? 'bg-red-500 hover:bg-red-600 text-white'
: 'bg-white/20 hover:bg-white/30 text-white border border-white/20'
}`}
title={isVideoOnHold ? 'Turn on camera' : 'Turn off camera'}
>
{isVideoOnHold ? (
<VideoOff className="h-6 w-6" />
) : (
<Video className="h-6 w-6" />
)}
</motion.button>
{/* End Call Button */}
<motion.button
variants={buttonVariants}
whileHover="hover"
whileTap="tap"
onClick={onEndCall}
className='w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center transition-all duration-200 shadow-lg'
title='End call'
>
<PhoneOff className="h-6 w-6" />
</motion.button>
</div>
{/* Subtle glow effect */}
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-indigo-400/20 to-purple-400/20 blur-xl -z-10"></div>
</motion.div>
);
};
const CallHandleButtons = ({ isAudioMute, isVideoOnHold, onToggleAudio, onToggleVideo, onEndCall }) => (
<div className='absolute bottom-0 flex w-full space-x-4 h-[80px] items-center justify-center rounded-md'>
<div className=' bg-[#2c3e508b] rounded-md flex px-4 py-2 justify-center gap-10'>
<button className="callButtons text-white bg-blue-700 hover:bg-white hover:text-blue-700
focus:ring-4 focus:ring-blue-300" onClick={onToggleAudio}>
{isAudioMute ? <MicOffIcon fontSize="large" /> : <KeyboardVoiceIcon fontSize="large" />}
</button>
<button className="callButtons text-white bg-blue-700 hover:bg-white hover:text-blue-700
focus:ring-4 focus:ring-blue-300"
onClick={onToggleVideo}
>
{isVideoOnHold ? <VideocamOffIcon fontSize="large" /> : <VideocamIcon fontSize="large" />}
</button>
<button className="callButtons text-white bg-red-600 hover:text-red-700 hover:bg-white
focus:ring-4 focus:ring-white" onClick={onEndCall}>
<CallEndIcon fontSize="large" />
</button>
</div>
</div>
);
export default CallHandleButtons;

Ver fichero

@@ -1,72 +1,183 @@
import { useSocket } from '@/context/SocketProvider';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react';
import VideoCallIcon from '@mui/icons-material/VideoCall';
import { motion } from 'framer-motion';
import { Video, Users, ArrowRight, Mail, Hash } from 'lucide-react';
const LobbyScreen = () => {
const [email, setEmail] = useState("");
const [room, setRoom] = useState("");
const [isLoading, setIsLoading] = useState(false);
const socket = useSocket();
const router = useRouter();
// console.log(socket);
const handleSubmitForm = useCallback((e) => {
const handleSubmitForm = useCallback(async (e) => {
e.preventDefault();
socket.emit('room:join', { email, room });
setIsLoading(true);
try {
socket.emit('room:join', { email, room });
} catch (error) {
console.error('Error joining room:', error);
setIsLoading(false);
}
}, [email, room, socket]);
const handleJoinRoom = useCallback((data) => {
const { email, room } = data;
setIsLoading(false);
router.push(`/room/${room}`);
}, [router]);
useEffect(() => {
socket.on("room:join", handleJoinRoom);
socket.on("error", (error) => {
console.error('Socket error:', error);
setIsLoading(false);
});
return () => {
socket.off("room:join", handleJoinRoom);
socket.off("error");
}
}, [socket, handleJoinRoom]);
return (
<div className='flex flex-col items-center justify-center h-screen bg-gray-100'>
<title>VideoPeers</title>
<link rel="shortcut icon" href="../../public/favicon.ico" type="image/x-icon" />
<h1 className='text-5xl font-[15px] mb-5 mt-5 text-center font-josefin tracking-tighter'>Video<VideoCallIcon sx={{ fontSize: 70, color: 'rgb(30,220,30)' }} />Peers</h1>
<p className='text-2xl mt-2 mb-4 text-center md:max-w-[400px] max-w-[300px] text-gray-600'>
Peer-to-Peer video calls, powered by <b>WebRTC!</b>
<br />
Bring People Closer Together.
</p>
<div className='bg-white p-6 rounded shadow-md'>
<form className='flex flex-col items-center justify-center'
onSubmit={handleSubmitForm}
>
<label htmlFor="email">Email ID</label>
<input
type="email"
id='email'
required
value={email}
autoComplete='off'
onChange={(e) => setEmail(e.target.value)}
/>
<br />
<label htmlFor="room">Room Number</label>
<input
type="number"
id='room'
required
autoComplete='off'
value={room}
onChange={(e) => setRoom(e.target.value)}
/>
<br />
<button className='bg-blue-500 hover:bg-blue-600'>
Join
</button>
</form>
<div className='min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex flex-col items-center justify-center p-4'>
<title>VideoPeersJS - Connect & Collaborate</title>
{/* Background decorative elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-4 -right-4 w-72 h-72 bg-purple-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse"></div>
<div className="absolute -bottom-8 -left-4 w-72 h-72 bg-blue-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse delay-1000"></div>
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-72 h-72 bg-indigo-300 rounded-full mix-blend-multiply filter blur-xl opacity-20 animate-pulse delay-500"></div>
</div>
<motion.div
className="relative z-10 w-full max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
{/* Logo and Title */}
<motion.div
className="text-center mb-8"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.2 }}
>
<div className="flex items-center justify-center mb-4">
<Video className="h-12 w-12 text-indigo-600 mr-2" />
<h1 className="text-5xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{process.env.NEXT_PUBLIC_APP_NAME || 'VideoPeersJS'}
</h1>
</div>
<p className="text-xl text-gray-600 font-medium">
Peer-to-Peer Video Calls
</p>
<p className="text-sm text-gray-500 mt-2 leading-relaxed">
Powered by <span className="font-semibold text-indigo-600">WebRTC</span>
Bringing people closer together
</p>
</motion.div>
{/* Main Card */}
<motion.div
className="bg-white/80 backdrop-blur-lg rounded-2xl shadow-xl border border-white/20 p-8"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<form onSubmit={handleSubmitForm} className="space-y-6">
{/* Email Input */}
<div className="space-y-2">
<label htmlFor="email" className="flex items-center text-sm font-medium text-gray-700">
<Mail className="h-4 w-4 mr-2 text-indigo-500" />
Email Address
</label>
<input
type="email"
id="email"
required
value={email}
autoComplete="email"
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all duration-200 bg-white/50 backdrop-blur-sm"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>
{/* Room Input */}
<div className="space-y-2">
<label htmlFor="room" className="flex items-center text-sm font-medium text-gray-700">
<Hash className="h-4 w-4 mr-2 text-indigo-500" />
Room ID
</label>
<input
type="text"
id="room"
required
autoComplete="off"
value={room}
onChange={(e) => setRoom(e.target.value)}
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all duration-200 bg-white/50 backdrop-blur-sm"
placeholder="Enter room ID"
disabled={isLoading}
/>
</div>
{/* Join Button */}
<motion.button
type="submit"
disabled={isLoading || !email || !room}
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-3 px-6 rounded-xl font-semibold text-lg shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center space-x-2"
whileHover={{ scale: isLoading ? 1 : 1.02 }}
whileTap={{ scale: isLoading ? 1 : 0.98 }}
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-2 border-white border-t-transparent"></div>
<span>Joining...</span>
</>
) : (
<>
<Users className="h-5 w-5" />
<span>Join Room</span>
<ArrowRight className="h-5 w-5" />
</>
)}
</motion.button>
</form>
{/* Features */}
<div className="mt-8 pt-6 border-t border-gray-100">
<div className="grid grid-cols-2 gap-4 text-center">
<div className="space-y-1">
<div className="text-2xl">🔒</div>
<p className="text-xs text-gray-600 font-medium">Secure</p>
</div>
<div className="space-y-1">
<div className="text-2xl"></div>
<p className="text-xs text-gray-600 font-medium">Fast</p>
</div>
</div>
</div>
</motion.div>
{/* Footer */}
<motion.div
className="text-center mt-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.8 }}
>
<p className="text-sm text-gray-500">
No downloads required Works in your browser
</p>
</motion.div>
</motion.div>
</div>
)
}

Ver fichero

@@ -1,29 +1,89 @@
import ReactPlayer from 'react-player';
import { motion } from 'framer-motion';
import { User, Mic, MicOff } from 'lucide-react';
const VideoPlayer = ({ stream, isAudioMute, name }) => {
const myStream = name === "My Stream" ? true : false;
const isMyStream = name === "My Stream";
return (
<div>
<div className={`${name === "My Stream" ? "flex flex-col items-center justify-center absolute top-2 right-3 z-10" : "px-2"}`}>
<h1 className={`text-sm font-poppins font-semibold md:text-xl mb-1 text-center ${myStream ? "mt-1" : "mt-4"}`}>
{name}
</h1>
<div className={`relative rounded-[30px] overflow-hidden
${myStream ? " mxs:w-[80px] mxs:h-[120px] msm:w-[100px] msm:rounded-md msm:h-[140px] mmd:w-[140px] md:w-[200px] lg:w-[280px]"
: "mxs:h-[450px] mss:h-[500px] mmd:h-[600px] md:w-[800px] md:h-[500px]"}`}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className={`relative ${isMyStream
? "absolute top-4 right-4 z-20 w-32 md:w-48 lg:w-64"
: "w-full max-w-4xl mx-auto"
}`}
>
{/* Name Badge */}
<div className={`absolute top-2 left-2 z-30 flex items-center space-x-2 px-3 py-1 rounded-full bg-black/50 backdrop-blur-sm text-white text-xs font-medium ${
isMyStream ? "scale-75" : ""
}`}>
<User className="h-3 w-3" />
<span>{name}</span>
{isAudioMute ? (
<MicOff className="h-3 w-3 text-red-400" />
) : (
<Mic className="h-3 w-3 text-green-400" />
)}
</div>
{/* Video Container */}
<div className={`relative overflow-hidden rounded-2xl shadow-2xl border-2 border-white/20 ${
isMyStream
? "aspect-[3/4] bg-gradient-to-br from-gray-900 to-gray-800"
: "aspect-video bg-gradient-to-br from-gray-900 to-gray-800"
}`}>
{stream ? (
<ReactPlayer
url={stream}
playing
muted={isAudioMute}
muted={isMyStream ? true : isAudioMute}
height="100%"
width="100%"
style={{ transform: 'scaleX(-1)' }}
style={{
transform: 'scaleX(-1)',
}}
config={{
file: {
attributes: {
style: {
width: '100%',
height: '100%',
objectFit: 'cover'
}
}
}
}}
/>
</div>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center text-white/60">
<User className="h-16 w-16 mx-auto mb-2 opacity-40" />
<p className="text-sm">Camera not available</p>
</div>
</div>
)}
{/* Overlay for connection status */}
{!stream && (
<div className="absolute inset-0 bg-gradient-to-br from-indigo-500/20 to-purple-500/20 flex items-center justify-center">
<div className="text-center text-white">
<div className="animate-pulse">
<User className="h-12 w-12 mx-auto mb-2" />
<p className="text-sm font-medium">Connecting...</p>
</div>
</div>
</div>
)}
</div>
</div>
)
{/* Glow effect for my stream */}
{isMyStream && (
<div className="absolute inset-0 rounded-2xl bg-gradient-to-r from-indigo-400 to-purple-400 opacity-20 blur-xl -z-10"></div>
)}
</motion.div>
);
};
export default VideoPlayer;

Ver fichero

@@ -9,7 +9,22 @@ export const useSocket = () => {
}
const SocketProvider = (props) => {
const socket = useMemo(() => io("video-peers-server.onrender.com/"), []);
const socket = useMemo(() => {
// Get server URL from environment variables
const serverUrl = process.env.NEXT_PUBLIC_SOCKET_SERVER_URL || 'http://localhost:8000';
console.log('Connecting to socket server:', serverUrl);
return io(serverUrl, {
transports: ['websocket', 'polling'],
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5,
timeout: 20000,
});
}, []);
return (
<SocketContext.Provider value={socket}>
{props.children}

Ver fichero

@@ -2,13 +2,16 @@ import { useSocket } from '@/context/SocketProvider';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect, useState } from 'react'
import peer from '@/service/peer';
import CallIcon from '@mui/icons-material/Call';
import VideoCallIcon from '@mui/icons-material/VideoCall';
import { motion } from 'framer-motion';
import { Video, Phone, Users, Send, ArrowLeft } from 'lucide-react';
import VideoPlayer from '@/components/VideoPlayer';
import CallHandleButtons from '@/components/CallHandleButtons';
const RoomPage = () => {
const socket = useSocket();
const router = useRouter();
const { slug } = router.query;
const [remoteSocketId, setRemoteSocketId] = useState(null);
const [myStream, setMyStream] = useState(null);
const [remoteStream, setRemoteStream] = useState(null);
@@ -16,36 +19,51 @@ const RoomPage = () => {
const [isVideoOnHold, setIsVideoOnHold] = useState(false);
const [callButton, setCallButton] = useState(true);
const [isSendButtonVisible, setIsSendButtonVisible] = useState(true);
const [isConnecting, setIsConnecting] = useState(false);
const handleUserJoined = useCallback(({ email, id }) => {
//! console.log(`Email ${email} joined the room!`);
console.log(`User ${email} joined the room!`);
setRemoteSocketId(id);
}, []);
const handleUserLeft = useCallback(({ email }) => {
console.log(`User ${email} left the room`);
setRemoteSocketId(null);
setRemoteStream(null);
}, []);
const handleIncomingCall = useCallback(async ({ from, offer }) => {
setRemoteSocketId(from);
//! console.log(`incoming call from ${from} with offer ${offer}`);
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
setMyStream(stream);
setIsConnecting(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
setMyStream(stream);
const ans = await peer.getAnswer(offer);
socket.emit("call:accepted", { to: from, ans });
const ans = await peer.getAnswer(offer);
socket.emit("call:accepted", { to: from, ans });
} catch (error) {
console.error('Error handling incoming call:', error);
setIsConnecting(false);
}
}, [socket]);
const sendStreams = useCallback(() => {
for (const track of myStream.getTracks()) {
peer.peer.addTrack(track, myStream);
if (myStream) {
for (const track of myStream.getTracks()) {
peer.peer.addTrack(track, myStream);
}
setIsSendButtonVisible(false);
}
setIsSendButtonVisible(false);
}, [myStream]);
const handleCallAccepted = useCallback(({ from, ans }) => {
peer.setLocalDescription(ans);
//! console.log("Call Accepted");
console.log("Call Accepted");
setIsConnecting(false);
sendStreams();
}, [sendStreams]);
@@ -54,7 +72,6 @@ const RoomPage = () => {
socket.emit("peer:nego:done", { to: from, ans });
}, [socket]);
const handleNegoNeeded = useCallback(async () => {
const offer = await peer.getOffer();
socket.emit("peer:nego:needed", { offer, to: remoteSocketId });
@@ -66,23 +83,23 @@ const RoomPage = () => {
useEffect(() => {
peer.peer.addEventListener('negotiationneeded', handleNegoNeeded);
return () => {
peer.peer.removeEventListener('negotiationneeded', handleNegoNeeded);
}
}, [handleNegoNeeded]);
useEffect(() => {
peer.peer.addEventListener('track', async ev => {
const remoteStream = ev.streams;
console.log("GOT TRACKS!");
setRemoteStream(remoteStream[0]);
setIsConnecting(false);
})
}, [])
useEffect(() => {
socket.on("user:joined", handleUserJoined);
socket.on("user:left", handleUserLeft);
socket.on("incoming:call", handleIncomingCall);
socket.on("call:accepted", handleCallAccepted);
socket.on("peer:nego:needed", handleNegoNeededIncoming);
@@ -90,21 +107,21 @@ const RoomPage = () => {
return () => {
socket.off("user:joined", handleUserJoined);
socket.off("user:left", handleUserLeft);
socket.off("incoming:call", handleIncomingCall);
socket.off("call:accepted", handleCallAccepted);
socket.off("peer:nego:needed", handleNegoNeededIncoming);
socket.off("peer:nego:final", handleNegoFinal);
};
},
[
socket,
handleUserJoined,
handleIncomingCall,
handleCallAccepted,
handleNegoNeededIncoming,
handleNegoFinal
]);
}, [
socket,
handleUserJoined,
handleUserLeft,
handleIncomingCall,
handleCallAccepted,
handleNegoNeededIncoming,
handleNegoFinal
]);
useEffect(() => {
socket.on("call:end", ({ from }) => {
@@ -118,6 +135,9 @@ const RoomPage = () => {
setRemoteStream(null);
setRemoteSocketId(null);
setCallButton(true);
setIsSendButtonVisible(true);
setIsConnecting(false);
}
});
@@ -126,7 +146,6 @@ const RoomPage = () => {
}
}, [remoteSocketId, myStream, socket]);
//* for disappearing call button
useEffect(() => {
socket.on("call:initiated", ({ from }) => {
if (from === remoteSocketId) {
@@ -139,46 +158,50 @@ const RoomPage = () => {
}
}, [socket, remoteSocketId]);
const handleCallUser = useCallback(async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
setIsConnecting(true);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: true
});
if (isAudioMute) {
const audioTracks = stream.getAudioTracks();
audioTracks.forEach(track => track.enabled = false);
if (isAudioMute) {
const audioTracks = stream.getAudioTracks();
audioTracks.forEach(track => track.enabled = false);
}
if (isVideoOnHold) {
const videoTracks = stream.getVideoTracks();
videoTracks.forEach(track => track.enabled = false);
}
const offer = await peer.getOffer();
socket.emit("user:call", { to: remoteSocketId, offer })
setMyStream(stream);
setCallButton(false);
socket.emit("call:initiated", { to: remoteSocketId });
} catch (error) {
console.error('Error starting call:', error);
setIsConnecting(false);
}
if (isVideoOnHold) {
const videoTracks = stream.getVideoTracks();
videoTracks.forEach(track => track.enabled = false);
}
//! create offer
const offer = await peer.getOffer();
//* send offer to remote user
socket.emit("user:call", { to: remoteSocketId, offer })
// set my stream
setMyStream(stream);
//* hide the call button
setCallButton(false);
//* Inform the remote user to hide their "CALL" button
socket.emit("call:initiated", { to: remoteSocketId });
}, [remoteSocketId, socket, isAudioMute, isVideoOnHold, callButton]);
}, [remoteSocketId, socket, isAudioMute, isVideoOnHold]);
const handleToggleAudio = () => {
peer.toggleAudio();
setIsAudioMute(!isAudioMute);
if (myStream) {
const audioTracks = myStream.getAudioTracks();
audioTracks.forEach(track => track.enabled = !track.enabled);
setIsAudioMute(!isAudioMute);
}
};
const handleToggleVideo = () => {
peer.toggleVideo();
setIsVideoOnHold(!isVideoOnHold);
if (myStream) {
const videoTracks = myStream.getVideoTracks();
videoTracks.forEach(track => track.enabled = !track.enabled);
setIsVideoOnHold(!isVideoOnHold);
}
}
const handleEndCall = useCallback(() => {
@@ -190,6 +213,9 @@ const RoomPage = () => {
}
setRemoteStream(null);
setCallButton(true);
setIsSendButtonVisible(true);
setIsConnecting(false);
if (remoteSocketId) {
socket.emit("call:end", { to: remoteSocketId });
@@ -197,59 +223,168 @@ const RoomPage = () => {
setRemoteSocketId(null);
}, [myStream, remoteSocketId, socket]);
const router = useRouter();
const { slug } = router.query;
const handleGoBack = () => {
handleEndCall();
router.push('/');
};
return (
<div className='flex flex-col items-center justify-center w-screen h-screen overflow-hidden'>
<title>Room No. {slug}</title>
<h1 className='absolute top-0 left-0 text-5xl
text-center font-josefin tracking-tighter mt-5 ml-5 mmd:text-xl mxs:text-sm'>Video
<VideoCallIcon sx={{ fontSize: 50, color: 'rgb(30,220,30)' }} />
Peers
</h1>
<h4 className='font-bold text-xl md:text-2xl
mmd:text-sm mt-5 mb-4 msm:max-w-[100px] text-center'>
{remoteSocketId ? "Connected With Remote User!" : "No One In Room"}
</h4>
{(remoteStream && remoteSocketId && isSendButtonVisible) &&
<button className='bg-green-500 hover:bg-green-600' onClick={sendStreams}>
Send Stream
</button>
}
{(remoteSocketId && callButton) &&
(
<button className='text-xl bg-green-500 hover:bg-green-600 rounded-3xl'
onClick={handleCallUser}
style={{ display: !remoteStream ? 'block' : 'none' }}>
Call <CallIcon fontSize='medium' className=' animate-pulse scale-125' />
</button>
)
}
<div className="flex flex-col w-full items-center justify-center overflow-hidden">
{
myStream &&
<VideoPlayer stream={myStream} name={"My Stream"} isAudioMute={isAudioMute} />
}
{
remoteStream &&
<VideoPlayer stream={remoteStream} name={"Remote Stream"} isAudioMute={isAudioMute} />
}
<div className='min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-indigo-900 relative overflow-hidden'>
<title>Room {slug} - VideoPeersJS</title>
{/* Background decorative elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 -left-4 w-72 h-72 bg-blue-500 rounded-full mix-blend-multiply filter blur-xl opacity-10 animate-pulse"></div>
<div className="absolute bottom-1/4 -right-4 w-72 h-72 bg-purple-500 rounded-full mix-blend-multiply filter blur-xl opacity-10 animate-pulse delay-1000"></div>
</div>
{myStream && remoteStream && !isSendButtonVisible &&
(
<CallHandleButtons
isAudioMute={isAudioMute}
isVideoOnHold={isVideoOnHold}
onToggleAudio={handleToggleAudio}
onToggleVideo={handleToggleVideo}
onEndCall={handleEndCall}
/>
)
}
</div>
{/* Header */}
<motion.header
className="absolute top-0 left-0 right-0 z-20 p-6"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<motion.button
onClick={handleGoBack}
className="flex items-center space-x-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-xl text-white hover:bg-white/20 transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowLeft className="h-4 w-4" />
<span>Back</span>
</motion.button>
<div className="flex items-center space-x-2">
<Video className="h-6 w-6 text-indigo-400" />
<h1 className="text-xl font-bold text-white">
{process.env.NEXT_PUBLIC_APP_NAME || 'VideoPeersJS'}
</h1>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2 px-4 py-2 bg-white/10 backdrop-blur-sm rounded-xl text-white">
<Users className="h-4 w-4" />
<span className="text-sm">Room {slug}</span>
</div>
</div>
</div>
</motion.header>
{/* Connection Status */}
<motion.div
className="absolute top-24 left-1/2 transform -translate-x-1/2 z-20"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<div className={`px-6 py-3 rounded-full backdrop-blur-sm text-white text-center ${
remoteSocketId ? 'bg-green-500/20 border border-green-400/30' : 'bg-orange-500/20 border border-orange-400/30'
}`}>
<p className="text-sm font-medium">
{isConnecting ? (
<span className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
<span>Connecting...</span>
</span>
) : remoteSocketId ? (
"🟢 Connected with remote user"
) : (
"🟡 Waiting for someone to join..."
)}
</p>
</div>
</motion.div>
{/* Video Container */}
<div className="relative h-screen flex items-center justify-center p-6 pt-32">
{/* Remote Stream (Main) */}
{remoteStream ? (
<VideoPlayer
stream={remoteStream}
name={"Remote Stream"}
isAudioMute={false}
/>
) : (
<motion.div
className="w-full max-w-4xl aspect-video bg-gradient-to-br from-gray-800 to-gray-900 rounded-2xl border-2 border-white/10 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6 }}
>
<div className="text-center text-white/60">
<Users className="h-24 w-24 mx-auto mb-4 opacity-40" />
<p className="text-xl font-medium mb-2">Waiting for remote video...</p>
<p className="text-sm">Share this room ID with someone to start a call</p>
</div>
</motion.div>
)}
{/* My Stream (Picture-in-Picture) */}
{myStream && (
<VideoPlayer
stream={myStream}
name={"My Stream"}
isAudioMute={isAudioMute}
/>
)}
</div>
{/* Action Buttons */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-30">
{remoteStream && isSendButtonVisible && (
<motion.button
onClick={sendStreams}
className="mb-4 flex items-center space-x-2 px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-xl font-semibold transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Send className="h-5 w-5" />
<span>Send Stream</span>
</motion.button>
)}
{remoteSocketId && callButton && !remoteStream && (
<motion.button
onClick={handleCallUser}
disabled={isConnecting}
className="flex items-center space-x-2 px-8 py-4 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white rounded-2xl font-semibold text-lg shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
whileHover={{ scale: isConnecting ? 1 : 1.05 }}
whileTap={{ scale: isConnecting ? 1 : 0.95 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
{isConnecting ? (
<>
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent"></div>
<span>Connecting...</span>
</>
) : (
<>
<Phone className="h-6 w-6" />
<span>Start Call</span>
</>
)}
</motion.button>
)}
</div>
{/* Call Control Buttons */}
{myStream && remoteStream && !isSendButtonVisible && (
<CallHandleButtons
isAudioMute={isAudioMute}
isVideoOnHold={isVideoOnHold}
onToggleAudio={handleToggleAudio}
onToggleVideo={handleToggleVideo}
onEndCall={handleEndCall}
/>
)}
</div>
)
}

Ver fichero

@@ -1,14 +1,41 @@
class PeerService {
constructor() {
if (typeof window !== 'undefined' && !this.peer) {
this.peer = new RTCPeerConnection({
iceServers: [{
// Get ICE servers from environment variables
const getIceServers = () => {
try {
const iceServersEnv = process.env.NEXT_PUBLIC_ICE_SERVERS;
if (iceServersEnv) {
const servers = JSON.parse(iceServersEnv);
return [{ urls: servers }];
}
} catch (error) {
console.warn('Error parsing ICE servers from environment:', error);
}
// Fallback to default ICE servers
return [{
urls: [
"stun:stun.l.google.com:19302",
"stun:global.stun.twilio.com:3478",
"stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302"
]
}]
})
}];
};
this.peer = new RTCPeerConnection({
iceServers: getIceServers()
});
// Add connection state monitoring
this.peer.onconnectionstatechange = () => {
console.log('Connection state:', this.peer.connectionState);
};
this.peer.oniceconnectionstatechange = () => {
console.log('ICE connection state:', this.peer.iceConnectionState);
};
}
}
@@ -36,17 +63,58 @@ class PeerService {
}
toggleAudio = () => {
const audioTracks = this.peer.getSenders().find(sender => sender.track.kind === 'audio').track;
audioTracks.enabled = !audioTracks.enabled;
// Mute the local audio track
const localAudioTrack = this.peer.getLocalStreams()[0].getAudioTracks()[0];
localAudioTrack.enabled = !localAudioTrack.enabled;
try {
const senders = this.peer.getSenders();
const audioSender = senders.find(sender =>
sender.track && sender.track.kind === 'audio'
);
if (audioSender && audioSender.track) {
audioSender.track.enabled = !audioSender.track.enabled;
return audioSender.track.enabled;
}
} catch (error) {
console.error('Error toggling audio:', error);
}
return false;
};
toggleVideo = () => {
const videoTracks = this.peer.getSenders().find(sender => sender.track.kind === 'video').track;
videoTracks.enabled = !videoTracks.enabled;
try {
const senders = this.peer.getSenders();
const videoSender = senders.find(sender =>
sender.track && sender.track.kind === 'video'
);
if (videoSender && videoSender.track) {
videoSender.track.enabled = !videoSender.track.enabled;
return videoSender.track.enabled;
}
} catch (error) {
console.error('Error toggling video:', error);
}
return false;
};
// Method to close and cleanup the peer connection
close = () => {
if (this.peer) {
this.peer.close();
this.peer = null;
}
};
// Method to get connection statistics
getStats = async () => {
if (this.peer) {
try {
return await this.peer.getStats();
} catch (error) {
console.error('Error getting connection stats:', error);
return null;
}
}
return null;
};
}

Ver fichero

@@ -0,0 +1,54 @@
/*
* VideoPeersJS - Font Configuration
* Local fonts configuration and fallbacks
*/
/* Import all local font families */
@import './inter.css';
@import './poppins.css';
@import './josefin-sans.css';
@import './space-grotesk.css';
/* Font Stack Definitions */
:root {
/* Primary font stack with fallbacks */
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
/* Accent font stack */
--font-accent: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
/* Display font stack */
--font-display: 'Space Grotesk', 'Josefin Sans', 'Inter', system-ui, sans-serif;
/* Monospace stack (for code/technical elements) */
--font-mono: 'SF Mono', Monaco, 'Inconsolata', 'Roboto Mono', 'Source Code Pro', monospace;
}
/* CSS Custom Properties for font weights */
:root {
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
}
/* Apply font optimization to all elements */
* {
font-feature-settings: 'kern' 1, 'liga' 1;
text-rendering: optimizeLegibility;
}
/* Utility classes for font optimization */
.font-optimized {
font-feature-settings: 'kern' 1, 'liga' 1, 'ss01' 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Font preload hints (for performance) */
.font-preload-hint {
font-display: swap;
}

Ver fichero

@@ -0,0 +1,36 @@
/* Inter Font Family - Local */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter/inter-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/inter/inter-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/inter/inter-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/inter/inter-700.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Ver fichero

@@ -0,0 +1,27 @@
/* Josefin Sans Font Family - Local */
@font-face {
font-family: 'Josefin Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/josefin-sans/josefin-sans-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Josefin Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/josefin-sans/josefin-sans-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Josefin Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/josefin-sans/josefin-sans-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Ver fichero

@@ -0,0 +1,36 @@
/* Poppins Font Family - Local */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/poppins/poppins-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/poppins/poppins-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/poppins/poppins-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/poppins/poppins-700.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Ver fichero

@@ -0,0 +1,27 @@
/* Space Grotesk Font Family - Local */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/space-grotesk/space-grotesk-400.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/space-grotesk/space-grotesk-500.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/space-grotesk/space-grotesk-600.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Ver fichero

@@ -1,66 +1,203 @@
@import url('https://fonts.googleapis.com/css2?family=Poppins&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@500&display=swap');
/* Import Local Fonts Configuration */
@import './fonts/index.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global Base Styles */
@layer base {
html {
scroll-behavior: smooth;
}
body{
overflow: hidden;
body {
font-family: var(--font-primary);
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
font-feature-settings: 'kern' 1, 'liga' 1;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%);
}
}
label{
color: #333;
margin-bottom: 0.5rem;
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: 500;
/* Component Styles */
@layer components {
/* Form Elements */
.form-label {
@apply flex items-center text-sm font-medium text-gray-700 mb-2;
font-family: 'Inter', sans-serif;
}
.form-input {
@apply w-full px-4 py-3 border border-gray-200 rounded-xl transition-all duration-200 bg-white/50 backdrop-blur-sm;
@apply focus:ring-2 focus:ring-indigo-500 focus:border-transparent;
@apply placeholder:text-gray-400 disabled:opacity-50 disabled:cursor-not-allowed;
font-family: 'Inter', sans-serif;
}
.form-input:hover {
@apply border-gray-300;
}
/* Button Variants */
.btn-primary {
@apply bg-gradient-to-r from-indigo-600 to-purple-600 text-white font-semibold py-3 px-6 rounded-xl;
@apply hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200;
@apply flex items-center justify-center space-x-2;
font-family: 'Inter', sans-serif;
}
.btn-secondary {
@apply bg-white/20 hover:bg-white/30 text-white border border-white/20 font-medium py-2 px-4 rounded-xl;
@apply transition-all duration-200 backdrop-blur-sm;
font-family: 'Inter', sans-serif;
}
.btn-danger {
@apply bg-red-500 hover:bg-red-600 text-white font-medium py-2 px-4 rounded-xl;
@apply transition-all duration-200 shadow-lg;
font-family: 'Inter', sans-serif;
}
/* Call Control Buttons */
.call-button {
@apply w-14 h-14 rounded-full flex items-center justify-center transition-all duration-200;
@apply shadow-lg backdrop-blur-sm;
}
.call-button-active {
@apply bg-white/20 hover:bg-white/30 text-white border border-white/20;
}
.call-button-muted {
@apply bg-red-500 hover:bg-red-600 text-white;
}
/* Glass Effect */
.glass {
@apply bg-white/10 backdrop-blur-lg border border-white/20;
}
.glass-card {
@apply bg-white/80 backdrop-blur-lg rounded-2xl shadow-xl border border-white/20;
}
/* Gradient Text */
.gradient-text {
@apply bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent;
}
/* Status Indicators */
.status-connected {
@apply bg-green-500/20 border border-green-400/30 text-green-100;
}
.status-waiting {
@apply bg-orange-500/20 border border-orange-400/30 text-orange-100;
}
.status-error {
@apply bg-red-500/20 border border-red-400/30 text-red-100;
}
}
/* Utility Classes */
@layer utilities {
/* Typography */
.font-display {
font-family: 'Space Grotesk', 'Josefin Sans', sans-serif;
}
.font-body {
font-family: 'Inter', system-ui, sans-serif;
}
.font-accent {
font-family: 'Poppins', sans-serif;
}
}
input{
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 0.25rem;
outline: 2px solid transparent;
outline-offset: 2px;
border-width: 1px;
border-color: rgb(170, 170, 170);
&:focus{
outline: 2px solid transparent;
outline-offset: 2px;
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
/* Animations */
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-glow {
animation: glow 2s ease-in-out infinite alternate;
}
.animate-pulse-slow {
animation: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Custom animations */
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
}
button{
color: white;
padding: 0.5rem 1rem 0.5rem 1rem;
border-radius: 0.25rem;
transition: background-color 250ms ease-in-out;
font-family: "Poppins", sans-serif;
&:focus{
outline: 2px solid transparent;
outline-offset: 2px;
50% {
transform: translateY(-10px);
}
}
@keyframes glow {
0% {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.4);
}
100% {
box-shadow: 0 0 30px rgba(99, 102, 241, 0.8);
}
}
/* Video specific styles */
.video-container {
@apply relative overflow-hidden rounded-2xl shadow-2xl border-2 border-white/20;
}
.video-overlay {
@apply absolute inset-0 bg-gradient-to-br from-indigo-500/20 to-purple-500/20;
}
}
.callButtons{
font-weight: 500;
border-radius: 9999px;
font-size: 0.875rem;
line-height: 1.25rem;
padding: 0.625rem;
text-align: center;
display: inline-flex;
align-items: center;
/* Legacy styles for backward compatibility */
label {
@apply form-label;
}
video{
width: 100%;
height: 100%;
object-fit: cover;
input[type="email"],
input[type="text"],
input[type="number"] {
@apply form-input;
}
button:not(.call-button):not(.btn-primary):not(.btn-secondary):not(.btn-danger) {
@apply btn-primary;
}
.callButtons {
@apply call-button call-button-active;
}
video {
@apply w-full h-full object-cover;
}

Ver fichero

@@ -7,23 +7,46 @@ module.exports = {
],
theme: {
extend: {
fontFamily: {
// Primary font stack with local fonts
'sans': ['var(--font-primary)', 'Inter', 'system-ui', 'sans-serif'],
'body': ['var(--font-primary)', 'Inter', 'system-ui', 'sans-serif'],
'accent': ['var(--font-accent)', 'Poppins', 'system-ui', 'sans-serif'],
'display': ['var(--font-display)', 'Space Grotesk', 'Josefin Sans', 'system-ui', 'sans-serif'],
'mono': ['var(--font-mono)', 'monospace'],
// Legacy support
'poppins': ['Poppins', 'system-ui', 'sans-serif'],
'josefin': ['Josefin Sans', 'system-ui', 'sans-serif'],
'inter': ['Inter', 'system-ui', 'sans-serif'],
'space-grotesk': ['Space Grotesk', 'system-ui', 'sans-serif']
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
fontFamily: {
'poppins': ['Poppins', 'sans-serif'],
'josefin': ['Josefin Sans', 'sans-serif']
},
keyframes: {
pulse: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.6' },
},
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'scale-in': {
'0%': { transform: 'scale(0.8)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
}
},
animation: {
'pulse': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'pulse': 'pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'fade-in': 'fade-in 0.5s ease-out',
'scale-in': 'scale-in 0.3s ease-out',
},
backdropBlur: {
xs: '2px',
},
screens: {
mxxl: { 'max': '1535px' },