feat(prejoin): add analyzing/loading screen with illustration and spinner; ensure minimum display time during token validation; update styles for compact controls and improved responsiveness

This commit is contained in:
Cesar Mendivil 2025-11-25 11:39:58 -07:00
parent adbec08f5e
commit 08aca81ab1
3 changed files with 306 additions and 36 deletions

View File

@ -34,15 +34,17 @@
display: flex;
gap: 16px;
margin-bottom: 24px;
min-width: 0; /* allow flex children to shrink appropriately but avoid container overflow */
}
.video-preview {
flex: 1;
flex: 1 1 auto; /* allow grow/shrink but respect min-width */
background-color: #0a0a1a;
border-radius: 12px;
aspect-ratio: 16/9;
position: relative;
overflow: hidden;
min-width: 320px; /* prevent video from collapsing when other elements change */
}
.user-badge {
@ -66,6 +68,7 @@
align-items: center;
justify-content: center;
min-width: 180px;
flex: 0 0 180px; /* fixed column so video size remains stable */
}
.mic-icon {
@ -123,9 +126,10 @@
}
.controls {
display: inline-flex;
justify-content: center;
gap: 8px;
/* Compact extreme: columns fit content tightly */
display: grid;
grid-template-columns: repeat(3, min-content);
gap: 6px;
padding: 12px;
background-color: #ffffff;
border: 1px solid #e5e5e5;
@ -134,6 +138,11 @@
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);
align-items: center;
width: 100%;
max-width: 490px; /* cap width to avoid overflow */
box-sizing: border-box;
overflow: visible; /* allow tooltips to overflow and be visible */
}
.controls-wrapper { text-align: center; }
@ -142,24 +151,92 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 6px; /* espacio mínimo entre icono y texto */
background: transparent;
border: none;
cursor: pointer;
color: #666666;
font-size: 13px;
transition: all 0.2s;
padding: 12px 20px;
border-radius: 8px;
font-size: 16px; /* texto aún más compacto */
transition: all 0.08s;
padding: 8px 24px; /* espacio interior mínimo extremo */
border-radius: 6px;
position: relative;
box-sizing: border-box;
min-width: 0; /* allow shrink to content */
min-height: 56px; /* altura compacta extrema */
flex: 0 0 auto;
}
/* Ensure the control label occupies a fixed area so buttons keep identical dimensions */
.control-btn > span:last-child {
display: block;
width: 100%;
text-align: center;
min-height: 14px; /* area mínima para la etiqueta extrema */
line-height: 14px;
white-space: nowrap; /* evitar wrap */
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
}
/* center content vertically in the button to avoid shifts */
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center; /* center vertically */
}
/* ensure disabled class doesn't alter sizing */
.disabled {
color: #dc2626;
background-color: #fecaca;
/* mantener medidas compactas */
min-height: 56px;
padding: 8px 24px;
box-sizing: border-box;
}
.control-btn:hover { color: #1a1a1a; background-color: #fee2e2; }
/* standalone disabled class for CSS modules mapping */
.disabled {
color: #dc2626;
background-color: #fecaca;
}
.disabled:hover {
color: #b91c1c;
background-color: #fca5a5;
}
/* keep existing combined selector to ensure icon strike-through works */
.control-btn.disabled { color: #dc2626; background-color: #fecaca; }
.control-btn.disabled:hover { color: #b91c1c; background-color: #fca5a5; }
.control-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; position: relative; }
.control-icon {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin-bottom: 1px; /* espacio mínimo entre icono y etiqueta */
}
/* diagonal strike-through on icons when disabled (matches template) */
.control-icon::after {
content: '';
position: absolute;
width: 2px;
height: 28px;
background-color: #dc2626;
transform: rotate(-45deg);
opacity: 0;
transition: opacity 0.2s;
}
.control-btn.disabled .control-icon::after { opacity: 1; }
.control-hint {
position: absolute;
@ -176,11 +253,24 @@
pointer-events: none;
transition: opacity 0.2s;
margin-bottom: 8px;
z-index: 40; /* ensure tooltip is above other elements */
}
.control-hint::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #1a1a1a;
}
.control-btn { position: relative; }
.control-btn:hover .control-hint { opacity: 1; }
.kbd { background-color: #374151; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 11px; color: #fff; }
.kbd { background-color: #374151; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 11px; color: #fff; margin: 0 2px; }
.form-group { margin-bottom: 20px; }
@ -201,8 +291,86 @@
.submit-btn:hover { background-color: #1d4ed8; }
.submit-btn:active { background-color: #1e40af; }
/* Analyzing / loading screen shown before PreJoin while token is validated */
.analyzing-header { text-align: center; margin-bottom: 18px; }
.analyzing-header h1 { font-size: 22px; margin: 0; color: #111827; }
.analyzing-box {
/* Make the analyzing box span edge-to-edge within the .container by
compensating the container padding (20px). This removes side gaps. */
width: calc(100% + 40px); /* full width plus the container padding both sides */
max-width: none;
margin: 8px -20px 18px -20px; /* pull edges to container edges */
border-radius: 10px;
display: block;
/* reduced internal padding so the reduced (70%) image sits tighter */
padding: 2px 6px;
box-sizing: border-box;
}
.analyzing-illustration {
width: 100%;
/* enforce 16:9 area */
aspect-ratio: 16 / 9;
overflow: hidden;
display: flex; /* center the inner image */
align-items: center;
justify-content: center;
border-radius: inherit; /* match container rounding */
margin: 0; /* ensure no extra spacing */
}
.analyzing-illustration img,
.analyzing-illustration svg {
/* reduce the visible artwork to 70% of the box and center it */
width: 70%;
height: auto;
display: block;
object-fit: contain;
border-radius: inherit; /* ensure image corners are rounded */
}
.analyzing-note {
color: #6b7280;
font-size: 14px;
text-align: center;
margin: 12px auto 18px auto;
max-width: 520px;
}
.analyzing-loader {
display: flex;
gap: 12px;
align-items: center;
justify-content: center;
margin-top: 8px;
}
/* spinner: simple circular loader */
.spinner {
width: 28px;
height: 28px;
border-radius: 50%;
border: 3px solid rgba(0,0,0,0.08);
border-top-color: rgba(0,0,0,0.28);
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
color: #6b7280;
font-size: 14px;
}
/* responsive */
@media (max-width: 800px) {
.video-container { flex-direction: column; }
.mic-status { min-width: unset; width: 100%; }
.video-preview { min-width: unset; width: 100%; }
}
/* Responsive: on narrow screens switch to fluid columns to avoid overflow */
@media (max-width: 540px) {
.controls { grid-template-columns: repeat(3, minmax(0, 1fr)); max-width: 100%; }
.control-btn { min-width: unset; padding: 4px 6px; }
/* On very narrow screens, avoid negative margins causing overflow */
.analyzing-box { width: 100%; margin-left: 0; margin-right: 0; padding-left: 8px; padding-right: 8px; }
}

View File

@ -5,16 +5,30 @@ import styles from './PreJoin.module.css'
import { isMacPlatform } from 'avanza-ui'
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
// Fallback static list in case dynamic discovery fails. Match actual files present in
// packages/broadcast-panel/public/assets/images/loadingprejoin
const PRELOAD_IMAGES = [
'/assets/images/loadingprejoin/card-preloading-1.png',
'/assets/images/loadingprejoin/card-preloading-2.png',
'/assets/images/loadingprejoin/card-preloading-3.png',
'/assets/images/loadingprejoin/card-preloading-4.png',
'/assets/images/loadingprejoin/card-preloading-5.png',
];
/* PRELOAD_DIR removed: we now use the explicit PRELOAD_IMAGES list and select a random index directly. */
type Props = {
roomName?: string
onProceed: () => void
onCancel?: () => void
serverUrl?: string
token?: string
isAnalyzing?: boolean
}
export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onCancel }: Props) {
export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onCancel, isAnalyzing = false }: Props) {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [preloadImage, setPreloadImage] = useState<string>(PRELOAD_IMAGES[0])
const [name, setName] = useState(() => {
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
})
@ -27,9 +41,31 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
// Use shared platform utils
const isMac = isMacPlatform()
// Select a random preload image by constructing card-preloading-${i}.png
useEffect(() => {
let mounted = true
try {
const max = PRELOAD_IMAGES.length || 5
const i = Math.floor(Math.random() * max) + 1 // 1..max
const src = `/assets/images/loadingprejoin/card-preloading-${i}.png`
if (mounted) setPreloadImage(src)
// preload selected image only
const img = new Image(); img.src = src
} catch (e) {
try { if (mounted) setPreloadImage(PRELOAD_IMAGES[0]) } catch (ee) {}
}
return () => { mounted = false }
}, [])
useEffect(() => {
// ensure any old skip flag does not affect behavior: remove legacy key
try { localStorage.removeItem('broadcast:skipPrejoin') } catch (e) {}
// if token is being analyzed, skip requesting media to avoid permission prompts
if (isAnalyzing) {
setPreviewStream(null)
return
}
// request preview stream whenever toggles change
let mounted = true
let localStream: MediaStream | null = null
@ -95,7 +131,7 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
setPreviewStream(null)
window.removeEventListener('keydown', onKeyDown)
}
}, [micEnabled, camEnabled])
}, [micEnabled, camEnabled, isAnalyzing])
const handleProceed = async () => {
setIsChecking(true)
@ -121,6 +157,31 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
setCamEnabled(v => !v)
}
// If analyzing the token, show the loading/analyzing UI instead of prejoin
if (isAnalyzing) {
return (
<div className={styles.container}>
<div className={styles['analyzing-header']}>
<h1>Entrar al estudio</h1>
</div>
<div className={styles['analyzing-box']}>
<div className={styles['analyzing-illustration']} aria-hidden>
{/* Use alternating preload image (keeps 16:9) */}
<img src={preloadImage} alt="Preloading illustration" />
</div>
</div>
<p className={styles['analyzing-note']}>Consejo profesional: recuerda saludar a los espectadores que verán la repetición.</p>
<div className={styles['analyzing-loader']}>
<div className={styles.spinner} aria-hidden></div>
<div className={styles['loading-text']}>Cargando</div>
</div>
</div>
)
}
return (
<div className={styles.container}>
<div className={styles.header}>
@ -154,20 +215,26 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onC
<div className={styles['controls-wrapper']}>
<div className={styles.controls}>
<button className={`control-btn ${micEnabled ? '' : 'disabled'}`} onClick={toggleMic} aria-pressed={micEnabled}>
<button className={`${styles['control-btn']} ${micEnabled ? '' : styles.disabled}`} onClick={toggleMic} aria-pressed={micEnabled}>
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>D</span></span>
<div className={styles['control-icon']}><FiMic /></div>
<div className={styles['control-icon']}>
<FiMic size={24} />
</div>
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span>
</button>
<button className={`control-btn ${camEnabled ? '' : 'disabled'}`} onClick={toggleCam} aria-pressed={camEnabled}>
<button className={`${styles['control-btn']} ${camEnabled ? '' : styles.disabled}`} onClick={toggleCam} aria-pressed={camEnabled}>
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>E</span></span>
<div className={styles['control-icon']}><FiVideo /></div>
<span>{camEnabled ? 'Detener cámara' : 'Iniciar cámara'}</span>
<div className={styles['control-icon']}>
<FiVideo size={24} />
</div>
<span>{camEnabled ? 'Desactivar cámara' : 'Activar cámara'}</span>
</button>
<button className="control-btn" onClick={() => {}}>
<div className={styles['control-icon']}><FiSettings /></div>
<button className={styles['control-btn']} onClick={() => {}}>
<div className={styles['control-icon']}>
<FiSettings size={24} />
</div>
<span>Configuración</span>
</button>
</div>

View File

@ -4,6 +4,7 @@ import PageContainer from "./components/PageContainer";
import "./styles.css";
import { ToastProvider } from "./hooks/useToast";
import StudioPortal from "./features/studio/StudioPortal";
import PreJoin from "./features/studio/PreJoin";
function SessionLoader({ sessionId }: { sessionId: string }) {
const [state, setState] = React.useState<{
@ -13,8 +14,29 @@ function SessionLoader({ sessionId }: { sessionId: string }) {
err?: string;
}>({ status: "loading" });
// Keep the loading/analyzing screen visible at least this many ms
const MIN_ANALYZE_MS = 10000; // 10 seconds
const loadingStartRef = React.useRef<number>(Date.now());
const pendingTimeoutRef = React.useRef<number | null>(null);
const setReadyWithMinDelay = (payload: { token?: string; url?: string }) => {
const elapsed = Date.now() - loadingStartRef.current;
const remaining = MIN_ANALYZE_MS - elapsed;
if (remaining > 0) {
// delay setting ready to guarantee minimum analyzing time
pendingTimeoutRef.current = window.setTimeout(() => {
pendingTimeoutRef.current = null;
setState({ status: 'ready', token: payload.token, url: payload.url });
}, remaining) as unknown as number;
} else {
setState({ status: 'ready', token: payload.token, url: payload.url });
}
};
React.useEffect(() => {
let cancelled = false;
// mark the start of a fresh loading attempt
loadingStartRef.current = Date.now();
(async () => {
try {
// First: check for session map with publicId key (so we can redirect to /studio/:publicId)
@ -52,11 +74,11 @@ function SessionLoader({ sessionId }: { sessionId: string }) {
);
} catch (e) {}
} catch (e) {}
setState({
status: token ? "ready" : "loading",
token: token || undefined,
url: url || undefined,
});
if (token) {
setReadyWithMinDelay({ token: token, url: url });
} else {
setState({ status: "loading", token: undefined, url: undefined });
}
}
if (token) return;
// if no token present, fallthrough to token server fetch below
@ -114,11 +136,7 @@ function SessionLoader({ sessionId }: { sessionId: string }) {
);
}
// json from /token endpoint may be { token, ttlSeconds, room, username, url }
setState({
status: "ready",
token: json.token || (json as any).token,
url: json.url || (json as any).url,
});
setReadyWithMinDelay({ token: json.token || (json as any).token, url: json.url || (json as any).url });
}
return;
}
@ -244,11 +262,8 @@ function SessionLoader({ sessionId }: { sessionId: string }) {
} catch (e) {
console.warn("[SessionLoader] failed to write sessionStorage", e);
}
setState({
status: "ready",
token: json2?.token || json2?.participantToken || json2?.token,
url: json2?.url || json2?.serverUrl || json2?.url,
});
// use helper to guarantee minimum analyzing time
setReadyWithMinDelay({ token: json2?.token || json2?.participantToken || json2?.token, url: json2?.url || json2?.serverUrl || json2?.url });
}
return;
} catch (err2) {
@ -264,11 +279,31 @@ function SessionLoader({ sessionId }: { sessionId: string }) {
})();
return () => {
cancelled = true;
// cleanup any pending timeout
if (pendingTimeoutRef.current) {
clearTimeout(pendingTimeoutRef.current as number);
pendingTimeoutRef.current = null;
}
};
}, [sessionId]);
if (state.status === "loading") {
return <div style={{ padding: 40 }}>Cargando sesión del estudio...</div>;
// Show the PreJoin component in analyzing mode while the session/token is being validated.
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f3f4f6', padding: 20 }}>
<PreJoin
roomName={sessionId}
isAnalyzing={true}
onProceed={() => {
// noop while analyzing; user cannot proceed until token validated
}}
onCancel={() => {
// user cancelled analyzing: redirect to home
try { window.location.replace('/') } catch (e) { window.location.href = '/' }
}}
/>
</div>
);
}
if (state.status === "missing") {
// redirect to home if no session