216 lines
7.9 KiB
TypeScript
216 lines
7.9 KiB
TypeScript
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>
|
|
)
|
|
}
|