Adapted preview from WebRTC
This commit is contained in:
parent
00e98a19b3
commit
89446e701f
@ -108,9 +108,13 @@ Ejemplo visual:
|
||||
|
||||
#### 1.6 Configuración WHIP (settings)
|
||||
Sección colapsable o en la misma pantalla de configuración del Core:
|
||||
- Toggle **Habilitar WHIP Server** → modifica `config.whip.enable` via `PATCH /api/v3/config`
|
||||
- Campo **Puerto** → modifica `config.whip.address`
|
||||
- Toggle **Habilitar WHIP Server** → modifica `config.whip.enable`
|
||||
- Campo **Puerto WHIP** → modifica `config.whip.address` (ej. `8555`)
|
||||
- Campo **Token** → modifica `config.whip.token`
|
||||
- Campo **Puerto relay RTSP interno** → modifica `config.whip.rtsp_address` (ej. `8554`)
|
||||
- Mostrar tooltip: *"Habilita el relay RTSP para que múltiples procesos FFmpeg puedan consumir el mismo stream WHIP simultáneamente. Dejar vacío para desactivar."*
|
||||
- Si está vacío: el relay RTSP está desactivado; solo un proceso puede leer el stream con `{whip}`.
|
||||
- Si está configurado: múltiples procesos pueden usar `{whip-rtsp}` como input.
|
||||
|
||||
---
|
||||
|
||||
@ -267,7 +271,8 @@ Leer configuración del servidor WHIP para el panel de settings.
|
||||
"whip": {
|
||||
"enable": true,
|
||||
"address": ":8555",
|
||||
"token": ""
|
||||
"token": "",
|
||||
"rtsp_address": ":8554"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -392,6 +397,104 @@ El proceso comenzará a recibir video cuando OBS empiece a transmitir.
|
||||
|
||||
---
|
||||
|
||||
## Parte 5 — Recuperación de estado tras reinicio del Core
|
||||
|
||||
Cuando el Core se reinicia, la UI debe re-leer el estado WHIP desde la API. La UI **no debe cachear** los valores localmente — siempre debe obtenerlos del Core en el momento de montar el componente.
|
||||
|
||||
### 5.1 Secuencia de inicialización del componente WHIP
|
||||
|
||||
Al montar cualquier componente/página que muestre datos WHIP, ejecutar en este orden:
|
||||
|
||||
```js
|
||||
async function load() {
|
||||
// 1. Leer config del servidor
|
||||
const { data } = await api.get('/api/v3/config')
|
||||
const whip = data.config.whip ?? { enable: false, address: ':8555', token: '' }
|
||||
|
||||
// 2. Extraer puerto sin el prefijo ':'
|
||||
const port = whip.address.replace(/^:/, '') || '8555'
|
||||
|
||||
// 3. Inicializar estado del formulario
|
||||
configValues.whip = {
|
||||
enable: whip.enable,
|
||||
address: port, // mostrar "8555", no ":8555"
|
||||
token: whip.token,
|
||||
rtspAddress: (whip.rtsp_address ?? '').replace(/^:/, ''), // "8554" o ""
|
||||
}
|
||||
|
||||
// 4. Obtener la URL base de publicación (host público)
|
||||
const urlInfo = await api.get('/api/v3/whip/url')
|
||||
state.basePublishUrl = urlInfo.data.base_publish_url // "http://189.x.x.x:8555/whip/"
|
||||
state.hasToken = urlInfo.data.has_token
|
||||
|
||||
// 5. Listar publishers activos
|
||||
const channels = await api.get('/api/v3/whip')
|
||||
state.activeChannels = channels.data // [{ name, published_at }]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Detectar que el Core se reinició
|
||||
|
||||
El Core no emite eventos de reconexión, pero la UI puede detectarlo por:
|
||||
|
||||
- **Polling de `/api/v3/whip`** — si la llamada falla (network error / 502) y luego vuelve a responder, el Core se reinició. Llamar `load()` nuevamente.
|
||||
- **`updated_at` en `/api/v3/config`** — si cambia respecto al valor previo cacheado, el Core fue reiniciado y la config debe recargarse.
|
||||
|
||||
```js
|
||||
// Ejemplo: detectar reinicio por cambio en updated_at
|
||||
let lastUpdatedAt = null
|
||||
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/api/v3/config')
|
||||
if (lastUpdatedAt && data.updated_at !== lastUpdatedAt) {
|
||||
await load() // Core reinició, recargar todo
|
||||
}
|
||||
lastUpdatedAt = data.updated_at
|
||||
} catch {
|
||||
// Core no disponible temporalmente, reintentar en próximo ciclo
|
||||
}
|
||||
}, 10000) // cada 10s
|
||||
```
|
||||
|
||||
### 5.3 Comportamiento esperado en el panel WHIP Server tras reinicio
|
||||
|
||||
| Estado | Qué hace la UI |
|
||||
|---|---|
|
||||
| Core acaba de arrancar, config.json existe | `load()` lee `whip.token = 'heavy666'` del disco — se muestra correctamente ✅ |
|
||||
| Core arranca sin config.json (primera vez) | `whip` no existe en respuesta → usar defaults `{ enable: false, address: '8555', token: '' }` |
|
||||
| Core no responde (arrancando) | Mostrar spinner/skeleton, reintentar cada 2s hasta que responda |
|
||||
| Core responde pero WHIP disabled | Mostrar badge "Disabled", ocultar la URL de publicación |
|
||||
|
||||
### 5.4 Flujo de guardado (`handleSave`)
|
||||
|
||||
```js
|
||||
async function handleSave() {
|
||||
// Leer config completa actual para no pisar otros campos
|
||||
const { data } = await api.get('/api/v3/config')
|
||||
const fullConfig = data.config
|
||||
|
||||
// Aplicar cambios WHIP (restaurar ':' en address)
|
||||
fullConfig.whip = {
|
||||
enable: configValues.whip.enable,
|
||||
address: ':' + configValues.whip.address, // "8555" → ":8555"
|
||||
token: configValues.whip.token,
|
||||
rtsp_address: configValues.whip.rtspAddress ? ':' + configValues.whip.rtspAddress : '', // "" o ":8554"
|
||||
}
|
||||
|
||||
// Enviar config completa
|
||||
await api.put('/api/v3/config', fullConfig)
|
||||
|
||||
// Recargar URL pública por si cambió el puerto
|
||||
const urlInfo = await api.get('/api/v3/whip/url')
|
||||
state.basePublishUrl = urlInfo.data.base_publish_url
|
||||
}
|
||||
```
|
||||
|
||||
> **Importante**: usar siempre `PUT /api/v3/config` con el objeto completo. El Core no soporta PATCH parcial — si se envía solo `{ whip: {...} }`, los demás campos se resetean a defaults.
|
||||
|
||||
---
|
||||
|
||||
## Notas técnicas para el desarrollador de la UI
|
||||
|
||||
1. **Compatibilidad de OBS**: OBS 30+ soporta WHIP nativo. En Configuración → Transmisión, seleccionar "Servicio: Personalizado" y pegar la URL completa.
|
||||
@ -407,3 +510,318 @@ El proceso comenzará a recibir video cuando OBS empiece a transmitir.
|
||||
6. **Puertos**: Por defecto el WHIP server escucha en `:8555` (HTTP, sin TLS). El Core HTTP principal escucha en `:8080`. Son servidores independientes.
|
||||
|
||||
7. **Múltiples publishers**: El servidor WHIP soporta múltiples stream keys simultáneos. Cada stream key es independiente y genera su propio relay interno.
|
||||
|
||||
---
|
||||
|
||||
## Parte 6 — WHEP: reproducción en browser sin FFmpeg (nueva funcionalidad)
|
||||
|
||||
WHEP (WebRTC HTTP Egress Protocol) es el complemento de WHIP. Permite que un browser reciba el stream de OBS directamente con latencia ~300ms, **sin transcoding ni FFmpeg**. El Core actúa como SFU: recibe RTP de OBS y lo reenvía vía DTLS-SRTP a cada suscriptor.
|
||||
|
||||
### 6.1 Cambios en las APIs existentes
|
||||
|
||||
#### `GET /api/v3/whip` — campo nuevo: `subscribers`
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "b89d39bb-5321-46f3-8d89-54a03150205d",
|
||||
"published_at": "2026-03-14T20:09:09.87237418Z",
|
||||
"subscribers": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
Usar `subscribers` para mostrar el contador de viewers en tiempo real en el panel WHIP Server.
|
||||
|
||||
#### `GET /api/v3/whip/:name/url` — campo nuevo: `whep_url`
|
||||
```json
|
||||
{
|
||||
"publish_url": "http://192.168.1.15:8555/whip/mystream",
|
||||
"sdp_url": "http://localhost:8555/whip/mystream/sdp",
|
||||
"stream_key": "mystream",
|
||||
"whep_url": "http://192.168.1.15:8555/whip/mystream/whep"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/v3/whip/url` — campo nuevo: `base_whep_url`
|
||||
```json
|
||||
{
|
||||
"base_publish_url": "http://192.168.1.15:8555/whip/",
|
||||
"base_sdp_url": "http://localhost:8555/whip/",
|
||||
"base_whep_url": "http://192.168.1.15:8555/whip/",
|
||||
"has_token": false,
|
||||
"example_obs_url": "http://192.168.1.15:8555/whip/<stream-key>",
|
||||
"input_address_template": "{whip:name=<stream-key>}"
|
||||
}
|
||||
```
|
||||
La `whep_url` completa se construye como: `base_whep_url + stream_key + "/whep"`.
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Nuevo endpoint WHEP
|
||||
|
||||
#### `POST /whip/:name/whep` — suscribirse al stream (en puerto 8555, **no 8080**)
|
||||
|
||||
El browser envía un SDP offer; el Core responde con SDP answer + ICE candidates.
|
||||
|
||||
> **IMPORTANTE**: Este endpoint vive en el servidor WHIP (puerto `:8555`), **no** en la API REST de Core (`:8080`). Llamarlo directamente desde el browser:
|
||||
> ```
|
||||
> POST http://192.168.1.15:8555/whip/mystream/whep
|
||||
> ```
|
||||
|
||||
**Headers de request:**
|
||||
```
|
||||
Content-Type: application/sdp
|
||||
Authorization: Bearer <token> (solo si has_token = true)
|
||||
```
|
||||
|
||||
**Response `201 Created`:**
|
||||
```
|
||||
Content-Type: application/sdp
|
||||
Location: /whip/mystream/whep/<subid>
|
||||
Body: <SDP answer con ICE candidates>
|
||||
```
|
||||
|
||||
**Response `404`** si no existe el stream key (canal no creado aún).
|
||||
**Response `401`** si el token es requerido y no fue enviado.
|
||||
|
||||
#### `DELETE /whip/:name/whep/:subid` — cerrar suscripción
|
||||
|
||||
```
|
||||
DELETE http://192.168.1.15:8555/whip/mystream/whep/<subid>
|
||||
```
|
||||
Donde `<subid>` es el último segmento del header `Location` devuelto en el POST.
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Preview embed en el panel WHIP Server
|
||||
|
||||
Agregar un botón **"Ver en vivo"** / **"Preview"** en la tarjeta de cada stream activo. Al hacer clic:
|
||||
|
||||
```js
|
||||
async function startWHEPPreview(streamKey, whepBaseUrl) {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] })
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
|
||||
const whepUrl = `${whepBaseUrl}${streamKey}/whep`
|
||||
|
||||
const headers = { 'Content-Type': 'application/sdp' }
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||
|
||||
const resp = await fetch(whepUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: offer.sdp,
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error(`WHEP error ${resp.status}`)
|
||||
}
|
||||
|
||||
const locationHeader = resp.headers.get('Location')
|
||||
const answerSDP = await resp.text()
|
||||
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP })
|
||||
|
||||
// Mostrar video en el elemento <video>
|
||||
pc.ontrack = (e) => {
|
||||
if (e.track.kind === 'video') {
|
||||
videoElement.srcObject = e.streams[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar para poder cerrar después
|
||||
return { pc, locationHeader }
|
||||
}
|
||||
|
||||
async function stopWHEPPreview(pc, locationHeader, whipServerBase) {
|
||||
pc.close()
|
||||
if (locationHeader) {
|
||||
await fetch(whipServerBase + locationHeader, { method: 'DELETE' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Elemento HTML necesario:**
|
||||
```html
|
||||
<video id="whep-preview" autoplay playsinline muted controls></video>
|
||||
```
|
||||
> Usar `muted` inicialmente para evitar bloqueo de autoplay en Chrome/Firefox.
|
||||
> Mostrar un botón de unmute para que el usuario active el audio voluntariamente.
|
||||
|
||||
---
|
||||
|
||||
### 6.4 CORS — el browser puede llamar directamente
|
||||
|
||||
El servidor WHIP responde con los headers CORS correctos en todos los endpoints:
|
||||
```
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization
|
||||
Access-Control-Allow-Methods: POST, DELETE, OPTIONS
|
||||
Access-Control-Expose-Headers: Location
|
||||
```
|
||||
No se necesita proxy en la UI. El `fetch()` al puerto 8555 funciona desde el browser sin problemas de CORS.
|
||||
|
||||
---
|
||||
|
||||
### 6.5 Indicadores de estado en el panel
|
||||
|
||||
| Situación | Qué mostrar |
|
||||
|---|---|
|
||||
| OBS publicando, `subscribers: 0` | Badge "En vivo" + botón "Preview" |
|
||||
| OBS publicando, `subscribers: N` | Badge "En vivo" + "N viewers" + botón "Preview activo" |
|
||||
| OBS no publicando | Badge "Sin señal" — ocultar botón Preview |
|
||||
| WHEP no soportado (token WebRTC no disponible) | No mostrar botón Preview |
|
||||
|
||||
Actualizar el contador de `subscribers` con el mismo polling de 5s de `GET /api/v3/whip`.
|
||||
|
||||
---
|
||||
|
||||
### 6.6 Tabla resumen de todos los endpoints WHEP/WHIP activos
|
||||
|
||||
| Endpoint | Puerto | Método | Descripción |
|
||||
|---|---|---|---|
|
||||
| `/api/v3/whip` | 8080 | GET | Lista publishers + contador subscribers |
|
||||
| `/api/v3/whip/url` | 8080 | GET | URL base del servidor + `base_whep_url` |
|
||||
| `/api/v3/whip/:name/url` | 8080 | GET | URLs completas: publish, sdp, **whep** |
|
||||
| `/whip/:name/whep` | **8555** | POST | **Suscribirse vía WHEP** (desde browser) |
|
||||
| `/whip/:name/whep/:subid` | **8555** | DELETE | Cerrar suscripción WHEP |
|
||||
|
||||
---
|
||||
|
||||
## Parte 7 — Multi-egress con `{whip-rtsp:name=X}` (relay RTSP interno)
|
||||
|
||||
### 7.1 Problema que resuelve
|
||||
|
||||
El placeholder `{whip:name=X}` (Parte 2) funciona bien para **un solo proceso FFmpeg** que consume el stream WHIP. Sin embargo, si se crean **dos o más procesos** que usan `{whip:name=X}` con el mismo stream key (por ejemplo, re-emitir a YouTube + Facebook + Twitch desde un solo OBS), solo uno puede leer el relay UDP loopback simultáneamente.
|
||||
|
||||
El relay RTSP interno resuelve esto: expone el stream como un servidor RTSP TCP al que pueden conectarse **N clientes FFmpeg en paralelo**, cada uno recibiendo una copia independiente.
|
||||
|
||||
```
|
||||
OBS → WHIP → Core
|
||||
├── {whip:name=X} → proceso: HLS/memfs (1 consumidor)
|
||||
└── RTSP relay :8554
|
||||
├── {whip-rtsp:name=X} → proceso: YouTube
|
||||
├── {whip-rtsp:name=X} → proceso: Facebook
|
||||
└── {whip-rtsp:name=X} → proceso: Twitch
|
||||
```
|
||||
|
||||
### 7.2 Requisito en config
|
||||
|
||||
El relay RTSP debe estar habilitado en la configuración del Core:
|
||||
```json
|
||||
"whip": {
|
||||
"enable": true,
|
||||
"address": ":8555",
|
||||
"token": "",
|
||||
"rtsp_address": ":8554"
|
||||
}
|
||||
```
|
||||
Si `rtsp_address` está vacío, el relay no arranca y `{whip-rtsp}` no funciona.
|
||||
|
||||
### 7.3 Placeholder de proceso
|
||||
|
||||
En el `input.address` de cualquier proceso de egress:
|
||||
```
|
||||
{whip-rtsp:name=<stream_key>}
|
||||
```
|
||||
El Core lo expande a `rtsp://127.0.0.1:8554/live/<stream_key>` e inyecta automáticamente `-rtsp_transport tcp` en las input options (para forzar RTP/AVP/TCP interleaved, el único modo soportado por el relay interno).
|
||||
|
||||
**Alternativa sin placeholder** (si la UI no usa el mecanismo de templates):
|
||||
```json
|
||||
{
|
||||
"address": "rtsp://127.0.0.1:8554/live/mistream",
|
||||
"options": ["-rtsp_transport", "tcp"]
|
||||
}
|
||||
```
|
||||
En este caso la UI debe incluir `-rtsp_transport tcp` manualmente.
|
||||
|
||||
### 7.4 Diferencias con `{whip}`
|
||||
|
||||
| | `{whip:name=X}` | `{whip-rtsp:name=X}` |
|
||||
|---|---|---|
|
||||
| Protocolo | HTTP → SDP → UDP RTP | RTSP/TCP (interleaved) |
|
||||
| Consumidores simultáneos | **1** | **N (ilimitado)** |
|
||||
| Requiere `rtsp_address` en config | No | **Sí** |
|
||||
| Auto-inyección de opciones FFmpeg | `-protocol_whitelist …` | `-rtsp_transport tcp` |
|
||||
| Latencia adicional | Ninguna | Ninguna (~igual) |
|
||||
| Uso típico | Proceso principal de grabación/HLS | Procesos de re-streaming a plataformas |
|
||||
|
||||
### 7.5 UI en el wizard de proceso — modo "WHIP Multi-Egress"
|
||||
|
||||
Cuando el usuario selecciona WHIP como fuente de entrada en un proceso, mostrar un **selector de modo**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Modo de acceso al stream WHIP │
|
||||
│ │
|
||||
│ ○ Directo — Un solo proceso lee el stream (recomendado │
|
||||
│ para grabación/HLS mientras OBS transmite) │
|
||||
│ │
|
||||
│ ○ Relay RTSP — Múltiples procesos pueden leer │
|
||||
│ simultáneamente (YouTube + Facebook + …) │
|
||||
│ Requiere "Puerto relay RTSP" configurado │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Modo Directo** → `input.address = "{whip:name=<stream_key>}"`
|
||||
**Modo Relay RTSP** → `input.address = "{whip-rtsp:name=<stream_key>}"`
|
||||
|
||||
Si el modo Relay RTSP está seleccionado y `config.whip.rtsp_address` está vacío, mostrar un aviso:
|
||||
> ⚠️ El relay RTSP no está configurado. Activalo en Configuración → WHIP Server → Puerto relay RTSP.
|
||||
|
||||
### 7.6 Creación de proceso via API
|
||||
|
||||
**Payload para proceso de re-streaming a YouTube usando relay RTSP:**
|
||||
```json
|
||||
{
|
||||
"id": "obs-to-youtube",
|
||||
"reference": "obs-to-youtube",
|
||||
"input": [
|
||||
{
|
||||
"id": "in",
|
||||
"address": "{whip-rtsp:name=mistream}",
|
||||
"options": []
|
||||
}
|
||||
],
|
||||
"output": [
|
||||
{
|
||||
"id": "out",
|
||||
"address": "rtmp://a.rtmp.youtube.com/live2/STREAM_KEY",
|
||||
"options": ["-c", "copy", "-f", "flv"]
|
||||
}
|
||||
],
|
||||
"options": ["-loglevel", "level+info"]
|
||||
}
|
||||
```
|
||||
> El Core expande `{whip-rtsp:name=mistream}` → `rtsp://127.0.0.1:8554/live/mistream`
|
||||
> e inyecta `-rtsp_transport tcp` automáticamente en las input options.
|
||||
|
||||
**Tres procesos de egress simultáneos (YouTube + Facebook + Twitch):**
|
||||
```json
|
||||
// proceso 1
|
||||
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://youtube..." }] }
|
||||
|
||||
// proceso 2
|
||||
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://facebook..." }] }
|
||||
|
||||
// proceso 3
|
||||
{ "input": [{ "address": "{whip-rtsp:name=mistream}" }], "output": [{ "address": "rtmp://twitch..." }] }
|
||||
```
|
||||
Los tres leen del relay RTSP independientemente; OBS solo transmite una vez.
|
||||
|
||||
### 7.7 Tabla resumen de endpoints actualizada
|
||||
|
||||
| Endpoint | Puerto | Método | Descripción |
|
||||
|---|---|---|---|
|
||||
| `/api/v3/whip` | 8080 | GET | Lista publishers + subscribers |
|
||||
| `/api/v3/whip/url` | 8080 | GET | URL base + `base_whep_url` |
|
||||
| `/api/v3/whip/:name/url` | 8080 | GET | URLs completas: publish, sdp, whep |
|
||||
| `/whip/:name` | **8555** | POST | Publicar stream WHIP (OBS/clientes) |
|
||||
| `/whip/:name/sdp` | **8555** | GET | SDP para FFmpeg (modo directo) |
|
||||
| `/whip/:name/whep` | **8555** | POST | Suscribirse vía WHEP (browser) |
|
||||
| `/whip/:name/whep/:subid` | **8555** | DELETE | Cerrar suscripción WHEP |
|
||||
| `rtsp://localhost/live/:name` | **8554** | — | Relay RTSP multi-consumer (TCP only) |
|
||||
|
||||
@ -984,6 +984,7 @@ class Restreamer {
|
||||
if (override && val) {
|
||||
const base = override.replace(/\/$/, '') + '/whip/';
|
||||
val.base_publish_url = base;
|
||||
val.base_whep_url = base;
|
||||
val.example_obs_url = base + '<stream-key>';
|
||||
}
|
||||
|
||||
@ -1001,6 +1002,7 @@ class Restreamer {
|
||||
if (override && val) {
|
||||
const base = override.replace(/\/$/, '') + '/whip/';
|
||||
val.publish_url = base + name;
|
||||
val.whep_url = base + name + '/whep';
|
||||
}
|
||||
|
||||
return val;
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Trans } from '@lingui/macro';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import Grid from '@mui/material/Grid';
|
||||
import Icon from '@mui/icons-material/CloudUpload';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import Typography from '@mui/material/Typography';
|
||||
|
||||
import BoxTextarea from '../../../../misc/BoxTextarea';
|
||||
@ -14,20 +18,26 @@ function Source(props) {
|
||||
const channelid = props.config?.channelid || 'external';
|
||||
const configWhip = props.config?.whip || {};
|
||||
|
||||
// Live state fetched from Core on every mount — never trust the cached session config.
|
||||
// null = still loading
|
||||
// false = WHIP disabled or Core returned an error
|
||||
// object = { base_publish_url, has_token, ... } (from GET /v3/whip/url)
|
||||
// object = { base_publish_url, base_whep_url, has_token, ... }
|
||||
const [whipInfo, setWhipInfo] = React.useState(null);
|
||||
const [obsUrl, setObsUrl] = React.useState('');
|
||||
const [whepUrl, setWhepUrl] = React.useState('');
|
||||
const [bearerToken, setBearerToken] = React.useState('');
|
||||
const [subscribers, setSubscribers] = React.useState(0);
|
||||
const [active, setActive] = React.useState(false);
|
||||
|
||||
// WHEP preview state
|
||||
const [previewActive, setPreviewActive] = React.useState(false);
|
||||
const [previewError, setPreviewError] = React.useState('');
|
||||
const pcRef = React.useRef(null);
|
||||
const locationRef = React.useRef(null);
|
||||
const videoRef = React.useRef(null);
|
||||
const streamRef = React.useRef(null); // MediaStream across re-renders
|
||||
|
||||
// ── 1. Load live WHIP state from Core ──────────────────────────────────
|
||||
const loadWhipInfo = React.useCallback(async () => {
|
||||
// Helper: build whipInfo from the cached session config as a fallback.
|
||||
// Used when the Core doesn't expose /v3/whip/url (older Core versions)
|
||||
// or when WhipUrl() fails for any transient reason.
|
||||
const fromSessionConfig = () =>
|
||||
configWhip.enabled === true
|
||||
? { base_publish_url: `http://${configWhip.host}/whip/`, has_token: !!configWhip.token }
|
||||
@ -35,11 +45,8 @@ function Source(props) {
|
||||
|
||||
if (props.restreamer?.WhipUrl) {
|
||||
const info = await props.restreamer.WhipUrl();
|
||||
// info === null means the endpoint returned an error (e.g. 404 on older
|
||||
// Core builds that don't expose /v3/whip/url yet). Fall back gracefully.
|
||||
setWhipInfo(info !== null ? info : fromSessionConfig());
|
||||
} else {
|
||||
// No authenticated API available — use cached session config directly.
|
||||
setWhipInfo(fromSessionConfig());
|
||||
}
|
||||
}, [props.restreamer, configWhip.enabled, configWhip.host, configWhip.token]);
|
||||
@ -49,42 +56,47 @@ function Source(props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── 2. Build OBS URL once whipInfo is available ────────────────────────
|
||||
// ── 2. Build OBS + WHEP URLs ────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (!whipInfo) {
|
||||
setObsUrl('');
|
||||
setWhepUrl('');
|
||||
setBearerToken('');
|
||||
return;
|
||||
}
|
||||
// OBS WHIP has two separate fields:
|
||||
// • Server → the WHIP endpoint URL (NO token in URL)
|
||||
// • Bearer Token → the token value (sent as Authorization: Bearer header)
|
||||
if (props.restreamer?.WhipStreamUrl) {
|
||||
props.restreamer.WhipStreamUrl(channelid).then((data) => {
|
||||
if (data?.publish_url) {
|
||||
// Strip any ?token=... query param — token goes in Bearer Token field
|
||||
const clean = data.publish_url.split('?')[0];
|
||||
setObsUrl(clean);
|
||||
setObsUrl(data.publish_url.split('?')[0]);
|
||||
} else {
|
||||
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
|
||||
setObsUrl(`${base}${channelid}`);
|
||||
}
|
||||
if (data?.whep_url) {
|
||||
setWhepUrl(data.whep_url);
|
||||
} else {
|
||||
const base = whipInfo.base_whep_url || whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
|
||||
setWhepUrl(`${base}${channelid}/whep`);
|
||||
}
|
||||
setBearerToken(configWhip.token || '');
|
||||
});
|
||||
} else {
|
||||
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
|
||||
setObsUrl(`${base}${channelid}`);
|
||||
setWhepUrl(`${base}${channelid}/whep`);
|
||||
setBearerToken(configWhip.token || '');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [whipInfo, channelid]);
|
||||
|
||||
// ── 3. Poll active streams ─────────────────────────────────────────────
|
||||
// ── 3. Poll active streams + subscriber count ───────────────────────────
|
||||
const pollWhipStreams = React.useCallback(async () => {
|
||||
if (!props.restreamer) return;
|
||||
try {
|
||||
const streams = await props.restreamer.WhipStreams();
|
||||
setActive(streams.some((s) => s.name === channelid));
|
||||
const stream = streams.find((s) => s.name === channelid);
|
||||
setActive(!!stream);
|
||||
setSubscribers(stream?.subscribers ?? 0);
|
||||
} catch (_) {}
|
||||
}, [props.restreamer, channelid]);
|
||||
|
||||
@ -95,19 +107,104 @@ function Source(props) {
|
||||
return () => clearInterval(id);
|
||||
}, [whipInfo, pollWhipStreams, props.restreamer]);
|
||||
|
||||
// ── 4. Notify parent whenever enabled state resolves ──────────────────
|
||||
// ── 4. Notify parent ───────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
if (whipInfo === null) return; // still loading — don't fire yet
|
||||
if (whipInfo === null) return;
|
||||
const enabled = !!whipInfo;
|
||||
const inputs = [{ address: `{whip:name=${channelid}}`, options: [] }];
|
||||
props.onChange('network', { mode: 'push', push: { type: 'whip', name: channelid } }, inputs, enabled);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [whipInfo, channelid]);
|
||||
|
||||
// Still loading — render nothing to avoid flicker
|
||||
if (whipInfo === null) {
|
||||
return null;
|
||||
}
|
||||
// ── 5. WHEP preview ────────────────────────────────────────────────────
|
||||
const startPreview = React.useCallback(async () => {
|
||||
setPreviewError('');
|
||||
try {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||
|
||||
// ontrack BEFORE createOffer — tracks fire as soon as ICE+DTLS completes
|
||||
pc.ontrack = (e) => {
|
||||
if (!e.streams?.[0]) return;
|
||||
streamRef.current = e.streams[0];
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = e.streams[0];
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// Wait for ICE gathering to complete (max 4s) before POSTing
|
||||
if (pc.iceGatheringState !== 'complete') {
|
||||
await new Promise((resolve) => {
|
||||
const tid = setTimeout(resolve, 4000);
|
||||
const handler = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(tid);
|
||||
pc.removeEventListener('icegatheringstatechange', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener('icegatheringstatechange', handler);
|
||||
});
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/sdp' };
|
||||
if (bearerToken) headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
|
||||
// Use pc.localDescription.sdp — now contains gathered ICE candidates
|
||||
const resp = await fetch(whepUrl, { method: 'POST', headers, body: pc.localDescription.sdp });
|
||||
if (!resp.ok) {
|
||||
throw new Error(`WHEP ${resp.status}`);
|
||||
}
|
||||
|
||||
locationRef.current = resp.headers.get('Location');
|
||||
const answerSDP = await resp.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
|
||||
|
||||
pcRef.current = pc;
|
||||
setPreviewActive(true);
|
||||
} catch (err) {
|
||||
setPreviewError(err.message);
|
||||
}
|
||||
}, [whepUrl, bearerToken]);
|
||||
|
||||
// Apply stream after previewActive→true causes re-render and videoRef becomes available
|
||||
React.useEffect(() => {
|
||||
if (previewActive && videoRef.current && streamRef.current) {
|
||||
videoRef.current.srcObject = streamRef.current;
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [previewActive]);
|
||||
|
||||
const stopPreview = React.useCallback(async () => {
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
if (locationRef.current && whepUrl) {
|
||||
const base = whepUrl.replace(/\/whep$/, '');
|
||||
const deleteUrl = base + locationRef.current;
|
||||
try { await fetch(deleteUrl, { method: 'DELETE' }); } catch (_) {}
|
||||
locationRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setPreviewActive(false);
|
||||
}, [whepUrl]);
|
||||
|
||||
// Stop preview on unmount
|
||||
React.useEffect(() => {
|
||||
return () => { if (pcRef.current) { pcRef.current.close(); } };
|
||||
}, []);
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────
|
||||
if (whipInfo === null) return null;
|
||||
|
||||
if (!whipInfo) {
|
||||
return (
|
||||
@ -145,23 +242,75 @@ function Source(props) {
|
||||
</Grid>
|
||||
)}
|
||||
{props.restreamer && (
|
||||
<Grid item xs={12}>
|
||||
{active ? (
|
||||
<Chip
|
||||
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
|
||||
label={<Trans>Live</Trans>}
|
||||
size="small"
|
||||
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
|
||||
label={<Trans>Waiting for publisher…</Trans>}
|
||||
size="small"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
<React.Fragment>
|
||||
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{active ? (
|
||||
<React.Fragment>
|
||||
<Chip
|
||||
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
|
||||
label={<Trans>Live</Trans>}
|
||||
size="small"
|
||||
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
|
||||
/>
|
||||
{subscribers > 0 && (
|
||||
<Chip
|
||||
icon={<PeopleIcon style={{ fontSize: 14 }} />}
|
||||
label={subscribers}
|
||||
size="small"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.08)', color: 'rgba(255,255,255,0.7)' }}
|
||||
/>
|
||||
)}
|
||||
{!previewActive ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={startPreview}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
<Trans>Preview</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={stopPreview}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
<Trans>Stop preview</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<Chip
|
||||
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
|
||||
label={<Trans>Waiting for publisher…</Trans>}
|
||||
size="small"
|
||||
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
{previewError && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="error">{previewError}</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
{/* Always render <video> while Live so videoRef is in DOM when ontrack fires */}
|
||||
{active && (
|
||||
<Grid item xs={12} style={{ display: previewActive ? 'block' : 'none' }}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: 4, background: '#000' }}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
||||
@ -66,8 +66,9 @@ function makeRestreamer(activeStreams = [], whipEnabled = true) {
|
||||
WhipStreamUrl: jest.fn().mockImplementation((name) =>
|
||||
Promise.resolve({
|
||||
publish_url: `http://192.168.1.15:8555/whip/${name}`,
|
||||
sdp_url: `http://localhost:8555/whip/${name}/sdp`,
|
||||
stream_key: name,
|
||||
whep_url: `http://192.168.1.15:8555/whip/${name}/whep`,
|
||||
sdp_url: `http://localhost:8555/whip/${name}/sdp`,
|
||||
stream_key: name,
|
||||
}),
|
||||
),
|
||||
WhipStreams: jest.fn().mockResolvedValue(activeStreams),
|
||||
@ -192,6 +193,22 @@ test('whip:onChange — emits enabled=true (from session config fallback) when W
|
||||
expect(lastCall[3]).toBe(true);
|
||||
});
|
||||
|
||||
test('whip:whep — shows whep_url from WhipStreamUrl response', async () => {
|
||||
const restreamer = makeRestreamer([], true);
|
||||
|
||||
await act(async () => {
|
||||
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
// The WHEP URL is stored in state but not rendered as text in the DOM
|
||||
// (it's used by the Preview button). Verify WhipStreamUrl was called and
|
||||
// returned the expected whep_url by checking the OBS URL field is correct.
|
||||
expect(restreamer.WhipStreamUrl).toHaveBeenCalledWith('my-channel');
|
||||
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('whip:live — shows "Live" chip when stream is active', async () => {
|
||||
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z' }], true);
|
||||
|
||||
@ -205,6 +222,33 @@ test('whip:live — shows "Live" chip when stream is active', async () => {
|
||||
expect(restreamer.WhipStreams).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('whip:subscribers — shows subscriber count chip when subscribers > 0', async () => {
|
||||
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z', subscribers: 3 }], true);
|
||||
|
||||
await act(async () => {
|
||||
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
// subscriber count chip shows the number
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('whip:subscribers — no subscriber chip when subscribers is 0', async () => {
|
||||
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z', subscribers: 0 }], true);
|
||||
|
||||
await act(async () => {
|
||||
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
expect(screen.queryByText('0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('whip:waiting — shows "Waiting" chip when no active publisher', async () => {
|
||||
const restreamer = makeRestreamer([], true); // WhipUrl enabled, no active streams
|
||||
|
||||
@ -247,3 +291,29 @@ test('whip:restart-recovery — re-mounting fetches fresh WhipUrl from Core', as
|
||||
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('whip:preview-button — Preview button visible only when Live and whepUrl available', async () => {
|
||||
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z' }], true);
|
||||
|
||||
await act(async () => {
|
||||
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
// When stream is Live and whepUrl is available, Preview button should be rendered
|
||||
expect(screen.getByText('Live')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /preview/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('whip:preview-button — no Preview button when stream is not live', async () => {
|
||||
const restreamer = makeRestreamer([], true); // no active streams
|
||||
|
||||
await act(async () => {
|
||||
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
|
||||
});
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
|
||||
expect(screen.queryByRole('button', { name: /preview/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@ import WarningIcon from '@mui/icons-material/Warning';
|
||||
import ScreenShareIcon from '@mui/icons-material/ScreenShare';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import Button from '@mui/material/Button';
|
||||
|
||||
import * as M from '../../utils/metadata';
|
||||
@ -79,6 +81,23 @@ const useStyles = makeStyles((theme) => ({
|
||||
marginRight: 6,
|
||||
animation: '$pulse 1.2s ease-in-out infinite',
|
||||
},
|
||||
whepVideo: {
|
||||
position: 'absolute',
|
||||
top: 0, left: 0,
|
||||
width: '100%', height: '100%',
|
||||
background: '#000',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
whepOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, bottom: 0, right: 0,
|
||||
backgroundColor: theme.palette.common.black,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
'@keyframes pulse': {
|
||||
'0%, 100%': { opacity: 1 },
|
||||
'50%': { opacity: 0.3 },
|
||||
@ -104,6 +123,20 @@ export default function Main(props) {
|
||||
log: [],
|
||||
},
|
||||
});
|
||||
// WHIP/WHEP source state
|
||||
const [$whipSource, setWhipSource] = React.useState({
|
||||
active: false,
|
||||
whepUrl: '',
|
||||
bearerToken: '',
|
||||
previewActive: false,
|
||||
previewError: '',
|
||||
whepConnState: 'idle', // idle | gathering | connecting | live | error
|
||||
});
|
||||
const whepPcRef = React.useRef(null);
|
||||
const whepLocationRef = React.useRef(null);
|
||||
const whepVideoRef = React.useRef(null);
|
||||
const whepStreamRef = React.useRef(null); // holds MediaStream across re-renders
|
||||
|
||||
// WebRTC Room detection
|
||||
const [$webrtcRoom, setWebrtcRoom] = React.useState({
|
||||
active: false, // source type = webrtcroom
|
||||
@ -171,6 +204,10 @@ export default function Main(props) {
|
||||
|
||||
// Detect if the video source is a WebRTC Room
|
||||
const videoSource = metadata.sources && metadata.sources[0];
|
||||
const isWhip = videoSource?.type === 'network'
|
||||
&& videoSource?.settings?.mode === 'push'
|
||||
&& videoSource?.settings?.push?.type === 'whip';
|
||||
|
||||
if (videoSource && videoSource.type === 'webrtcroom') {
|
||||
const settings = videoSource.settings || {};
|
||||
const roomId = settings.roomId || _channelid;
|
||||
@ -182,8 +219,24 @@ export default function Main(props) {
|
||||
roomId,
|
||||
copied: false,
|
||||
});
|
||||
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
|
||||
} else if (isWhip) {
|
||||
const name = videoSource.settings.push.name || _channelid;
|
||||
const token = config?.source?.network?.whip?.token || '';
|
||||
let whepUrl = '';
|
||||
if (props.restreamer.WhipStreamUrl) {
|
||||
const data = await props.restreamer.WhipStreamUrl(name);
|
||||
whepUrl = data?.whep_url || '';
|
||||
}
|
||||
if (!whepUrl) {
|
||||
const whipHost = config?.source?.network?.whip?.host || 'localhost:8555';
|
||||
whepUrl = `http://${whipHost}/whip/${name}/whep`;
|
||||
}
|
||||
setWhipSource({ active: true, whepUrl, bearerToken: token, previewActive: false, previewError: '' });
|
||||
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
|
||||
} else {
|
||||
setWebrtcRoom({ active: false, roomUrl: '', roomId: '', copied: false });
|
||||
setWhipSource({ active: false, whepUrl: '', bearerToken: '', previewActive: false, previewError: '' });
|
||||
}
|
||||
|
||||
await update();
|
||||
@ -333,6 +386,135 @@ export default function Main(props) {
|
||||
});
|
||||
};
|
||||
|
||||
// ── WHEP preview ─────────────────────────────────────────────────────────
|
||||
const startWhepPreview = React.useCallback(async () => {
|
||||
setWhipSource((prev) => ({ ...prev, previewError: '', whepConnState: 'gathering' }));
|
||||
try {
|
||||
const pc = new RTCPeerConnection({ iceServers: [] });
|
||||
|
||||
// ① ontrack set BEFORE createOffer — tracks fire as soon as ICE+DTLS finishes
|
||||
pc.ontrack = (e) => {
|
||||
if (!e.streams?.[0]) return;
|
||||
whepStreamRef.current = e.streams[0];
|
||||
if (whepVideoRef.current) {
|
||||
whepVideoRef.current.srcObject = e.streams[0];
|
||||
whepVideoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// ICE failure feedback
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const s = pc.iceConnectionState;
|
||||
if (s === 'connected' || s === 'completed') {
|
||||
setWhipSource((prev) => ({ ...prev, whepConnState: 'live' }));
|
||||
} else if (s === 'failed') {
|
||||
setWhipSource((prev) => ({
|
||||
...prev,
|
||||
whepConnState: 'error',
|
||||
previewActive: false,
|
||||
previewError: 'ICE failed — check that port 8555 UDP is reachable from this browser',
|
||||
}));
|
||||
} else if (s === 'disconnected') {
|
||||
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewActive: false }));
|
||||
}
|
||||
};
|
||||
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
// ② Wait for ICE gathering to complete (max 4 s) before POSTing.
|
||||
// This ensures local candidates are included in the offer SDP,
|
||||
// which is required for Core’s ICE-lite WHEP server to reach the browser.
|
||||
if (pc.iceGatheringState !== 'complete') {
|
||||
await new Promise((resolve) => {
|
||||
const tid = setTimeout(resolve, 4000);
|
||||
const handler = () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(tid);
|
||||
pc.removeEventListener('icegatheringstatechange', handler);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
pc.addEventListener('icegatheringstatechange', handler);
|
||||
});
|
||||
}
|
||||
|
||||
setWhipSource((prev) => ({ ...prev, whepConnState: 'connecting' }));
|
||||
const headers = { 'Content-Type': 'application/sdp' };
|
||||
if ($whipSource.bearerToken) headers['Authorization'] = `Bearer ${$whipSource.bearerToken}`;
|
||||
// ③ Use pc.localDescription.sdp — now contains gathered ICE candidates
|
||||
const resp = await fetch($whipSource.whepUrl, {
|
||||
method: 'POST', headers, body: pc.localDescription.sdp,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`WHEP ${resp.status}`);
|
||||
whepLocationRef.current = resp.headers.get('Location');
|
||||
const answerSDP = await resp.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
|
||||
whepPcRef.current = pc;
|
||||
setWhipSource((prev) => ({ ...prev, previewActive: true }));
|
||||
} catch (err) {
|
||||
setWhipSource((prev) => ({ ...prev, whepConnState: 'idle', previewError: err.message }));
|
||||
}
|
||||
}, [$whipSource.whepUrl, $whipSource.bearerToken]);
|
||||
|
||||
// ② After previewActive → true React renders <video>. Apply the stream
|
||||
// that may have arrived (ontrack) before the DOM node existed.
|
||||
React.useEffect(() => {
|
||||
if ($whipSource.previewActive && whepVideoRef.current && whepStreamRef.current) {
|
||||
whepVideoRef.current.srcObject = whepStreamRef.current;
|
||||
whepVideoRef.current.play().catch(() => {});
|
||||
}
|
||||
}, [$whipSource.previewActive]);
|
||||
|
||||
const stopWhepPreview = React.useCallback(async () => {
|
||||
if (whepPcRef.current) {
|
||||
whepPcRef.current.close();
|
||||
whepPcRef.current = null;
|
||||
}
|
||||
if (whepLocationRef.current && $whipSource.whepUrl) {
|
||||
const base = $whipSource.whepUrl.replace(/\/whep$/, '');
|
||||
const deleteUrl = base + whepLocationRef.current;
|
||||
try { await fetch(deleteUrl, { method: 'DELETE' }); } catch (_) {}
|
||||
whepLocationRef.current = null;
|
||||
}
|
||||
if (whepVideoRef.current) {
|
||||
whepVideoRef.current.srcObject = null;
|
||||
}
|
||||
whepStreamRef.current = null;
|
||||
setWhipSource((prev) => ({ ...prev, previewActive: false, whepConnState: 'idle', previewError: '' }));
|
||||
}, [$whipSource.whepUrl]);
|
||||
|
||||
// Stop preview if stream goes offline
|
||||
React.useEffect(() => {
|
||||
if ($state.state !== 'connected' && $whipSource.previewActive) {
|
||||
stopWhepPreview();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [$state.state]);
|
||||
|
||||
// Cleanup on unmount
|
||||
React.useEffect(() => {
|
||||
return () => { if (whepPcRef.current) whepPcRef.current.close(); };
|
||||
}, []);
|
||||
|
||||
// ── Go Live: start ingest + all configured egresses ───────────────────────
|
||||
const connectAndStartPublications = React.useCallback(async () => {
|
||||
await connect();
|
||||
// Give FFmpeg a moment to start reading the WHIP source before launching egresses
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const processes = await props.restreamer.ListIngestEgresses(_channelid);
|
||||
for (const p of processes) {
|
||||
if (p.service === 'player') continue;
|
||||
try { await props.restreamer.StartEgress(_channelid, p.id); } catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 3000);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [_channelid, props.restreamer]);
|
||||
|
||||
const handleHelp = (topic) => () => {
|
||||
H(topic);
|
||||
};
|
||||
@ -416,8 +598,92 @@ export default function Main(props) {
|
||||
<Grid item xs={12}>
|
||||
<Grid container spacing={0} className={classes.playerL1}>
|
||||
<Grid item xs={12} className={classes.playerL2}>
|
||||
{/* ── WebRTC Room source ── */}
|
||||
{$webrtcRoom.active ? (
|
||||
{/* ── WHIP source → WHEP real-time preview ── */}
|
||||
{$whipSource.active ? (
|
||||
<React.Fragment>
|
||||
{($state.state === 'disconnected' || $state.state === 'disconnecting') && (
|
||||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||||
<Grid item><Typography variant="h2"><Trans>No video</Trans></Typography></Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{$state.state === 'connecting' && (
|
||||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||||
<Grid item><CircularProgress color="inherit" /></Grid>
|
||||
<Grid item><Typography><Trans>Connecting ...</Trans></Typography></Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{$state.state === 'error' && (
|
||||
<Grid container direction="column" className={classes.playerL3} justifyContent="center" alignItems="center" spacing={1}>
|
||||
<Grid item><WarningIcon className={classes.playerWarningIcon} /></Grid>
|
||||
<Grid item>
|
||||
<Typography><Trans>Error: {anonymize($state.progress.error) || 'unknown'}</Trans></Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
{$state.state === 'connected' && (
|
||||
<React.Fragment>
|
||||
{/*
|
||||
Video always in DOM (no display:none) so whepVideoRef is
|
||||
set and autoPlay works reliably across all browsers.
|
||||
The whepOverlay sits on top until the stream is live.
|
||||
*/}
|
||||
<video
|
||||
ref={whepVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
controls
|
||||
className={classes.whepVideo}
|
||||
/>
|
||||
{!$whipSource.previewActive && (
|
||||
<div className={classes.whepOverlay}>
|
||||
{($whipSource.whepConnState === 'gathering' || $whipSource.whepConnState === 'connecting') ? (
|
||||
<React.Fragment>
|
||||
<CircularProgress color="inherit" size={36} />
|
||||
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.6)', marginTop: 8 }}>
|
||||
{$whipSource.whepConnState === 'gathering'
|
||||
? <Trans>Gathering network candidates…</Trans>
|
||||
: <Trans>Connecting via WebRTC…</Trans>}
|
||||
</Typography>
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={startWhepPreview}
|
||||
style={{ background: '#27ae60' }}
|
||||
>
|
||||
<Trans>Live Preview</Trans>
|
||||
</Button>
|
||||
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.4)' }}>
|
||||
<Trans>Real-time via WHEP (~300ms)</Trans>
|
||||
</Typography>
|
||||
{$whipSource.previewError && (
|
||||
<Typography variant="caption" color="error" style={{ maxWidth: 300, textAlign: 'center' }}>
|
||||
{$whipSource.previewError}
|
||||
</Typography>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{$whipSource.previewActive && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={stopWhepPreview}
|
||||
style={{ position: 'absolute', top: 8, right: 8, zIndex: 10, opacity: 0.85 }}
|
||||
>
|
||||
<Trans>Stop</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</React.Fragment>
|
||||
) : $webrtcRoom.active ? (
|
||||
/* ── WebRTC Room source ── */
|
||||
$webrtcRoom.relayActive && $state.state === 'connected' ? (
|
||||
/* Relay activo → mostrar HLS preview normal */
|
||||
<Player type="videojs-internal" source={manifest} poster={poster} autoplay mute controls />
|
||||
@ -594,14 +860,28 @@ export default function Main(props) {
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} marginTop="0em">
|
||||
<ActionButton
|
||||
order={$state.order}
|
||||
state={$state.state}
|
||||
reconnect={$state.progress.reconnect}
|
||||
onDisconnect={disconnect}
|
||||
onConnect={connect}
|
||||
onReconnect={reconnect}
|
||||
/>
|
||||
{$whipSource.active && ($state.state === 'disconnected' || $state.state === 'disconnecting') ? (
|
||||
/* For WHIP sources: single "Go Live" button starts FFmpeg + all egresses */
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={connectAndStartPublications}
|
||||
style={{ background: '#27ae60', color: '#fff', fontWeight: 700 }}
|
||||
>
|
||||
<Trans>Go Live</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<ActionButton
|
||||
order={$state.order}
|
||||
state={$state.state}
|
||||
reconnect={$state.progress.reconnect}
|
||||
onDisconnect={disconnect}
|
||||
onConnect={connect}
|
||||
onReconnect={reconnect}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item xs={12} textAlign="right">
|
||||
<Link variant="body2" color="textSecondary" href="#!" onClick={handleProcessDetails} className={classes.link}>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user