🦄 refactor: 基础适配新格式

This commit is contained in:
imsyy
2025-11-18 00:16:59 +08:00
parent c9f3553806
commit 772f6552e7
12 changed files with 874 additions and 801 deletions

2
components.d.ts vendored
View File

@@ -100,9 +100,11 @@ declare module 'vue' {
NP: typeof import('naive-ui')['NP'] NP: typeof import('naive-ui')['NP']
NPopconfirm: typeof import('naive-ui')['NPopconfirm'] NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover'] NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode'] NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio'] NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup'] NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
NScrollbar: typeof import('naive-ui')['NScrollbar'] NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect'] NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton'] NSkeleton: typeof import('naive-ui')['NSkeleton']

View File

@@ -31,7 +31,7 @@
'player-content', 'player-content',
{ {
pure: statusStore.pureLyricMode && musicStore.isHasLrc, pure: statusStore.pureLyricMode && musicStore.isHasLrc,
'no-lrc': !musicStore.isHasLrc, 'no-lrc': !musicStore.isHasLrc || (!musicStore.isHasLrc && !statusStore.lyricLoading),
}, },
]" ]"
@mousemove="playerMove" @mousemove="playerMove"
@@ -114,7 +114,8 @@ const instantLyrics = computed(() => {
const content = isYrc const content = isYrc
? musicStore.songLyric.yrcData[statusStore.lyricIndex] ? musicStore.songLyric.yrcData[statusStore.lyricIndex]
: musicStore.songLyric.lrcData[statusStore.lyricIndex]; : musicStore.songLyric.lrcData[statusStore.lyricIndex];
return { content: content?.content, tran: settingStore.showTran && content?.tran }; const contentStr = content?.words?.map((v) => v.word).join("") || "";
return { content: contentStr, tran: settingStore.showTran && content?.translatedLyric };
}); });
// 隐藏播放元素 // 隐藏播放元素

View File

@@ -72,8 +72,8 @@ const amLyricsData = computed<LyricLine[]>(() => {
if (!songLyric) return []; if (!songLyric) return [];
// 优先使用逐字歌词(YRC/TTML) // 优先使用逐字歌词(YRC/TTML)
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc; const useYrc = songLyric.yrcData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData; const lyrics = useYrc ? songLyric.yrcData : songLyric.lrcData;
// 简单检查歌词有效性 // 简单检查歌词有效性
if (!Array.isArray(lyrics) || lyrics.length === 0) return []; if (!Array.isArray(lyrics) || lyrics.length === 0) return [];

View File

@@ -21,7 +21,7 @@
> >
<Transition name="fade" mode="out-in"> <Transition name="fade" mode="out-in">
<div <div
:key="musicStore.songLyric.lrcData?.[0]?.content" :key="musicStore.songLyric.lrcData?.[0]?.words?.[0]?.word"
class="lyric-content" class="lyric-content"
@after-enter="lyricsScroll(statusStore.lyricIndex)" @after-enter="lyricsScroll(statusStore.lyricIndex)"
@after-leave="lyricsScroll(statusStore.lyricIndex)" @after-leave="lyricsScroll(statusStore.lyricIndex)"
@@ -34,7 +34,7 @@
<!-- 倒计时 --> <!-- 倒计时 -->
<CountDown <CountDown
:start="0" :start="0"
:duration="musicStore.songLyric.yrcData[0].time || 0" :duration="(musicStore.songLyric.yrcData[0].startTime || 0) / 1000"
:seek="playSeek" :seek="playSeek"
:playing="statusStore.playStatus" :playing="statusStore.playStatus"
/> />
@@ -50,7 +50,7 @@
// on: statusStore.lyricIndex === index, // on: statusStore.lyricIndex === index,
// 当播放时间大于等于当前歌词的开始时间 // 当播放时间大于等于当前歌词的开始时间
on: on:
(playSeek >= item.time && playSeek < item.endTime) || (playSeek >= item.startTime / 1000 && playSeek < item.endTime / 1000) ||
statusStore.lyricIndex === index, statusStore.lyricIndex === index,
'is-bg': item.isBG, 'is-bg': item.isBG,
'is-duet': item.isDuet, 'is-duet': item.isDuet,
@@ -58,60 +58,65 @@
]" ]"
:style="{ :style="{
filter: settingStore.lyricsBlur filter: settingStore.lyricsBlur
? (playSeek >= item.time && playSeek < item.endTime) || ? (playSeek >= item.startTime / 1000 && playSeek < item.endTime / 1000) ||
statusStore.lyricIndex === index statusStore.lyricIndex === index
? 'blur(0)' ? 'blur(0)'
: `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)` : `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)`
: 'blur(0)', : 'blur(0)',
}" }"
@click="jumpSeek(item.time)" @click="jumpSeek(item.startTime)"
> >
<!-- 歌词 --> <!-- 歌词 -->
<div class="content"> <div class="content">
<div <div
v-for="(text, textIndex) in item.contents" v-for="(text, textIndex) in item.words"
:key="textIndex" :key="textIndex"
:class="{ :class="{
'content-text': true, 'content-text': true,
'content-long': 'content-long':
settingStore.showYrcLongEffect && settingStore.showYrcLongEffect &&
text.duration >= 1.5 && (text.endTime - item.startTime) / 1000 >= 1.5 &&
playSeek <= text.endTime, playSeek <= text.endTime / 1000,
'end-with-space': text.endsWithSpace, 'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
}" }"
> >
<span class="word" :lang="getLyricLanguage(text.content)"> <span class="word" :lang="getLyricLanguage(text.word)">
{{ text.content }} {{ text.word }}
</span> </span>
<span <span
class="filler" class="filler"
:style="getYrcStyle(text, index)" :style="getYrcStyle(text, index)"
:lang="getLyricLanguage(text.content)" :lang="getLyricLanguage(text.word)"
> >
{{ text.content }} {{ text.word }}
</span> </span>
</div> </div>
</div> </div>
<!-- 翻译 --> <!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en"> <span v-if="item.translatedLyric && settingStore.showTran" class="tran" lang="en">
{{ item.tran }} {{ item.translatedLyric }}
</span> </span>
<!-- 音译 --> <!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en"> <span v-if="item.romanLyric && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }} {{ item.romanLyric }}
</span> </span>
<!-- 间奏倒计时 --> <!-- 间奏倒计时 -->
<div <div
v-if=" v-if="
settingStore.countDownShow && settingStore.countDownShow &&
item.time > 0 && item.startTime > 0 &&
musicStore.songLyric.yrcData[index + 1]?.time - item.endTime >= 10 ((musicStore.songLyric.yrcData[index + 1]?.startTime || 0) - item.endTime) /
1000 >=
10
" "
class="count-down-content" class="count-down-content"
> >
<CountDown <CountDown
:start="item.endTime" :start="item.endTime / 1000"
:duration="musicStore.songLyric.yrcData[index + 1]?.time - item.endTime || 0" :duration="
((musicStore.songLyric.yrcData[index + 1]?.startTime || 0) - item.endTime) /
1000
"
:seek="playSeek" :seek="playSeek"
:playing="statusStore.playStatus" :playing="statusStore.playStatus"
/> />
@@ -125,7 +130,7 @@
<!-- 倒计时 --> <!-- 倒计时 -->
<CountDown <CountDown
:start="0" :start="0"
:duration="musicStore.songLyric.lrcData[0].time || 0" :duration="(musicStore.songLyric.lrcData[0].startTime || 0) / 1000"
:seek="playSeek" :seek="playSeek"
:playing="statusStore.playStatus" :playing="statusStore.playStatus"
/> />
@@ -140,17 +145,19 @@
? `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)` ? `blur(${Math.min(Math.abs(statusStore.lyricIndex - index) * 1.8, 10)}px)`
: 'blur(0)', : 'blur(0)',
}" }"
@click="jumpSeek(item.time)" @click="jumpSeek(item.startTime)"
> >
<!-- 歌词 --> <!-- 歌词 -->
<span class="content" :lang="getLyricLanguage(item.content)">{{ item.content }}</span> <span class="content" :lang="getLyricLanguage(item.words?.[0]?.word)">
{{ item.words?.[0]?.word }}
</span>
<!-- 翻译 --> <!-- 翻译 -->
<span v-if="item.tran && settingStore.showTran" class="tran" lang="en"> <span v-if="item.translatedLyric && settingStore.showTran" class="tran" lang="en">
{{ item.tran }} {{ item.translatedLyric }}
</span> </span>
<!-- 音译 --> <!-- 音译 -->
<span v-if="item.roma && settingStore.showRoma" class="roma" lang="en"> <span v-if="item.romanLyric && settingStore.showRoma" class="roma" lang="en">
{{ item.roma }} {{ item.romanLyric }}
</span> </span>
</div> </div>
<div class="placeholder" /> <div class="placeholder" />
@@ -164,7 +171,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { LyricContentType } from "@/types/main"; // import type { LyricContentType } from "@/types/main";
import { LyricWord } from "@applemusic-like-lyrics/lyric";
import { NScrollbar } from "naive-ui"; import { NScrollbar } from "naive-ui";
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores"; import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import player from "@/utils/player"; import player from "@/utils/player";
@@ -222,7 +230,7 @@ const INACTIVE_NO_ANIMATION_STYLE = { opacity: 0 } as const;
* @param lyricIndex 歌词索引 * @param lyricIndex 歌词索引
* @returns 逐字歌词动画样式 * @returns 逐字歌词动画样式
*/ */
const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => { const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
// 获取当前歌词行数据 // 获取当前歌词行数据
const currentLine = musicStore.songLyric.yrcData[lyricIndex]; const currentLine = musicStore.songLyric.yrcData[lyricIndex];
// 缓存 playSeek 值,避免多次访问响应式变量 // 缓存 playSeek 值,避免多次访问响应式变量
@@ -230,14 +238,14 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
// 判断当前行是否处于激活状态 // 判断当前行是否处于激活状态
const isLineActive = const isLineActive =
(currentSeek >= currentLine.time && currentSeek < currentLine.endTime) || (currentSeek >= currentLine.startTime / 1000 && currentSeek < currentLine.endTime / 1000) ||
statusStore.lyricIndex === lyricIndex; statusStore.lyricIndex === lyricIndex;
// 如果当前歌词行不是激活状态,返回固定样式,避免不必要的计算 // 如果当前歌词行不是激活状态,返回固定样式,避免不必要的计算
if (!isLineActive) { if (!isLineActive) {
if (settingStore.showYrcAnimation) { if (settingStore.showYrcAnimation) {
// 判断单词是否已经唱过:已唱过保持填充状态(0%),未唱到保持未填充状态(100%) // 判断单词是否已经唱过:已唱过保持填充状态(0%),未唱到保持未填充状态(100%)
const hasPlayed = currentSeek >= wordData.time + wordData.duration; const hasPlayed = currentSeek >= wordData.endTime / 1000;
return { return {
WebkitMaskPositionX: hasPlayed ? "0%" : "100%", WebkitMaskPositionX: hasPlayed ? "0%" : "100%",
}; };
@@ -249,24 +257,30 @@ const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => {
// 激活状态的样式计算 // 激活状态的样式计算
if (settingStore.showYrcAnimation) { if (settingStore.showYrcAnimation) {
// 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0 // 如果播放状态不是加载中,且当前单词的时间加上持续时间减去播放进度大于 0
if (statusStore.playLoading === false && wordData.time + wordData.duration - currentSeek > 0) { if (statusStore.playLoading === false && wordData.endTime / 1000 - currentSeek > 0) {
return { return {
transitionDuration: `0s, 0s, 0.35s`, transitionDuration: `0s, 0s, 0.35s`,
transitionDelay: `0ms`, transitionDelay: `0ms`,
WebkitMaskPositionX: `${ WebkitMaskPositionX: `${
100 - Math.max(((currentSeek - wordData.time) / wordData.duration) * 100, 0) 100 -
Math.max(
((currentSeek - wordData.startTime / 1000) /
((wordData.endTime - wordData.startTime) / 1000)) *
100,
0,
)
}%`, }%`,
}; };
} }
// 预计算时间差,避免重复计算 // 预计算时间差,避免重复计算
const timeDiff = wordData.time - currentSeek; const timeDiff = wordData.startTime - currentSeek * 1000;
return { return {
transitionDuration: `${wordData.duration}ms, ${wordData.duration * 0.8}ms, 0.35s`, transitionDuration: `${wordData.endTime - wordData.startTime}ms, ${(wordData.endTime - wordData.startTime) * 0.8}ms, 0.35s`,
transitionDelay: `${timeDiff}ms, ${timeDiff + wordData.duration * 0.5}ms, 0ms`, transitionDelay: `${timeDiff}ms, ${timeDiff + (wordData.endTime - wordData.startTime) * 0.5}ms, 0ms`,
}; };
} else { } else {
// 无动画模式:根据单词时间判断透明度 // 无动画模式:根据单词时间判断透明度
return statusStore.playLoading === false && wordData.time >= currentSeek return statusStore.playLoading === false && wordData.startTime / 1000 >= currentSeek
? { opacity: 0 } ? { opacity: 0 }
: { opacity: 1 }; : { opacity: 1 };
} }
@@ -277,7 +291,7 @@ const jumpSeek = (time: number) => {
if (!time) return; if (!time) return;
lrcMouseStatus.value = false; lrcMouseStatus.value = false;
const offsetSeconds = statusStore.getSongOffset(musicStore.playSong?.id); const offsetSeconds = statusStore.getSongOffset(musicStore.playSong?.id);
player.setSeek(time - offsetSeconds); player.setSeek(time / 1000 - offsetSeconds);
player.play(); player.play();
}; };
@@ -411,7 +425,7 @@ onBeforeUnmount(() => {
opacity 0.3s, opacity 0.3s,
filter 0.3s, filter 0.3s,
margin 0.3s, margin 0.3s,
padding 0.3s !important; padding 0.3s;
} }
&.end-with-space { &.end-with-space {
margin-right: 12px; margin-right: 12px;

View File

@@ -275,9 +275,10 @@ const instantLyrics = computed(() => {
const content = isYrc const content = isYrc
? musicStore.songLyric.yrcData[statusStore.lyricIndex] ? musicStore.songLyric.yrcData[statusStore.lyricIndex]
: musicStore.songLyric.lrcData[statusStore.lyricIndex]; : musicStore.songLyric.lrcData[statusStore.lyricIndex];
return content?.tran && settingStore.showTran const contentStr = content?.words?.map((v) => v.word).join("") || "";
? `${content?.content} ${content?.tran} ` return content?.translatedLyric && settingStore.showTran
: content?.content; ? `${contentStr} ${content?.translatedLyric} `
: contentStr || "";
}); });
</script> </script>

View File

@@ -1,18 +1,13 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import type { LyricLine } from "@applemusic-like-lyrics/core"; import type { SongType } from "@/types/main";
import type { SongType, LyricType } from "@/types/main";
import { isElectron } from "@/utils/env"; import { isElectron } from "@/utils/env";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { SongLyric } from "@/types/lyric";
interface MusicState { interface MusicState {
playSong: SongType; playSong: SongType;
playPlaylistId: number; playPlaylistId: number;
songLyric: { songLyric: SongLyric;
lrcData: LyricType[];
yrcData: LyricType[];
lrcAMData: LyricLine[];
yrcAMData: LyricLine[];
};
personalFM: { personalFM: {
playIndex: number; playIndex: number;
list: SongType[]; list: SongType[];
@@ -46,8 +41,6 @@ export const useMusicStore = defineStore("music", {
songLyric: { songLyric: {
lrcData: [], // 普通歌词 lrcData: [], // 普通歌词
yrcData: [], // 逐字歌词 yrcData: [], // 逐字歌词
lrcAMData: [], // 普通歌词-AM
yrcAMData: [], // 逐字歌词-AM
}, },
// 私人FM数据 // 私人FM数据
personalFM: { personalFM: {
@@ -88,32 +81,23 @@ export const useMusicStore = defineStore("music", {
// 恢复默认音乐数据 // 恢复默认音乐数据
resetMusicData() { resetMusicData() {
this.playSong = { ...defaultMusicData }; this.playSong = { ...defaultMusicData };
this.songLyric = { this.songLyric = { lrcData: [], yrcData: [] };
lrcData: [],
yrcData: [],
lrcAMData: [],
yrcAMData: [],
};
}, },
/** /**
* 设置/更新歌曲歌词数据 * 设置/更新歌曲歌词数据
* @param updates 部分或完整歌词数据 * @param updates 部分或完整歌词数据
* @param replace 是否覆盖true用提供的数据覆盖并为缺省字段置空false合并更新 * @param replace 是否覆盖true用提供的数据覆盖并为缺省字段置空false合并更新
*/ */
setSongLyric(updates: Partial<MusicState["songLyric"]>, replace: boolean = false) { setSongLyric(updates: Partial<SongLyric>, replace: boolean = false) {
if (replace) { if (replace) {
this.songLyric = { this.songLyric = {
lrcData: updates.lrcData ?? [], lrcData: updates.lrcData ?? [],
yrcData: updates.yrcData ?? [], yrcData: updates.yrcData ?? [],
lrcAMData: updates.lrcAMData ?? [],
yrcAMData: updates.yrcAMData ?? [],
}; };
} else { } else {
this.songLyric = { this.songLyric = {
lrcData: updates.lrcData ?? this.songLyric.lrcData, lrcData: updates.lrcData ?? this.songLyric.lrcData,
yrcData: updates.yrcData ?? this.songLyric.yrcData, yrcData: updates.yrcData ?? this.songLyric.yrcData,
lrcAMData: updates.lrcAMData ?? this.songLyric.lrcAMData,
yrcAMData: updates.yrcAMData ?? this.songLyric.yrcAMData,
}; };
} }
// 更新歌词窗口数据 // 更新歌词窗口数据

View File

@@ -1,4 +1,4 @@
import { LyricType } from "@/types/main"; import { type LyricLine } from "@applemusic-like-lyrics/lyric";
/** 桌面歌词数据 */ /** 桌面歌词数据 */
export interface LyricData { export interface LyricData {
@@ -13,8 +13,8 @@ export interface LyricData {
/** 当前歌曲的时间偏移(秒,正负均可) */ /** 当前歌曲的时间偏移(秒,正负均可) */
songOffset?: number; songOffset?: number;
/** 歌词数据 */ /** 歌词数据 */
lrcData?: LyricType[]; lrcData?: LyricLine[];
yrcData?: LyricType[]; yrcData?: LyricLine[];
/** 歌词播放索引 */ /** 歌词播放索引 */
lyricIndex?: number; lyricIndex?: number;
} }
@@ -52,7 +52,7 @@ export interface LyricConfig {
*/ */
export interface RenderLine { export interface RenderLine {
/** 当前整行歌词数据(用于逐字渲染) */ /** 当前整行歌词数据(用于逐字渲染) */
line: LyricType; line: LyricLine;
/** 当前行在歌词数组中的索引 */ /** 当前行在歌词数组中的索引 */
index: number; index: number;
/** 唯一键 */ /** 唯一键 */

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,13 @@
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores"; import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
import { songLyric, songLyricTTML } from "@/api/song"; import { songLyric, songLyricTTML } from "@/api/song";
import { type SongLyric } from "@/types/lyric"; import { type SongLyric } from "@/types/lyric";
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric"; import {
type LyricLine,
LyricWord,
parseLrc,
parseTTML,
parseYrc,
} from "@applemusic-like-lyrics/lyric";
import { isElectron } from "./env"; import { isElectron } from "./env";
import { isEmpty } from "lodash-es"; import { isEmpty } from "lodash-es";
@@ -104,8 +110,7 @@ class LyricManager {
const settingStore = useSettingStore(); const settingStore = useSettingStore();
// 请求序列 // 请求序列
const req = this.activeLyricReq; const req = this.activeLyricReq;
// 请求是否成功 // 最终结果
let adopted = false;
let result: SongLyric = { lrcData: [], yrcData: [] }; let result: SongLyric = { lrcData: [], yrcData: [] };
// 过期判断 // 过期判断
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id; const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
@@ -119,8 +124,7 @@ class LyricManager {
const parsed = parseTTML(ttmlContent); const parsed = parseTTML(ttmlContent);
const lines = parsed?.lines || []; const lines = parsed?.lines || [];
if (!lines.length) return; if (!lines.length) return;
result = { lrcData: [], yrcData: lines }; result.yrcData = lines;
adopted = true;
} catch { } catch {
/* empty */ /* empty */
} }
@@ -153,9 +157,11 @@ class LyricManager {
if (data?.yromalrc?.lyric) if (data?.yromalrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric"); yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
} }
if (adopted) return; if (lrcLines.length) result.lrcData = lrcLines;
result = { lrcData: lrcLines, yrcData: yrcLines }; // 如果没有 TTML则采用 网易云 YRC
adopted = true; if (!result.yrcData.length && yrcLines.length) {
result.yrcData = yrcLines;
}
} catch { } catch {
/* empty */ /* empty */
} }
@@ -183,7 +189,19 @@ class LyricManager {
const ttml = parseTTML(lyric); const ttml = parseTTML(lyric);
const lines = ttml?.lines || []; const lines = ttml?.lines || [];
statusStore.usingTTMLLyric = true; statusStore.usingTTMLLyric = true;
return { lrcData: [], yrcData: lines }; // 构成普通歌词
const lrcLines: LyricLine[] = lines.map((line) => ({
...line,
words: [
{
word: line.words?.map((w) => w.word)?.join("") || "",
startTime: line.startTime || 0,
endTime: line.endTime || 0,
romanWord: line.words?.map((w) => w.romanWord)?.join("") || "",
},
] as LyricWord[],
}));
return { lrcData: lrcLines, yrcData: lines };
} }
// 解析本地歌词并对其 // 解析本地歌词并对其
const lrcLines = parseLrc(lyric); const lrcLines = parseLrc(lyric);
@@ -200,7 +218,6 @@ class LyricManager {
* @returns 歌词数据 * @returns 歌词数据
*/ */
private async checkLocalLyricOverride(id: number): Promise<SongLyric> { private async checkLocalLyricOverride(id: number): Promise<SongLyric> {
console.log("检测本地歌词覆盖", id);
const statusStore = useStatusStore(); const statusStore = useStatusStore();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const { localLyricPath } = settingStore; const { localLyricPath } = settingStore;
@@ -222,6 +239,7 @@ class LyricManager {
const lrcContent = typeof lrc === "string" ? lrc : ""; const lrcContent = typeof lrc === "string" ? lrc : "";
if (lrcContent) { if (lrcContent) {
lrcLines = parseLrc(lrcContent); lrcLines = parseLrc(lrcContent);
console.log("检测到本地歌词覆盖", lrcLines);
} }
} catch (err) { } catch (err) {
console.error("parseLrc 本地解析失败:", err); console.error("parseLrc 本地解析失败:", err);
@@ -232,6 +250,7 @@ class LyricManager {
const ttmlContent = typeof ttml === "string" ? ttml : ""; const ttmlContent = typeof ttml === "string" ? ttml : "";
if (ttmlContent) { if (ttmlContent) {
ttmlLines = parseTTML(ttmlContent).lines || []; ttmlLines = parseTTML(ttmlContent).lines || [];
console.log("检测到本地TTML歌词覆盖", ttmlLines);
} }
} catch (err) { } catch (err) {
console.error("parseTTML 本地解析失败:", err); console.error("parseTTML 本地解析失败:", err);
@@ -295,14 +314,10 @@ class LyricManager {
try { try {
// 歌词加载状态 // 歌词加载状态
statusStore.lyricLoading = true; statusStore.lyricLoading = true;
// 重置歌词
this.resetSongLyric();
// 标记当前歌词请求(避免旧请求覆盖新请求) // 标记当前歌词请求(避免旧请求覆盖新请求)
this.activeLyricReq = ++this.lyricReqSeq; this.activeLyricReq = ++this.lyricReqSeq;
// 检查歌词覆盖 // 检查歌词覆盖
let lyricData = await this.checkLocalLyricOverride(id); let lyricData = await this.checkLocalLyricOverride(id);
console.log("本地歌词覆盖", lyricData);
// 开始获取歌词 // 开始获取歌词
if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) { if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) {
// 进行本地歌词对齐 // 进行本地歌词对齐
@@ -314,8 +329,13 @@ class LyricManager {
} }
// 排除内容 // 排除内容
lyricData = this.handleLyricExclude(lyricData); lyricData = this.handleLyricExclude(lyricData);
// 设置歌词
musicStore.setSongLyric(lyricData, true);
console.log("最终歌词数据", lyricData); console.log("最终歌词数据", lyricData);
} catch (error) { } catch (error) {
console.error("❌ 处理歌词失败:", error);
// 重置歌词
this.resetSongLyric();
} finally { } finally {
// 歌词加载状态 // 歌词加载状态
if (musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) { if (musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {

View File

@@ -1,173 +1,173 @@
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores"; // import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
import { // import {
parsedLyricsData, // parsedLyricsData,
parseLocalLyric, // parseLocalLyric,
parseTTMLToAMLL, // parseTTMLToAMLL,
parseTTMLToYrc, // parseTTMLToYrc,
resetSongLyric, // resetSongLyric,
} from "../lyric"; // } from "../lyric";
import { songLyric, songLyricTTML } from "@/api/song"; // import { songLyric, songLyricTTML } from "@/api/song";
import { parseTTML } from "@applemusic-like-lyrics/lyric"; // import { parseTTML } from "@applemusic-like-lyrics/lyric";
import { LyricLine } from "@applemusic-like-lyrics/core"; // import { LyricLine } from "@applemusic-like-lyrics/core";
import { LyricType } from "@/types/main"; // import { LyricType } from "@/types/main";
/** // /**
* 获取歌词 // * 获取歌词
* @param id 歌曲id // * @param id 歌曲id
*/ // */
export const getLyricData = async (id: number) => { // export const getLyricData = async (id: number) => {
const musicStore = useMusicStore(); // const musicStore = useMusicStore();
const settingStore = useSettingStore(); // const settingStore = useSettingStore();
const statusStore = useStatusStore(); // const statusStore = useStatusStore();
// 切歌或重新获取时,先标记为加载中 // // 切歌或重新获取时,先标记为加载中
statusStore.lyricLoading = true; // statusStore.lyricLoading = true;
if (!id) { // if (!id) {
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
resetSongLyric(); // resetSongLyric();
statusStore.lyricLoading = false; // statusStore.lyricLoading = false;
return; // return;
} // }
try { // try {
// 检测本地歌词覆盖 // // 检测本地歌词覆盖
const getLyric = getLyricFun(settingStore.localLyricPath, id); // const getLyric = getLyricFun(settingStore.localLyricPath, id);
// 并发请求:如果 TTML 先到并且有效,则直接采用 TTML不再等待或覆盖为 LRC // // 并发请求:如果 TTML 先到并且有效,则直接采用 TTML不再等待或覆盖为 LRC
const lrcPromise = getLyric("lrc", songLyric); // const lrcPromise = getLyric("lrc", songLyric);
// 这里的第二个 getLyric 方法不传入第二个参数(在线获取函数)表明不进行在线获取,仅获取本地 // // 这里的第二个 getLyric 方法不传入第二个参数(在线获取函数)表明不进行在线获取,仅获取本地
const ttmlPromise = settingStore.enableTTMLLyric // const ttmlPromise = settingStore.enableTTMLLyric
? getLyric("ttml", songLyricTTML) // ? getLyric("ttml", songLyricTTML)
: getLyric("ttml"); // : getLyric("ttml");
let settled = false; // 是否已采用某一种歌词并结束加载状态 // let settled = false; // 是否已采用某一种歌词并结束加载状态
let ttmlAdopted = false; // 是否已采用 TTML // let ttmlAdopted = false; // 是否已采用 TTML
const adoptTTML = async () => { // const adoptTTML = async () => {
if (!ttmlPromise) { // if (!ttmlPromise) {
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
return; // return;
} // }
try { // try {
const { lyric: ttmlContent, isLocal: ttmlLocal } = await ttmlPromise; // const { lyric: ttmlContent, isLocal: ttmlLocal } = await ttmlPromise;
if (!ttmlContent) { // if (!ttmlContent) {
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
return; // return;
} // }
// 本地 TTML 使用 parseLocalLyric在线 TTML 使用原有解析方式 // // 本地 TTML 使用 parseLocalLyric在线 TTML 使用原有解析方式
if (ttmlLocal) { // if (ttmlLocal) {
parseLocalLyric(ttmlContent, "ttml"); // parseLocalLyric(ttmlContent, "ttml");
statusStore.usingTTMLLyric = true; // statusStore.usingTTMLLyric = true;
ttmlAdopted = true; // ttmlAdopted = true;
if (!settled) { // if (!settled) {
statusStore.lyricLoading = false; // statusStore.lyricLoading = false;
settled = true; // settled = true;
} // }
console.log("✅ TTML lyrics adopted (prefer TTML)"); // console.log("✅ TTML lyrics adopted (prefer TTML)");
return; // return;
} // }
// 在线 TTML 解析 // // 在线 TTML 解析
const parsedResult = parseTTML(ttmlContent); // const parsedResult = parseTTML(ttmlContent);
if (!parsedResult?.lines?.length) { // if (!parsedResult?.lines?.length) {
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
return; // return;
} // }
const skipExcludeLocal = ttmlLocal && !settingStore.enableExcludeLocalLyrics; // const skipExcludeLocal = ttmlLocal && !settingStore.enableExcludeLocalLyrics;
const skipExcludeTTML = !settingStore.enableExcludeTTML; // const skipExcludeTTML = !settingStore.enableExcludeTTML;
const skipExclude = skipExcludeLocal || skipExcludeTTML; // const skipExclude = skipExcludeLocal || skipExcludeTTML;
const ttmlLyric = parseTTMLToAMLL(parsedResult, skipExclude); // const ttmlLyric = parseTTMLToAMLL(parsedResult, skipExclude);
const ttmlYrcLyric = parseTTMLToYrc(parsedResult, skipExclude); // const ttmlYrcLyric = parseTTMLToYrc(parsedResult, skipExclude);
const updates: Partial<{ // const updates: Partial<{
yrcAMData: LyricLine[]; // yrcAMData: LyricLine[];
yrcData: LyricType[]; // yrcData: LyricType[];
lrcData: LyricType[]; // lrcData: LyricType[];
lrcAMData: LyricLine[]; // lrcAMData: LyricLine[];
}> = {}; // }> = {};
if (ttmlLyric?.length) { // if (ttmlLyric?.length) {
updates.yrcAMData = ttmlLyric; // updates.yrcAMData = ttmlLyric;
// 若当前无 LRC-AM 数据,使用 TTML-AM 作为回退 // // 若当前无 LRC-AM 数据,使用 TTML-AM 作为回退
if (!musicStore.songLyric.lrcAMData?.length) { // if (!musicStore.songLyric.lrcAMData?.length) {
updates.lrcAMData = ttmlLyric; // updates.lrcAMData = ttmlLyric;
} // }
} // }
if (ttmlYrcLyric?.length) { // if (ttmlYrcLyric?.length) {
updates.yrcData = ttmlYrcLyric; // updates.yrcData = ttmlYrcLyric;
// 若当前无 LRC 数据,使用 TTML 行级数据作为回退 // // 若当前无 LRC 数据,使用 TTML 行级数据作为回退
if (!musicStore.songLyric.lrcData?.length) { // if (!musicStore.songLyric.lrcData?.length) {
updates.lrcData = ttmlYrcLyric; // updates.lrcData = ttmlYrcLyric;
} // }
} // }
if (Object.keys(updates).length) { // if (Object.keys(updates).length) {
musicStore.setSongLyric(updates); // musicStore.setSongLyric(updates);
statusStore.usingTTMLLyric = true; // statusStore.usingTTMLLyric = true;
ttmlAdopted = true; // ttmlAdopted = true;
if (!settled) { // if (!settled) {
statusStore.lyricLoading = false; // statusStore.lyricLoading = false;
settled = true; // settled = true;
} // }
console.log("✅ TTML lyrics adopted (prefer TTML)"); // console.log("✅ TTML lyrics adopted (prefer TTML)");
} else { // } else {
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
} // }
} catch (err) { // } catch (err) {
console.error("❌ Error loading TTML lyrics:", err); // console.error("❌ Error loading TTML lyrics:", err);
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
} // }
}; // };
const adoptLRC = async () => { // const adoptLRC = async () => {
try { // try {
const { lyric: lyricRes, isLocal: lyricLocal } = await lrcPromise; // const { lyric: lyricRes, isLocal: lyricLocal } = await lrcPromise;
// 如果 TTML 已采用,则忽略 LRC // // 如果 TTML 已采用,则忽略 LRC
if (ttmlAdopted) return; // if (ttmlAdopted) return;
// 如果没有歌词内容,直接返回 // // 如果没有歌词内容,直接返回
if (!lyricRes) return; // if (!lyricRes) return;
// 本地歌词使用 parseLocalLyric在线歌词使用 parsedLyricsData // // 本地歌词使用 parseLocalLyric在线歌词使用 parsedLyricsData
if (lyricLocal) { // if (lyricLocal) {
parseLocalLyric(lyricRes, "lrc"); // parseLocalLyric(lyricRes, "lrc");
} else { // } else {
parsedLyricsData(lyricRes, !settingStore.enableExcludeLocalLyrics); // parsedLyricsData(lyricRes, !settingStore.enableExcludeLocalLyrics);
} // }
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
if (!settled) { // if (!settled) {
statusStore.lyricLoading = false; // statusStore.lyricLoading = false;
settled = true; // settled = true;
} // }
console.log("✅ LRC lyrics adopted"); // console.log("✅ LRC lyrics adopted");
} catch (err) { // } catch (err) {
console.error("❌ Error loading LRC lyrics:", err); // console.error("❌ Error loading LRC lyrics:", err);
if (!settled) statusStore.lyricLoading = false; // if (!settled) statusStore.lyricLoading = false;
} // }
}; // };
// 启动并发任务TTML 与 LRC 同时进行,哪个先成功就先用 // // 启动并发任务TTML 与 LRC 同时进行,哪个先成功就先用
void adoptLRC(); // void adoptLRC();
void adoptTTML(); // void adoptTTML();
} catch (error) { // } catch (error) {
console.error("❌ Error loading lyrics:", error); // console.error("❌ Error loading lyrics:", error);
statusStore.usingTTMLLyric = false; // statusStore.usingTTMLLyric = false;
resetSongLyric(); // resetSongLyric();
statusStore.lyricLoading = false; // statusStore.lyricLoading = false;
} // }
}; // };
/** // /**
* 获取歌词函数生成器 // * 获取歌词函数生成器
* @param paths 本地歌词路径数组 // * @param paths 本地歌词路径数组
* @param id 歌曲ID // * @param id 歌曲ID
* @returns 返回一个函数,该函数接受扩展名和在线获取函数作为参数 // * @returns 返回一个函数,该函数接受扩展名和在线获取函数作为参数
*/ // */
const getLyricFun = // const getLyricFun =
(paths: string[], id: number) => // (paths: string[], id: number) =>
async ( // async (
ext: string, // ext: string,
getOnline?: (id: number) => Promise<string | null>, // getOnline?: (id: number) => Promise<string | null>,
): Promise<{ lyric: string | null; isLocal: boolean }> => { // ): Promise<{ lyric: string | null; isLocal: boolean }> => {
for (const path of paths) { // for (const path of paths) {
const lyric = await window.electron.ipcRenderer.invoke("read-local-lyric", path, id, ext); // const lyric = await window.electron.ipcRenderer.invoke("read-local-lyric", path, id, ext);
if (lyric) return { lyric, isLocal: true }; // if (lyric) return { lyric, isLocal: true };
} // }
return { lyric: getOnline ? await getOnline(id) : null, isLocal: false }; // return { lyric: getOnline ? await getOnline(id) : null, isLocal: false };
}; // };

View File

@@ -3,7 +3,8 @@ import type { MessageReactive } from "naive-ui";
import { Howl, Howler } from "howler"; import { Howl, Howler } from "howler";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores"; import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { resetSongLyric, parseLocalLyric, calculateLyricIndex } from "./lyric"; // import { resetSongLyric, parseLocalLyric, calculateLyricIndex } from "./lyric";
import { calculateLyricIndex } from "./lyric";
import { calculateProgress } from "./time"; import { calculateProgress } from "./time";
import { shuffleArray, runIdle } from "./helper"; import { shuffleArray, runIdle } from "./helper";
import { heartRateList } from "@/api/playlist"; import { heartRateList } from "@/api/playlist";
@@ -19,7 +20,7 @@ import {
getUnlockSongUrl, getUnlockSongUrl,
} from "./player-utils/song"; } from "./player-utils/song";
import { isDev, isElectron } from "./env"; import { isDev, isElectron } from "./env";
import { getLyricData } from "./player-utils/lyric"; // import { getLyricData } from "./player-utils/lyric";
import audioContextManager from "@/utils/player-utils/context"; import audioContextManager from "@/utils/player-utils/context";
import lyricManager from "./lyricManager"; import lyricManager from "./lyricManager";
import blob from "./blob"; import blob from "./blob";
@@ -246,9 +247,9 @@ class Player {
if (!settingStore.showSpectrums) this.toggleOutputDevice(); if (!settingStore.showSpectrums) this.toggleOutputDevice();
// 自动播放 // 自动播放
if (autoPlay) await this.play(); if (autoPlay) await this.play();
// 获取歌曲附加信息 - 非电台和本地 // // 获取歌曲附加信息 - 非电台和本地
if (type !== "radio" && !path) getLyricData(id); // if (type !== "radio" && !path) getLyricData(id);
else resetSongLyric(); // else resetSongLyric();
// 获取歌词数据 // 获取歌词数据
lyricManager.handleLyric(id, path); lyricManager.handleLyric(id, path);
// 定时获取状态 // 定时获取状态
@@ -549,8 +550,8 @@ class Player {
// 获取主色 // 获取主色
runIdle(() => getCoverColor(musicStore.playSong.cover)); runIdle(() => getCoverColor(musicStore.playSong.cover));
// 获取歌词数据 // 获取歌词数据
const { lyric, format } = await window.electron.ipcRenderer.invoke("get-music-lyric", path); // const { lyric, format } = await window.electron.ipcRenderer.invoke("get-music-lyric", path);
parseLocalLyric(lyric, format); // parseLocalLyric(lyric, format);
// 更新媒体会话 // 更新媒体会话
this.updateMediaSession(); this.updateMediaSession();
} catch (error) { } catch (error) {

View File

@@ -63,7 +63,7 @@
'lyric-line', 'lyric-line',
{ {
active: line.active, active: line.active,
'is-yrc': Boolean(lyricData?.yrcData?.length && line.line?.contents?.length), 'is-yrc': Boolean(lyricData?.yrcData?.length && line.line?.words?.length > 1),
}, },
]" ]"
:style="{ :style="{
@@ -73,7 +73,7 @@
> >
<!-- 逐字歌词渲染 --> <!-- 逐字歌词渲染 -->
<template <template
v-if="lyricConfig.showYrc && lyricData?.yrcData?.length && line.line?.contents?.length" v-if="lyricConfig.showYrc && lyricData?.yrcData?.length && line.line?.words?.length > 1"
> >
<span <span
class="scroll-content" class="scroll-content"
@@ -82,21 +82,21 @@
> >
<span class="content"> <span class="content">
<span <span
v-for="(text, textIndex) in line.line.contents" v-for="(text, textIndex) in line.line.words"
:key="textIndex" :key="textIndex"
:class="{ :class="{
'content-text': true, 'content-text': true,
'end-with-space': text.endsWithSpace, 'end-with-space': text.word.endsWith(' ') || text.startTime === 0,
}" }"
> >
<span class="word" :style="{ color: lyricConfig.unplayedColor }"> <span class="word" :style="{ color: lyricConfig.unplayedColor }">
{{ text.content }} {{ text.word }}
</span> </span>
<span <span
class="filler" class="filler"
:style="[{ color: lyricConfig.playedColor }, getYrcStyle(text, line.index)]" :style="[{ color: lyricConfig.playedColor }, getYrcStyle(text, line.index)]"
> >
{{ text.content }} {{ text.word }}
</span> </span>
</span> </span>
</span> </span>
@@ -109,7 +109,7 @@
:style="getScrollStyle(line)" :style="getScrollStyle(line)"
:ref="(el) => line.active && (currentContentRef = el as HTMLElement)" :ref="(el) => line.active && (currentContentRef = el as HTMLElement)"
> >
{{ line.line?.content }} {{ line.line?.words?.[0]?.word || "" }}
</span> </span>
</template> </template>
</span> </span>
@@ -122,7 +122,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from "@vueuse/core"; import { useRafFn } from "@vueuse/core";
import { LyricContentType, LyricType } from "@/types/main"; import { LyricLine, LyricWord } from "@applemusic-like-lyrics/lyric";
import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric"; import { LyricConfig, LyricData, RenderLine } from "@/types/desktop-lyric";
import defaultDesktopLyricConfig from "@/assets/data/lyricConfig"; import defaultDesktopLyricConfig from "@/assets/data/lyricConfig";
@@ -192,13 +192,13 @@ const handleMouseMove = () => {
* @param idx 当前行索引 * @param idx 当前行索引
* @returns 安全的结束时间(秒) * @returns 安全的结束时间(秒)
*/ */
const getSafeEndTime = (lyrics: LyricType[], idx: number) => { const getSafeEndTime = (lyrics: LyricLine[], idx: number) => {
const cur = lyrics?.[idx]; const cur = lyrics?.[idx];
const next = lyrics?.[idx + 1]; const next = lyrics?.[idx + 1];
const curEnd = Number(cur?.endTime); const curEnd = Number(cur?.endTime);
const curStart = Number(cur?.time); const curStart = Number(cur?.startTime);
if (Number.isFinite(curEnd) && curEnd > curStart) return curEnd; if (Number.isFinite(curEnd) && curEnd > curStart) return curEnd;
const nextStart = Number(next?.time); const nextStart = Number(next?.startTime);
if (Number.isFinite(nextStart) && nextStart > curStart) return nextStart; if (Number.isFinite(nextStart) && nextStart > curStart) return nextStart;
// 无有效结束参照:返回 0表示无时长不滚动 // 无有效结束参照:返回 0表示无时长不滚动
return 0; return 0;
@@ -213,19 +213,35 @@ const renderLyricLines = computed<RenderLine[]>(() => {
if (!lyrics?.length) { if (!lyrics?.length) {
return [ return [
{ {
line: { time: 0, endTime: 0, content: "纯音乐,请欣赏", contents: [] }, line: {
startTime: 0,
endTime: 0,
words: [{ word: "纯音乐,请欣赏", startTime: 0, endTime: 0, romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
index: -1, index: -1,
key: "placeholder", key: "placeholder",
active: true, active: true,
}, },
]; ];
} }
let idx = lyricData?.lyricIndex ?? -1; const idx = lyricData?.lyricIndex ?? -1;
// 显示歌名
if (idx < 0) { if (idx < 0) {
const text = lyricData.playName ?? "未知歌曲";
return [ return [
{ {
line: { time: 0, endTime: 0, content: lyricData.playName ?? "未知歌曲", contents: [] }, line: {
startTime: 0,
endTime: 0,
words: [{ word: text, startTime: 0, endTime: 0, romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
index: -1, index: -1,
key: "placeholder", key: "placeholder",
active: true, active: true,
@@ -236,39 +252,79 @@ const renderLyricLines = computed<RenderLine[]>(() => {
const next = lyrics[idx + 1]; const next = lyrics[idx + 1];
if (!current) return []; if (!current) return [];
const safeEnd = getSafeEndTime(lyrics, idx); const safeEnd = getSafeEndTime(lyrics, idx);
// 有翻译:保留第二行显示翻译,第一行显示原文(逐字由 contents 驱动) if (
if (lyricConfig.showTran && current.tran && current.tran.trim().length > 0) { lyricConfig.showTran &&
current.translatedLyric &&
current.translatedLyric.trim().length > 0
) {
const lines: RenderLine[] = [ const lines: RenderLine[] = [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true }, { line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
{ {
line: { time: current.time, endTime: safeEnd, content: current.tran, contents: [] }, line: {
startTime: current.startTime,
endTime: safeEnd,
words: [
{
word: current.translatedLyric,
startTime: current.startTime,
endTime: safeEnd,
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
index: idx, index: idx,
key: `${idx}:tran`, key: `${idx}:tran`,
active: false, active: false,
}, },
]; ];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0); return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
} }
// 单行:仅当前句原文,高亮
if (!lyricConfig.isDoubleLine) { if (!lyricConfig.isDoubleLine) {
return [ return [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true }, { line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
].filter((l) => l.line?.content && l.line.content.trim().length > 0); ].filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
} }
// 双行交替:只高亮当前句所在行
const isEven = idx % 2 === 0; const isEven = idx % 2 === 0;
if (isEven) { if (isEven) {
const lines: RenderLine[] = [ const lines: RenderLine[] = [
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true }, { line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []), ...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []),
]; ];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0); return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
} }
const lines: RenderLine[] = [ const lines: RenderLine[] = [
...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []), ...(next ? [{ line: next, index: idx + 1, key: `${idx + 1}:next`, active: false }] : []),
{ line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true }, { line: { ...current, endTime: safeEnd }, index: idx, key: `${idx}:orig`, active: true },
]; ];
return lines.filter((l) => l.line?.content && l.line.content.trim().length > 0); return lines.filter((l) => {
const s = (l.line?.words || [])
.map((w) => w.word)
.join("")
.trim();
return s.length > 0;
});
}); });
/** /**
@@ -276,21 +332,24 @@ const renderLyricLines = computed<RenderLine[]>(() => {
* @param wordData 逐字歌词数据 * @param wordData 逐字歌词数据
* @param lyricIndex 歌词索引 * @param lyricIndex 歌词索引
*/ */
const getYrcStyle = (wordData: LyricContentType, lyricIndex: number) => { const getYrcStyle = (wordData: LyricWord, lyricIndex: number) => {
const currentLine = lyricData.yrcData?.[lyricIndex]; const currentLine = lyricData.yrcData?.[lyricIndex];
if (!currentLine) return { WebkitMaskPositionX: "100%" }; if (!currentLine) return { WebkitMaskPositionX: "100%" };
const seek = playSeekMs.value / 1000; // 转为秒 const seekSec = playSeekMs.value / 1000;
const startSec = (currentLine.startTime || 0) / 1000;
const endSec = (currentLine.endTime || 0) / 1000;
const isLineActive = const isLineActive =
(seek >= currentLine.time && seek < currentLine.endTime) || lyricData.lyricIndex === lyricIndex; (seekSec >= startSec && seekSec < endSec) || lyricData.lyricIndex === lyricIndex;
if (!isLineActive) { if (!isLineActive) {
// 已唱过保持填充状态(0%),未唱到保持未填充状态(100%) const hasPlayed = seekSec >= (wordData.endTime || 0) / 1000;
const hasPlayed = seek >= wordData.time + wordData.duration;
return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" }; return { WebkitMaskPositionX: hasPlayed ? "0%" : "100%" };
} }
// 激活状态:根据进度实时填充 const durationSec = Math.max(((wordData.endTime || 0) - (wordData.startTime || 0)) / 1000, 0.001);
const duration = wordData.duration || 0.001; // 避免除零 const progress = Math.max(
const progress = Math.max(Math.min((seek - wordData.time) / duration, 1), 0); Math.min((seekSec - (wordData.startTime || 0) / 1000) / durationSec, 1),
0,
);
return { return {
transitionDuration: `0s, 0s, 0.35s`, transitionDuration: `0s, 0s, 0.35s`,
transitionDelay: `0ms`, transitionDelay: `0ms`,
@@ -324,10 +383,10 @@ const getScrollStyle = (line: RenderLine) => {
if (overflow <= 0) return { transform: "translateX(0px)" }; if (overflow <= 0) return { transform: "translateX(0px)" };
// 计算进度:毫秒锚点插值(`playSeekMs`),并以当前行的 `time` 与有效 `endTime` 计算区间 // 计算进度:毫秒锚点插值(`playSeekMs`),并以当前行的 `time` 与有效 `endTime` 计算区间
const seekSec = playSeekMs.value / 1000; const seekSec = playSeekMs.value / 1000;
const start = Number(line.line.time ?? 0); const start = Number(line.line.startTime ?? 0) / 1000;
// 仅在滚动计算中提前 1 秒 // 仅在滚动计算中提前 1 秒
const END_MARGIN_SEC = 1; const END_MARGIN_SEC = 1;
const endRaw = Number(line.line.endTime); const endRaw = Number(line.line.endTime) / 1000;
// 若 endTime 仍为 0 或不大于 start视为无时长不滚动 // 若 endTime 仍为 0 或不大于 start视为无时长不滚动
const hasSafeEnd = Number.isFinite(endRaw) && endRaw > 0 && endRaw > start; const hasSafeEnd = Number.isFinite(endRaw) && endRaw > 0 && endRaw > start;
if (!hasSafeEnd) return { transform: "translateX(0px)" }; if (!hasSafeEnd) return { transform: "translateX(0px)" };