From e6ac5a107ad8617d18b4c4c7c10ed1f147269221 Mon Sep 17 00:00:00 2001 From: whm <973418690@qq.com> Date: Thu, 26 Mar 2026 15:46:11 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=B4=E6=92=AD=EF=BC=9A=E9=93=BA=E6=BB=A1?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E4=B8=8E=E9=BB=91=E5=B1=8F=E9=87=8D=E6=92=AD?= =?UTF-8?q?=E3=80=81=E5=BC=B9=E5=B9=95=E7=A4=BC=E7=89=A9=E4=B8=8E=E5=85=A8?= =?UTF-8?q?=E7=AB=99=E7=89=B9=E6=95=88=E3=80=81=E7=A4=BC=E7=89=A9=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=20WebRTC=20nudge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- server/pkg/weblive/danmaku.go | 83 ++++++-- web/src/utils/liveGifts.js | 29 +++ web/src/utils/liveWebRTC.js | 19 +- web/src/views/LiveRoom.vue | 377 +++++++++++++++++++++++++++++++--- 4 files changed, 457 insertions(+), 51 deletions(-) create mode 100644 web/src/utils/liveGifts.js diff --git a/server/pkg/weblive/danmaku.go b/server/pkg/weblive/danmaku.go index fd1c15d..0d6f4ef 100644 --- a/server/pkg/weblive/danmaku.go +++ b/server/pkg/weblive/danmaku.go @@ -15,6 +15,23 @@ import ( const maxDanmakuRunes = 120 +var allowedGifts = map[string]struct{}{ + "rocket": {}, + "sports_car": {}, + "plane": {}, + "carnival": {}, + "rose": {}, + "heart": {}, + "star": {}, + "clap": {}, + "cake": {}, + "crown": {}, + "fireworks": {}, + "gift_box": {}, + "beer": {}, + "mic": {}, +} + var ( danmakuClientsMu sync.Mutex danmakuClients = make(map[*websocket.Conn]struct{}) @@ -28,7 +45,19 @@ func writeDanmakuJSON(ws *websocket.Conn, v any) error { return ws.WriteMessage(websocket.TextMessage, b) } -// handleDanmakuWS 弹幕:收 JSON {"text":"..."};未带有效 token 仅可收广播不可发。广播 {"type":"dm","text","from","ts"},不落库 +func clipDanmakuText(t string) string { + t = strings.TrimSpace(t) + if t == "" { + return "" + } + if utf8.RuneCountInString(t) <= maxDanmakuRunes { + return t + } + runes := []rune(t) + return string(runes[:maxDanmakuRunes]) +} + +// handleDanmakuWS 弹幕 / 礼物:未带有效 token 仅可收广播。礼物 JSON {"type":"gift","gift":"rocket"};弹幕 {"text":"..."} func handleDanmakuWS(c *gin.Context) { claims, tokenOK := handlers.ParseSiteClaims(c.Query("token")) canSend := tokenOK @@ -66,11 +95,41 @@ func handleDanmakuWS(c *gin.Context) { _ = writeDanmakuJSON(ws, map[string]interface{}{ "type": "error", "code": "login_required", - "message": "请先登录或注册后再发弹幕", + "message": "请先登录或注册后再发弹幕或礼物", }) continue } - text := extractDanmakuText(payload) + var envelope struct { + Type string `json:"type"` + Text string `json:"text"` + Gift string `json:"gift"` + } + if err := json.Unmarshal(payload, &envelope); err != nil { + continue + } + if strings.EqualFold(strings.TrimSpace(envelope.Type), "gift") { + gid := strings.TrimSpace(envelope.Gift) + if _, ok := allowedGifts[gid]; !ok { + _ = writeDanmakuJSON(ws, map[string]interface{}{ + "type": "error", + "code": "bad_gift", + "message": "无效的礼物", + }) + continue + } + out, err := json.Marshal(map[string]interface{}{ + "type": "gift", + "gift": gid, + "from": fromDisplay, + "ts": time.Now().UnixMilli(), + }) + if err != nil { + continue + } + danmakuBroadcast(out) + continue + } + text := clipDanmakuText(envelope.Text) if text == "" { continue } @@ -87,24 +146,6 @@ func handleDanmakuWS(c *gin.Context) { } } -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() diff --git a/web/src/utils/liveGifts.js b/web/src/utils/liveGifts.js new file mode 100644 index 0000000..6ea6a08 --- /dev/null +++ b/web/src/utils/liveGifts.js @@ -0,0 +1,29 @@ +/** 与 server/pkg/weblive/danmaku.go allowedGifts 的 id 保持一致 */ +/** 四大件(火箭/跑车/飞机/嘉年华)须保留且排在前列;其余为扩展礼物 */ +export const LIVE_GIFTS = [ + { id: 'rocket', label: '火箭', emoji: '🚀', tier: 'big' }, + { id: 'sports_car', label: '跑车', emoji: '🏎️', tier: 'big' }, + { id: 'plane', label: '飞机', emoji: '✈️', tier: 'big' }, + { id: 'carnival', label: '嘉年华', emoji: '🎡', tier: 'big' }, + { id: 'rose', label: '玫瑰', emoji: '🌹', tier: 'normal' }, + { id: 'heart', label: '爱心', emoji: '❤️', tier: 'normal' }, + { id: 'star', label: '星星', emoji: '⭐', tier: 'normal' }, + { id: 'clap', label: '鼓掌', emoji: '👏', tier: 'normal' }, + { id: 'cake', label: '蛋糕', emoji: '🎂', tier: 'normal' }, + { id: 'crown', label: '皇冠', emoji: '👑', tier: 'normal' }, + { id: 'fireworks', label: '礼花', emoji: '🎆', tier: 'normal' }, + { id: 'gift_box', label: '礼盒', emoji: '🎁', tier: 'normal' }, + { id: 'beer', label: '干杯', emoji: '🍻', tier: 'normal' }, + { id: 'mic', label: '麦克风', emoji: '🎤', tier: 'normal' } +] + +const BIG_IDS = new Set(LIVE_GIFTS.filter((g) => g.tier === 'big').map((g) => g.id)) + +export function liveGiftEmoji(id) { + const g = LIVE_GIFTS.find((x) => x.id === id) + return g ? g.emoji : '🎁' +} + +export function liveGiftTier(id) { + return BIG_IDS.has(id) ? 'big' : 'normal' +} diff --git a/web/src/utils/liveWebRTC.js b/web/src/utils/liveWebRTC.js index e794f5e..4ee6610 100644 --- a/web/src/utils/liveWebRTC.js +++ b/web/src/utils/liveWebRTC.js @@ -54,11 +54,22 @@ export function startViewing(videoEl, opts = {}) { let mainRecv = new MediaStream() + function kickPlay() { + if (stopped || !videoEl) return + const run = () => { + if (stopped || !videoEl) return + const p = videoEl.play() + if (p && typeof p.catch === 'function') p.catch(() => {}) + } + run() + requestAnimationFrame(run) + } + function syncMain() { if (!videoEl) return videoEl.srcObject = mainRecv videoEl.muted = !!muted - videoEl.play().catch(() => {}) + kickPlay() } function handleTrack(e) { @@ -186,6 +197,10 @@ export function startViewing(videoEl, opts = {}) { await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }) onStatus('正在播放…') scheduleBlackFrameHint(pc) + syncMain() + ;[300, 1200, 2800, 5200].forEach((ms) => { + setTimeout(() => kickPlay(), ms) + }) } if (msg.type === 'ice' && msg.candidate) { try { @@ -274,5 +289,5 @@ export function startViewing(videoEl, opts = {}) { if (videoEl) videoEl.srcObject = null } - return { stop } + return { stop, nudgePlay: kickPlay } } diff --git a/web/src/views/LiveRoom.vue b/web/src/views/LiveRoom.vue index 1255cb8..15d56ce 100644 --- a/web/src/views/LiveRoom.vue +++ b/web/src/views/LiveRoom.vue @@ -9,7 +9,7 @@

本站直播(WebRTC)

-

观看直播无需登录;注册/登录仅用于发送弹幕。

+

观看直播无需登录;注册/登录用于发弹幕与送礼物(虚拟礼物,无扣款)。

(rawLiveUrl.value || '').trim()) let viewSession = null +const giftFxList = ref([]) +let giftFxSeq = 0 + +function onVideoMediaNudge() { + viewSession?.nudgePlay?.() +} + +function onPageVisibilityPlay() { + if (document.visibilityState === 'visible') { + viewSession?.nudgePlay?.() + } +} + +function pushGiftFx(gift, from) { + const id = ++giftFxSeq + const emoji = liveGiftEmoji(gift) + const tier = liveGiftTier(gift) + const offsetX = Math.round(Math.random() * 160 - 80) + const fromLabel = typeof from === 'string' && from.trim() ? from.trim() : '***' + giftFxList.value = [...giftFxList.value, { id, gift, from: fromLabel, emoji, tier, offsetX }].slice( + -16 + ) + const ms = tier === 'big' ? 4200 : 3200 + window.setTimeout(() => { + giftFxList.value = giftFxList.value.filter((x) => x.id !== id) + }, ms) +} + +function sendGift(giftId) { + if (!getSiteDmToken()) { + dmHint.value = '送礼请先登录或注册' + return + } + if (dmWs && dmWs.readyState === WebSocket.OPEN) { + try { + dmWs.send(JSON.stringify({ type: 'gift', gift: giftId })) + dmHint.value = '' + } catch (_) { + dmHint.value = '礼物发送失败' + } + return + } + dmHint.value = '弹幕通道未连接,请稍后再试' + if (!dmIntentionalClose) { + connectDanmaku() + } +} + function loadCaptureQualityPref() { try { const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY) @@ -371,8 +457,15 @@ function connectDanmaku() { pushDmLine(j.text, j.from) return } + if (j.type === 'gift' && typeof j.gift === 'string' && j.gift) { + pushGiftFx(j.gift, j.from) + return + } if (j.type === 'error' && j.code === 'login_required') { - dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕' + dmHint.value = typeof j.message === 'string' ? j.message : '请先登录后再发弹幕或礼物' + } + if (j.type === 'error' && j.code === 'bad_gift') { + dmHint.value = typeof j.message === 'string' ? j.message : '礼物无效' } } dmWs.onclose = () => { @@ -396,7 +489,7 @@ function sendDm() { const t = dmDraft.value.trim() if (!t) return if (!getSiteDmToken()) { - dmHint.value = '发弹幕请先登录或注册' + dmHint.value = '发弹幕请先登录或注册(观看无需登录)' return } dmDraft.value = '' @@ -423,6 +516,7 @@ onMounted(async () => { dmIntentionalClose = false document.addEventListener('fullscreenchange', syncStageFullscreenFlag) document.addEventListener('webkitfullscreenchange', syncStageFullscreenFlag) + document.addEventListener('visibilitychange', onPageVisibilityPlay) loadCaptureQualityPref() loadHomepage() await nextTick() @@ -435,11 +529,13 @@ onMounted(async () => { }) await nextTick() syncVolumeUIFromVideo() + viewSession?.nudgePlay?.() }) onUnmounted(() => { document.removeEventListener('fullscreenchange', syncStageFullscreenFlag) document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag) + document.removeEventListener('visibilitychange', onPageVisibilityPlay) dmIntentionalClose = true dmSendQueue.length = 0 if (dmReconnectTimer) { @@ -465,8 +561,10 @@ onUnmounted(() => { font-family: 'Noto Sans SC', system-ui, sans-serif; } .live-room-top { - max-width: 640px; + max-width: min(960px, 100%); margin: 0 auto 24px; + padding: 0 12px; + box-sizing: border-box; } .live-room-back { color: rgba(255, 255, 255, 0.55); @@ -478,8 +576,10 @@ onUnmounted(() => { color: #00d4ff; } .live-room-main { - max-width: 640px; + max-width: min(960px, 100%); margin: 0 auto; + padding: 0 12px 32px; + box-sizing: border-box; text-align: center; } .live-room-title { @@ -539,13 +639,17 @@ onUnmounted(() => { min-height: 1.4em; } .live-stage { - max-width: 480px; + max-width: min(920px, 100%); margin: 0 auto; } .live-video-wrap { position: relative; - max-width: 480px; + width: 100%; margin: 0 auto; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 14px; + background: #000; } .live-stage:fullscreen, .live-stage:-webkit-full-screen { @@ -577,14 +681,20 @@ onUnmounted(() => { display: flex; flex-direction: column; } -.live-stage:fullscreen .live-room-video.live-room-video--contain, -.live-stage:-webkit-full-screen .live-room-video.live-room-video--contain { - flex: 1; - min-height: 0; - width: 100%; - max-height: none; +.live-stage:fullscreen .live-video-wrap, +.live-stage:-webkit-full-screen .live-video-wrap { aspect-ratio: unset; - object-fit: contain; + border-radius: 12px; +} +.live-stage:fullscreen .live-room-video.live-room-video--fill, +.live-stage:-webkit-full-screen .live-room-video.live-room-video--fill { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + aspect-ratio: unset; + object-fit: cover; } .live-stage:fullscreen .live-stage-footer, .live-stage:-webkit-full-screen .live-stage-footer { @@ -592,9 +702,11 @@ onUnmounted(() => { padding-top: 6px; } .live-stage:fullscreen .live-dm-auth-row, +.live-stage:fullscreen .live-gift-row, .live-stage:fullscreen .live-dm-bar, .live-stage:fullscreen .live-dm-hint, .live-stage:-webkit-full-screen .live-dm-auth-row, +.live-stage:-webkit-full-screen .live-gift-row, .live-stage:-webkit-full-screen .live-dm-bar, .live-stage:-webkit-full-screen .live-dm-hint { max-width: none; @@ -690,6 +802,108 @@ onUnmounted(() => { border-radius: 14px; z-index: 2; } +.live-gift-layer { + pointer-events: none; + position: absolute; + inset: 0; + overflow: hidden; + border-radius: 14px; + z-index: 4; +} +.live-gift-burst { + --gift-x: 0px; + position: absolute; + left: 50%; + bottom: 16%; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + text-align: center; + animation: live-gift-pop 3.1s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +.live-gift-burst--mega { + bottom: 12%; + animation: live-gift-pop-mega 4.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +.live-gift-burst--mega .live-gift-emoji { + font-size: clamp(56px, 15vw, 96px); +} +.live-gift-emoji { + font-size: clamp(44px, 12vw, 76px); + line-height: 1; + filter: drop-shadow(0 0 14px rgba(255, 210, 120, 0.55)); +} +.live-gift-from { + font-size: 13px; + font-weight: 700; + color: #fff; + text-shadow: 0 0 10px #000, 0 1px 3px #000; + white-space: nowrap; + max-width: 90vw; + overflow: hidden; + text-overflow: ellipsis; +} +.live-gift-burst--rocket .live-gift-emoji { + filter: drop-shadow(0 0 18px rgba(0, 212, 255, 0.75)); +} +.live-gift-burst--sports_car .live-gift-emoji { + filter: drop-shadow(0 0 16px rgba(255, 140, 60, 0.7)); +} +.live-gift-burst--plane .live-gift-emoji { + filter: drop-shadow(0 0 16px rgba(180, 200, 255, 0.75)); +} +.live-gift-burst--carnival .live-gift-emoji { + filter: drop-shadow(0 0 20px rgba(255, 80, 200, 0.65)); +} +.live-gift-burst--heart .live-gift-emoji { + filter: drop-shadow(0 0 16px rgba(255, 80, 120, 0.65)); +} +.live-gift-burst--fireworks .live-gift-emoji { + filter: drop-shadow(0 0 18px rgba(255, 200, 80, 0.7)); +} +@keyframes live-gift-pop { + 0% { + opacity: 0; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(48px) scale(0.35); + } + 12% { + opacity: 1; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(0) scale(1.12); + } + 28% { + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-6px) scale(1); + } + 72% { + opacity: 1; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-12px) scale(1); + } + 100% { + opacity: 0; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-42px) scale(0.88); + } +} +@keyframes live-gift-pop-mega { + 0% { + opacity: 0; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(64px) scale(0.22); + } + 14% { + opacity: 1; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(0) scale(1.2); + } + 30% { + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-10px) scale(1.05); + } + 70% { + opacity: 1; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-18px) scale(1.02); + } + 100% { + opacity: 0; + transform: translateX(calc(-50% + var(--gift-x, 0px))) translateY(-52px) scale(0.85); + } +} .live-dm-line { position: absolute; left: 0; @@ -722,11 +936,118 @@ onUnmounted(() => { align-items: center; justify-content: center; gap: 10px 14px; - max-width: 480px; + max-width: min(920px, 100%); margin: 12px auto 0; font-size: 13px; color: rgba(255, 255, 255, 0.7); } +.live-gift-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 10px; + max-width: min(920px, 100%); + margin: 10px auto 0; +} +.live-gift-row--scroll { + flex-wrap: nowrap; + align-items: stretch; + gap: 10px; + padding: 4px 0 2px; +} +.live-gift-row--scroll .live-gift-hint { + flex-shrink: 0; + align-self: center; + margin-right: 0; + padding-left: 2px; +} +.live-gift-strip { + flex: 1; + min-width: 0; + display: flex; + flex-wrap: nowrap; + gap: 8px; + overflow-x: auto; + overflow-y: hidden; + padding: 4px 12px 10px 4px; + scroll-snap-type: x proximity; + -webkit-overflow-scrolling: touch; + scrollbar-width: thin; + scrollbar-color: rgba(0, 212, 255, 0.35) transparent; + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - 28px), + transparent 100% + ); +} +.live-gift-strip::-webkit-scrollbar { + height: 5px; +} +.live-gift-strip::-webkit-scrollbar-thumb { + background: rgba(0, 212, 255, 0.35); + border-radius: 3px; +} +.live-gift-hint { + font-size: 12px; + color: rgba(255, 255, 255, 0.55); + margin-right: 4px; +} +.live-gift-hint-muted { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + text-align: center; +} +.live-gift-chip { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + min-width: 52px; + min-height: 52px; + padding: 6px 8px; + border-radius: 10px; + border: 1px solid rgba(0, 212, 255, 0.45); + background: rgba(0, 0, 0, 0.4); + cursor: pointer; + scroll-snap-align: start; + transition: transform 0.15s, border-color 0.2s, box-shadow 0.2s; +} +.live-gift-chip-emoji { + font-size: 22px; + line-height: 1; +} +.live-gift-chip-label { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.78); + max-width: 5em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.live-gift-chip--big { + min-width: 58px; + min-height: 56px; + border-width: 2px; + border-color: rgba(255, 200, 100, 0.55); + background: linear-gradient(165deg, rgba(255, 100, 180, 0.14), rgba(0, 212, 255, 0.1)); + box-shadow: 0 0 14px rgba(255, 180, 80, 0.12); +} +.live-gift-chip--big .live-gift-chip-emoji { + font-size: 26px; +} +.live-gift-chip--big .live-gift-chip-label { + color: rgba(255, 230, 180, 0.95); +} +.live-gift-chip:hover { + transform: translateY(-2px); + border-color: rgba(255, 45, 149, 0.65); + box-shadow: 0 6px 20px rgba(0, 212, 255, 0.2); +} .live-dm-user { color: rgba(255, 255, 255, 0.85); } @@ -760,7 +1081,7 @@ onUnmounted(() => { gap: 10px; justify-content: center; align-items: center; - max-width: 480px; + max-width: min(920px, 100%); margin: 14px auto 0; } .live-dm-input { @@ -799,7 +1120,7 @@ onUnmounted(() => { cursor: not-allowed; } .live-dm-hint { - max-width: 480px; + max-width: min(920px, 100%); margin: 10px auto 0; font-size: 12px; line-height: 1.55; @@ -838,19 +1159,19 @@ onUnmounted(() => { } .live-room-video { display: block; - width: 100%; - margin: 0 auto; - border-radius: 14px; - border: 1px solid rgba(0, 212, 255, 0.25); background: #000; - aspect-ratio: 4 / 3; - object-fit: cover; } .live-room-video--watch { - margin-top: 4px; + margin-top: 0; } -.live-room-video--contain { - object-fit: contain; - aspect-ratio: 16 / 9; +.live-room-video.live-room-video--fill { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + border-radius: 14px; + border: 1px solid rgba(0, 212, 255, 0.25); + object-fit: cover; }