diff --git a/.cursor/rules/music-vue-rule.mdc b/.cursor/rules/music-vue-rule.mdc
new file mode 100644
index 0000000..4dd9f3d
--- /dev/null
+++ b/.cursor/rules/music-vue-rule.mdc
@@ -0,0 +1,90 @@
+---
+description: 这个规则是项目描述
+globs:
+alwaysApply: false
+---
+您是 TypeScript、Node.j、Vue3、Electron、naive-ui、VueUse 和 Tailwind 方面的专家。
+
+项目结构
+- 这是 Electron 项目,使用 Vue3 和 Vuex 进行开发的第三方网易云音乐播放器。
+- 使用 Vue3 和 Vuex 进行开发。
+- 使用 Vuex 进行状态管理。
+- 使用 VueUse 进行状态管理。
+- 使用 naive-ui 进行 UI 设计。
+- 使用 Tailwind 进行样式设计。
+- 使用 remixicon 进行图标设计。
+- 使用 vite 进行项目构建。
+- 使用 electron-builder 进行项目打包。
+- 使用 electron-vite 进行项目开发。
+- 使用 netease-cloud-music-api 进行网易云音乐接口调用。
+- 使用 electron-store 进行本地数据存储。
+- 使用 axios 进行网络请求。
+- 使用 @unblockneteasemusic/server 进行网易云音乐解锁。
+- 使用 vue-i18n 进行国际化。目录为 src/i18n
+
+代码风格和结构
+- 编写简洁、技术性的 TypeScript 代码,并提供准确示例。
+- 使用组合 API 和声明性编程模式;避免使用选项 API。
+- 优先使用迭代和模块化,而不是代码重复。
+- 使用带有助动词的描述性变量名称(例如 isLoading、hasError)。
+- 结构文件:导出的组件、可组合项、帮助程序、静态内容、类型。
+
+命名约定
+- 使用带破折号的小写字母表示目录(例如 components/auth-wizard)。
+- 使用 PascalCase 表示组件名称(例如 AuthWizard.vue)。
+- 使用 camelCase 表示可组合项(例如 useAuthState.ts)。
+
+TypeScript 用法
+- 对所有代码使用 TypeScript;优先使用类型而不是接口。
+- 避免使用枚举;改用 const 对象。
+- 将 Vue 3 与 TypeScript 结合使用,利用 defineComponent 和 PropType。
+
+语法和格式
+- 对方法和计算属性使用箭头函数。
+- 避免在条件中使用不必要的花括号;对简单语句使用简洁的语法。
+- 使用模板语法进行声明式渲染。
+
+UI 和样式
+- 使用 naive-ui 和 Tailwind 进行组件和样式设计。
+- 使用 Tailwind CSS 实现响应式设计;采用移动优先方法。
+
+图标
+- 使用 remixicon 作为图标库。
+
+性能优化
+- 对异步组件使用 Suspense。
+- 为路由和组件实现延迟加载。
+
+关键约定
+- 对常见可组合项和实用函数使用 VueUse。
+- 使用 Vuex 进行状态管理。
+- 优化 Web Vitals(LCP、CLS、FID)。
+
+
+Vue 3 和 Composition API 最佳实践
+- 使用
+
+
diff --git a/src/renderer/components/MusicBar.vue b/src/renderer/components/MusicBar.vue
new file mode 100644
index 0000000..c904a2a
--- /dev/null
+++ b/src/renderer/components/MusicBar.vue
@@ -0,0 +1,410 @@
+
+
+
+
+
+
+
+
+
+ {{ playerStore.currentSong ? playerStore.currentSong.name : '' }}
+
+
+ {{ getArtistName(playerStore.currentSong) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ formatTime(currentTime) }}
+
+
{{ formatTime(duration) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/components/common/BilibiliItem.vue b/src/renderer/components/common/BilibiliItem.vue
new file mode 100644
index 0000000..7ef5684
--- /dev/null
+++ b/src/renderer/components/common/BilibiliItem.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
{{ formatDuration(item.duration) }}
+
+
+
+
{{ item.author }}
+
+ {{ formatNumber(item.view) }}
+ {{ formatNumber(item.danmaku) }}
+
+
+
+
+
+
+
+
diff --git a/src/renderer/const/bar-const.ts b/src/renderer/const/bar-const.ts
index 3040ac5..49f174d 100644
--- a/src/renderer/const/bar-const.ts
+++ b/src/renderer/const/bar-const.ts
@@ -45,6 +45,10 @@ export const SEARCH_TYPES = [
{
label: 'MV',
key: 1004
+ },
+ {
+ label: 'B站',
+ key: 2000
}
// {
// label: '歌词',
@@ -63,3 +67,12 @@ export const SEARCH_TYPES = [
// key: 1018,
// },
];
+
+export const SEARCH_TYPE = {
+ MUSIC: 1, // 单曲
+ ALBUM: 10, // 专辑
+ ARTIST: 100, // 歌手
+ PLAYLIST: 1000, // 歌单
+ MV: 1004, // MV
+ BILIBILI: 2000 // B站视频
+} as const;
diff --git a/src/renderer/layout/components/MusicFull.vue b/src/renderer/layout/components/MusicFull.vue
index bafcb19..b4ff89a 100644
--- a/src/renderer/layout/components/MusicFull.vue
+++ b/src/renderer/layout/components/MusicFull.vue
@@ -112,7 +112,7 @@
-
+
{{ t('player.lrc.noLrc') }}
diff --git a/src/renderer/store/modules/player.ts b/src/renderer/store/modules/player.ts
index 7c33d80..65a34ce 100644
--- a/src/renderer/store/modules/player.ts
+++ b/src/renderer/store/modules/player.ts
@@ -24,13 +24,27 @@ function getLocalStorageItem(key: string, defaultValue: T): T {
}
}
-export const getSongUrl = async (id: number, songData: any, isDownloaded: boolean = false) => {
- const { data } = await getMusicUrl(id, isDownloaded);
+export const getSongUrl = async (
+ id: string | number,
+ songData: SongResult,
+ isDownloaded: boolean = false
+) => {
+ if (songData.playMusicUrl) {
+ return songData.playMusicUrl;
+ }
+
+ if (songData.source === 'bilibili' && songData.bilibiliData) {
+ console.log('加载B站音频URL');
+ return songData.playMusicUrl || '';
+ }
+
+ const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
+ const { data } = await getMusicUrl(numericId, isDownloaded);
let url = '';
let songDetail = null;
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
- const res = await getParsingMusicUrl(id, songData);
+ const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
url = res.data.data.url;
songDetail = res.data.data;
} else {
@@ -45,6 +59,7 @@ export const getSongUrl = async (id: number, songData: any, isDownloaded: boolea
url = url || data.data[0].url;
return url;
};
+
const parseTime = (timeString: string): number => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
@@ -71,9 +86,18 @@ const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: numbe
return { lyrics, times };
};
-export const loadLrc = async (playMusicId: number): Promise => {
+export const loadLrc = async (id: string | number): Promise => {
+ if (typeof id === 'string' && id.includes('--')) {
+ console.log('B站音频,无需加载歌词');
+ return {
+ lrcTimeArray: [],
+ lrcArray: []
+ };
+ }
+
try {
- const { data } = await getMusicLrc(playMusicId);
+ const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
+ const { data } = await getMusicLrc(numericId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
const tlyric: Record = {};
@@ -102,8 +126,19 @@ export const loadLrc = async (playMusicId: number): Promise => {
const getSongDetail = async (playMusic: SongResult) => {
playMusic.playLoading = true;
- const playMusicUrl =
- playMusic.playMusicUrl || (await getSongUrl(playMusic.id, cloneDeep(playMusic)));
+
+ if (playMusic.source === 'bilibili') {
+ console.log('处理B站音频详情');
+ const { backgroundColor, primaryColor } =
+ playMusic.backgroundColor && playMusic.primaryColor
+ ? playMusic
+ : await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
+
+ playMusic.playLoading = false;
+ return { ...playMusic, backgroundColor, primaryColor } as SongResult;
+ }
+
+ const playMusicUrl = playMusic.playMusicUrl || (await getSongUrl(playMusic.id, playMusic));
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
@@ -115,7 +150,6 @@ const getSongDetail = async (playMusic: SongResult) => {
const preloadNextSong = (nextSongUrl: string) => {
try {
- // 限制同时预加载的数量
if (preloadingSounds.value.length >= 2) {
const oldestSound = preloadingSounds.value.shift();
if (oldestSound) {
@@ -132,7 +166,6 @@ const preloadNextSong = (nextSongUrl: string) => {
preloadingSounds.value.push(sound);
- // 添加加载错误处理
sound.on('loaderror', () => {
console.error('预加载音频失败:', nextSongUrl);
const index = preloadingSounds.value.indexOf(sound);
@@ -156,8 +189,7 @@ const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex:
const detailedSongs = await Promise.all(
songs.map(async (song: SongResult) => {
try {
- // 如果歌曲详情已经存在,就不重复请求
- if (!song.playMusicUrl) {
+ if (!song.playMusicUrl || (song.source === 'netease' && !song.backgroundColor)) {
return await getSongDetail(song);
}
return song;
@@ -168,7 +200,6 @@ const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex:
})
);
- // 加载下一首的歌词
const nextSong = detailedSongs[0];
if (nextSong && !(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
try {
@@ -178,14 +209,12 @@ const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex:
}
}
- // 更新播放列表中的歌曲详情
detailedSongs.forEach((song, index) => {
if (song && startIndex + index < playList.length) {
playList[startIndex + index] = song;
}
});
- // 只预加载下一首歌曲
if (nextSong && nextSong.playMusicUrl) {
preloadNextSong(nextSong.playMusicUrl);
}
@@ -194,7 +223,6 @@ const fetchSongs = async (playList: SongResult[], startIndex: number, endIndex:
}
};
-// 异步加载歌词的方法
const loadLrcAsync = async (playMusic: SongResult) => {
if (playMusic.lyric && playMusic.lyric.lrcTimeArray.length > 0) {
return;
@@ -204,7 +232,6 @@ const loadLrcAsync = async (playMusic: SongResult) => {
};
export const usePlayerStore = defineStore('player', () => {
- // 状态
const play = ref(false);
const isPlay = ref(false);
const playMusic = ref(getLocalStorageItem('currentPlayMusic', {} as SongResult));
@@ -216,7 +243,6 @@ export const usePlayerStore = defineStore('player', () => {
const favoriteList = ref(getLocalStorageItem('favoriteList', []));
const savedPlayProgress = ref();
- // 计算属性
const currentSong = computed(() => playMusic.value);
const isPlaying = computed(() => isPlay.value);
const currentPlayList = computed(() => playList.value);
@@ -227,24 +253,36 @@ export const usePlayerStore = defineStore('player', () => {
playMusic.value = updatedPlayMusic;
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
- // 记录当前设置的播放状态
play.value = isPlay;
- // 每次设置新歌曲时,立即更新 localStorage
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
localStorage.setItem('isPlaying', play.value.toString());
- // 设置网页标题
- document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
+ let title = updatedPlayMusic.name;
+
+ if (updatedPlayMusic.source === 'netease' && updatedPlayMusic?.song?.artists) {
+ title += ` - ${updatedPlayMusic.song.artists.reduce(
+ (prev: string, curr: any) => `${prev}${curr.name}/`,
+ ''
+ )}`;
+ } else if (updatedPlayMusic.source === 'bilibili' && updatedPlayMusic?.song?.ar?.[0]) {
+ title += ` - ${updatedPlayMusic.song.ar[0].name}`;
+ }
+
+ document.title = title;
+
loadLrcAsync(playMusic.value);
+
musicHistory.addMusic(playMusic.value);
- playListIndex.value = playList.value.findIndex((item: SongResult) => item.id === music.id);
- // 请求后续五首歌曲的详情
+
+ playListIndex.value = playList.value.findIndex(
+ (item: SongResult) => item.id === music.id && item.source === music.source
+ );
+
fetchSongs(playList.value, playListIndex.value + 1, playListIndex.value + 6);
};
- // 方法
const setPlay = async (song: SongResult) => {
await handlePlayMusic(song);
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
@@ -303,12 +341,10 @@ export const usePlayerStore = defineStore('player', () => {
let nowPlayListIndex: number;
if (playMode.value === 2) {
- // 随机播放模式
do {
nowPlayListIndex = Math.floor(Math.random() * playList.value.length);
} while (nowPlayListIndex === playListIndex.value && playList.value.length > 1);
} else {
- // 列表循环模式
nowPlayListIndex = (playListIndex.value + 1) % playList.value.length;
}
@@ -344,7 +380,6 @@ export const usePlayerStore = defineStore('player', () => {
localStorage.setItem('favoriteList', JSON.stringify(favoriteList.value));
};
- // 初始化播放状态
const initializePlayState = async () => {
const settingStore = useSettingsStore();
const savedPlayList = getLocalStorageItem('playList', []);
@@ -390,16 +425,13 @@ export const usePlayerStore = defineStore('player', () => {
const initializeFavoriteList = async () => {
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;
@@ -414,12 +446,10 @@ export const usePlayerStore = defineStore('player', () => {
favoriteList.value = localList;
}
- // 更新本地存储
localStorage.setItem('favoriteList', JSON.stringify(favoriteList.value));
};
return {
- // 状态
play,
isPlay,
playMusic,
@@ -431,13 +461,11 @@ export const usePlayerStore = defineStore('player', () => {
savedPlayProgress,
favoriteList,
- // 计算属性
currentSong,
isPlaying,
currentPlayList,
currentPlayListIndex,
- // 方法
setPlay,
setIsPlay,
nextPlay,
diff --git a/src/renderer/type/music.ts b/src/renderer/type/music.ts
index 4388575..3962e15 100644
--- a/src/renderer/type/music.ts
+++ b/src/renderer/type/music.ts
@@ -13,23 +13,26 @@ export interface ILyric {
}
export interface SongResult {
- id: number;
- type: number;
+ id: string | number;
name: string;
- copywriter?: any;
picUrl: string;
- canDislike: boolean;
- trackNumberUpdateTime?: any;
- song: Song;
- alg: string;
- count?: number;
+ playCount?: number;
+ song?: any;
+ copywriter?: string;
+ type?: number;
+ canDislike?: boolean;
+ program?: any;
+ alg?: string;
+ playMusicUrl?: string;
playLoading?: boolean;
- ar?: Artist[];
- al?: Album;
+ lyric?: ILyric;
backgroundColor?: string;
primaryColor?: string;
- playMusicUrl?: string;
- lyric?: ILyric;
+ bilibiliData?: {
+ bvid: string;
+ cid: number;
+ };
+ source?: 'netease' | 'bilibili';
}
export interface Song {
@@ -214,3 +217,16 @@ interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}
+
+export interface IArtists {
+ id: number;
+ name: string;
+ picUrl: string | null;
+ alias: string[];
+ albumSize: number;
+ picId: number;
+ fansGroup: null;
+ img1v1Url: string;
+ img1v1: number;
+ trans: null;
+}
diff --git a/src/renderer/types/bilibili.ts b/src/renderer/types/bilibili.ts
new file mode 100644
index 0000000..93ebbf8
--- /dev/null
+++ b/src/renderer/types/bilibili.ts
@@ -0,0 +1,111 @@
+export interface IBilibiliSearchResult {
+ id: number;
+ bvid: string;
+ title: string;
+ pic: string;
+ duration: number | string;
+ pubdate: number;
+ ctime: number;
+ owner: {
+ mid: number;
+ name: string;
+ face: string;
+ };
+ stat: {
+ view: number;
+ danmaku: number;
+ reply: number;
+ favorite: number;
+ coin: number;
+ share: number;
+ like: number;
+ };
+}
+
+export interface IBilibiliVideoDetail {
+ aid: number;
+ bvid: string;
+ title: string;
+ pic: string;
+ desc: string;
+ duration: number;
+ pubdate: number;
+ ctime: number;
+ owner: {
+ mid: number;
+ name: string;
+ face: string;
+ };
+ stat: {
+ view: number;
+ danmaku: number;
+ reply: number;
+ favorite: number;
+ coin: number;
+ share: number;
+ like: number;
+ };
+ pages: IBilibiliPage[];
+}
+
+export interface IBilibiliPage {
+ cid: number;
+ page: number;
+ part: string;
+ duration: number;
+ dimension: {
+ width: number;
+ height: number;
+ rotate: number;
+ };
+}
+
+export interface IBilibiliPlayUrl {
+ durl?: {
+ order: number;
+ length: number;
+ size: number;
+ ahead: string;
+ vhead: string;
+ url: string;
+ backup_url: string[];
+ }[];
+ dash?: {
+ duration: number;
+ minBufferTime: number;
+ min_buffer_time: number;
+ video: IBilibiliDashItem[];
+ audio: IBilibiliDashItem[];
+ };
+ support_formats: {
+ quality: number;
+ format: string;
+ new_description: string;
+ display_desc: string;
+ }[];
+ accept_quality: number[];
+ accept_description: string[];
+ quality: number;
+ format: string;
+ timelength: number;
+ high_format: string;
+}
+
+export interface IBilibiliDashItem {
+ id: number;
+ baseUrl: string;
+ base_url: string;
+ backupUrl: string[];
+ backup_url: string[];
+ bandwidth: number;
+ mimeType: string;
+ mime_type: string;
+ codecs: string;
+ width?: number;
+ height?: number;
+ frameRate?: string;
+ frame_rate?: string;
+ startWithSap?: number;
+ start_with_sap?: number;
+ codecid: number;
+}
diff --git a/src/renderer/views/search/index.vue b/src/renderer/views/search/index.vue
index 9c8f0e8..0433c95 100644
--- a/src/renderer/views/search/index.vue
+++ b/src/renderer/views/search/index.vue
@@ -40,33 +40,52 @@
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+ {{ t('search.loading.more') }}
+
+ {{ t('search.noMore') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('search.loading.more') }}
+
+ {{ t('search.noMore') }}
-
-
-
- {{ t('search.loading.more') }}
-
- {{ t('search.noMore') }}
@@ -99,22 +118,29 @@
+
+