直播:铺满画面与黑屏重播、弹幕礼物与全站特效、礼物列表与 WebRTC nudge
Made-with: Cursor
This commit is contained in:
@@ -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()
|
||||
|
||||
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()
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user