Skip to content

Commit 8a11f72

Browse files
committed
🌈 style: 优化默认歌词样式
1 parent 7d08758 commit 8a11f72

3 files changed

Lines changed: 131 additions & 127 deletions

File tree

src/components/Player/MainLyric.vue

Lines changed: 131 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
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"
@@ -54,9 +54,7 @@
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
},
@@ -78,21 +76,11 @@
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+
*/
291304
const jumpSeek = (time: number) => {
292305
if (!time) return;
293306
lrcMouseStatus.value = false;
@@ -299,7 +312,14 @@ const jumpSeek = (time: number) => {
299312
// 监听歌词滚动
300313
watch(
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
305325
onMounted(() => {
@@ -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

src/components/Setting/LyricsSetting.vue

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,18 +260,6 @@
260260
class="set"
261261
/>
262262
</n-card>
263-
<n-card class="set-item">
264-
<div class="label">
265-
<n-text class="name">显示长音发光效果</n-text>
266-
<n-text class="tip" :depth="3"> 当单词持续时间过长时显示发光效果 </n-text>
267-
</div>
268-
<n-switch
269-
v-model:value="settingStore.showYrcLongEffect"
270-
:disabled="settingStore.useAMLyrics || !settingStore.showYrcAnimation"
271-
:round="false"
272-
class="set"
273-
/>
274-
</n-card>
275263
</n-collapse-transition>
276264
<n-card class="set-item">
277265
<div class="label">

src/stores/setting.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,6 @@ export interface SettingState {
6363
showYrc: boolean;
6464
/** 显示逐字歌词动画 */
6565
showYrcAnimation: boolean;
66-
/** 显示逐字歌词长音发光效果 */
67-
showYrcLongEffect: boolean;
6866
/** 显示歌词翻译 */
6967
showTran: boolean;
7068
/** 显示歌词音译 */
@@ -338,7 +336,6 @@ export const useSettingStore = defineStore("setting", {
338336
amllDbServer: defaultAMLLDbServer,
339337
showYrc: true,
340338
showYrcAnimation: true,
341-
showYrcLongEffect: true,
342339
showTran: true,
343340
showRoma: true,
344341
lyricsPosition: "flex-start",

0 commit comments

Comments
 (0)