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:
parent
adbec08f5e
commit
08aca81ab1
@ -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; }
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user