1. 播放器歌词效果

This commit is contained in:
李琦
2026-08-02 17:39:19 +08:00
parent e32990b212
commit 439839727a
3 changed files with 89 additions and 76 deletions

View File

@@ -38,23 +38,36 @@
</div>
</div>
<!-- 九宫格轻量入场可关避免每格 IntersectionObserver + 双解码拖垮 iOS -->
<!-- 九宫格串行挂载 300ms 1 v-reveal不预挂九格观察器 -->
<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-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"
v-if="imgIdx < nineShownCount && nineGridAnimate"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="img"
class="nine-grid-img preview-img"
:loading="nineGridAnimate ? 'lazy' : 'eager'"
decoding="async"
@click.stop="previewImage(img)"
/>
</div>
<div
v-else-if="imgIdx < nineShownCount"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="img"
class="nine-grid-img preview-img"
loading="eager"
decoding="async"
@click.stop="previewImage(img)"
/>
@@ -101,36 +114,28 @@
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass } from '@/composables/borderStyles'
const STAGGER_STEP_MS = 45
const STEP_MS = 300
const SETTLE_MS = 400
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格专用:父级在 section 入视口后设为 true 才挂载展示 */
activate: { type: Boolean, default: false },
/** 是否开轻量入场动画;关闭时提前预加载,进入窗口即展示 */
/** 开启:串行 v-reveal关闭预加载后直接展示 */
nineGridAnimate: { type: Boolean, default: false },
})
const emit = defineEmits(['preview-image', 'ready'])
const nineShownCount = ref(0)
const nineGridEntered = ref(false)
let readyEmitted = false
let staggerTimer = null
let enterRaf = 0
let preloadStarted = false
let seqToken = 0
const frameClass = computed(() => borderClass(props.page.borderStyle))
const clearStagger = () => {
clearTimeout(staggerTimer)
staggerTimer = null
if (enterRaf) {
cancelAnimationFrame(enterRaf)
enterRaf = 0
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** 关闭动画时提前暖缓存,进入窗口即可直接出图 */
/** 关闭动画时提前暖缓存 */
const preloadNineGridImages = () => {
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
const urls = (props.page.images || []).filter(Boolean)
@@ -143,47 +148,51 @@ const preloadNineGridImages = () => {
}
}
const showNineGrid = async () => {
clearStagger()
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}
/** 每 300ms 只挂载 1 格并触发单格 v-reveal */
const runSequentialReveal = async () => {
const token = ++seqToken
const total = (props.page.images || []).length
if (!total) {
readyEmitted = true
emit('ready')
emitReady()
return
}
if (!props.nineGridAnimate) {
nineShownCount.value = total
nineGridEntered.value = true
nineShownCount.value = 0
await nextTick()
for (let i = 0; i < total; i += 1) {
if (token !== seqToken) return
await sleep(STEP_MS)
if (token !== seqToken) return
nineShownCount.value = i + 1
await nextTick()
readyEmitted = true
emit('ready')
return
}
// 先挂载opacity:0再下一帧加 --in保证 CSS 过渡生效;无双解码 / 无 v-reveal
nineGridEntered.value = false
if (token !== seqToken) return
await sleep(SETTLE_MS)
if (token !== seqToken) return
emitReady()
}
const showNineGridInstant = async () => {
const total = (props.page.images || []).length
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)
emitReady()
}
watch(
() => props.activate && props.page?.type === 'nine-grid',
async (on) => {
if (!on || readyEmitted) return
await showNineGrid()
if (props.nineGridAnimate) await runSequentialReveal()
else await showNineGridInstant()
},
{ flush: 'post' }
)
@@ -200,7 +209,28 @@ onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
})
onUnmounted(clearStagger)
onUnmounted(() => {
seqToken += 1
})
function getRevealConfig(imgIdx) {
const row = Math.floor(imgIdx / 3)
const col = imgIdx % 3
if (imgIdx === 4) {
return { variant: 'scale', delay: 0 }
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 0,
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 0,
}
}
const singleWrapClass = computed(() =>
props.page.singleWidth === 'full' ? 'max-w-none' : 'max-w-[300px]'
@@ -261,24 +291,17 @@ function polaroidTransform(i) {
height: 100%;
object-fit: cover;
display: block;
content-visibility: auto;
}
.nine-grid-slot {
background: transparent;
contain: layout paint;
}
.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;
.nine-grid-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.overlap-wrap {

View File

@@ -162,7 +162,7 @@
</a-form-item>
<a-form-item label="九宫格入场动画">
<a-switch v-model:checked="cfg.nineGridAnimate" />
<div class="text-[11px] text-[#8A8680] mt-1">关闭提前预加载进入窗口即展示且不暂停滚动推荐 iOS开启轻量淡入并短暂停滚</div>
<div class="text-[11px] text-[#8A8680] mt-1">开启进入视口后暂停滚动 300ms 入场一格方向动效全部完成后再继续关闭直接展示更稳推荐 iOS</div>
</a-form-item>
</div>
</a-form>

View File

@@ -778,9 +778,8 @@ let nineGridRevealTimer = null
const nineGridDone = new Set()
const BOTTOM_RETURN_DELAY = 30000
const FREE_SCROLL_BASE_STEP = 1.2
/** 开启动画时,入场结束后短暂停一下再继续自动滚 */
const NINE_GRID_REVEAL_HOLD_ANIM_MS = 520
const NINE_GRID_FULL_RATIO = 0.9
/** ready 后极短缓冲再恢复滚动(串行等待已在 PhotoGallery 内完成) */
const NINE_GRID_RESUME_DELAY_MS = 200
const freeInterval = computed(() => {
const v = Number(data.value.freeScrollInterval)
@@ -993,22 +992,17 @@ const canAutoAdvance = () =>
const beginNineGridGate = (pIdx) => {
if (nineGridDone.has(pIdx)) return
nineGridDone.add(pIdx)
// 仅开启动画时暂停自动滚动;关闭动画不挡滚动
if (data.value.nineGridAnimate) nineGridHold.value = true
nineGridHold.value = true
nineGridActivate[pIdx] = true
}
const onNineGridReady = (pIdx) => {
if (!nineGridDone.has(pIdx)) return
if (!data.value.nineGridAnimate) {
nineGridHold.value = false
return
}
clearTimeout(nineGridRevealTimer)
nineGridRevealTimer = setTimeout(() => {
nineGridHold.value = false
nineGridRevealTimer = null
}, NINE_GRID_REVEAL_HOLD_ANIM_MS)
}, NINE_GRID_RESUME_DELAY_MS)
}
const bindNineGridObserver = () => {
@@ -1017,13 +1011,11 @@ const bindNineGridObserver = () => {
if (typeof IntersectionObserver === 'undefined') return
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
if (!nodes?.length) return
const animate = !!data.value.nineGridAnimate
// 进入窗口即 activate期间 nineGridHold 暂停自动滚
nineGridObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
// 开启动画:等几乎整页入屏;关闭:进入窗口即展示
if (animate && entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
const raw = entry.target.getAttribute('data-nine-grid')
if (raw == null) continue
const pIdx = Number(raw)
@@ -1031,9 +1023,7 @@ const bindNineGridObserver = () => {
beginNineGridGate(pIdx)
}
},
animate
? { threshold: [0, 0.85, 1] }
: { threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
{ threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
)
nodes.forEach((el) => nineGridObserver.observe(el))
}