├── .nvmrc ├── .prettierignore ├── public ├── robots.txt ├── favicon.ico ├── img │ ├── icons │ │ ├── 16x16.png │ │ ├── 24x24.png │ │ ├── 32x32.png │ │ ├── 48x48.png │ │ ├── 64x64.png │ │ ├── exit.png │ │ ├── icon.icns │ │ ├── icon.ico │ │ ├── left.png │ │ ├── like.png │ │ ├── menu.png │ │ ├── pause.png │ │ ├── play.png │ │ ├── repeat.png │ │ ├── right.png │ │ ├── unlike.png │ │ ├── 128x128.png │ │ ├── 256x256.png │ │ ├── 512x512.png │ │ ├── favicon.ico │ │ ├── menu@88.png │ │ ├── 1024x1024.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── apple-touch-icon.png │ │ ├── mstile-150x150.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-60x60.png │ │ ├── apple-touch-icon-76x76.png │ │ ├── apple-touch-icon-120x120.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── apple-touch-icon-180x180.png │ │ ├── msapplication-icon-144x144.png │ │ └── safari-pinned-tab.svg │ ├── logos │ │ ├── lastfm.png │ │ ├── nyancat.gif │ │ ├── netease-music.png │ │ ├── nyancat-stop.png │ │ ├── yesplaymusic.png │ │ └── yesplaymusic-white24x24.png │ └── touchbar │ │ ├── like.png │ │ ├── pause.png │ │ ├── play.png │ │ ├── backward.png │ │ ├── forward.png │ │ ├── next_up.png │ │ ├── search.png │ │ ├── like_fill.png │ │ ├── page_next.png │ │ └── page_prev.png └── index.html ├── images ├── album.png ├── artist.png ├── home-2.png ├── home.png ├── logo.png ├── lyrics.png ├── search.png ├── explore.png ├── library.png └── library-dark.png ├── src ├── assets │ ├── fonts │ │ ├── Barlow-Bold.ttf │ │ ├── Barlow-Black.ttf │ │ ├── Barlow-Bold.woff2 │ │ ├── Barlow-Medium.ttf │ │ ├── Barlow-Black.woff2 │ │ ├── Barlow-ExtraBold.ttf │ │ ├── Barlow-Medium.woff2 │ │ ├── Barlow-Regular.ttf │ │ ├── Barlow-Regular.woff2 │ │ ├── Barlow-SemiBold.ttf │ │ ├── Barlow-ExtraBold.woff2 │ │ └── Barlow-SemiBold.woff2 │ ├── icons │ │ ├── explicit.svg │ │ ├── index.js │ │ ├── play.svg │ │ ├── dropdown.svg │ │ ├── heart-solid.svg │ │ ├── next.svg │ │ ├── arrow-left.svg │ │ ├── previous.svg │ │ ├── arrow-right.svg │ │ ├── pause.svg │ │ ├── more.svg │ │ ├── arrow-up-alt.svg │ │ ├── mobile.svg │ │ ├── lock.svg │ │ ├── search.svg │ │ ├── login.svg │ │ ├── logout.svg │ │ ├── plus.svg │ │ ├── x.svg │ │ ├── arrow-down.svg │ │ ├── arrow-up.svg │ │ ├── volume-half.svg │ │ ├── mail.svg │ │ ├── fm.svg │ │ ├── list.svg │ │ ├── volume-mute.svg │ │ ├── sort-up.svg │ │ ├── shuffle.svg │ │ ├── volume.svg │ │ ├── settings.svg │ │ ├── thumbs-down.svg │ │ ├── repeat.svg │ │ ├── github.svg │ │ ├── heart.svg │ │ └── repeat-1.svg │ └── css │ │ ├── nprogress.css │ │ ├── slider.css │ │ └── global.scss ├── store │ ├── plugins │ │ ├── localStorage.js │ │ └── sendSettings.js │ ├── state.js │ ├── initLocalStorage.js │ ├── index.js │ └── mutations.js ├── utils │ ├── platform.js │ ├── nativeAlert.js │ ├── shortcuts.js │ ├── auth.js │ ├── updateApp.js │ ├── playList.js │ ├── base64.js │ ├── request.js │ ├── lyrics.js │ ├── filters.js │ └── db.js ├── electron │ ├── services.js │ ├── dockMenu.js │ ├── globalShortcut.js │ ├── mpris.js │ ├── ipcRenderer.js │ └── touchBar.js ├── locale │ └── index.js ├── components │ ├── ExplicitSymbol.vue │ ├── ButtonIcon.vue │ ├── SvgIcon.vue │ ├── Toast.vue │ ├── ArtistsInLine.vue │ ├── ButtonTwoTone.vue │ ├── Win32Titlebar.vue │ ├── LinuxTitlebar.vue │ ├── ModalNewPlaylist.vue │ ├── MvRow.vue │ ├── Modal.vue │ ├── ModalAddTrackToPlaylist.vue │ ├── DailyTracksCard.vue │ ├── Cover.vue │ └── Scrollbar.vue ├── views │ ├── newAlbum.vue │ ├── lastfmCallback.vue │ ├── artistMV.vue │ ├── dailyTracks.vue │ ├── login.vue │ ├── next.vue │ ├── searchType.vue │ └── loginUsername.vue ├── registerServiceWorker.js ├── main.js ├── api │ ├── others.js │ ├── mv.js │ ├── album.js │ ├── lastfm.js │ ├── artist.js │ ├── auth.js │ ├── track.js │ └── user.js ├── router │ └── index.js └── App.vue ├── .editorconfig ├── restyled.yml ├── vercel.example.json ├── .gitattributes ├── docker-compose.yml ├── babel.config.js ├── .dockerignore ├── .prettierrc ├── jsconfig.json ├── .gitignore ├── LICENSE ├── Dockerfile └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 14 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | coverage 3 | dist 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /images/album.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/album.png -------------------------------------------------------------------------------- /images/artist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/artist.png -------------------------------------------------------------------------------- /images/home-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/home-2.png -------------------------------------------------------------------------------- /images/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/home.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/logo.png -------------------------------------------------------------------------------- /images/lyrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/lyrics.png -------------------------------------------------------------------------------- /images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/search.png -------------------------------------------------------------------------------- /images/explore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/explore.png -------------------------------------------------------------------------------- /images/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/library.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /images/library-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/images/library-dark.png -------------------------------------------------------------------------------- /public/img/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/16x16.png -------------------------------------------------------------------------------- /public/img/icons/24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/24x24.png -------------------------------------------------------------------------------- /public/img/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/32x32.png -------------------------------------------------------------------------------- /public/img/icons/48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/48x48.png -------------------------------------------------------------------------------- /public/img/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/64x64.png -------------------------------------------------------------------------------- /public/img/icons/exit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/exit.png -------------------------------------------------------------------------------- /public/img/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/icon.icns -------------------------------------------------------------------------------- /public/img/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/icon.ico -------------------------------------------------------------------------------- /public/img/icons/left.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/left.png -------------------------------------------------------------------------------- /public/img/icons/like.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/like.png -------------------------------------------------------------------------------- /public/img/icons/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/menu.png -------------------------------------------------------------------------------- /public/img/icons/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/pause.png -------------------------------------------------------------------------------- /public/img/icons/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/play.png -------------------------------------------------------------------------------- /public/img/icons/repeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/repeat.png -------------------------------------------------------------------------------- /public/img/icons/right.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/right.png -------------------------------------------------------------------------------- /public/img/icons/unlike.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/unlike.png -------------------------------------------------------------------------------- /public/img/logos/lastfm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/lastfm.png -------------------------------------------------------------------------------- /public/img/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/128x128.png -------------------------------------------------------------------------------- /public/img/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/256x256.png -------------------------------------------------------------------------------- /public/img/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/512x512.png -------------------------------------------------------------------------------- /public/img/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/menu@88.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/menu@88.png -------------------------------------------------------------------------------- /public/img/logos/nyancat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/nyancat.gif -------------------------------------------------------------------------------- /public/img/touchbar/like.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/like.png -------------------------------------------------------------------------------- /public/img/touchbar/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/pause.png -------------------------------------------------------------------------------- /public/img/touchbar/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/play.png -------------------------------------------------------------------------------- /public/img/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/1024x1024.png -------------------------------------------------------------------------------- /public/img/touchbar/backward.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/backward.png -------------------------------------------------------------------------------- /public/img/touchbar/forward.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/forward.png -------------------------------------------------------------------------------- /public/img/touchbar/next_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/next_up.png -------------------------------------------------------------------------------- /public/img/touchbar/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/search.png -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Bold.ttf -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/logos/netease-music.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/netease-music.png -------------------------------------------------------------------------------- /public/img/logos/nyancat-stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/nyancat-stop.png -------------------------------------------------------------------------------- /public/img/logos/yesplaymusic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/yesplaymusic.png -------------------------------------------------------------------------------- /public/img/touchbar/like_fill.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/like_fill.png -------------------------------------------------------------------------------- /public/img/touchbar/page_next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/page_next.png -------------------------------------------------------------------------------- /public/img/touchbar/page_prev.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/touchbar/page_prev.png -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Black.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Bold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Medium.ttf -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Black.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-ExtraBold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Medium.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Regular.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-SemiBold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-ExtraBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-ExtraBold.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/Barlow-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/src/assets/fonts/Barlow-SemiBold.woff2 -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/img/logos/yesplaymusic-white24x24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/logos/yesplaymusic-white24x24.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Groupguanfang/TVYesPlayMusic/HEAD/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = false -------------------------------------------------------------------------------- /restyled.yml: -------------------------------------------------------------------------------- 1 | commit_template: 'style: with ${restyler.name}' 2 | restylers: 3 | - prettier 4 | - prettier-json 5 | - prettier-markdown 6 | - prettier-yaml 7 | - whitespace 8 | -------------------------------------------------------------------------------- /vercel.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/api/:match*", 5 | "destination": "https://your-netease-api.example.com/:match*" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | # Denote all files that are truly binary and should not be modified. 3 | *.png binary 4 | *.jpg binary 5 | *.mp3 binary 6 | *.icns binary 7 | *.gif binary 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | YesPlayMusic: 3 | build: 4 | context: . 5 | image: yesplaymusic 6 | container_name: YesPlayMusic 7 | ports: 8 | - 80:80 9 | restart: always 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@vue/cli-plugin-babel/preset', 5 | { 6 | useBuiltIns: 'usage', 7 | shippedProposals: true, 8 | }, 9 | ], 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | Dockerfile* 4 | docker-compose* 5 | .dockerignore 6 | .git 7 | .github 8 | .gitignore 9 | README.md 10 | LICENSE 11 | .vscode 12 | dist 13 | dist_electron 14 | build 15 | images 16 | script -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/explicit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/plugins/localStorage.js: -------------------------------------------------------------------------------- 1 | export default store => { 2 | store.subscribe((mutation, state) => { 3 | // console.log(mutation); 4 | localStorage.setItem('settings', JSON.stringify(state.settings)); 5 | localStorage.setItem('data', JSON.stringify(state.data)); 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/assets/icons/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import SvgIcon from '@/components/SvgIcon'; 3 | 4 | Vue.component('svg-icon', SvgIcon); 5 | const requireAll = requireContext => requireContext.keys().map(requireContext); 6 | const req = require.context('./', true, /\.svg$/); 7 | requireAll(req); 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "lf", 10 | "bracketSpacing": true, 11 | "htmlWhitespaceSensitivity": "strict" 12 | } 13 | -------------------------------------------------------------------------------- /src/assets/icons/play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // 支持 @ 的别名解析 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@/*": ["src/*"] 7 | }, 8 | "target": "ES6", 9 | "module": "commonjs", 10 | "allowSyntheticDefaultImports": true, 11 | "jsx": "preserve" 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/icons/dropdown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/platform.js: -------------------------------------------------------------------------------- 1 | export const isWindows = process.platform === 'win32'; 2 | export const isMac = process.platform === 'darwin'; 3 | export const isLinux = process.platform === 'linux'; 4 | export const isDevelopment = process.env.NODE_ENV === 'development'; 5 | 6 | export const isCreateTray = isWindows || isLinux || isDevelopment; 7 | export const isCreateMpris = isLinux; 8 | -------------------------------------------------------------------------------- /src/assets/icons/heart-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/store/plugins/sendSettings.js: -------------------------------------------------------------------------------- 1 | export function getSendSettingsPlugin() { 2 | const electron = window.require('electron'); 3 | const ipcRenderer = electron.ipcRenderer; 4 | return store => { 5 | store.subscribe((mutation, state) => { 6 | // console.log(mutation); 7 | if (mutation.type !== 'updateSettings') return; 8 | ipcRenderer.send('settings', state.settings); 9 | }); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/icons/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/previous.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/electron/services.js: -------------------------------------------------------------------------------- 1 | import clc from 'cli-color'; 2 | import server from 'NeteaseCloudMusicApi/server'; 3 | 4 | export async function startNeteaseMusicApi() { 5 | // Let user know that the service is starting 6 | console.log(`${clc.redBright('[NetEase API]')} initiating NCM API`); 7 | 8 | // Load the NCM API. 9 | await server.serveNcmApi({ 10 | port: 35216, 11 | moduleDefs: require('../ncmModDef'), 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/more.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-up-alt.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/mobile.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | .vercel 26 | 27 | #Electron-builder output 28 | /dist_electron 29 | NeteaseCloudMusicApi-master 30 | NeteaseCloudMusicApi-master.zip 31 | 32 | # Local Netlify folder 33 | .netlify 34 | vercel.json 35 | -------------------------------------------------------------------------------- /src/assets/icons/lock.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/electron/dockMenu.js: -------------------------------------------------------------------------------- 1 | const { Menu } = require('electron'); 2 | 3 | export function createDockMenu(win) { 4 | return Menu.buildFromTemplate([ 5 | { 6 | label: 'Play', 7 | click() { 8 | win.webContents.send('play'); 9 | }, 10 | }, 11 | { type: 'separator' }, 12 | { 13 | label: 'Next', 14 | click() { 15 | win.webContents.send('next'); 16 | }, 17 | }, 18 | { 19 | label: 'Previous', 20 | click() { 21 | win.webContents.send('previous'); 22 | }, 23 | }, 24 | ]); 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/icons/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/login.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/locale/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueClipboard from 'vue-clipboard2'; 3 | import VueI18n from 'vue-i18n'; 4 | import store from '@/store'; 5 | 6 | import en from './lang/en.js'; 7 | import zhCN from './lang/zh-CN.js'; 8 | import zhTW from './lang/zh-TW.js'; 9 | import tr from './lang/tr.js'; 10 | 11 | Vue.use(VueClipboard); 12 | Vue.use(VueI18n); 13 | 14 | const i18n = new VueI18n({ 15 | locale: store.state.settings.lang, 16 | messages: { 17 | en, 18 | 'zh-CN': zhCN, 19 | 'zh-TW': zhTW, 20 | tr, 21 | }, 22 | silentTranslationWarn: true, 23 | }); 24 | 25 | export default i18n; 26 | -------------------------------------------------------------------------------- /src/assets/icons/x.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/arrow-up.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/volume-half.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/mail.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/fm.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/assets/icons/list.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ExplicitSymbol.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/components/ButtonIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /src/assets/icons/volume-mute.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/sort-up.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/SvgIcon.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 34 | 35 | 40 | -------------------------------------------------------------------------------- /src/assets/css/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: #335eea; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 2px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px #335eea, 0 0 5px #335eea; 26 | opacity: 1; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | .nprogress-custom-parent { 34 | overflow: hidden; 35 | position: relative; 36 | } 37 | 38 | .nprogress-custom-parent #nprogress .bar { 39 | position: absolute; 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/icons/shuffle.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/volume.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/thumbs-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/views/newAlbum.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 43 | 44 | 50 | -------------------------------------------------------------------------------- /src/utils/nativeAlert.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns an alert-like function that fits current runtime environment 3 | * 4 | * This function is amid to solve a electron bug on Windows, that, when 5 | * user dismissed a browser alert, elements cannot be focused 6 | * for further editing unless switching to another window and then back 7 | * 8 | * @returns { (message:string) => void } 9 | * Built-in alert function for browser environment 10 | * A function wrapping {@link dialog.showMessageBoxSync} for electron environment 11 | * 12 | * @see {@link https://github.com/electron/electron/issues/19977} for upstream electron issue 13 | */ 14 | const nativeAlert = (() => { 15 | if (process.env.IS_ELECTRON === true) { 16 | const { dialog } = require('electron'); 17 | if (dialog) { 18 | return message => { 19 | var options = { 20 | type: 'warning', 21 | message, 22 | }; 23 | dialog.showMessageBoxSync(null, options); 24 | }; 25 | } 26 | } 27 | return alert; 28 | })(); 29 | 30 | export default nativeAlert; 31 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (!process.env.IS_ELECTRON) { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | // console.log( 9 | // "App is being served from cache by a service worker.\n" + 10 | // "For more details, visit https://goo.gl/AFskqB" 11 | // ); 12 | }, 13 | registered() { 14 | // console.log("Service worker has been registered."); 15 | }, 16 | cached() { 17 | // console.log("Content has been cached for offline use."); 18 | }, 19 | updatefound() { 20 | // console.log("New content is downloading."); 21 | }, 22 | updated() { 23 | // console.log("New content is available; please refresh."); 24 | }, 25 | offline() { 26 | // console.log( 27 | // "No internet connection found. App is running in offline mode." 28 | // ); 29 | }, 30 | error(error) { 31 | console.error('Error during service worker registration:', error); 32 | }, 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 qier222 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/shortcuts.js: -------------------------------------------------------------------------------- 1 | // default shortcuts 2 | // for more info, check https://www.electronjs.org/docs/api/accelerator 3 | 4 | export default [ 5 | { 6 | id: 'play', 7 | name: '播放/暂停', 8 | shortcut: 'CommandOrControl+P', 9 | globalShortcut: 'Alt+CommandOrControl+P', 10 | }, 11 | { 12 | id: 'next', 13 | name: '下一首', 14 | shortcut: 'CommandOrControl+Right', 15 | globalShortcut: 'Alt+CommandOrControl+Right', 16 | }, 17 | { 18 | id: 'previous', 19 | name: '上一首', 20 | shortcut: 'CommandOrControl+Left', 21 | globalShortcut: 'Alt+CommandOrControl+Left', 22 | }, 23 | { 24 | id: 'increaseVolume', 25 | name: '增加音量', 26 | shortcut: 'CommandOrControl+Up', 27 | globalShortcut: 'Alt+CommandOrControl+Up', 28 | }, 29 | { 30 | id: 'decreaseVolume', 31 | name: '减少音量', 32 | shortcut: 'CommandOrControl+Down', 33 | globalShortcut: 'Alt+CommandOrControl+Down', 34 | }, 35 | { 36 | id: 'like', 37 | name: '喜欢歌曲', 38 | shortcut: 'CommandOrControl+L', 39 | globalShortcut: 'Alt+CommandOrControl+L', 40 | }, 41 | { 42 | id: 'minimize', 43 | name: '隐藏/显示播放器', 44 | shortcut: 'CommandOrControl+M', 45 | globalShortcut: 'Alt+CommandOrControl+M', 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /src/components/Toast.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 52 | -------------------------------------------------------------------------------- /src/assets/icons/repeat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ArtistsInLine.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 44 | 45 | 54 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueGtag from 'vue-gtag'; 3 | import App from './App.vue'; 4 | import router from './router'; 5 | import store from './store'; 6 | import i18n from '@/locale'; 7 | import '@/assets/icons'; 8 | import '@/utils/filters'; 9 | import './registerServiceWorker'; 10 | import { dailyTask } from '@/utils/common'; 11 | import '@/assets/css/global.scss'; 12 | import NProgress from 'nprogress'; 13 | import '@/assets/css/nprogress.css'; 14 | import focusable from 'vue-tv-focusable'; 15 | 16 | window.resetApp = () => { 17 | localStorage.clear(); 18 | indexedDB.deleteDatabase('yesplaymusic'); 19 | document.cookie.split(';').forEach(function (c) { 20 | document.cookie = c 21 | .replace(/^ +/, '') 22 | .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/'); 23 | }); 24 | return '已重置应用,请刷新页面(按Ctrl/Command + R)'; 25 | }; 26 | console.log( 27 | '如出现问题,可尝试在本页输入 %cresetApp()%c 然后按回车重置应用。', 28 | 'background: #eaeffd;color:#335eea;padding: 4px 6px;border-radius:3px;', 29 | 'background:unset;color:unset;' 30 | ); 31 | 32 | Vue.use( 33 | focusable, 34 | VueGtag, 35 | { 36 | config: { id: 'G-KMJJCFZDKF' }, 37 | }, 38 | router 39 | ); 40 | Vue.config.productionTip = false; 41 | 42 | NProgress.configure({ showSpinner: false, trickleSpeed: 100 }); 43 | dailyTask(); 44 | 45 | new Vue({ 46 | i18n, 47 | store, 48 | router, 49 | render: h => h(App), 50 | }).$mount('#app'); 51 | -------------------------------------------------------------------------------- /src/store/state.js: -------------------------------------------------------------------------------- 1 | import initLocalStorage from './initLocalStorage'; 2 | import pkg from '../../package.json'; 3 | import updateApp from '@/utils/updateApp'; 4 | 5 | if (localStorage.getItem('appVersion') === null) { 6 | localStorage.setItem('settings', JSON.stringify(initLocalStorage.settings)); 7 | localStorage.setItem('data', JSON.stringify(initLocalStorage.data)); 8 | localStorage.setItem('appVersion', pkg.version); 9 | } 10 | 11 | updateApp(); 12 | 13 | export default { 14 | showLyrics: false, 15 | enableScrolling: true, 16 | title: 'YesPlayMusic', 17 | liked: { 18 | songs: [], 19 | songsWithDetails: [], // 只有前12首 20 | playlists: [], 21 | albums: [], 22 | artists: [], 23 | mvs: [], 24 | cloudDisk: [], 25 | playHistory: { 26 | weekData: [], 27 | allData: [], 28 | }, 29 | }, 30 | contextMenu: { 31 | clickObjectID: 0, 32 | showMenu: false, 33 | }, 34 | toast: { 35 | show: false, 36 | text: '', 37 | timer: null, 38 | }, 39 | modals: { 40 | addTrackToPlaylistModal: { 41 | show: false, 42 | selectedTrackID: 0, 43 | }, 44 | newPlaylistModal: { 45 | show: false, 46 | afterCreateAddTrackID: 0, 47 | }, 48 | }, 49 | dailyTracks: [], 50 | lastfm: JSON.parse(localStorage.getItem('lastfm')) || {}, 51 | player: JSON.parse(localStorage.getItem('player')), 52 | settings: JSON.parse(localStorage.getItem('settings')), 53 | data: JSON.parse(localStorage.getItem('data')), 54 | }; 55 | -------------------------------------------------------------------------------- /src/assets/icons/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/store/initLocalStorage.js: -------------------------------------------------------------------------------- 1 | import { playlistCategories } from '@/utils/staticData'; 2 | import shortcuts from '@/utils/shortcuts'; 3 | 4 | console.debug('[debug][initLocalStorage.js]'); 5 | const enabledPlaylistCategories = playlistCategories 6 | .filter(c => c.enable) 7 | .map(c => c.name); 8 | 9 | let localStorage = { 10 | player: {}, 11 | settings: { 12 | lang: null, 13 | musicLanguage: 'all', 14 | appearance: 'auto', 15 | musicQuality: 320000, 16 | lyricFontSize: 28, 17 | outputDevice: 'default', 18 | showPlaylistsByAppleMusic: true, 19 | enableUnblockNeteaseMusic: true, 20 | automaticallyCacheSongs: true, 21 | cacheLimit: 8192, 22 | enableReversedMode: false, 23 | nyancatStyle: false, 24 | showLyricsTranslation: true, 25 | lyricsBackground: true, 26 | closeAppOption: 'ask', 27 | enableDiscordRichPresence: false, 28 | enableGlobalShortcut: true, 29 | showLibraryDefault: false, 30 | subTitleDefault: false, 31 | linuxEnableCustomTitlebar: false, 32 | enabledPlaylistCategories, 33 | proxyConfig: { 34 | protocol: 'noProxy', 35 | server: '', 36 | port: null, 37 | }, 38 | shortcuts: shortcuts, 39 | }, 40 | data: { 41 | user: {}, 42 | likedSongPlaylistID: 0, 43 | lastRefreshCookieDate: 0, 44 | loginMode: null, 45 | }, 46 | }; 47 | 48 | if (process.env.IS_ELECTRON === true) { 49 | localStorage.settings.automaticallyCacheSongs = true; 50 | } 51 | 52 | export default localStorage; 53 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | <%= htmlWebpackPlugin.options.title %> 12 | 13 | 14 | 15 | 16 | 23 |
24 | 加载中 25 |
26 | 27 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/api/others.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | import { mapTrackPlayableStatus } from '@/utils/common'; 3 | 4 | /** 5 | * 搜索 6 | * 说明 : 调用此接口 , 传入搜索关键词可以搜索该音乐 / 专辑 / 歌手 / 歌单 / 用户 , 关键词可以多个 , 以空格隔开 , 7 | * 如 " 周杰伦 搁浅 "( 不需要登录 ), 搜索获取的 mp3url 不能直接用 , 可通过 /song/url 接口传入歌曲 id 获取具体的播放链接 8 | * - keywords : 关键词 9 | * - limit : 返回数量 , 默认为 30 10 | * - offset : 偏移数量,用于分页 , 如 : 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 11 | * - type: 搜索类型;默认为 1 即单曲 , 取值意义 : 1: 单曲, 10: 专辑, 100: 歌手, 1000: 歌单, 1002: 用户, 1004: MV, 1006: 歌词, 1009: 电台, 1014: 视频, 1018:综合 12 | * - 调用例子 : /search?keywords=海阔天空 /cloudsearch?keywords=海阔天空(更全) 13 | * @param {Object} params 14 | * @param {string} params.keywords 15 | * @param {number=} params.limit 16 | * @param {number=} params.offset 17 | * @param {number=} params.type 18 | */ 19 | export function search(params) { 20 | return request({ 21 | url: '/search', 22 | method: 'get', 23 | params, 24 | }).then(data => { 25 | if (data.result?.song !== undefined) 26 | data.result.song.songs = mapTrackPlayableStatus(data.result.song.songs); 27 | return data; 28 | }); 29 | } 30 | 31 | export function personalFM() { 32 | return request({ 33 | url: '/personal_fm', 34 | method: 'get', 35 | params: { 36 | timestamp: new Date().getTime(), 37 | }, 38 | }); 39 | } 40 | 41 | export function fmTrash(id) { 42 | return request({ 43 | url: '/fm_trash', 44 | method: 'post', 45 | params: { 46 | timestamp: new Date().getTime(), 47 | id, 48 | }, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /src/assets/icons/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/api/mv.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | /** 4 | * 获取 mv 数据 5 | * 说明 : 调用此接口 , 传入 mvid ( 在搜索音乐的时候传 type=1004 获得 ) , 可获取对应 MV 数据 , 数据包含 mv 名字 , 歌手 , 发布时间 , mv 视频地址等数据 , 6 | * 其中 mv 视频 网易做了防盗链处理 , 可能不能直接播放 , 需要播放的话需要调用 ' mv 地址' 接口 7 | * - 调用例子 : /mv/detail?mvid=5436712 8 | * @param {number} mvid mv 的 id 9 | */ 10 | export function mvDetail(mvid) { 11 | return request({ 12 | url: '/mv/detail', 13 | method: 'get', 14 | params: { 15 | mvid, 16 | timestamp: new Date().getTime(), 17 | }, 18 | }); 19 | } 20 | 21 | /** 22 | * mv 地址 23 | * 说明 : 调用此接口 , 传入 mv id,可获取 mv 播放地址 24 | * - id: mv id 25 | * - r: 分辨率,默认1080,可从 /mv/detail 接口获取分辨率列表 26 | * - 调用例子 : /mv/url?id=5436712 /mv/url?id=10896407&r=1080 27 | * @param {Object} params 28 | * @param {number} params.id 29 | * @param {number=} params.r 30 | */ 31 | export function mvUrl(params) { 32 | return request({ 33 | url: '/mv/url', 34 | method: 'get', 35 | params, 36 | }); 37 | } 38 | 39 | /** 40 | * 相似 mv 41 | * 说明 : 调用此接口 , 传入 mvid 可获取相似 mv 42 | * @param {number} mvid 43 | */ 44 | export function simiMv(mvid) { 45 | return request({ 46 | url: '/simi/mv', 47 | method: 'get', 48 | params: { mvid }, 49 | }); 50 | } 51 | 52 | /** 53 | * 收藏/取消收藏 MV 54 | * 说明 : 调用此接口,可收藏/取消收藏 MV 55 | * - mvid: mv id 56 | * - t: 1 为收藏,其他为取消收藏 57 | * @param {Object} params 58 | * @param {number} params.mvid 59 | * @param {number=} params.t 60 | */ 61 | 62 | export function likeAMV(params) { 63 | params.timestamp = new Date().getTime(); 64 | return request({ 65 | url: '/mv/sub', 66 | method: 'post', 67 | params, 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13.1-alpine as build 2 | ENV VUE_APP_NETEASE_API_URL=/api 3 | WORKDIR /app 4 | RUN apk add --no-cache python3 make g++ git 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install 7 | COPY . . 8 | RUN yarn build 9 | 10 | FROM nginx:1.20.2-alpine as app 11 | RUN echo $'server { \n\ 12 | gzip on;\n\ 13 | listen 80; \n\ 14 | listen [::]:80; \n\ 15 | server_name localhost; \n\ 16 | \n\ 17 | location / { \n\ 18 | root /usr/share/nginx/html; \n\ 19 | index index.html; \n\ 20 | try_files $uri $uri/ /index.html; \n\ 21 | } \n\ 22 | \n\ 23 | location @rewrites { \n\ 24 | rewrite ^(.*)$ /index.html last; \n\ 25 | } \n\ 26 | \n\ 27 | location /api/ { \n\ 28 | proxy_buffer_size 128k; \n\ 29 | proxy_buffers 16 32k; \n\ 30 | proxy_busy_buffers_size 128k; \n\ 31 | proxy_set_header Host $host; \n\ 32 | proxy_set_header X-Real-IP $remote_addr; \n\ 33 | proxy_set_header X-Forwarded-For $remote_addr; \n\ 34 | proxy_set_header X-Forwarded-Host $remote_addr; \n\ 35 | proxy_set_header X-NginX-Proxy true; \n\ 36 | proxy_pass http://localhost:3000/; \n\ 37 | } \n\ 38 | }' > /etc/nginx/conf.d/default.conf 39 | 40 | COPY --from=build /app/package.json /usr/local/lib/ 41 | 42 | RUN apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main libuv jq \ 43 | && apk add --no-cache --update-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.14/main nodejs npm \ 44 | && npm i -g NeteaseCloudMusicApi@"$(jq -r '.dependencies.NeteaseCloudMusicApi' /usr/local/lib/package.json)" 45 | 46 | COPY --from=build /app/dist /usr/share/nginx/html 47 | 48 | CMD nginx ; exec npx NeteaseCloudMusicApi 49 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | import { logout } from '@/api/auth'; 3 | import store from '@/store'; 4 | 5 | export function setCookies(string) { 6 | const cookies = string.split(';;'); 7 | cookies.map(cookie => { 8 | document.cookie = cookie; 9 | const cookieKeyValue = cookie.split(';')[0].split('='); 10 | localStorage.setItem(`cookie-${cookieKeyValue[0]}`, cookieKeyValue[1]); 11 | }); 12 | } 13 | 14 | export function getCookie(key) { 15 | return Cookies.get(key) ?? localStorage.getItem(`cookie-${key}`); 16 | } 17 | 18 | export function removeCookie(key) { 19 | Cookies.remove(key); 20 | localStorage.removeItem(`cookie-${key}`); 21 | } 22 | 23 | // MUSIC_U 只有在账户登录的情况下才有 24 | export function isLoggedIn() { 25 | return getCookie('MUSIC_U') !== undefined; 26 | } 27 | 28 | // 账号登录 29 | export function isAccountLoggedIn() { 30 | return ( 31 | getCookie('MUSIC_U') !== undefined && 32 | store.state.data.loginMode === 'account' 33 | ); 34 | } 35 | 36 | // 用户名搜索(用户数据为只读) 37 | export function isUsernameLoggedIn() { 38 | return store.state.data.loginMode === 'username'; 39 | } 40 | 41 | // 账户登录或者用户名搜索都判断为登录,宽松检查 42 | export function isLooseLoggedIn() { 43 | return isAccountLoggedIn() || isUsernameLoggedIn(); 44 | } 45 | 46 | export function doLogout() { 47 | logout(); 48 | removeCookie('MUSIC_U'); 49 | removeCookie('__csrf'); 50 | // 更新状态仓库中的用户信息 51 | store.commit('updateData', { key: 'user', value: {} }); 52 | // 更新状态仓库中的登录状态 53 | store.commit('updateData', { key: 'loginMode', value: null }); 54 | // 更新状态仓库中的喜欢列表 55 | store.commit('updateData', { key: 'likedSongPlaylistID', value: undefined }); 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/icons/repeat-1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/electron/globalShortcut.js: -------------------------------------------------------------------------------- 1 | import defaultShortcuts from '@/utils/shortcuts'; 2 | const { globalShortcut } = require('electron'); 3 | 4 | const clc = require('cli-color'); 5 | const log = text => { 6 | console.log(`${clc.blueBright('[globalShortcut.js]')} ${text}`); 7 | }; 8 | 9 | export function registerGlobalShortcut(win, store) { 10 | log('registerGlobalShortcut'); 11 | let shortcuts = store.get('settings.shortcuts'); 12 | if (shortcuts === undefined) { 13 | shortcuts = defaultShortcuts; 14 | } 15 | 16 | globalShortcut.register( 17 | shortcuts.find(s => s.id === 'play').globalShortcut, 18 | () => { 19 | win.webContents.send('play'); 20 | } 21 | ); 22 | globalShortcut.register( 23 | shortcuts.find(s => s.id === 'next').globalShortcut, 24 | () => { 25 | win.webContents.send('next'); 26 | } 27 | ); 28 | globalShortcut.register( 29 | shortcuts.find(s => s.id === 'previous').globalShortcut, 30 | () => { 31 | win.webContents.send('previous'); 32 | } 33 | ); 34 | globalShortcut.register( 35 | shortcuts.find(s => s.id === 'increaseVolume').globalShortcut, 36 | () => { 37 | win.webContents.send('increaseVolume'); 38 | } 39 | ); 40 | globalShortcut.register( 41 | shortcuts.find(s => s.id === 'decreaseVolume').globalShortcut, 42 | () => { 43 | win.webContents.send('decreaseVolume'); 44 | } 45 | ); 46 | globalShortcut.register( 47 | shortcuts.find(s => s.id === 'like').globalShortcut, 48 | () => { 49 | win.webContents.send('like'); 50 | } 51 | ); 52 | globalShortcut.register( 53 | shortcuts.find(s => s.id === 'minimize').globalShortcut, 54 | () => { 55 | win.isVisible() ? win.hide() : win.show(); 56 | } 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/api/album.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | import { mapTrackPlayableStatus } from '@/utils/common'; 3 | import { cacheAlbum, getAlbumFromCache } from '@/utils/db'; 4 | 5 | /** 6 | * 获取专辑内容 7 | * 说明 : 调用此接口 , 传入专辑 id, 可获得专辑内容 8 | * @param {number} id 9 | */ 10 | export function getAlbum(id) { 11 | const fetchLatest = () => { 12 | return request({ 13 | url: '/album', 14 | method: 'get', 15 | params: { 16 | id, 17 | }, 18 | }).then(data => { 19 | cacheAlbum(id, data); 20 | data.songs = mapTrackPlayableStatus(data.songs); 21 | return data; 22 | }); 23 | }; 24 | fetchLatest(); 25 | 26 | return getAlbumFromCache(id).then(result => { 27 | return result ?? fetchLatest(); 28 | }); 29 | } 30 | 31 | /** 32 | * 全部新碟 33 | * 说明 : 登录后调用此接口 ,可获取全部新碟 34 | * - limit - 返回数量 , 默认为 30 35 | * - offset - 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 36 | * - area - ALL:全部,ZH:华语,EA:欧美,KR:韩国,JP:日本 37 | * @param {Object} params 38 | * @param {number} params.limit 39 | * @param {number=} params.offset 40 | * @param {string} params.area 41 | */ 42 | export function newAlbums(params) { 43 | return request({ 44 | url: '/album/new', 45 | method: 'get', 46 | params, 47 | }); 48 | } 49 | 50 | /** 51 | * 专辑动态信息 52 | * 说明 : 调用此接口 , 传入专辑 id, 可获得专辑动态信息,如是否收藏,收藏数,评论数,分享数 53 | * - id - 专辑id 54 | * @param {number} id 55 | */ 56 | export function albumDynamicDetail(id) { 57 | return request({ 58 | url: '/album/detail/dynamic', 59 | method: 'get', 60 | params: { id, timestamp: new Date().getTime() }, 61 | }); 62 | } 63 | 64 | /** 65 | * 收藏/取消收藏专辑 66 | * 说明 : 调用此接口,可收藏/取消收藏专辑 67 | * - id - 返专辑 id 68 | * - t - 1 为收藏,其他为取消收藏 69 | * @param {Object} params 70 | * @param {number} params.id 71 | * @param {number} params.t 72 | */ 73 | export function likeAAlbum(params) { 74 | return request({ 75 | url: '/album/sub', 76 | method: 'post', 77 | params, 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /src/utils/updateApp.js: -------------------------------------------------------------------------------- 1 | import initLocalStorage from '@/store/initLocalStorage.js'; 2 | import pkg from '../../package.json'; 3 | 4 | const updateSetting = () => { 5 | const parsedSettings = JSON.parse(localStorage.getItem('settings')); 6 | const settings = { 7 | ...initLocalStorage.settings, 8 | ...parsedSettings, 9 | }; 10 | 11 | if ( 12 | settings.shortcuts.length !== initLocalStorage.settings.shortcuts.length 13 | ) { 14 | // 当新增 shortcuts 时 15 | const oldShortcutsId = settings.shortcuts.map(s => s.id); 16 | const newShortcutsId = initLocalStorage.settings.shortcuts.filter( 17 | s => oldShortcutsId.includes(s.id) === false 18 | ); 19 | newShortcutsId.map(id => { 20 | settings.shortcuts.push( 21 | initLocalStorage.settings.shortcuts.find(s => s.id === id) 22 | ); 23 | }); 24 | } 25 | 26 | if (localStorage.getItem('appVersion') === '"0.3.9"') { 27 | settings.lyricsBackground = true; 28 | } 29 | 30 | localStorage.setItem('settings', JSON.stringify(settings)); 31 | }; 32 | 33 | const updateData = () => { 34 | const parsedData = JSON.parse(localStorage.getItem('data')); 35 | const data = { 36 | ...parsedData, 37 | }; 38 | localStorage.setItem('data', JSON.stringify(data)); 39 | }; 40 | 41 | const updatePlayer = () => { 42 | let parsedData = JSON.parse(localStorage.getItem('player')); 43 | let appVersion = localStorage.getItem('appVersion'); 44 | if (appVersion === `"0.2.5"`) parsedData = {}; // 0.2.6版本重构了player 45 | const data = { 46 | ...parsedData, 47 | }; 48 | localStorage.setItem('player', JSON.stringify(data)); 49 | }; 50 | 51 | const removeOldStuff = () => { 52 | // remove old indexedDB databases created by localforage 53 | indexedDB.deleteDatabase('tracks'); 54 | }; 55 | 56 | export default function () { 57 | updateSetting(); 58 | updateData(); 59 | updatePlayer(); 60 | removeOldStuff(); 61 | localStorage.setItem('appVersion', JSON.stringify(pkg.version)); 62 | } 63 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import state from './state'; 4 | import mutations from './mutations'; 5 | import actions from './actions'; 6 | import { changeAppearance } from '@/utils/common'; 7 | import Player from '@/utils/Player'; 8 | // vuex 自定义插件 9 | import saveToLocalStorage from './plugins/localStorage'; 10 | import { getSendSettingsPlugin } from './plugins/sendSettings'; 11 | 12 | Vue.use(Vuex); 13 | 14 | let plugins = [saveToLocalStorage]; 15 | if (process.env.IS_ELECTRON === true) { 16 | let sendSettings = getSendSettingsPlugin(); 17 | plugins.push(sendSettings); 18 | } 19 | const options = { 20 | state, 21 | mutations, 22 | actions, 23 | plugins, 24 | }; 25 | 26 | const store = new Vuex.Store(options); 27 | 28 | if ([undefined, null].includes(store.state.settings.lang)) { 29 | const defaultLang = 'en'; 30 | const langMapper = new Map() 31 | .set('zh', 'zh-CN') 32 | .set('zh-TW', 'zh-TW') 33 | .set('en', 'en') 34 | .set('tr', 'tr'); 35 | store.state.settings.lang = 36 | langMapper.get( 37 | langMapper.has(navigator.language) 38 | ? navigator.language 39 | : navigator.language.slice(0, 2) 40 | ) || defaultLang; 41 | localStorage.setItem('settings', JSON.stringify(store.state.settings)); 42 | } 43 | 44 | changeAppearance(store.state.settings.appearance); 45 | 46 | window 47 | .matchMedia('(prefers-color-scheme: dark)') 48 | .addEventListener('change', () => { 49 | if (store.state.settings.appearance === 'auto') { 50 | changeAppearance(store.state.settings.appearance); 51 | } 52 | }); 53 | 54 | let player = new Player(); 55 | player = new Proxy(player, { 56 | set(target, prop, val) { 57 | // console.log({ prop, val }); 58 | target[prop] = val; 59 | if (prop === '_howler') return true; 60 | target.saveSelfToLocalStorage(); 61 | target.sendSelfToIpcMain(); 62 | return true; 63 | }, 64 | }); 65 | store.state.player = player; 66 | 67 | export default store; 68 | -------------------------------------------------------------------------------- /src/utils/playList.js: -------------------------------------------------------------------------------- 1 | import router from '../router'; 2 | import state from '../store/state'; 3 | import { 4 | recommendPlaylist, 5 | dailyRecommendPlaylist, 6 | getPlaylistDetail, 7 | } from '@/api/playlist'; 8 | import { isAccountLoggedIn } from '@/utils/auth'; 9 | 10 | export function hasListSource() { 11 | return !state.player.isPersonalFM && state.player.playlistSource.id !== 0; 12 | } 13 | 14 | export function goToListSource() { 15 | router.push({ path: getListSourcePath() }); 16 | } 17 | 18 | export function getListSourcePath() { 19 | if (state.player.playlistSource.id === state.data.likedSongPlaylistID) { 20 | return '/library/liked-songs'; 21 | } else if (state.player.playlistSource.type === 'url') { 22 | return state.player.playlistSource.id; 23 | } else if (state.player.playlistSource.type === 'cloudDisk') { 24 | return '/library'; 25 | } else { 26 | return `/${state.player.playlistSource.type}/${state.player.playlistSource.id}`; 27 | } 28 | } 29 | 30 | export async function getRecommendPlayList(limit, removePrivateRecommand) { 31 | if (isAccountLoggedIn()) { 32 | const playlists = await Promise.all([ 33 | dailyRecommendPlaylist(), 34 | recommendPlaylist({ limit }), 35 | ]); 36 | let recommend = playlists[0].recommend ?? []; 37 | if (recommend.length) { 38 | if (removePrivateRecommand) recommend = recommend.slice(1); 39 | await replaceRecommendResult(recommend); 40 | } 41 | return recommend.concat(playlists[1].result).slice(0, limit); 42 | } else { 43 | const response = await recommendPlaylist({ limit }); 44 | return response.result; 45 | } 46 | } 47 | 48 | async function replaceRecommendResult(recommend) { 49 | for (let r of recommend) { 50 | if (specialPlaylist.indexOf(r.id) > -1) { 51 | const data = await getPlaylistDetail(r.id, true); 52 | const playlist = data.playlist; 53 | if (playlist) { 54 | r.name = playlist.name; 55 | r.picUrl = playlist.coverImgUrl; 56 | } 57 | } 58 | } 59 | } 60 | 61 | const specialPlaylist = [3136952023, 2829883282, 2829816518, 2829896389]; 62 | -------------------------------------------------------------------------------- /src/electron/mpris.js: -------------------------------------------------------------------------------- 1 | import { ipcMain, app } from 'electron'; 2 | 3 | export function createMpris(window) { 4 | const Player = require('mpris-service'); 5 | const renderer = window.webContents; 6 | 7 | const player = Player({ 8 | name: 'yesplaymusic', 9 | identity: 'YesPlayMusic', 10 | }); 11 | 12 | player.on('next', () => renderer.send('next')); 13 | player.on('previous', () => renderer.send('previous')); 14 | player.on('playpause', () => renderer.send('play')); 15 | player.on('play', () => renderer.send('play')); 16 | player.on('pause', () => renderer.send('play')); 17 | player.on('quit', () => app.exit()); 18 | player.on('position', args => 19 | renderer.send('setPosition', args.position / 1000 / 1000) 20 | ); 21 | player.on('loopStatus', () => renderer.send('repeat')); 22 | player.on('shuffle', () => renderer.send('shuffle')); 23 | 24 | ipcMain.on('player', (e, { playing }) => { 25 | player.playbackStatus = playing 26 | ? Player.PLAYBACK_STATUS_PLAYING 27 | : Player.PLAYBACK_STATUS_PAUSED; 28 | }); 29 | 30 | ipcMain.on('metadata', (e, metadata) => { 31 | player.metadata = { 32 | 'mpris:trackid': player.objectPath('track/' + metadata.trackId), 33 | 'mpris:artUrl': metadata.artwork[0].src, 34 | 'mpris:length': metadata.length * 1000 * 1000, 35 | 'xesam:title': metadata.title, 36 | 'xesam:album': metadata.album, 37 | 'xesam:artist': metadata.artist.split(','), 38 | }; 39 | }); 40 | 41 | ipcMain.on('playerCurrentTrackTime', (e, position) => { 42 | player.getPosition = () => position * 1000 * 1000; 43 | }); 44 | 45 | ipcMain.on('switchRepeatMode', (e, mode) => { 46 | switch (mode) { 47 | case 'off': 48 | player.loopStatus = Player.LOOP_STATUS_NONE; 49 | break; 50 | case 'one': 51 | player.loopStatus = Player.LOOP_STATUS_TRACK; 52 | break; 53 | case 'on': 54 | player.loopStatus = Player.LOOP_STATUS_PLAYLIST; 55 | break; 56 | } 57 | }); 58 | 59 | ipcMain.on('switchShuffle', (e, shuffle) => { 60 | player.shuffle = shuffle; 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/base64.js: -------------------------------------------------------------------------------- 1 | // https://github.com/niklasvh/base64-arraybuffer/blob/master/src/index.ts 2 | // Copyright (c) 2012 Niklas von Hertzen Licensed under the MIT license. 3 | 4 | const chars = 5 | 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; 6 | 7 | // Use a lookup table to find the index. 8 | const lookup = typeof Uint8Array === 'undefined' ? [] : new Uint8Array(256); 9 | for (let i = 0; i < chars.length; i++) { 10 | lookup[chars.charCodeAt(i)] = i; 11 | } 12 | 13 | export const encode = arraybuffer => { 14 | let bytes = new Uint8Array(arraybuffer), 15 | i, 16 | len = bytes.length, 17 | base64 = ''; 18 | 19 | for (i = 0; i < len; i += 3) { 20 | base64 += chars[bytes[i] >> 2]; 21 | base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; 22 | base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; 23 | base64 += chars[bytes[i + 2] & 63]; 24 | } 25 | 26 | if (len % 3 === 2) { 27 | base64 = base64.substring(0, base64.length - 1) + '='; 28 | } else if (len % 3 === 1) { 29 | base64 = base64.substring(0, base64.length - 2) + '=='; 30 | } 31 | 32 | return base64; 33 | }; 34 | 35 | export const decode = base64 => { 36 | let bufferLength = base64.length * 0.75, 37 | len = base64.length, 38 | i, 39 | p = 0, 40 | encoded1, 41 | encoded2, 42 | encoded3, 43 | encoded4; 44 | 45 | if (base64[base64.length - 1] === '=') { 46 | bufferLength--; 47 | if (base64[base64.length - 2] === '=') { 48 | bufferLength--; 49 | } 50 | } 51 | 52 | const arraybuffer = new ArrayBuffer(bufferLength), 53 | bytes = new Uint8Array(arraybuffer); 54 | 55 | for (i = 0; i < len; i += 4) { 56 | encoded1 = lookup[base64.charCodeAt(i)]; 57 | encoded2 = lookup[base64.charCodeAt(i + 1)]; 58 | encoded3 = lookup[base64.charCodeAt(i + 2)]; 59 | encoded4 = lookup[base64.charCodeAt(i + 3)]; 60 | 61 | bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); 62 | bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); 63 | bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); 64 | } 65 | 66 | return arraybuffer; 67 | }; 68 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import router from '@/router'; 2 | import { doLogout, getCookie } from '@/utils/auth'; 3 | import axios from 'axios'; 4 | 5 | let baseURL = ''; 6 | // Web 和 Electron 跑在不同端口避免同时启动时冲突 7 | if (process.env.IS_ELECTRON) { 8 | if (process.env.NODE_ENV === 'production') { 9 | baseURL = process.env.VUE_APP_ELECTRON_API_URL; 10 | } else { 11 | baseURL = process.env.VUE_APP_ELECTRON_API_URL_DEV; 12 | } 13 | } else { 14 | baseURL = process.env.VUE_APP_NETEASE_API_URL; 15 | } 16 | 17 | const service = axios.create({ 18 | baseURL, 19 | withCredentials: true, 20 | timeout: 15000, 21 | }); 22 | 23 | service.interceptors.request.use(function (config) { 24 | if (!config.params) config.params = {}; 25 | if (baseURL.length) { 26 | if (baseURL[0] !== '/' && !process.env.IS_ELECTRON) { 27 | config.params.cookie = `MUSIC_U=${getCookie('MUSIC_U')};`; 28 | } 29 | } else { 30 | console.error("You must set up the baseURL in the service's config"); 31 | } 32 | 33 | if (!process.env.IS_ELECTRON && !config.url.includes('/login')) { 34 | config.params.realIP = '211.161.244.70'; 35 | } 36 | 37 | if (process.env.VUE_APP_REAL_IP) { 38 | config.params.realIP = process.env.VUE_APP_REAL_IP; 39 | } 40 | 41 | const proxy = JSON.parse(localStorage.getItem('settings')).proxyConfig; 42 | if (['HTTP', 'HTTPS'].includes(proxy.protocol)) { 43 | config.params.proxy = `${proxy.protocol}://${proxy.server}:${proxy.port}`; 44 | } 45 | 46 | return config; 47 | }); 48 | 49 | service.interceptors.response.use( 50 | response => { 51 | const res = response.data; 52 | return res; 53 | }, 54 | async error => { 55 | /** @type {import('axios').AxiosResponse | null} */ 56 | const response = error.response; 57 | const data = response.data; 58 | 59 | if ( 60 | response && 61 | typeof data === 'object' && 62 | data.code === 301 && 63 | data.msg === '需要登录' 64 | ) { 65 | console.warn('Token has expired. Logout now!'); 66 | 67 | // 登出帳戶 68 | doLogout(); 69 | 70 | // 導向登入頁面 71 | if (process.env.IS_ELECTRON === true) { 72 | router.push({ name: 'loginAccount' }); 73 | } else { 74 | router.push({ name: 'login' }); 75 | } 76 | } 77 | } 78 | ); 79 | 80 | export default service; 81 | -------------------------------------------------------------------------------- /src/components/ButtonTwoTone.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 61 | 62 | 97 | -------------------------------------------------------------------------------- /src/api/lastfm.js: -------------------------------------------------------------------------------- 1 | // Last.fm API documents 👉 https://www.last.fm/api 2 | 3 | import axios from 'axios'; 4 | import md5 from 'crypto-js/md5'; 5 | 6 | const apiKey = process.env.VUE_APP_LASTFM_API_KEY; 7 | const apiSharedSecret = process.env.VUE_APP_LASTFM_API_SHARED_SECRET; 8 | const baseUrl = window.location.origin; 9 | const url = 'https://ws.audioscrobbler.com/2.0/'; 10 | 11 | const sign = params => { 12 | const sortParamsKeys = Object.keys(params).sort(); 13 | const sortedParams = sortParamsKeys.reduce((acc, key) => { 14 | acc[key] = params[key]; 15 | return acc; 16 | }, {}); 17 | let signature = ''; 18 | for (const [key, value] of Object.entries(sortedParams)) { 19 | signature += `${key}${value}`; 20 | } 21 | return md5(signature + apiSharedSecret).toString(); 22 | }; 23 | 24 | export function auth() { 25 | const url = process.env.IS_ELECTRON 26 | ? `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${baseUrl}/#/lastfm/callback` 27 | : `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${baseUrl}/lastfm/callback`; 28 | window.open(url); 29 | } 30 | 31 | export function authGetSession(token) { 32 | const signature = md5( 33 | `api_key${apiKey}methodauth.getSessiontoken${token}${apiSharedSecret}` 34 | ).toString(); 35 | return axios({ 36 | url, 37 | method: 'GET', 38 | params: { 39 | method: 'auth.getSession', 40 | format: 'json', 41 | api_key: apiKey, 42 | api_sig: signature, 43 | token, 44 | }, 45 | }); 46 | } 47 | 48 | export function trackUpdateNowPlaying(params) { 49 | params.api_key = apiKey; 50 | params.method = 'track.updateNowPlaying'; 51 | params.sk = JSON.parse(localStorage.getItem('lastfm'))['key']; 52 | const signature = sign(params); 53 | 54 | return axios({ 55 | url, 56 | method: 'POST', 57 | params: { 58 | ...params, 59 | api_sig: signature, 60 | format: 'json', 61 | }, 62 | }); 63 | } 64 | 65 | export function trackScrobble(params) { 66 | params.api_key = apiKey; 67 | params.method = 'track.scrobble'; 68 | params.sk = JSON.parse(localStorage.getItem('lastfm'))['key']; 69 | const signature = sign(params); 70 | 71 | return axios({ 72 | url, 73 | method: 'POST', 74 | params: { 75 | ...params, 76 | api_sig: signature, 77 | format: 'json', 78 | }, 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /src/views/lastfmCallback.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 47 | 48 | 97 | -------------------------------------------------------------------------------- /src/utils/lyrics.js: -------------------------------------------------------------------------------- 1 | export function lyricParser(lrc) { 2 | return { 3 | lyric: parseLyric(lrc?.lrc?.lyric || ''), 4 | tlyric: parseLyric(lrc?.tlyric?.lyric || ''), 5 | lyricuser: lrc.lyricUser, 6 | transuser: lrc.transUser, 7 | }; 8 | } 9 | 10 | // regexr.com/6e52n 11 | const extractLrcRegex = 12 | /^(?(?:\[.+?\])+)(?!\[)(?.+)$/gm; 13 | const extractTimestampRegex = 14 | /\[(?\d+):(?\d+)(?:\.|:)*(?\d+)*\]/g; 15 | 16 | /** 17 | * @typedef {{time: number, rawTime: string, content: string}} ParsedLyric 18 | */ 19 | 20 | /** 21 | * Parse the lyric string. 22 | * 23 | * @param {string} lrc The `lrc` input. 24 | * @returns {ParsedLyric[]} The parsed lyric. 25 | * @example parseLyric("[00:00.00] Hello, World!\n[00:00.10] Test\n"); 26 | */ 27 | function parseLyric(lrc) { 28 | /** 29 | * A sorted list of parsed lyric and its timestamp. 30 | * 31 | * @type {ParsedLyric[]} 32 | * @see binarySearch 33 | */ 34 | const parsedLyrics = []; 35 | 36 | /** 37 | * Find the appropriate index to push our parsed lyric. 38 | * @param {ParsedLyric} lyric 39 | */ 40 | const binarySearch = lyric => { 41 | let time = lyric.time; 42 | 43 | let low = 0; 44 | let high = parsedLyrics.length - 1; 45 | 46 | while (low <= high) { 47 | const mid = Math.floor((low + high) / 2); 48 | const midTime = parsedLyrics[mid].time; 49 | if (midTime === time) { 50 | return mid; 51 | } else if (midTime < time) { 52 | low = mid + 1; 53 | } else { 54 | high = mid - 1; 55 | } 56 | } 57 | 58 | return low; 59 | }; 60 | 61 | for (const line of lrc.trim().matchAll(extractLrcRegex)) { 62 | const { lyricTimestamps, content } = line.groups; 63 | 64 | for (const timestamp of lyricTimestamps.matchAll(extractTimestampRegex)) { 65 | const { min, sec, ms } = timestamp.groups; 66 | const rawTime = timestamp[0]; 67 | const time = Number(min) * 60 + Number(sec) + Number(ms ?? 0) * 0.001; 68 | 69 | /** @type {ParsedLyric} */ 70 | const parsedLyric = { rawTime, time, content: trimContent(content) }; 71 | parsedLyrics.splice(binarySearch(parsedLyric), 0, parsedLyric); 72 | } 73 | } 74 | 75 | return parsedLyrics; 76 | } 77 | 78 | /** 79 | * @param {string} content 80 | * @returns {string} 81 | */ 82 | function trimContent(content) { 83 | let t = content.trim(); 84 | return t.length < 1 ? content : t; 85 | } 86 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import shortcuts from '@/utils/shortcuts'; 2 | import cloneDeep from 'lodash/cloneDeep'; 3 | 4 | export default { 5 | updateLikedXXX(state, { name, data }) { 6 | state.liked[name] = data; 7 | if (name === 'songs') { 8 | state.player.sendSelfToIpcMain(); 9 | } 10 | }, 11 | changeLang(state, lang) { 12 | state.settings.lang = lang; 13 | }, 14 | changeMusicQuality(state, value) { 15 | state.settings.musicQuality = value; 16 | }, 17 | changeLyricFontSize(state, value) { 18 | state.settings.lyricFontSize = value; 19 | }, 20 | changeOutputDevice(state, deviceId) { 21 | state.settings.outputDevice = deviceId; 22 | }, 23 | updateSettings(state, { key, value }) { 24 | state.settings[key] = value; 25 | }, 26 | updateData(state, { key, value }) { 27 | state.data[key] = value; 28 | }, 29 | togglePlaylistCategory(state, name) { 30 | const index = state.settings.enabledPlaylistCategories.findIndex( 31 | c => c === name 32 | ); 33 | if (index !== -1) { 34 | state.settings.enabledPlaylistCategories = 35 | state.settings.enabledPlaylistCategories.filter(c => c !== name); 36 | } else { 37 | state.settings.enabledPlaylistCategories.push(name); 38 | } 39 | }, 40 | updateToast(state, toast) { 41 | state.toast = toast; 42 | }, 43 | updateModal(state, { modalName, key, value }) { 44 | state.modals[modalName][key] = value; 45 | if (key === 'show') { 46 | // 100ms的延迟是为等待右键菜单blur之后再disableScrolling 47 | value === true 48 | ? setTimeout(() => (state.enableScrolling = false), 100) 49 | : (state.enableScrolling = true); 50 | } 51 | }, 52 | toggleLyrics(state) { 53 | state.showLyrics = !state.showLyrics; 54 | }, 55 | updateDailyTracks(state, dailyTracks) { 56 | state.dailyTracks = dailyTracks; 57 | }, 58 | updateLastfm(state, session) { 59 | state.lastfm = session; 60 | }, 61 | updateShortcut(state, { id, type, shortcut }) { 62 | let newShortcut = state.settings.shortcuts.find(s => s.id === id); 63 | newShortcut[type] = shortcut; 64 | state.settings.shortcuts = state.settings.shortcuts.map(s => { 65 | if (s.id !== id) return s; 66 | return newShortcut; 67 | }); 68 | }, 69 | restoreDefaultShortcuts(state) { 70 | state.settings.shortcuts = cloneDeep(shortcuts); 71 | }, 72 | enableScrolling(state, status = null) { 73 | state.enableScrolling = status ? status : !state.enableScrolling; 74 | }, 75 | updateTitle(state, title) { 76 | state.title = title; 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /src/views/artistMV.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 83 | 84 | 101 | -------------------------------------------------------------------------------- /src/api/artist.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | import { mapTrackPlayableStatus } from '@/utils/common'; 3 | 4 | /** 5 | * 获取歌手单曲 6 | * 说明 : 调用此接口 , 传入歌手 id, 可获得歌手部分信息和热门歌曲 7 | * @param {number} id - 歌手 id, 可由搜索接口获得 8 | */ 9 | export function getArtist(id) { 10 | return request({ 11 | url: '/artists', 12 | method: 'get', 13 | params: { 14 | id, 15 | timestamp: new Date().getTime(), 16 | }, 17 | }).then(data => { 18 | data.hotSongs = mapTrackPlayableStatus(data.hotSongs); 19 | return data; 20 | }); 21 | } 22 | 23 | /** 24 | * 获取歌手专辑 25 | * 说明 : 调用此接口 , 传入歌手 id, 可获得歌手专辑内容 26 | * - id: 歌手 id 27 | * - limit: 取出数量 , 默认为 50 28 | * - offset: 偏移数量 , 用于分页 , 如 :( 页数 -1)*50, 其中 50 为 limit 的值 , 默认为 0 29 | * @param {Object} params 30 | * @param {number} params.id 31 | * @param {number=} params.limit 32 | * @param {number=} params.offset 33 | */ 34 | export function getArtistAlbum(params) { 35 | return request({ 36 | url: '/artist/album', 37 | method: 'get', 38 | params, 39 | }); 40 | } 41 | 42 | /** 43 | * 歌手榜 44 | * 说明 : 调用此接口 , 可获取排行榜中的歌手榜 45 | * - type : 地区 46 | * 1: 华语 47 | * 2: 欧美 48 | * 3: 韩国 49 | * 4: 日本 50 | * @param {number=} type 51 | */ 52 | export function toplistOfArtists(type = null) { 53 | let params = {}; 54 | if (type) { 55 | params.type = type; 56 | } 57 | return request({ 58 | url: '/toplist/artist', 59 | method: 'get', 60 | params, 61 | }); 62 | } 63 | /** 64 | * 获取歌手 mv 65 | * 说明 : 调用此接口 , 传入歌手 id, 可获得歌手 mv 信息 , 具体 mv 播放地址可调 用/mv传入此接口获得的 mvid 来拿到 , 如 : /artist/mv?id=6452,/mv?mvid=5461064 66 | * @param {number} params.id 歌手 id, 可由搜索接口获得 67 | * @param {number} params.offset 68 | * @param {number} params.limit 69 | */ 70 | export function artistMv(params) { 71 | return request({ 72 | url: '/artist/mv', 73 | method: 'get', 74 | params, 75 | }); 76 | } 77 | 78 | /** 79 | * 收藏歌手 80 | * 说明 : 调用此接口 , 传入歌手 id, 可收藏歌手 81 | * - id: 歌手 id 82 | * - t: 操作,1 为收藏,其他为取消收藏 83 | * @param {Object} params 84 | * @param {number} params.id 85 | * @param {number} params.t 86 | */ 87 | export function followAArtist(params) { 88 | return request({ 89 | url: '/artist/sub', 90 | method: 'post', 91 | params, 92 | }); 93 | } 94 | 95 | /** 96 | * 相似歌手 97 | * 说明 : 调用此接口 , 传入歌手 id, 可获得相似歌手 98 | * - id: 歌手 id 99 | * @param {number} id 100 | */ 101 | export function similarArtists(id) { 102 | return request({ 103 | url: '/simi/artist', 104 | method: 'post', 105 | params: { id }, 106 | }); 107 | } 108 | -------------------------------------------------------------------------------- /src/api/auth.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | /** 4 | * 手机登录 5 | * - phone: 手机号码 6 | * - password: 密码 7 | * - countrycode: 国家码,用于国外手机号登录,例如美国传入:1 8 | * - md5_password: md5加密后的密码,传入后 password 将失效 9 | * @param {Object} params 10 | * @param {string} params.phone 11 | * @param {string} params.password 12 | * @param {string=} params.countrycode 13 | * @param {string=} params.md5_password 14 | */ 15 | export function loginWithPhone(params) { 16 | return request({ 17 | url: '/login/cellphone', 18 | method: 'post', 19 | params, 20 | }); 21 | } 22 | 23 | /** 24 | * 邮箱登录 25 | * - email: 163 网易邮箱 26 | * - password: 密码 27 | * - md5_password: md5加密后的密码,传入后 password 将失效 28 | * @param {Object} params 29 | * @param {string} params.email 30 | * @param {string} params.password 31 | * @param {string=} params.md5_password 32 | */ 33 | export function loginWithEmail(params) { 34 | return request({ 35 | url: '/login', 36 | method: 'post', 37 | params, 38 | }); 39 | } 40 | 41 | /** 42 | * 二维码key生成接口 43 | */ 44 | export function loginQrCodeKey() { 45 | return request({ 46 | url: '/login/qr/key', 47 | method: 'get', 48 | params: { 49 | timestamp: new Date().getTime(), 50 | }, 51 | }); 52 | } 53 | 54 | /** 55 | * 二维码生成接口 56 | * 说明: 调用此接口传入上一个接口生成的key可生成二维码图片的base64和二维码信息, 57 | * 可使用base64展示图片,或者使用二维码信息内容自行使用第三方二维码生产库渲染二维码 58 | * @param {Object} params 59 | * @param {string} params.key 60 | * @param {string=} params.qrimg 传入后会额外返回二维码图片base64编码 61 | */ 62 | export function loginQrCodeCreate(params) { 63 | return request({ 64 | url: '/login/qr/create', 65 | method: 'get', 66 | params: { 67 | ...params, 68 | timestamp: new Date().getTime(), 69 | }, 70 | }); 71 | } 72 | 73 | /** 74 | * 二维码检测扫码状态接口 75 | * 说明: 轮询此接口可获取二维码扫码状态,800为二维码过期,801为等待扫码,802为待确认,803为授权登录成功(803状态码下会返回cookies) 76 | * @param {string} key 77 | */ 78 | export function loginQrCodeCheck(key) { 79 | return request({ 80 | url: '/login/qr/check', 81 | method: 'get', 82 | params: { 83 | key, 84 | timestamp: new Date().getTime(), 85 | }, 86 | }); 87 | } 88 | 89 | /** 90 | * 刷新登录 91 | * 说明 : 调用此接口 , 可刷新登录状态 92 | * - 调用例子 : /login/refresh 93 | */ 94 | export function refreshCookie() { 95 | return request({ 96 | url: '/login/refresh', 97 | method: 'post', 98 | }); 99 | } 100 | 101 | /** 102 | * 退出登录 103 | * 说明 : 调用此接口 , 可退出登录 104 | */ 105 | export function logout() { 106 | return request({ 107 | url: '/logout', 108 | method: 'post', 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /src/electron/ipcRenderer.js: -------------------------------------------------------------------------------- 1 | import store from '@/store'; 2 | 3 | const player = store.state.player; 4 | 5 | export function ipcRenderer(vueInstance) { 6 | const self = vueInstance; 7 | // 添加专有的类名 8 | document.body.setAttribute('data-electron', 'yes'); 9 | document.body.setAttribute( 10 | 'data-electron-os', 11 | window.require('os').platform() 12 | ); 13 | // ipc message channel 14 | const electron = window.require('electron'); 15 | const ipcRenderer = electron.ipcRenderer; 16 | 17 | // listens to the main process 'changeRouteTo' event and changes the route from 18 | // inside this Vue instance, according to what path the main process requires. 19 | // responds to Menu click() events at the main process and changes the route accordingly. 20 | 21 | ipcRenderer.on('changeRouteTo', (event, path) => { 22 | self.$router.push(path); 23 | if (store.state.showLyrics) { 24 | store.commit('toggleLyrics'); 25 | } 26 | }); 27 | 28 | ipcRenderer.on('search', () => { 29 | // 触发数据响应 30 | self.$refs.navbar.$refs.searchInput.focus(); 31 | self.$refs.navbar.inputFocus = true; 32 | }); 33 | 34 | ipcRenderer.on('play', () => { 35 | player.playOrPause(); 36 | }); 37 | 38 | ipcRenderer.on('next', () => { 39 | if (player.isPersonalFM) { 40 | player.playNextFMTrack(); 41 | } else { 42 | player.playNextTrack(); 43 | } 44 | }); 45 | 46 | ipcRenderer.on('previous', () => { 47 | player.playPrevTrack(); 48 | }); 49 | 50 | ipcRenderer.on('increaseVolume', () => { 51 | if (player.volume + 0.1 >= 1) { 52 | return (player.volume = 1); 53 | } 54 | player.volume += 0.1; 55 | }); 56 | 57 | ipcRenderer.on('decreaseVolume', () => { 58 | if (player.volume - 0.1 <= 0) { 59 | return (player.volume = 0); 60 | } 61 | player.volume -= 0.1; 62 | }); 63 | 64 | ipcRenderer.on('like', () => { 65 | store.dispatch('likeATrack', player.currentTrack.id); 66 | }); 67 | 68 | ipcRenderer.on('repeat', () => { 69 | player.switchRepeatMode(); 70 | }); 71 | 72 | ipcRenderer.on('shuffle', () => { 73 | player.switchShuffle(); 74 | }); 75 | 76 | ipcRenderer.on('routerGo', (event, where) => { 77 | self.$refs.navbar.go(where); 78 | }); 79 | 80 | ipcRenderer.on('nextUp', () => { 81 | self.$refs.player.goToNextTracksPage(); 82 | }); 83 | 84 | ipcRenderer.on('rememberCloseAppOption', (event, value) => { 85 | store.commit('updateSettings', { 86 | key: 'closeAppOption', 87 | value, 88 | }); 89 | }); 90 | 91 | ipcRenderer.on('setPosition', (event, position) => { 92 | player._howler.seek(position); 93 | }); 94 | } 95 | -------------------------------------------------------------------------------- /src/electron/touchBar.js: -------------------------------------------------------------------------------- 1 | const { TouchBar, nativeImage, ipcMain } = require('electron'); 2 | const { TouchBarButton, TouchBarSpacer } = TouchBar; 3 | const path = require('path'); 4 | 5 | export function createTouchBar(window) { 6 | const renderer = window.webContents; 7 | 8 | // Icon follow touchbar design guideline. 9 | // See: https://developer.apple.com/design/human-interface-guidelines/macos/touch-bar/touch-bar-icons-and-images/ 10 | // Icon Resource: https://devimages-cdn.apple.com/design/resources/ 11 | function getNativeIcon(name) { 12 | return nativeImage.createFromPath( 13 | // eslint-disable-next-line no-undef 14 | path.join(__static, 'img/touchbar/', name) 15 | ); 16 | } 17 | 18 | const previousPage = new TouchBarButton({ 19 | click: () => { 20 | renderer.send('routerGo', 'back'); 21 | }, 22 | icon: getNativeIcon('page_prev.png'), 23 | }); 24 | 25 | const nextPage = new TouchBarButton({ 26 | click: () => { 27 | renderer.send('routerGo', 'forward'); 28 | }, 29 | icon: getNativeIcon('page_next.png'), 30 | }); 31 | 32 | const searchButton = new TouchBarButton({ 33 | click: () => { 34 | renderer.send('search'); 35 | }, 36 | icon: getNativeIcon('search.png'), 37 | }); 38 | 39 | const playButton = new TouchBarButton({ 40 | click: () => { 41 | renderer.send('play'); 42 | }, 43 | icon: getNativeIcon('play.png'), 44 | }); 45 | 46 | const previousTrackButton = new TouchBarButton({ 47 | click: () => { 48 | renderer.send('previous'); 49 | }, 50 | icon: getNativeIcon('backward.png'), 51 | }); 52 | 53 | const nextTrackButton = new TouchBarButton({ 54 | click: () => { 55 | renderer.send('next'); 56 | }, 57 | icon: getNativeIcon('forward.png'), 58 | }); 59 | 60 | const likeButton = new TouchBarButton({ 61 | click: () => { 62 | renderer.send('like'); 63 | }, 64 | icon: getNativeIcon('like.png'), 65 | }); 66 | 67 | const nextUpButton = new TouchBarButton({ 68 | click: () => { 69 | renderer.send('nextUp'); 70 | }, 71 | icon: getNativeIcon('next_up.png'), 72 | }); 73 | 74 | ipcMain.on('player', (e, { playing, likedCurrentTrack }) => { 75 | playButton.icon = 76 | playing === true ? getNativeIcon('pause.png') : getNativeIcon('play.png'); 77 | likeButton.icon = likedCurrentTrack 78 | ? getNativeIcon('like_fill.png') 79 | : getNativeIcon('like.png'); 80 | }); 81 | 82 | const touchBar = new TouchBar({ 83 | items: [ 84 | previousPage, 85 | nextPage, 86 | searchButton, 87 | new TouchBarSpacer({ size: 'flexible' }), 88 | previousTrackButton, 89 | playButton, 90 | nextTrackButton, 91 | new TouchBarSpacer({ size: 'flexible' }), 92 | likeButton, 93 | nextUpButton, 94 | ], 95 | }); 96 | return touchBar; 97 | } 98 | -------------------------------------------------------------------------------- /src/components/Win32Titlebar.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 66 | 67 | 122 | -------------------------------------------------------------------------------- /src/views/dailyTracks.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 59 | 60 | 133 | -------------------------------------------------------------------------------- /src/components/LinuxTitlebar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 69 | 70 | 131 | -------------------------------------------------------------------------------- /src/assets/css/slider.css: -------------------------------------------------------------------------------- 1 | /* rail style */ 2 | .vue-slider-rail { 3 | background-color: rgba(128, 128, 128, 0.18); 4 | border-radius: 15px; 5 | } 6 | 7 | /* process style */ 8 | .vue-slider-process { 9 | background-color: #335eea; 10 | border-radius: 15px; 11 | } 12 | 13 | /* dot style */ 14 | .vue-slider-dot-handle { 15 | cursor: pointer; 16 | width: 100%; 17 | height: 100%; 18 | border-radius: 50%; 19 | background-color: #fff; 20 | box-sizing: border-box; 21 | box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.12); 22 | visibility: hidden; 23 | } 24 | 25 | /* tooltip style */ 26 | .vue-slider-dot-tooltip-wrapper { 27 | opacity: 0; 28 | transition: all 1s; 29 | } 30 | 31 | .vue-slider-dot-tooltip-wrapper-show { 32 | opacity: 1; 33 | } 34 | 35 | .vue-slider-dot-tooltip-inner { 36 | font-size: 14px; 37 | white-space: nowrap; 38 | padding: 2px 6px; 39 | min-width: 20px; 40 | text-align: center; 41 | color: #000; 42 | border-radius: 5px; 43 | border-color: #fff; 44 | background-color: #fff; 45 | box-sizing: content-box; 46 | box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.08); 47 | } 48 | 49 | /* hover */ 50 | .vue-slider:hover .vue-slider-dot-handle, 51 | .vue-slider:active .vue-slider-dot-handle { 52 | visibility: visible; 53 | } 54 | 55 | /* volume style */ 56 | .volume-control .vue-slider-process { 57 | opacity: 0.8; 58 | background-color: var(--color-text); 59 | border-radius: 15px; 60 | } 61 | 62 | .volume-control:hover .vue-slider-process { 63 | background-color: #335eea; 64 | } 65 | 66 | /* nyancat */ 67 | 68 | .nyancat .vue-slider-rail { 69 | background-color: rgba(128, 128, 128, 0.18); 70 | padding: 2.5px 0px; 71 | border-radius: 0; 72 | } 73 | 74 | .nyancat .vue-slider-process { 75 | padding: 0px 1px; 76 | top: -2px; 77 | border-radius: 0; 78 | background: -webkit-gradient( 79 | linear, 80 | left top, 81 | left bottom, 82 | color-stop(0, #f00), 83 | color-stop(17%, #f90), 84 | color-stop(33%, #ff0), 85 | color-stop(50%, #3f0), 86 | color-stop(67%, #09f), 87 | color-stop(83%, #63f) 88 | ); 89 | } 90 | 91 | .nyancat .vue-slider-dot-handle { 92 | background: url('/img/logos/nyancat.gif'); 93 | background-size: 36px; 94 | width: 36px; 95 | height: 24px; 96 | margin-top: -6px; 97 | box-shadow: none; 98 | border-radius: 0; 99 | box-sizing: border-box; 100 | visibility: visible; 101 | } 102 | 103 | .nyancat-stop .vue-slider-dot-handle { 104 | background-image: url('/img/logos/nyancat-stop.png'); 105 | transition: 300ms; 106 | } 107 | 108 | /* lyrics */ 109 | .lyrics-page .vue-slider-rail { 110 | background-color: rgba(128, 128, 128, 0.18); 111 | border-radius: 2px; 112 | height: 4px; 113 | opacity: 0.88; 114 | } 115 | 116 | .lyrics-page .vue-slider-process { 117 | background-color: #060606; 118 | } 119 | 120 | .lyrics-page .vue-slider-dot-handle { 121 | background-color: #060606; 122 | box-shadow: unset; 123 | } 124 | 125 | .lyrics-page .vue-slider-dot-tooltip { 126 | display: none; 127 | } 128 | 129 | body[data-theme='dark'] .lyrics-page .vue-slider-process { 130 | background-color: #fafafa; 131 | } 132 | 133 | body[data-theme='dark'] .lyrics-page .vue-slider-dot-handle { 134 | background-color: #fff; 135 | } 136 | 137 | .lyrics-page[data-theme='dark'] .vue-slider-rail { 138 | background-color: rgba(255, 255, 255, 0.18); 139 | } 140 | 141 | .lyrics-page[data-theme='dark'] .vue-slider-process, 142 | .lyrics-page[data-theme='dark'] .vue-slider-dot-handle { 143 | background-color: #fff; 144 | } 145 | -------------------------------------------------------------------------------- /src/utils/filters.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import dayjs from 'dayjs'; 3 | import duration from 'dayjs/plugin/duration'; 4 | import relativeTime from 'dayjs/plugin/relativeTime'; 5 | import locale from '@/locale'; 6 | 7 | Vue.filter('formatTime', (Milliseconds, format = 'HH:MM:SS') => { 8 | if (!Milliseconds) return ''; 9 | dayjs.extend(duration); 10 | dayjs.extend(relativeTime); 11 | 12 | let time = dayjs.duration(Milliseconds); 13 | let hours = time.hours().toString(); 14 | let mins = time.minutes().toString(); 15 | let seconds = time.seconds().toString().padStart(2, '0'); 16 | 17 | if (format === 'HH:MM:SS') { 18 | return hours !== '0' 19 | ? `${hours}:${mins.padStart(2, '0')}:${seconds}` 20 | : `${mins}:${seconds}`; 21 | } else if (format === 'Human') { 22 | let hoursUnit, minitesUnit; 23 | switch (locale.locale) { 24 | case 'zh-CN': 25 | hoursUnit = '小时'; 26 | minitesUnit = '分钟'; 27 | break; 28 | case 'zh-TW': 29 | hoursUnit = '小時'; 30 | minitesUnit = '分鐘'; 31 | break; 32 | default: 33 | hoursUnit = 'hr'; 34 | minitesUnit = 'min'; 35 | break; 36 | } 37 | return hours !== '0' 38 | ? `${hours} ${hoursUnit} ${mins} ${minitesUnit}` 39 | : `${mins} ${minitesUnit}`; 40 | } 41 | }); 42 | 43 | Vue.filter('formatDate', (timestamp, format = 'MMM D, YYYY') => { 44 | if (!timestamp) return ''; 45 | if (locale.locale === 'zh-CN') format = 'YYYY年MM月DD日'; 46 | else if (locale.locale === 'zh-TW') format = 'YYYY年MM月DD日'; 47 | return dayjs(timestamp).format(format); 48 | }); 49 | 50 | Vue.filter('formatAlbumType', (type, album) => { 51 | if (!type) return ''; 52 | if (type === 'EP/Single') { 53 | return album.size === 1 ? 'Single' : 'EP'; 54 | } else if (type === 'Single') { 55 | return 'Single'; 56 | } else if (type === '专辑') { 57 | return 'Album'; 58 | } else { 59 | return type; 60 | } 61 | }); 62 | 63 | Vue.filter('resizeImage', (imgUrl, size = 512) => { 64 | if (!imgUrl) return ''; 65 | let httpsImgUrl = imgUrl; 66 | if (imgUrl.slice(0, 5) !== 'https') { 67 | httpsImgUrl = 'https' + imgUrl.slice(4); 68 | } 69 | return `${httpsImgUrl}?param=${size}y${size}`; 70 | }); 71 | 72 | Vue.filter('formatPlayCount', count => { 73 | if (!count) return ''; 74 | if (locale.locale === 'zh-CN') { 75 | if (count > 100000000) { 76 | return `${Math.floor((count / 100000000) * 100) / 100}亿`; // 2.32 亿 77 | } 78 | if (count > 100000) { 79 | return `${Math.floor((count / 10000) * 10) / 10}万`; // 232.1 万 80 | } 81 | if (count > 10000) { 82 | return `${Math.floor((count / 10000) * 100) / 100}万`; // 2.3 万 83 | } 84 | return count; 85 | } else if (locale.locale === 'zh-TW') { 86 | if (count > 100000000) { 87 | return `${Math.floor((count / 100000000) * 100) / 100}億`; // 2.32 億 88 | } 89 | if (count > 100000) { 90 | return `${Math.floor((count / 10000) * 10) / 10}萬`; // 232.1 萬 91 | } 92 | if (count > 10000) { 93 | return `${Math.floor((count / 10000) * 100) / 100}萬`; // 2.3 萬 94 | } 95 | return count; 96 | } else { 97 | if (count > 10000000) { 98 | return `${Math.floor((count / 1000000) * 10) / 10}M`; // 233.2M 99 | } 100 | if (count > 1000000) { 101 | return `${Math.floor((count / 1000000) * 100) / 100}M`; // 2.3M 102 | } 103 | if (count > 1000) { 104 | return `${Math.floor((count / 1000) * 100) / 100}K`; // 233.23K 105 | } 106 | return count; 107 | } 108 | }); 109 | 110 | Vue.filter('toHttps', url => { 111 | if (!url) return ''; 112 | return url.replace(/^http:/, 'https:'); 113 | }); 114 | -------------------------------------------------------------------------------- /src/views/login.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 66 | 67 | 152 | -------------------------------------------------------------------------------- /src/assets/css/global.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Barlow'; 3 | font-weight: normal; 4 | src: url('~@/assets/fonts/Barlow-Regular.woff2') format('woff2'), 5 | url('~@/assets/fonts/Barlow-Regular.ttf') format('truetype'); 6 | } 7 | @font-face { 8 | font-family: 'Barlow'; 9 | font-weight: medium; 10 | src: url('~@/assets/fonts/Barlow-Medium.woff2') format('woff2'), 11 | url('~@/assets/fonts/Barlow-Medium.ttf') format('truetype'); 12 | } 13 | @font-face { 14 | font-family: 'Barlow'; 15 | font-weight: 600; 16 | src: url('~@/assets/fonts/Barlow-SemiBold.woff2') format('woff2'), 17 | url('~@/assets/fonts/Barlow-SemiBold.ttf') format('truetype'); 18 | } 19 | @font-face { 20 | font-family: 'Barlow'; 21 | font-weight: bold; 22 | src: url('~@/assets/fonts/Barlow-Bold.woff2') format('woff2'), 23 | url('~@/assets/fonts/Barlow-Bold.ttf') format('truetype'); 24 | } 25 | @font-face { 26 | font-family: 'Barlow'; 27 | font-weight: 800; 28 | src: url('~@/assets/fonts/Barlow-ExtraBold.woff2') format('woff2'), 29 | url('~@/assets/fonts/Barlow-ExtraBold.ttf') format('truetype'); 30 | } 31 | @font-face { 32 | font-family: 'Barlow'; 33 | font-weight: 900; 34 | src: url('~@/assets/fonts/Barlow-Black.woff2') format('woff2'), 35 | url('~@/assets/fonts/Barlow-Black.ttf') format('truetype'); 36 | } 37 | 38 | :root { 39 | --color-body-bg: #ffffff; 40 | --color-text: #000; 41 | --color-primary: #335eea; 42 | --color-primary-bg: #eaeffd; 43 | --color-secondary: #7a7a7b; 44 | --color-secondary-bg: #f5f5f7; 45 | --color-navbar-bg: rgba(255, 255, 255, 0.86); 46 | --color-primary-bg-for-transparent: rgba(189, 207, 255, 0.28); 47 | --color-secondary-bg-for-transparent: rgba(209, 209, 214, 0.28); 48 | --html-overflow-y: overlay; 49 | } 50 | 51 | [data-theme='dark'] { 52 | --color-body-bg: #222222; 53 | --color-text: #ffffff; 54 | --color-primary: #335eea; 55 | --color-primary-bg: #bbcdff; 56 | --color-secondary: #7a7a7b; 57 | --color-secondary-bg: #323232; 58 | --color-navbar-bg: rgba(34, 34, 34, 0.86); 59 | --color-primary-bg-for-transparent: rgba(255, 255, 255, 0.12); 60 | --color-secondary-bg-for-transparent: rgba(255, 255, 255, 0.08); 61 | } 62 | 63 | #app, 64 | input { 65 | font-family: 'Barlow', ui-sans-serif, system-ui, -apple-system, 66 | BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, 67 | Source Han Sans SC, Noto Sans CJK SC, WenQuanYi Micro Hei, sans-serif, 68 | microsoft uighur; 69 | } 70 | body { 71 | background-color: var(--color-body-bg); 72 | } 73 | 74 | html { 75 | overflow-y: var(--html-overflow-y); 76 | min-width: 768px; 77 | overscroll-behavior: none; 78 | } 79 | 80 | select, 81 | button { 82 | font-family: inherit; 83 | } 84 | button { 85 | background: none; 86 | border: none; 87 | cursor: pointer; 88 | user-select: none; 89 | } 90 | input, 91 | button { 92 | &:focus { 93 | outline: none; 94 | } 95 | } 96 | a { 97 | color: inherit; 98 | text-decoration: none; 99 | cursor: pointer; 100 | &:hover { 101 | text-decoration: underline; 102 | } 103 | } 104 | 105 | [data-electron='yes'] { 106 | button, 107 | .navigation-links a, 108 | .playlist-info .description { 109 | cursor: default !important; 110 | } 111 | } 112 | 113 | ::-webkit-scrollbar { 114 | width: 8px; 115 | } 116 | 117 | ::-webkit-scrollbar-track { 118 | background: transparent; 119 | border-left: 1px solid rgba(128, 128, 128, 0.18); 120 | background: var(--color-body-bg); 121 | } 122 | 123 | ::-webkit-scrollbar-thumb { 124 | -webkit-border-radius: 10px; 125 | border-radius: 10px; 126 | background: rgba(128, 128, 128, 0.38); 127 | } 128 | 129 | [data-theme='dark'] ::-webkit-scrollbar-thumb { 130 | background: var(--color-secondary-bg); 131 | } 132 | 133 | .user-select-none { 134 | user-select: none; 135 | } 136 | -------------------------------------------------------------------------------- /src/views/next.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 113 | 114 | 143 | -------------------------------------------------------------------------------- /src/api/track.js: -------------------------------------------------------------------------------- 1 | import store from '@/store'; 2 | import request from '@/utils/request'; 3 | import { mapTrackPlayableStatus } from '@/utils/common'; 4 | import { 5 | cacheTrackDetail, 6 | getTrackDetailFromCache, 7 | cacheLyric, 8 | getLyricFromCache, 9 | } from '@/utils/db'; 10 | 11 | /** 12 | * 获取音乐 url 13 | * 说明 : 使用歌单详情接口后 , 能得到的音乐的 id, 但不能得到的音乐 url, 调用此接口, 传入的音乐 id( 可多个 , 用逗号隔开 ), 可以获取对应的音乐的 url, 14 | * !!!未登录状态返回试听片段(返回字段包含被截取的正常歌曲的开始时间和结束时间) 15 | * @param {string} id - 音乐的 id,例如 id=405998841,33894312 16 | */ 17 | export function getMP3(id) { 18 | const getBr = () => { 19 | // 当返回的 quality >= 400000时,就会优先返回 hi-res 20 | const quality = store.state.settings?.musicQuality ?? '320000'; 21 | return quality === 'flac' ? '350000' : quality; 22 | }; 23 | 24 | return request({ 25 | url: '/song/url', 26 | method: 'get', 27 | params: { 28 | id, 29 | br: getBr(), 30 | }, 31 | }); 32 | } 33 | 34 | /** 35 | * 获取歌曲详情 36 | * 说明 : 调用此接口 , 传入音乐 id(支持多个 id, 用 , 隔开), 可获得歌曲详情(注意:歌曲封面现在需要通过专辑内容接口获取) 37 | * @param {string} ids - 音乐 id, 例如 ids=405998841,33894312 38 | */ 39 | export function getTrackDetail(ids) { 40 | const fetchLatest = () => { 41 | return request({ 42 | url: '/song/detail', 43 | method: 'get', 44 | params: { 45 | ids, 46 | }, 47 | }).then(data => { 48 | data.songs.map(song => { 49 | const privileges = data.privileges.find(t => t.id === song.id); 50 | cacheTrackDetail(song, privileges); 51 | }); 52 | data.songs = mapTrackPlayableStatus(data.songs, data.privileges); 53 | return data; 54 | }); 55 | }; 56 | fetchLatest(); 57 | 58 | let idsInArray = [String(ids)]; 59 | if (typeof ids === 'string') { 60 | idsInArray = ids.split(','); 61 | } 62 | 63 | return getTrackDetailFromCache(idsInArray).then(result => { 64 | if (result) { 65 | result.songs = mapTrackPlayableStatus(result.songs, result.privileges); 66 | } 67 | return result ?? fetchLatest(); 68 | }); 69 | } 70 | 71 | /** 72 | * 获取歌词 73 | * 说明 : 调用此接口 , 传入音乐 id 可获得对应音乐的歌词 ( 不需要登录 ) 74 | * @param {number} id - 音乐 id 75 | */ 76 | export function getLyric(id) { 77 | const fetchLatest = () => { 78 | return request({ 79 | url: '/lyric', 80 | method: 'get', 81 | params: { 82 | id, 83 | }, 84 | }).then(result => { 85 | cacheLyric(id, result); 86 | return result; 87 | }); 88 | }; 89 | 90 | fetchLatest(); 91 | 92 | return getLyricFromCache(id).then(result => { 93 | return result ?? fetchLatest(); 94 | }); 95 | } 96 | 97 | /** 98 | * 新歌速递 99 | * 说明 : 调用此接口 , 可获取新歌速递 100 | * @param {number} type - 地区类型 id, 对应以下: 全部:0 华语:7 欧美:96 日本:8 韩国:16 101 | */ 102 | export function topSong(type) { 103 | return request({ 104 | url: '/top/song', 105 | method: 'get', 106 | params: { 107 | type, 108 | }, 109 | }); 110 | } 111 | 112 | /** 113 | * 喜欢音乐 114 | * 说明 : 调用此接口 , 传入音乐 id, 可喜欢该音乐 115 | * - id - 歌曲 id 116 | * - like - 默认为 true 即喜欢 , 若传 false, 则取消喜欢 117 | * @param {Object} params 118 | * @param {number} params.id 119 | * @param {boolean=} [params.like] 120 | */ 121 | export function likeATrack(params) { 122 | params.timestamp = new Date().getTime(); 123 | return request({ 124 | url: '/like', 125 | method: 'get', 126 | params, 127 | }); 128 | } 129 | 130 | /** 131 | * 听歌打卡 132 | * 说明 : 调用此接口 , 传入音乐 id, 来源 id,歌曲时间 time,更新听歌排行数据 133 | * - id - 歌曲 id 134 | * - sourceid - 歌单或专辑 id 135 | * - time - 歌曲播放时间,单位为秒 136 | * @param {Object} params 137 | * @param {number} params.id 138 | * @param {number} params.sourceid 139 | * @param {number=} params.time 140 | */ 141 | export function scrobble(params) { 142 | params.timestamp = new Date().getTime(); 143 | return request({ 144 | url: '/scrobble', 145 | method: 'get', 146 | params, 147 | }); 148 | } 149 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import { isLooseLoggedIn, isAccountLoggedIn } from '@/utils/auth'; 4 | 5 | Vue.use(VueRouter); 6 | const routes = [ 7 | { 8 | path: '/', 9 | name: 'home', 10 | component: () => import('@/views/home.vue'), 11 | meta: { 12 | keepAlive: true, 13 | savePosition: true, 14 | }, 15 | }, 16 | { 17 | path: '/login', 18 | name: 'login', 19 | component: () => import('@/views/login.vue'), 20 | }, 21 | { 22 | path: '/login/username', 23 | name: 'loginUsername', 24 | component: () => import('@/views/loginUsername.vue'), 25 | }, 26 | { 27 | path: '/login/account', 28 | name: 'loginAccount', 29 | component: () => import('@/views/loginAccount.vue'), 30 | }, 31 | { 32 | path: '/playlist/:id', 33 | name: 'playlist', 34 | component: () => import('@/views/playlist.vue'), 35 | }, 36 | { 37 | path: '/album/:id', 38 | name: 'album', 39 | component: () => import('@/views/album.vue'), 40 | }, 41 | { 42 | path: '/artist/:id', 43 | name: 'artist', 44 | component: () => import('@/views/artist.vue'), 45 | meta: { 46 | keepAlive: true, 47 | savePosition: true, 48 | }, 49 | }, 50 | { 51 | path: '/artist/:id/mv', 52 | name: 'artistMV', 53 | component: () => import('@/views/artistMV.vue'), 54 | meta: { 55 | keepAlive: true, 56 | }, 57 | }, 58 | { 59 | path: '/mv/:id', 60 | name: 'mv', 61 | component: () => import('@/views/mv.vue'), 62 | }, 63 | { 64 | path: '/next', 65 | name: 'next', 66 | component: () => import('@/views/next.vue'), 67 | meta: { 68 | keepAlive: true, 69 | savePosition: true, 70 | }, 71 | }, 72 | { 73 | path: '/search/:keywords?', 74 | name: 'search', 75 | component: () => import('@/views/search.vue'), 76 | meta: { 77 | keepAlive: true, 78 | }, 79 | }, 80 | { 81 | path: '/search/:keywords/:type', 82 | name: 'searchType', 83 | component: () => import('@/views/searchType.vue'), 84 | }, 85 | { 86 | path: '/new-album', 87 | name: 'newAlbum', 88 | component: () => import('@/views/newAlbum.vue'), 89 | }, 90 | { 91 | path: '/explore', 92 | name: 'explore', 93 | component: () => import('@/views/explore.vue'), 94 | meta: { 95 | keepAlive: true, 96 | savePosition: true, 97 | }, 98 | }, 99 | { 100 | path: '/library', 101 | name: 'library', 102 | component: () => import('@/views/library.vue'), 103 | meta: { 104 | requireLogin: true, 105 | keepAlive: true, 106 | savePosition: true, 107 | }, 108 | }, 109 | { 110 | path: '/library/liked-songs', 111 | name: 'likedSongs', 112 | component: () => import('@/views/playlist.vue'), 113 | meta: { 114 | requireLogin: true, 115 | }, 116 | }, 117 | { 118 | path: '/settings', 119 | name: 'settings', 120 | component: () => import('@/views/settings.vue'), 121 | }, 122 | { 123 | path: '/daily/songs', 124 | name: 'dailySongs', 125 | component: () => import('@/views/dailyTracks.vue'), 126 | meta: { 127 | requireAccountLogin: true, 128 | }, 129 | }, 130 | { 131 | path: '/lastfm/callback', 132 | name: 'lastfmCallback', 133 | component: () => import('@/views/lastfmCallback.vue'), 134 | }, 135 | ]; 136 | 137 | const router = new VueRouter({ 138 | mode: 'hash', 139 | routes, 140 | }); 141 | 142 | const originalPush = VueRouter.prototype.push; 143 | VueRouter.prototype.push = function push(location) { 144 | return originalPush.call(this, location).catch(err => err); 145 | }; 146 | 147 | router.beforeEach((to, from, next) => { 148 | // 需要登录的逻辑 149 | if (to.meta.requireAccountLogin) { 150 | if (isAccountLoggedIn()) { 151 | next(); 152 | } else { 153 | next({ path: '/login/account' }); 154 | } 155 | } 156 | if (to.meta.requireLogin) { 157 | if (isLooseLoggedIn()) { 158 | next(); 159 | } else { 160 | if (process.env.IS_ELECTRON === true) { 161 | next({ path: '/login/account' }); 162 | } else { 163 | next({ path: '/login' }); 164 | } 165 | } 166 | } else { 167 | next(); 168 | } 169 | }); 170 | 171 | export default router; 172 | -------------------------------------------------------------------------------- /src/components/ModalNewPlaylist.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 113 | 114 | 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yesplaymusic", 3 | "version": "0.4.5", 4 | "private": true, 5 | "description": "A third party music player for Netease Music", 6 | "author": "qier222", 7 | "scripts": { 8 | "serve": "vue-cli-service serve", 9 | "build": "vue-cli-service build", 10 | "lint": "vue-cli-service lint", 11 | "electron:build": "vue-cli-service electron:build -p never", 12 | "electron:build-all": "vue-cli-service electron:build -p never -mwl", 13 | "electron:build-mac": "vue-cli-service electron:build -p never -m", 14 | "electron:build-win": "vue-cli-service electron:build -p never -w", 15 | "electron:build-linux": "vue-cli-service electron:build -p never -l", 16 | "electron:serve": "vue-cli-service electron:serve", 17 | "electron:buildicon": "electron-icon-builder --input=./build/icons/icon.png --output=build --flatten", 18 | "electron:publish": "vue-cli-service electron:build -mwl -p always", 19 | "postinstall": "electron-builder install-app-deps", 20 | "postuninstall": "electron-builder install-app-deps", 21 | "prettier": "npx prettier --write ./src", 22 | "netease_api:run": "npx NeteaseCloudMusicApi" 23 | }, 24 | "main": "background.js", 25 | "dependencies": { 26 | "@unblockneteasemusic/rust-napi": "^0.3.0-pre.1", 27 | "NeteaseCloudMusicApi": "^4.5.2", 28 | "axios": "^0.26.1", 29 | "change-case": "^4.1.2", 30 | "cli-color": "^2.0.0", 31 | "color": "^4.2.3", 32 | "core-js": "^3.6.5", 33 | "crypto-js": "^4.0.0", 34 | "dayjs": "^1.8.36", 35 | "dexie": "^3.0.3", 36 | "discord-rich-presence": "^0.0.8", 37 | "electron": "^13.6.7", 38 | "electron-builder": "^23.0.0", 39 | "electron-context-menu": "^3.1.2", 40 | "electron-debug": "^3.1.0", 41 | "electron-devtools-installer": "^3.2", 42 | "electron-icon-builder": "^2.0.1", 43 | "electron-is-dev": "^2.0.0", 44 | "electron-log": "^4.3.0", 45 | "electron-store": "^8.0.1", 46 | "electron-updater": "^5.0.1", 47 | "express": "^4.17.1", 48 | "express-fileupload": "^1.2.0", 49 | "express-http-proxy": "^1.6.2", 50 | "extract-zip": "^2.0.1", 51 | "howler": "^2.2.3", 52 | "js-cookie": "^2.2.1", 53 | "jsbi": "^4.1.0", 54 | "lodash": "^4.17.20", 55 | "md5": "^2.3.0", 56 | "mpris-service": "^2.1.2", 57 | "music-metadata": "^7.5.3", 58 | "node-vibrant": "^3.2.1-alpha.1", 59 | "nprogress": "^0.2.0", 60 | "pac-proxy-agent": "^4.1.0", 61 | "plyr": "^3.6.2", 62 | "prettier": "2.5.1", 63 | "qrcode": "^1.4.4", 64 | "register-service-worker": "^1.7.1", 65 | "svg-sprite-loader": "^6.0.11", 66 | "tunnel": "^0.0.6", 67 | "vscode-codicons": "^0.0.17", 68 | "vue": "^2.6.11", 69 | "vue-clipboard2": "^0.3.1", 70 | "vue-gtag": "1", 71 | "vue-i18n": "^8.22.0", 72 | "vue-router": "^3.4.3", 73 | "vue-slider-component": "^3.2.5", 74 | "vue-tv-focusable": "^1.1.0", 75 | "vuex": "^3.4.0", 76 | "x11": "^2.3.0" 77 | }, 78 | "devDependencies": { 79 | "@types/node": "^17.0.0", 80 | "@vue/cli-plugin-babel": "~4.5.0", 81 | "@vue/cli-plugin-eslint": "~4.5.0", 82 | "@vue/cli-plugin-pwa": "~4.5.0", 83 | "@vue/cli-plugin-vuex": "~4.5.0", 84 | "@vue/cli-service": "~4.5.0", 85 | "babel-eslint": "^10.1.0", 86 | "eslint": "^6.7.2", 87 | "eslint-config-prettier": "^8.1.0", 88 | "eslint-plugin-prettier": "^3.3.1", 89 | "eslint-plugin-vue": "^7.9.0", 90 | "husky": "^4.3.0", 91 | "sass": "^1.26.11", 92 | "sass-loader": "^10.0.2", 93 | "vue-cli-plugin-electron-builder": "~2.1.1", 94 | "vue-template-compiler": "^2.6.11" 95 | }, 96 | "resolutions": { 97 | "icon-gen": "3.0.0", 98 | "degenerator": "2.2.0", 99 | "electron-builder": "^23.0.0" 100 | }, 101 | "eslintConfig": { 102 | "root": true, 103 | "env": { 104 | "node": true, 105 | "browser": true 106 | }, 107 | "extends": [ 108 | "plugin:vue/essential", 109 | "plugin:vue/recommended", 110 | "plugin:prettier/recommended", 111 | "eslint:recommended" 112 | ], 113 | "parserOptions": { 114 | "parser": "babel-eslint" 115 | }, 116 | "globals": { 117 | "ipcRenderer": "off" 118 | }, 119 | "rules": {} 120 | }, 121 | "browserslist": [ 122 | "> 1%", 123 | "last 2 versions", 124 | "not dead" 125 | ], 126 | "husky": { 127 | "hooks": { 128 | "pre-commit": "npm run prettier" 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/components/MvRow.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 84 | 85 | 178 | -------------------------------------------------------------------------------- /src/views/searchType.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 139 | 140 | 162 | -------------------------------------------------------------------------------- /src/components/Modal.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 64 | 65 | 194 | -------------------------------------------------------------------------------- /src/components/ModalAddTrackToPlaylist.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 109 | 110 | 175 | -------------------------------------------------------------------------------- /src/components/DailyTracksCard.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 79 | 80 | 189 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 132 | 133 | 172 | -------------------------------------------------------------------------------- /src/api/user.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | /** 4 | * 获取用户详情 5 | * 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户详情 6 | * - uid : 用户 id 7 | * @param {number} uid 8 | */ 9 | export function userDetail(uid) { 10 | return request({ 11 | url: '/user/detail', 12 | method: 'get', 13 | params: { 14 | uid, 15 | timestamp: new Date().getTime(), 16 | }, 17 | }); 18 | } 19 | 20 | /** 21 | * 获取账号详情 22 | * 说明 : 登录后调用此接口 ,可获取用户账号信息 23 | */ 24 | export function userAccount() { 25 | return request({ 26 | url: '/user/account', 27 | method: 'get', 28 | params: { 29 | timestamp: new Date().getTime(), 30 | }, 31 | }); 32 | } 33 | 34 | /** 35 | * 获取用户歌单 36 | * 说明 : 登录后调用此接口 , 传入用户 id, 可以获取用户歌单 37 | * - uid : 用户 id 38 | * - limit : 返回数量 , 默认为 30 39 | * - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0 40 | * @param {Object} params 41 | * @param {number} params.uid 42 | * @param {number} params.limit 43 | * @param {number=} params.offset 44 | */ 45 | export function userPlaylist(params) { 46 | return request({ 47 | url: '/user/playlist', 48 | method: 'get', 49 | params, 50 | }); 51 | } 52 | 53 | /** 54 | * 获取用户播放记录 55 | * 说明 : 登录后调用此接口 , 传入用户 id, 可获取用户播放记录 56 | * - uid : 用户 id 57 | * - type : type=1 时只返回 weekData, type=0 时返回 allData 58 | * @param {Object} params 59 | * @param {number} params.uid 60 | * @param {number} params.type 61 | */ 62 | export function userPlayHistory(params) { 63 | return request({ 64 | url: '/user/record', 65 | method: 'get', 66 | params, 67 | }); 68 | } 69 | 70 | /** 71 | * 喜欢音乐列表(需要登录) 72 | * 说明 : 调用此接口 , 传入用户 id, 可获取已喜欢音乐id列表(id数组) 73 | * - uid: 用户 id 74 | * @param {number} uid 75 | */ 76 | export function userLikedSongsIDs(uid) { 77 | return request({ 78 | url: '/likelist', 79 | method: 'get', 80 | params: { 81 | uid, 82 | timestamp: new Date().getTime(), 83 | }, 84 | }); 85 | } 86 | 87 | /** 88 | * 每日签到 89 | * 说明 : 调用此接口可签到获取积分 90 | * - type: 签到类型 , 默认 0, 其中 0 为安卓端签到 ,1 为 web/PC 签到 91 | * @param {number} type 92 | */ 93 | export function dailySignin(type = 0) { 94 | return request({ 95 | url: '/daily_signin', 96 | method: 'post', 97 | params: { 98 | type, 99 | timestamp: new Date().getTime(), 100 | }, 101 | }); 102 | } 103 | 104 | /** 105 | * 获取收藏的专辑(需要登录) 106 | * 说明 : 调用此接口可获取到用户收藏的专辑 107 | * - limit : 返回数量 , 默认为 25 108 | * - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*25, 其中 25 为 limit 的值 , 默认为 0 109 | * @param {Object} params 110 | * @param {number} params.limit 111 | * @param {number=} params.offset 112 | */ 113 | export function likedAlbums(params) { 114 | return request({ 115 | url: '/album/sublist', 116 | method: 'get', 117 | params: { 118 | limit: params.limit, 119 | timestamp: new Date().getTime(), 120 | }, 121 | }); 122 | } 123 | 124 | /** 125 | * 获取收藏的歌手(需要登录) 126 | * 说明 : 调用此接口可获取到用户收藏的歌手 127 | */ 128 | export function likedArtists(params) { 129 | return request({ 130 | url: '/artist/sublist', 131 | method: 'get', 132 | params: { 133 | limit: params.limit, 134 | timestamp: new Date().getTime(), 135 | }, 136 | }); 137 | } 138 | 139 | /** 140 | * 获取收藏的MV(需要登录) 141 | * 说明 : 调用此接口可获取到用户收藏的MV 142 | */ 143 | export function likedMVs(params) { 144 | return request({ 145 | url: '/mv/sublist', 146 | method: 'get', 147 | params: { 148 | limit: params.limit, 149 | timestamp: new Date().getTime(), 150 | }, 151 | }); 152 | } 153 | 154 | /** 155 | * 上传歌曲到云盘(需要登录) 156 | */ 157 | export function uploadSong(file) { 158 | let formData = new FormData(); 159 | formData.append('songFile', file); 160 | return request({ 161 | url: '/cloud', 162 | method: 'post', 163 | params: { 164 | timestamp: new Date().getTime(), 165 | }, 166 | data: formData, 167 | headers: { 168 | 'Content-Type': 'multipart/form-data', 169 | }, 170 | timeout: 200000, 171 | }).catch(error => { 172 | alert(`上传失败,Error: ${error}`); 173 | }); 174 | } 175 | 176 | /** 177 | * 获取云盘歌曲(需要登录) 178 | * 说明 : 登录后调用此接口 , 可获取云盘数据 , 获取的数据没有对应 url, 需要再调用一 次 /song/url 获取 url 179 | * - limit : 返回数量 , 默认为 200 180 | * - offset : 偏移数量,用于分页 , 如 :( 页数 -1)*200, 其中 200 为 limit 的值 , 默认为 0 181 | * @param {Object} params 182 | * @param {number} params.limit 183 | * @param {number=} params.offset 184 | */ 185 | export function cloudDisk(params = {}) { 186 | params.timestamp = new Date().getTime(); 187 | return request({ 188 | url: '/user/cloud', 189 | method: 'get', 190 | params, 191 | }); 192 | } 193 | 194 | /** 195 | * 获取云盘歌曲详情(需要登录) 196 | */ 197 | export function cloudDiskTrackDetail(id) { 198 | return request({ 199 | url: '/user/cloud/detail', 200 | method: 'get', 201 | params: { 202 | timestamp: new Date().getTime(), 203 | id, 204 | }, 205 | }); 206 | } 207 | 208 | /** 209 | * 删除云盘歌曲(需要登录) 210 | * @param {Array} id 211 | */ 212 | export function cloudDiskTrackDelete(id) { 213 | return request({ 214 | url: '/user/cloud/del', 215 | method: 'get', 216 | params: { 217 | timestamp: new Date().getTime(), 218 | id, 219 | }, 220 | }); 221 | } 222 | -------------------------------------------------------------------------------- /src/views/loginUsername.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 108 | 109 | 206 | -------------------------------------------------------------------------------- /src/utils/db.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Dexie from 'dexie'; 3 | import store from '@/store'; 4 | // import pkg from "../../package.json"; 5 | 6 | const db = new Dexie('yesplaymusic'); 7 | 8 | db.version(4).stores({ 9 | trackDetail: '&id, updateTime', 10 | lyric: '&id, updateTime', 11 | album: '&id, updateTime', 12 | }); 13 | 14 | db.version(3) 15 | .stores({ 16 | trackSources: '&id, createTime', 17 | }) 18 | .upgrade(tx => 19 | tx 20 | .table('trackSources') 21 | .toCollection() 22 | .modify( 23 | track => !track.createTime && (track.createTime = new Date().getTime()) 24 | ) 25 | ); 26 | 27 | db.version(1).stores({ 28 | trackSources: '&id', 29 | }); 30 | 31 | let tracksCacheBytes = 0; 32 | 33 | async function deleteExcessCache() { 34 | if ( 35 | store.state.settings.cacheLimit === false || 36 | tracksCacheBytes < store.state.settings.cacheLimit * Math.pow(1024, 2) 37 | ) { 38 | return; 39 | } 40 | try { 41 | const delCache = await db.trackSources.orderBy('createTime').first(); 42 | await db.trackSources.delete(delCache.id); 43 | tracksCacheBytes -= delCache.source.byteLength; 44 | console.debug( 45 | `[debug][db.js] deleteExcessCacheSucces, track: ${delCache.name}, size: ${delCache.source.byteLength}, cacheSize:${tracksCacheBytes}` 46 | ); 47 | deleteExcessCache(); 48 | } catch (error) { 49 | console.debug('[debug][db.js] deleteExcessCacheFailed', error); 50 | } 51 | } 52 | 53 | export function cacheTrackSource(trackInfo, url, bitRate, from = 'netease') { 54 | if (!process.env.IS_ELECTRON) return; 55 | const name = trackInfo.name; 56 | const artist = 57 | (trackInfo.ar && trackInfo.ar[0]?.name) || 58 | (trackInfo.artists && trackInfo.artists[0]?.name) || 59 | 'Unknown'; 60 | let cover = trackInfo.al.picUrl; 61 | if (cover.slice(0, 5) !== 'https') { 62 | cover = 'https' + cover.slice(4); 63 | } 64 | axios.get(`${cover}?param=512y512`); 65 | axios.get(`${cover}?param=224y224`); 66 | axios.get(`${cover}?param=1024y1024`); 67 | return axios 68 | .get(url, { 69 | responseType: 'arraybuffer', 70 | }) 71 | .then(response => { 72 | db.trackSources.put({ 73 | id: trackInfo.id, 74 | source: response.data, 75 | bitRate, 76 | from, 77 | name, 78 | artist, 79 | createTime: new Date().getTime(), 80 | }); 81 | console.debug(`[debug][db.js] cached track 👉 ${name} by ${artist}`); 82 | tracksCacheBytes += response.data.byteLength; 83 | deleteExcessCache(); 84 | return { trackID: trackInfo.id, source: response.data, bitRate }; 85 | }); 86 | } 87 | 88 | export function getTrackSource(id) { 89 | return db.trackSources.get(Number(id)).then(track => { 90 | if (!track) return null; 91 | console.debug( 92 | `[debug][db.js] get track from cache 👉 ${track.name} by ${track.artist}` 93 | ); 94 | return track; 95 | }); 96 | } 97 | 98 | export function cacheTrackDetail(track, privileges) { 99 | db.trackDetail.put({ 100 | id: track.id, 101 | detail: track, 102 | privileges: privileges, 103 | updateTime: new Date().getTime(), 104 | }); 105 | } 106 | 107 | export function getTrackDetailFromCache(ids) { 108 | return db.trackDetail 109 | .filter(track => { 110 | return ids.includes(String(track.id)); 111 | }) 112 | .toArray() 113 | .then(tracks => { 114 | const result = { songs: [], privileges: [] }; 115 | ids.map(id => { 116 | const one = tracks.find(t => String(t.id) === id); 117 | result.songs.push(one?.detail); 118 | result.privileges.push(one?.privileges); 119 | }); 120 | if (result.songs.includes(undefined)) { 121 | return undefined; 122 | } 123 | return result; 124 | }); 125 | } 126 | 127 | export function cacheLyric(id, lyrics) { 128 | db.lyric.put({ 129 | id, 130 | lyrics, 131 | updateTime: new Date().getTime(), 132 | }); 133 | } 134 | 135 | export function getLyricFromCache(id) { 136 | return db.lyric.get(Number(id)).then(result => { 137 | if (!result) return undefined; 138 | return result.lyrics; 139 | }); 140 | } 141 | 142 | export function cacheAlbum(id, album) { 143 | db.album.put({ 144 | id: Number(id), 145 | album, 146 | updateTime: new Date().getTime(), 147 | }); 148 | } 149 | 150 | export function getAlbumFromCache(id) { 151 | return db.album.get(Number(id)).then(result => { 152 | if (!result) return undefined; 153 | return result.album; 154 | }); 155 | } 156 | 157 | export function countDBSize() { 158 | const trackSizes = []; 159 | return db.trackSources 160 | .each(track => { 161 | trackSizes.push(track.source.byteLength); 162 | }) 163 | .then(() => { 164 | const res = { 165 | bytes: trackSizes.reduce((s1, s2) => s1 + s2, 0), 166 | length: trackSizes.length, 167 | }; 168 | tracksCacheBytes = res.bytes; 169 | console.debug( 170 | `[debug][db.js] load tracksCacheBytes: ${tracksCacheBytes}` 171 | ); 172 | return res; 173 | }); 174 | } 175 | 176 | export function clearDB() { 177 | return new Promise(resolve => { 178 | db.tables.forEach(function (table) { 179 | table.clear(); 180 | }); 181 | resolve(); 182 | }); 183 | } 184 | -------------------------------------------------------------------------------- /src/components/Cover.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 101 | 102 | 207 | -------------------------------------------------------------------------------- /src/components/Scrollbar.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 152 | 153 | 199 | --------------------------------------------------------------------------------