1. 播放器歌词效果

This commit is contained in:
李琦
2026-08-02 16:11:35 +08:00
parent b4dc67fda1
commit e32990b212
9 changed files with 396 additions and 156 deletions

View File

@@ -4,13 +4,18 @@
v-for="row in rows"
:key="row.id"
class="danmaku-item"
:class="{ 'danmaku-item--grad': row.textLight }"
:style="row.style"
@animationend="onEnded(row.id)"
>
<span class="danmaku-name" :style="{ color: row.color }">{{ row.name }}</span>
<span class="danmaku-sep" :style="{ color: row.color }"></span>
<span class="danmaku-text" :style="{ color: row.color }">{{ row.content }}</span>
<span v-if="showTime && row.timeLabel" class="danmaku-time">{{ row.timeLabel }}</span>
<span
v-if="showTime && row.timeLabel"
class="danmaku-time"
:class="{ 'danmaku-time--on-grad': row.textLight }"
>{{ row.timeLabel }}</span>
</div>
</div>
</template>
@@ -29,7 +34,9 @@ const props = defineProps({
const POLL_MS = 30000
const DEDUPE_MS = 30000
const TRACKS = 6
const TRACKS = 5
/** 同轨占用结束前不可再发,避免重叠 */
const TRACK_GAP_MS = 420
const items = ref([])
const rows = ref([])
@@ -41,6 +48,43 @@ let uid = 0
const lastShownAt = new Map()
/** 本批已飘过的业务弹幕 id */
const shownIds = new Set()
/** 各轨道最早可再发时间performance.now */
const trackFreeAt = Array.from({ length: TRACKS }, () => 0)
const resetTracks = () => {
for (let i = 0; i < TRACKS; i += 1) trackFreeAt[i] = 0
}
/** @returns {{ track: number, extraDelay: number } | null} */
const reserveTrack = (durationSec, delaySec, { force = false } = {}) => {
const now = performance.now()
let free = -1
let soonest = 0
let soonestAt = Infinity
for (let i = 0; i < TRACKS; i += 1) {
if (trackFreeAt[i] <= now) {
free = i
break
}
if (trackFreeAt[i] < soonestAt) {
soonestAt = trackFreeAt[i]
soonest = i
}
}
if (free >= 0) {
const start = now + delaySec * 1000
trackFreeAt[free] = start + durationSec * 1000 + TRACK_GAP_MS
return { track: free, extraDelay: 0 }
}
if (!force) return null
const extraDelay = Math.max(0, (soonestAt - now) / 1000)
const start = soonestAt + delaySec * 1000
trackFreeAt[soonest] = start + durationSec * 1000 + TRACK_GAP_MS
return { track: soonest, extraDelay }
}
const itemKey = (item) => {
if (item?.id != null) return String(item.id)
@@ -90,14 +134,21 @@ const markShown = (item) => {
const buildRow = (item, { force = false } = {}) => {
const key = itemKey(item)
if (!force && isCooling(key)) return null
// 速度区间收窄,降低同屏追尾概率;轨道占用保证同轨不叠
const duration = 12 + Math.random() * 4
const delay = force ? 0 : Math.random() * 0.25
const slot = reserveTrack(duration, delay, { force })
if (!slot) return null
lastShownAt.set(key, Date.now())
markShown(item)
const tint = resolveDanmakuTint(item.color)
const track = Math.floor(Math.random() * TRACKS)
const duration = 10 + Math.random() * 8
const delay = force ? 0 : Math.random() * 0.4
const id = ++uid
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const timeLabel = force
? '刚刚'
@@ -108,13 +159,15 @@ const buildRow = (item, { force = false } = {}) => {
name: item.name,
content: item.content,
color: resolveDanmakuColor(item.color),
textLight: !!tint.textLight,
timeLabel,
style: {
top: `${8 + track * 12}%`,
top: `${topPct}%`,
animationDuration: `${duration}s`,
animationDelay: `${delay}s`,
animationDelay: `${totalDelay}s`,
background: tint.background,
borderColor: tint.borderColor,
boxShadow: tint.boxShadow,
},
}
}
@@ -156,7 +209,7 @@ const fetchList = async () => {
const spawnOne = () => {
if (!props.active || !props.enabled || !items.value.length) return
if (rows.value.length >= TRACKS * 2) return
if (rows.value.length >= TRACKS) return
const item = pickNextItem()
if (!item) return
@@ -186,7 +239,7 @@ const startSpawn = () => {
spawnTimer = null
if (!props.enabled || !props.active || !items.value.length) return
spawnOne()
spawnTimer = setInterval(spawnOne, 1800)
spawnTimer = setInterval(spawnOne, 2200)
}
const stopSpawn = () => {
@@ -219,6 +272,7 @@ watch(
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
}
}
}
@@ -238,6 +292,7 @@ watch(
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
}
}
)
@@ -269,7 +324,7 @@ defineExpose({ refresh: fetchList, pushItem })
position: absolute;
left: 100%;
white-space: nowrap;
padding: 4px 12px;
padding: 5px 14px;
border-radius: 999px;
border: 1px solid rgba(168, 140, 107, 0.28);
box-shadow: 0 4px 14px rgba(88, 68, 42, 0.08);
@@ -282,6 +337,11 @@ defineExpose({ refresh: fetchList, pushItem })
width: max-content;
max-width: none;
}
.danmaku-item--grad {
backdrop-filter: saturate(1.25) blur(8px);
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
font-weight: 500;
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
.danmaku-text { opacity: 0.95; }
@@ -291,6 +351,9 @@ defineExpose({ refresh: fetchList, pushItem })
font-size: 10px;
letter-spacing: 0.04em;
}
.danmaku-time--on-grad {
color: rgba(255, 255, 255, 0.82);
}
@keyframes danmaku-fly {
from { transform: translateX(0); }
to { transform: translateX(calc(-100vw - 100%)); }

View File

@@ -38,24 +38,24 @@
</div>
</div>
<!-- 九宫格外层仅透明占位decode 完成后再挂边框+ v-reveal 一起出现 -->
<div v-else-if="page.type === 'nine-grid'" ref="nineGridRef" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<!-- 九宫格轻量入场可关避免每格 IntersectionObserver + 双解码拖垮 iOS -->
<div v-else-if="page.type === 'nine-grid'" class="nine-grid grid grid-cols-3 gap-1.5 px-4 mt-6">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
class="aspect-square relative nine-grid-slot"
:class="{ 'nine-grid-placeholder': imgIdx >= nineShownCount }"
>
<div
v-if="imgIdx < nineShownCount"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
v-show="imgIdx < nineShownCount"
class="absolute inset-0 nine-grid-cell"
:class="[frameClass, { 'nine-grid-cell--anim': nineGridAnimate, 'nine-grid-cell--in': nineGridEntered }]"
:style="nineGridAnimate ? { transitionDelay: `${Math.min(imgIdx, 8) * 45}ms` } : undefined"
>
<img
:src="img"
:data-nine-idx="imgIdx"
class="nine-grid-img preview-img"
:loading="nineGridAnimate ? 'lazy' : 'eager'"
decoding="async"
@click.stop="previewImage(img)"
/>
</div>
@@ -98,91 +98,110 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass } from '@/composables/borderStyles'
const BATCH_SIZE = 3
const BATCH_GAP_MS = 140
const BATCH_DECODE_TIMEOUT_MS = 2500
const STAGGER_STEP_MS = 45
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格专用:父级在 section 入视口后设为 true 才加载 */
/** 九宫格专用:父级在 section 入视口后设为 true 才挂载展示 */
activate: { type: Boolean, default: false },
/** 是否开轻量入场动画;关闭时提前预加载,进入窗口即展示 */
nineGridAnimate: { type: Boolean, default: false },
})
const emit = defineEmits(['preview-image', 'ready'])
const nineGridRef = ref(null)
const nineShownCount = ref(0)
const nineGridEntered = ref(false)
let readyEmitted = false
let staggerTimer = null
let enterRaf = 0
let preloadStarted = false
const frameClass = computed(() => borderClass(props.page.borderStyle))
const decodeImg = (img) => {
if (!img) return Promise.resolve()
if (typeof img.decode === 'function') return img.decode().catch(() => {})
if (img.complete) return Promise.resolve()
return new Promise((resolve) => {
img.addEventListener('load', resolve, { once: true })
img.addEventListener('error', resolve, { once: true })
})
const clearStagger = () => {
clearTimeout(staggerTimer)
staggerTimer = null
if (enterRaf) {
cancelAnimationFrame(enterRaf)
enterRaf = 0
}
}
const yieldToMain = () => new Promise((resolve) => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => resolve())
} else {
setTimeout(resolve, 0)
/** 关闭动画时提前暖缓存,进入窗口即可直接出图 */
const preloadNineGridImages = () => {
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
const urls = (props.page.images || []).filter(Boolean)
if (!urls.length) return
preloadStarted = true
for (const url of urls) {
const img = new Image()
img.decoding = 'async'
img.src = url
}
})
}
/** 离屏预解码,完成后再抬 shownCount避免先露白底框 */
const preloadBatch = (urls) => Promise.race([
Promise.allSettled(
urls.map((url) => {
if (!url) return Promise.resolve()
const img = new Image()
img.decoding = 'async'
img.src = url
return decodeImg(img)
})
),
new Promise((resolve) => setTimeout(resolve, BATCH_DECODE_TIMEOUT_MS)),
])
const showNineGrid = async () => {
clearStagger()
const total = (props.page.images || []).length
if (!total) {
readyEmitted = true
emit('ready')
return
}
const runBatchedReveal = async () => {
const images = props.page.images || []
const total = images.length
if (!total) return
for (let start = 0; start < total; start += BATCH_SIZE) {
const end = Math.min(total, start + BATCH_SIZE)
await preloadBatch(images.slice(start, end))
await yieldToMain()
// decode 完再挂载:白卡纸/边框与图片一同走 reveal
nineShownCount.value = end
if (!props.nineGridAnimate) {
nineShownCount.value = total
nineGridEntered.value = true
await nextTick()
await yieldToMain()
if (end < total) {
await new Promise((resolve) => setTimeout(resolve, BATCH_GAP_MS))
await yieldToMain()
}
readyEmitted = true
emit('ready')
return
}
// 先挂载opacity:0再下一帧加 --in保证 CSS 过渡生效;无双解码 / 无 v-reveal
nineGridEntered.value = false
nineShownCount.value = total
await nextTick()
enterRaf = requestAnimationFrame(() => {
enterRaf = requestAnimationFrame(() => {
enterRaf = 0
nineGridEntered.value = true
})
})
const wait = Math.min(total, 9) * STAGGER_STEP_MS + 420
staggerTimer = setTimeout(() => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}, wait)
}
watch(
() => props.activate && props.page?.type === 'nine-grid',
async (on) => {
if (!on || readyEmitted) return
await runBatchedReveal()
if (readyEmitted) return
readyEmitted = true
emit('ready')
await showNineGrid()
},
{ flush: 'post' }
)
watch(
() => [props.page?.type, props.nineGridAnimate, props.page?.images],
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
},
{ deep: true }
)
onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
})
onUnmounted(clearStagger)
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
)
@@ -226,24 +245,6 @@ function polaroidTransform(i) {
return `rotate(${a}deg) translateY(${s}px)`
}
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
if (imgIdx === 4) {
return { variant: 'scale', delay: 200 }
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 80 + imgIdx * 50,
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 80 + ((imgIdx + 80) * 20),
}
}
</script>
<style scoped>
@@ -259,21 +260,25 @@ function getRevealConfig(imgIdx) {
width: 100%;
height: 100%;
object-fit: cover;
transform: translateZ(0);
transition: transform 420ms ease-out;
backface-visibility: hidden;
display: block;
content-visibility: auto;
}
.nine-grid-img:hover { transform: translateZ(0) scale(1.03); }
.nine-grid-slot {
background: transparent;
contain: layout paint;
}
/* 透明占位:占满格子尺寸,不露白底/边框 */
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
.nine-grid-cell--anim {
opacity: 0;
transform: translate3d(0, 6px, 0);
transition: opacity 0.36s ease, transform 0.36s ease;
}
.nine-grid-cell--anim.nine-grid-cell--in {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.nine-grid-cell:not(.nine-grid-cell--anim) {
opacity: 1;
transform: none;
}
.overlap-wrap {