直播:铺满画面与黑屏重播、弹幕礼物与全站特效、礼物列表与 WebRTC nudge
Made-with: Cursor
This commit is contained in:
@@ -15,6 +15,23 @@ import (
|
|||||||
|
|
||||||
const maxDanmakuRunes = 120
|
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 (
|
var (
|
||||||
danmakuClientsMu sync.Mutex
|
danmakuClientsMu sync.Mutex
|
||||||
danmakuClients = make(map[*websocket.Conn]struct{})
|
danmakuClients = make(map[*websocket.Conn]struct{})
|
||||||
@@ -28,7 +45,19 @@ func writeDanmakuJSON(ws *websocket.Conn, v any) error {
|
|||||||
return ws.WriteMessage(websocket.TextMessage, b)
|
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) {
|
func handleDanmakuWS(c *gin.Context) {
|
||||||
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
|
claims, tokenOK := handlers.ParseSiteClaims(c.Query("token"))
|
||||||
canSend := tokenOK
|
canSend := tokenOK
|
||||||
@@ -66,11 +95,41 @@ func handleDanmakuWS(c *gin.Context) {
|
|||||||
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
_ = writeDanmakuJSON(ws, map[string]interface{}{
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"code": "login_required",
|
"code": "login_required",
|
||||||
"message": "请先登录或注册后再发弹幕",
|
"message": "请先登录或注册后再发弹幕或礼物",
|
||||||
})
|
})
|
||||||
continue
|
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 == "" {
|
if text == "" {
|
||||||
continue
|
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) {
|
func danmakuBroadcast(b []byte) {
|
||||||
danmakuClientsMu.Lock()
|
danmakuClientsMu.Lock()
|
||||||
defer danmakuClientsMu.Unlock()
|
defer danmakuClientsMu.Unlock()
|
||||||
|
|||||||
29
web/src/utils/liveGifts.js
Normal file
29
web/src/utils/liveGifts.js
Normal 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'
|
||||||
|
}
|
||||||
@@ -54,11 +54,22 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
|
|
||||||
let mainRecv = new MediaStream()
|
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() {
|
function syncMain() {
|
||||||
if (!videoEl) return
|
if (!videoEl) return
|
||||||
videoEl.srcObject = mainRecv
|
videoEl.srcObject = mainRecv
|
||||||
videoEl.muted = !!muted
|
videoEl.muted = !!muted
|
||||||
videoEl.play().catch(() => {})
|
kickPlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTrack(e) {
|
function handleTrack(e) {
|
||||||
@@ -186,6 +197,10 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp })
|
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp })
|
||||||
onStatus('正在播放…')
|
onStatus('正在播放…')
|
||||||
scheduleBlackFrameHint(pc)
|
scheduleBlackFrameHint(pc)
|
||||||
|
syncMain()
|
||||||
|
;[300, 1200, 2800, 5200].forEach((ms) => {
|
||||||
|
setTimeout(() => kickPlay(), ms)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (msg.type === 'ice' && msg.candidate) {
|
if (msg.type === 'ice' && msg.candidate) {
|
||||||
try {
|
try {
|
||||||
@@ -274,5 +289,5 @@ export function startViewing(videoEl, opts = {}) {
|
|||||||
if (videoEl) videoEl.srcObject = null
|
if (videoEl) videoEl.srcObject = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return { stop }
|
return { stop, nudgePlay: kickPlay }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<section class="live-block" aria-label="本站直播">
|
<section class="live-block" aria-label="本站直播">
|
||||||
<h2 class="live-block-title">本站直播(WebRTC)</h2>
|
<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">
|
<div class="live-quality-row">
|
||||||
<label class="live-quality-label" for="live-cap-q">采集画质</label>
|
<label class="live-quality-label" for="live-cap-q">采集画质</label>
|
||||||
<select id="live-cap-q" v-model="captureQualityPref" class="live-quality-select">
|
<select id="live-cap-q" v-model="captureQualityPref" class="live-quality-select">
|
||||||
@@ -24,10 +24,12 @@
|
|||||||
<div class="live-video-wrap">
|
<div class="live-video-wrap">
|
||||||
<video
|
<video
|
||||||
ref="watchVideoRef"
|
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
|
playsinline
|
||||||
autoplay
|
autoplay
|
||||||
@volumechange="syncVolumeUIFromVideo"
|
@volumechange="syncVolumeUIFromVideo"
|
||||||
|
@loadeddata="onVideoMediaNudge"
|
||||||
|
@canplay="onVideoMediaNudge"
|
||||||
></video>
|
></video>
|
||||||
<div class="live-dm-layer" aria-hidden="true">
|
<div class="live-dm-layer" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
@@ -39,6 +41,21 @@
|
|||||||
<span class="live-dm-from">{{ d.from }}:</span>{{ d.text }}
|
<span class="live-dm-from">{{ d.from }}:</span>{{ d.text }}
|
||||||
</div>
|
</div>
|
||||||
</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" role="toolbar" aria-label="播放与音量">
|
||||||
<div class="live-video-overlay-inner">
|
<div class="live-video-overlay-inner">
|
||||||
<button type="button" class="live-overlay-btn" @click="toggleMute">
|
<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>
|
<router-link class="live-dm-auth-link" to="/live/register">注册</router-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<div class="live-dm-bar">
|
||||||
<input
|
<input
|
||||||
v-model="dmDraft"
|
v-model="dmDraft"
|
||||||
@@ -118,6 +155,7 @@ import { apiBase } from '../config'
|
|||||||
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
|
import { LIVE_QUALITY_OPTIONS, LIVE_CAPTURE_QUALITY_STORAGE_KEY } from '../utils/liveQuality'
|
||||||
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
import { startViewing, liveDanmakuWsURL } from '../utils/liveWebRTC'
|
||||||
import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth'
|
import { getSiteDmToken, getSiteDmUsername, clearSiteDmSession } from '../utils/siteUserAuth'
|
||||||
|
import { LIVE_GIFTS, liveGiftEmoji, liveGiftTier } from '../utils/liveGifts'
|
||||||
|
|
||||||
const watchVideoRef = ref(null)
|
const watchVideoRef = ref(null)
|
||||||
const liveStageRef = ref(null)
|
const liveStageRef = ref(null)
|
||||||
@@ -156,6 +194,54 @@ const enterUrl = computed(() => (rawLiveUrl.value || '').trim())
|
|||||||
|
|
||||||
let viewSession = null
|
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() {
|
function loadCaptureQualityPref() {
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
|
const v = localStorage.getItem(LIVE_CAPTURE_QUALITY_STORAGE_KEY)
|
||||||
@@ -371,8 +457,15 @@ function connectDanmaku() {
|
|||||||
pushDmLine(j.text, j.from)
|
pushDmLine(j.text, j.from)
|
||||||
return
|
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') {
|
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 = () => {
|
dmWs.onclose = () => {
|
||||||
@@ -396,7 +489,7 @@ function sendDm() {
|
|||||||
const t = dmDraft.value.trim()
|
const t = dmDraft.value.trim()
|
||||||
if (!t) return
|
if (!t) return
|
||||||
if (!getSiteDmToken()) {
|
if (!getSiteDmToken()) {
|
||||||
dmHint.value = '发弹幕请先登录或注册'
|
dmHint.value = '发弹幕请先登录或注册(观看无需登录)'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dmDraft.value = ''
|
dmDraft.value = ''
|
||||||
@@ -423,6 +516,7 @@ onMounted(async () => {
|
|||||||
dmIntentionalClose = false
|
dmIntentionalClose = false
|
||||||
document.addEventListener('fullscreenchange', syncStageFullscreenFlag)
|
document.addEventListener('fullscreenchange', syncStageFullscreenFlag)
|
||||||
document.addEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
document.addEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
||||||
|
document.addEventListener('visibilitychange', onPageVisibilityPlay)
|
||||||
loadCaptureQualityPref()
|
loadCaptureQualityPref()
|
||||||
loadHomepage()
|
loadHomepage()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -435,11 +529,13 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
syncVolumeUIFromVideo()
|
syncVolumeUIFromVideo()
|
||||||
|
viewSession?.nudgePlay?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('fullscreenchange', syncStageFullscreenFlag)
|
document.removeEventListener('fullscreenchange', syncStageFullscreenFlag)
|
||||||
document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
document.removeEventListener('webkitfullscreenchange', syncStageFullscreenFlag)
|
||||||
|
document.removeEventListener('visibilitychange', onPageVisibilityPlay)
|
||||||
dmIntentionalClose = true
|
dmIntentionalClose = true
|
||||||
dmSendQueue.length = 0
|
dmSendQueue.length = 0
|
||||||
if (dmReconnectTimer) {
|
if (dmReconnectTimer) {
|
||||||
@@ -465,8 +561,10 @@ onUnmounted(() => {
|
|||||||
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
font-family: 'Noto Sans SC', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
.live-room-top {
|
.live-room-top {
|
||||||
max-width: 640px;
|
max-width: min(960px, 100%);
|
||||||
margin: 0 auto 24px;
|
margin: 0 auto 24px;
|
||||||
|
padding: 0 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.live-room-back {
|
.live-room-back {
|
||||||
color: rgba(255, 255, 255, 0.55);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
@@ -478,8 +576,10 @@ onUnmounted(() => {
|
|||||||
color: #00d4ff;
|
color: #00d4ff;
|
||||||
}
|
}
|
||||||
.live-room-main {
|
.live-room-main {
|
||||||
max-width: 640px;
|
max-width: min(960px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 0 12px 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.live-room-title {
|
.live-room-title {
|
||||||
@@ -539,13 +639,17 @@ onUnmounted(() => {
|
|||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
}
|
}
|
||||||
.live-stage {
|
.live-stage {
|
||||||
max-width: 480px;
|
max-width: min(920px, 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
.live-video-wrap {
|
.live-video-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 480px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #000;
|
||||||
}
|
}
|
||||||
.live-stage:fullscreen,
|
.live-stage:fullscreen,
|
||||||
.live-stage:-webkit-full-screen {
|
.live-stage:-webkit-full-screen {
|
||||||
@@ -577,14 +681,20 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.live-stage:fullscreen .live-room-video.live-room-video--contain,
|
.live-stage:fullscreen .live-video-wrap,
|
||||||
.live-stage:-webkit-full-screen .live-room-video.live-room-video--contain {
|
.live-stage:-webkit-full-screen .live-video-wrap {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
max-height: none;
|
|
||||||
aspect-ratio: unset;
|
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:fullscreen .live-stage-footer,
|
||||||
.live-stage:-webkit-full-screen .live-stage-footer {
|
.live-stage:-webkit-full-screen .live-stage-footer {
|
||||||
@@ -592,9 +702,11 @@ onUnmounted(() => {
|
|||||||
padding-top: 6px;
|
padding-top: 6px;
|
||||||
}
|
}
|
||||||
.live-stage:fullscreen .live-dm-auth-row,
|
.live-stage:fullscreen .live-dm-auth-row,
|
||||||
|
.live-stage:fullscreen .live-gift-row,
|
||||||
.live-stage:fullscreen .live-dm-bar,
|
.live-stage:fullscreen .live-dm-bar,
|
||||||
.live-stage:fullscreen .live-dm-hint,
|
.live-stage:fullscreen .live-dm-hint,
|
||||||
.live-stage:-webkit-full-screen .live-dm-auth-row,
|
.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-bar,
|
||||||
.live-stage:-webkit-full-screen .live-dm-hint {
|
.live-stage:-webkit-full-screen .live-dm-hint {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -690,6 +802,108 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
z-index: 2;
|
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 {
|
.live-dm-line {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -722,11 +936,118 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px 14px;
|
gap: 10px 14px;
|
||||||
max-width: 480px;
|
max-width: min(920px, 100%);
|
||||||
margin: 12px auto 0;
|
margin: 12px auto 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
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 {
|
.live-dm-user {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
@@ -760,7 +1081,7 @@ onUnmounted(() => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: 480px;
|
max-width: min(920px, 100%);
|
||||||
margin: 14px auto 0;
|
margin: 14px auto 0;
|
||||||
}
|
}
|
||||||
.live-dm-input {
|
.live-dm-input {
|
||||||
@@ -799,7 +1120,7 @@ onUnmounted(() => {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.live-dm-hint {
|
.live-dm-hint {
|
||||||
max-width: 480px;
|
max-width: min(920px, 100%);
|
||||||
margin: 10px auto 0;
|
margin: 10px auto 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
@@ -838,19 +1159,19 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.live-room-video {
|
.live-room-video {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid rgba(0, 212, 255, 0.25);
|
|
||||||
background: #000;
|
background: #000;
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
}
|
||||||
.live-room-video--watch {
|
.live-room-video--watch {
|
||||||
margin-top: 4px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.live-room-video--contain {
|
.live-room-video.live-room-video--fill {
|
||||||
object-fit: contain;
|
position: absolute;
|
||||||
aspect-ratio: 16 / 9;
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.25);
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user