直播:画质可选、只读 /live/info、弹幕 WS 透传;Nginx 弹幕路径

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 10:07:49 +08:00
parent 6b3210f714
commit 106e6e1f16
11 changed files with 417 additions and 8 deletions

View File

@@ -3,8 +3,44 @@
*/
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
function liveWsURLPublish(token) {
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}`
export const LIVE_QUALITY_OPTIONS = [
{ value: 'source', label: '原画(设备默认)' },
{ value: 'high', label: '高清 720p' },
{ value: 'mid', label: '标清 480p' },
{ value: 'low', label: '流畅 360p' }
]
const QUALITY_MEDIA = {
source: { video: true, audio: true },
high: {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 }
},
audio: true
},
mid: {
video: {
width: { ideal: 854 },
height: { ideal: 480 },
frameRate: { ideal: 24 }
},
audio: true
},
low: {
video: {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 20 }
},
audio: true
}
}
function liveWsURLPublish(token, quality) {
const q = QUALITY_MEDIA[quality] ? quality : 'high'
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}&quality=${encodeURIComponent(q)}`
if (apiBase) {
const base = apiBase.replace(/\/$/, '')
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
@@ -48,15 +84,21 @@ function humanizeGetUserMediaError(err) {
* @param {string} opts.token 管理员 JWT
* @param {(s: string) => void} [opts.onStatus]
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
* @param {'source'|'high'|'mid'|'low'} [opts.quality] 推流画质(约束摄像头采集分辨率)
*/
export function startPublishing(opts = {}) {
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
const {
token = '',
quality = 'high',
onStatus = () => {},
onLocalStream = () => {}
} = opts
if (!token) {
onStatus('未登录,无法开播')
return { stop: () => {} }
}
const wsUrl = liveWsURLPublish(token)
const wsUrl = liveWsURLPublish(token, quality)
const ws = new WebSocket(wsUrl)
const pc = new RTCPeerConnection({ iceServers: defaultIce })
let stream = null
@@ -75,7 +117,8 @@ export function startPublishing(opts = {}) {
onStatus('信令已连接,正在采集摄像头…')
try {
// 后台多为 PC不要用 facingMode:'user',部分机器会直接导致 “Could not start video source”
stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
const cons = QUALITY_MEDIA[quality] || QUALITY_MEDIA.high
stream = await navigator.mediaDevices.getUserMedia(cons)
onLocalStream(stream)
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
const offer = await pc.createOffer()

View File

@@ -10,6 +10,17 @@
<code>LIVE_PUBLIC_IP</code>服务器公网 IPv4与域名一致并配置 <code>LIVE_ICE_SERVERS</code> TURN
</p>
<p class="status">{{ status }}</p>
<div v-if="!session" class="quality-row">
<span class="quality-label">推流画质</span>
<el-select v-model="quality" style="width: 220px" :disabled="!token">
<el-option
v-for="o in qualityOptions"
:key="o.value"
:label="o.label"
:value="o.value"
/>
</el-select>
</div>
<div class="actions">
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
<el-button v-else type="danger" @click="stop">结束直播</el-button>
@@ -23,7 +34,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '../../stores/auth'
import { startPublishing } from '../../utils/liveWebRTC'
import { startPublishing, LIVE_QUALITY_OPTIONS } from '../../utils/liveWebRTC'
const authStore = useAuthStore()
const token = computed(() => authStore.getToken() || '')
@@ -39,6 +50,7 @@ function start() {
status.value = '正在连接…'
const { stop } = startPublishing({
token: token.value,
quality: quality.value,
onStatus: (s) => {
status.value = s
},
@@ -96,6 +108,16 @@ onBeforeRouteLeave(() => {
margin-bottom: 12px;
min-height: 1.5em;
}
.quality-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.quality-label {
font-size: 14px;
color: #606266;
}
.actions {
margin-bottom: 16px;
}

View File

@@ -18,6 +18,18 @@ server {
proxy_read_timeout 86400s;
}
location /api/web/live/danmaku/ws {
proxy_pass http://api:9527;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location /api/ {
proxy_pass http://api:9527;
proxy_http_version 1.1;

View File

@@ -49,6 +49,20 @@ server {
proxy_send_timeout 86400s;
}
location /api/web/live/danmaku/ws {
set $upstream_api api;
proxy_pass http://$upstream_api:8088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
location /api/ {
set $upstream_api api;
proxy_pass http://$upstream_api:8088;

View File

@@ -37,6 +37,18 @@ server {
proxy_read_timeout 86400s;
}
location /api/web/live/danmaku/ws {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
location / {
proxy_pass http://127.0.0.1:8443;
proxy_http_version 1.1;

View File

@@ -0,0 +1,96 @@
package weblive
import (
"encoding/json"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
const maxDanmakuRunes = 120
var (
danmakuClientsMu sync.Mutex
danmakuClients = make(map[*websocket.Conn]struct{})
)
// handleDanmakuWS 弹幕:客户端发 JSON {"text":"..."},服务端立刻向所有连接广播 {"type":"dm","text","ts"},不落库
func handleDanmakuWS(c *gin.Context) {
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
ws.SetReadLimit(4096)
danmakuClientsMu.Lock()
danmakuClients[ws] = struct{}{}
danmakuClientsMu.Unlock()
defer func() {
danmakuClientsMu.Lock()
delete(danmakuClients, ws)
danmakuClientsMu.Unlock()
_ = ws.Close()
}()
for {
mt, payload, err := ws.ReadMessage()
if err != nil {
return
}
if mt != websocket.TextMessage {
continue
}
text := extractDanmakuText(payload)
if text == "" {
continue
}
out, err := json.Marshal(map[string]interface{}{
"type": "dm",
"text": text,
"ts": time.Now().UnixMilli(),
})
if err != nil {
continue
}
danmakuBroadcast(out)
}
}
func extractDanmakuText(payload []byte) string {
var v struct {
Text string `json:"text"`
}
if err := json.Unmarshal(payload, &v); err != nil {
return ""
}
t := strings.TrimSpace(v.Text)
if t == "" {
return ""
}
if utf8.RuneCountInString(t) <= maxDanmakuRunes {
return t
}
runes := []rune(t)
return string(runes[:maxDanmakuRunes])
}
func danmakuBroadcast(b []byte) {
danmakuClientsMu.Lock()
defer danmakuClientsMu.Unlock()
dead := make([]*websocket.Conn, 0)
for conn := range danmakuClients {
_ = conn.SetWriteDeadline(time.Now().Add(8 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, b); err != nil {
dead = append(dead, conn)
}
}
for _, conn := range dead {
delete(danmakuClients, conn)
_ = conn.Close()
}
}

View File

@@ -77,7 +77,9 @@ type Hub struct {
publishConn *websocket.Conn
pubPC *webrtc.PeerConnection
forwarders []*trackForwarder
// 开播 WebSocket 上 quality= 参数,供 GET /live/info 只读输出
publishQuality string
forwarders []*trackForwarder
viewers map[string]*viewerSession
}
@@ -128,6 +130,7 @@ func (h *Hub) clearPublisher() {
h.pubPC = nil
}
h.publishConn = nil
h.publishQuality = ""
}
func (h *Hub) removeViewer(id string) {

View File

@@ -0,0 +1,50 @@
package weblive
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
// liveQualitySet 与前端开播档位一致;非法 query 回落为 high
var liveQualitySet = map[string]struct{}{
"source": {}, "high": {}, "mid": {}, "low": {},
}
func normalizeQuality(q string) string {
q = strings.TrimSpace(strings.ToLower(q))
if _, ok := liveQualitySet[q]; ok {
return q
}
return "high"
}
func liveQualityList() []gin.H {
return []gin.H{
{"id": "source", "label": "原画(设备默认)"},
{"id": "high", "label": "高清 720p"},
{"id": "mid", "label": "标清 480p"},
{"id": "low", "label": "流畅 360p"},
}
}
// handleLiveInfo 仅 GET、无请求体、不读 query只输出直播状态与画质元数据
func handleLiveInfo(c *gin.Context) {
h, herr := getHub()
live := false
cq := ""
if herr == nil {
h.mu.RLock()
live = h.publishConn != nil && h.pubPC != nil && len(h.forwarders) > 0
cq = h.publishQuality
h.mu.RUnlock()
}
c.JSON(http.StatusOK, gin.H{
"live": live,
"qualities": liveQualityList(),
"current_quality": cq,
"ts": time.Now().UnixMilli(),
})
}

View File

@@ -32,7 +32,9 @@ type wsEnvelope struct {
func RegisterRoutes(r gin.IRoutes) {
r.GET("/live/status", handleLiveStatus)
r.GET("/live/info", handleLiveInfo)
r.GET("/live/ws", handleLiveWS)
r.GET("/live/danmaku/ws", handleDanmakuWS)
}
func handleLiveStatus(c *gin.Context) {
@@ -90,12 +92,14 @@ func handlePublisherWS(c *gin.Context, h *Hub) {
return
}
h.publishConn = ws
h.publishQuality = normalizeQuality(c.Query("quality"))
h.mu.Unlock()
pc, err := h.api.NewPeerConnection(h.cfg)
if err != nil {
h.mu.Lock()
h.publishConn = nil
h.publishQuality = ""
h.mu.Unlock()
_ = ws.Close()
return

View File

@@ -12,6 +12,23 @@ export function liveWsURLView() {
return `${proto}//${window.location.host}${path}`
}
/** 只读直播元信息GET无请求体 */
export function liveInfoURL() {
return apiBase ? `${apiBase}/api/web/live/info` : '/api/web/live/info'
}
/** 弹幕 WebSocket发 {"text":"..."},收 {"type":"dm","text","ts"} */
export function liveDanmakuWsURL() {
const path = '/api/web/live/danmaku/ws'
if (apiBase) {
const base = apiBase.replace(/\/$/, '')
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
return `${wsOrigin}${path}`
}
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${path}`
}
export async function fetchLiveStatus() {
const url = apiBase ? `${apiBase}/api/web/live/status` : '/api/web/live/status'
try {

View File

@@ -17,11 +17,33 @@
playsinline
autoplay
></video>
<div class="live-dm-layer" aria-hidden="true">
<div
v-for="d in dmItems"
:key="d.id"
class="live-dm-line"
:style="{ top: d.top + '%' }"
>
{{ d.text }}
</div>
</div>
<div class="live-video-toolbar" role="toolbar" aria-label="播放控制">
<button type="button" class="live-video-toolbtn" @click="unmuteAndPlay">开声音</button>
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
</div>
</div>
<div class="live-dm-bar">
<input
v-model="dmDraft"
class="live-dm-input"
type="text"
maxlength="120"
placeholder="发条弹幕…"
autocomplete="off"
@keydown.enter.prevent="sendDm"
/>
<button type="button" class="live-dm-send" @click="sendDm">发送</button>
</div>
</section>
<section class="live-block live-block--divider" aria-label="外链直播间">
@@ -44,12 +66,16 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { apiBase } from '../config'
import { startViewing } from '../utils/liveWebRTC'
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
const watchVideoRef = ref(null)
const rawLiveUrl = ref('')
const pageTitle = ref('视频直播')
const watchStatus = ref('正在检测本站直播…')
const dmDraft = ref('')
const dmItems = ref([])
let dmIdSeq = 0
let dmWs = null
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
@@ -108,9 +134,48 @@ function toggleVideoFullscreen() {
el.requestFullscreen?.() || el.webkitRequestFullscreen?.()
}
function pushDmLine(text) {
const id = ++dmIdSeq
const top = 8 + Math.floor(Math.random() * 55)
dmItems.value = [...dmItems.value, { id, text, top }]
setTimeout(() => {
dmItems.value = dmItems.value.filter((x) => x.id !== id)
}, 12000)
}
function connectDanmaku() {
try {
dmWs?.close()
} catch (_) {}
dmWs = new WebSocket(liveDanmakuWsURL())
dmWs.onmessage = (ev) => {
let j
try {
j = JSON.parse(ev.data)
} catch {
return
}
if (j.type === 'dm' && typeof j.text === 'string' && j.text) {
pushDmLine(j.text)
}
}
dmWs.onclose = () => {
dmWs = null
}
}
function sendDm() {
const t = dmDraft.value.trim()
if (!t) return
if (!dmWs || dmWs.readyState !== WebSocket.OPEN) return
dmWs.send(JSON.stringify({ text: t }))
dmDraft.value = ''
}
onMounted(async () => {
loadHomepage()
await nextTick()
connectDanmaku()
viewSession = startViewing(watchVideoRef.value, {
muted: false,
onStatus: (s) => {
@@ -121,6 +186,10 @@ onMounted(async () => {
onUnmounted(() => {
viewSession?.stop()
try {
dmWs?.close()
} catch (_) {}
dmWs = null
})
</script>
@@ -187,6 +256,73 @@ onUnmounted(() => {
max-width: 480px;
margin: 0 auto;
}
.live-dm-layer {
pointer-events: none;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
border-radius: 14px;
z-index: 2;
}
.live-dm-line {
position: absolute;
left: 0;
right: 0;
white-space: nowrap;
overflow: visible;
text-align: right;
font-size: 14px;
font-weight: 600;
color: #fff;
text-shadow: 0 0 6px #000, 0 0 2px #000;
animation: live-dm-marquee 12s linear forwards;
}
@keyframes live-dm-marquee {
from {
transform: translateX(105%);
}
to {
transform: translateX(-105%);
}
}
.live-dm-bar {
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
max-width: 480px;
margin: 14px auto 0;
}
.live-dm-input {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
color: #fff;
font-size: 14px;
}
.live-dm-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.live-dm-send {
flex-shrink: 0;
padding: 10px 18px;
border-radius: 10px;
border: 1px solid rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.15);
color: #00d4ff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.live-dm-send:hover {
background: rgba(0, 212, 255, 0.25);
}
.live-video-toolbar {
display: flex;
flex-wrap: wrap;