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;