hookehuyr

feat(弹幕组件): 优化弹幕循环逻辑并添加预加载功能

重构弹幕组件实现无缝循环播放,避免弹幕突然消失
添加预加载功能使弹幕在页面加载时就开始滚动
优化弹幕间距控制和淡出效果
...@@ -93,6 +93,7 @@ const isPlaying = ref(false) ...@@ -93,6 +93,7 @@ const isPlaying = ref(false)
93 const danmuId = ref(0) 93 const danmuId = ref(0)
94 const trackHeight = ref(110) // 适配最多3行文字显示,确保弹幕之间有足够间距 94 const trackHeight = ref(110) // 适配最多3行文字显示,确保弹幕之间有足够间距
95 const animationTimers = ref(new Map()) // 存储动画定时器 95 const animationTimers = ref(new Map()) // 存储动画定时器
96 +const FADE_DURATION = 400 // 弹幕靠近左侧时淡出时长(毫秒)
96 97
97 // 轨道系统 98 // 轨道系统
98 const tracks = reactive( 99 const tracks = reactive(
...@@ -117,6 +118,8 @@ const fetchDanmuData = async () => { ...@@ -117,6 +118,8 @@ const fetchDanmuData = async () => {
117 familyIntro: family.note, 118 familyIntro: family.note,
118 avatar: family.avatar_url || defaultAvatar 119 avatar: family.avatar_url || defaultAvatar
119 })) 120 }))
121 + // 重置弹幕索引,确保从头开始遍历新数据
122 + resetDanmuIndex()
120 } 123 }
121 } catch (error) { 124 } catch (error) {
122 console.error('获取弹幕数据失败:', error) 125 console.error('获取弹幕数据失败:', error)
...@@ -130,7 +133,7 @@ const getDanmuStyle = (danmu, trackIndex) => { ...@@ -130,7 +133,7 @@ const getDanmuStyle = (danmu, trackIndex) => {
130 133
131 return { 134 return {
132 transform: `translateX(${danmu.x}rpx)`, 135 transform: `translateX(${danmu.x}rpx)`,
133 - transition: danmu.isMoving ? `transform ${danmu.duration}ms linear` : 'none', 136 + transition: danmu.isMoving ? `transform ${danmu.duration}ms linear, opacity ${FADE_DURATION}ms linear` : 'none',
134 opacity: danmu.opacity || 1, 137 opacity: danmu.opacity || 1,
135 // 明确设置垂直位置 138 // 明确设置垂直位置
136 position: 'absolute', 139 position: 'absolute',
...@@ -138,13 +141,15 @@ const getDanmuStyle = (danmu, trackIndex) => { ...@@ -138,13 +141,15 @@ const getDanmuStyle = (danmu, trackIndex) => {
138 top: `${topPosition}rpx`, 141 top: `${topPosition}rpx`,
139 zIndex: 10, 142 zIndex: 10,
140 // 添加硬件加速 143 // 添加硬件加速
141 - willChange: 'transform' 144 + willChange: 'transform, opacity'
142 } 145 }
143 } 146 }
144 147
145 // 检查轨道中是否有足够空间放置新弹幕 148 // 检查轨道中是否有足够空间放置新弹幕
146 -const checkTrackSpace = (track, danmuWidth = 350) => { 149 +// options.ignoreTime: 跳过时间间隔限制,用于同轨跟随插入,保证无缝
150 +const checkTrackSpace = (track, danmuWidth = 350, options = {}) => {
147 const now = Date.now() 151 const now = Date.now()
152 + const ignoreTime = options.ignoreTime === true
148 153
149 // 如果轨道为空,可以直接放置 154 // 如果轨道为空,可以直接放置
150 if (track.danmus.length === 0) { 155 if (track.danmus.length === 0) {
...@@ -186,9 +191,9 @@ const checkTrackSpace = (track, danmuWidth = 350) => { ...@@ -186,9 +191,9 @@ const checkTrackSpace = (track, danmuWidth = 350) => {
186 191
187 // 检查最后一个弹幕的时间间隔 - 更严格的时间控制 192 // 检查最后一个弹幕的时间间隔 - 更严格的时间控制
188 const timeSinceLastDanmu = now - track.lastDanmuTime 193 const timeSinceLastDanmu = now - track.lastDanmuTime
189 - const minTimeInterval = 2500 // 增加到2.5秒,确保足够的时间间隔 194 + const minTimeInterval = 2500 // 增加到2.5秒,确保足够的时间间隔(正常模式)
190 195
191 - return timeSinceLastDanmu >= minTimeInterval 196 + return ignoreTime ? true : timeSinceLastDanmu >= minTimeInterval
192 } 197 }
193 198
194 // 找到可用的轨道 - 随机选择而非按顺序 199 // 找到可用的轨道 - 随机选择而非按顺序
...@@ -225,12 +230,12 @@ const createDanmu = (data) => { ...@@ -225,12 +230,12 @@ const createDanmu = (data) => {
225 } 230 }
226 231
227 // 添加弹幕到轨道 232 // 添加弹幕到轨道
228 -const addDanmuToTrack = (danmu) => { 233 +const addDanmuToTrack = (danmu, targetTrack = null, options = {}) => {
229 - const track = findAvailableTrack() 234 + const track = targetTrack || findAvailableTrack()
230 if (!track) return false 235 if (!track) return false
231 236
232 // 更严格的最终检查 - 确保轨道真的有足够空间 237 // 更严格的最终检查 - 确保轨道真的有足够空间
233 - if (!checkTrackSpace(track)) { 238 + if (!checkTrackSpace(track, 350, options)) {
234 return false 239 return false
235 } 240 }
236 241
...@@ -284,9 +289,30 @@ const startDanmuAnimation = (danmu, track) => { ...@@ -284,9 +289,30 @@ const startDanmuAnimation = (danmu, track) => {
284 // 计算剩余动画时间 289 // 计算剩余动画时间
285 const remainingTime = danmu.duration - elapsedTime 290 const remainingTime = danmu.duration - elapsedTime
286 291
292 + // 在动画末尾前触发淡出,避免到最左侧突然消失
293 + const fadeStartDelay = Math.max(0, remainingTime - FADE_DURATION)
294 + setTimeout(() => {
295 + danmu.opacity = 0
296 + }, fadeStartDelay)
297 +
298 + // 在本弹幕进入一定进度后,尝试在同一轨道追加下一条,保证无缝滚动
299 + const followProgress = 0.08 // 更早跟随,缩小间隙
300 + const followDelay = Math.max(200, danmu.duration * followProgress)
301 + setTimeout(() => {
302 + if (!isPlaying.value) return
303 + // 仅当轨道有空间时在同轨追加下一条弹幕
304 + if (checkTrackSpace(track, 350, { ignoreTime: true })) {
305 + const nextData = getNextUnusedDanmu()
306 + if (nextData) {
307 + const nextDanmu = createDanmu(nextData)
308 + addDanmuToTrack(nextDanmu, track, { ignoreTime: true })
309 + }
310 + }
311 + }, followDelay)
312 +
287 // 监听动画结束事件,实现循环 313 // 监听动画结束事件,实现循环
288 const timer = setTimeout(() => { 314 const timer = setTimeout(() => {
289 - // 动画结束后,重置弹幕位置到右侧,开始新的循环 315 + // 动画结束后,重置弹幕位置到右侧,开始新的循环(复用元素)
290 resetDanmuForLoop(danmu, track) 316 resetDanmuForLoop(danmu, track)
291 }, remainingTime) 317 }, remainingTime)
292 318
...@@ -297,24 +323,24 @@ const startDanmuAnimation = (danmu, track) => { ...@@ -297,24 +323,24 @@ const startDanmuAnimation = (danmu, track) => {
297 const resetDanmuForLoop = (danmu, track) => { 323 const resetDanmuForLoop = (danmu, track) => {
298 if (!isPlaying.value) return 324 if (!isPlaying.value) return
299 325
300 - // 立即停止当前动画 326 + // 停止当前动画并复位到右侧,复用元素,避免删除/新增造成的空档
301 danmu.isMoving = false 327 danmu.isMoving = false
328 + danmu.opacity = 1
302 329
303 - // 从轨道中移除这个弹幕,避免重叠检查时的干扰 330 + // 切换下一条数据,遵循首轮不重复规则
304 - removeDanmuFromTrack(danmu, track) 331 + const nextData = getNextUnusedDanmu()
332 + if (nextData) {
333 + danmu.data = nextData
334 + }
305 335
306 - // 使用 nextTick 确保 DOM 更新 336 + // 复位到右侧并重新计时
307 - nextTick(() => { 337 + danmu.startTime = Date.now()
308 - // 重新创建弹幕数据,避免状态污染 338 + danmu.x = 750
309 - const newDanmuData = { ...danmu.data } 339 + track.lastDanmuTime = Date.now()
310 - const newDanmu = createDanmu(newDanmuData)
311 340
312 - // 延迟重新添加,确保轨道空间检查正确 341 + // 重新启动动画(无缝继续)
313 - setTimeout(() => { 342 + nextTick(() => {
314 - if (isPlaying.value) { 343 + startDanmuAnimation(danmu, track)
315 - addDanmuToTrack(newDanmu)
316 - }
317 - }, 100 + Math.random() * 200) // 随机延迟100-300ms,避免同时重置造成的重叠
318 }) 344 })
319 } 345 }
320 346
...@@ -335,103 +361,63 @@ const sendDanmu = (data) => { ...@@ -335,103 +361,63 @@ const sendDanmu = (data) => {
335 } 361 }
336 362
337 // 初始化满屏弹幕显示 363 // 初始化满屏弹幕显示
338 -const initFullScreenDanmus = () => { 364 +// 弹幕索引管理
339 - if (!isPlaying.value) return 365 +let currentDanmuIndex = 0
340 - 366 +let usedDanmuIds = new Set() // 记录已使用的弹幕ID
341 - const screenWidth = 750 // 屏幕宽度 367 +let isFirstRound = true // 标记是否是第一轮遍历
342 - const danmuWidth = 350 // 弹幕宽度
343 -
344 - // 为每个轨道创建初始弹幕,确保轨道间距合理
345 - tracks.forEach((track, trackIndex) => {
346 - // 每个轨道放置2个弹幕,避免过度拥挤
347 - const danmusPerTrack = 2
348 368
349 - for (let i = 0; i < danmusPerTrack; i++) { 369 +// 重置弹幕索引和去重记录
350 - const danmuData = familyDanmus.value[currentDanmuIndex % familyDanmus.value.length] 370 +const resetDanmuIndex = () => {
351 - currentDanmuIndex++ 371 + currentDanmuIndex = 0
372 + usedDanmuIds.clear()
373 + isFirstRound = true
374 +}
352 375
353 - // 计算弹幕持续时间 376 +// 获取下一个未使用的弹幕数据
354 - const duration = (screenWidth + danmuWidth + 100) / (props.danmuSpeed / 1000) 377 +const getNextUnusedDanmu = () => {
355 - 378 + if (!familyDanmus.value || familyDanmus.value.length === 0) {
356 - // 计算已经运行的时间,确保弹幕间有足够间距 379 + return null
357 - // 第一个弹幕运行时间较长,第二个弹幕运行时间较短,形成间距
358 - const baseRunTime = i * (duration * 0.4) // 每个弹幕间隔40%的动画时长
359 - const randomOffset = Math.random() * (duration * 0.2) // 添加20%的随机偏移
360 - const randomRunTime = baseRunTime + randomOffset
361 - const startTime = Date.now() - randomRunTime
362 -
363 - // 计算初始位置,基于已经运行的时间
364 - const totalDistance = screenWidth + danmuWidth + 100
365 - const progress = Math.min(randomRunTime / duration, 0.9) // 最多运行90%,确保弹幕可见
366 - const initialX = screenWidth - (progress * totalDistance)
367 -
368 - const danmu = {
369 - id: `init-${trackIndex}-${i}-${Date.now()}-${Math.random()}`,
370 - data: danmuData,
371 - x: initialX,
372 - duration: duration,
373 - isMoving: true, // 立即开始移动
374 - opacity: 1,
375 - startTime: startTime // 使用计算出的开始时间
376 } 380 }
377 381
378 - track.danmus.push(danmu) 382 + // 如果是第一轮且还有未使用的弹幕
379 - // 更新轨道的最后弹幕时间,确保后续弹幕有合理间隔 383 + if (isFirstRound && usedDanmuIds.size < familyDanmus.value.length) {
380 - track.lastDanmuTime = Date.now() - (danmusPerTrack - i - 1) * 1500 // 每个弹幕间隔1.5秒 384 + // 查找下一个未使用的弹幕
385 + while (currentDanmuIndex < familyDanmus.value.length) {
386 + const danmuData = familyDanmus.value[currentDanmuIndex]
387 + const danmuId = danmuData.id
381 388
382 - // 立即启动动画,不延迟 389 + if (!usedDanmuIds.has(danmuId)) {
383 - nextTick(() => { 390 + usedDanmuIds.add(danmuId)
384 - startDanmuAnimation(danmu, track) 391 + currentDanmuIndex++
385 - }) 392 + return danmuData
393 + }
394 + currentDanmuIndex++
386 } 395 }
387 - })
388 -}
389 -
390 -// 当前弹幕索引,用于顺序显示
391 -let currentDanmuIndex = 0
392 -
393 -// 发送顺序弹幕 - 根据可用轨道数量同时填充弹幕
394 -const sendSequentialDanmu = () => {
395 - // 获取所有可用的轨道
396 - const availableTracks = tracks.filter(track => checkTrackSpace(track))
397 396
398 - if (availableTracks.length === 0) { 397 + // 第一轮遍历完成
399 - return // 没有可用轨道,直接返回 398 + isFirstRound = false
399 + currentDanmuIndex = 0
400 + usedDanmuIds.clear()
400 } 401 }
401 402
402 - // 为每个可用轨道同时发送一个弹幕 403 + // 第一轮遍历完成后,重新开始循环
403 - availableTracks.forEach(track => { 404 + if (!isFirstRound) {
404 - // 按顺序获取弹幕数据
405 const danmuData = familyDanmus.value[currentDanmuIndex % familyDanmus.value.length] 405 const danmuData = familyDanmus.value[currentDanmuIndex % familyDanmus.value.length]
406 -
407 - // 发送弹幕到指定轨道
408 - sendDanmuToTrack(danmuData, track)
409 -
410 - // 更新索引,循环使用
411 currentDanmuIndex++ 406 currentDanmuIndex++
412 - }) 407 + return danmuData
413 -}
414 -
415 -// 发送弹幕到指定轨道
416 -const sendDanmuToTrack = (danmuData, track) => {
417 - const danmu = {
418 - id: `danmu-${++danmuId.value}-${Date.now()}-${Math.random()}`,
419 - data: danmuData,
420 - x: 750, // 从右侧开始
421 - duration: 0,
422 - isMoving: false,
423 - opacity: 1,
424 - startTime: Date.now()
425 } 408 }
426 409
427 - // 添加到轨道 410 + return null
428 - track.danmus.push(danmu) 411 +}
429 - track.lastDanmuTime = Date.now()
430 412
431 - // 启动动画 413 +// 发送顺序弹幕
432 - nextTick(() => { 414 +const sendSequentialDanmu = () => {
433 - startDanmuAnimation(danmu, track) 415 + // 获取下一个未使用的弹幕数据
434 - }) 416 + const danmuData = getNextUnusedDanmu()
417 +
418 + if (danmuData) {
419 + sendDanmu(danmuData)
420 + }
435 } 421 }
436 422
437 // 自动发送弹幕 423 // 自动发送弹幕
...@@ -452,16 +438,34 @@ const startAutoSend = () => { ...@@ -452,16 +438,34 @@ const startAutoSend = () => {
452 438
453 // 动态调整发送间隔,根据可用轨道数量 439 // 动态调整发送间隔,根据可用轨道数量
454 const availableTrackCount = tracks.filter(track => checkTrackSpace(track)).length 440 const availableTrackCount = tracks.filter(track => checkTrackSpace(track)).length
455 - const baseInterval = 1200 // 基础间隔1.2秒 441 + const baseInterval = 800 // 减少基础间隔到0.8秒,让弹幕更密集
456 - const intervalMultiplier = Math.max(1, (6 - availableTrackCount) * 0.3) // 可用轨道越少,间隔越长 442 + const intervalMultiplier = Math.max(1, (6 - availableTrackCount) * 0.2) // 减少间隔倍数
457 - const dynamicInterval = baseInterval * intervalMultiplier + Math.random() * 400 443 + const dynamicInterval = baseInterval * intervalMultiplier + Math.random() * 300
458 444
459 autoSendTimer = setTimeout(sendNext, dynamicInterval) 445 autoSendTimer = setTimeout(sendNext, dynamicInterval)
460 } 446 }
461 447
448 + // 立即发送第一条弹幕,不等待
462 sendNext() 449 sendNext()
463 } 450 }
464 451
452 +// 预加载弹幕,让弹幕在组件显示前就开始滚动
453 +const preloadDanmus = () => {
454 + if (!isPlaying.value) return
455 +
456 + // 立即发送多条弹幕到不同轨道,模拟已经在滚动的状态
457 + const preloadCount = Math.min(tracks.length, familyDanmus.value.length)
458 +
459 + for (let i = 0; i < preloadCount; i++) {
460 + // 为每个轨道发送一条弹幕,并设置不同的延迟
461 + setTimeout(() => {
462 + if (isPlaying.value) {
463 + sendSequentialDanmu()
464 + }
465 + }, i * 200) // 每条弹幕间隔200ms发送
466 + }
467 +}
468 +
465 const stopAutoSend = () => { 469 const stopAutoSend = () => {
466 if (autoSendTimer) { 470 if (autoSendTimer) {
467 clearTimeout(autoSendTimer) 471 clearTimeout(autoSendTimer)
...@@ -528,15 +532,15 @@ onMounted(async () => { ...@@ -528,15 +532,15 @@ onMounted(async () => {
528 // 自动开始播放 532 // 自动开始播放
529 isPlaying.value = true 533 isPlaying.value = true
530 534
531 - // 使用nextTick确保DOM完全渲染后再初始化弹幕 535 + // 使用nextTick确保DOM完全渲染后立即开始弹幕滚动
532 nextTick(() => { 536 nextTick(() => {
533 - // 立即初始化满屏弹幕,避免白屏状态 537 + // 先预加载弹幕,让多条弹幕快速出现
534 - initFullScreenDanmus() 538 + preloadDanmus()
535 539
536 - // 稍微延迟启动自动发送,让初始弹幕先稳定显示 540 + // 然后启动自动发送,维持持续滚动
537 setTimeout(() => { 541 setTimeout(() => {
538 startAutoSend() 542 startAutoSend()
539 - }, 1000) // 1秒后开始自动发送新弹幕 543 + }, 1000) // 1秒后开始正常的自动发送节奏
540 }) 544 })
541 }) 545 })
542 546
......
...@@ -124,8 +124,8 @@ ...@@ -124,8 +124,8 @@
124 </view> 124 </view>
125 </view> 125 </view>
126 126
127 - <!-- 弹幕显示助力榜内容 --> 127 + <!-- 隐藏的弹幕组件,在页面加载时就开始滚动 -->
128 - <view v-if="activeTab === 'support'" class="danmu-section"> 128 + <view class="danmu-section" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
129 <NativeDanmuComponent 129 <NativeDanmuComponent
130 :container-height="700" 130 :container-height="700"
131 :show-controls="true" 131 :show-controls="true"
......
...@@ -156,8 +156,8 @@ ...@@ -156,8 +156,8 @@
156 156
157 </view> 157 </view>
158 158
159 - <!-- 弹幕显示助力榜内容 --> 159 + <!-- 隐藏的弹幕组件,在页面加载时就开始滚动 -->
160 - <view v-if="activeTab === 'support'" class="danmu-section mt-8"> 160 + <view class="danmu-section mt-8" :style="{ visibility: activeTab === 'support' ? 'visible' : 'hidden', position: activeTab === 'support' ? 'static' : 'absolute', top: activeTab === 'support' ? 'auto' : '-9999px' }">
161 <NativeDanmuComponent 161 <NativeDanmuComponent
162 :container-height="1200" 162 :container-height="1200"
163 :show-controls="true" 163 :show-controls="true"
......