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 @@ + + + + + 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 @@ + + + + + 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 @@