直播:画质可选、只读 /live/info、弹幕 WS 透传;Nginx 弹幕路径
Made-with: Cursor
This commit is contained in:
@@ -3,8 +3,44 @@
|
|||||||
*/
|
*/
|
||||||
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
const apiBase = (import.meta.env.VITE_API_BASE || '').replace(/\/$/, '')
|
||||||
|
|
||||||
function liveWsURLPublish(token) {
|
export const LIVE_QUALITY_OPTIONS = [
|
||||||
const path = `/api/web/live/ws?role=publish&token=${encodeURIComponent(token)}`
|
{ 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) {
|
if (apiBase) {
|
||||||
const base = apiBase.replace(/\/$/, '')
|
const base = apiBase.replace(/\/$/, '')
|
||||||
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
const wsOrigin = base.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:')
|
||||||
@@ -48,15 +84,21 @@ function humanizeGetUserMediaError(err) {
|
|||||||
* @param {string} opts.token 管理员 JWT
|
* @param {string} opts.token 管理员 JWT
|
||||||
* @param {(s: string) => void} [opts.onStatus]
|
* @param {(s: string) => void} [opts.onStatus]
|
||||||
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
|
* @param {(stream: MediaStream) => void} [opts.onLocalStream]
|
||||||
|
* @param {'source'|'high'|'mid'|'low'} [opts.quality] 推流画质(约束摄像头采集分辨率)
|
||||||
*/
|
*/
|
||||||
export function startPublishing(opts = {}) {
|
export function startPublishing(opts = {}) {
|
||||||
const { token = '', onStatus = () => {}, onLocalStream = () => {} } = opts
|
const {
|
||||||
|
token = '',
|
||||||
|
quality = 'high',
|
||||||
|
onStatus = () => {},
|
||||||
|
onLocalStream = () => {}
|
||||||
|
} = opts
|
||||||
if (!token) {
|
if (!token) {
|
||||||
onStatus('未登录,无法开播')
|
onStatus('未登录,无法开播')
|
||||||
return { stop: () => {} }
|
return { stop: () => {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = liveWsURLPublish(token)
|
const wsUrl = liveWsURLPublish(token, quality)
|
||||||
const ws = new WebSocket(wsUrl)
|
const ws = new WebSocket(wsUrl)
|
||||||
const pc = new RTCPeerConnection({ iceServers: defaultIce })
|
const pc = new RTCPeerConnection({ iceServers: defaultIce })
|
||||||
let stream = null
|
let stream = null
|
||||||
@@ -75,7 +117,8 @@ export function startPublishing(opts = {}) {
|
|||||||
onStatus('信令已连接,正在采集摄像头…')
|
onStatus('信令已连接,正在采集摄像头…')
|
||||||
try {
|
try {
|
||||||
// 后台多为 PC:不要用 facingMode:'user',部分机器会直接导致 “Could not start video source”
|
// 后台多为 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)
|
onLocalStream(stream)
|
||||||
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
stream.getTracks().forEach((t) => pc.addTrack(t, stream))
|
||||||
const offer = await pc.createOffer()
|
const offer = await pc.createOffer()
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
<code>LIVE_PUBLIC_IP</code>(服务器公网 IPv4,与域名一致),并配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
<code>LIVE_PUBLIC_IP</code>(服务器公网 IPv4,与域名一致),并配置 <code>LIVE_ICE_SERVERS</code>(含 TURN)。
|
||||||
</p>
|
</p>
|
||||||
<p class="status">{{ status }}</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">
|
<div class="actions">
|
||||||
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
<el-button v-if="!session" type="primary" :disabled="!token" @click="start">开始直播</el-button>
|
||||||
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
<el-button v-else type="danger" @click="stop">结束直播</el-button>
|
||||||
@@ -23,7 +34,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { startPublishing } from '../../utils/liveWebRTC'
|
import { startPublishing, LIVE_QUALITY_OPTIONS } from '../../utils/liveWebRTC'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const token = computed(() => authStore.getToken() || '')
|
const token = computed(() => authStore.getToken() || '')
|
||||||
@@ -39,6 +50,7 @@ function start() {
|
|||||||
status.value = '正在连接…'
|
status.value = '正在连接…'
|
||||||
const { stop } = startPublishing({
|
const { stop } = startPublishing({
|
||||||
token: token.value,
|
token: token.value,
|
||||||
|
quality: quality.value,
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
status.value = s
|
status.value = s
|
||||||
},
|
},
|
||||||
@@ -96,6 +108,16 @@ onBeforeRouteLeave(() => {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
min-height: 1.5em;
|
min-height: 1.5em;
|
||||||
}
|
}
|
||||||
|
.quality-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.quality-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
.actions {
|
.actions {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ server {
|
|||||||
proxy_read_timeout 86400s;
|
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/ {
|
location /api/ {
|
||||||
proxy_pass http://api:9527;
|
proxy_pass http://api:9527;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -49,6 +49,20 @@ server {
|
|||||||
proxy_send_timeout 86400s;
|
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/ {
|
location /api/ {
|
||||||
set $upstream_api api;
|
set $upstream_api api;
|
||||||
proxy_pass http://$upstream_api:8088;
|
proxy_pass http://$upstream_api:8088;
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ server {
|
|||||||
proxy_read_timeout 86400s;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8443;
|
proxy_pass http://127.0.0.1:8443;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
96
server/pkg/weblive/danmaku.go
Normal file
96
server/pkg/weblive/danmaku.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ type Hub struct {
|
|||||||
|
|
||||||
publishConn *websocket.Conn
|
publishConn *websocket.Conn
|
||||||
pubPC *webrtc.PeerConnection
|
pubPC *webrtc.PeerConnection
|
||||||
|
// 开播 WebSocket 上 quality= 参数,供 GET /live/info 只读输出
|
||||||
|
publishQuality string
|
||||||
forwarders []*trackForwarder
|
forwarders []*trackForwarder
|
||||||
|
|
||||||
viewers map[string]*viewerSession
|
viewers map[string]*viewerSession
|
||||||
@@ -128,6 +130,7 @@ func (h *Hub) clearPublisher() {
|
|||||||
h.pubPC = nil
|
h.pubPC = nil
|
||||||
}
|
}
|
||||||
h.publishConn = nil
|
h.publishConn = nil
|
||||||
|
h.publishQuality = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) removeViewer(id string) {
|
func (h *Hub) removeViewer(id string) {
|
||||||
|
|||||||
50
server/pkg/weblive/info.go
Normal file
50
server/pkg/weblive/info.go
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ type wsEnvelope struct {
|
|||||||
|
|
||||||
func RegisterRoutes(r gin.IRoutes) {
|
func RegisterRoutes(r gin.IRoutes) {
|
||||||
r.GET("/live/status", handleLiveStatus)
|
r.GET("/live/status", handleLiveStatus)
|
||||||
|
r.GET("/live/info", handleLiveInfo)
|
||||||
r.GET("/live/ws", handleLiveWS)
|
r.GET("/live/ws", handleLiveWS)
|
||||||
|
r.GET("/live/danmaku/ws", handleDanmakuWS)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleLiveStatus(c *gin.Context) {
|
func handleLiveStatus(c *gin.Context) {
|
||||||
@@ -90,12 +92,14 @@ func handlePublisherWS(c *gin.Context, h *Hub) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.publishConn = ws
|
h.publishConn = ws
|
||||||
|
h.publishQuality = normalizeQuality(c.Query("quality"))
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
pc, err := h.api.NewPeerConnection(h.cfg)
|
pc, err := h.api.NewPeerConnection(h.cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.publishConn = nil
|
h.publishConn = nil
|
||||||
|
h.publishQuality = ""
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
_ = ws.Close()
|
_ = ws.Close()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ export function liveWsURLView() {
|
|||||||
return `${proto}//${window.location.host}${path}`
|
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() {
|
export async function fetchLiveStatus() {
|
||||||
const url = apiBase ? `${apiBase}/api/web/live/status` : '/api/web/live/status'
|
const url = apiBase ? `${apiBase}/api/web/live/status` : '/api/web/live/status'
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -17,11 +17,33 @@
|
|||||||
playsinline
|
playsinline
|
||||||
autoplay
|
autoplay
|
||||||
></video>
|
></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="播放控制">
|
<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="unmuteAndPlay">开声音</button>
|
||||||
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
|
<button type="button" class="live-video-toolbtn" @click="toggleVideoFullscreen">全屏</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<section class="live-block live-block--divider" aria-label="外链直播间">
|
<section class="live-block live-block--divider" aria-label="外链直播间">
|
||||||
@@ -44,12 +66,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { apiBase } from '../config'
|
import { apiBase } from '../config'
|
||||||
import { startViewing } from '../utils/liveWebRTC'
|
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||||
|
|
||||||
const watchVideoRef = ref(null)
|
const watchVideoRef = ref(null)
|
||||||
const rawLiveUrl = ref('')
|
const rawLiveUrl = ref('')
|
||||||
const pageTitle = ref('视频直播')
|
const pageTitle = ref('视频直播')
|
||||||
const watchStatus = ref('正在检测本站直播…')
|
const watchStatus = ref('正在检测本站直播…')
|
||||||
|
const dmDraft = ref('')
|
||||||
|
const dmItems = ref([])
|
||||||
|
let dmIdSeq = 0
|
||||||
|
let dmWs = null
|
||||||
|
|
||||||
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
||||||
|
|
||||||
@@ -108,9 +134,48 @@ function toggleVideoFullscreen() {
|
|||||||
el.requestFullscreen?.() || el.webkitRequestFullscreen?.()
|
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 () => {
|
onMounted(async () => {
|
||||||
loadHomepage()
|
loadHomepage()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
connectDanmaku()
|
||||||
viewSession = startViewing(watchVideoRef.value, {
|
viewSession = startViewing(watchVideoRef.value, {
|
||||||
muted: false,
|
muted: false,
|
||||||
onStatus: (s) => {
|
onStatus: (s) => {
|
||||||
@@ -121,6 +186,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
viewSession?.stop()
|
viewSession?.stop()
|
||||||
|
try {
|
||||||
|
dmWs?.close()
|
||||||
|
} catch (_) {}
|
||||||
|
dmWs = null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -187,6 +256,73 @@ onUnmounted(() => {
|
|||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 0 auto;
|
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 {
|
.live-video-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user