mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-17 02:07:29 +08:00
feat: 逐字歌词
This commit is contained in:
+141
-22
@@ -10,6 +10,7 @@ import { getSongUrl } from '@/store/modules/player';
|
||||
import type { Artist, ILyricText, SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils';
|
||||
import { getTextColors } from '@/utils/linearColor';
|
||||
import { parseLyrics } from '@/utils/yrcParser';
|
||||
|
||||
const windowData = window as any;
|
||||
|
||||
@@ -64,7 +65,7 @@ export const musicDB = await useIndexedDB(
|
||||
{ name: 'music_url_cache', keyPath: 'id' },
|
||||
{ name: 'music_failed_cache', keyPath: 'id' }
|
||||
],
|
||||
2
|
||||
3
|
||||
);
|
||||
|
||||
// 键盘事件处理器,在初始化后设置
|
||||
@@ -268,27 +269,119 @@ const initProgressAnimation = () => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析歌词字符串并转换为ILyricText格式
|
||||
* @param lyricsStr 歌词字符串
|
||||
* @returns 解析后的歌词数据
|
||||
*/
|
||||
const parseLyricsString = async (
|
||||
lyricsStr: string
|
||||
): Promise<{ lrcArray: ILyricText[]; lrcTimeArray: number[]; hasWordByWord: boolean }> => {
|
||||
if (!lyricsStr || typeof lyricsStr !== 'string') {
|
||||
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const parseResult = parseLyrics(lyricsStr);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('歌词解析失败:', parseResult.error.message);
|
||||
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||
}
|
||||
|
||||
const { lyrics } = parseResult.data;
|
||||
const lrcArray: ILyricText[] = [];
|
||||
const lrcTimeArray: number[] = [];
|
||||
let hasWordByWord = false;
|
||||
|
||||
for (const line of lyrics) {
|
||||
// 检查是否有逐字歌词
|
||||
const hasWords = line.words && line.words.length > 0;
|
||||
if (hasWords) {
|
||||
hasWordByWord = true;
|
||||
}
|
||||
|
||||
lrcArray.push({
|
||||
text: line.fullText,
|
||||
trText: '', // 翻译文本稍后处理
|
||||
words: hasWords
|
||||
? line.words.map((word) => ({
|
||||
text: word.text,
|
||||
startTime: word.startTime,
|
||||
duration: word.duration
|
||||
}))
|
||||
: undefined,
|
||||
hasWordByWord: hasWords,
|
||||
startTime: line.startTime,
|
||||
duration: line.duration
|
||||
});
|
||||
|
||||
lrcTimeArray.push(line.startTime);
|
||||
}
|
||||
console.log('parseLyricsString', lrcArray);
|
||||
return { lrcArray, lrcTimeArray, hasWordByWord };
|
||||
} catch (error) {
|
||||
console.error('解析歌词时发生错误:', error);
|
||||
return { lrcArray: [], lrcTimeArray: [], hasWordByWord: false };
|
||||
}
|
||||
};
|
||||
|
||||
// 设置音乐相关的监听器
|
||||
const setupMusicWatchers = () => {
|
||||
const store = getPlayerStore();
|
||||
|
||||
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
||||
watch(
|
||||
() => store.playMusic,
|
||||
() => {
|
||||
nextTick(async () => {
|
||||
console.log('歌曲切换,更新歌词数据');
|
||||
// 更新歌词数据
|
||||
const rawLrc = playMusic.value.lyric?.lrcArray || [];
|
||||
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
() => store.playMusic.id,
|
||||
async (newId, oldId) => {
|
||||
// 如果没有歌曲ID,清空歌词
|
||||
if (!newId) {
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
nowIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免相同ID的重复执行(但允许初始化时执行)
|
||||
if (newId === oldId && lrcArray.value.length > 0) return;
|
||||
|
||||
// 歌曲切换时重置歌词索引
|
||||
if (newId !== oldId) {
|
||||
nowIndex.value = 0;
|
||||
}
|
||||
|
||||
await nextTick(async () => {
|
||||
console.log('歌曲切换,更新歌词数据');
|
||||
|
||||
// 检查是否有原始歌词字符串需要解析
|
||||
const lyricData = playMusic.value.lyric;
|
||||
if (lyricData && typeof lyricData === 'string') {
|
||||
// 如果歌词是字符串格式,使用新的解析器
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(lyricData);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
|
||||
// 更新歌曲的歌词数据结构
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else {
|
||||
// 使用现有的歌词数据结构
|
||||
const rawLrc = lyricData?.lrcArray || [];
|
||||
lrcTimeArray.value = lyricData?.lrcTimeArray || [];
|
||||
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
}
|
||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
console.log('歌词窗口已打开,同步最新歌词数据');
|
||||
@@ -302,10 +395,7 @@ const setupMusicWatchers = () => {
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
{ immediate: true }
|
||||
);
|
||||
};
|
||||
|
||||
@@ -563,20 +653,46 @@ export const adjustCorrectionTime = (delta: number) => {
|
||||
// 获取当前播放歌词
|
||||
export const isCurrentLrc = (index: number, time: number): boolean => {
|
||||
const currentTime = lrcTimeArray.value[index];
|
||||
|
||||
// 如果是最后一句歌词,只需要判断时间是否大于等于当前句的开始时间
|
||||
if (index === lrcTimeArray.value.length - 1) {
|
||||
const correctedTime = time + correctionTime.value;
|
||||
return correctedTime >= currentTime;
|
||||
}
|
||||
|
||||
// 非最后一句歌词,需要判断时间在当前句和下一句之间
|
||||
const nextTime = lrcTimeArray.value[index + 1];
|
||||
const correctedTime = time + correctionTime.value;
|
||||
return correctedTime > currentTime && correctedTime < nextTime;
|
||||
return correctedTime >= currentTime && correctedTime < nextTime;
|
||||
};
|
||||
|
||||
// 获取当前播放歌词INDEX
|
||||
export const getLrcIndex = (time: number): number => {
|
||||
const correctedTime = time + correctionTime.value;
|
||||
for (let i = 0; i < lrcTimeArray.value.length; i++) {
|
||||
if (isCurrentLrc(i, correctedTime - correctionTime.value)) {
|
||||
|
||||
// 如果歌词数组为空,返回当前索引
|
||||
if (lrcTimeArray.value.length === 0) {
|
||||
return nowIndex.value;
|
||||
}
|
||||
|
||||
// 处理最后一句歌词的情况
|
||||
const lastIndex = lrcTimeArray.value.length - 1;
|
||||
if (correctedTime >= lrcTimeArray.value[lastIndex]) {
|
||||
nowIndex.value = lastIndex;
|
||||
return lastIndex;
|
||||
}
|
||||
|
||||
// 查找当前时间对应的歌词索引
|
||||
for (let i = 0; i < lrcTimeArray.value.length - 1; i++) {
|
||||
const currentTime = lrcTimeArray.value[i];
|
||||
const nextTime = lrcTimeArray.value[i + 1];
|
||||
|
||||
if (correctedTime >= currentTime && correctedTime < nextTime) {
|
||||
nowIndex.value = i;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return nowIndex.value;
|
||||
};
|
||||
|
||||
@@ -833,6 +949,9 @@ onUnmounted(() => {
|
||||
stopLyricSync();
|
||||
});
|
||||
|
||||
// 导出歌词解析函数供外部使用
|
||||
export { parseLyricsString };
|
||||
|
||||
// 添加播放控制命令监听
|
||||
if (isElectron) {
|
||||
windowData.electron.ipcRenderer.on('lyric-control-back', (_, command: string) => {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
import { Howl } from 'howler';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { useSettingsStore } from '@/store';
|
||||
import type { ILyric, ILyricText, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
|
||||
const musicHistory = useMusicHistory();
|
||||
|
||||
// 获取歌曲url
|
||||
export const getSongUrl = async (id: any, songData: any, isDownloaded: boolean = false) => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { unlimitedDownload } = settingsStore.setData;
|
||||
|
||||
const { data } = await getMusicUrl(id, !unlimitedDownload);
|
||||
let url = '';
|
||||
let songDetail: any = null;
|
||||
|
||||
try {
|
||||
if (data.data[0].freeTrialInfo || !data.data[0].url) {
|
||||
const res = await getParsingMusicUrl(id, cloneDeep(songData));
|
||||
if (res.data.data?.url) {
|
||||
url = res.data.data.url;
|
||||
songDetail = res.data.data;
|
||||
}
|
||||
} else {
|
||||
songDetail = data.data[0] as any;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
}
|
||||
if (isDownloaded) {
|
||||
return songDetail;
|
||||
}
|
||||
url = url || data.data[0].url;
|
||||
return url;
|
||||
};
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
playMusic.playLoading = true;
|
||||
const playMusicUrl =
|
||||
playMusic.playMusicUrl || (await getSongUrl(playMusic.id, cloneDeep(playMusic)));
|
||||
const { backgroundColor, primaryColor } =
|
||||
playMusic.backgroundColor && playMusic.primaryColor
|
||||
? playMusic
|
||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
|
||||
};
|
||||
|
||||
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
|
||||
export const useMusicListHook = () => {
|
||||
const handlePlayMusic = async (state: any, playMusic: SongResult, isPlay: boolean = true) => {
|
||||
const updatedPlayMusic = await getSongDetail(playMusic);
|
||||
state.playMusic = updatedPlayMusic;
|
||||
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
|
||||
|
||||
// 记录当前设置的播放状态
|
||||
state.play = isPlay;
|
||||
|
||||
// 每次设置新歌曲时,立即更新 localStorage
|
||||
localStorage.setItem('currentPlayMusic', JSON.stringify(state.playMusic));
|
||||
localStorage.setItem('currentPlayMusicUrl', state.playMusicUrl);
|
||||
localStorage.setItem('isPlaying', state.play.toString());
|
||||
|
||||
// 设置网页标题
|
||||
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
|
||||
loadLrcAsync(state, updatedPlayMusic.id);
|
||||
musicHistory.addMusic(state.playMusic);
|
||||
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
|
||||
state.playListIndex = playListIndex;
|
||||
// 请求后续五首歌曲的详情
|
||||
fetchSongs(state, playListIndex + 1, playListIndex + 6);
|
||||
};
|
||||
|
||||
const preloadingSounds = ref<Howl[]>([]);
|
||||
|
||||
// 用于预加载下一首歌曲的 MP3 数据
|
||||
const preloadNextSong = (nextSongUrl: string) => {
|
||||
try {
|
||||
// 限制同时预加载的数量
|
||||
if (preloadingSounds.value.length >= 2) {
|
||||
const oldestSound = preloadingSounds.value.shift();
|
||||
if (oldestSound) {
|
||||
oldestSound.unload();
|
||||
}
|
||||
}
|
||||
|
||||
const sound = new Howl({
|
||||
src: [nextSongUrl],
|
||||
html5: true,
|
||||
preload: true,
|
||||
autoplay: false
|
||||
});
|
||||
|
||||
preloadingSounds.value.push(sound);
|
||||
|
||||
// 添加加载错误处理
|
||||
sound.on('loaderror', () => {
|
||||
console.error('预加载音频失败:', nextSongUrl);
|
||||
const index = preloadingSounds.value.indexOf(sound);
|
||||
if (index > -1) {
|
||||
preloadingSounds.value.splice(index, 1);
|
||||
}
|
||||
sound.unload();
|
||||
});
|
||||
|
||||
return sound;
|
||||
} catch (error) {
|
||||
console.error('预加载音频出错:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
|
||||
try {
|
||||
const songs = state.playList.slice(
|
||||
Math.max(0, startIndex),
|
||||
Math.min(endIndex, state.playList.length)
|
||||
);
|
||||
|
||||
const detailedSongs = await Promise.all(
|
||||
songs.map(async (song: SongResult) => {
|
||||
try {
|
||||
// 如果歌曲详情已经存在,就不重复请求
|
||||
if (!song.playMusicUrl) {
|
||||
return await getSongDetail(song);
|
||||
}
|
||||
return song;
|
||||
} catch (error) {
|
||||
console.error('获取歌曲详情失败:', error);
|
||||
return song;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 加载下一首的歌词
|
||||
const nextSong = detailedSongs[0];
|
||||
if (nextSong && !(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
|
||||
try {
|
||||
nextSong.lyric = await loadLrc(nextSong.id);
|
||||
} catch (error) {
|
||||
console.error('加载歌词失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新播放列表中的歌曲详情
|
||||
detailedSongs.forEach((song, index) => {
|
||||
if (song && startIndex + index < state.playList.length) {
|
||||
state.playList[startIndex + index] = song;
|
||||
}
|
||||
});
|
||||
|
||||
// 只预加载下一首歌曲
|
||||
if (nextSong && nextSong.playMusicUrl) {
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌曲列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const nextPlay = async (state: any) => {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let playListIndex: number;
|
||||
|
||||
if (state.playMode === 2) {
|
||||
// 随机播放模式
|
||||
do {
|
||||
playListIndex = Math.floor(Math.random() * state.playList.length);
|
||||
} while (playListIndex === state.playListIndex && state.playList.length > 1);
|
||||
} else {
|
||||
// 列表循环模式
|
||||
playListIndex = (state.playListIndex + 1) % state.playList.length;
|
||||
}
|
||||
|
||||
state.playListIndex = playListIndex;
|
||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||
};
|
||||
|
||||
const prevPlay = async (state: any) => {
|
||||
if (state.playList.length === 0) {
|
||||
state.play = true;
|
||||
return;
|
||||
}
|
||||
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
|
||||
await handlePlayMusic(state, state.playList[playListIndex]);
|
||||
await fetchSongs(state, playListIndex - 5, playListIndex);
|
||||
};
|
||||
|
||||
const parseTime = (timeString: string): number => {
|
||||
const [minutes, seconds] = timeString.split(':');
|
||||
return Number(minutes) * 60 + Number(seconds);
|
||||
};
|
||||
|
||||
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
|
||||
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
|
||||
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
|
||||
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
|
||||
const time = parseTime(timeText);
|
||||
const text = lyricLine.replace(LRC_REGEX, '').trim();
|
||||
return { time, text };
|
||||
};
|
||||
|
||||
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
||||
const lines = lyricsString.split('\n');
|
||||
const lyrics: ILyricText[] = [];
|
||||
const times: number[] = [];
|
||||
lines.forEach((line) => {
|
||||
const { time, text } = parseLyricLine(line);
|
||||
times.push(time);
|
||||
lyrics.push({ text, trText: '' });
|
||||
});
|
||||
return { lyrics, times };
|
||||
};
|
||||
|
||||
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
|
||||
try {
|
||||
const { data } = await getMusicLrc(playMusicId);
|
||||
const { lyrics, times } = parseLyrics(data.lrc.lyric);
|
||||
const tlyric: Record<string, string> = {};
|
||||
|
||||
if (data.tlyric && data.tlyric.lyric) {
|
||||
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
|
||||
tLyrics.forEach((lyric, index) => {
|
||||
tlyric[tTimes[index].toString()] = lyric.text;
|
||||
});
|
||||
}
|
||||
|
||||
lyrics.forEach((item, index) => {
|
||||
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
|
||||
});
|
||||
return {
|
||||
lrcTimeArray: times,
|
||||
lrcArray: lyrics
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading lyrics:', err);
|
||||
return {
|
||||
lrcTimeArray: [],
|
||||
lrcArray: []
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 异步加载歌词的方法
|
||||
const loadLrcAsync = async (state: any, playMusicId: any) => {
|
||||
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
|
||||
return;
|
||||
}
|
||||
const lyrics = await loadLrc(playMusicId);
|
||||
state.playMusic.lyric = lyrics;
|
||||
};
|
||||
|
||||
const play = () => {
|
||||
audioService.getCurrentSound()?.play();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
audioService.getCurrentSound()?.pause();
|
||||
};
|
||||
|
||||
// 在组件卸载时清理预加载的音频
|
||||
onUnmounted(() => {
|
||||
preloadingSounds.value.forEach((sound) => sound.unload());
|
||||
preloadingSounds.value = [];
|
||||
});
|
||||
|
||||
return {
|
||||
handlePlayMusic,
|
||||
nextPlay,
|
||||
prevPlay,
|
||||
play,
|
||||
pause
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user