Write the Code. Change the World.

8月 04

现在更狠,要求服务端拉前端的流,还不告诉地址。还得让服务端通过前端广播的 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

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注