feat: add PreJoin page and components with studio controls, microphone meter, platform utils, and design tokens; update styles for prejoin template compatibility

This commit is contained in:
Cesar Mendivil 2025-11-22 00:22:09 -07:00
parent d162014030
commit c408c28185
28 changed files with 6605 additions and 51 deletions

BIN
docs/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
docs/img_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
docs/img_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

BIN
docs/img_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

533
docs/prejoin_template.html Normal file

File diff suppressed because it is too large Load Diff

863
docs/prejoin_ui.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,80 +1,170 @@
/* stylelint-disable */
.controlButton { .controlButton {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 0.5rem;
width: 64px; width: auto;
height: 64px; min-width: 7.5rem;
background: linear-gradient(135deg, var(--au-gray-700) 0%, var(--au-gray-800) 100%); height: 2.75rem;
border: 2px solid var(--au-border-dark); background: linear-gradient(135deg, var(--au-gray-700), var(--au-gray-800));
border: 0.125rem solid rgba(255,255,255,0.06);
color: var(--au-text-primary); color: var(--au-text-primary);
cursor: pointer; cursor: pointer;
border-radius: var(--au-radius-full); border-radius: 0.625rem;
transition: all var(--au-transition-fast); transition: transform 160ms cubic-bezier(.2,.9,.2,1), box-shadow 160ms ease, background 160ms ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12);
position: relative; position: relative;
backdrop-filter: blur(10px); font-size: 1rem;
font-size: 24px;
outline: none; outline: none;
padding: 0 0.875rem;
}
.controlButton span:first-child {
display:inline-flex;
align-items:center;
justify-content:center;
width:1.5rem;
height:1.5rem;
}
/* layout column: icon above label */
.column{
flex-direction: column;
gap: 8px;
padding: 12px 20px;
}
.column span:first-child{ width:24px; height:24px }
/* studio variant: light background, subtle border & shadow to match PreJoin */
.studio{
background: #ffffff;
border: 1px solid #e5e5e5;
color: #666666;
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
border-radius: 8px;
}
/* studio hover / disabled to match prejoin template */
.studio:hover:not(:disabled){
background: #fee2e2;
color: #1a1a1a;
}
.studio[data-active="false"],
.studio:where([data-active="false"]) {
background: #fecaca;
color: #dc2626;
}
.studio[data-active="false"] span:first-child svg{ fill: #b91c1c !important; color: #b91c1c !important }
/* internal hint (tooltip-like) used by studio variant */
.controlHint{
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: #1a1a1a;
color: white;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
pointer-events:none;
transition: opacity 0.2s;
margin-bottom: 8px;
}
.studio:hover .controlHint{ opacity:1 }
/* slash mark over icon when disabled (like template control-icon::after) */
.studio span:first-child{ position: relative }
.studio[data-active="false"] span:first-child::after{
content: '';
position: absolute;
width: 2px;
height: 28px;
background-color: #dc2626;
transform: rotate(-45deg);
left: 50%;
top: 50%;
transform-origin: center;
transform: translate(-50%, -50%) rotate(-45deg);
opacity: 1;
} }
.controlButton:hover:not(:disabled) { .controlButton:hover:not(:disabled) {
background: linear-gradient(135deg, var(--au-gray-600) 0%, var(--au-gray-700) 100%); transform: translateY(-0.125rem) scale(1.01);
border-color: var(--au-primary); box-shadow: 0 0.5rem 1.25rem rgba(2,6,23,0.12);
transform: scale(1.05); }
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
.controlButton:focus-visible {
box-shadow: 0 0.5rem 1.25rem rgba(79,70,229,0.18);
transform: translateY(-0.0625rem);
} }
.controlButton:active:not(:disabled) { .controlButton:active:not(:disabled) {
transform: scale(0.98); transform: translateY(0) scale(0.995);
} }
.controlButton:disabled { .controlButton:disabled {
opacity: 0.5; opacity: 1;
cursor: not-allowed; cursor: not-allowed;
} }
.controlButton.active { .controlButton.active {
background: linear-gradient(135deg, var(--au-primary) 0%, var(--au-primary-hover) 100%); background: linear-gradient(135deg, var(--au-primary), var(--au-primary-hover));
border-color: var(--au-primary); border-color: var(--au-primary);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.5); box-shadow: 0 0.375rem 1.25rem rgba(79, 70, 229, 0.28);
} }
.controlButton.danger { .controlButton.danger {
background: linear-gradient(135deg, var(--au-danger-600) 0%, var(--au-danger-700) 100%); background: linear-gradient(135deg, var(--au-danger-200), var(--au-danger-300));
border-color: var(--au-danger-600); border-color: var(--au-danger-300);
} }
.controlButton.danger:hover:not(:disabled) { .controlButton.danger:hover:not(:disabled) {
background: linear-gradient(135deg, var(--au-danger-500) 0%, var(--au-danger-600) 100%); background: linear-gradient(135deg, var(--au-danger-600), var(--au-danger-500));
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5); box-shadow: 0 0.375rem 1.25rem rgba(239,68,68,0.18);
}
.controlButton[data-active="false"] {
background: linear-gradient(180deg, var(--au-danger-100), var(--au-danger-200));
border-color: var(--au-danger-400);
}
.controlButton[data-active="false"] span:first-child svg,
.controlButton.danger span:first-child svg {
fill: var(--au-danger-700) !important;
color: var(--au-danger-700) !important;
} }
/* Sizes */
.sm { .sm {
width: 48px; min-width: 5.75rem;
height: 48px; height: 2.5rem;
font-size: 20px; font-size: 0.875rem;
} }
.md { .md {
width: 64px; min-width: 7.5rem;
height: 64px; height: 2.75rem;
font-size: 24px; font-size: 1rem;
} }
.lg { .lg {
width: 80px; min-width: 10rem;
height: 80px; height: 3.25rem;
font-size: 32px; font-size: 1.125rem;
} }
.controlButtonLabel { .controlButtonLabel {
font-size: 10px; font-size: 0.875rem;
font-weight: var(--au-font-medium); font-weight: var(--au-font-medium);
margin-top: 2px; margin: 0;
text-transform: uppercase; text-transform: none;
letter-spacing: 0.5px; letter-spacing: 0;
} }
.tooltip { display: inline-block; }

View File

@ -2,6 +2,7 @@ import React from 'react';
import { cn } from '../utils/helpers'; import { cn } from '../utils/helpers';
import type { ComponentBaseProps } from '../types'; import type { ComponentBaseProps } from '../types';
import styles from './ControlButton.module.css'; import styles from './ControlButton.module.css';
import { Tooltip } from './Tooltip';
export interface ControlButtonProps extends ComponentBaseProps { export interface ControlButtonProps extends ComponentBaseProps {
icon?: React.ReactNode; icon?: React.ReactNode;
@ -12,6 +13,10 @@ export interface ControlButtonProps extends ComponentBaseProps {
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
title?: string; title?: string;
hint?: string;
hintPosition?: 'top' | 'bottom' | 'left' | 'right';
layout?: 'row' | 'column';
variant?: 'default' | 'studio';
} }
export const ControlButton: React.FC<ControlButtonProps> = (props) => { export const ControlButton: React.FC<ControlButtonProps> = (props) => {
@ -27,13 +32,19 @@ export const ControlButton: React.FC<ControlButtonProps> = (props) => {
className, className,
style, style,
id, id,
hint,
hintPosition = 'top',
layout = 'row',
variant = 'default',
} = props; } = props;
return ( const button = (
<button <button
className={cn( className={cn(
styles.controlButton, styles.controlButton,
styles[size], styles[size],
layout === 'column' && styles.column,
variant && styles[variant],
active && styles.active, active && styles.active,
danger && styles.danger, danger && styles.danger,
className className
@ -43,13 +54,26 @@ export const ControlButton: React.FC<ControlButtonProps> = (props) => {
title={title} title={title}
style={style} style={style}
id={id} id={id}
data-active={active}
type="button" type="button"
> >
{/* internal hint for studio variant to match template */}
{variant === 'studio' && hint ? <span className={styles.controlHint}>{hint}</span> : null}
{icon && <span>{icon}</span>} {icon && <span>{icon}</span>}
{label && <span className={styles.controlButtonLabel}>{label}</span>} {label && <span className={styles.controlButtonLabel}>{label}</span>}
</button> </button>
); );
// For studio variant we render the hint inside the button (template behavior).
if (variant === 'studio' && hint) return button
return hint ? (
<Tooltip content={hint} position={hintPosition}>
{button}
</Tooltip>
) : (
button
);
}; };
ControlButton.displayName = 'ControlButton'; ControlButton.displayName = 'ControlButton';

View File

@ -0,0 +1,31 @@
:root{
--au-meter-bg: rgba(15,23,42,0.06);
--au-meter-fill: #10b981;
}
.meter{
display:flex;
align-items:flex-end;
justify-content:center;
}
.track{
width:18px;
background: rgba(0,0,0,0.04);
border-radius: 8px;
overflow: hidden;
display:flex;
align-items:flex-end;
justify-content:center;
}
.fill{
width:100%;
background: linear-gradient(180deg, #34d399, #10b981);
transition: height 140ms ease;
height:4%;
}
@media (prefers-color-scheme: light){
.track{ background: #f3f4f6 }
}

View File

@ -0,0 +1,82 @@
import React, { useEffect, useRef, useState } from 'react';
import styles from './MicrophoneMeter.module.css';
export interface MicrophoneMeterProps {
stream?: MediaStream | null;
className?: string;
// altura del medidor en px opcional
height?: number;
}
export const MicrophoneMeter: React.FC<MicrophoneMeterProps> = ({ stream = null, className, height = 80 }) => {
const audioCtxRef = useRef<AudioContext | null>(null);
const analyserRef = useRef<AnalyserNode | null>(null);
const dataRef = useRef<Uint8Array | null>(null);
const rafRef = useRef<number | null>(null);
const [level, setLevel] = useState(0);
useEffect(() => {
if (!stream) return;
let mounted = true;
try {
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
const audioCtx = new AudioCtx();
audioCtxRef.current = audioCtx;
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
analyserRef.current = analyser;
const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
const bufferLength = analyser.frequencyBinCount;
const data = new Uint8Array(bufferLength);
dataRef.current = data;
const tick = () => {
if (!mounted) return;
try {
analyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) {
const v = (data[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / data.length);
// scale to 0..1, clamp
const lvl = Math.min(1, Math.max(0, (rms - 0.01) * 3));
setLevel(lvl);
} catch (e) {
// ignore
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
} catch (e) {
// ignore if audio not possible
}
return () => {
mounted = false;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
try {
analyserRef.current && analyserRef.current.disconnect();
audioCtxRef.current && audioCtxRef.current.close();
} catch (e) {}
audioCtxRef.current = null;
analyserRef.current = null;
dataRef.current = null;
};
}, [stream]);
const fillPercent = Math.round(level * 100);
return (
<div className={`${styles.meter} ${className || ''}`} style={{ height }} aria-hidden>
<div className={styles.track}>
<div className={styles.fill} style={{ height: `${fillPercent}%` }} />
</div>
</div>
);
};
export default MicrophoneMeter;

View File

@ -0,0 +1,20 @@
// filepath: packages/avanza-ui/src/components/PrejoinControlButton.tsx
import React from 'react';
import { ControlButton, type ControlButtonProps } from './ControlButton';
import { cn } from '../utils/helpers';
/**
* Thin wrapper that forces `variant="studio"` and exposes avz classes for prejoin template compatibility.
*/
export const PrejoinControlButton: React.FC<ControlButtonProps> = ({ className, ...props }) => {
return (
<ControlButton
{...props}
variant="studio"
className={cn('avz-control-btn', className)}
/>
);
};
PrejoinControlButton.displayName = 'PrejoinControlButton';

View File

@ -0,0 +1,16 @@
// filepath: packages/avanza-ui/src/components/PrejoinControls.tsx
import React from 'react';
import { cn } from '../utils/helpers';
export interface PrejoinControlsProps extends React.HTMLAttributes<HTMLDivElement> {}
export const PrejoinControls: React.FC<PrejoinControlsProps> = ({ children, className, ...rest }) => {
return (
<div className={cn('avz-controls', className)} {...rest}>
{children}
</div>
);
};
PrejoinControls.displayName = 'PrejoinControls';

View File

@ -1,6 +1,8 @@
// Styles // Styles
import './styles/globals.css'; import './styles/globals.css';
import './styles/controls.css'; import './styles/controls.css';
import './tokens.css';
import './styles/prejoin.css';
// Components // Components
export { Button } from './components/Button'; export { Button } from './components/Button';
@ -85,8 +87,20 @@ export type { SceneCardProps } from './components/SceneCard';
export { VideoTile } from './components/VideoTile'; export { VideoTile } from './components/VideoTile';
export type { VideoTileProps, ConnectionQuality } from './components/VideoTile'; export type { VideoTileProps, ConnectionQuality } from './components/VideoTile';
export { MicrophoneMeter } from './components/MicrophoneMeter';
export type { MicrophoneMeterProps } from './components/MicrophoneMeter';
export { PrejoinControls } from './components/PrejoinControls';
export { PrejoinControlButton } from './components/PrejoinControlButton';
export type { PrejoinControlsProps } from './components/PrejoinControls';
// Tokens
export { tokens } from './tokens';
export type { Tokens } from './tokens';
// Types // Types
export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './types'; export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './types';
// Utils // Utils
export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers'; export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers';
export { modifierKeyLabel, isMacPlatform, isWindowsPlatform, isLinuxPlatform } from './utils/platform';

View File

@ -12,7 +12,17 @@
--au-primary-hover: #4338ca; --au-primary-hover: #4338ca;
--au-success-500: #10b981; --au-success-500: #10b981;
--au-warning-500: #f59e0b; --au-warning-500: #f59e0b;
/* danger color moved to danger palette below */
/* Danger palette (shades) */
--au-danger-100: #fff5f5;
--au-danger-200: #fee2e2;
--au-danger-300: #fed7d7;
--au-danger-400: #fca5a5;
--au-danger-500: #ef4444; --au-danger-500: #ef4444;
--au-danger-600: #fb7185;
--au-danger-700: #c53030;
--au-danger-800: #9b1f1f;
--au-text-primary: #f1f5f9; --au-text-primary: #f1f5f9;
--au-text-secondary: #cbd5e1; --au-text-secondary: #cbd5e1;
@ -107,6 +117,13 @@ button { font-family: inherit }
--studio-success: var(--au-success-500); --studio-success: var(--au-success-500);
--studio-warning: var(--au-warning-500); --studio-warning: var(--au-warning-500);
--studio-danger: var(--au-danger-500); --studio-danger: var(--au-danger-500);
--studio-danger-100: var(--au-danger-100);
--studio-danger-200: var(--au-danger-200);
--studio-danger-300: var(--au-danger-300);
--studio-danger-400: var(--au-danger-400);
--studio-danger-500: var(--au-danger-500);
--studio-danger-600: var(--au-danger-600);
--studio-danger-700: var(--au-danger-700);
--studio-recording: var(--au-danger-500); --studio-recording: var(--au-danger-500);
--studio-recording-pulse: rgba(239, 68, 68, 0.12); --studio-recording-pulse: rgba(239, 68, 68, 0.12);

View File

@ -0,0 +1,26 @@
/* filepath: packages/avanza-ui/src/styles/prejoin.css */
@import './studio-theme.css';
:root{
--au-prejoin-badge: rgba(99,102,241,0.9);
--au-prejoin-danger: var(--studio-danger, #ef4444);
}
/* Reusable PreJoin tokens and classes for avanza-ui */
.avz-prejoin-container{ max-width: 628px; margin: 0 auto; padding: 20px }
.avz-prejoin-card{ background: var(--studio-bg-elevated); border-radius: 12px; padding: 0 }
.avz-prejoin-header{ text-align:center; margin-bottom:24px }
.avz-prejoin-title{ font-size:28px; font-weight:600; color:var(--studio-text-primary) }
.avz-prejoin-note{ font-size:14px; color:var(--studio-text-secondary) }
.avz-video-preview{ border-radius:12px; overflow:hidden; background:var(--studio-bg-tertiary); position:relative; aspect-ratio:16/9 }
.avz-badge{ position:absolute; bottom:16px; left:16px; background:var(--au-prejoin-badge); color:#fff; padding:8px 20px; border-radius:20px; font-weight:500 }
.avz-controls{ display:inline-flex; justify-content:center; gap:8px; padding:12px; background:var(--studio-bg-elevated); border:1px solid var(--studio-border); border-radius:12px }
.avz-control-btn{ display:flex; flex-direction:column; align-items:center; gap:8px; padding:12px 20px; border-radius:8px; cursor:pointer; color:var(--studio-text-secondary) }
.avz-control-btn:hover{ background:var(--studio-bg-hover); color:var(--studio-text-primary) }
.avz-control-btn--danger{ color:var(--au-prejoin-danger); background:rgba(254,202,202,1) }
.avz-input{ width:100%; padding:12px 16px; border-radius:8px; border:1px solid var(--studio-border) }
.avz-submit{ width:100%; padding:14px; background:var(--studio-accent); color:#fff; border-radius:8px }

View File

@ -0,0 +1,83 @@
:root {
/* Colors */
--color-bg: #ffffff;
--color-text: #1a1a1a;
--color-muted: #666666;
--color-video-bg: #0a0a1a;
--color-badge-bg: rgba(99, 102, 241, 0.9); /* #6366f1 @ 0.9 */
--color-mic-bg: #f8f9fa;
--color-muted-light: #e8e8e8;
--color-mic-from: #22c55e;
--color-mic-to: #86efac;
--color-control-border: #e5e5e5;
--color-control-hover-bg: #fee2e2;
--color-control-disabled-bg: #fecaca;
--color-control-disabled-text: #dc2626;
--color-kbd-bg: #374151;
--color-info: #3b82f6;
--color-input-border: #d1d5db;
--color-input-placeholder: #9ca3af;
--color-submit: #2563eb;
--color-submit-hover: #1d4ed8;
--color-submit-active: #1e40af;
/* Typography */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--fs-h1: 1.75rem; /* 28px */
--fs-body: 0.875rem; /* 14px */
--fs-small: 0.6875rem; /* 11px */
--fs-btn: 0.8125rem; /* 13px */
--fs-submit: 0.9375rem; /* 15px */
/* Radii */
--radius-lg: 12px;
--radius-md: 8px;
--radius-pill: 20px;
--radius-meter: 16px;
/* Layout */
--container-max-width: 628px;
--gap-default: 16px;
/* Shadows */
--shadow-controls: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
/* Misc */
--kbd-padding: 2px 6px;
}
/* Small helper utilities used by avanza-ui components when not using Tailwind */
.avz-kbd {
background-color: var(--color-kbd-bg);
padding: var(--kbd-padding);
border-radius: 3px;
font-family: monospace;
font-size: var(--fs-small);
color: #fff;
}
.avz-badge {
background: var(--color-badge-bg);
color: #fff;
padding: 8px 20px;
border-radius: var(--radius-pill);
font-size: 0.875rem;
font-weight: 500;
}
.avz-preview {
background: var(--color-video-bg);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
}
.avz-control-shadow {
box-shadow: var(--shadow-controls);
}

View File

@ -0,0 +1,46 @@
export const tokens = {
colors: {
bg: '#ffffff',
text: '#1a1a1a',
muted: '#666666',
videoBg: '#0a0a1a',
badgeBg: 'rgba(99,102,241,0.9)',
micFrom: '#22c55e',
micTo: '#86efac',
controlBorder: '#e5e5e5',
controlHoverBg: '#fee2e2',
controlDisabledBg: '#fecaca',
controlDisabledText: '#dc2626',
kbdBg: '#374151',
info: '#3b82f6',
inputBorder: '#d1d5db',
inputPlaceholder: '#9ca3af',
submit: '#2563eb',
submitHover: '#1d4ed8',
submitActive: '#1e40af',
},
radii: {
lg: '12px',
md: '8px',
pill: '20px',
meter: '16px',
},
sizes: {
containerMaxWidth: '628px',
gapDefault: '16px',
},
typography: {
fontSans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
fsH1: '1.75rem',
fsBody: '0.875rem',
fsSmall: '0.6875rem',
fsBtn: '0.8125rem',
fsSubmit: '0.9375rem',
},
shadows: {
controls: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',
},
} as const;
export type Tokens = typeof tokens;

View File

@ -0,0 +1,33 @@
// Utility to detect platform and provide modifier key label
export const isBrowser = typeof window !== 'undefined' && typeof navigator !== 'undefined';
export function isMacPlatform(): boolean {
if (!isBrowser) return false;
const platform = navigator.platform || '';
const ua = navigator.userAgent || '';
return /Mac|iPhone|iPad|iPod/i.test(platform) || /Macintosh/i.test(ua);
}
export function isWindowsPlatform(): boolean {
if (!isBrowser) return false;
const platform = navigator.platform || '';
return /Win(dows)?/i.test(platform);
}
export function isLinuxPlatform(): boolean {
if (!isBrowser) return false;
const platform = navigator.platform || '';
return /Linux|X11/i.test(platform) && !/Android/i.test(navigator.userAgent || '');
}
export function modifierKeyLabel(): { key: 'Meta' | 'Ctrl'; display: string } {
return isMacPlatform() ? { key: 'Meta', display: '⌘' } : { key: 'Ctrl', display: 'CTRL' };
}
export default {
isMacPlatform,
isWindowsPlatform,
isLinuxPlatform,
modifierKeyLabel,
};

View File

@ -0,0 +1,259 @@
/* filepath: /home/xesar/Documentos/Nextream/AvanzaCast/packages/broadcast-panel/src/features/studio/PreJoin.module.css */
:root{
--card-bg: var(--studio-bg-elevated, #ffffff);
--muted: var(--studio-text-secondary, #6b7280);
--accent: var(--studio-accent, #4f46e5);
--badge-bg: rgba(99,102,241,0.9); /* keep template purple */
--danger: var(--studio-danger, #ef4444);
--danger-700: #b91c1c;
}
.prejoinContainer{
max-width: 628px;
margin: 0 auto;
padding: 20px;
}
.card{
background: var(--card-bg);
border-radius: 12px;
padding: 0;
box-shadow: none;
}
.header{
text-align: center;
margin-bottom: 24px;
}
.header > div:first-child{
font-size: 28px;
font-weight: 600;
color: var(--studio-text-primary, #1a1a1a);
margin-bottom: 8px;
}
.note{
font-size: 14px;
color: var(--studio-text-secondary, #666666);
line-height: 1.5;
}
/* layout: video + mic-status side panel like template */
.contentRow{
display: flex;
gap: 16px;
margin-bottom: 24px;
align-items: flex-start;
}
.previewColumn{
flex: 1;
}
.previewCard{
border-radius: 12px;
overflow: hidden;
background: var(--studio-bg-tertiary, #0a0a1a);
position: relative;
aspect-ratio: 16/9;
}
.videoEl{
width: 100%;
height: 100%;
object-fit: cover;
background: #0b0b0b;
}
.badge{
position: absolute;
bottom: 16px;
left: 16px;
background: var(--badge-bg);
color: #fff;
padding: 8px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 20px;
box-shadow: none;
}
.micPanel{
background-color: var(--studio-bg-secondary, #f8f9fa);
border-radius: 12px;
padding: 20px;
min-width: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.mic-icon{
width: 48px;
height: 48px;
background-color: #e8e8e8;
border-radius: 50%;
display:flex;
align-items:center;
justify-content:center;
margin-bottom:12px;
}
.mic-meter{
width: 32px;
height: 80px;
background-color: #e8e8e8;
border-radius: 16px;
margin-bottom: 12px;
position: relative;
overflow: hidden;
}
.mic-level{
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 20%;
background: linear-gradient(to top, #22c55e, #86efac);
border-radius: 16px;
transition: height 0.1s ease-out;
}
.micStatus{
color: #22c55e;
font-weight: 500;
font-size: 14px;
text-align: center;
margin-bottom: 4px;
}
.mic-device{
font-size: 11px;
color: var(--studio-text-disabled, #999999);
text-align: center;
}
/* Controls wrapper (segmented) - copiar exactamente del template */
.controlsRow{
display: inline-flex;
justify-content: center;
gap: 8px;
padding: 12px;
background-color: var(--card-bg);
border: 1px solid var(--studio-border, #e5e5e5);
border-radius: 12px;
margin-bottom: 24px;
margin-left: auto;
margin-right: auto;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
}
.controlButtonLocal{
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
background: transparent;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 13px;
transition: all 0.2s;
padding: 12px 20px;
border-radius: 8px;
position: relative;
}
/* hover and active states like template */
.controlButtonLocal:hover{
color: var(--studio-text-primary, #1a1a1a);
background-color: rgba(254,226,226,1); /* template fee2e2 */
}
/* disabled / error */
.controlsRow > button[data-active="false"],
.controlButtonLocal.disabled{
color: var(--danger, #dc2626);
background-color: rgba(254,202,202,1);
}
.controlButtonLocal.disabled:hover,
.controlsRow > button[data-active="false"]:hover{
color: var(--danger-700, #b91c1c);
background-color: rgba(252,165,165,1);
}
.controlButtonLocal > span:first-child{
width:24px;
height:24px;
display:inline-flex;
align-items:center;
justify-content:center;
}
.controlButtonLocal > span:first-child svg{
width:24px;
height:24px;
}
.controlButtonLocal .controlButtonLabel{
font-size:13px;
margin:0;
}
/* hints (tooltip) */
.control-hint{
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background-color: var(--studio-text-primary, #1a1a1a);
color: white;
padding:6px 12px;
border-radius:6px;
font-size:12px;
white-space:nowrap;
opacity:0;
pointer-events:none;
transition: opacity 0.2s;
margin-bottom:8px;
}
.controlButtonLocal:hover .control-hint{ opacity:1 }
/* Form elements */
.roomTitle{
margin-top: 8px;
margin-bottom: 8px;
font-weight:500;
color: var(--studio-text-primary, #1a1a1a);
}
.input{
width:100%;
padding:12px 16px;
border-radius:8px;
border:1px solid var(--studio-border, #d1d5db);
font-size:14px;
margin-bottom:16px;
}
.input:focus{ outline:none; box-shadow: 0 0 0 3px rgba(59,130,246,0.1); border-color:var(--studio-accent, #3b82f6) }
.actions{ display:flex; gap:12px; margin-top:8px }
.cancelBtn{ background:transparent; border: none; padding:10px 14px; border-radius:8px }
.primaryBtn{ background:var(--studio-accent, #2563eb); color:#fff; padding:12px 18px; border-radius:8px; border:none }
.shortcutsLegend{ text-align:center; color: var(--studio-text-muted, #9ca3af); margin-top: 8px }
.kbd{ background-color: #374151; padding: 2px 6px; border-radius:3px; font-family: monospace; font-size:11px; margin: 0 2px }
@media (max-width: 768px){
.contentRow{ flex-direction: column; }
.controlsRow{ display:flex; flex-direction: column; gap:8px; padding:0; background:transparent; box-shadow:none; border:none }
.controlButtonLocal{ width:100%; padding:12px 16px }
.previewCard{ aspect-ratio: 16/9; }
.micPanel{ min-width: auto; width: 100%; }
}

View File

@ -0,0 +1,215 @@
import React, { useEffect, useRef, useState } from 'react'
import styles from './PreJoin.module.css'
import { ControlBar, ControlButton, MicrophoneMeter, modifierKeyLabel, isMacPlatform } from 'avanza-ui'
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
type Props = {
roomName?: string
onProceed: () => void
onCancel?: () => void
serverUrl?: string
token?: string
}
export default function PreJoin({ roomName, onProceed, onCancel }: Props) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [name, setName] = useState(() => {
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
})
const [micEnabled, setMicEnabled] = useState(true)
const [camEnabled, setCamEnabled] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isChecking, setIsChecking] = useState(false)
// checkbox state is local only; do NOT persist skip preference so PreJoin always appears
const [skipNextTime, setSkipNextTime] = useState<boolean>(false)
// keep preview stream active for meter and preview
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null)
// Use shared platform utils
const isMac = isMacPlatform()
const modLabel = modifierKeyLabel()
const micHint = `${modLabel.display} + D`
const camHint = `${modLabel.display} + E`
useEffect(() => {
// ensure any old skip flag does not affect behavior: remove legacy key
try { localStorage.removeItem('broadcast:skipPrejoin') } catch (e) {}
// request preview stream whenever toggles change
let mounted = true
let localStream: MediaStream | null = null
;(async () => {
try {
if (!navigator?.mediaDevices?.getUserMedia) return
localStream = await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: camEnabled })
if (!mounted) {
try { localStream.getTracks().forEach(t => t.stop()) } catch (e) {}
return
}
setPreviewStream(localStream)
if (videoRef.current) {
videoRef.current.srcObject = localStream
videoRef.current.play().catch(() => {})
}
} catch (e: any) {
// ignore permission errors
}
})()
// Keyboard shortcuts: toggle mic/camera. Support Ctrl on Windows/Linux and Meta (⌘) on macOS.
const onKeyDown = (e: KeyboardEvent) => {
// ignore when focused on input/textarea or when modifier keys conflict with browser shortcuts
const active = document.activeElement;
const tag = active && (active as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (active as HTMLElement)?.isContentEditable) return;
const mod = isMac ? e.metaKey : e.ctrlKey
// Mod + D -> toggle mic
if (mod && !e.shiftKey && (e.key === 'd' || e.key === 'D')) {
e.preventDefault()
setMicEnabled(v => !v)
}
// Mod + E -> toggle camera (requested)
if (mod && !e.shiftKey && (e.key === 'e' || e.key === 'E')) {
e.preventDefault()
setCamEnabled(v => !v)
}
// Mod + Shift + C -> alternate camera shortcut (also supported)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'c' || e.key === 'C')) {
e.preventDefault()
setCamEnabled(v => !v)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
mounted = false
if (localStream) {
try { localStream.getTracks().forEach(t => t.stop()) } catch (e) {}
}
// clear previewStream state
setPreviewStream(null)
window.removeEventListener('keydown', onKeyDown)
}
}, [micEnabled, camEnabled])
const handleProceed = async () => {
setError(null)
setIsChecking(true)
try {
// request permissions explicitly
await navigator.mediaDevices.getUserMedia({ audio: micEnabled, video: camEnabled })
// save name
try { if (name) localStorage.setItem('avanzacast_user', name) } catch (e) {}
// proceed to connect
onProceed()
} catch (e: any) {
setError(e?.message || 'No se pudo acceder a la cámara/micrófono')
} finally {
setIsChecking(false)
}
}
const toggleMic = async () => {
setMicEnabled(v => !v)
}
const toggleCam = async () => {
setCamEnabled(v => !v)
}
return (
<div className={styles.prejoinContainer}>
<div className={styles.card}>
<div className={styles.header}>
<div>Configura tu estudio</div>
<div className={styles.note}>Entrar al estudio no iniciará automáticamente la transmisión.</div>
</div>
<div className={styles.contentRow}>
<div className={styles.previewColumn}>
<div className={styles.previewCard}>
<video ref={videoRef} className={styles.videoEl} playsInline muted />
<div className={styles.badge}>{name || 'Invitado'}</div>
<div className={styles.micPanel}>
<div className={styles.micPanelInner}>
<MicrophoneMeter level={previewStream ? 1 : 0} />
<div className={styles.micStatus}>{micEnabled ? 'El micrófono está funcionando' : 'Micrófono desactivado'}</div>
</div>
</div>
</div>
<div className={styles.controlsRow}>
<ControlButton
className={styles.controlButtonLocal}
icon={<FiMic />}
label={micEnabled ? 'Desactivar audio' : 'Activar audio'}
active={micEnabled}
danger={!micEnabled}
layout="column"
variant="studio"
onClick={toggleMic}
hint={micHint}
size="md"
/>
<ControlButton
className={styles.controlButtonLocal}
icon={<FiVideo />}
label={camEnabled ? 'Detener cámara' : 'Iniciar cámara'}
active={camEnabled}
danger={!camEnabled}
layout="column"
variant="studio"
onClick={toggleCam}
hint={camHint}
size="md"
/>
<ControlButton
className={styles.controlButtonLocal}
icon={<FiSettings />}
label={'Configuración'}
active={true}
layout="column"
variant="studio"
onClick={() => { /* abrir modal de settings si aplica */ }}
size="md"
/>
</div>
{/* Leyenda de atajos: muestra las combinaciones detectadas (ej: ⌘ + D) */}
<div className={styles.shortcutsLegend} aria-hidden="true">
Atajos: <span className={styles.kbd}>{micHint}</span> mic · <span className={styles.kbd}>{camHint}</span> cámara
</div>
</div>
<div className={styles.sideColumn}>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.roomTitle}>Nombre para mostrar</div>
<input className={styles.input} value={name} onChange={e => setName(e.target.value)} placeholder="Tu nombre" />
<div className={styles.roomTitle}>Título (opcional)</div>
<input className={styles.input} placeholder="p. ej.: Founder of Creativity Inc" />
<div className={styles.checkboxRow}>
<input id="skipNext" type="checkbox" checked={skipNextTime} onChange={e => setSkipNextTime(e.target.checked)} />
<label htmlFor="skipNext">Omitir PreJoin la próxima vez</label>
</div>
<div className={styles.actions}>
<button className={styles.cancelBtn} onClick={() => { onCancel?.() }}>Cancelar</button>
<button className={styles.primaryBtn} onClick={handleProceed} disabled={isChecking}>{isChecking ? 'Comprobando...' : 'Entrar al estudio'}</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from "react";
import StudioRoom from "./StudioRoom"; import StudioRoom from "./StudioRoom";
import "./StudioPortal.css"; import "./StudioPortal.css";
import { Room } from "livekit-client"; import { Room } from "livekit-client";
import AutoRequestAndInject from "./AutoRequestAndInject"; import AutoRequestAndInject from './AutoRequestAndInject'
import PreJoin from './PreJoin'
export interface StudioPortalProps { export interface StudioPortalProps {
serverUrl: string; serverUrl: string;
@ -47,6 +48,8 @@ export default function StudioPortal({
// New: tokenFromMessage state and connectError // New: tokenFromMessage state and connectError
const [tokenFromMessage, setTokenFromMessage] = useState<string | null>(null); const [tokenFromMessage, setTokenFromMessage] = useState<string | null>(null);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
// Always show PreJoin at startup (rendered as a separate page)
const [showPreJoin, setShowPreJoin] = useState<boolean>(true);
// Connect function used by UI or auto when token arrives // Connect function used by UI or auto when token arrives
const connectWithToken = async (useToken?: string, useServer?: string) => { const connectWithToken = async (useToken?: string, useServer?: string) => {
@ -81,6 +84,26 @@ export default function StudioPortal({
} }
}; };
// If we have a token and we're not connected, show PreJoin as a separate page
if (!isExternalRoom && (tokenFromMessage || token) && (tokenFromMessage || token).trim() && !isConnected && showPreJoin) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f3f4f6', padding: 20 }}>
<PreJoin
roomName={roomName}
onProceed={() => {
// hide prejoin and connect
setShowPreJoin(false);
setTimeout(() => connectWithToken(), 50);
}}
onCancel={() => {
// user cancelled prejoin: hide it so portal UI appears (no auto-connect)
setShowPreJoin(false);
}}
/>
</div>
)
}
const disconnectLocalRoom = () => { const disconnectLocalRoom = () => {
try { try {
if (localRoomRef.current) { if (localRoomRef.current) {
@ -103,10 +126,11 @@ export default function StudioPortal({
!isConnected && !isConnected &&
!isConnecting !isConnecting
) { ) {
connectWithToken(); // If prejoin shown, wait for user to confirm; otherwise auto-connect
if (!showPreJoin) connectWithToken();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, tokenFromMessage, serverUrl]); }, [token, tokenFromMessage, serverUrl, showPreJoin]);
// Listen for postMessage tokens from Broadcast Panel (or parent) // Listen for postMessage tokens from Broadcast Panel (or parent)
useEffect(() => { useEffect(() => {
@ -280,15 +304,27 @@ export default function StudioPortal({
</div> </div>
<div className={`preview-wrapper ${activeLayout}`}> <div className={`preview-wrapper ${activeLayout}`}>
<StudioRoom {(!isExternalRoom && (tokenFromMessage || token) && !isConnected && showPreJoin) ? (
serverUrl={serverUrl} <PreJoin
token={tokenFromMessage || token} roomName={roomName}
roomName={roomName} onProceed={() => {
onConnected={onRoomConnected} setShowPreJoin(false)
onDisconnected={onRoomDisconnected} // allow effect to pick up and connect
room={effectiveRoom} setTimeout(() => connectWithToken(), 50)
/> }}
</div> onCancel={() => { setShowPreJoin(false) }}
/>
) : (
<StudioRoom
serverUrl={serverUrl}
token={tokenFromMessage || token}
roomName={roomName}
onConnected={onRoomConnected}
onDisconnected={onRoomDisconnected}
room={effectiveRoom}
/>
)}
</div>
<div className="controls-bar"> <div className="controls-bar">
<div className="layout-presets"> <div className="layout-presets">

View File

@ -57,12 +57,37 @@ module.exports = {
800: '#1e40af', 800: '#1e40af',
900: '#1e3a8a', 900: '#1e3a8a',
}, },
/* Prejoin-specific tokens */
video: '#0a0a1a',
badge: '#6366f1',
'badge-alpha': 'rgba(99,102,241,0.9)',
mic: {
from: '#22c55e',
to: '#86efac',
},
control: {
border: '#e5e5e5',
hover: '#fee2e2',
disabledBg: '#fecaca',
disabledText: '#dc2626',
},
kbd: '#374151',
input: {
border: '#d1d5db',
placeholder: '#9ca3af',
},
submit: {
DEFAULT: '#2563eb',
hover: '#1d4ed8',
active: '#1e40af',
},
}, },
boxShadow: { boxShadow: {
sm: '0 2px 4px 0 rgb(60 72 88 / 0.15)', sm: '0 2px 4px 0 rgb(60 72 88 / 0.15)',
DEFAULT: '0 0 3px rgb(60 72 88 / 0.15)', DEFAULT: '0 0 3px rgb(60 72 88 / 0.15)',
md: '0 5px 13px rgb(60 72 88 / 0.20)', md: '0 5px 13px rgb(60 72 88 / 0.20)',
lg: '0 10px 25px -3px rgb(60 72 88 / 0.15)', lg: '0 10px 25px -3px rgb(60 72 88 / 0.15)',
controls: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',
}, },
spacing: { spacing: {
0.75: '0.1875rem', 0.75: '0.1875rem',
@ -72,6 +97,18 @@ module.exports = {
'1200': '71.25rem', '1200': '71.25rem',
'992': '60rem', '992': '60rem',
'768': '45rem', '768': '45rem',
'prejoin-container': '628px',
},
borderRadius: {
panel: '12px',
btn: '8px',
pill: '20px',
meter: '16px',
},
fontSize: {
'h1-prejoin': '28px',
'btn-sm': '13px',
'btn-lg': '15px',
}, },
}, },
}, },