现在更狠,要求服务端拉前端的流,还不告诉地址。还得让服务端通过前端广播的 udp 消息来确定拉流地址,然后进行推流处理。
修改后,代码结构如下:
pico rtmp 推流/
├── .env # 部署配置(Pico 地址 + CANDIDATE)
├── .gitignore ← 新增:忽略缓存/IDE/OS 残留
├── docker-compose.yaml # srs + puller + controller 三服务
├── index.html # H5 播放页
├── srs.conf # SRS 配置
└── src/controller/
├── controller.py # UDP 监听 + 健康检查 + 写共享地址
├── puller-wrapper.sh # 监控地址变化、重启 ffmpeg
├── start-controller.ps1 # 原生启动脚本(备用)
└── test-broadcast.py # 广播模拟测试工具
开始
udp 监听相关
src/controller/controller.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Pico 设备自动发现 + 拉流切换控制器(容器化版)
============================================================
容器化架构(解耦,无需 docker socket):
controller(python, UDP 1973) --写 /config/current_url--> puller(ffmpeg 包装脚本)
controller 只负责发现 + 写共享文件;puller 自行监控文件并在地址变化时
杀掉当前 ffmpeg、用新地址重启。无需重建容器,切换 <1s 完成。
环境变量(docker-compose.yml 注入):
UDP_PORT UDP 监听端口(默认 1973)
SRS_API SRS HTTP API(容器内用 http://srs:1985,走内部网络)
STREAM_PATH 拉流目标流路径(默认 /live/pico,与 index.html 一致)
CURRENT_URL_FILE 共享当前地址文件(默认 /config/current_url,与 puller 共享卷)
ENV_FILE .env 路径(默认 /app/.env,仅作人读镜像 + 首次种子)
Pico UDP 广播消息含 rtmp:// 地址即可(JSON 或纯文本,正则提取)。
"""
import os
import re
import json
import time
import socket
import threading
import urllib.request
from datetime import datetime
# ==================== 配置(来自环境变量) ====================
UDP_PORT = int(os.environ.get('UDP_PORT', '1973'))
UDP_BIND = os.environ.get('UDP_BIND', '0.0.0.0')
SRS_API = os.environ.get('SRS_API', 'http://srs:1985').rstrip('/')
STREAM_PATH = os.environ.get('STREAM_PATH', '/live/pico')
CURRENT_URL_FILE = os.environ.get('CURRENT_URL_FILE', '/config/current_url')
ENV_FILE = os.environ.get('ENV_FILE', '/app/.env')
HEALTH_INTERVAL = int(os.environ.get('HEALTH_INTERVAL', '10'))
# =============================================================
URL_PATTERN = re.compile(r'rtmp://[^\s"\'<>]+')
STATE_DESC = {
'online': 'Pico 推流中',
'offline': 'Pico 已断开,等待新设备广播',
'idle': '无推流连接',
'error': 'SRS 不可达',
}
current_url = None
last_health = None
lock = threading.Lock()
def log(msg):
print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}', flush=True)
def read_env_url():
try:
with open(ENV_FILE, 'r', encoding='utf-8') as f:
for line in f:
if line.startswith('PICO_RTMP_URL='):
return line.split('=', 1)[1].strip()
except FileNotFoundError:
pass
return None
def update_env_url(new_url):
"""把新地址同步写回 .env,仅作人读镜像(puller 实际读 /config/current_url)"""
try:
with open(ENV_FILE, 'r', encoding='utf-8') as f:
lines = f.readlines()
except FileNotFoundError:
lines = []
out, found = [], False
for line in lines:
if line.startswith('PICO_RTMP_URL='):
out.append(f'PICO_RTMP_URL={new_url}\n')
found = True
else:
out.append(line)
if not found:
out.append(f'PICO_RTMP_URL={new_url}\n')
tmp = ENV_FILE + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
f.writelines(out)
os.replace(tmp, ENV_FILE)
def read_current_url():
try:
with open(CURRENT_URL_FILE, 'r', encoding='utf-8') as f:
return f.read().strip()
except FileNotFoundError:
return None
def write_current_url(url):
os.makedirs(os.path.dirname(CURRENT_URL_FILE), exist_ok=True)
tmp = CURRENT_URL_FILE + '.tmp'
with open(tmp, 'w', encoding='utf-8') as f:
f.write(url)
os.replace(tmp, CURRENT_URL_FILE)
def apply_new_url(new_url):
"""收到新地址:写共享文件 -> puller 自动切换;并镜像到 .env"""
global current_url
with lock:
if new_url == current_url:
log(f'广播地址与当前一致,忽略: {new_url}')
return
log(f'>>> 检测到新 Pico 设备: {new_url} (旧: {current_url})')
try:
write_current_url(new_url)
log(f' 已写入 {CURRENT_URL_FILE}')
except Exception as e:
log(f' 写入 current_url 失败: {e}')
return
try:
update_env_url(new_url)
log(' .env 已同步(人读镜像)')
except Exception as e:
log(f' .env 同步失败(不影响拉流): {e}')
current_url = new_url
log(f'<<< 已切换: {new_url}(puller 将在 ~1s 内切换 ffmpeg)')
def udp_listener():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((UDP_BIND, UDP_PORT))
log(f'UDP 监听 {UDP_BIND}:{UDP_PORT},等待 Pico 广播...')
while True:
try:
data, addr = s.recvfrom(4096)
msg = data.decode('utf-8', errors='replace').strip()
log(f'收到 UDP 来自 {addr[0]}:{addr[1]}: {msg[:200]}')
m = URL_PATTERN.search(msg)
if not m:
log(' 未解析到 rtmp 地址,忽略')
continue
apply_new_url(m.group(0))
except Exception as e:
log(f'UDP 处理异常: {e}')
def health_check():
"""周期查询 SRS,仅状态变化时打印"""
global last_health
while True:
time.sleep(HEALTH_INTERVAL)
try:
with urllib.request.urlopen(f'{SRS_API}/api/v1/streams/', timeout=5) as r:
data = json.loads(r.read())
streams = data.get('streams', [])
pico = next((s for s in streams if s.get('url') == STREAM_PATH), None)
if pico and pico.get('publish', {}).get('active'):
state = 'online'
elif pico:
state = 'offline'
else:
state = 'idle'
err = ''
except Exception as e:
state = 'error'
err = str(e)
if state != last_health:
desc = STATE_DESC.get(state) + (f' ({err})' if state == 'error' else '')
log(f'[健康] 状态: {state} - {desc}')
last_health = state
def main():
global current_url
# 当前地址优先读共享文件;为空则用 .env 种子初始化(首次启动)
current_url = read_current_url()
if not current_url:
current_url = read_env_url()
if current_url:
try:
write_current_url(current_url)
log(f'已用 .env 种子初始化 {CURRENT_URL_FILE}')
except Exception as e:
log(f'初始化 current_url 失败: {e}')
log(f'控制器启动,当前 Pico 地址: {current_url or "(无)"}')
threading.Thread(target=udp_listener, daemon=True).start()
threading.Thread(target=health_check, daemon=True).start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
log('控制器退出')
if __name__ == '__main__':
main()
src/controller/puller-wrapper.sh
#!/bin/sh
# ============================================================
# Pico 拉流智能包装脚本(容器内运行)
# 读取 /config/current_url 启动 ffmpeg;地址变化时杀掉当前 ffmpeg
# 并用新地址重启;ffmpeg 自行退出(Pico 断线)则等待重试。
# controller 只需写 /config/current_url,本脚本自动完成切换,
# 无需 docker socket、无需重建容器,切换 <1s。
# ============================================================
URL_FILE=/config/current_url
FFMPEG=/usr/local/srs/objs/ffmpeg/bin/ffmpeg
OUT=rtmp://127.0.0.1:1935/live/pico
while true; do
url="$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n')"
if [ -z "$url" ]; then
echo "[puller] 未配置地址,等待 controller 写入..."
sleep 2
continue
fi
echo "[puller] 拉流开始: $url"
"$FFMPEG" -hide_banner -loglevel warning -rw_timeout 5000000 -i "$url" -c copy -f flv "$OUT" &
pid=$!
# 监控:ffmpeg 进程存活期间轮询地址文件
while kill -0 "$pid" 2>/dev/null; do
new="$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n')"
if [ "$new" != "$url" ]; then
echo "[puller] 地址变更: $url -> $new,切换中..."
kill "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
break
fi
sleep 1
done
# 退出原因判断:若地址未变,说明 ffmpeg 自行退出(Pico 断线),等待重试
cur="$(cat "$URL_FILE" 2>/dev/null | tr -d '\r\n')"
if [ "$cur" = "$url" ]; then
echo "[puller] ffmpeg 已退出,3 秒后重试同一地址"
sleep 3
fi
done
src/controller/start-controller.ps1
# Pico 自动发现控制器启动脚本(崩溃自动重启)
$ErrorActionPreference = 'Stop'
Set-Location $PSScriptRoot
Write-Host "启动 Pico 自动发现控制器(Ctrl+C 停止)..." -ForegroundColor Cyan
while ($true) {
python controller.py
$code = $LASTEXITCODE
if ($code -eq 0 -or $null -eq $code) { break } # 正常退出
Write-Host "控制器异常退出 (code=$code),3 秒后自动重启..." -ForegroundColor Yellow
Start-Sleep -Seconds 3
}
src/controller/start-controller.ps1
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
模拟 Pico 设备广播推流地址,用于测试 controller.py
用法:
python test-broadcast.py # 用默认地址测
python test-broadcast.py rtmp://192.168.31.99:3343/live/cast # 指定地址
说明:容器化 controller 经端口映射只收得到 UDP 广播(收不到单播),
所以这里发到子网广播地址 192.168.31.255(同时发 255.255.255.255 兜底)。
真实 Pico 也应向广播地址发送,控制器即可收到。
"""
import sys
import socket
url = sys.argv[1] if len(sys.argv) > 1 else 'rtmp://192.168.31.99:3343/live/cast'
msg = '{"device":"Pico-test","rtmp_url":"' + url + '"}'
data = msg.encode('utf-8')
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
for dst in ('192.168.31.255', '255.255.255.255'):
s.sendto(data, (dst, 1973))
print(f'已发送广播: {msg} -> {dst}:1973')
docker 构建相关
.env
# ============================================================
# 部署配置:改这里即可,无需动 docker-compose.yml
# 改完执行 docker compose up -d 生效
# ============================================================
# Pico 眼镜 RTMP 服务地址(puller 边车从这里拉流)
PICO_RTMP_URL=rtmp://192.168.31.54:3343/live/cast?hport=3342&hhost=http.flv
# SRS 服务器 IP:WebRTC 候选地址,需与 index.html 的 SRS_HOST 一致
CANDIDATE=192.168.31.112
.gitignore
# Python 字节码缓存(py_compile / import 生成,可随时重建,勿提交)
__pycache__/
*.py[cod]
*$py.class
# controller 原子写 .env 时的瞬态临时文件(正常被 rename 覆盖)
.env.tmp
# IDE / 编辑器临时文件
.vscode/
.idea/
*.swp
*.swo
*~
# 操作系统残留
.DS_Store
Thumbs.db
desktop.ini
docker-compose.yaml
# ============================================================
# Pico RTMP 推流 -> H5 播放:SRS 流媒体服务器
# 启动:docker compose up -d
# 部署参数(Pico 地址、本机 IP)已抽到 .env,改 .env 后执行 docker compose up -d
# ============================================================
services:
srs:
image: ossrs/srs:6
container_name: srs-pico
restart: always
# 镜像默认启动命令加载的是 conf/docker.conf,必须显式指定为挂载的 srs.conf 才能生效
command: ["./objs/srs", "-c", "conf/srs.conf"]
ports:
- "1935:1935" # RTMP 推流(Pico 推到这里)
- "8080:8080" # HTTP:HLS / HTTP-FLV / H5 播放页
- "1985:1985" # HTTP API:WebRTC 信令 / SRS 控制台
- "8000:8000/udp" # WebRTC 媒体传输
environment:
# WebRTC 候选地址:必须与 H5 页面中的 SRS_HOST 指向同一台机器
CANDIDATE: ${CANDIDATE} # 取自 .env
volumes:
# SRS 主配置
- ./srs.conf:/usr/local/srs/conf/srs.conf
# H5 播放页挂进 SRS 内置 HTTP 服务,与 HLS 同源免跨域
# 访问: http://{IP}:8080/index.html
- ./index.html:/usr/local/srs/objs/nginx/html/index.html
# ---------- 拉流边车:读共享地址拉 RTMP 转推到 SRS ----------
# 原理:puller-wrapper.sh 监控 /config/current_url,地址一变就杀 ffmpeg 重启
# controller 写该文件即可触发切换,无需 docker socket、无需重建容器
puller:
image: ossrs/srs:6
container_name: srs-puller
restart: always
network_mode: "service:srs" # 共享 srs 网络栈:可同时访问 Pico(LAN) 与 SRS(127.0.0.1)
volumes:
- ./src/controller/puller-wrapper.sh:/app/puller-wrapper.sh:ro
- rtmpcfg:/config # 与 controller 共享的当前拉流地址
command: ["sh", "/app/puller-wrapper.sh"]
# ---------- 自动发现控制器:UDP 监听 Pico 广播 -> 写共享地址 ----------
# Docker Desktop(WSL2) 容器经端口映射可收到局域网 UDP 广播(已实测)
controller:
image: python:3.13-alpine
container_name: srs-controller
restart: always
depends_on: [srs]
ports:
- "1973:1973/udp" # 接收 Pico 推流地址广播
environment:
UDP_PORT: "1973"
SRS_API: http://srs:1985 # 走容器内部网络访问 SRS API
STREAM_PATH: /live/pico
CURRENT_URL_FILE: /config/current_url
ENV_FILE: /app/.env
volumes:
- ./:/app # 项目根:.env 读写 + controller.py 可达
- rtmpcfg:/config # 与 puller 共享
working_dir: /app/src/controller # controller.py 已移到 src/controller/
command: ["python", "controller.py"]
# controller 与 puller 共享的当前拉流地址卷(持久化,重启不丢)
volumes:
rtmpcfg:
srs.conf
# ============================================================
# SRS 5.x 配置:Pico 眼镜 RTMP 推流 -> HLS / HTTP-FLV / WebRTC 三协议分发
# 配合 docker-compose.yml 使用,挂载到 /usr/local/srs/conf/srs.conf
# ============================================================
listen 1935; # RTMP 推流/拉流端口
max_connections 1000;
daemon off; # Docker 内必须前台运行
srs_log_tank console; # 日志输出到控制台,便于 docker logs 查看
# ---------- HTTP API:WebRTC 信令(/rtc/v1/play/) 与控制台 ----------
http_api {
enabled on;
listen 1985;
crossdomain on; # H5 页跨域调用信令接口必须开启
}
# ---------- HTTP 服务:HLS 切片 / HTTP-FLV / 播放页静态文件 ----------
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
crossdomain on; # H5 页与流不同源时必须开启
}
# ---------- WebRTC 服务(UDP) ----------
rtc_server {
enabled on;
listen 8000; # UDP
# 观看端能访问到的本机 IP,通过 docker-compose 的 CANDIDATE 环境变量注入
candidate $CANDIDATE;
}
vhost __defaultVhost__ {
# ===== 输出 1:HLS(兼容性最好,iOS/微信兜底) =====
hls {
enabled on;
hls_fragment 2; # 切片时长(秒),越小延迟越低
hls_window 10; # m3u8 列表窗口时长(秒)
hls_path ./objs/nginx/html;
hls_m3u8_file [app]/[stream].m3u8;
hls_ts_file [app]/[stream]-[seq].ts;
hls_cleanup on; # 流断开后自动清理过期切片
# 播放地址: http://{IP}:8080/{app}/{stream}.m3u8
}
# ===== 输出 2:HTTP-FLV(国内安卓低延迟备用,iOS 不支持) =====
http_remux {
enabled on;
mount [vhost]/[app]/[stream].flv;
# 播放地址: http://{IP}:8080/{app}/{stream}.flv
}
# ===== 输出 3:WebRTC(延迟 < 1s,实时互动首选) =====
rtc {
enabled on;
rtmp_to_rtc on; # 关键:RTMP 推流直接转 RTC 播放,无需转码
rtc_to_rtmp off;
# 播放地址: webrtc://{IP}/{app}/{stream}(信令走 1985 端口 HTTP API)
}
# ---------- 低延迟调优 ----------
play {
gop_cache off; # 关闭 GOP 缓存:降低首屏延迟,新观众等下一个关键帧
queue_length 10;
mw_latency 100;
}
tcp_nodelay on;
min_latency on;
}
# ============================================================
# 可选:主动拉流模式(Pico 端 App 内嵌 RTSP Server 时启用)
# 原理:SRS 通过内置 ffmpeg 从 Pico 拉取 RTSP 流,原封不动转推到本地
# RTMP 入口,后续的 HLS/HTTP-FLV/WebRTC 输出与 H5 播放页完全不用改。
# 说明:SRS 6 已移除 ingest 配置指令(启动会报 "illegal directive ingest"),
# 因此改用 docker-compose.yml 中的 puller 边车服务(独立 ffmpeg 进程)
# 持续从 Pico 拉流并转推本地,效果与 ingest 等价且与 SRS 版本无关。
# Pico 的拉流地址维护在 docker-compose.yml 的 puller.command 中。
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Pico 眼镜实时画面</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; background: #000; overflow: hidden;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; }
#player { width: 100%; height: 100%; object-fit: contain; }
#status {
position: fixed; top: 12px; left: 12px; z-index: 10;
padding: 6px 12px; border-radius: 4px;
background: rgba(0, 0, 0, .6); color: #0f0; font-size: 13px;
}
#toolbar {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
z-index: 10; display: flex; gap: 12px;
}
#toolbar button {
padding: 8px 20px; border: none; border-radius: 20px;
background: rgba(255, 255, 255, .85); color: #000;
font-size: 14px; cursor: pointer;
}
</style>
</head>
<body>
<!-- muted + playsinline:满足浏览器自动播放策略;iOS 上 playsinline 防止强制全屏 -->
<video id="player" autoplay muted playsinline controls></video>
<div id="status">初始化…</div>
<div id="toolbar">
<button id="btnSound">开启声音</button>
<button id="btnRetry">重新连接</button>
</div>
<!-- 内网无公网环境:下载 hls.js 到本目录,改为 <script src="./hls.min.js"></script> -->
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<script>
/* ==================== 配置区(部署时只需修改这里) ==================== */
const SRS_HOST = '192.168.31.112'; // SRS 服务器 IP/域名(与 docker-compose 的 CANDIDATE 一致)
const HTTP_PORT = 8080; // SRS HTTP 端口:HLS / HTTP-FLV
const API_PORT = 1985; // SRS HTTP API 端口:WebRTC 信令
const APP = 'live'; // 应用名
const STREAM = 'pico'; // 流名,对应 Pico 推流地址 rtmp://{IP}/live/pico
const RETRY_INTERVAL = 3000; // 断线/失败重试间隔(毫秒)
/* ==================== 以下一般无需修改 ==================== */
// 派生播放地址
const RTC_STREAM_URL = `webrtc://${SRS_HOST}/${APP}/${STREAM}`;
const RTC_API_URL = `http://${SRS_HOST}:${API_PORT}/rtc/v1/play/`;
const HLS_URL = `http://${SRS_HOST}:${HTTP_PORT}/${APP}/${STREAM}.m3u8`;
// HTTP-FLV 备用地址(本页未使用,安卓低延迟场景可用 flv.js 接入):
// const FLV_URL = `http://${SRS_HOST}:${HTTP_PORT}/${APP}/${STREAM}.flv`;
const video = document.getElementById('player');
const statusEl = document.getElementById('status');
const btnSound = document.getElementById('btnSound');
const btnRetry = document.getElementById('btnRetry');
let pc = null; // RTCPeerConnection 实例
let hls = null; // hls.js 实例
let retryTimer = null; // 重试定时器
function setStatus(text) {
statusEl.textContent = text;
console.log('[player]', text);
}
/* ---------- 入口:WebRTC 优先,失败降级 HLS,两者都失败则定时重试 ---------- */
async function start() {
clearTimeout(retryTimer);
cleanup();
try {
await playWebRTC();
} catch (e) {
console.warn('WebRTC 不可用,降级 HLS:', e);
try {
playHLS();
} catch (e2) {
console.warn('HLS 播放失败:', e2);
setStatus(e2.message + ',等待重试…');
scheduleRetry();
}
}
}
/* ---------- 方案一:WebRTC(延迟 < 1s) ---------- */
async function playWebRTC() {
setStatus('WebRTC 连接中…');
pc = new RTCPeerConnection();
// 只收不发:拉流场景
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.addTransceiver('video', { direction: 'recvonly' });
pc.ontrack = (e) => {
video.srcObject = e.streams[0];
video.play().catch(() => {});
setStatus('WebRTC 播放中(低延迟模式)');
};
// 运行中断线 -> 走整体重试
pc.onconnectionstatechange = () => {
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) {
setStatus('WebRTC 连接断开,准备重连…');
scheduleRetry();
}
};
// 与 SRS 交换 SDP(WHIP 风格 API)
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch(RTC_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api: RTC_API_URL, streamurl: RTC_STREAM_URL, sdp: offer.sdp })
});
const data = await res.json();
if (data.code !== 0) {
// 流未推上来时 SRS 也会返回错误码,抛出让上层降级/重试
throw new Error('RTC 信令失败,code=' + data.code);
}
await pc.setRemoteDescription({ type: 'answer', sdp: data.sdp });
}
/* ---------- 方案二:HLS 兜底(iOS Safari / 微信全兼容) ---------- */
function playHLS() {
setStatus('HLS 连接中…');
if (window.Hls && Hls.isSupported()) {
// Chrome / Edge / Firefox / 安卓微信
hls = new Hls({
liveSyncDurationCount: 2, // 贴近直播边缘,降低延迟
liveMaxLatencyDurationCount: 5,
maxBufferLength: 10
});
hls.loadSource(HLS_URL);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
setStatus('HLS 播放中(兼容模式)');
});
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal) return; // 非致命错误(如短暂卡顿)由 hls.js 自行恢复
console.warn('HLS 致命错误:', data.type, data.details);
setStatus('HLS 播放失败,等待重试…');
scheduleRetry(); // 重试会重新走 start(),优先再试 WebRTC
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// iOS Safari:原生 HLS
video.src = HLS_URL;
video.play().catch(() => {});
setStatus('HLS 播放中(原生模式)');
// 流未就绪(m3u8 404)或中断时触发重试
video.onerror = () => { setStatus('播放失败,等待重试…'); scheduleRetry(); };
video.onended = () => { setStatus('流已结束,等待重试…'); scheduleRetry(); };
} else {
throw new Error('当前浏览器不支持播放,请使用最新版 Chrome/Safari');
}
}
/* ---------- 工具函数 ---------- */
function scheduleRetry() {
clearTimeout(retryTimer);
retryTimer = setTimeout(start, RETRY_INTERVAL);
}
function cleanup() {
if (pc) { pc.close(); pc = null; }
if (hls) { hls.destroy(); hls = null; }
video.onerror = null;
video.onended = null;
video.srcObject = null;
video.removeAttribute('src');
video.load();
}
/* ---------- 页面交互 ---------- */
// 浏览器要求静音自动播放,声音需用户手动开启
btnSound.onclick = () => {
video.muted = !video.muted;
btnSound.textContent = video.muted ? '开启声音' : '关闭声音';
if (!video.muted) video.play().catch(() => {});
};
btnRetry.onclick = start;
start();
</script>
</body>
</html>
启动
docker compose up -d
