🦄 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']
NPopconfirm: typeof import('naive-ui')['NPopconfirm']
NPopover: typeof import('naive-ui')['NPopover']
NProgress: typeof import('naive-ui')['NProgress']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
NResult: typeof import('naive-ui')['NResult']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']

View File

@@ -31,7 +31,7 @@
'player-content',
{
pure: statusStore.pureLyricMode && musicStore.isHasLrc,
'no-lrc': !musicStore.isHasLrc,
'no-lrc': !musicStore.isHasLrc || (!musicStore.isHasLrc && !statusStore.lyricLoading),
},
]"
@mousemove="playerMove"
@@ -114,7 +114,8 @@ const instantLyrics = computed(() => {
const content = isYrc
? musicStore.songLyric.yrcData[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 [];
// 优先使用逐字歌词(YRC/TTML)
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData;
const useYrc = songLyric.yrcData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcData : songLyric.lrcData;
// 简单检查歌词有效性
if (!Array.isArray(lyrics) || lyrics.length === 0) return [];

View File

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

View File

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

View File

@@ -1,18 +1,13 @@
import { defineStore } from "pinia";
import type { LyricLine } from "@applemusic-like-lyrics/core";
import type { SongType, LyricType } from "@/types/main";
import type { SongType } from "@/types/main";
import { isElectron } from "@/utils/env";
import { cloneDeep } from "lodash-es";
import { SongLyric } from "@/types/lyric";
interface MusicState {
playSong: SongType;
playPlaylistId: number;
songLyric: {
lrcData: LyricType[];
yrcData: LyricType[];
lrcAMData: LyricLine[];
yrcAMData: LyricLine[];
};
songLyric: SongLyric;
personalFM: {
playIndex: number;
list: SongType[];
@@ -46,8 +41,6 @@ export const useMusicStore = defineStore("music", {
songLyric: {
lrcData: [], // 普通歌词
yrcData: [], // 逐字歌词
lrcAMData: [], // 普通歌词-AM
yrcAMData: [], // 逐字歌词-AM
},
// 私人FM数据
personalFM: {
@@ -88,32 +81,23 @@ export const useMusicStore = defineStore("music", {
// 恢复默认音乐数据
resetMusicData() {
this.playSong = { ...defaultMusicData };
this.songLyric = {
lrcData: [],
yrcData: [],
lrcAMData: [],
yrcAMData: [],
};
this.songLyric = { lrcData: [], yrcData: [] };
},
/**
* 设置/更新歌曲歌词数据
* @param updates 部分或完整歌词数据
* @param replace 是否覆盖true用提供的数据覆盖并为缺省字段置空false合并更新
*/
setSongLyric(updates: Partial<MusicState["songLyric"]>, replace: boolean = false) {
setSongLyric(updates: Partial<SongLyric>, replace: boolean = false) {
if (replace) {
this.songLyric = {
lrcData: updates.lrcData ?? [],
yrcData: updates.yrcData ?? [],
lrcAMData: updates.lrcAMData ?? [],
yrcAMData: updates.yrcAMData ?? [],
};
} else {
this.songLyric = {
lrcData: updates.lrcData ?? this.songLyric.lrcData,
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 {
@@ -13,8 +13,8 @@ export interface LyricData {
/** 当前歌曲的时间偏移(秒,正负均可) */
songOffset?: number;
/** 歌词数据 */
lrcData?: LyricType[];
yrcData?: LyricType[];
lrcData?: LyricLine[];
yrcData?: LyricLine[];
/** 歌词播放索引 */
lyricIndex?: number;
}
@@ -52,7 +52,7 @@ export interface LyricConfig {
*/
export interface RenderLine {
/** 当前整行歌词数据(用于逐字渲染) */
line: LyricType;
line: LyricLine;
/** 当前行在歌词数组中的索引 */
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 { songLyric, songLyricTTML } from "@/api/song";
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 { isEmpty } from "lodash-es";
@@ -104,8 +110,7 @@ class LyricManager {
const settingStore = useSettingStore();
// 请求序列
const req = this.activeLyricReq;
// 请求是否成功
let adopted = false;
// 最终结果
let result: SongLyric = { lrcData: [], yrcData: [] };
// 过期判断
const isStale = () => this.activeLyricReq !== req || musicStore.playSong?.id !== id;
@@ -119,8 +124,7 @@ class LyricManager {
const parsed = parseTTML(ttmlContent);
const lines = parsed?.lines || [];
if (!lines.length) return;
result = { lrcData: [], yrcData: lines };
adopted = true;
result.yrcData = lines;
} catch {
/* empty */
}
@@ -153,9 +157,11 @@ class LyricManager {
if (data?.yromalrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
}
if (adopted) return;
result = { lrcData: lrcLines, yrcData: yrcLines };
adopted = true;
if (lrcLines.length) result.lrcData = lrcLines;
// 如果没有 TTML则采用 网易云 YRC
if (!result.yrcData.length && yrcLines.length) {
result.yrcData = yrcLines;
}
} catch {
/* empty */
}
@@ -183,7 +189,19 @@ class LyricManager {
const ttml = parseTTML(lyric);
const lines = ttml?.lines || [];
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);
@@ -200,7 +218,6 @@ class LyricManager {
* @returns 歌词数据
*/
private async checkLocalLyricOverride(id: number): Promise<SongLyric> {
console.log("检测本地歌词覆盖", id);
const statusStore = useStatusStore();
const settingStore = useSettingStore();
const { localLyricPath } = settingStore;
@@ -222,6 +239,7 @@ class LyricManager {
const lrcContent = typeof lrc === "string" ? lrc : "";
if (lrcContent) {
lrcLines = parseLrc(lrcContent);
console.log("检测到本地歌词覆盖", lrcLines);
}
} catch (err) {
console.error("parseLrc 本地解析失败:", err);
@@ -232,6 +250,7 @@ class LyricManager {
const ttmlContent = typeof ttml === "string" ? ttml : "";
if (ttmlContent) {
ttmlLines = parseTTML(ttmlContent).lines || [];
console.log("检测到本地TTML歌词覆盖", ttmlLines);
}
} catch (err) {
console.error("parseTTML 本地解析失败:", err);
@@ -295,14 +314,10 @@ class LyricManager {
try {
// 歌词加载状态
statusStore.lyricLoading = true;
// 重置歌词
this.resetSongLyric();
// 标记当前歌词请求(避免旧请求覆盖新请求)
this.activeLyricReq = ++this.lyricReqSeq;
// 检查歌词覆盖
let lyricData = await this.checkLocalLyricOverride(id);
console.log("本地歌词覆盖", lyricData);
// 开始获取歌词
if (!isEmpty(lyricData.lrcData) || !isEmpty(lyricData.yrcData)) {
// 进行本地歌词对齐
@@ -314,8 +329,13 @@ class LyricManager {
}
// 排除内容
lyricData = this.handleLyricExclude(lyricData);
// 设置歌词
musicStore.setSongLyric(lyricData, true);
console.log("最终歌词数据", lyricData);
} catch (error) {
console.error("❌ 处理歌词失败:", error);
// 重置歌词
this.resetSongLyric();
} finally {
// 歌词加载状态
if (musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {

View File

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

View File

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