1. 播放器歌词效果

This commit is contained in:
李琦
2026-08-02 18:19:12 +08:00
parent 439839727a
commit 8f3918198a
4 changed files with 558 additions and 122 deletions

View File

@@ -27,27 +27,62 @@
</div>
</div>
<!-- 一大五小 -->
<div v-else-if="page.type === 'one-large-five-small'"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20">
<div v-reveal="{ variant: 'scale', delay: 100 }" class="col-span-2 row-span-2 relative" :class="frameClass">
<img :src="page.images[0]" class="preview-img hover:scale-105 transition-transform duration-1000" @click.stop="previewImage(page.images[0])" />
<!-- 一大五小固定 6 槽占位 + 串行填内容大图 decode 后再挂小图 -->
<div
v-else-if="page.type === 'one-large-five-small'"
class="relative w-full aspect-square mt-6 grid grid-cols-3 grid-rows-3 gap-1.5 z-20"
>
<div
class="col-span-2 row-span-2 relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount < 1 }"
>
<div
v-if="shownCount >= 1"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
ref="olfsLargeImgRef"
:src="page.images[0]"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(page.images[0])"
/>
</div>
</div>
<div v-for="i in [1,2,3,4,5]" :key="i" v-reveal="{ variant: 'up', delay: i * 80 }" class="relative" :class="frameClass">
<img :src="page.images[i]" class="preview-img" @click.stop="previewImage(page.images[i])" />
<div
v-for="i in [1, 2, 3, 4, 5]"
:key="i"
class="relative olfs-slot"
:class="{ 'olfs-placeholder': shownCount <= i }"
>
<div
v-if="shownCount > i"
v-reveal="{ variant: 'up', immediate: true }"
class="absolute inset-0"
:class="frameClass"
>
<img
:src="page.images[i]"
class="preview-img olfs-img"
decoding="async"
@click.stop="previewImage(page.images[i])"
/>
</div>
</div>
</div>
<!-- 九宫格串行挂载 300ms 1 v-reveal不预挂九格观察器 -->
<!-- 九宫格串行挂载 + immediate由父级 activate 触发 -->
<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 }"
:class="{ 'nine-grid-placeholder': imgIdx >= shownCount }"
>
<div
v-if="imgIdx < nineShownCount && nineGridAnimate"
v-if="imgIdx < shownCount && nineGridAnimate"
v-reveal="getRevealConfig(imgIdx)"
class="absolute inset-0"
:class="frameClass"
@@ -60,7 +95,7 @@
/>
</div>
<div
v-else-if="imgIdx < nineShownCount"
v-else-if="imgIdx < shownCount"
class="absolute inset-0"
:class="frameClass"
>
@@ -75,11 +110,20 @@
</div>
</div>
<!-- 瀑布流 -->
<div v-else-if="page.type === 'masonry'" class="g-masonry mt-6 px-1">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'up', delay: imgIdx * 50 }"
class="g-masonry-item" :class="frameClass">
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
<!-- 瀑布流串行挂载 + immediate -->
<div v-else-if="page.type === 'masonry'" ref="multiRootRef" class="g-masonry mt-6 px-1">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'up', immediate: true }"
class="g-masonry-item"
:class="frameClass"
>
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
</div>
</div>
</div>
@@ -98,11 +142,20 @@
</div>
</div>
<!-- 拍立得拼贴 -->
<div v-else-if="page.type === 'polaroid'" class="g-polaroid mt-6">
<div v-for="(img, imgIdx) in page.images" :key="imgIdx" v-reveal="{ variant: 'scale', delay: imgIdx * 70 }"
class="g-polaroid-item" :style="{ transform: polaroidTransform(imgIdx) }">
<img :src="img" class="preview-img" @click.stop="previewImage(img)" />
<!-- 拍立得串行挂载 + immediate -->
<div v-else-if="page.type === 'polaroid'" ref="multiRootRef" class="g-polaroid mt-6">
<div
v-for="(img, imgIdx) in (page.images || [])"
:key="imgIdx"
>
<div
v-if="imgIdx < shownCount"
v-reveal="{ variant: 'scale', immediate: true }"
class="g-polaroid-item"
:style="{ transform: polaroidTransform(imgIdx) }"
>
<img :src="img" class="preview-img" decoding="async" @click.stop="previewImage(img)" />
</div>
</div>
</div>
@@ -113,29 +166,66 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { borderClass } from '@/composables/borderStyles'
import { createSequentialMount } from '@/composables/useSequentialMount'
const STEP_MS = 300
const SETTLE_MS = 400
const NINE_STEP_MS = 300
const NINE_SETTLE_MS = 400
const MULTI_STEP_MS = 220
const MULTI_SETTLE_MS = 300
const OLFS_STEP_MS = 300
const OLFS_SETTLE_MS = 320
const OLFS_DECODE_TIMEOUT_MS = 1200
/** 瀑布流/拍立得仍自观测;九宫格/一大五小由父级 activate+停滚驱动 */
const SEQUENTIAL_SELF_TYPES = new Set(['masonry', 'polaroid'])
const HOLD_ACTIVATE_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const props = defineProps({
page: { type: Object, required: true },
/** 九宫格专用:父级在 section 入视口后设为 true 才挂载展示 */
/** 九宫格/一大五小:父级停滚后设为 true 才串行挂载 */
activate: { type: Boolean, default: false },
/** 开启串行 v-reveal关闭:预加载后直接展示 */
/** 九宫格:开启串行 v-reveal关闭则一次展示 */
nineGridAnimate: { type: Boolean, default: false },
})
const emit = defineEmits(['preview-image', 'ready'])
const nineShownCount = ref(0)
const shownCount = ref(0)
const multiRootRef = ref(null)
const olfsLargeImgRef = ref(null)
let readyEmitted = false
let preloadStarted = false
let seqToken = 0
let multiObserver = null
let multiStarted = false
let olfsToken = 0
const seq = createSequentialMount()
const frameClass = computed(() => borderClass(props.page.borderStyle))
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/** 关闭动画时提前暖缓存 */
const waitImgReady = (img, timeoutMs) => {
if (!img) return Promise.resolve()
const decode = () => {
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 })
})
}
return Promise.race([
decode(),
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
])
}
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
}
/** 关闭九宫格动画时提前暖缓存 */
const preloadNineGridImages = () => {
if (preloadStarted || props.nineGridAnimate || props.page?.type !== 'nine-grid') return
const urls = (props.page.images || []).filter(Boolean)
@@ -148,69 +238,153 @@ const preloadNineGridImages = () => {
}
}
const emitReady = () => {
if (readyEmitted) return
readyEmitted = true
emit('ready')
const runNineSequential = async () => {
const total = (props.page.images || []).length
const { cancelled } = await seq.run({
total,
stepMs: NINE_STEP_MS,
settleMs: NINE_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
if (!cancelled) emitReady()
}
/** 每 300ms 只挂载 1 格并触发单格 v-reveal */
const runSequentialReveal = async () => {
const token = ++seqToken
const total = (props.page.images || []).length
const showNineInstant = async () => {
shownCount.value = (props.page.images || []).length
await nextTick()
emitReady()
}
/** 一大五小:先大图并等 decode再慢挂小图 */
const runOlfsSequential = async () => {
const token = ++olfsToken
const total = Math.min(6, (props.page.images || []).length)
if (!total) {
emitReady()
return
}
nineShownCount.value = 0
shownCount.value = 0
await nextTick()
if (token !== olfsToken) return
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 sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = 1
await nextTick()
await waitImgReady(olfsLargeImgRef.value, OLFS_DECODE_TIMEOUT_MS)
if (token !== olfsToken) return
for (let n = 2; n <= total; n += 1) {
await sleep(OLFS_STEP_MS)
if (token !== olfsToken) return
shownCount.value = n
await nextTick()
}
if (token !== seqToken) return
await sleep(SETTLE_MS)
if (token !== seqToken) return
if (token !== olfsToken) return
await sleep(OLFS_SETTLE_MS)
emitReady()
}
const showNineGridInstant = async () => {
const runMultiSequential = async () => {
const type = props.page?.type
if (!SEQUENTIAL_SELF_TYPES.has(type) || multiStarted) return
multiStarted = true
const total = (props.page.images || []).length
nineShownCount.value = total
await seq.run({
total,
stepMs: MULTI_STEP_MS,
settleMs: MULTI_SETTLE_MS,
setCount: (n) => { shownCount.value = n },
})
}
const unbindMultiObserver = () => {
multiObserver?.disconnect()
multiObserver = null
}
const bindMultiObserver = async () => {
unbindMultiObserver()
if (!SEQUENTIAL_SELF_TYPES.has(props.page?.type)) return
await nextTick()
emitReady()
const el = multiRootRef.value
if (!el || typeof IntersectionObserver === 'undefined') {
// 无观察器时直接串行,避免永不出现
if (!multiStarted) runMultiSequential()
return
}
// 露出约 30% 再串行,避免进屏前播完
multiObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting || entry.intersectionRatio < 0.3) continue
multiObserver?.disconnect()
multiObserver = null
runMultiSequential()
break
}
},
{ threshold: [0.3, 0.5, 0.7], rootMargin: '0px' }
)
multiObserver.observe(el)
}
watch(
() => props.activate && props.page?.type === 'nine-grid',
() => props.activate && HOLD_ACTIVATE_TYPES.has(props.page?.type),
async (on) => {
if (!on || readyEmitted) return
if (props.nineGridAnimate) await runSequentialReveal()
else await showNineGridInstant()
const type = props.page?.type
if (type === 'nine-grid') {
if (props.nineGridAnimate) await runNineSequential()
else await showNineInstant()
return
}
if (type === 'one-large-five-small') {
await runOlfsSequential()
}
},
{ flush: 'post' }
)
watch(
() => [props.page?.type, props.nineGridAnimate, props.page?.images],
() => [props.page?.type, props.page?.images],
async () => {
seq.cancel()
olfsToken += 1
unbindMultiObserver()
multiStarted = false
shownCount.value = 0
readyEmitted = false
if (props.page?.type === 'nine-grid') {
if (!props.nineGridAnimate) preloadNineGridImages()
return
}
if (props.page?.type === 'one-large-five-small') return
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) {
await bindMultiObserver()
}
},
{ deep: true, flush: 'post' }
)
watch(
() => props.nineGridAnimate,
() => {
if (!props.nineGridAnimate) preloadNineGridImages()
},
{ deep: true }
}
)
onMounted(() => {
if (!props.nineGridAnimate) preloadNineGridImages()
if (SEQUENTIAL_SELF_TYPES.has(props.page?.type)) bindMultiObserver()
})
onUnmounted(() => {
seqToken += 1
seq.cancel()
olfsToken += 1
unbindMultiObserver()
})
function getRevealConfig(imgIdx) {
@@ -218,17 +392,19 @@ function getRevealConfig(imgIdx) {
const col = imgIdx % 3
if (imgIdx === 4) {
return { variant: 'scale', delay: 0 }
return { variant: 'scale', delay: 0, immediate: true }
}
if (col === 1) {
return {
variant: row === 0 ? 'down' : 'up',
delay: 0,
immediate: true,
}
}
return {
variant: col === 0 ? 'left' : 'right',
delay: 0,
immediate: true,
}
}
@@ -303,6 +479,22 @@ function polaroidTransform(i) {
border: none !important;
box-shadow: none !important;
}
.olfs-slot {
min-width: 0;
min-height: 0;
contain: layout paint;
}
.olfs-placeholder {
visibility: hidden;
pointer-events: none;
background: transparent !important;
}
.olfs-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.overlap-wrap {
width: 100%;

View File

@@ -0,0 +1,45 @@
import { nextTick } from 'vue'
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
/**
* 串行挂载:每隔 stepMs 增加 1 个可见项,避免同时挂大量观察器/解码。
*/
export function createSequentialMount() {
let token = 0
const cancel = () => {
token += 1
}
/**
* @param {{ total: number, stepMs?: number, settleMs?: number, setCount: (n: number) => void }} opts
* @returns {Promise<{ cancelled: boolean }>}
*/
const run = async ({ total, stepMs = 220, settleMs = 300, setCount }) => {
const t = ++token
const n = Math.max(0, Number(total) || 0)
setCount(0)
await nextTick()
if (t !== token) return { cancelled: true }
if (!n) return { cancelled: false }
for (let i = 0; i < n; i += 1) {
if (t !== token) return { cancelled: true }
await sleep(stepMs)
if (t !== token) return { cancelled: true }
setCount(i + 1)
await nextTick()
}
if (t !== token) return { cancelled: true }
if (settleMs > 0) {
await sleep(settleMs)
if (t !== token) return { cancelled: true }
}
return { cancelled: false }
}
return { run, cancel }
}

View File

@@ -5,6 +5,7 @@
* v-reveal -> 默认向上淡入
* v-reveal="'left'" -> 从左侧滑入
* v-reveal="{ variant: 'scale', delay: 200 }"
* v-reveal="{ variant: 'up', immediate: true }" -> 不建观察器,下一帧直接入场(串行挂载用)
*
* 支持的 variant: up | down | left | right | scale | blur
*/
@@ -18,22 +19,41 @@ const VARIANTS = {
}
function parseBinding(value) {
if (!value) return { variant: 'up', delay: 0 }
if (typeof value === 'string') return { variant: value, delay: 0 }
if (!value) return { variant: 'up', delay: 0, immediate: false }
if (typeof value === 'string') return { variant: value, delay: 0, immediate: false }
return {
variant: value.variant || 'up',
delay: value.delay || 0,
immediate: !!value.immediate,
}
}
const reveal = {
mounted(el, binding) {
const { variant, delay } = parseBinding(binding.value)
// false跳过入场同模板不挂观察器
if (binding.value === false) return
const { variant, delay, immediate } = parseBinding(binding.value)
el.classList.add('reveal')
el.classList.add(VARIANTS[variant] || 'reveal-up')
if (delay) el.style.transitionDelay = `${delay}ms`
// 已处于视口(如首屏)时立即显示,避免永远不可见
// 串行挂载场景:父级已保证在视口内,免 IntersectionObserver
if (immediate) {
const kick = () => el.classList.add('reveal--in')
if (typeof requestAnimationFrame === 'function') {
el.__revealRaf = requestAnimationFrame(() => {
el.__revealRaf = requestAnimationFrame(() => {
el.__revealRaf = 0
kick()
})
})
} else {
el.__revealTimer = setTimeout(kick, 16)
}
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
@@ -51,6 +71,14 @@ const reveal = {
el.__revealObserver.disconnect()
delete el.__revealObserver
}
if (el.__revealRaf) {
cancelAnimationFrame(el.__revealRaf)
el.__revealRaf = 0
}
if (el.__revealTimer) {
clearTimeout(el.__revealTimer)
el.__revealTimer = 0
}
},
}

View File

@@ -43,8 +43,16 @@
:active="effectsActive"
/>
<!-- 顶部滚动进度条 -->
<div class="fixed top-0 left-0 h-[2px] bg-[#a88c6b] z-[55] transition-[width] duration-150" :style="{ width: progress * 100 + '%' }"></div>
<!-- 顶部滚动进度条transform 驱动避免 width/left 强制重排 -->
<div
class="scroll-progress"
aria-hidden="true"
:style="{ '--scroll-p': String(Math.min(1, Math.max(0, progress))) }"
>
<div class="scroll-progress-track" />
<div class="scroll-progress-fill" />
<span class="scroll-progress-xi calligraphy"></span>
</div>
<!-- 右上角自动滚动 -->
<div class="fixed top-6 right-5 z-50 flex flex-col items-end gap-4">
@@ -110,11 +118,15 @@
title="打开播放器"
@click="openPlayerPanel"
>
<span class="vinyl-rim" aria-hidden="true" />
<span class="vinyl-grooves" aria-hidden="true" />
<span class="vinyl-sheen" aria-hidden="true" />
<span class="vinyl-label-ring" aria-hidden="true" />
<span
class="vinyl-label"
:style="audio.currentCover.value ? { backgroundImage: `url(${audio.currentCover.value})` } : undefined"
/>
<span class="vinyl-spindle" aria-hidden="true" />
</button>
<button
type="button"
@@ -298,17 +310,17 @@
class="page-section p-6 relative"
:class="data.scrollMode === 'snap' ? 'snap-start' : ''"
:style="pageHeightStyle(page)"
:data-nine-grid="page.type === 'nine-grid' ? String(pIdx) : undefined">
:data-gallery-hold="isHoldGalleryType(page.type) ? String(pIdx) : undefined">
<div class="absolute inset-0 bg-white/40 -z-10"></div>
<div class="absolute top-0 left-0 w-24 h-24 border-l-[0.5px] border-t-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="absolute bottom-0 right-0 w-24 h-24 border-r-[0.5px] border-b-[0.5px] border-[#a88c6b]/30 m-6 pointer-events-none z-0"></div>
<div class="w-full h-full flex flex-col justify-center py-6 relative z-10">
<PhotoGallery
:page="page"
:activate="page.type === 'nine-grid' ? !!nineGridActivate[pIdx] : false"
:activate="isHoldGalleryType(page.type) ? !!galleryActivate[pIdx] : false"
:nine-grid-animate="!!data.nineGridAnimate"
@preview-image="openImagePreview"
@ready="onNineGridReady(pIdx)"
@ready="onGalleryReady(pIdx)"
/>
</div>
</div>
@@ -732,8 +744,11 @@ const likeBusy = ref(false)
let likePulseTimer = null
const previewVisible = ref(false), previewIndex = ref(0), albumLoaded = ref(false)
const albumPreviewImages = ref([])
const nineGridActivate = reactive({})
const nineGridHold = ref(false)
/** 九宫格 / 一大五小:进视口中线后停滚再串行加载 */
const HOLD_GALLERY_TYPES = new Set(['nine-grid', 'one-large-five-small'])
const isHoldGalleryType = (type) => HOLD_GALLERY_TYPES.has(type)
const galleryActivate = reactive({})
const galleryHold = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
const danmakuColorOptions = DANMAKU_COLORS
@@ -773,13 +788,13 @@ const scheduleBlessingAutoClose = () => {
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
let freeScrollLastTs = 0
let nineGridObserver = null
let nineGridRevealTimer = null
const nineGridDone = new Set()
let galleryObserver = null
let galleryRevealTimer = null
const galleryDone = new Set()
const BOTTOM_RETURN_DELAY = 30000
const FREE_SCROLL_BASE_STEP = 1.2
/** ready 后极短缓冲再恢复滚动(串行等待已在 PhotoGallery 内完成) */
const NINE_GRID_RESUME_DELAY_MS = 200
const GALLERY_RESUME_DELAY_MS = 200
const freeInterval = computed(() => {
const v = Number(data.value.freeScrollInterval)
@@ -915,12 +930,25 @@ const effectsActive = computed(() =>
const closeDrawer = () => { isDrawerOpen.value = false }
const pageScrollTop = () => window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
const pageScrollMax = () => Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
/** 缓存文档高度,避免每次 scroll 读 scrollHeight 触发强制重排 */
let cachedScrollMax = 0
let scrollRaf = 0
const refreshScrollMax = () => {
cachedScrollMax = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
return cachedScrollMax
}
const pageScrollMax = () => (cachedScrollMax > 0 ? cachedScrollMax : refreshScrollMax())
const handleScroll = () => {
const max = pageScrollMax()
progress.value = max > 0 ? pageScrollTop() / max : 0
if (isAtBottom()) scheduleBottomReturn()
else clearBottomReturn()
if (scrollRaf) return
scrollRaf = requestAnimationFrame(() => {
scrollRaf = 0
const max = pageScrollMax()
const top = pageScrollTop()
progress.value = max > 0 ? top / max : 0
if (max - top <= 8) scheduleBottomReturn()
else clearBottomReturn()
})
}
const handleUserInteraction = () => {
@@ -932,7 +960,10 @@ const handleUserInteraction = () => {
}
const sectionEls = () => Array.from(scrollContainerRef.value?.querySelectorAll('.page-section') || [])
const isAtBottom = () => pageScrollMax() - pageScrollTop() <= 8
const isAtBottom = () => {
const max = pageScrollMax()
return max - pageScrollTop() <= 8
}
const clearBottomReturn = () => {
clearTimeout(bottomReturnTimer)
@@ -987,45 +1018,51 @@ const canAutoAdvance = () =>
&& !previewVisible.value
&& !showMapOptions.value
&& !showOpenInBrowser.value
&& !nineGridHold.value
&& !galleryHold.value
const beginNineGridGate = (pIdx) => {
if (nineGridDone.has(pIdx)) return
nineGridDone.add(pIdx)
nineGridHold.value = true
nineGridActivate[pIdx] = true
const beginGalleryGate = (pIdx) => {
if (galleryDone.has(pIdx)) return
galleryDone.add(pIdx)
galleryHold.value = true
galleryActivate[pIdx] = true
}
const onNineGridReady = (pIdx) => {
if (!nineGridDone.has(pIdx)) return
clearTimeout(nineGridRevealTimer)
nineGridRevealTimer = setTimeout(() => {
nineGridHold.value = false
nineGridRevealTimer = null
}, NINE_GRID_RESUME_DELAY_MS)
const onGalleryReady = (pIdx) => {
if (!galleryDone.has(pIdx)) return
refreshScrollMax()
clearTimeout(galleryRevealTimer)
galleryRevealTimer = setTimeout(() => {
galleryHold.value = false
galleryRevealTimer = null
}, GALLERY_RESUME_DELAY_MS)
}
const bindNineGridObserver = () => {
nineGridObserver?.disconnect()
nineGridObserver = null
const bindGalleryHoldObserver = () => {
galleryObserver?.disconnect()
galleryObserver = null
if (typeof IntersectionObserver === 'undefined') return
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
const nodes = scrollContainerRef.value?.querySelectorAll('[data-gallery-hold]')
if (!nodes?.length) return
// 进入窗口即 activate期间 nineGridHold 暂停自动滚
nineGridObserver = new IntersectionObserver(
// 区块抵达「自下而上 75%」线后再停滚+activate= 距视口顶 25%
galleryObserver = new IntersectionObserver(
(entries) => {
const vh = window.innerHeight || 1
const lineY = vh * (1 - 0.75) // 从下往上 75%
for (const entry of entries) {
if (!entry.isIntersecting) continue
const raw = entry.target.getAttribute('data-nine-grid')
const rect = entry.boundingClientRect
// 区块跨越该水平线
if (rect.top > lineY || rect.bottom < lineY) continue
const raw = entry.target.getAttribute('data-gallery-hold')
if (raw == null) continue
const pIdx = Number(raw)
if (!Number.isFinite(pIdx) || nineGridDone.has(pIdx)) continue
beginNineGridGate(pIdx)
if (!Number.isFinite(pIdx) || galleryDone.has(pIdx)) continue
beginGalleryGate(pIdx)
}
},
{ threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
{ threshold: [0, 0.1, 0.25, 0.5, 0.75, 1], rootMargin: '0px' }
)
nodes.forEach((el) => nineGridObserver.observe(el))
nodes.forEach((el) => galleryObserver.observe(el))
}
const startAutoScroll = () => {
@@ -1045,11 +1082,12 @@ const startAutoScroll = () => {
const dt = freeScrollLastTs ? Math.min(34, now - freeScrollLastTs) : 16.7
freeScrollLastTs = now
if (canAutoAdvance()) {
if (isAtBottom()) {
const top = pageScrollTop()
const max = pageScrollMax()
if (max - top <= 8) {
scheduleBottomReturn()
} else {
window.scrollTo(0, pageScrollTop() + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
if (isAtBottom()) scheduleBottomReturn()
window.scrollTo(0, top + FREE_SCROLL_BASE_STEP * (dt / freeInterval.value))
}
}
rafId = requestAnimationFrame(tickFreeScroll)
@@ -1288,18 +1326,23 @@ onMounted(async () => {
}
await nextTick()
bindNineGridObserver()
bindGalleryHoldObserver()
refreshScrollMax()
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', refreshScrollMax, { passive: true })
handleScroll()
})
onUnmounted(() => {
document.documentElement.classList.remove('page-scroll-snap')
window.removeEventListener('scroll', handleScroll)
nineGridObserver?.disconnect()
nineGridObserver = null
clearTimeout(nineGridRevealTimer)
nineGridRevealTimer = null
window.removeEventListener('resize', refreshScrollMax)
galleryObserver?.disconnect()
galleryObserver = null
clearTimeout(galleryRevealTimer)
galleryRevealTimer = null
cancelAnimationFrame(scrollRaf)
scrollRaf = 0
cancelAnimationFrame(rafId)
cancelAnimationFrame(animId)
clearInterval(snapTimer)
@@ -1316,7 +1359,8 @@ watch(
() => data.value.photoPages,
async () => {
await nextTick()
bindNineGridObserver()
bindGalleryHoldObserver()
refreshScrollMax()
},
{ deep: true }
)
@@ -1324,7 +1368,7 @@ watch(
() => data.value.nineGridAnimate,
async () => {
await nextTick()
bindNineGridObserver()
bindGalleryHoldObserver()
}
)
watch(freeScrollSignature, restartAutoScroll)
@@ -1486,29 +1530,105 @@ watch(
.vinyl-wrap {
position: relative;
flex: 0 0 auto;
width: 52px;
height: 52px;
width: 54px;
height: 54px;
}
.vinyl-disc {
width: 52px;
height: 52px;
width: 54px;
height: 54px;
border-radius: 999px;
position: relative;
overflow: hidden;
border: 1px solid rgba(60, 45, 30, 0.35);
border: none;
background:
radial-gradient(circle at center, rgba(40, 30, 22, 0.2) 0 14%, transparent 15%),
repeating-radial-gradient(circle at center, #2a2118 0 1px, #3a2f24 1px 3px);
box-shadow: 0 6px 16px rgba(60, 45, 30, 0.22);
radial-gradient(circle at 50% 50%, #1a1410 0 6%, transparent 6.2%),
radial-gradient(circle at 32% 28%, rgba(255, 240, 220, 0.14) 0 10%, transparent 28%),
repeating-radial-gradient(
circle at center,
#1e1813 0 0.6px,
#2c241c 0.6px 1.4px,
#241c16 1.4px 2.2px,
#332a21 2.2px 3px
);
box-shadow:
0 0 0 1px rgba(90, 70, 50, 0.45),
0 0 0 2px rgba(30, 22, 16, 0.55),
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
0 7px 18px rgba(40, 28, 18, 0.32);
}
.vinyl-disc.spinning { animation: vinyl-spin 4.8s linear infinite; }
.vinyl-grooves { position: absolute; inset: 0; opacity: 0.35; pointer-events: none; }
.vinyl-rim {
position: absolute;
inset: 0;
border-radius: 999px;
box-shadow:
inset 0 0 0 1.5px rgba(180, 150, 110, 0.22),
inset 0 0 10px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.vinyl-grooves {
position: absolute;
inset: 8%;
border-radius: 999px;
opacity: 0.55;
pointer-events: none;
background:
repeating-radial-gradient(
circle at center,
transparent 0 1.5px,
rgba(255, 245, 230, 0.045) 1.5px 1.9px,
transparent 1.9px 3.4px,
rgba(0, 0, 0, 0.18) 3.4px 3.8px
);
mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
-webkit-mask-image: radial-gradient(circle at center, transparent 0 26%, #000 28% 92%, transparent 94%);
}
.vinyl-sheen {
position: absolute;
inset: 0;
border-radius: 999px;
pointer-events: none;
background: linear-gradient(
125deg,
rgba(255, 255, 255, 0.22) 0%,
rgba(255, 255, 255, 0) 28%,
rgba(255, 255, 255, 0) 58%,
rgba(255, 255, 255, 0.08) 100%
);
mix-blend-mode: soft-light;
}
.vinyl-label-ring {
position: absolute;
inset: 25%;
border-radius: 999px;
pointer-events: none;
box-shadow:
0 0 0 1px rgba(212, 180, 140, 0.35),
inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.vinyl-label {
position: absolute;
inset: 28%;
border-radius: 999px;
background: #c4a484 center/cover no-repeat;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.25);
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.2), transparent 42%),
#c4a484 center/cover no-repeat;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.28),
0 1px 3px rgba(0, 0, 0, 0.25);
}
.vinyl-spindle {
position: absolute;
left: 50%;
top: 50%;
width: 5px;
height: 5px;
margin: -2.5px 0 0 -2.5px;
border-radius: 999px;
background: radial-gradient(circle at 35% 30%, #f0e0c8, #8a704f 70%, #5c4a35);
box-shadow: 0 0 0 1px rgba(40, 28, 18, 0.35);
pointer-events: none;
z-index: 2;
}
.vinyl-play-fab {
position: absolute;
@@ -1527,6 +1647,57 @@ watch(
z-index: 2;
}
.vinyl-play-fab .fa-play { margin-left: 1px; }
.scroll-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 10px;
z-index: 55;
pointer-events: none;
}
.scroll-progress-track {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(
90deg,
rgba(168, 140, 107, 0.08),
rgba(168, 140, 107, 0.18),
rgba(168, 140, 107, 0.08)
);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.35);
}
.scroll-progress-fill {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, #d4b48a 0%, #a88c6b 55%, #8a704f 100%);
box-shadow: 0 0 8px rgba(168, 140, 107, 0.45);
transform: scaleX(var(--scroll-p, 0));
transform-origin: left center;
will-change: transform;
}
.scroll-progress-xi {
position: absolute;
top: 0;
left: 0;
font-size: 11px;
line-height: 1;
color: #a88c6b;
text-shadow:
0 0 6px rgba(253, 251, 247, 0.95),
0 1px 0 rgba(255, 255, 255, 0.8);
transform: translate3d(calc(var(--scroll-p, 0) * (100vw - 1.1em) + 0.55em - 50%), 0, 0);
will-change: transform;
font-weight: 700;
letter-spacing: 0;
}
.player-panel {
position: absolute;
left: 0;