From 106e6e1f16e1c0039e366202481e628997615e38 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Thu, 26 Mar 2026 10:07:49 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=92=AD=EF=BC=9A=E7=94=BB=E8=B4=A8?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E3=80=81=E5=8F=AA=E8=AF=BB=20/live/info?= =?UTF-8?q?=E3=80=81=E5=BC=B9=E5=B9=95=20WS=20=E9=80=8F=E4=BC=A0=EF=BC=9BN?= =?UTF-8?q?ginx=20=E5=BC=B9=E5=B9=95=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- admin/src/utils/liveWebRTC.js | 53 ++++++++- admin/src/views/sites/LiveBroadcast.vue | 24 ++++- nginx/nginx.conf | 12 +++ nginx/yuheng.docker.conf.tpl | 14 +++ nginx/yuheng.yuxindazhineng.com.conf | 12 +++ server/pkg/weblive/danmaku.go | 96 +++++++++++++++++ server/pkg/weblive/hub.go | 5 +- server/pkg/weblive/info.go | 50 +++++++++ server/pkg/weblive/ws.go | 4 + web/src/utils/liveWebRTC.js | 17 +++ web/src/views/LiveRoom.vue | 138 +++++++++++++++++++++++- 11 files changed, 417 insertions(+), 8 deletions(-) create mode 100644 server/pkg/weblive/danmaku.go create mode 100644 server/pkg/weblive/info.go diff --git a/admin/src/utils/liveWebRTC.js b/admin/src/utils/liveWebRTC.js index 542f2d1..5074c82 100644 --- a/admin/src/utils/liveWebRTC.js +++ b/admin/src/utils/liveWebRTC.js @@ -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() diff --git a/admin/src/views/sites/LiveBroadcast.vue b/admin/src/views/sites/LiveBroadcast.vue index c4c5fd2..577af39 100644 --- a/admin/src/views/sites/LiveBroadcast.vue +++ b/admin/src/views/sites/LiveBroadcast.vue @@ -10,6 +10,17 @@ LIVE_PUBLIC_IP(服务器公网 IPv4,与域名一致),并配置 LIVE_ICE_SERVERS(含 TURN)。

{{ status }}

+
+ 推流画质 + + + +
开始直播 结束直播 @@ -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; } diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 8f7cb4a..473cf4e 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -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; diff --git a/nginx/yuheng.docker.conf.tpl b/nginx/yuheng.docker.conf.tpl index 865ac87..c1a3d5f 100644 --- a/nginx/yuheng.docker.conf.tpl +++ b/nginx/yuheng.docker.conf.tpl @@ -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; diff --git a/nginx/yuheng.yuxindazhineng.com.conf b/nginx/yuheng.yuxindazhineng.com.conf index a9bd51e..67d0e23 100644 --- a/nginx/yuheng.yuxindazhineng.com.conf +++ b/nginx/yuheng.yuxindazhineng.com.conf @@ -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; diff --git a/server/pkg/weblive/danmaku.go b/server/pkg/weblive/danmaku.go new file mode 100644 index 0000000..e2ccef2 --- /dev/null +++ b/server/pkg/weblive/danmaku.go @@ -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() + } +} diff --git a/server/pkg/weblive/hub.go b/server/pkg/weblive/hub.go index cdc3c1c..e9dc112 100644 --- a/server/pkg/weblive/hub.go +++ b/server/pkg/weblive/hub.go @@ -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) { diff --git a/server/pkg/weblive/info.go b/server/pkg/weblive/info.go new file mode 100644 index 0000000..4bf6a68 --- /dev/null +++ b/server/pkg/weblive/info.go @@ -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(), + }) +} diff --git a/server/pkg/weblive/ws.go b/server/pkg/weblive/ws.go index d34fbba..0647268 100644 --- a/server/pkg/weblive/ws.go +++ b/server/pkg/weblive/ws.go @@ -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 diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index 77bfe36..e285761 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -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 { diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index 59e1af8..f28872f 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -17,11 +17,33 @@ playsinline autoplay > +
+
+ + +
@@ -44,12 +66,16 @@ @@ -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;