1919 'lyric',
2020 settingStore.playerType,
2121 settingStore.lyricsPosition,
22- { pure: statusStore.pureLyricMode },
22+ { pure: statusStore.pureLyricMode, 'yrc-anim': settingStore.showYrcAnimation },
2323 ]"
2424 @mouseenter =" lrcMouseStatus = settingStore.lrcMousePause ? true : false"
2525 @mouseleave =" lrcAllLeave"
5454 {
5555 // on: statusStore.lyricIndex === index,
5656 // 当播放时间大于等于当前歌词的开始时间
57- on:
58- (playSeek >= item.startTime && playSeek < item.endTime) ||
59- statusStore.lyricIndex === index,
57+ on: isYrcLineOn(item, index),
6058 'is-bg': item.isBG,
6159 'is-duet': item.isDuet,
6260 },
7876 :key =" textIndex"
7977 :class =" {
8078 'content-text': true,
81- 'content-long':
82- settingStore.showYrcLongEffect &&
83- text.endTime - text.startTime >= 1500 &&
84- playSeek <= text.endTime,
8579 'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
8680 }"
81+ :style =" getYrcVars(text, index)"
8782 >
88- <span class =" word" :lang =" getLyricLanguage(text.word)" >
89- {{ text.word }}
90- </span >
91- <span
92- class =" filler"
93- :style =" getYrcStyle(text, index)"
94- :lang =" getLyricLanguage(text.word)"
95- >
83+ <span class =" yrc-word" :lang =" getLyricLanguage(text.word)" >
9684 {{ text.word }}
9785 </span >
9886 </div >
@@ -223,71 +211,96 @@ const lyricsScroll = (index: number) => {
223211};
224212
225213/**
226- * 不活跃的普通歌词动画样式
214+ * CSS 变量类型(避免随意使用 any)
227215 */
228- const INACTIVE_NO_ANIMATION_STYLE = { opacity: 0 } as const ;
216+ type CssVars = Record <` --${string } ` , string >;
217+
218+ const YRC_DIM_ALPHA = 0.3 ;
219+ const YRC_LINE_FADE_MS = 250 ;
220+
221+ /** 逐字歌词行数据类型 */
222+ type YrcLineLike = { startTime: number ; endTime: number };
223+
224+ /** 逐字歌词行淡出索引 */
225+ const yrcFadingLineIndex = ref <number | null >(null );
226+ /** 逐字歌词行淡出时间 */
227+ const yrcFadingUntilAt = ref <number >(0 );
228+
229+ /**
230+ * 获取逐字歌词行淡出因子
231+ * @param index 歌词行索引
232+ * @returns 淡出因子
233+ */
234+ const getYrcFadeFactor = (index : number ): number => {
235+ if (yrcFadingLineIndex .value !== index ) return 1 ;
236+ const now = Date .now ();
237+ if (now >= yrcFadingUntilAt .value ) return 1 ;
238+ const remain = yrcFadingUntilAt .value - now ;
239+ return Math .min (Math .max (remain / YRC_LINE_FADE_MS , 0 ), 1 );
240+ };
229241
230242/**
231243 * 逐字歌词样式计算
232244 * @param wordData 逐字歌词数据
233- * @param lyricIndex 歌词索引
245+ * @param lyricIndex 歌词行索引
234246 * @returns 逐字歌词动画样式
235247 */
236- const getYrcStyle = (wordData : LyricWord , lyricIndex : number ) => {
237- // 获取当前歌词行数据
238- const currentLine = musicStore .songLyric .yrcData [lyricIndex ];
248+ const getYrcVars = (wordData : LyricWord , lyricIndex : number ): CssVars => {
239249 // 缓存 playSeek 值,避免多次访问响应式变量
240250 const currentSeek = playSeek .value ;
251+ const fadeFactor = getYrcFadeFactor (lyricIndex );
241252
242- // 判断当前行是否处于激活状态
243- const isLineActive =
244- (currentSeek >= currentLine .startTime && currentSeek < currentLine .endTime ) ||
245- statusStore .lyricIndex === lyricIndex ;
253+ // 只对激活行计算逐字变量:非激活行走纯 CSS(避免无谓计算)
254+ const currentLine = musicStore .songLyric .yrcData [lyricIndex ];
255+ if (! isYrcLineOn (currentLine , lyricIndex )) return {};
246256
247- // 如果当前歌词行不是激活状态,返回固定样式,避免不必要的计算
248- if (! isLineActive ) {
249- if (settingStore .showYrcAnimation ) {
250- // 判断单词是否已经唱过:已唱过保持填充状态(0%),未唱到保持未填充状态(100%)
251- const hasPlayed = currentSeek >= wordData .endTime ;
252- return {
253- WebkitMaskPositionX: hasPlayed ? " 0%" : " 100%" ,
254- };
255- } else {
256- return INACTIVE_NO_ANIMATION_STYLE ;
257- }
257+ // 无动画模式:未唱到的词保持暗色,唱到后整词高亮
258+ if (! settingStore .showYrcAnimation ) {
259+ const wordOpacity =
260+ statusStore .playLoading === false && wordData .startTime > currentSeek ? YRC_DIM_ALPHA : 1 ;
261+ return { " --yrc-opacity" : ` ${wordOpacity } ` };
258262 }
259263
260- // 激活状态的样式计算
261- if (settingStore .showYrcAnimation ) {
262- // 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0
263- if (statusStore .playLoading === false && wordData .endTime - currentSeek > 0 ) {
264- return {
265- transitionDuration: ` 0s, 0s, 0.35s ` ,
266- transitionDelay: ` 0ms ` ,
267- WebkitMaskPositionX: ` ${
268- 100 -
269- Math .max (
270- ((currentSeek - wordData .startTime ) / (wordData .endTime - wordData .startTime )) * 100 ,
271- 0 ,
272- )
273- }% ` ,
274- };
275- }
276- // 预计算时间差,避免重复计算
277- const timeDiff = wordData .startTime - currentSeek ;
278- return {
279- transitionDuration: ` ${wordData .endTime - wordData .startTime }ms, ${(wordData .endTime - wordData .startTime ) * 0.8 }ms, 0.35s ` ,
280- transitionDelay: ` ${timeDiff }ms, ${timeDiff + (wordData .endTime - wordData .startTime ) * 0.5 }ms, 0ms ` ,
281- };
282- } else {
283- // 无动画模式:根据单词时间判断透明度
284- return statusStore .playLoading === false && wordData .startTime >= currentSeek
285- ? { opacity: 0 }
286- : { opacity: 1 };
287- }
264+ const duration = wordData .endTime - wordData .startTime ;
265+ const safeDuration = Math .max (duration , 1 );
266+ const rawProgress = (currentSeek - wordData .startTime ) / safeDuration ;
267+ const progress = Math .min (Math .max (rawProgress , 0 ), 1 );
268+ const maskX = ` ${(1 - progress ) * 100 }% ` ;
269+
270+ // 未唱到的词:保持统一暗色,避免出现半亮半暗的“虚影边”
271+ const hasStarted = currentSeek >= wordData .startTime ;
272+ // 注意:激活行会启用 mask,mask alpha 会与元素 opacity 相乘;
273+ // 为避免未开始词在激活行变得“更淡”(0.3 * 0.3 = 0.09),动画模式下元素 opacity 固定为 1,
274+ // 明暗仅由 mask alpha 控制。
275+ const brightAlpha = hasStarted ? YRC_DIM_ALPHA + (1 - YRC_DIM_ALPHA ) * fadeFactor : YRC_DIM_ALPHA ;
276+ const darkAlpha = YRC_DIM_ALPHA ;
277+
278+ return {
279+ " --yrc-mask-x" : maskX ,
280+ " --yrc-opacity" : " 1" ,
281+ " --yrc-bright-alpha" : ` ${brightAlpha } ` ,
282+ " --yrc-dark-alpha" : ` ${darkAlpha } ` ,
283+ };
284+ };
285+
286+ /**
287+ * 判断逐字歌词行是否激活
288+ * @param line 逐字歌词行数据
289+ * @param index 歌词行索引
290+ * @returns 是否激活
291+ */
292+ const isYrcLineOn = (line : YrcLineLike , index : number ): boolean => {
293+ const currentSeek = playSeek .value ;
294+ const isInRange = currentSeek >= line .startTime && currentSeek < line .endTime ;
295+ const isCurrent = statusStore .lyricIndex === index ;
296+ const isFading = yrcFadingLineIndex .value === index && Date .now () < yrcFadingUntilAt .value ;
297+ return isInRange || isCurrent || isFading ;
288298};
289299
290- // 进度跳转
300+ /**
301+ * 进度跳转
302+ * @param time 时间
303+ */
291304const jumpSeek = (time : number ) => {
292305 if (! time ) return ;
293306 lrcMouseStatus .value = false ;
@@ -299,7 +312,14 @@ const jumpSeek = (time: number) => {
299312// 监听歌词滚动
300313watch (
301314 () => statusStore .lyricIndex ,
302- (val ) => lyricsScroll (val ),
315+ (val , oldVal ) => {
316+ lyricsScroll (val );
317+ // 行切换时,让上一行做一次短暂淡出(高亮不会瞬间消失)
318+ if (typeof oldVal === " number" && oldVal >= 0 && oldVal !== val ) {
319+ yrcFadingLineIndex .value = oldVal ;
320+ yrcFadingUntilAt .value = Date .now () + YRC_LINE_FADE_MS ;
321+ }
322+ },
303323);
304324
305325onMounted (() => {
@@ -398,55 +418,34 @@ onBeforeUnmount(() => {
398418 .content-text {
399419 position : relative ;
400420 display : inline-block ;
421+ overflow : visible ; /* 允许字形下伸部(j/g/y 等)正常绘制 */
401422 overflow-wrap : anywhere; /* 新增:逐字歌词单词支持换行 */
402423 word-break : break-word ; /* 新增:单词内换行 */
403424 white-space : normal ; /* 新增:确保逐字歌词换行 */
404- .word {
405- opacity : 0.3 ;
425+ .yrc-word {
406426 display : inline-block ;
427+ box-sizing : border-box ;
428+ /* 给字形上下留一点空间,避免下伸部在某些渲染条件下被裁 */
429+ padding-block : 0.2em ;
430+ margin-block : -0.2em ;
431+ /* 非激活行/未唱到 */
432+ opacity : var (--yrc-opacity , 0.3 );
407433 }
408- .filler {
409- opacity : 0 ;
410- position : absolute ;
411- left : 0 ;
412- top : 0 ;
413- transform : none ;
414- will-change : -webkit-mask-position-x , transform , opacity ;
415- // padding: 0.3em 0;
416- // margin: -0.3em 0;
417- mask-image : linear-gradient (
418- to right ,
419- rgb (0 , 0 , 0 ) 45.4545454545% ,
420- rgba (0 , 0 , 0 , 0 ) 54.5454545455%
421- );
422- mask-size : 220% 100% ;
423- mask-repeat : no-repeat ;
424- -webkit-mask-image : linear-gradient (
425- to right ,
426- rgb (0 , 0 , 0 ) 45.4545454545% ,
427- rgba (0 , 0 , 0 , 0 ) 54.5454545455%
428- );
429- -webkit-mask-size : 220% 100% ;
430- -webkit-mask-repeat : no-repeat ;
431- transition :
432- opacity 0.3s ,
433- filter 0.3s ,
434- margin 0.3s ,
435- padding 0.3s !important ;
434+ .yrc-word :lang (ja ) {
435+ font-family : var (--ja-font-family );
436+ }
437+ .yrc-word :lang (en ) {
438+ font-family : var (--en-font-family );
439+ }
440+ .yrc-word :lang (ko ) {
441+ font-family : var (--ko-font-family );
436442 }
437443 & .end-with-space {
438444 margin-right : 12px ;
439445 & :last-child {
440446 margin-right : 0 ;
441447 }
442448 }
443- & .content-long {
444- .filler {
445- margin : -40px ;
446- padding : 40px ;
447- filter : drop-shadow (0px 0px 14px rgba (255 , 255 , 255 , 0.6 ));
448- }
449- }
450449 }
451450 & :lang (ja ) {
452451 font-family : var (--ja-font-family );
@@ -522,14 +521,6 @@ onBeforeUnmount(() => {
522521 & .on {
523522 opacity : 1 !important ;
524523 transform : scale (1 );
525- .content-text {
526- .filler {
527- opacity : 1 ;
528- -webkit-mask-position-x : 0% ;
529- transition-property : -webkit-mask-position-x , transform , opacity ;
530- transition-timing-function : linear , ease , ease ;
531- }
532- }
533524 .tran ,
534525 .roma {
535526 opacity : 0.6 ;
@@ -633,6 +624,34 @@ onBeforeUnmount(() => {
633624 filter : blur (0 ) !important ;
634625 }
635626 }
627+
628+ /* 逐字歌词:动画模式仅对激活行启用 mask */
629+ & .yrc-anim {
630+ .lrc-line.is-yrc.on {
631+ .content-text {
632+ .yrc-word {
633+ /* 亮/暗由 mask alpha 控制;opacity 用于行尾渐隐到暗态 */
634+ will-change : -webkit-mask-position-x ;
635+ mask-image : linear-gradient (
636+ to right ,
637+ rgba (0 , 0 , 0 , var (--yrc-bright-alpha , 1 )) 45.4545454545% ,
638+ rgba (0 , 0 , 0 , var (--yrc-dark-alpha , 0.3 )) 54.5454545455%
639+ );
640+ mask-size : 220% 100% ;
641+ mask-repeat : no-repeat ;
642+ -webkit-mask-image : linear-gradient (
643+ to right ,
644+ rgba (0 , 0 , 0 , var (--yrc-bright-alpha , 1 )) 45.4545454545% ,
645+ rgba (0 , 0 , 0 , var (--yrc-dark-alpha , 0.3 )) 54.5454545455%
646+ );
647+ -webkit-mask-size : 220% 100% ;
648+ -webkit-mask-repeat : no-repeat ;
649+ -webkit-mask-position-x : var (--yrc-mask-x , 0% );
650+ transition : none ;
651+ }
652+ }
653+ }
654+ }
636655}
637656 </style >
638657
0 commit comments