直播:铺满画面与黑屏重播、弹幕礼物与全站特效、礼物列表与 WebRTC nudge

Made-with: Cursor
This commit is contained in:
whm
2026-03-26 15:46:11 +08:00
parent 4112ea4447
commit e6ac5a107a
4 changed files with 457 additions and 51 deletions

View File

@@ -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()

View File

@@ -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'
}

View File

@@ -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 }
}

View File

@@ -9,7 +9,7 @@
<section class="live-block" aria-label="本站直播">
<h2 class="live-block-title">本站直播WebRTC</h2>
<p class="live-no-login-hint">观看直播无需登录注册/登录用于发弹幕</p>
<p class="live-no-login-hint">观看直播无需登录注册/登录用于发弹幕与送礼物虚拟礼物无扣款</p>
<div class="live-quality-row">
<label class="live-quality-label" for="live-cap-q">采集画质</label>
<select id="live-cap-q" v-model="captureQualityPref" class="live-quality-select">
@@ -24,10 +24,12 @@
<div class="live-video-wrap">
<video
ref="watchVideoRef"
class="live-room-video live-room-video--watch live-room-video--contain"
class="live-room-video live-room-video--watch live-room-video--fill"
playsinline
autoplay
@volumechange="syncVolumeUIFromVideo"
@loadeddata="onVideoMediaNudge"
@canplay="onVideoMediaNudge"
></video>
<div class="live-dm-layer" aria-hidden="true">
<div
@@ -39,6 +41,21 @@
<span class="live-dm-from">{{ d.from }}</span>{{ d.text }}
</div>
</div>
<div class="live-gift-layer" aria-hidden="true">
<div
v-for="g in giftFxList"
:key="g.id"
class="live-gift-burst"
:class="[
'live-gift-burst--' + g.gift,
g.tier === 'big' ? 'live-gift-burst--mega' : ''
]"
:style="{ '--gift-x': g.offsetX + 'px' }"
>
<span class="live-gift-emoji">{{ g.emoji }}</span>
<span class="live-gift-from">{{ g.from }} 送出</span>
</div>
</div>
<div class="live-video-overlay" role="toolbar" aria-label="播放与音量">
<div class="live-video-overlay-inner">
<button type="button" class="live-overlay-btn" @click="toggleMute">
@@ -77,6 +94,26 @@
<router-link class="live-dm-auth-link" to="/live/register">注册</router-link>
</template>
</div>
<div v-if="dmLoggedIn" class="live-gift-row live-gift-row--scroll" aria-label="礼物">
<span class="live-gift-hint">送礼</span>
<div class="live-gift-strip">
<button
v-for="g in LIVE_GIFTS"
:key="g.id"
type="button"
class="live-gift-chip"
:class="{ 'live-gift-chip--big': g.tier === 'big' }"
:title="g.label"
@click="sendGift(g.id)"
>
<span class="live-gift-chip-emoji" aria-hidden="true">{{ g.emoji }}</span>
<span class="live-gift-chip-label">{{ g.label }}</span>
</button>
</div>
</div>
<div v-else class="live-gift-row">
<span class="live-gift-hint-muted">登录后可送礼物含火箭·跑车·飞机·嘉年华等</span>
</div>
<div class="live-dm-bar">
<input
v-model="dmDraft"
@@ -118,6 +155,7 @@ import { apiBase } from '../config'
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth'
import { LIVE_GIFTS, liveGiftEmoji, liveGiftTier } from '../utils/liveGifts'
const watchVideoRef = ref(null)
const liveStageRef = ref(null)
@@ -156,6 +194,54 @@ const enterUrl = computed(() => (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;
}
</style>