mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
Compare commits
4 Commits
3527da17da
...
56adac0d4e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56adac0d4e | ||
|
|
452e1d1129 | ||
|
|
34ba2250bf | ||
|
|
1005718c07 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,6 +30,8 @@ resources/android/**/*
|
||||
android/app/release
|
||||
|
||||
.cursor
|
||||
.windsurf
|
||||
|
||||
|
||||
.auto-imports.d.ts
|
||||
.components.d.ts
|
||||
|
||||
@@ -22,7 +22,6 @@ mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
notarize: false
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"netease-cloud-music-api-alger": "^4.26.1",
|
||||
"node-id3": "^0.2.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"pinia-plugin-persistedstate": "^4.5.0",
|
||||
"sharp": "^0.34.3",
|
||||
"vue-i18n": "^11.1.3"
|
||||
},
|
||||
|
||||
@@ -59,6 +59,13 @@ function getSongTitle(song: SongInfo | null): string {
|
||||
return artistStr ? `${song.name} - ${artistStr}` : song.name;
|
||||
}
|
||||
|
||||
// 截断歌曲标题,防止菜单中显示过长
|
||||
function getTruncatedSongTitle(song: SongInfo | null, maxLength: number = 14): string {
|
||||
const fullTitle = getSongTitle(song);
|
||||
if (fullTitle.length <= maxLength) return fullTitle;
|
||||
return fullTitle.slice(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
// 更新当前播放的音乐信息
|
||||
export function updateCurrentSong(song: SongInfo | null) {
|
||||
currentSong = song;
|
||||
@@ -143,7 +150,7 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
|
||||
if (currentSong) {
|
||||
menu.append(
|
||||
new MenuItem({
|
||||
label: getSongTitle(currentSong),
|
||||
label: getTruncatedSongTitle(currentSong),
|
||||
enabled: false,
|
||||
type: 'normal'
|
||||
})
|
||||
@@ -250,7 +257,7 @@ export function updateTrayMenu(mainWindow: BrowserWindow) {
|
||||
...((currentSong
|
||||
? [
|
||||
{
|
||||
label: getSongTitle(currentSong),
|
||||
label: getTruncatedSongTitle(currentSong),
|
||||
enabled: false,
|
||||
type: 'normal'
|
||||
},
|
||||
|
||||
403
src/renderer/hooks/usePlayerHooks.ts
Normal file
403
src/renderer/hooks/usePlayerHooks.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import type { ILyric, ILyricText, IWordData, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
import { parseLyrics as parseYrcLyrics } from '@/utils/yrcParser';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
// 预加载的音频实例
|
||||
export const preloadingSounds = ref<Howl[]>([]);
|
||||
|
||||
/**
|
||||
* 获取歌曲播放URL(独立函数)
|
||||
*/
|
||||
export const getSongUrl = async (
|
||||
id: string | number,
|
||||
songData: SongResult,
|
||||
isDownloaded: boolean = false
|
||||
) => {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
|
||||
// 动态导入 settingsStore
|
||||
const { useSettingsStore } = await import('@/store/modules/settings');
|
||||
const settingsStore = useSettingsStore();
|
||||
|
||||
try {
|
||||
if (songData.playMusicUrl) {
|
||||
return songData.playMusicUrl;
|
||||
}
|
||||
|
||||
if (songData.source === 'bilibili' && songData.bilibiliData) {
|
||||
console.log('加载B站音频URL');
|
||||
if (!songData.playMusicUrl && songData.bilibiliData.bvid && songData.bilibiliData.cid) {
|
||||
try {
|
||||
songData.playMusicUrl = await getBilibiliAudioUrl(
|
||||
songData.bilibiliData.bvid,
|
||||
songData.bilibiliData.cid
|
||||
);
|
||||
return songData.playMusicUrl;
|
||||
} catch (error) {
|
||||
console.error('重启后获取B站音频URL失败:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
return songData.playMusicUrl || '';
|
||||
}
|
||||
|
||||
// ==================== 自定义API最优先 ====================
|
||||
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||
const useCustomApiGlobally = globalSources.includes('custom');
|
||||
|
||||
const songId = String(id);
|
||||
const savedSourceStr = localStorage.getItem(`song_source_${songId}`);
|
||||
let useCustomApiForSong = false;
|
||||
if (savedSourceStr) {
|
||||
try {
|
||||
const songSources = JSON.parse(savedSourceStr);
|
||||
useCustomApiForSong = songSources.includes('custom');
|
||||
} catch (e) {
|
||||
console.error('解析歌曲音源设置失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果全局或歌曲专属设置中启用了自定义API,则最优先尝试
|
||||
if ((useCustomApiGlobally || useCustomApiForSong) && settingsStore.setData.customApiPlugin) {
|
||||
console.log(`优先级 1: 尝试使用自定义API解析歌曲 ${id}...`);
|
||||
try {
|
||||
const { parseFromCustomApi } = await import('@/api/parseFromCustomApi');
|
||||
const customResult = await parseFromCustomApi(
|
||||
numericId,
|
||||
cloneDeep(songData),
|
||||
settingsStore.setData.musicQuality || 'higher'
|
||||
);
|
||||
|
||||
if (
|
||||
customResult &&
|
||||
customResult.data &&
|
||||
customResult.data.data &&
|
||||
customResult.data.data.url
|
||||
) {
|
||||
console.log('自定义API解析成功!');
|
||||
if (isDownloaded) return customResult.data.data as any;
|
||||
return customResult.data.data.url;
|
||||
} else {
|
||||
console.log('自定义API解析失败,将使用默认降级流程...');
|
||||
message.warning(i18n.global.t('player.reparse.customApiFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('调用自定义API时发生错误:', error);
|
||||
message.error(i18n.global.t('player.reparse.customApiError'));
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||
if (savedSourceStr && songData.source !== 'bilibili') {
|
||||
try {
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${songId}`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
console.log('res', res);
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
return res.data.data.url;
|
||||
}
|
||||
console.warn('自定义音源解析失败,使用默认音源');
|
||||
} catch (error) {
|
||||
console.error('error', error);
|
||||
console.error('自定义音源解析出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 正常获取URL流程
|
||||
const { data } = await getMusicUrl(numericId, isDownloaded);
|
||||
if (data && data.data && data.data[0]) {
|
||||
const songDetail = data.data[0];
|
||||
const hasNoUrl = !songDetail.url;
|
||||
const isTrial = !!songDetail.freeTrialInfo;
|
||||
|
||||
if (hasNoUrl || isTrial) {
|
||||
console.log(`官方URL无效 (无URL: ${hasNoUrl}, 试听: ${isTrial}),进入内置备用解析...`);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
}
|
||||
|
||||
console.log('官方API解析成功!');
|
||||
if (isDownloaded) return songDetail as any;
|
||||
return songDetail.url;
|
||||
}
|
||||
|
||||
console.log('官方API返回数据结构异常,进入内置备用解析...');
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
} catch (error) {
|
||||
console.error('官方API请求失败,进入内置备用解析流程:', error);
|
||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||
if (isDownloaded) return res?.data?.data as any;
|
||||
return res?.data?.data?.url || null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useSongUrl hook(兼容旧代码)
|
||||
*/
|
||||
export const useSongUrl = () => {
|
||||
return { getSongUrl };
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用新的yrcParser解析歌词(独立函数)
|
||||
*/
|
||||
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
|
||||
if (!lyricsString || typeof lyricsString !== 'string') {
|
||||
return { lyrics: [], times: [] };
|
||||
}
|
||||
|
||||
try {
|
||||
const parseResult = parseYrcLyrics(lyricsString);
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('歌词解析失败:', parseResult.error.message);
|
||||
return { lyrics: [], times: [] };
|
||||
}
|
||||
|
||||
const { lyrics: parsedLyrics } = parseResult.data;
|
||||
const lyrics: ILyricText[] = [];
|
||||
const times: number[] = [];
|
||||
|
||||
for (const line of parsedLyrics) {
|
||||
// 检查是否有逐字歌词
|
||||
const hasWords = line.words && line.words.length > 0;
|
||||
|
||||
lyrics.push({
|
||||
text: line.fullText,
|
||||
trText: '', // 翻译文本稍后处理
|
||||
words: hasWords ? (line.words as IWordData[]) : undefined,
|
||||
hasWordByWord: hasWords,
|
||||
startTime: line.startTime,
|
||||
duration: line.duration
|
||||
});
|
||||
|
||||
// 时间数组使用秒为单位(与原有逻辑保持一致)
|
||||
times.push(line.startTime / 1000);
|
||||
}
|
||||
|
||||
return { lyrics, times };
|
||||
} catch (error) {
|
||||
console.error('解析歌词时发生错误:', error);
|
||||
return { lyrics: [], times: [] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 加载歌词(独立函数)
|
||||
*/
|
||||
export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
||||
if (typeof id === 'string' && id.includes('--')) {
|
||||
console.log('B站音频,无需加载歌词');
|
||||
return {
|
||||
lrcTimeArray: [],
|
||||
lrcArray: [],
|
||||
hasWordByWord: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
const { data } = await getMusicLrc(numericId);
|
||||
const { lyrics, times } = parseLyrics(data?.yrc?.lyric || data?.lrc?.lyric);
|
||||
|
||||
// 检查是否有逐字歌词
|
||||
let hasWordByWord = false;
|
||||
for (const lyric of lyrics) {
|
||||
if (lyric.hasWordByWord) {
|
||||
hasWordByWord = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.tlyric && data.tlyric.lyric) {
|
||||
const { lyrics: tLyrics } = parseLyrics(data.tlyric.lyric);
|
||||
|
||||
// 按索引顺序一一对应翻译歌词
|
||||
if (tLyrics.length === lyrics.length) {
|
||||
// 数量相同,直接按索引对应
|
||||
lyrics.forEach((item, index) => {
|
||||
item.trText = item.text && tLyrics[index] ? tLyrics[index].text : '';
|
||||
});
|
||||
} else {
|
||||
// 数量不同,构建时间戳映射并尝试匹配
|
||||
const tLyricMap = new Map<number, string>();
|
||||
tLyrics.forEach((lyric) => {
|
||||
if (lyric.text && lyric.startTime !== undefined) {
|
||||
const timeInSeconds = lyric.startTime / 1000;
|
||||
tLyricMap.set(timeInSeconds, lyric.text);
|
||||
}
|
||||
});
|
||||
|
||||
// 为每句歌词查找最接近的翻译
|
||||
lyrics.forEach((item, index) => {
|
||||
if (!item.text) {
|
||||
item.trText = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTime = times[index];
|
||||
let closestTime = -1;
|
||||
let minDiff = 2.0; // 最大允许差异2秒
|
||||
|
||||
// 查找最接近的时间戳
|
||||
for (const [tTime] of tLyricMap.entries()) {
|
||||
const diff = Math.abs(tTime - currentTime);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestTime = tTime;
|
||||
}
|
||||
}
|
||||
|
||||
item.trText = closestTime !== -1 ? tLyricMap.get(closestTime) || '' : '';
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 没有翻译歌词,清空 trText
|
||||
lyrics.forEach((item) => {
|
||||
item.trText = '';
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
lrcTimeArray: times,
|
||||
lrcArray: lyrics,
|
||||
hasWordByWord
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error loading lyrics:', err);
|
||||
return {
|
||||
lrcTimeArray: [],
|
||||
lrcArray: [],
|
||||
hasWordByWord: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* useLyrics hook(兼容旧代码)
|
||||
*/
|
||||
export const useLyrics = () => {
|
||||
return { loadLrc, parseLyrics };
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取歌曲详情
|
||||
*/
|
||||
export const useSongDetail = () => {
|
||||
const { getSongUrl } = useSongUrl();
|
||||
|
||||
const getSongDetail = async (playMusic: SongResult) => {
|
||||
if (playMusic.source === 'bilibili') {
|
||||
try {
|
||||
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
||||
playMusic.playMusicUrl = await getBilibiliAudioUrl(
|
||||
playMusic.bilibiliData.bvid,
|
||||
playMusic.bilibiliData.cid
|
||||
);
|
||||
}
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic } as SongResult;
|
||||
} catch (error) {
|
||||
console.error('获取B站音频详情失败:', error);
|
||||
playMusic.playLoading = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (playMusic.expiredAt && playMusic.expiredAt < Date.now()) {
|
||||
console.info(`歌曲已过期,重新获取: ${playMusic.name}`);
|
||||
playMusic.playMusicUrl = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
|
||||
playMusic.createdAt = Date.now();
|
||||
// 半小时后过期
|
||||
playMusic.expiredAt = playMusic.createdAt + 1800000;
|
||||
const { backgroundColor, primaryColor } =
|
||||
playMusic.backgroundColor && playMusic.primaryColor
|
||||
? playMusic
|
||||
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
|
||||
|
||||
playMusic.playLoading = false;
|
||||
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor } as SongResult;
|
||||
} catch (error) {
|
||||
console.error('获取音频URL失败:', error);
|
||||
playMusic.playLoading = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { getSongDetail };
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载下一首歌曲音频
|
||||
*/
|
||||
export const preloadNextSong = (nextSongUrl: string): Howl | null => {
|
||||
try {
|
||||
// 清理多余的预加载实例,确保最多只有2个预加载音频
|
||||
while (preloadingSounds.value.length >= 2) {
|
||||
const oldestSound = preloadingSounds.value.shift();
|
||||
if (oldestSound) {
|
||||
try {
|
||||
oldestSound.stop();
|
||||
oldestSound.unload();
|
||||
} catch (e) {
|
||||
console.error('清理预加载音频实例失败:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查这个URL是否已经在预加载列表中
|
||||
const existingPreload = preloadingSounds.value.find(
|
||||
(sound) => (sound as any)._src === nextSongUrl
|
||||
);
|
||||
if (existingPreload) {
|
||||
console.log('该音频已在预加载列表中,跳过:', nextSongUrl);
|
||||
return existingPreload;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
try {
|
||||
sound.stop();
|
||||
sound.unload();
|
||||
} catch (e) {
|
||||
console.error('卸载预加载音频失败:', e);
|
||||
}
|
||||
});
|
||||
|
||||
return sound;
|
||||
} catch (error) {
|
||||
console.error('预加载音频出错:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createPinia } from 'pinia';
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
import { markRaw } from 'vue';
|
||||
|
||||
import router from '@/router';
|
||||
|
||||
// 创建 pinia 实例
|
||||
const pinia = createPinia();
|
||||
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
// 添加路由到 Pinia
|
||||
pinia.use(({ store }) => {
|
||||
store.router = markRaw(router);
|
||||
|
||||
159
src/renderer/store/modules/favorite.ts
Normal file
159
src/renderer/store/modules/favorite.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { getLikedList, likeSong } from '@/api/music';
|
||||
import { hasPermission } from '@/utils/auth';
|
||||
import { getLocalStorageItem, isBilibiliIdMatch, setLocalStorageItem } from '@/utils/playerUtils';
|
||||
|
||||
/**
|
||||
* 收藏管理 Store
|
||||
* 负责:收藏列表、不喜欢列表的管理
|
||||
*/
|
||||
export const useFavoriteStore = defineStore('favorite', () => {
|
||||
// ==================== 状态 ====================
|
||||
const favoriteList = ref<Array<number | string>>(getLocalStorageItem('favoriteList', []));
|
||||
const dislikeList = ref<Array<number | string>>(getLocalStorageItem('dislikeList', []));
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 添加到收藏列表
|
||||
*/
|
||||
const addToFavorite = async (id: number | string) => {
|
||||
// 检查是否已存在
|
||||
const isAlreadyInList = favoriteList.value.some((existingId) =>
|
||||
typeof id === 'string' && id.includes('--')
|
||||
? isBilibiliIdMatch(existingId, id)
|
||||
: existingId === id
|
||||
);
|
||||
|
||||
if (!isAlreadyInList) {
|
||||
favoriteList.value.push(id);
|
||||
setLocalStorageItem('favoriteList', favoriteList.value);
|
||||
|
||||
// 只有在有真实登录权限时才调用API
|
||||
if (typeof id === 'number') {
|
||||
const { useUserStore } = await import('./user');
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user && hasPermission(true)) {
|
||||
try {
|
||||
await likeSong(id, true);
|
||||
} catch (error) {
|
||||
console.error('收藏歌曲API调用失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从收藏列表移除
|
||||
*/
|
||||
const removeFromFavorite = async (id: number | string) => {
|
||||
// 对于B站视频,需要根据bvid和cid来匹配
|
||||
if (typeof id === 'string' && id.includes('--')) {
|
||||
favoriteList.value = favoriteList.value.filter(
|
||||
(existingId) => !isBilibiliIdMatch(existingId, id)
|
||||
);
|
||||
} else {
|
||||
favoriteList.value = favoriteList.value.filter((existingId) => existingId !== id);
|
||||
|
||||
// 只有在有真实登录权限时才调用API
|
||||
if (typeof id === 'number') {
|
||||
const { useUserStore } = await import('./user');
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user && hasPermission(true)) {
|
||||
try {
|
||||
await likeSong(id, false);
|
||||
} catch (error) {
|
||||
console.error('取消收藏歌曲API调用失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setLocalStorageItem('favoriteList', favoriteList.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加到不喜欢列表
|
||||
*/
|
||||
const addToDislikeList = (id: number | string) => {
|
||||
if (!dislikeList.value.includes(id)) {
|
||||
dislikeList.value.push(id);
|
||||
setLocalStorageItem('dislikeList', dislikeList.value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从不喜欢列表移除
|
||||
*/
|
||||
const removeFromDislikeList = (id: number | string) => {
|
||||
dislikeList.value = dislikeList.value.filter((existingId) => existingId !== id);
|
||||
setLocalStorageItem('dislikeList', dislikeList.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化收藏列表(从服务器同步)
|
||||
*/
|
||||
const initializeFavoriteList = async () => {
|
||||
const { useUserStore } = await import('./user');
|
||||
const userStore = useUserStore();
|
||||
const localFavoriteList = localStorage.getItem('favoriteList');
|
||||
const localList: number[] = localFavoriteList ? JSON.parse(localFavoriteList) : [];
|
||||
|
||||
if (userStore.user && userStore.user.userId) {
|
||||
try {
|
||||
const res = await getLikedList(userStore.user.userId);
|
||||
if (res.data?.ids) {
|
||||
const serverList = res.data.ids.reverse();
|
||||
const mergedList = Array.from(new Set([...localList, ...serverList]));
|
||||
favoriteList.value = mergedList;
|
||||
} else {
|
||||
favoriteList.value = localList;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取服务器收藏列表失败,使用本地数据:', error);
|
||||
favoriteList.value = localList;
|
||||
}
|
||||
} else {
|
||||
favoriteList.value = localList;
|
||||
}
|
||||
|
||||
setLocalStorageItem('favoriteList', favoriteList.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查歌曲是否已收藏
|
||||
*/
|
||||
const isFavorite = (id: number | string): boolean => {
|
||||
return favoriteList.value.some((existingId) =>
|
||||
typeof id === 'string' && id.includes('--')
|
||||
? isBilibiliIdMatch(existingId, id)
|
||||
: existingId === id
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查歌曲是否在不喜欢列表中
|
||||
*/
|
||||
const isDisliked = (id: number | string): boolean => {
|
||||
return dislikeList.value.includes(id);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
favoriteList,
|
||||
dislikeList,
|
||||
|
||||
// Actions
|
||||
addToFavorite,
|
||||
removeFromFavorite,
|
||||
addToDislikeList,
|
||||
removeFromDislikeList,
|
||||
initializeFavoriteList,
|
||||
isFavorite,
|
||||
isDisliked
|
||||
};
|
||||
});
|
||||
134
src/renderer/store/modules/intelligenceMode.ts
Normal file
134
src/renderer/store/modules/intelligenceMode.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getLikedList } from '@/api/music';
|
||||
import type { Platform } from '@/types/music';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/playerUtils';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
/**
|
||||
* 心动模式管理 Store
|
||||
* 负责:心动模式的播放和状态管理
|
||||
*/
|
||||
export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
|
||||
// ==================== 状态 ====================
|
||||
const isIntelligenceMode = ref(getLocalStorageItem('isIntelligenceMode', false));
|
||||
const intelligenceModeInfo = ref<{
|
||||
playlistId: number;
|
||||
seedSongId: number;
|
||||
} | null>(getLocalStorageItem('intelligenceModeInfo', null));
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 播放心动模式
|
||||
*/
|
||||
const playIntelligenceMode = async () => {
|
||||
const { useUserStore } = await import('./user');
|
||||
const { usePlayerCoreStore } = await import('./playerCore');
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
|
||||
const userStore = useUserStore();
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const playlistStore = usePlaylistStore();
|
||||
const { t } = i18n.global;
|
||||
|
||||
// 检查是否使用cookie登录
|
||||
if (!userStore.user || userStore.loginType !== 'cookie') {
|
||||
message.warning(t('player.playBar.intelligenceMode.needCookieLogin'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户歌单列表
|
||||
if (userStore.playList.length === 0) {
|
||||
await userStore.initializePlaylist();
|
||||
}
|
||||
|
||||
// 找到"我喜欢的音乐"歌单
|
||||
const favoritePlaylist = userStore.playList.find(
|
||||
(pl: any) => pl.userId === userStore.user?.userId && pl.specialType === 5
|
||||
);
|
||||
|
||||
if (!favoritePlaylist) {
|
||||
message.warning(t('player.playBar.intelligenceMode.noFavoritePlaylist'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取喜欢的歌曲列表
|
||||
const likedListRes = await getLikedList(userStore.user.userId);
|
||||
const likedIds = likedListRes.data?.ids || [];
|
||||
|
||||
if (likedIds.length === 0) {
|
||||
message.warning(t('player.playBar.intelligenceMode.noLikedSongs'));
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择一首歌曲
|
||||
const randomSongId = likedIds[Math.floor(Math.random() * likedIds.length)];
|
||||
|
||||
// 调用心动模式API
|
||||
const { getIntelligenceList } = await import('@/api/music');
|
||||
const res = await getIntelligenceList({
|
||||
id: randomSongId,
|
||||
pid: favoritePlaylist.id
|
||||
});
|
||||
|
||||
if (res.data?.data && res.data.data.length > 0) {
|
||||
const intelligenceSongs = res.data.data.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.songInfo.name,
|
||||
picUrl: item.songInfo.al?.picUrl,
|
||||
source: 'netease' as Platform,
|
||||
song: item.songInfo,
|
||||
...item.songInfo,
|
||||
playLoading: false
|
||||
}));
|
||||
|
||||
// 设置心动模式状态
|
||||
isIntelligenceMode.value = true;
|
||||
intelligenceModeInfo.value = {
|
||||
playlistId: favoritePlaylist.id,
|
||||
seedSongId: randomSongId
|
||||
};
|
||||
playlistStore.playMode = 3; // 设置播放模式为心动模式
|
||||
|
||||
setLocalStorageItem('isIntelligenceMode', true);
|
||||
setLocalStorageItem('intelligenceModeInfo', intelligenceModeInfo.value);
|
||||
setLocalStorageItem('playMode', playlistStore.playMode);
|
||||
|
||||
// 替换播放列表并开始播放
|
||||
playlistStore.setPlayList(intelligenceSongs, false, true);
|
||||
await playerCore.handlePlayMusic(intelligenceSongs[0], true);
|
||||
} else {
|
||||
message.error(t('player.playBar.intelligenceMode.failed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('心动模式播放失败:', error);
|
||||
message.error(t('player.playBar.intelligenceMode.error'));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除心动模式状态
|
||||
*/
|
||||
const clearIntelligenceMode = () => {
|
||||
isIntelligenceMode.value = false;
|
||||
intelligenceModeInfo.value = null;
|
||||
setLocalStorageItem('isIntelligenceMode', false);
|
||||
localStorage.removeItem('intelligenceModeInfo');
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isIntelligenceMode,
|
||||
intelligenceModeInfo,
|
||||
|
||||
// Actions
|
||||
playIntelligenceMode,
|
||||
clearIntelligenceMode
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
488
src/renderer/store/modules/playerCore.ts
Normal file
488
src/renderer/store/modules/playerCore.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import type { Platform, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
|
||||
const musicHistory = useMusicHistory();
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
/**
|
||||
* 核心播放控制 Store
|
||||
* 负责:播放/暂停、当前歌曲、音频URL、音量、播放速度、全屏状态
|
||||
*/
|
||||
export const usePlayerCoreStore = defineStore(
|
||||
'playerCore',
|
||||
() => {
|
||||
// ==================== 状态 ====================
|
||||
const play = ref(false);
|
||||
const isPlay = ref(false);
|
||||
const playMusic = ref<SongResult>({} as SongResult);
|
||||
const playMusicUrl = ref('');
|
||||
const musicFull = ref(false);
|
||||
const playbackRate = ref(1.0);
|
||||
const volume = ref(1);
|
||||
const userPlayIntent = ref(true);
|
||||
|
||||
let checkPlayTime: NodeJS.Timeout | null = null;
|
||||
|
||||
// ==================== Computed ====================
|
||||
const currentSong = computed(() => playMusic.value);
|
||||
const isPlaying = computed(() => isPlay.value);
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 设置播放状态
|
||||
*/
|
||||
const setIsPlay = (value: boolean) => {
|
||||
isPlay.value = value;
|
||||
play.value = value;
|
||||
window.electron?.ipcRenderer.send('update-play-state', value);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置全屏状态
|
||||
*/
|
||||
const setMusicFull = (value: boolean) => {
|
||||
musicFull.value = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置播放速度
|
||||
*/
|
||||
const setPlaybackRate = (rate: number) => {
|
||||
playbackRate.value = rate;
|
||||
audioService.setPlaybackRate(rate);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置音量
|
||||
*/
|
||||
const setVolume = (newVolume: number) => {
|
||||
const normalizedVolume = Math.max(0, Math.min(1, newVolume));
|
||||
volume.value = normalizedVolume;
|
||||
audioService.setVolume(normalizedVolume);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取音量
|
||||
*/
|
||||
const getVolume = () => volume.value;
|
||||
|
||||
/**
|
||||
* 增加音量
|
||||
*/
|
||||
const increaseVolume = (step: number = 0.1) => {
|
||||
const newVolume = Math.min(1, volume.value + step);
|
||||
setVolume(newVolume);
|
||||
return newVolume;
|
||||
};
|
||||
|
||||
/**
|
||||
* 减少音量
|
||||
*/
|
||||
const decreaseVolume = (step: number = 0.1) => {
|
||||
const newVolume = Math.max(0, volume.value - step);
|
||||
setVolume(newVolume);
|
||||
return newVolume;
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放状态检测
|
||||
*/
|
||||
const checkPlaybackState = (song: SongResult, timeout: number = 4000) => {
|
||||
if (checkPlayTime) {
|
||||
clearTimeout(checkPlayTime);
|
||||
}
|
||||
const sound = audioService.getCurrentSound();
|
||||
if (!sound) return;
|
||||
|
||||
const onPlayHandler = () => {
|
||||
console.log('播放事件触发,歌曲成功开始播放');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
};
|
||||
|
||||
const onPlayErrorHandler = async () => {
|
||||
console.log('播放错误事件触发,尝试重新获取URL');
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
|
||||
if (userPlayIntent.value && play.value) {
|
||||
playMusic.value.playMusicUrl = undefined;
|
||||
const refreshedSong = { ...song, isFirstPlay: true };
|
||||
await handlePlayMusic(refreshedSong, true);
|
||||
}
|
||||
};
|
||||
|
||||
audioService.on('play', onPlayHandler);
|
||||
audioService.on('playerror', onPlayErrorHandler);
|
||||
|
||||
checkPlayTime = setTimeout(() => {
|
||||
if (!audioService.isActuallyPlaying() && userPlayIntent.value && play.value) {
|
||||
console.log(`${timeout}ms后歌曲未真正播放且用户仍希望播放,尝试重新获取URL`);
|
||||
audioService.off('play', onPlayHandler);
|
||||
audioService.off('playerror', onPlayErrorHandler);
|
||||
|
||||
playMusic.value.playMusicUrl = undefined;
|
||||
(async () => {
|
||||
const refreshedSong = { ...song, isFirstPlay: true };
|
||||
await handlePlayMusic(refreshedSong, true);
|
||||
})();
|
||||
}
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
/**
|
||||
* 核心播放处理函数
|
||||
*/
|
||||
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
console.log('主动停止并卸载当前音频实例');
|
||||
currentSound.stop();
|
||||
currentSound.unload();
|
||||
}
|
||||
|
||||
const originalMusic = { ...music };
|
||||
const { loadLrc } = useLyrics();
|
||||
const { getSongDetail } = useSongDetail();
|
||||
|
||||
// 并行加载歌词和背景色
|
||||
const [lyrics, { backgroundColor, primaryColor }] = await Promise.all([
|
||||
(async () => {
|
||||
if (music.lyric && music.lyric.lrcTimeArray.length > 0) {
|
||||
return music.lyric;
|
||||
}
|
||||
return await loadLrc(music.id);
|
||||
})(),
|
||||
(async () => {
|
||||
if (music.backgroundColor && music.primaryColor) {
|
||||
return { backgroundColor: music.backgroundColor, primaryColor: music.primaryColor };
|
||||
}
|
||||
return await getImageLinearBackground(getImgUrl(music?.picUrl, '30y30'));
|
||||
})()
|
||||
]);
|
||||
|
||||
// 设置歌词和背景色
|
||||
music.lyric = lyrics;
|
||||
music.backgroundColor = backgroundColor;
|
||||
music.primaryColor = primaryColor;
|
||||
music.playLoading = true;
|
||||
|
||||
// 更新 playMusic
|
||||
playMusic.value = music;
|
||||
play.value = isPlay;
|
||||
|
||||
// 更新标题
|
||||
let title = music.name;
|
||||
if (music.source === 'netease' && music?.song?.artists) {
|
||||
title += ` - ${music.song.artists.reduce(
|
||||
(prev: string, curr: any) => `${prev}${curr.name}/`,
|
||||
''
|
||||
)}`;
|
||||
} else if (music.source === 'bilibili' && music?.song?.ar?.[0]) {
|
||||
title += ` - ${music.song.ar[0].name}`;
|
||||
}
|
||||
document.title = 'AlgerMusic - ' + title;
|
||||
|
||||
try {
|
||||
// 添加到历史记录
|
||||
musicHistory.addMusic(music);
|
||||
|
||||
// 获取歌曲详情
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic);
|
||||
updatedPlayMusic.lyric = lyrics;
|
||||
|
||||
playMusic.value = updatedPlayMusic;
|
||||
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
|
||||
music.playMusicUrl = updatedPlayMusic.playMusicUrl as string;
|
||||
|
||||
let playInProgress = false;
|
||||
|
||||
try {
|
||||
if (playInProgress) {
|
||||
console.warn('播放操作正在进行中,避免重复调用');
|
||||
return true;
|
||||
}
|
||||
|
||||
playInProgress = true;
|
||||
const result = await playAudio();
|
||||
playInProgress = false;
|
||||
return !!result;
|
||||
} catch (error) {
|
||||
console.error('自动播放音频失败:', error);
|
||||
playInProgress = false;
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理播放音乐失败:', error);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
if (playMusic.value) {
|
||||
playMusic.value.playLoading = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放音频
|
||||
*/
|
||||
const playAudio = async () => {
|
||||
if (!playMusicUrl.value || !playMusic.value) return null;
|
||||
|
||||
try {
|
||||
const shouldPlay = play.value;
|
||||
console.log('播放音频,当前播放状态:', shouldPlay ? '播放' : '暂停');
|
||||
|
||||
// 检查保存的进度
|
||||
let initialPosition = 0;
|
||||
const savedProgress = JSON.parse(localStorage.getItem('playProgress') || '{}');
|
||||
if (savedProgress.songId === playMusic.value.id) {
|
||||
initialPosition = savedProgress.progress;
|
||||
}
|
||||
|
||||
// B站视频URL检查
|
||||
if (
|
||||
playMusic.value.source === 'bilibili' &&
|
||||
(!playMusicUrl.value || playMusicUrl.value === 'undefined')
|
||||
) {
|
||||
console.log('B站视频URL无效,尝试重新获取');
|
||||
|
||||
if (playMusic.value.bilibiliData) {
|
||||
try {
|
||||
const proxyUrl = await getBilibiliAudioUrl(
|
||||
playMusic.value.bilibiliData.bvid,
|
||||
playMusic.value.bilibiliData.cid
|
||||
);
|
||||
|
||||
(playMusic.value as any).playMusicUrl = proxyUrl;
|
||||
playMusicUrl.value = proxyUrl;
|
||||
} catch (error) {
|
||||
console.error('获取B站音频URL失败:', error);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 播放新音频
|
||||
const newSound = await audioService.play(
|
||||
playMusicUrl.value,
|
||||
playMusic.value,
|
||||
shouldPlay,
|
||||
initialPosition || 0
|
||||
);
|
||||
|
||||
// 添加播放状态检测
|
||||
if (shouldPlay) {
|
||||
checkPlaybackState(playMusic.value);
|
||||
}
|
||||
|
||||
// 发布音频就绪事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('audio-ready', { detail: { sound: newSound, shouldPlay } })
|
||||
);
|
||||
|
||||
return newSound;
|
||||
} catch (error) {
|
||||
console.error('播放音频失败:', error);
|
||||
setPlayMusic(false);
|
||||
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// 操作锁错误处理
|
||||
if (errorMsg.includes('操作锁激活')) {
|
||||
console.log('由于操作锁正在使用,将在1000ms后重试');
|
||||
|
||||
try {
|
||||
audioService.forceResetOperationLock();
|
||||
console.log('已强制重置操作锁');
|
||||
} catch (e) {
|
||||
console.error('重置操作锁失败:', e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (userPlayIntent.value && play.value) {
|
||||
playAudio().catch((e) => {
|
||||
console.error('重试播放失败:', e);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 暂停播放
|
||||
*/
|
||||
const handlePause = async () => {
|
||||
try {
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.pause();
|
||||
}
|
||||
setPlayMusic(false);
|
||||
userPlayIntent.value = false;
|
||||
} catch (error) {
|
||||
console.error('暂停播放失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置播放/暂停
|
||||
*/
|
||||
const setPlayMusic = async (value: boolean | SongResult) => {
|
||||
if (typeof value === 'boolean') {
|
||||
setIsPlay(value);
|
||||
userPlayIntent.value = value;
|
||||
} else {
|
||||
await handlePlayMusic(value);
|
||||
play.value = true;
|
||||
isPlay.value = true;
|
||||
userPlayIntent.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用指定音源重新解析当前歌曲
|
||||
*/
|
||||
const reparseCurrentSong = async (sourcePlatform: Platform) => {
|
||||
try {
|
||||
const currentSong = playMusic.value;
|
||||
if (!currentSong || !currentSong.id) {
|
||||
console.warn('没有有效的播放对象');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSong.source === 'bilibili') {
|
||||
console.warn('B站视频不支持重新解析');
|
||||
return false;
|
||||
}
|
||||
|
||||
const songId = String(currentSong.id);
|
||||
localStorage.setItem(`song_source_${songId}`, JSON.stringify([sourcePlatform]));
|
||||
|
||||
const currentSound = audioService.getCurrentSound();
|
||||
if (currentSound) {
|
||||
currentSound.pause();
|
||||
}
|
||||
|
||||
const numericId =
|
||||
typeof currentSong.id === 'string' ? parseInt(currentSong.id, 10) : currentSong.id;
|
||||
|
||||
console.log(`使用音源 ${sourcePlatform} 重新解析歌曲 ${numericId}`);
|
||||
|
||||
const songData = cloneDeep(currentSong);
|
||||
const res = await getParsingMusicUrl(numericId, songData);
|
||||
|
||||
if (res && res.data && res.data.data && res.data.data.url) {
|
||||
const newUrl = res.data.data.url;
|
||||
console.log(`解析成功,获取新URL: ${newUrl.substring(0, 50)}...`);
|
||||
|
||||
const updatedMusic = {
|
||||
...currentSong,
|
||||
playMusicUrl: newUrl,
|
||||
expiredAt: Date.now() + 1800000
|
||||
};
|
||||
|
||||
await handlePlayMusic(updatedMusic, true);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`使用音源 ${sourcePlatform} 解析失败`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新解析失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化播放状态
|
||||
*/
|
||||
const initializePlayState = async () => {
|
||||
const { useSettingsStore } = await import('./settings');
|
||||
const settingStore = useSettingsStore();
|
||||
|
||||
if (playMusic.value && Object.keys(playMusic.value).length > 0) {
|
||||
try {
|
||||
console.log('恢复上次播放的音乐:', playMusic.value.name);
|
||||
const isPlaying = settingStore.setData.autoPlay;
|
||||
|
||||
if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData) {
|
||||
console.log('恢复B站视频播放', playMusic.value.bilibiliData);
|
||||
playMusic.value.playMusicUrl = undefined;
|
||||
}
|
||||
|
||||
await handlePlayMusic(
|
||||
{ ...playMusic.value, isFirstPlay: true, playMusicUrl: undefined },
|
||||
isPlaying
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('重新获取音乐链接失败:', error);
|
||||
play.value = false;
|
||||
isPlay.value = false;
|
||||
playMusic.value = {} as SongResult;
|
||||
playMusicUrl.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
audioService.setPlaybackRate(playbackRate.value);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
play,
|
||||
isPlay,
|
||||
playMusic,
|
||||
playMusicUrl,
|
||||
musicFull,
|
||||
playbackRate,
|
||||
volume,
|
||||
userPlayIntent,
|
||||
|
||||
// Computed
|
||||
currentSong,
|
||||
isPlaying,
|
||||
|
||||
// Actions
|
||||
setIsPlay,
|
||||
setMusicFull,
|
||||
setPlayMusic,
|
||||
setPlaybackRate,
|
||||
setVolume,
|
||||
getVolume,
|
||||
increaseVolume,
|
||||
decreaseVolume,
|
||||
handlePlayMusic,
|
||||
playAudio,
|
||||
handlePause,
|
||||
checkPlaybackState,
|
||||
reparseCurrentSong,
|
||||
initializePlayState
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'player-core-store',
|
||||
storage: localStorage,
|
||||
pick: ['playMusic', 'playMusicUrl', 'playbackRate', 'volume', 'isPlay']
|
||||
}
|
||||
}
|
||||
);
|
||||
594
src/renderer/store/modules/playlist.ts
Normal file
594
src/renderer/store/modules/playlist.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { createDiscreteApi } from 'naive-ui';
|
||||
import { defineStore, storeToRefs } from 'pinia';
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { preloadNextSong, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { performShuffle, preloadCoverImage } from '@/utils/playerUtils';
|
||||
|
||||
import { useIntelligenceModeStore } from './intelligenceMode';
|
||||
import { usePlayerCoreStore } from './playerCore';
|
||||
import { useSleepTimerStore } from './sleepTimer';
|
||||
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
/**
|
||||
* 播放列表管理 Store
|
||||
* 负责:播放列表、索引、播放模式、预加载、上/下一首
|
||||
*/
|
||||
export const usePlaylistStore = defineStore(
|
||||
'playlist',
|
||||
() => {
|
||||
// ==================== 状态 ====================
|
||||
// 状态将由 pinia-plugin-persistedstate 自动从 localStorage 恢复
|
||||
const playList = shallowRef<SongResult[]>([]);
|
||||
const playListIndex = ref(0);
|
||||
const playMode = ref(0);
|
||||
const originalPlayList = shallowRef<SongResult[]>([]);
|
||||
const playListDrawerVisible = ref(false);
|
||||
|
||||
// ==================== Computed ====================
|
||||
const currentPlayList = computed(() => playList.value);
|
||||
const currentPlayListIndex = computed(() => playListIndex.value);
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 获取歌曲详情并预加载
|
||||
*/
|
||||
const fetchSongs = async (startIndex: number, endIndex: number) => {
|
||||
try {
|
||||
const songs = playList.value.slice(
|
||||
Math.max(0, startIndex),
|
||||
Math.min(endIndex, playList.value.length)
|
||||
);
|
||||
const { getSongDetail } = useSongDetail();
|
||||
|
||||
const detailedSongs = await Promise.all(
|
||||
songs.map(async (song: SongResult) => {
|
||||
try {
|
||||
if (!song.playMusicUrl || (song.source === 'netease' && !song.backgroundColor)) {
|
||||
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 {
|
||||
const { useLyrics } = await import('@/hooks/usePlayerHooks');
|
||||
const { loadLrc } = useLyrics();
|
||||
nextSong.lyric = await loadLrc(nextSong.id);
|
||||
} catch (error) {
|
||||
console.error('加载歌词失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
detailedSongs.forEach((song, index) => {
|
||||
if (song && startIndex + index < playList.value.length) {
|
||||
playList.value[startIndex + index] = song;
|
||||
}
|
||||
});
|
||||
|
||||
// 预加载下一首歌曲的音频和封面
|
||||
if (nextSong) {
|
||||
if (nextSong.playMusicUrl) {
|
||||
preloadNextSong(nextSong.playMusicUrl);
|
||||
}
|
||||
if (nextSong.picUrl) {
|
||||
preloadCoverImage(nextSong.picUrl, getImgUrl);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取歌曲列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能预加载下一首歌曲
|
||||
*/
|
||||
const preloadNextSongs = (currentIndex: number) => {
|
||||
if (playList.value.length <= 1) return;
|
||||
|
||||
let nextIndex: number;
|
||||
|
||||
if (playMode.value === 0) {
|
||||
// 顺序播放模式
|
||||
if (currentIndex >= playList.value.length - 1) {
|
||||
return;
|
||||
}
|
||||
nextIndex = currentIndex + 1;
|
||||
} else {
|
||||
// 循环播放和随机播放模式
|
||||
nextIndex = (currentIndex + 1) % playList.value.length;
|
||||
}
|
||||
|
||||
const endIndex = Math.min(nextIndex + 2, playList.value.length);
|
||||
|
||||
if (nextIndex < playList.value.length) {
|
||||
fetchSongs(nextIndex, endIndex);
|
||||
|
||||
// 循环模式且接近列表末尾,预加载列表开头
|
||||
if (
|
||||
(playMode.value === 1 || playMode.value === 2) &&
|
||||
nextIndex + 1 >= playList.value.length &&
|
||||
playList.value.length > 2
|
||||
) {
|
||||
setTimeout(() => {
|
||||
fetchSongs(0, 1);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 应用随机播放
|
||||
*/
|
||||
const shufflePlayList = () => {
|
||||
if (playList.value.length <= 1) return;
|
||||
|
||||
// 保存原始播放列表
|
||||
if (originalPlayList.value.length === 0) {
|
||||
originalPlayList.value = [...playList.value];
|
||||
}
|
||||
|
||||
const currentSong = playList.value[playListIndex.value];
|
||||
const shuffledList = performShuffle(playList.value, currentSong);
|
||||
|
||||
playList.value = shuffledList;
|
||||
playListIndex.value = 0;
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
};
|
||||
|
||||
/**
|
||||
* 恢复原始播放列表顺序
|
||||
*/
|
||||
const restoreOriginalOrder = () => {
|
||||
if (originalPlayList.value.length === 0) return;
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { playMusic } = storeToRefs(playerCore);
|
||||
const currentSong = playMusic.value;
|
||||
const originalIndex = originalPlayList.value.findIndex((song) => song.id === currentSong.id);
|
||||
|
||||
playList.value = [...originalPlayList.value];
|
||||
playListIndex.value = Math.max(0, originalIndex);
|
||||
|
||||
originalPlayList.value = [];
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置播放列表
|
||||
*/
|
||||
const setPlayList = (
|
||||
list: SongResult[],
|
||||
keepIndex: boolean = false,
|
||||
fromIntelligenceMode: boolean = false
|
||||
) => {
|
||||
// 如果不是从心动模式调用,清除心动模式状态
|
||||
if (!fromIntelligenceMode) {
|
||||
const intelligenceStore = useIntelligenceModeStore();
|
||||
if (intelligenceStore.isIntelligenceMode) {
|
||||
intelligenceStore.clearIntelligenceMode();
|
||||
}
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
playList.value = [];
|
||||
playListIndex.value = 0;
|
||||
originalPlayList.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { playMusic } = storeToRefs(playerCore);
|
||||
|
||||
// 根据当前播放模式处理新的播放列表
|
||||
if (playMode.value === 2) {
|
||||
// 随机模式
|
||||
console.log('随机模式下设置新播放列表,保存原始顺序并洗牌');
|
||||
|
||||
originalPlayList.value = [...list];
|
||||
|
||||
const currentSong = playMusic.value;
|
||||
const shuffledList = performShuffle(list, currentSong);
|
||||
|
||||
if (currentSong && currentSong.id) {
|
||||
const currentSongIndex = shuffledList.findIndex((song) => song.id === currentSong.id);
|
||||
playListIndex.value =
|
||||
currentSongIndex !== -1 ? 0 : keepIndex ? Math.max(0, playListIndex.value) : 0;
|
||||
} else {
|
||||
playListIndex.value = keepIndex ? Math.max(0, playListIndex.value) : 0;
|
||||
}
|
||||
|
||||
playList.value = shuffledList;
|
||||
} else {
|
||||
console.log('顺序/循环模式下设置新播放列表');
|
||||
if (originalPlayList.value.length > 0) {
|
||||
originalPlayList.value = [];
|
||||
}
|
||||
|
||||
if (!keepIndex) {
|
||||
const foundIndex = list.findIndex((item) => item.id === playMusic.value.id);
|
||||
playListIndex.value = foundIndex !== -1 ? foundIndex : 0;
|
||||
}
|
||||
|
||||
playList.value = list;
|
||||
}
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加到下一首播放
|
||||
*/
|
||||
const addToNextPlay = (song: SongResult) => {
|
||||
const list = [...playList.value];
|
||||
const currentIndex = playListIndex.value;
|
||||
|
||||
// 如果歌曲已在播放列表中,先移除
|
||||
const existingIndex = list.findIndex((item) => item.id === song.id);
|
||||
if (existingIndex !== -1) {
|
||||
list.splice(existingIndex, 1);
|
||||
if (existingIndex <= currentIndex) {
|
||||
playListIndex.value = Math.max(0, playListIndex.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 插入到当前播放歌曲的下一个位置
|
||||
const insertIndex = playListIndex.value + 1;
|
||||
list.splice(insertIndex, 0, song);
|
||||
|
||||
setPlayList(list, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从播放列表移除歌曲
|
||||
*/
|
||||
const removeFromPlayList = (id: number | string) => {
|
||||
const index = playList.value.findIndex((item) => item.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { playMusic } = storeToRefs(playerCore);
|
||||
|
||||
// 如果删除的是当前播放的歌曲,先切换到下一首
|
||||
if (id === playMusic.value.id) {
|
||||
nextPlay();
|
||||
}
|
||||
|
||||
const newPlayList = [...playList.value];
|
||||
newPlayList.splice(index, 1);
|
||||
setPlayList(newPlayList);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空播放列表
|
||||
*/
|
||||
const clearPlayAll = async () => {
|
||||
const { audioService } = await import('@/services/audioService');
|
||||
const playerCore = usePlayerCoreStore();
|
||||
|
||||
audioService.pause();
|
||||
setTimeout(() => {
|
||||
playerCore.playMusic = {} as SongResult;
|
||||
playerCore.playMusicUrl = '';
|
||||
playList.value = [];
|
||||
playListIndex.value = 0;
|
||||
originalPlayList.value = [];
|
||||
// 只清除 playerCore 的 localStorage(这些由 playerCore store 管理)
|
||||
localStorage.removeItem('currentPlayMusic');
|
||||
localStorage.removeItem('currentPlayMusicUrl');
|
||||
// playlist 状态由 pinia-plugin-persistedstate 自动管理
|
||||
}, 500);
|
||||
};
|
||||
|
||||
/**
|
||||
* 切换播放模式
|
||||
*/
|
||||
const togglePlayMode = async () => {
|
||||
const { useUserStore } = await import('./user');
|
||||
const userStore = useUserStore();
|
||||
const wasIntelligence = playMode.value === 3;
|
||||
const newMode = (playMode.value + 1) % 4;
|
||||
const wasRandom = playMode.value === 2;
|
||||
const isRandom = newMode === 2;
|
||||
const isIntelligence = newMode === 3;
|
||||
|
||||
// 如果要切换到心动模式,但用户未使用cookie登录,则跳过
|
||||
if (isIntelligence && (!userStore.user || userStore.loginType !== 'cookie')) {
|
||||
console.log('跳过心动模式:需要cookie登录');
|
||||
playMode.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
playMode.value = newMode;
|
||||
// pinia-plugin-persistedstate 会自动保存状态
|
||||
|
||||
// 切换到随机模式时洗牌
|
||||
if (isRandom && !wasRandom && playList.value.length > 0) {
|
||||
shufflePlayList();
|
||||
console.log('切换到随机模式,洗牌播放列表');
|
||||
}
|
||||
|
||||
// 从随机模式切换出去时恢复原始顺序
|
||||
if (!isRandom && wasRandom && !isIntelligence) {
|
||||
restoreOriginalOrder();
|
||||
console.log('切换出随机模式,恢复原始顺序');
|
||||
}
|
||||
|
||||
// 切换到心动模式
|
||||
if (isIntelligence && !wasIntelligence) {
|
||||
console.log('切换到心动模式');
|
||||
const intelligenceStore = useIntelligenceModeStore();
|
||||
await intelligenceStore.playIntelligenceMode();
|
||||
}
|
||||
|
||||
// 从心动模式切换出去
|
||||
if (!isIntelligence && wasIntelligence) {
|
||||
console.log('退出心动模式');
|
||||
const intelligenceStore = useIntelligenceModeStore();
|
||||
intelligenceStore.clearIntelligenceMode();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 下一首
|
||||
*/
|
||||
const _nextPlay = async () => {
|
||||
try {
|
||||
if (playList.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const sleepTimerStore = useSleepTimerStore();
|
||||
|
||||
// 检查是否是播放列表的最后一首且设置了播放列表结束定时
|
||||
if (
|
||||
playMode.value === 0 &&
|
||||
playListIndex.value === playList.value.length - 1 &&
|
||||
sleepTimerStore.sleepTimer.type === 'end'
|
||||
) {
|
||||
sleepTimerStore.stopPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = playListIndex.value;
|
||||
const nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
|
||||
const nextSong = { ...playList.value[nowPlayListIndex] };
|
||||
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
|
||||
const success = await playerCore.handlePlayMusic(nextSong, true);
|
||||
|
||||
if (success) {
|
||||
sleepTimerStore.handleSongChange();
|
||||
} else {
|
||||
console.error('播放下一首失败');
|
||||
playListIndex.value = currentIndex;
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换下一首出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const nextPlay = useThrottleFn(_nextPlay, 500);
|
||||
|
||||
/**
|
||||
* 上一首
|
||||
*/
|
||||
const _prevPlay = async () => {
|
||||
try {
|
||||
if (playList.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const currentIndex = playListIndex.value;
|
||||
const nowPlayListIndex =
|
||||
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
|
||||
|
||||
const prevSong = { ...playList.value[nowPlayListIndex] };
|
||||
playListIndex.value = nowPlayListIndex;
|
||||
|
||||
let success = false;
|
||||
let retryCount = 0;
|
||||
const maxRetries = 2;
|
||||
|
||||
while (!success && retryCount < maxRetries) {
|
||||
success = await playerCore.handlePlayMusic(prevSong);
|
||||
|
||||
if (!success) {
|
||||
retryCount++;
|
||||
console.error(`播放上一首失败,尝试 ${retryCount}/${maxRetries}`);
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error('多次尝试播放失败,将从播放列表中移除此歌曲');
|
||||
const newPlayList = [...playList.value];
|
||||
newPlayList.splice(nowPlayListIndex, 1);
|
||||
|
||||
if (newPlayList.length > 0) {
|
||||
const keepCurrentIndexPosition = true;
|
||||
setPlayList(newPlayList, keepCurrentIndexPosition);
|
||||
|
||||
if (newPlayList.length === 1) {
|
||||
playListIndex.value = 0;
|
||||
} else {
|
||||
const newPrevIndex =
|
||||
(playListIndex.value - 1 + newPlayList.length) % newPlayList.length;
|
||||
playListIndex.value = newPrevIndex;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
prevPlay();
|
||||
}, 300);
|
||||
return;
|
||||
} else {
|
||||
console.error('播放列表为空,停止尝试');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
console.error('所有尝试都失败,无法播放上一首歌曲');
|
||||
playListIndex.value = currentIndex;
|
||||
playerCore.setIsPlay(false);
|
||||
message.error(i18n.global.t('player.playFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换上一首出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const prevPlay = useThrottleFn(_prevPlay, 500);
|
||||
|
||||
/**
|
||||
* 设置播放列表抽屉显示状态
|
||||
*/
|
||||
const setPlayListDrawerVisible = (value: boolean) => {
|
||||
playListDrawerVisible.value = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置播放(兼容旧API)
|
||||
*/
|
||||
const setPlay = async (song: SongResult) => {
|
||||
try {
|
||||
const playerCore = usePlayerCoreStore();
|
||||
|
||||
// 检查URL是否已过期
|
||||
if (song.expiredAt && song.expiredAt < Date.now()) {
|
||||
console.info(`歌曲URL已过期,重新获取: ${song.name}`);
|
||||
song.playMusicUrl = undefined;
|
||||
song.expiredAt = undefined;
|
||||
}
|
||||
|
||||
// 如果是当前正在播放的音乐,则切换播放/暂停状态
|
||||
if (
|
||||
playerCore.playMusic.id === song.id &&
|
||||
playerCore.playMusic.playMusicUrl === song.playMusicUrl &&
|
||||
!song.isFirstPlay
|
||||
) {
|
||||
if (playerCore.play) {
|
||||
playerCore.setPlayMusic(false);
|
||||
const { audioService } = await import('@/services/audioService');
|
||||
audioService.getCurrentSound()?.pause();
|
||||
playerCore.userPlayIntent = false;
|
||||
} else {
|
||||
playerCore.setPlayMusic(true);
|
||||
playerCore.userPlayIntent = true;
|
||||
const { audioService } = await import('@/services/audioService');
|
||||
const sound = audioService.getCurrentSound();
|
||||
if (sound) {
|
||||
sound.play();
|
||||
playerCore.checkPlaybackState(playerCore.playMusic);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (song.isFirstPlay) {
|
||||
song.isFirstPlay = false;
|
||||
}
|
||||
|
||||
// 查找歌曲在播放列表中的索引
|
||||
const songIndex = playList.value.findIndex(
|
||||
(item: SongResult) => item.id === song.id && item.source === song.source
|
||||
);
|
||||
|
||||
// 更新播放索引
|
||||
if (songIndex !== -1 && songIndex !== playListIndex.value) {
|
||||
console.log('歌曲索引不匹配,更新为:', songIndex);
|
||||
playListIndex.value = songIndex;
|
||||
}
|
||||
|
||||
const success = await playerCore.handlePlayMusic(song);
|
||||
|
||||
// playerCore 的状态由其自己的 store 管理
|
||||
|
||||
if (success) {
|
||||
playerCore.isPlay = true;
|
||||
|
||||
// 预加载下一首歌曲
|
||||
if (songIndex !== -1) {
|
||||
setTimeout(() => {
|
||||
preloadNextSongs(playListIndex.value);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error('设置播放失败:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 初始化播放列表
|
||||
* 注意:状态已由 pinia-plugin-persistedstate 自动恢复
|
||||
* 这里只需要处理特殊逻辑(如随机模式的恢复)
|
||||
*/
|
||||
const initializePlaylist = async () => {
|
||||
// 重启后恢复随机播放状态
|
||||
if (playMode.value === 2 && playList.value.length > 0) {
|
||||
if (originalPlayList.value.length === 0) {
|
||||
console.log('重启后恢复随机播放模式,重新洗牌播放列表');
|
||||
shufflePlayList();
|
||||
} else {
|
||||
console.log('重启后恢复随机播放模式,播放列表已是洗牌状态');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
playList,
|
||||
playListIndex,
|
||||
playMode,
|
||||
originalPlayList,
|
||||
playListDrawerVisible,
|
||||
|
||||
// Computed
|
||||
currentPlayList,
|
||||
currentPlayListIndex,
|
||||
|
||||
// Actions
|
||||
setPlayList,
|
||||
addToNextPlay,
|
||||
removeFromPlayList,
|
||||
clearPlayAll,
|
||||
togglePlayMode,
|
||||
shufflePlayList,
|
||||
restoreOriginalOrder,
|
||||
preloadNextSongs,
|
||||
nextPlay: nextPlay as unknown as typeof _nextPlay,
|
||||
prevPlay: prevPlay as unknown as typeof _prevPlay,
|
||||
setPlayListDrawerVisible,
|
||||
setPlay,
|
||||
initializePlaylist,
|
||||
fetchSongs
|
||||
};
|
||||
},
|
||||
{
|
||||
// 配置 pinia-plugin-persistedstate
|
||||
persist: {
|
||||
key: 'playlist-store',
|
||||
storage: localStorage,
|
||||
// 持久化所有状态,除了 playListDrawerVisible(UI 状态不需要持久化)
|
||||
pick: ['playList', 'playListIndex', 'playMode', 'originalPlayList']
|
||||
}
|
||||
}
|
||||
);
|
||||
264
src/renderer/store/modules/sleepTimer.ts
Normal file
264
src/renderer/store/modules/sleepTimer.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '@/utils/playerUtils';
|
||||
|
||||
// 定时关闭类型
|
||||
export enum SleepTimerType {
|
||||
NONE = 'none',
|
||||
TIME = 'time',
|
||||
SONGS = 'songs',
|
||||
PLAYLIST_END = 'end'
|
||||
}
|
||||
|
||||
// 定时关闭信息
|
||||
export interface SleepTimerInfo {
|
||||
type: SleepTimerType;
|
||||
value: number;
|
||||
endTime?: number;
|
||||
startSongIndex?: number;
|
||||
remainingSongs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 定时关闭管理 Store
|
||||
* 负责:定时关闭功能
|
||||
*/
|
||||
export const useSleepTimerStore = defineStore('sleepTimer', () => {
|
||||
// ==================== 状态 ====================
|
||||
const sleepTimer = ref<SleepTimerInfo>(
|
||||
getLocalStorageItem('sleepTimer', {
|
||||
type: SleepTimerType.NONE,
|
||||
value: 0
|
||||
})
|
||||
);
|
||||
const showSleepTimer = ref(false);
|
||||
const timerInterval = ref<number | null>(null);
|
||||
|
||||
// ==================== Computed ====================
|
||||
const currentSleepTimer = computed(() => sleepTimer.value);
|
||||
const hasSleepTimerActive = computed(() => sleepTimer.value.type !== SleepTimerType.NONE);
|
||||
|
||||
const sleepTimerRemainingTime = computed(() => {
|
||||
if (sleepTimer.value.type === SleepTimerType.TIME && sleepTimer.value.endTime) {
|
||||
const remaining = Math.max(0, sleepTimer.value.endTime - Date.now());
|
||||
return Math.ceil(remaining / 60000);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const sleepTimerRemainingSongs = computed(() => {
|
||||
if (sleepTimer.value.type === SleepTimerType.SONGS) {
|
||||
return sleepTimer.value.remainingSongs || 0;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
/**
|
||||
* 按时间设置定时关闭
|
||||
*/
|
||||
const setSleepTimerByTime = (minutes: number) => {
|
||||
clearSleepTimer();
|
||||
|
||||
if (minutes <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endTime = Date.now() + minutes * 60 * 1000;
|
||||
|
||||
sleepTimer.value = {
|
||||
type: SleepTimerType.TIME,
|
||||
value: minutes,
|
||||
endTime
|
||||
};
|
||||
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
|
||||
timerInterval.value = window.setInterval(() => {
|
||||
checkSleepTimer();
|
||||
}, 1000) as unknown as number;
|
||||
|
||||
console.log(`设置定时关闭: ${minutes}分钟后`);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按歌曲数设置定时关闭
|
||||
*/
|
||||
const setSleepTimerBySongs = (songs: number) => {
|
||||
clearSleepTimer();
|
||||
|
||||
if (songs <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { usePlaylistStore } = require('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
sleepTimer.value = {
|
||||
type: SleepTimerType.SONGS,
|
||||
value: songs,
|
||||
startSongIndex: playlistStore.playListIndex,
|
||||
remainingSongs: songs
|
||||
};
|
||||
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
|
||||
console.log(`设置定时关闭: 再播放${songs}首歌后`);
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 播放列表结束时关闭
|
||||
*/
|
||||
const setSleepTimerAtPlaylistEnd = () => {
|
||||
clearSleepTimer();
|
||||
|
||||
sleepTimer.value = {
|
||||
type: SleepTimerType.PLAYLIST_END,
|
||||
value: 0
|
||||
};
|
||||
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
|
||||
console.log('设置定时关闭: 播放列表结束时');
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取消定时关闭
|
||||
*/
|
||||
const clearSleepTimer = () => {
|
||||
if (timerInterval.value) {
|
||||
window.clearInterval(timerInterval.value);
|
||||
timerInterval.value = null;
|
||||
}
|
||||
|
||||
sleepTimer.value = {
|
||||
type: SleepTimerType.NONE,
|
||||
value: 0
|
||||
};
|
||||
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
|
||||
console.log('取消定时关闭');
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查定时关闭是否应该触发
|
||||
*/
|
||||
const checkSleepTimer = () => {
|
||||
if (sleepTimer.value.type === SleepTimerType.NONE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sleepTimer.value.type === SleepTimerType.TIME && sleepTimer.value.endTime) {
|
||||
if (Date.now() >= sleepTimer.value.endTime) {
|
||||
stopPlayback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止播放并清除定时器
|
||||
*/
|
||||
const stopPlayback = async () => {
|
||||
console.log('定时器触发:停止播放');
|
||||
|
||||
const { usePlayerCoreStore } = await import('./playerCore');
|
||||
const playerCore = usePlayerCoreStore();
|
||||
const { audioService } = await import('@/services/audioService');
|
||||
|
||||
if (playerCore.isPlaying) {
|
||||
playerCore.setIsPlay(false);
|
||||
audioService.pause();
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
if (window.electron?.ipcRenderer) {
|
||||
window.electron.ipcRenderer.send('show-notification', {
|
||||
title: i18n.global.t('player.sleepTimer.timerEnded'),
|
||||
body: i18n.global.t('player.sleepTimer.playbackStopped')
|
||||
});
|
||||
}
|
||||
|
||||
clearSleepTimer();
|
||||
};
|
||||
|
||||
/**
|
||||
* 监听歌曲变化,处理按歌曲数定时和播放列表结束定时
|
||||
*/
|
||||
const handleSongChange = async () => {
|
||||
console.log('歌曲已切换,检查定时器状态:', sleepTimer.value);
|
||||
|
||||
// 处理按歌曲数定时
|
||||
if (
|
||||
sleepTimer.value.type === SleepTimerType.SONGS &&
|
||||
sleepTimer.value.remainingSongs !== undefined
|
||||
) {
|
||||
sleepTimer.value.remainingSongs--;
|
||||
console.log(`剩余歌曲数: ${sleepTimer.value.remainingSongs}`);
|
||||
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
|
||||
if (sleepTimer.value.remainingSongs <= 0) {
|
||||
console.log('已播放完设定的歌曲数,停止播放');
|
||||
stopPlayback();
|
||||
setTimeout(() => {
|
||||
stopPlayback();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理播放列表结束定时
|
||||
if (sleepTimer.value.type === SleepTimerType.PLAYLIST_END) {
|
||||
const { usePlaylistStore } = await import('./playlist');
|
||||
const playlistStore = usePlaylistStore();
|
||||
|
||||
const isLastSong = playlistStore.playListIndex === playlistStore.playList.length - 1;
|
||||
|
||||
if (isLastSong && playlistStore.playMode !== 1) {
|
||||
console.log('已到达播放列表末尾,将在当前歌曲结束后停止播放');
|
||||
sleepTimer.value = {
|
||||
type: SleepTimerType.SONGS,
|
||||
value: 1,
|
||||
remainingSongs: 1
|
||||
};
|
||||
setLocalStorageItem('sleepTimer', sleepTimer.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置定时器弹窗显示状态
|
||||
*/
|
||||
const setShowSleepTimer = (value: boolean) => {
|
||||
showSleepTimer.value = value;
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
sleepTimer,
|
||||
showSleepTimer,
|
||||
|
||||
// Computed
|
||||
currentSleepTimer,
|
||||
hasSleepTimerActive,
|
||||
sleepTimerRemainingTime,
|
||||
sleepTimerRemainingSongs,
|
||||
|
||||
// Actions
|
||||
setSleepTimerByTime,
|
||||
setSleepTimerBySongs,
|
||||
setSleepTimerAtPlaylistEnd,
|
||||
clearSleepTimer,
|
||||
checkSleepTimer,
|
||||
stopPlayback,
|
||||
handleSongChange,
|
||||
setShowSleepTimer
|
||||
};
|
||||
});
|
||||
135
src/renderer/utils/playerUtils.ts
Normal file
135
src/renderer/utils/playerUtils.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { SongResult } from '@/types/music';
|
||||
|
||||
/**
|
||||
* 从 localStorage 获取项目,带类型安全和错误处理
|
||||
*/
|
||||
export function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 localStorage 项目,自动序列化
|
||||
*/
|
||||
export function setLocalStorageItem<T>(key: string, value: T): void {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Failed to save to localStorage: ${key}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较B站视频ID的辅助函数
|
||||
*/
|
||||
export const isBilibiliIdMatch = (id1: string | number, id2: string | number): boolean => {
|
||||
const str1 = String(id1);
|
||||
const str2 = String(id2);
|
||||
|
||||
// 如果两个ID都不包含--分隔符,直接比较
|
||||
if (!str1.includes('--') && !str2.includes('--')) {
|
||||
return str1 === str2;
|
||||
}
|
||||
|
||||
// 处理B站视频ID
|
||||
if (str1.includes('--') || str2.includes('--')) {
|
||||
// 尝试从ID中提取bvid和cid
|
||||
const extractBvIdAndCid = (str: string) => {
|
||||
if (!str.includes('--')) return { bvid: '', cid: '' };
|
||||
const parts = str.split('--');
|
||||
if (parts.length >= 3) {
|
||||
// bvid--pid--cid格式
|
||||
return { bvid: parts[0], cid: parts[2] };
|
||||
} else if (parts.length === 2) {
|
||||
// 旧格式或其他格式
|
||||
return { bvid: '', cid: parts[1] };
|
||||
}
|
||||
return { bvid: '', cid: '' };
|
||||
};
|
||||
|
||||
const { bvid: bvid1, cid: cid1 } = extractBvIdAndCid(str1);
|
||||
const { bvid: bvid2, cid: cid2 } = extractBvIdAndCid(str2);
|
||||
|
||||
// 如果两个ID都有bvid,比较bvid和cid
|
||||
if (bvid1 && bvid2) {
|
||||
return bvid1 === bvid2 && cid1 === cid2;
|
||||
}
|
||||
|
||||
// 其他情况,只比较cid部分
|
||||
if (cid1 && cid2) {
|
||||
return cid1 === cid2;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认情况,直接比较完整ID
|
||||
return str1 === str2;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fisher-Yates 洗牌算法
|
||||
* @param list 歌曲列表
|
||||
* @param currentSong 当前歌曲(会被放在第一位)
|
||||
*/
|
||||
export const performShuffle = (list: SongResult[], currentSong?: SongResult): SongResult[] => {
|
||||
if (list.length <= 1) return [...list];
|
||||
|
||||
const result: SongResult[] = [];
|
||||
const remainingSongs = [...list];
|
||||
|
||||
// 如果指定了当前歌曲,先把它放在第一位
|
||||
if (currentSong && currentSong.id) {
|
||||
const currentSongIndex = remainingSongs.findIndex((song) => song.id === currentSong.id);
|
||||
if (currentSongIndex !== -1) {
|
||||
// 把当前歌曲放在第一位
|
||||
result.push(remainingSongs.splice(currentSongIndex, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// 对剩余歌曲进行洗牌
|
||||
if (remainingSongs.length > 0) {
|
||||
// Fisher-Yates 洗牌算法
|
||||
for (let i = remainingSongs.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[remainingSongs[i], remainingSongs[j]] = [remainingSongs[j], remainingSongs[i]];
|
||||
}
|
||||
|
||||
// 把洗牌后的歌曲添加到结果中
|
||||
result.push(...remainingSongs);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 预加载封面图片
|
||||
*/
|
||||
export const preloadCoverImage = (
|
||||
picUrl: string,
|
||||
getImgUrl: (url: string, size: string) => string
|
||||
) => {
|
||||
if (!picUrl) return;
|
||||
|
||||
try {
|
||||
const imageUrl = getImgUrl(picUrl, '500y500');
|
||||
console.log('预加载封面图片:', imageUrl);
|
||||
|
||||
// 创建一个 Image 对象来预加载图片
|
||||
const img = new Image();
|
||||
img.src = imageUrl;
|
||||
|
||||
// 可选:添加加载完成和错误的回调
|
||||
img.onload = () => {
|
||||
console.log('封面图片预加载成功:', imageUrl);
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error('封面图片预加载失败:', imageUrl);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('预加载封面图片出错:', error);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user