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

@@ -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;