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

@@ -160,6 +160,10 @@
<a-switch v-model:checked="cfg.danmakuShowTime" :disabled="!cfg.danmakuEnabled" />
<div class="text-[11px] text-[#8A8680] mt-1">开启后弹幕后缀展示相对时间(刚刚、x分钟前等)。</div>
</a-form-item>
<a-form-item label="九宫格入场动画">
<a-switch v-model:checked="cfg.nineGridAnimate" />
<div class="text-[11px] text-[#8A8680] mt-1">关闭:提前预加载、进入窗口即展示且不暂停滚动(推荐 iOS);开启:轻量淡入并短暂停滚。</div>
</a-form-item>
</div>
</a-form>
</a-card>
@@ -437,7 +441,7 @@
<template #bodyCell="{ column, record }">
<template v-if="column.key==='content'">
<span class="inline-flex items-center gap-2 min-w-0">
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuColor(record.color) }" />
<i class="danmaku-admin-dot" :style="{ background: resolveDanmakuSwatch(record.color) }" />
<span class="truncate">{{ record.content }}</span>
</span>
</template>
@@ -479,7 +483,7 @@ import {
import { useAdminStore } from '@/stores/admin'
import ImageField from '@/components/ImageField.vue'
import { BORDER_STYLES, borderClass } from '@/composables/borderStyles'
import { resolveDanmakuColor } from '@/utils/danmakuColors'
import { resolveDanmakuSwatch } from '@/utils/danmakuColors'
const router = useRouter()
const adminStore = useAdminStore()
@@ -512,6 +516,7 @@ function defaultConfig() {
musicRandomEnabled: false,
danmakuEnabled: true,
danmakuShowTime: true,
nineGridAnimate: false,
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
introExitEffect: 'wind',
freeScrollInterval: 50, pageSwitchDuration: 800, firstScreenDuration: 4500,
@@ -869,6 +874,7 @@ onMounted(async () => {
if (typeof cfg.musicRandomEnabled !== 'boolean') cfg.musicRandomEnabled = false
if (typeof cfg.danmakuEnabled !== 'boolean') cfg.danmakuEnabled = true
if (typeof cfg.danmakuShowTime !== 'boolean') cfg.danmakuShowTime = true
if (typeof cfg.nineGridAnimate !== 'boolean') cfg.nineGridAnimate = false
}
} catch (e) { /* 使用默认配置 */ }
if (adminStore.isAdmin) {

View File

@@ -232,7 +232,7 @@
type="button"
class="action-comment"
title="送祝福"
@click="showTrackList = false; showBlessingDrawer = true"
@click="showTrackList = false; openBlessingDrawer()"
>
<img src="https://api.iconify.design/lucide:message-circle-heart.svg?color=%23a88c6b" class="action-comment-icon" alt="" />
<span>写一句祝福</span>
@@ -306,6 +306,7 @@
<PhotoGallery
:page="page"
:activate="page.type === 'nine-grid' ? !!nineGridActivate[pIdx] : false"
:nine-grid-animate="!!data.nineGridAnimate"
@preview-image="openImagePreview"
@ready="onNineGridReady(pIdx)"
/>
@@ -520,13 +521,13 @@
</div>
<!-- 送祝福抽屉 -->
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="showBlessingDrawer = false"></div>
<div v-if="showBlessingDrawer" class="fixed inset-0 z-[60] bg-black/40 backdrop-blur-sm" @click="closeBlessingDrawer"></div>
<div
class="fixed bottom-0 left-0 w-full bg-white rounded-t-[2.5rem] shadow-2xl transition-transform duration-500 z-[70] p-10 flex flex-col"
:class="{ 'pointer-events-none': !showBlessingDrawer }"
:style="{ transform: showBlessingDrawer ? 'translateY(0)' : 'translateY(100%)' }"
>
<div @click="showBlessingDrawer = false" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
<div @click="closeBlessingDrawer" class="absolute top-6 right-6 p-2 text-[#a88c6b] cursor-pointer hover:text-[#2c2c2c] transition-colors text-xl">✕</div>
<span class="text-xl text-center text-[#a88c6b] mb-1 tracking-[0.3em] font-light mt-2">送上祝福</span>
<span class="text-center text-[10px] text-[#8A8680] mb-8 tracking-[0.4em]">即 时 展 示</span>
<div v-if="!blessingSubmitted" class="space-y-6">
@@ -569,13 +570,32 @@
@click="blessingForm.color = c.key"
/>
</div>
<span class="block text-[10px] text-[#a88c6b] mb-2 mt-3 tracking-widest pl-1">渐变配色</span>
<div class="danmaku-color-row">
<button
v-for="c in danmakuGradientOptions"
:key="c.key"
type="button"
class="danmaku-color-dot danmaku-color-dot--grad"
:class="{ active: blessingForm.color === c.key }"
:style="{ background: c.gradient }"
:title="c.label"
@click="blessingForm.color = c.key"
/>
</div>
</div>
<div @click="handleSubmitBlessing" class="w-full bg-[#a88c6b] text-white py-4 rounded-full text-center tracking-[0.3em] text-[12px] cursor-pointer hover:bg-[#967b5c] transition-all shadow-md">发送祝福</div>
</div>
<div v-else class="text-center py-10 flex flex-col items-center">
<span class="text-3xl text-[#a88c6b] mb-6">♥</span>
<span class="text-lg text-[#2c2c2c] mb-3 tracking-[0.2em] font-light">祝福已送达</span>
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light">已展示在弹幕中</span>
<div v-else class="text-center py-8 flex flex-col items-center">
<span class="text-3xl text-[#a88c6b] mb-5">♥</span>
<span class="text-lg text-[#2c2c2c] mb-2 tracking-[0.2em] font-light">祝福已送达</span>
<span class="text-[13px] text-[#8A8680] leading-loose tracking-widest font-light mb-8">已展示在弹幕中</span>
<button
type="button"
class="w-full max-w-[280px] bg-[#a88c6b] text-white py-4 rounded-full tracking-[0.3em] text-[12px] hover:bg-[#967b5c] transition-all shadow-md"
@click="closeBlessingDrawer"
>确认</button>
<span class="mt-4 text-[11px] text-[#8A8680] tracking-widest">{{ blessingCloseLeft }} 秒后自动关闭</span>
</div>
</div>
@@ -595,7 +615,7 @@ import { useAudio } from '@/composables/useAudio'
import { toast } from '@/composables/useToast'
import { pickMusicUrl, rememberMusicPick } from '@/utils/musicPick'
import { getClientId } from '@/utils/clientId'
import { DANMAKU_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { DANMAKU_COLORS, DANMAKU_GRADIENT_COLORS, DEFAULT_DANMAKU_COLOR, BLESSING_STYLES } from '@/utils/danmakuColors'
import { parseLrc, formatAudioTime, findLrcIndex } from '@/utils/parseLrc'
import CanvasEffects from '@/components/CanvasEffects.vue'
import DanmakuLayer from '@/components/DanmakuLayer.vue'
@@ -605,7 +625,7 @@ import PhotoGallery from '@/components/PhotoGallery.vue'
const DEFAULTS = {
scrollMode: 'snap', petalEnabled: true, bubbleEnabled: true, introEnabled: true,
musicList: [], activeMusicUrl: '', musicRandomEnabled: false,
danmakuEnabled: true, danmakuShowTime: true,
danmakuEnabled: true, danmakuShowTime: true, nineGridAnimate: false,
freeScrollInterval: 50, // 自由滚动速度:滚动间隔 ms(越小越快)
pageSwitchDuration: 800, // 页面切换速度:翻页动画时长 ms
firstScreenDuration: 4500, // 首屏停留时间:首屏效果结束后再开始自动滚动
@@ -623,6 +643,9 @@ const showIntro = ref(false), introOpening = ref(false), showTrackList = ref(fal
const showOpenInBrowser = ref(false)
const showBlessingDrawer = ref(false)
const blessingSubmitted = ref(false)
const blessingCloseLeft = ref(0)
let blessingCloseTimer = null
let blessingCloseTick = null
const showPlayerPanel = ref(false)
const showVolumeSlider = ref(false)
const showPanelTrackList = ref(false)
@@ -714,8 +737,40 @@ const nineGridHold = ref(false)
const rsvpForm = reactive({ name: '', guest_count: '1', wishes: '' })
const blessingForm = reactive({ name: '', content: '', color: DEFAULT_DANMAKU_COLOR })
const danmakuColorOptions = DANMAKU_COLORS
const danmakuGradientOptions = DANMAKU_GRADIENT_COLORS
const rsvpOptions = [ { value: '1', label: '1 人出席' }, { value: '2', label: '2 人出席' }, { value: '3', label: '3 人及以上' }, { value: '0', label: '遗憾缺席' } ]
const clearBlessingAutoClose = () => {
clearTimeout(blessingCloseTimer)
clearInterval(blessingCloseTick)
blessingCloseTimer = null
blessingCloseTick = null
blessingCloseLeft.value = 0
}
const openBlessingDrawer = () => {
clearBlessingAutoClose()
blessingSubmitted.value = false
showBlessingDrawer.value = true
}
const closeBlessingDrawer = () => {
clearBlessingAutoClose()
showBlessingDrawer.value = false
blessingSubmitted.value = false
}
const scheduleBlessingAutoClose = () => {
clearBlessingAutoClose()
blessingCloseLeft.value = 5
blessingCloseTick = setInterval(() => {
blessingCloseLeft.value = Math.max(0, blessingCloseLeft.value - 1)
}, 1000)
blessingCloseTimer = setTimeout(() => {
closeBlessingDrawer()
}, 5000)
}
let rafId = null, snapTimer = null, pauseTimer = null, introTimer = null, freeTimer = null, animId = null, scrollStartTimer = null, bottomReturnTimer = null
let freeScrollLastTs = 0
let nineGridObserver = null
@@ -723,8 +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_MS = 400
/** 开启动画时,入场结束后短暂停一下再继续自动滚 */
const NINE_GRID_REVEAL_HOLD_ANIM_MS = 520
const NINE_GRID_FULL_RATIO = 0.9
const freeInterval = computed(() => {
@@ -938,17 +993,22 @@ const canAutoAdvance = () =>
const beginNineGridGate = (pIdx) => {
if (nineGridDone.has(pIdx)) return
nineGridDone.add(pIdx)
nineGridHold.value = true
// 仅开启动画时暂停自动滚动;关闭动画不挡滚动
if (data.value.nineGridAnimate) 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_MS)
}, NINE_GRID_REVEAL_HOLD_ANIM_MS)
}
const bindNineGridObserver = () => {
@@ -957,10 +1017,13 @@ const bindNineGridObserver = () => {
if (typeof IntersectionObserver === 'undefined') return
const nodes = scrollContainerRef.value?.querySelectorAll('[data-nine-grid]')
if (!nodes?.length) return
const animate = !!data.value.nineGridAnimate
nineGridObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.intersectionRatio < NINE_GRID_FULL_RATIO) continue
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)
@@ -968,7 +1031,9 @@ const bindNineGridObserver = () => {
beginNineGridGate(pIdx)
}
},
{ threshold: [0, 0.85, 1] }
animate
? { threshold: [0, 0.85, 1] }
: { threshold: [0, 0.08, 0.2], rootMargin: '120px 0px' }
)
nodes.forEach((el) => nineGridObserver.observe(el))
}
@@ -1109,8 +1174,7 @@ const pickAiStyle = async (target, style) => {
aiBlessingTarget.value = target
aiBlessingLoading.value = true
try {
const name = target === 'rsvp' ? rsvpForm.name.trim() : blessingForm.name.trim()
const res = await generateAiBlessing({ name, style })
const res = await generateAiBlessing({ style })
const text = res.data?.text || ''
if (!text) throw new Error('未生成内容')
if (target === 'rsvp') rsvpForm.wishes = text
@@ -1147,6 +1211,7 @@ const handleSubmitBlessing = async () => {
if (res.code === 200) {
if (res.data) danmakuLayerRef.value?.pushItem?.(res.data)
blessingSubmitted.value = true
scheduleBlessingAutoClose()
}
else toast.error(res.error || '提交失败')
} catch (e) {
@@ -1253,6 +1318,7 @@ onUnmounted(() => {
clearTimeout(introTimer)
clearTimeout(scrollStartTimer)
clearBottomReturn()
clearBlessingAutoClose()
})
watch(() => data.value.scrollMode, syncPageScrollMode)
@@ -1264,6 +1330,13 @@ watch(
},
{ deep: true }
)
watch(
() => data.value.nineGridAnimate,
async () => {
await nextTick()
bindNineGridObserver()
}
)
watch(freeScrollSignature, restartAutoScroll)
watch(
() => [isDrawerOpen.value, showBlessingDrawer.value, previewVisible.value, showMapOptions.value, showOpenInBrowser.value],
@@ -1794,6 +1867,14 @@ watch(
box-shadow: 0 0 0 2px rgba(168,140,107,0.35);
transform: scale(1.08);
}
.danmaku-color-dot--grad {
width: 30px;
height: 30px;
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.55),
0 2px 8px rgba(88,68,42,0.18),
0 0 12px rgba(255,140,160,0.22);
}
.ai-bless-wrap { position: relative; }
.ai-bless-btn {
font-size: 10px; letter-spacing: 0.12em; color: #a88c6b;