├── .browserslistrc ├── .editorconfig ├── .env ├── .eslintrc.js ├── .gitignore ├── .husky └── commit-msg ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── babel.config.js ├── jsconfig.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── favicon.ico ├── img │ └── warn.png ├── index.html └── prompt.html ├── screenshots ├── 1.jpg ├── 2.jpg ├── 3.jpg ├── 4.jpg ├── 5.jpg ├── 6.jpg ├── 7.jpg └── 8.jpg ├── src ├── App.vue ├── api │ └── index.js ├── assets │ ├── background │ │ ├── bg-1.jpg │ │ ├── bg-2.jpg │ │ └── bg-3.jpg │ └── img │ │ ├── album_cover_player.png │ │ ├── default.png │ │ ├── player_cover.png │ │ └── wave.gif ├── base │ ├── mm-dialog │ │ └── mm-dialog.vue │ ├── mm-icon │ │ └── mm-icon.vue │ ├── mm-loading │ │ └── mm-loading.vue │ ├── mm-no-result │ │ └── mm-no-result.vue │ ├── mm-progress │ │ └── mm-progress.vue │ └── mm-toast │ │ ├── index.js │ │ └── mm-toast.vue ├── components │ ├── lyric │ │ └── lyric.vue │ ├── mm-header │ │ └── mm-header.vue │ ├── music-btn │ │ └── music-btn.vue │ ├── music-list │ │ └── music-list.vue │ └── volume │ │ └── volume.vue ├── config.js ├── main.js ├── pages │ ├── comment │ │ └── comment.vue │ ├── details │ │ └── details.vue │ ├── historyList │ │ └── historyList.vue │ ├── mmPlayer.js │ ├── music.vue │ ├── playList │ │ └── playList.vue │ ├── search │ │ └── search.vue │ ├── topList │ │ └── topList.vue │ └── userList │ │ └── userList.vue ├── router │ └── index.js ├── store │ ├── actions.js │ ├── getters.js │ ├── index.js │ ├── mutation-types.js │ ├── mutations.js │ └── state.js ├── styles │ ├── index.less │ ├── mixin.less │ ├── reset.less │ └── var.less └── utils │ ├── axios.js │ ├── hack.js │ ├── mixin.js │ ├── song.js │ ├── storage.js │ └── util.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # 后台 api 服务地址 2 | VUE_APP_BASE_API_URL=http://localhost:3000 3 | 4 | # 访客统计 id (默认不开启,设置值后展示) 5 | VUE_APP_VISITOR_BADGE_ID= 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parserOptions: { 7 | parser: '@babel/eslint-parser', 8 | }, 9 | extends: ['plugin:vue/recommended', 'prettier', 'plugin:prettier/recommended'], 10 | rules: { 11 | 'vue/multi-word-component-names': 'off', 12 | 'vue/max-attributes-per-line': [ 13 | 'error', 14 | { 15 | singleline: 10, 16 | multiline: { 17 | max: 1, 18 | }, 19 | }, 20 | ], 21 | 'vue/singleline-html-element-content-newline': 'off', 22 | 'vue/html-self-closing': [ 23 | 'warn', 24 | { 25 | html: { 26 | void: 'always', 27 | normal: 'never', 28 | }, 29 | svg: 'never', 30 | math: 'never', 31 | }, 32 | ], 33 | 'vue/multiline-html-element-content-newline': 'off', 34 | 'vue/name-property-casing': ['error', 'PascalCase'], 35 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 36 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 37 | 'no-sequences': 2, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Editor directories and files 4 | .idea 5 | 6 | # dependencies 7 | node_modules 8 | 9 | # lock files 10 | yarn.lock 11 | package-lock.json 12 | 13 | # log files 14 | *-debug.log 15 | *-error.log 16 | 17 | # compile 18 | dist 19 | 20 | # local env files 21 | .env.local 22 | .env.*.local 23 | 24 | # misc 25 | .DS_Store 26 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx femm-verify-commit $1 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | strict-peer-dependencies=false 3 | 4 | registry=https://registry.npmmirror.com 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 maomao1996 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mmPlayer 2 | 3 | > mmPlayer 是由茂茂开源的一款在线音乐播放器,具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能 4 | 5 | 模仿 QQ 音乐网页版界面,采用 `flexbox` 和 `position` 布局;<br /> 6 | mmPlayer 虽然是响应式,但主要以 PC 端为主,移动端只做相应适配;<br /> 7 | 只做主流浏览器兼容(对 IE 说拜拜,想想以前做项目还要兼容 IE7 ,都是泪啊!!!) 8 | 9 | - [在线演示地址](https://netease-music.fe-mm.com/) 10 | - [React 移动端版本(高仿网易云音乐)](https://github.com/maomao1996/react-music) 11 | - [交流 QQ 群:529940193](http://shang.qq.com/wpa/qunwpa?idkey=f8be1b627a89108ccfda9308720d2a4d0eb3306f253c5d3e8d58452e20b91129) 本群不解答部署相关问题,如有部署问题请看[关于项目线上部署](#关于项目线上部署) 12 | - 本播放器由 **[茂茂](https://github.com/maomao1996) 开发**,您可以随意修改、使用、转载。但**使用或转载时请务必保留出处(控制台的注释信息)**!!! 13 | 14 | ## 免责声明 15 | 16 | 1. 本项目是一个**前端练手的实战项目**,旨在**帮助开发者提升技能水平和对前端技术的理解**。 17 | 18 | 2. 本项目**不提供任何音频存储和贩卖服务**。所有音频内容均由网易云音乐的第三方 API 提供,**仅供个人学习研究使用,严禁将其用于任何商业及非法用途**,版权归原始平台所有。 19 | 20 | 3. 使用本项目造成的任何纠纷、责任或损失**由使用者自行承担**。本项目开发者不对因使用本项目而产生的任何直接或间接责任承担责任,并保留追究使用者违法行为的权利。 21 | 22 | 4. **请使用者在使用本项目时遵守相关法律法规,不得将本项目用于任何商业及非法用途**。如有违反,一切后果由使用者自负。同时,使用者应该自行承担因使用本项目而带来的风险和责任。 23 | 24 | 5. 本项目使用了网易云音乐的[第三方 API 服务](https://github.com/Binaryify/NeteaseCloudMusicApi),对于该第三方 API 服务造成的任何问题,本项目开发者不承担责任。 25 | 26 | 请在使用本项目之前仔细阅读以上免责声明,并确保您已完全理解并接受其中的所有条款和条件。如果您不同意或无法遵守这些规定,请不要使用本项目。 27 | 28 | ## 安装与使用 29 | 30 | ### 检查 node 版本 31 | 32 | ```sh 33 | # 查看 node 版本,确保 node 版本高于 12 版本 34 | node -v 35 | ``` 36 | 37 | ### mmPlayer 38 | 39 | ```sh 40 | # 下载 mmPlayer 41 | git clone https://github.com/maomao1996/Vue-mmPlayer 42 | 43 | # 进入 mmPlayer 播放器目录 44 | cd Vue-mmPlayer 45 | 46 | # 安装依赖 推荐使用 pnpm 47 | pnpm install 48 | # 或者 49 | npm install 50 | 51 | # 本地运行 mmPlayer 52 | npm run serve 53 | 54 | # 编译打包 55 | npm run build 56 | ``` 57 | 58 | ### 后台 api 服务(本地开发) 59 | 60 | [网易云音乐 NodeJS 版 API](https://binaryify.github.io/NeteaseCloudMusicApi) 61 | 62 | ```sh 63 | # 下载 NeteaseCloudMusicApi 64 | git clone --depth=1 https://github.com/Binaryify/NeteaseCloudMusicApi 65 | 66 | # 进入 NeteaseCloudMusicApi 后台服务目录 67 | cd NeteaseCloudMusicApi 68 | 69 | # 安装依赖 70 | npm install 71 | 72 | # 运行后台 api 服务 访问 http://localhost:3000 73 | node app.js 74 | ``` 75 | 76 | ### 注意点 77 | 78 | **运行 mmPlayer 后无法获取音乐请检查后台 `api` 服务是否启动(即控制台请求报 404)**<br /> 79 | **线上部署不是直接将整个项目丢到服务器,再去运行 `npm run serve` 命令**<br /> 80 | **项目打包前 `VUE_APP_BASE_API_URL` 必须改后台 `api` 服务地址为线上地址,不能是本地地址** 81 | 82 | ### 关于项目线上部署 83 | 84 | 最近有不少小伙伴部署出了问题,我在这说明下 85 | 86 | - 后台 `api` 服务线上部署 87 | - 你需要将 [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 下载 88 | - 然后将下载的文件上传至服务器 89 | - 再通过 `pm2` 去启动服务(`pm2` 安装和相关命令网上有很多,这里不再赘述) 90 | - 最后通过服务器 `ip` + 端口号访问验证 `api` 服务是否启动成功 91 | - `mmPlayer` 线上部署(推荐使用 [Vercel 部署](#vercel-部署)) 92 | - 首先要注意的是 93 | - 先将 `.env` 文件的 `VUE_APP_BASE_API_URL` 修改成上一步启动的后台 `api` 服务地址(服务器 `ip` + 端口号或者你绑定的域名) 94 | - 然后先在本地运行 `npm run build` 命令,会打包在生成一个 `dist` 文件 95 | - 最后将打包的 `dist` 文件上传到你的网站服务器目录即可 96 | - 其他:[在宝塔面板部署 mmPlayer](https://github.com/maomao1996/Blog/issues/1)(不喜欢写文,可能有点烂不要介意哈) 97 | - 最后:本人已和谷歌、百度达成合作了,如果还有啥不懂的,以后可以直接谷歌、百度 98 | 99 | #### Vercel 部署 100 | 101 | 1. `fork` 此项目 102 | 2. 在 [Vercel](https://vercel.com) 官网点击 New Project 103 | 3. 点击 `Import Git Repository` 104 | 1. 选择你 `fork` 的此项目 105 | 2. 点击 `import` 106 | 4. `Configure Project` 配置 107 | 1. `Project Name` 自己填 108 | 2. `Framework Preset` 选 `Vue.js` (基本默认就是,不用修改) 109 | 3. 点击 `Environment Variables`,并添加一条 110 | 1. `key` 输入 `VUE_APP_BASE_API_URL` 111 | 2. `value` 输入你后台 `api`([NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi))服务的线上地址 112 | 5. 点击 `Deploy` 等部署完成即可 113 | 114 | ## 技术栈 115 | 116 | - [Vue Cli](https://cli.vuejs.org/zh/) Vue 脚手架工具 117 | - [Vue 2.x](https://v2.cn.vuejs.org/) 核心框架 118 | - [Vue Router](https://router.vuejs.org/zh/) 页面路由 119 | - [Vuex](https://vuex.vuejs.org/zh/) 状态管理 120 | - ES6 (JavaScript 语言的下一代标准) 121 | - Less(CSS 预处理器) 122 | - Axios(网络请求) 123 | - FastClick(解决移动端 300ms 点击延迟) 124 | 125 | ## 项目结构目录图(使用 tree 生成) 126 | 127 | <details> 128 | <summary>展开查看</summary> 129 | <pre><code> 130 | ├── public // 静态资源目录 131 | │ └─index.html // 入口 html 文件 132 | ├── screenshots // 项目截图 133 | ├── src // 项目源码目录 134 | │ ├── api // 数据交互目录 135 | │ │ └── index.js // 获取数据 136 | │ ├── assets // 资源目录 137 | │ │ └── background // 启动背景图目录 138 | │ │ └── img // 静态图片目录 139 | │ ├── base // 公共基础组件目录 140 | │ │ ├── mm-dialog 141 | │ │ │ └── mm-dialog.vue // 对话框组件 142 | │ │ ├── mm-icon 143 | │ │ │ └── mm-icon.vue // icon 组件 144 | │ │ ├── mm-loading 145 | │ │ │ └── mm-loading.vue // 加载动画组件 146 | │ │ ├── mm-no-result 147 | │ │ │ └── mm-no-result.vue // 暂无数据提示组件 148 | │ │ ├── mm-progress 149 | │ │ │ └── mm-progress.vue // 进度条拖动组件 150 | │ │ └── mm-toast 151 | │ │ ├── index.js // mm-toast 组件插件化配置 152 | │ │ └── mm-toast.vue // 弹出层提示组件 153 | │ ├── components // 公共项目组件目录 154 | │ │ ├── lyric 155 | │ │ │ └── lyric // 歌词和封面组件 156 | │ │ └── mm-header 157 | │ │ │ └── mm-header.vue // 头部组件 158 | │ │ ├── music-btn 159 | │ │ │ └── music-btn.vue // 按钮组件 160 | │ │ ├── music-list 161 | │ │ │ └── music-list.vue // 列表组件 162 | │ │ └── volume 163 | │ │ └── volume.vue // 音量控制组件 164 | │ ├── pages // 页面组件目录 165 | │ │ ├── comment 166 | │ │ │ └── comment.vue // 评论 167 | │ │ ├── details 168 | │ │ │ └── details.vue // 排行榜详情 169 | │ │ ├── historyList 170 | │ │ │ └── historyList.vue // 我听过的(播放历史) 171 | │ │ ├── playList 172 | │ │ │ └── playList.vue // 正在播放 173 | │ │ ├── search 174 | │ │ │ └── search.vue // 搜索 175 | │ │ ├── topList 176 | │ │ │ └── topList.vue // 排行榜页面 177 | │ │ ├── userList 178 | │ │ │ └── userList.vue // 我的歌单 179 | │ │ ├── mmPlayer.js // 播放器事相关件绑定 180 | │ │ └── music.vue // 播放器主页面 181 | │ ├── router 182 | │ │ └── index.js // 路由配置 183 | │ ├── store // vuex 的状态管理 184 | │ │ ├── actions.js // 配置 actions 185 | │ │ ├── getters.js // 配置 getters 186 | │ │ ├── index.js // 引用 vuex,创建 store 187 | │ │ ├── mutation-types.js // 定义常量 mutations 名 188 | │ │ ├── mutations.js // 配置 mutations 189 | │ │ └── state.js // 配置 state 190 | │ ├── styles // 样式文件目录 191 | │ │ ├── index.less // mmPlayer 相关基础样式 192 | │ │ ├── mixin.less // 样式混合 193 | │ │ ├── reset.less // 样式重置 194 | │ │ └── var.less // 样式变量(字体大小、字体颜色、背景颜色) 195 | │ ├── js // 数据交互目录 196 | │ │ ├── axios.js // axios 简单封装 197 | │ │ ├── hack.js // 修改 nextTick 198 | │ │ ├── mixin.js // 组件混合 199 | │ │ ├── song.js // 数据处理 200 | │ │ ├── storage.js // localStorage 配置 201 | │ │ └── util.js // 公用 js 方法 202 | │ ├── App.vue // 根组件 203 | │ ├── config.js // 配置文件(播放器默认配置、版本号等) 204 | │ └── main.js // 入口主文件 205 | └── vue.config.js // vue-cli 配置文件 206 | 207 | </code></pre> 208 | 209 | </details> 210 | 211 | ## 功能与界面 212 | 213 | - 播放器 214 | - 快捷键操作 215 | - 歌词滚动 216 | - 正在播放 217 | - 排行榜 218 | - 歌单详情 219 | - 搜索 220 | - 播放历史 221 | - 查看评论 222 | - 同步网易云歌单 223 | 224 | ### 界面欣赏 225 | 226 | PC 端界面自我感觉还行, 就是移动端界面总觉得怪怪的,奈何审美有限,所以又去整了高仿网易云的 `React` 版本(如果小哥哥、小姐姐们有好看的界面,欢迎交流哈) 227 | 228 | <details> 229 | <summary>点击查看</summary> 230 | 231 | #### PC 232 | 233 | ##### 正在播放 234 | 235 |  236 | 237 | ##### 排行榜 238 | 239 |  240 | 241 | ##### 搜索 242 | 243 |  244 | 245 | ##### 我的歌单 246 | 247 |  248 | 249 | ##### 我听过的 250 | 251 |  252 | 253 | ##### 歌曲评论 254 | 255 |  256 | 257 | #### 移动端 258 | 259 |  260 |  261 | 262 | </details> 263 | 264 | ## 更新说明 265 | 266 | ### V1.8.3(2022.12.01) 267 | 268 | - 修复音乐搜索 269 | - 修复歌手信息为空 270 | - 优化横向滚动条样式 271 | 272 | <details> 273 | <summary>查看更多</summary> 274 | 275 | ### V1.8.2(2021.08.23) 276 | 277 | - 移除我的歌单喜欢的音乐 278 | - 优化请求错误处理 279 | 280 | ### V1.8.1(2021.02.02) 281 | 282 | - 修复音乐进度条点击无效问题 283 | 284 | ### V1.8.0(2020.08.22) 285 | 286 | - 适配最新版后台 api 287 | - 修复背景图白边 288 | 289 | ### V1.7.1(2020.07.11) 290 | 291 | - 新增 IE 提示页面 292 | - 统一错误处理 293 | 294 | ### V1.7.0(2020.06.27) 295 | 296 | - 移动端增加歌词显示 297 | 298 | ### V1.6.9(2020.06.04) 299 | 300 | - 修改登录用户头像和网易云跳转地址为 https 协议 301 | 302 | ### V1.6.8(2020.06.01) 303 | 304 | - 修复歌单详情获取不到完整歌曲详情问题 305 | 306 | ### V1.6.7(2020.05.02) 307 | 308 | - 优化进度条拖动,分离拖动进度和音乐播放进度 309 | 310 | ### V1.6.6(2020.04.18) 311 | 312 | - 增加播放失败重试机制 313 | - 优化 `toHttps` 方法和版本更新时间的写入 314 | 315 | ### V1.6.5(2020.04.09) 316 | 317 | - 增加对 https 的支持 318 | 319 | ### V1.6.4(2020.02.04) 320 | 321 | - 调整默认音量 322 | 323 | ### V1.6.3(2020.01.09) 324 | 325 | - 修复快速滚动页面空白问题 326 | - 修复播放失败控制台报错问题 327 | 328 | ### V1.6.2(2019.11.17) 329 | 330 | - 提高歌词滚动精度 331 | 332 | ### V1.6.1(2019.09.28) 333 | 334 | - 修复歌单列表无数据时 JS 报错问题 335 | - 优化有文字复制选中时进度条拖动异常问题 336 | 337 | ### V1.6.0(2019.08.26) 338 | 339 | - 采用字体图标 340 | - 优化歌词滚动处理 341 | - 修复推荐页面样式问题 342 | - 调整封面图的分辨率 343 | - 优化首屏加载动画逻辑 344 | 345 | ### V1.5.7(2019.08.19) 346 | 347 | - 增加默认背景图随机展示,同时出除默认背景图,需开发者自行引入网络图 / 本地图 348 | - 调整默认音量 349 | - 优化首屏加载动画样式(提高逼格) 350 | - 优化 loading 遮罩颜色 351 | 352 | ### V1.5.6(2019.04.04) 353 | 354 | - 升级 `Vue` 版本 355 | - 优化脚手架配置 356 | - 修复 Safari、IOS 微信、安卓 UC 不能播放问题 357 | 358 | ### V1.5.5(2019.03.29) 359 | 360 | - 修改 `Vue` 构建版本 361 | - 优化滚动体验,缓存滚动位置 362 | - 优化暂停 / 播放逻辑,减少重复请求 363 | - 优化代码,提高复用 364 | - 修复 IOS 下滚动卡顿的情况 365 | 366 | ### V1.5.4(2019.01.08) 367 | 368 | - 更新后台服务器 369 | - 修复无法播放问题 370 | - 修复歌单详情打开失败问题 371 | - 修改音乐是否可用的判断逻辑 372 | - 优化登录操作体验,增加回车事件监听 373 | - 扩大查看评论者主页点击范围 374 | 375 | ### V1.5.3(2018.07.30) 376 | 377 | - 修复列表只有一首歌时的 `BUG` 378 | - 去除无关请求操作 379 | - 优化请求播放列表逻辑 380 | 381 | ### V1.5.2(2018.05.23) 382 | 383 | - 新增推荐歌单 384 | - 新增图片懒加载 385 | - 更新获取歌单列表接口 386 | - 优化歌单列表展示 387 | 388 | ### V1.5.1(2018.05.21) 389 | 390 | - 更新后台服务器 391 | - 修改热搜展示数据 392 | - 提取基础网络请求中的配置 393 | 394 | ### V1.5.0(2018.05.05) 395 | 396 | - 新增评论详情功能(网易云音乐最重要的部分不能漏) 397 | - 新增 `title` 提示 398 | - 新增 `noscript` 提示 399 | - 优化歌词滚动 400 | - 优化图片大小,提升加载速度 401 | - 优化歌曲切换时样式错乱 402 | - 增强模块化 403 | 404 | ### V1.4.0(2018.04.09)预期功能全部完成 405 | 406 | - 新增同步网易云歌单功能 407 | - 新增快捷键控制 408 | - 上一曲 Ctrl + Left 409 | - 播放暂停 Ctrl + Space 410 | - 下一曲 Ctrl + Right 411 | - 切换播放模式 Ctrl + O 412 | - 音量加 Ctrl + Up 413 | - 音量减 Ctrl + Down 414 | - 修复 safari 和安卓 UC 不能播放的问题 415 | - 优化 `url` 失效问题和音乐无法播放的提示 416 | - 优化移动端下的样式兼容 417 | 418 | ### V1.3.2(2018.03.19) 419 | 420 | - 新增播放链接失效后自动重载当前音乐 421 | - 优化列表循环不会自动下一曲问题 422 | - 优化删除正在播放列表歌曲失效问题 423 | - 优化删除歌曲过快会触发播放问题 424 | - 优化音乐来源错误不能播放问题,并使用 `oncanplay` 425 | - 添加播放历史,避免不能播放的音乐加入播放历史 426 | - 修复不能加入音乐到我听过的问题 427 | 428 | ### V1.3.1(2018.03.12) 429 | 430 | - 新增双击播放 431 | - 新增更新提示 432 | - 优化无歌词时的显示 433 | - 优化暂无内容提醒 434 | - 优化列表多位歌手的显示 435 | 436 | ### V1.3.0(2018.03.07) 437 | 438 | - 新增随机播放、列表循环、单曲循环、顺序播放功能 439 | - 新增清空正在播放列表功能 440 | - 新增清空列表的提示 441 | - 新增版权信息(控制台输入 mmPlayer ) 442 | - 增加背景滤镜的模糊度和透明度 443 | - 增加浏览器访问的限制(兼容主流浏览器,最好全是用 chrome,哈哈) 444 | - 整合 `music-list` 组件 445 | - `CSS` 的 `@import` 使用 `~` 代替相对路径(原理:`css-loader` 会把非根路径的 `url` 解释为相对路径,加 `~` 前缀才会解释成模块路径) 446 | - 优化 Safari 下不能滚动和不能播放的问题 447 | - 优化移动端 300ms 点击延迟 448 | - 优化当播放列表只有一首歌时,点击上(下)一曲导致播放失败的问题 449 | - 优化重复插入音乐的问题 450 | - 优化暂停后播放下一首播放状态图标不改变的问题 451 | 452 | ### V1.2.1(2018.03.01) 453 | 454 | - 优化正在播放列表第一次加载 455 | - 优化删除歌曲 456 | - 优化 `Vuex` 模块 457 | - 优化加载 loading 458 | - 优化移动端适配 459 | - 提高代码复用性 460 | 461 | ### V1.2.0(2018.02.28) 462 | 463 | - 新增搜索功能 464 | - 新增歌曲删除功能(播放历史列表) 465 | - 使用 `ES6` 的 `class` 对数据进行二次处理 466 | - 优化歌词居中显示 467 | - 优化播放可能出现的错误 468 | 469 | ### V1.1.0(2018.02.09) 470 | 471 | - 新增我听过的(播放历史) 472 | - 整合公用列表组件 473 | - 新增 `mmToast` 插件 474 | - 整合字体大小、颜色相关 `CSS` 475 | - 优化清空正在播放列表功能 476 | 477 | ### V1.0.0(2018.02.05) 478 | 479 | - 发布正式版(因为一系列原因,mmPlayer V1.0.0 版本在试用版的基础上进行了重构了,并引入了 `Vue Router` 和 `Vuex` 480 | - 当前播放歌曲高亮(感觉一个小 GIF 还不够) 481 | - 优化快速切歌导致歌曲播放失败的问题 482 | - 进度条拖动适配移动端 483 | - 优化点击时可能出现的半透明背景 484 | - 新增排行榜 485 | 486 | </details> 487 | 488 | ## 数据统计 489 | 490 | 因为百度统计现在数据存储时长默认为 1 年,造成前几年的数据都丢了(虽说没啥用,但是也是本作品成长的历史),所以在 [github](https://github.com/maomao1996/picture/tree/main/mmPlayer/stats) 保存下每年的累计访问 491 | 492 | > 2023 年累计访问 493 | 494 |  495 | 496 | > 2022 年累计访问 497 | 498 |  499 | 500 | ## 其他说明 501 | 502 | - 个人练手项目(本想先做移动端的,但是发现有很多人都做过,就稍微标新立异做个 PC 端) 503 | - 如果您喜欢该作品,您可以点右上角 "Star" "Fork" 表示支持 谢谢! 504 | - 如有问题请直接在 [Issues](https://github.com/maomao1996/Vue-mmPlayer/issues/new) 中提,或者您发现问题并有非常好的解决方案,欢迎 `PR` 505 | 506 | ## 鸣谢 507 | 508 | 特别感谢 [JetBrains](https://www.jetbrains.com/) 为开源项目提供免费的 [WebStorm](https://www.jetbrains.com/webstorm/) 授权 509 | 510 | ## License 511 | 512 | [MIT](https://github.com/maomao1996/Vue-mmPlayer/blob/LICENSE) 513 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | } 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "baseUrl": "./", 7 | "paths": { 8 | "@/*": [ 9 | "src/*" 10 | ], 11 | "api/*": [ 12 | "src/api/*" 13 | ], 14 | "assets/*": [ 15 | "src/assets/*" 16 | ], 17 | "base/*": [ 18 | "src/base/*" 19 | ], 20 | "components/*": [ 21 | "src/components/*" 22 | ], 23 | "pages/*": [ 24 | "src/pages/*" 25 | ], 26 | }, 27 | "lib": [ 28 | "esnext", 29 | "dom", 30 | "dom.iterable", 31 | "scripthost" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-mmplayer", 3 | "version": "1.8.3", 4 | "private": true, 5 | "packageManager": "pnpm@8.0.0", 6 | "description": "Online music player", 7 | "author": "maomao1996 <1714487678@qq.com>", 8 | "bugs": { 9 | "url": "https://github.com/maomao1996/Vue-mmPlayer/issues" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/maomao1996/Vue-mmPlayer" 14 | }, 15 | "license": "MIT", 16 | "scripts": { 17 | "dev": "vue-cli-service serve", 18 | "serve": "vue-cli-service serve", 19 | "build": "vue-cli-service build", 20 | "lint": "vue-cli-service lint", 21 | "prepare": "husky install" 22 | }, 23 | "dependencies": { 24 | "axios": "^1.6.2", 25 | "core-js": "^3.33.3", 26 | "fastclick": "^1.0.6", 27 | "vue": "^2.6.14", 28 | "vue-lazyload": "^1.3.4", 29 | "vue-router": "^3.5.1", 30 | "vuex": "^3.6.2" 31 | }, 32 | "devDependencies": { 33 | "@babel/core": "^7.23.5", 34 | "@babel/eslint-parser": "^7.23.3", 35 | "@femm/prettier": "^1.1.0", 36 | "@femm/verify-commit": "^1.0.1", 37 | "@vue/cli-plugin-babel": "~5.0.8", 38 | "@vue/cli-plugin-eslint": "~5.0.8", 39 | "@vue/cli-service": "~5.0.8", 40 | "dayjs": "^1.11.10", 41 | "eslint": "^7.32.0", 42 | "eslint-config-prettier": "^8.5.0", 43 | "eslint-plugin-prettier": "^4.2.1", 44 | "eslint-plugin-vue": "^8.7.1", 45 | "husky": "^8.0.3", 46 | "less": "^4.2.0", 47 | "less-loader": "^8.0.0", 48 | "prettier": "^2.8.1", 49 | "style-resources-loader": "^1.5.0", 50 | "vue-cli-plugin-style-resources-loader": "^0.1.5", 51 | "vue-template-compiler": "^2.6.14" 52 | }, 53 | "prettier": "@femm/prettier" 54 | } 55 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/public/favicon.ico -------------------------------------------------------------------------------- /public/img/warn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/public/img/warn.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="utf-8" /> 5 | <meta name="renderer" content="webkit" /> 6 | <meta name="force-rendering" content="webkit" /> 7 | <meta 8 | name="viewport" 9 | content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" 10 | /> 11 | <title>mmPlayer 在线音乐播放器</title> 12 | <meta 13 | name="keywords" 14 | content="mmPlayer,播放器,在线音乐,在线播放器,音乐播放器,在线音乐播放器,mmPlayer 在线音乐播放器" 15 | /> 16 | <meta 17 | name="description" 18 | content="mmPlayer 是由茂茂开源的一款在线音乐播放器,具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能" 19 | /> 20 | <link 21 | rel="stylesheet" 22 | href="//at.alicdn.com/t/font_1367495_eza6utwbiqn.css" 23 | /> 24 | <style type="text/css"> 25 | noscript { 26 | position: fixed; 27 | top: 0; 28 | right: 0; 29 | bottom: 0; 30 | left: 0; 31 | z-index: 1996520; 32 | background: #fff; 33 | text-align: center; 34 | font-weight: 700; 35 | font-size: 34px; 36 | line-height: 100px; 37 | } 38 | #appLoading { 39 | position: fixed; 40 | top: 0; 41 | left: 0; 42 | z-index: 1996; 43 | width: 100%; 44 | height: 100%; 45 | font-size: 20px; 46 | background: rgba(255, 255, 255, 1); 47 | } 48 | #appLoading.removeAnimate { 49 | animation: removeAnimate 0.3s 0.5s 1 both; 50 | } 51 | #appLoading .loader { 52 | position: absolute; 53 | top: 50%; 54 | left: 50%; 55 | width: 5em; 56 | height: 5em; 57 | transform: translate(-50%, -50%) rotate(165deg); 58 | } 59 | #appLoading .loader::before, 60 | #appLoading .loader::after { 61 | content: ''; 62 | position: absolute; 63 | top: 50%; 64 | left: 50%; 65 | display: block; 66 | width: 1em; 67 | height: 1em; 68 | border-radius: 0.5em; 69 | transform: translate(-50%, -50%); 70 | } 71 | #appLoading .loader::before { 72 | animation: before 2s infinite; 73 | } 74 | #appLoading .loader::after { 75 | animation: after 2s infinite; 76 | } 77 | @keyframes before { 78 | 0% { 79 | width: 1em; 80 | box-shadow: 2em -1em rgba(225, 20, 98, 0.75), 81 | -2em 1em rgba(111, 202, 220, 0.75); 82 | } 83 | 35% { 84 | width: 5em; 85 | box-shadow: 0 -1em rgba(225, 20, 98, 0.75), 86 | 0 1em rgba(111, 202, 220, 0.75); 87 | } 88 | 70% { 89 | width: 1em; 90 | box-shadow: -2em -1em rgba(225, 20, 98, 0.75), 91 | 2em 1em rgba(111, 202, 220, 0.75); 92 | } 93 | 100% { 94 | box-shadow: 2em -1em rgba(225, 20, 98, 0.75), 95 | -2em 1em rgba(111, 202, 220, 0.75); 96 | } 97 | } 98 | @keyframes after { 99 | 0% { 100 | height: 1em; 101 | box-shadow: 1em 2em rgba(61, 184, 143, 0.75), 102 | -1em -2em rgba(233, 169, 32, 0.75); 103 | } 104 | 35% { 105 | height: 5em; 106 | box-shadow: 1em 0 rgba(61, 184, 143, 0.75), 107 | -1em 0 rgba(233, 169, 32, 0.75); 108 | } 109 | 70% { 110 | height: 1em; 111 | box-shadow: 1em -2em rgba(61, 184, 143, 0.75), 112 | -1em 2em rgba(233, 169, 32, 0.75); 113 | } 114 | 100% { 115 | box-shadow: 1em 2em rgba(61, 184, 143, 0.75), 116 | -1em -2em rgba(233, 169, 32, 0.75); 117 | } 118 | } 119 | @keyframes removeAnimate { 120 | from { 121 | opacity: 1; 122 | } 123 | to { 124 | opacity: 0; 125 | } 126 | } 127 | </style> 128 | <script> 129 | ;(function () { 130 | if (!!window.ActiveXObject || 'ActiveXObject' in window) { 131 | window.location = './prompt.html' 132 | return false 133 | } 134 | })() 135 | </script> 136 | <% if ( NODE_ENV === 'production' ) { %> 137 | <script> 138 | var _hmt = _hmt || [] 139 | window._hmt = _hmt 140 | ;(function () { 141 | var hm = document.createElement('script') 142 | hm.src = 'https://hm.baidu.com/hm.js?71e62b6d09afa9deac7bfa5c60ad06dd' 143 | var s = document.getElementsByTagName('script')[0] 144 | s.parentNode.insertBefore(hm, s) 145 | })() 146 | </script> 147 | <% } %> 148 | </head> 149 | <body> 150 | <noscript> 151 | mmPlayer 在线音乐播放器<br />为了更好的体验请开启 script 152 | </noscript> 153 | <div id="appLoading"> 154 | <div class="loader"></div> 155 | </div> 156 | <div id="mmPlayer"> 157 | mmPlayer 158 | 是由茂茂开源的一款在线音乐播放器,具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能 159 | </div> 160 | <!-- built files will be auto injected --> 161 | </body> 162 | </html> 163 | -------------------------------------------------------------------------------- /public/prompt.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <meta name="robots" content="noindex,nofollow"/> 6 | <title>mmPlayer | 温馨提示</title> 7 | <style> 8 | body{font-size: 14px;font-family: 'helvetica neue',tahoma,arial,'hiragino sans gb','microsoft yahei','Simsun',sans-serif; background-color:#fff; color:#808080;} 9 | .wrap{margin:200px auto;width:510px;} 10 | td{text-align:left; padding:2px 10px;} 11 | td.header{font-size:22px; padding-bottom:10px; color:#000;} 12 | td.check-info{padding-top:20px;} 13 | a{color:#328ce5; text-decoration:none;} 14 | a:hover{text-decoration:underline;} 15 | </style> 16 | </head> 17 | <body> 18 | <div class="wrap"> 19 | <table> 20 | <tr> 21 | <td rowspan="5"><img src="./img/warn.png"></td> 22 | <td class="header">mmPlayer | 温馨提示</td> 23 | </tr> 24 | <tr><td></td></tr> 25 | <tr><td>很抱歉!为了更好的体验,本站限制以下浏览器访问:</td></tr> 26 | <tr><td>IE浏览器和使用IE内核的浏览器</td></tr> 27 | <tr><td>解决办法:下载其他主流浏览器或者切换浏览器内核为极速内核</td></tr> 28 | </table> 29 | </div> 30 | </body> 31 | </html> 32 | -------------------------------------------------------------------------------- /screenshots/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/1.jpg -------------------------------------------------------------------------------- /screenshots/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/2.jpg -------------------------------------------------------------------------------- /screenshots/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/3.jpg -------------------------------------------------------------------------------- /screenshots/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/4.jpg -------------------------------------------------------------------------------- /screenshots/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/5.jpg -------------------------------------------------------------------------------- /screenshots/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/6.jpg -------------------------------------------------------------------------------- /screenshots/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/7.jpg -------------------------------------------------------------------------------- /screenshots/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/screenshots/8.jpg -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div id="app"> 3 | <!--主体--> 4 | <mm-header /> 5 | <router-view /> 6 | <!--更新说明--> 7 | <mm-dialog ref="versionDialog" type="alert" head-text="更新提示" :body-text="versionInfo" /> 8 | <!--播放器--> 9 | <audio ref="mmPlayer"></audio> 10 | </div> 11 | </template> 12 | 13 | <script> 14 | import { mapMutations, mapActions } from 'vuex' 15 | import { getPlaylistDetail } from 'api' 16 | import { MMPLAYER_CONFIG, VERSION } from '@/config' 17 | import MmHeader from 'components/mm-header/mm-header' 18 | import MmDialog from 'base/mm-dialog/mm-dialog' 19 | import { getVersion, setVersion } from '@/utils/storage' 20 | 21 | const VERSION_INFO = `<div class="mm-dialog-text text-left"> 22 | 版本号:${VERSION}(${process.env.VUE_APP_UPDATE_TIME})<br/> 23 | 1、 采用新版图标<br> 24 | 2、 修复音乐搜索<br> 25 | 3、 优化滚动条样式 26 | </div>` 27 | 28 | export default { 29 | name: 'App', 30 | components: { 31 | MmHeader, 32 | MmDialog, 33 | }, 34 | created() { 35 | // 设置版本更新信息 36 | this.versionInfo = VERSION_INFO 37 | 38 | // 获取正在播放列表 39 | getPlaylistDetail(MMPLAYER_CONFIG.PLAYLIST_ID).then((playlist) => { 40 | const list = playlist.tracks.slice(0, 100) 41 | this.setPlaylist({ list }) 42 | }) 43 | 44 | // 设置title 45 | let OriginTitile = document.title 46 | let titleTime 47 | document.addEventListener('visibilitychange', function () { 48 | if (document.hidden) { 49 | document.title = '死鬼去哪里了!' 50 | clearTimeout(titleTime) 51 | } else { 52 | document.title = '(つェ⊂)咦!又好了!' 53 | titleTime = setTimeout(function () { 54 | document.title = OriginTitile 55 | }, 2000) 56 | } 57 | }) 58 | 59 | // 设置audio元素 60 | this.$nextTick(() => { 61 | this.setAudioele(this.$refs.mmPlayer) 62 | }) 63 | 64 | // 首次加载完成后移除动画 65 | let loadDOM = document.querySelector('#appLoading') 66 | if (loadDOM) { 67 | const animationendFunc = function () { 68 | loadDOM.removeEventListener('animationend', animationendFunc) 69 | loadDOM.removeEventListener('webkitAnimationEnd', animationendFunc) 70 | document.body.removeChild(loadDOM) 71 | loadDOM = null 72 | const version = getVersion() 73 | if (version !== null) { 74 | setVersion(VERSION) 75 | if (version !== VERSION) { 76 | this.$refs.versionDialog.show() 77 | } 78 | } else { 79 | setVersion(VERSION) 80 | this.$refs.versionDialog.show() 81 | } 82 | }.bind(this) 83 | loadDOM.addEventListener('animationend', animationendFunc) 84 | loadDOM.addEventListener('webkitAnimationEnd', animationendFunc) 85 | loadDOM.classList.add('removeAnimate') 86 | } 87 | }, 88 | methods: { 89 | ...mapMutations({ 90 | setAudioele: 'SET_AUDIOELE', 91 | }), 92 | ...mapActions(['setPlaylist']), 93 | }, 94 | } 95 | </script> 96 | 97 | <style lang="less"> 98 | #app { 99 | position: relative; 100 | width: 100%; 101 | height: 100%; 102 | color: @text_color; 103 | font-size: @font_size_medium; 104 | 105 | audio { 106 | position: fixed; 107 | } 108 | } 109 | </style> 110 | -------------------------------------------------------------------------------- /src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from '@/utils/axios' 2 | import { DEFAULT_LIMIT } from '@/config' 3 | import { formatSongs } from '@/utils/song' 4 | 5 | // 排行榜列表 6 | export function getToplistDetail() { 7 | return axios.get('/toplist/detail') 8 | } 9 | 10 | // 推荐歌单 11 | export function getPersonalized() { 12 | return axios.get('/personalized') 13 | } 14 | 15 | // 歌单详情 16 | export function getPlaylistDetail(id) { 17 | return new Promise((resolve, reject) => { 18 | axios 19 | .get('/playlist/detail', { 20 | params: { id }, 21 | }) 22 | .then(({ playlist }) => playlist || {}) 23 | .then((playlist) => { 24 | const { trackIds, tracks } = playlist 25 | if (!Array.isArray(trackIds)) { 26 | reject(new Error('获取歌单详情失败')) 27 | return 28 | } 29 | // 过滤完整歌单 如排行榜 30 | if (tracks.length === trackIds.length) { 31 | playlist.tracks = formatSongs(playlist.tracks) 32 | resolve(playlist) 33 | return 34 | } 35 | // 限制歌单详情最大 500 36 | const ids = trackIds 37 | .slice(0, 500) 38 | .map((v) => v.id) 39 | .toString() 40 | getMusicDetail(ids).then(({ songs }) => { 41 | playlist.tracks = formatSongs(songs) 42 | resolve(playlist) 43 | }) 44 | }) 45 | }) 46 | } 47 | 48 | // 搜索 49 | export function search(keywords, page = 0, limit = DEFAULT_LIMIT) { 50 | return axios.get('/search', { 51 | params: { 52 | offset: page * limit, 53 | limit: limit, 54 | keywords, 55 | }, 56 | }) 57 | } 58 | 59 | // 热搜 60 | export function searchHot() { 61 | return axios.get('/search/hot') 62 | } 63 | 64 | // 获取用户歌单详情 65 | export function getUserPlaylist(uid) { 66 | return axios.get('/user/playlist', { 67 | params: { 68 | uid, 69 | }, 70 | }) 71 | } 72 | 73 | // 获取歌曲详情 74 | export function getMusicDetail(ids) { 75 | return axios.get('/song/detail', { 76 | params: { 77 | ids, 78 | }, 79 | }) 80 | } 81 | 82 | // 获取音乐是否可以用 83 | export function getCheckMusic(id) { 84 | return axios.get('/check/music', { 85 | params: { 86 | id, 87 | }, 88 | }) 89 | } 90 | 91 | // 获取音乐地址 92 | export function getMusicUrl(id) { 93 | return axios.get('/song/url', { 94 | params: { 95 | id, 96 | }, 97 | }) 98 | } 99 | 100 | // 获取歌词 101 | export function getLyric(id) { 102 | const url = '/lyric' 103 | return axios.get(url, { 104 | params: { 105 | id, 106 | }, 107 | }) 108 | } 109 | 110 | // 获取音乐评论 111 | export function getComment(id, page, limit = DEFAULT_LIMIT) { 112 | return axios.get('/comment/music', { 113 | params: { 114 | offset: page * limit, 115 | limit: limit, 116 | id, 117 | }, 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /src/assets/background/bg-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/background/bg-1.jpg -------------------------------------------------------------------------------- /src/assets/background/bg-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/background/bg-2.jpg -------------------------------------------------------------------------------- /src/assets/background/bg-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/background/bg-3.jpg -------------------------------------------------------------------------------- /src/assets/img/album_cover_player.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/img/album_cover_player.png -------------------------------------------------------------------------------- /src/assets/img/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/img/default.png -------------------------------------------------------------------------------- /src/assets/img/player_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/img/player_cover.png -------------------------------------------------------------------------------- /src/assets/img/wave.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maomao1996/Vue-mmPlayer/ccba70781bef07fae53f697bca898ec429d326db/src/assets/img/wave.gif -------------------------------------------------------------------------------- /src/base/mm-dialog/mm-dialog.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--对话框--> 3 | <transition name="mm-dialog-fade"> 4 | <div v-show="dialogShow" class="mm-dialog-box"> 5 | <div class="mm-dialog-wrapper"> 6 | <div class="mm-dialog-content"> 7 | <div class="mm-dialog-head" v-text="headText"></div> 8 | <slot> 9 | <!-- eslint-disable-next-line vue/no-v-html --> 10 | <div class="mm-dialog-text" v-html="bodyText"></div> 11 | </slot> 12 | <div class="mm-dialog-btns"> 13 | <div 14 | v-if="dialogType !== 'alert'" 15 | class="mm-btn-cancel" 16 | @click="cancel" 17 | v-text="cancelBtnText" 18 | ></div> 19 | <slot name="btn"></slot> 20 | <div class="mm-btn-confirm" @click="confirm" v-text="confirmBtnText"></div> 21 | </div> 22 | </div> 23 | </div> 24 | </div> 25 | </transition> 26 | </template> 27 | 28 | <script> 29 | export default { 30 | name: 'MmDialog', 31 | props: { 32 | // type:confirm、alert 33 | type: { 34 | type: String, 35 | default: 'confirm', 36 | }, 37 | // 标题文本 38 | headText: { 39 | type: String, 40 | default: '提示', 41 | }, 42 | // 内容文本(可以是html) 43 | bodyText: { 44 | type: String, 45 | default: '', 46 | }, 47 | // 取消按钮文本 48 | cancelBtnText: { 49 | type: String, 50 | default: '取消', 51 | }, 52 | // 确定按钮文本 53 | confirmBtnText: { 54 | type: String, 55 | default: '确定', 56 | }, 57 | // Dialog 是否插入至 body 元素下 58 | appendToBody: { 59 | type: Boolean, 60 | default: true, 61 | }, 62 | }, 63 | data() { 64 | return { 65 | dialogShow: false, // 是否显示对话框 66 | } 67 | }, 68 | computed: { 69 | dialogType() { 70 | return this.type.toLowerCase() 71 | }, 72 | }, 73 | watch: { 74 | dialogShow(val) { 75 | if (val && this.appendToBody) { 76 | document.body.appendChild(this.$el) 77 | } 78 | }, 79 | }, 80 | mounted() { 81 | if (this.dialogShow && this.appendToBody) { 82 | document.body.appendChild(this.$el) 83 | } 84 | }, 85 | destroyed() { 86 | if (this.appendToBody && this.$el && this.$el.parentNode) { 87 | this.$el.parentNode.removeChild(this.$el) 88 | } 89 | }, 90 | methods: { 91 | // 显示事件 92 | show() { 93 | this.dialogShow = true 94 | }, 95 | // 隐藏事件 96 | hide() { 97 | this.dialogShow = false 98 | }, 99 | // 取消事件 100 | cancel() { 101 | this.hide() 102 | this.$emit('cancel') 103 | }, 104 | // 确定事件 105 | confirm() { 106 | this.hide() 107 | this.$emit('confirm') 108 | }, 109 | }, 110 | } 111 | </script> 112 | 113 | <style lang="less"> 114 | @dialog-prefix-cls: mm-dialog; 115 | 116 | .@{dialog-prefix-cls}-box { 117 | position: fixed; 118 | left: 0; 119 | right: 0; 120 | top: 0; 121 | bottom: 0; 122 | z-index: 1996; 123 | background-color: @dialog_bg_color; 124 | user-select: none; 125 | backdrop-filter: @backdrop_filter; 126 | &.@{dialog-prefix-cls}-fade-enter-active { 127 | animation: mm-dialog-fadein 0.3s; 128 | .@{dialog-prefix-cls}-content { 129 | animation: mm-dialog-zoom 0.3s; 130 | } 131 | } 132 | .@{dialog-prefix-cls}-wrapper { 133 | position: absolute; 134 | top: 50%; 135 | left: 50%; 136 | transform: translate(-50%, -50%); 137 | z-index: 1996; 138 | .@{dialog-prefix-cls}-content { 139 | width: 420px; 140 | border-radius: @dialog_border_radius; 141 | background: @dialog_content_bg_color; 142 | @media (max-width: 767px) { 143 | width: 270px; 144 | border-radius: @dialog_mobile_border_radius; 145 | text-align: center; 146 | } 147 | .@{dialog-prefix-cls}-head { 148 | padding: 15px; 149 | padding-bottom: 0; 150 | font-size: @font_size_large; 151 | color: @text_color_active; 152 | } 153 | .@{dialog-prefix-cls}-text { 154 | padding: 20px 15px; 155 | line-height: 22px; 156 | font-size: @font_size_medium; 157 | color: @dialog_text_color; 158 | } 159 | .@{dialog-prefix-cls}-btns { 160 | display: flex; 161 | align-items: center; 162 | padding: 0 15px 10px; 163 | text-align: center; 164 | color: @dialog_text_color; 165 | @media (min-width: 768px) { 166 | justify-content: flex-end; 167 | div { 168 | display: block; 169 | padding: 8px 15px; 170 | border-radius: @dialog_btn_mobile_border_radius; 171 | border: 1px solid @btn_color; 172 | font-size: @font_size_medium; 173 | cursor: pointer; 174 | &:not(:nth-of-type(1)) { 175 | margin-left: 10px; 176 | } 177 | &:hover { 178 | color: @text_color_active; 179 | border: 1px solid @btn_color_active; 180 | } 181 | } 182 | } 183 | @media (max-width: 767px) { 184 | & { 185 | padding: 0; 186 | justify-content: center; 187 | div { 188 | flex: 1; 189 | line-height: 22px; 190 | padding: 10px 0; 191 | border-top: 1px solid @dialog_line_color; 192 | font-size: @font_size_large; 193 | &:not(:nth-of-type(1)) { 194 | border-left: 1px solid @dialog_line_color; 195 | } 196 | } 197 | } 198 | } 199 | } 200 | } 201 | } 202 | } 203 | 204 | @keyframes mm-dialog-fadein { 205 | 0% { 206 | opacity: 0; 207 | } 208 | 100% { 209 | opacity: 1; 210 | } 211 | } 212 | 213 | @keyframes mm-dialog-zoom { 214 | 0% { 215 | transform: scale(0); 216 | } 217 | 50% { 218 | transform: scale(1.1); 219 | } 220 | 100% { 221 | transform: scale(1); 222 | } 223 | } 224 | </style> 225 | -------------------------------------------------------------------------------- /src/base/mm-icon/mm-icon.vue: -------------------------------------------------------------------------------- 1 | <!-- icon 组件 --> 2 | <script> 3 | export default { 4 | name: 'MmIcon', 5 | props: { 6 | type: { 7 | type: String, 8 | required: true, 9 | }, 10 | size: { 11 | type: Number, 12 | default: 16, 13 | }, 14 | }, 15 | methods: { 16 | getIconCls() { 17 | return `icon-${this.type}` 18 | }, 19 | getIconStyle() { 20 | return { fontSize: this.size + 'px' } 21 | }, 22 | onClick(e) { 23 | this.$emit('click', e) 24 | }, 25 | }, 26 | render() { 27 | const Icon = ( 28 | <i 29 | onClick={this.onClick} 30 | class={`iconfont ${this.getIconCls()}`} 31 | style={this.getIconStyle()} 32 | /> 33 | ) 34 | return Icon 35 | }, 36 | } 37 | </script> 38 | 39 | <style lang="less"> 40 | .iconfont { 41 | display: inline-block; 42 | font-style: normal; 43 | font-weight: normal; 44 | font-variant: normal; 45 | line-height: 1; 46 | vertical-align: baseline; 47 | text-transform: none; 48 | speak: none; 49 | /* Better Font Rendering =========== */ 50 | -webkit-font-smoothing: antialiased; 51 | -moz-osx-font-smoothing: grayscale; 52 | } 53 | </style> 54 | -------------------------------------------------------------------------------- /src/base/mm-loading/mm-loading.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--加载动画--> 3 | <div v-show="value" class="mm-loading" :style="{ backgroundColor: loadingBgColor }"> 4 | <div class="mm-loading-content"> 5 | <svg class="circular" viewBox="25 25 50 50"> 6 | <circle class="path" cx="50" cy="50" r="20" fill="none"></circle> 7 | </svg> 8 | </div> 9 | </div> 10 | </template> 11 | 12 | <script> 13 | export default { 14 | name: 'MmLoading', 15 | props: { 16 | // 是否显示 17 | value: { 18 | type: Boolean, 19 | default: true, 20 | }, 21 | // 加载动画背景颜色 22 | loadingBgColor: { 23 | type: String, 24 | default: '', 25 | }, 26 | }, 27 | } 28 | </script> 29 | 30 | <style lang="less"> 31 | .mm-loading { 32 | position: absolute; 33 | top: 0; 34 | left: 0; 35 | right: 0; 36 | bottom: 0; 37 | z-index: 1996; 38 | background: @load_bg_color; 39 | .mm-loading-content { 40 | position: absolute; 41 | top: 50%; 42 | width: 100%; 43 | transform: translateY(-50%); 44 | text-align: center; 45 | .circular { 46 | height: 50px; 47 | width: 50px; 48 | animation: loading-rotate 2s linear infinite; 49 | .path { 50 | animation: loading-dash 1.5s ease-in-out infinite; 51 | stroke-dasharray: 90, 150; 52 | stroke-dashoffset: 0; 53 | stroke-width: 2; 54 | stroke: @text_color; 55 | stroke-linecap: round; 56 | } 57 | } 58 | } 59 | } 60 | 61 | //动画函数 62 | @keyframes loading-rotate { 63 | 100% { 64 | transform: rotate(360deg); 65 | } 66 | } 67 | 68 | @keyframes loading-dash { 69 | 0% { 70 | stroke-dasharray: 1, 200; 71 | stroke-dashoffset: 0; 72 | } 73 | 50% { 74 | stroke-dasharray: 90, 150; 75 | stroke-dashoffset: -40px; 76 | } 77 | 100% { 78 | stroke-dasharray: 90, 150; 79 | stroke-dashoffset: -120px; 80 | } 81 | } 82 | </style> 83 | -------------------------------------------------------------------------------- /src/base/mm-no-result/mm-no-result.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--暂无数据提示--> 3 | <div class="mm-no-result"> 4 | <p class="mm-no-result-text">{{ title }}</p> 5 | </div> 6 | </template> 7 | 8 | <script> 9 | export default { 10 | name: 'MmNoResult', 11 | props: { 12 | // 无数据提示文本 13 | title: { 14 | type: String, 15 | default: '', 16 | }, 17 | }, 18 | } 19 | </script> 20 | 21 | <style lang="less"> 22 | .mm-no-result { 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | width: 100%; 27 | height: 100%; 28 | &-text { 29 | margin-top: 30px; 30 | font-size: @font_size_medium; 31 | color: @text_color; 32 | } 33 | } 34 | </style> 35 | -------------------------------------------------------------------------------- /src/base/mm-progress/mm-progress.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--进度条拖动--> 3 | <div ref="mmProgress" class="mmProgress" @click="barClick"> 4 | <div class="mmProgress-bar"></div> 5 | <div ref="mmPercentProgress" class="mmProgress-outer"></div> 6 | <div ref="mmProgressInner" class="mmProgress-inner"> 7 | <div class="mmProgress-dot" @mousedown="barDown" @touchstart.prevent="barDown"></div> 8 | </div> 9 | </div> 10 | </template> 11 | 12 | <script> 13 | const dotWidth = 10 14 | export default { 15 | name: 'MmProgress', 16 | props: { 17 | // 进度值一 18 | percent: { 19 | type: [Number], 20 | default: 0, 21 | }, 22 | // 进度值二(歌曲缓冲进度用) 23 | percentProgress: { 24 | type: [Number], 25 | default: 0, 26 | }, 27 | }, 28 | data() { 29 | return { 30 | move: { 31 | status: false, // 是否可拖动 32 | startX: 0, // 记录最开始点击的X坐标 33 | left: 0, // 记录当前已经移动的距离 34 | }, 35 | } 36 | }, 37 | watch: { 38 | percent(newPercent) { 39 | if (newPercent >= 0 && !this.move.status) { 40 | const barWidth = this.$refs.mmProgress.clientWidth - dotWidth 41 | const offsetWidth = newPercent * barWidth 42 | this.moveSilde(offsetWidth) 43 | } 44 | }, 45 | percentProgress(newValue) { 46 | let offsetWidth = this.$refs.mmProgress.clientWidth * newValue 47 | this.$refs.mmPercentProgress.style.width = `${offsetWidth}px` 48 | }, 49 | }, 50 | mounted() { 51 | this.$nextTick(() => { 52 | this.bindEvents() 53 | const barWidth = this.$refs.mmProgress.clientWidth - dotWidth 54 | const offsetWidth = this.percent * barWidth 55 | this.moveSilde(offsetWidth) 56 | }) 57 | }, 58 | beforeDestroy() { 59 | this.unbindEvents() 60 | }, 61 | methods: { 62 | // 添加绑定事件 63 | bindEvents() { 64 | document.addEventListener('mousemove', this.barMove) 65 | document.addEventListener('mouseup', this.barUp) 66 | 67 | document.addEventListener('touchmove', this.barMove) 68 | document.addEventListener('touchend', this.barUp) 69 | }, 70 | // 移除绑定事件 71 | unbindEvents() { 72 | document.removeEventListener('mousemove', this.barMove) 73 | document.removeEventListener('mouseup', this.barUp) 74 | 75 | document.removeEventListener('touchmove', this.barMove) 76 | document.removeEventListener('touchend', this.barUp) 77 | }, 78 | // 点击事件 79 | barClick(e) { 80 | let rect = this.$refs.mmProgress.getBoundingClientRect() 81 | let offsetWidth = Math.min( 82 | this.$refs.mmProgress.clientWidth - dotWidth, 83 | Math.max(0, e.clientX - rect.left), 84 | ) 85 | this.moveSilde(offsetWidth) 86 | this.commitPercent(true) 87 | }, 88 | // 鼠标按下事件 89 | barDown(e) { 90 | this.move.status = true 91 | this.move.startX = e.clientX || e.touches[0].pageX 92 | this.move.left = this.$refs.mmProgressInner.clientWidth 93 | }, 94 | // 鼠标/触摸移动事件 95 | barMove(e) { 96 | if (!this.move.status) { 97 | return false 98 | } 99 | e.preventDefault() 100 | let endX = e.clientX || e.touches[0].pageX 101 | let dist = endX - this.move.startX 102 | let offsetWidth = Math.min( 103 | this.$refs.mmProgress.clientWidth - dotWidth, 104 | Math.max(0, this.move.left + dist), 105 | ) 106 | this.moveSilde(offsetWidth) 107 | this.commitPercent() 108 | }, 109 | // 鼠标/触摸释放事件 110 | barUp(e) { 111 | if (this.move.status) { 112 | this.commitPercent(true) 113 | this.move.status = false 114 | } 115 | }, 116 | // 移动滑块 117 | moveSilde(offsetWidth) { 118 | this.$refs.mmProgressInner.style.width = `${offsetWidth}px` 119 | }, 120 | // 修改 percent 121 | commitPercent(isEnd = false) { 122 | const { mmProgress, mmProgressInner } = this.$refs 123 | const lineWidth = mmProgress.clientWidth - dotWidth 124 | const percent = mmProgressInner.clientWidth / lineWidth 125 | this.$emit(isEnd ? 'percentChangeEnd' : 'percentChange', percent) 126 | }, 127 | }, 128 | } 129 | </script> 130 | 131 | <style lang="less"> 132 | .mmProgress { 133 | position: relative; 134 | padding: 5px; 135 | user-select: none; 136 | cursor: pointer; 137 | overflow: hidden; 138 | .mmProgress-bar { 139 | height: 2px; 140 | width: 100%; 141 | background: @bar_color; 142 | } 143 | .mmProgress-outer { 144 | position: absolute; 145 | top: 50%; 146 | left: 5px; 147 | display: inline-block; 148 | width: 0; 149 | height: 2px; 150 | margin-top: -1px; 151 | background: rgba(255, 255, 255, 0.2); 152 | } 153 | .mmProgress-inner { 154 | position: absolute; 155 | top: 50%; 156 | left: 5px; 157 | display: inline-block; 158 | width: 0; 159 | height: 2px; 160 | margin-top: -1px; 161 | background: @line_color; 162 | .mmProgress-dot { 163 | position: absolute; 164 | top: 50%; 165 | right: -5px; 166 | width: 10px; 167 | height: 10px; 168 | border-radius: 50%; 169 | background-color: @dot_color; 170 | transform: translateY(-50%); 171 | } 172 | } 173 | } 174 | </style> 175 | -------------------------------------------------------------------------------- /src/base/mm-toast/index.js: -------------------------------------------------------------------------------- 1 | import TempToast from './mm-toast.vue' 2 | 3 | let instance 4 | let showToast = false 5 | let time // 存储toast显示状态 6 | const mmToast = { 7 | install(Vue, options = {}) { 8 | let opt = TempToast.data() // 获取组件中的默认配置 9 | Object.assign(opt, options) // 合并配置 10 | Vue.prototype.$mmToast = (message, position) => { 11 | if (showToast) { 12 | clearTimeout(time) 13 | instance.vm.visible = showToast = false 14 | document.body.removeChild(instance.vm.$el) 15 | // return;// 如果toast还在,则不再执行 16 | } 17 | if (message) { 18 | opt.message = message // 如果有传message,则使用所传的message 19 | } 20 | if (position) { 21 | opt.position = position // 如果有传type,则使用所传的type 22 | } 23 | let TempToastConstructor = Vue.extend(TempToast) 24 | instance = new TempToastConstructor({ 25 | data: opt, 26 | }) 27 | instance.vm = instance.$mount() 28 | document.body.appendChild(instance.vm.$el) 29 | instance.vm.visible = showToast = true 30 | 31 | time = setTimeout(function () { 32 | instance.vm.visible = showToast = false 33 | document.body.removeChild(instance.vm.$el) 34 | }, opt.duration) 35 | } 36 | }, 37 | } 38 | 39 | export default mmToast 40 | -------------------------------------------------------------------------------- /src/base/mm-toast/mm-toast.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--弹出层提示--> 3 | <transition name="toast-fade"> 4 | <div v-show="visible" class="mm-toast" :class="positionClasss"> 5 | {{ message }} 6 | </div> 7 | </transition> 8 | </template> 9 | 10 | <script> 11 | export default { 12 | name: 'MmToast', 13 | data() { 14 | return { 15 | position: 'center', // 默认显示位置 16 | message: '', // 默认显示文本 17 | duration: 1500, // 显示时间, 毫秒 18 | visible: false, // 是否显示 19 | } 20 | }, 21 | computed: { 22 | positionClasss() { 23 | return 'mm-toast-' + this.position 24 | }, 25 | }, 26 | } 27 | </script> 28 | 29 | <style lang="less"> 30 | @prefix-cls: mm-toast; 31 | 32 | .@{prefix-cls} { 33 | position: fixed; 34 | left: 50%; 35 | z-index: 1996; 36 | max-width: 80%; 37 | box-sizing: border-box; 38 | border-radius: @border_radius; 39 | padding: 10px 20px; 40 | overflow: hidden; 41 | text-align: center; 42 | min-height: 40px; 43 | line-height: 20px; 44 | font-size: 14px; 45 | color: #fff; 46 | background: rgba(0, 0, 0, 0.5); 47 | user-select: none; 48 | transform: translateX(-50%); 49 | &&-top { 50 | top: 10%; 51 | } 52 | &&-center { 53 | top: 50%; 54 | margin-top: -20px; 55 | } 56 | &&-bottom { 57 | bottom: 10%; 58 | } 59 | } 60 | 61 | .toast-fade-enter { 62 | opacity: 0; 63 | transform: translate3d(-50%, -10px, 0); 64 | } 65 | 66 | .toast-fade-enter-active { 67 | will-change: transform; 68 | transition: all 0.2s; 69 | } 70 | 71 | .toast-fade-enter-to { 72 | opacity: 1; 73 | transform: translate3d(-50%, 0, 0); 74 | } 75 | </style> 76 | -------------------------------------------------------------------------------- /src/components/lyric/lyric.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div> 3 | <!--封面--> 4 | <dl class="music-info"> 5 | <dt> 6 | <img :src="musicPicUrl" /> 7 | </dt> 8 | <template v-if="currentMusic.id"> 9 | <dd>歌曲名:{{ currentMusic.name }}</dd> 10 | <dd>歌手名:{{ currentMusic.singer }}</dd> 11 | <dd>专辑名:{{ currentMusic.album }}</dd> 12 | </template> 13 | <template v-else> 14 | <dd>mmPlayer在线音乐播放器</dd> 15 | <dd> 16 | <a class="hover" target="_blank" href="https://github.com/maomao1996"> 17 | <mm-icon type="github" :size="14" /> 18 | 茂茂 19 | </a> 20 | </dd> 21 | </template> 22 | </dl> 23 | <!--歌词--> 24 | <div ref="musicLyric" class="music-lyric"> 25 | <div class="music-lyric-items" :style="lyricTop"> 26 | <p v-if="!currentMusic.id">还没有播放音乐哦!</p> 27 | <p v-else-if="nolyric">暂无歌词!</p> 28 | <template v-else-if="lyric.length > 0"> 29 | <p v-for="(item, index) in lyric" :key="index" :class="{ on: lyricIndex === index }"> 30 | {{ item.text }} 31 | </p> 32 | </template> 33 | <p v-else>歌词加载失败!</p> 34 | </div> 35 | </div> 36 | </div> 37 | </template> 38 | 39 | <script> 40 | import { mapGetters } from 'vuex' 41 | 42 | export default { 43 | name: 'Lyric', 44 | props: { 45 | // 歌词数据 46 | lyric: { 47 | type: Array, 48 | default: () => [], 49 | }, 50 | // 是否无歌词 51 | nolyric: { 52 | type: Boolean, 53 | default: false, 54 | }, 55 | // 当前歌词下标 56 | lyricIndex: { 57 | type: Number, 58 | default: 0, 59 | }, 60 | }, 61 | data() { 62 | return { 63 | top: 0, // 歌词居中 64 | } 65 | }, 66 | computed: { 67 | musicPicUrl() { 68 | return this.currentMusic.id 69 | ? `${this.currentMusic.image}?param=300y300` 70 | : require('../../assets/img/player_cover.png') 71 | }, 72 | lyricTop() { 73 | return `transform :translate3d(0, ${-34 * (this.lyricIndex - this.top)}px, 0)` 74 | }, 75 | ...mapGetters(['currentMusic']), 76 | }, 77 | mounted() { 78 | window.addEventListener('resize', () => { 79 | clearTimeout(this.resizeTimer) 80 | this.resizeTimer = setTimeout(() => this.clacTop(), 60) 81 | }) 82 | this.$nextTick(() => this.clacTop()) 83 | }, 84 | methods: { 85 | // 计算歌词居中的 top值 86 | clacTop() { 87 | const dom = this.$refs.musicLyric 88 | const { display = '' } = window.getComputedStyle(dom) 89 | if (display === 'none') { 90 | return 91 | } 92 | const height = dom.offsetHeight 93 | this.top = Math.floor(height / 34 / 2) 94 | }, 95 | }, 96 | } 97 | </script> 98 | 99 | <style lang="less" scoped> 100 | .music-info { 101 | padding-bottom: 20px; 102 | text-align: center; 103 | font-size: @font_size_medium; 104 | dt { 105 | position: relative; 106 | width: 186px; 107 | height: 186px; 108 | margin: 0 auto 15px; 109 | &:after { 110 | content: ''; 111 | position: absolute; 112 | left: 9px; 113 | top: 0; 114 | width: 201px; 115 | height: 180px; 116 | background: url('~assets/img/album_cover_player.png') 0 0 no-repeat; 117 | } 118 | img { 119 | vertical-align: middle; 120 | width: 186px; 121 | height: 186px; 122 | } 123 | } 124 | dd { 125 | height: 30px; 126 | line-height: 30px; 127 | .no-wrap(); 128 | } 129 | } 130 | 131 | /*歌词部分*/ 132 | .music-lyric { 133 | position: absolute; 134 | top: 315px; 135 | right: 0; 136 | bottom: 0; 137 | left: 0; 138 | overflow: hidden; 139 | text-align: center; 140 | mask-image: linear-gradient( 141 | to bottom, 142 | rgba(255, 255, 255, 0) 0, 143 | rgba(255, 255, 255, 0.6) 15%, 144 | rgba(255, 255, 255, 1) 25%, 145 | rgba(255, 255, 255, 1) 75%, 146 | rgba(255, 255, 255, 0.6) 85%, 147 | rgba(255, 255, 255, 0) 100% 148 | ); 149 | .music-lyric-items { 150 | text-align: center; 151 | line-height: 34px; 152 | font-size: @font_size_small; 153 | transform: translate3d(0, 0, 0); 154 | transition: transform 0.6s ease-out; 155 | .no-wrap(); 156 | .on { 157 | color: @lyric_color_active; 158 | } 159 | } 160 | } 161 | 162 | // 当屏幕小于 960 时 163 | @media (max-width: 960px) { 164 | .music-info { 165 | display: none; 166 | } 167 | .music-lyric { 168 | top: 0; 169 | } 170 | } 171 | </style> 172 | -------------------------------------------------------------------------------- /src/components/mm-header/mm-header.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--头部--> 3 | <header class="mm-header"> 4 | <h1 class="header"> 5 | <a href="https://github.com/maomao1996/Vue-mmPlayer" target="_blank"> 6 | mmPlayer 在线音乐播放器 7 | </a> 8 | <img 9 | v-if="visitorBadge" 10 | :src="visitorBadge" 11 | alt="累计访问数" 12 | class="visitor" 13 | onerror="this.style.display='none'" 14 | /> 15 | </h1> 16 | <dl class="user"> 17 | <template v-if="user.userId"> 18 | <router-link class="user-info" to="/music/userlist" tag="dt"> 19 | <img class="avatar" :src="`${user.avatarUrl}?param=50y50`" /> 20 | <span>{{ user.nickname }}</span> 21 | </router-link> 22 | <dd class="user-btn" @click="openDialog(2)">退出</dd> 23 | </template> 24 | <dd v-else class="user-btn" @click="openDialog(0)">登录</dd> 25 | </dl> 26 | <!--登录--> 27 | <mm-dialog 28 | ref="loginDialog" 29 | head-text="登录" 30 | confirm-btn-text="登录" 31 | cancel-btn-text="关闭" 32 | @confirm="login" 33 | > 34 | <div class="mm-dialog-text"> 35 | <input 36 | v-model.trim="uidValue" 37 | class="mm-dialog-input" 38 | type="number" 39 | autofocus 40 | placeholder="请输入您的网易云 UID" 41 | @keyup.enter="login" 42 | /> 43 | </div> 44 | <div slot="btn" @click="openDialog(1)">帮助</div> 45 | </mm-dialog> 46 | <!--帮助--> 47 | <mm-dialog 48 | ref="helpDialog" 49 | head-text="登录帮助" 50 | confirm-btn-text="去登录" 51 | cancel-btn-text="关闭" 52 | @confirm="openDialog(0)" 53 | > 54 | <div class="mm-dialog-text"> 55 | <p> 56 | 1、 57 | <a target="_blank" href="https://music.163.com">点我(https://music.163.com)</a> 58 | 打开网易云音乐官网 59 | </p> 60 | <p>2、点击页面右上角的“登录”</p> 61 | <p>3、点击您的头像,进入我的主页</p> 62 | <p>4、复制浏览器地址栏 /user/home?id= 后面的数字(网易云 UID)</p> 63 | </div> 64 | </mm-dialog> 65 | <!--退出--> 66 | <mm-dialog ref="outDialog" body-text="确定退出当前用户吗?" @confirm="out" /> 67 | </header> 68 | </template> 69 | 70 | <script> 71 | import { getUserPlaylist } from 'api' 72 | import { mapGetters, mapActions } from 'vuex' 73 | import MmDialog from 'base/mm-dialog/mm-dialog' 74 | import { toHttps } from '@/utils/util' 75 | import { VISITOR_BADGE_ID } from '@/config' 76 | 77 | export default { 78 | name: 'MmHeader', 79 | components: { 80 | MmDialog, 81 | }, 82 | data() { 83 | return { 84 | user: {}, // 用户数据 85 | uidValue: '', // 记录用户 UID 86 | } 87 | }, 88 | computed: { 89 | visitorBadge() { 90 | if (VISITOR_BADGE_ID) { 91 | return `https://visitor-badge.laobi.icu/badge?left_color=transparent&right_color=transparent&page_id=${VISITOR_BADGE_ID}` 92 | } 93 | return '' 94 | }, 95 | ...mapGetters(['uid']), 96 | }, 97 | created() { 98 | this.uid && this._getUserPlaylist(this.uid) 99 | }, 100 | methods: { 101 | // 打开对话框 102 | openDialog(key) { 103 | switch (key) { 104 | case 0: 105 | this.$refs.loginDialog.show() 106 | break 107 | case 1: 108 | this.$refs.loginDialog.hide() 109 | this.$refs.helpDialog.show() 110 | break 111 | case 2: 112 | this.$refs.outDialog.show() 113 | break 114 | case 3: 115 | this.$refs.loginDialog.hide() 116 | break 117 | } 118 | }, 119 | // 退出登录 120 | out() { 121 | this.user = {} 122 | this.setUid(null) 123 | this.$mmToast('退出成功!') 124 | }, 125 | // 登录 126 | login() { 127 | if (this.uidValue === '') { 128 | this.$mmToast('UID 不能为空') 129 | this.openDialog(0) 130 | return 131 | } 132 | this.openDialog(3) 133 | this._getUserPlaylist(this.uidValue) 134 | }, 135 | // 获取用户数据 136 | _getUserPlaylist(uid) { 137 | getUserPlaylist(uid).then(({ playlist = [] }) => { 138 | this.uidValue = '' 139 | if (playlist.length === 0 || !playlist[0].creator) { 140 | this.$mmToast(`未查询找 UID 为 ${uid} 的用户信息`) 141 | return 142 | } 143 | const creator = playlist[0].creator 144 | this.setUid(uid) 145 | creator.avatarUrl = toHttps(creator.avatarUrl) 146 | this.user = creator 147 | setTimeout(() => { 148 | this.$mmToast(`${this.user.nickname} 欢迎使用 mmPlayer`) 149 | }, 200) 150 | }) 151 | }, 152 | ...mapActions(['setUid']), 153 | }, 154 | } 155 | </script> 156 | 157 | <style lang="less"> 158 | .mm-header { 159 | position: absolute; 160 | top: 0; 161 | left: 0; 162 | width: 100%; 163 | height: 60px; 164 | @media (max-width: 768px) { 165 | background: @header_bg_color; 166 | } 167 | .header { 168 | .flex-center; 169 | line-height: 60px; 170 | color: @text_color_active; 171 | font-size: @font_size_large; 172 | @media (max-width: 768px) { 173 | padding-left: 15px; 174 | justify-content: flex-start; 175 | } 176 | @media (max-width: 414px) { 177 | font-size: @font_size_medium; 178 | } 179 | .visitor { 180 | margin-left: 6px; 181 | height: 20px; 182 | @media (max-width: 414px) { 183 | display: none; 184 | } 185 | } 186 | } 187 | .user { 188 | position: absolute; 189 | top: 50%; 190 | right: 15px; 191 | line-height: 30px; 192 | text-align: right; 193 | transform: translateY(-50%); 194 | &-info { 195 | float: left; 196 | margin-right: 15px; 197 | cursor: pointer; 198 | .avatar { 199 | width: 30px; 200 | height: 30px; 201 | border-radius: 50%; 202 | vertical-align: middle; 203 | } 204 | span { 205 | margin-left: 10px; 206 | color: @text_color_active; 207 | } 208 | } 209 | &-btn { 210 | float: left; 211 | cursor: pointer; 212 | &:hover { 213 | color: @text_color_active; 214 | } 215 | } 216 | @media (max-width: 768px) { 217 | &-info { 218 | margin-right: 10px; 219 | span { 220 | display: none; 221 | } 222 | } 223 | } 224 | } 225 | } 226 | .mm-dialog-text { 227 | text-align: left; 228 | .mm-dialog-input { 229 | width: 100%; 230 | height: 40px; 231 | box-sizing: border-box; 232 | padding: 0 15px; 233 | border: 1px solid @btn_color; 234 | outline: 0; 235 | background: transparent; 236 | color: @text_color_active; 237 | font-size: @font_size_medium; 238 | box-shadow: 0 0 1px 0 #fff inset; 239 | &::placeholder { 240 | color: @text_color; 241 | } 242 | } 243 | a:hover { 244 | color: #d43c33; 245 | } 246 | } 247 | </style> 248 | -------------------------------------------------------------------------------- /src/components/music-btn/music-btn.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--选项--> 3 | <div class="music-btn"> 4 | <router-link to="/music/playlist" tag="span">正在播放</router-link> 5 | <router-link to="/music/toplist" tag="span">推荐</router-link> 6 | <router-link to="/music/search" tag="span">搜索</router-link> 7 | <router-link to="/music/userlist" tag="span">我的歌单</router-link> 8 | <span class="show-960" @click="$emit('onClickLyric')">歌词</span> 9 | <router-link to="/music/historylist" tag="span">我听过的</router-link> 10 | </div> 11 | </template> 12 | 13 | <script> 14 | export default {} 15 | </script> 16 | 17 | <style lang="less" scoped> 18 | .music-btn { 19 | width: 100%; 20 | height: 60px; 21 | font-size: 0; 22 | white-space: nowrap; 23 | overflow-x: auto; 24 | -webkit-overflow-scrolling: touch; 25 | span { 26 | display: inline-block; 27 | height: 40px; 28 | box-sizing: border-box; 29 | margin-right: 8px; 30 | padding: 0 23px; 31 | border: 1px solid @btn_color; 32 | color: @btn_color; 33 | border-radius: @btn_border_radius; 34 | font-size: 14px; 35 | line-height: 40px; 36 | overflow: hidden; 37 | cursor: pointer; 38 | &:nth-last-of-type(1) { 39 | margin: 0; 40 | } 41 | &:hover, 42 | &.active { 43 | border-color: @btn_color_active; 44 | color: @btn_color_active; 45 | } 46 | } 47 | @media (min-width: 960px) { 48 | span.show-960 { 49 | display: none; 50 | } 51 | } 52 | @media (max-width: 960px) { 53 | span.show-960 { 54 | display: inline-block; 55 | } 56 | } 57 | @media (max-width: 768px) { 58 | height: 50px; 59 | span { 60 | height: 35px; 61 | padding: 0 10px; 62 | margin-right: 6px; 63 | line-height: 35px; 64 | } 65 | } 66 | } 67 | </style> 68 | -------------------------------------------------------------------------------- /src/components/music-list/music-list.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--歌曲列表--> 3 | <div class="music-list flex-col"> 4 | <template v-if="list.length > 0"> 5 | <div class="list-item list-header"> 6 | <span class="list-name">歌曲</span> 7 | <span class="list-artist">歌手</span> 8 | <span v-if="isDuration" class="list-time">时长</span> 9 | <span v-else class="list-album">专辑</span> 10 | </div> 11 | <div ref="listContent" class="list-content" @scroll="listScroll($event)"> 12 | <div 13 | v-for="(item, index) in list" 14 | :key="item.id" 15 | class="list-item" 16 | :class="{ on: playing && currentMusic.id === item.id }" 17 | @dblclick="selectItem(item, index, $event)" 18 | > 19 | <span class="list-num" v-text="index + 1"></span> 20 | <div class="list-name"> 21 | <span>{{ item.name }}</span> 22 | <div class="list-menu"> 23 | <mm-icon 24 | class="hover" 25 | :type="getPlayIconType(item)" 26 | :size="40" 27 | @click.stop="selectItem(item, index)" 28 | /> 29 | </div> 30 | </div> 31 | <span class="list-artist">{{ item.singer }}</span> 32 | <span v-if="isDuration" class="list-time"> 33 | {{ item.duration % 3600 | format }} 34 | <mm-icon 35 | class="hover list-menu-icon-del" 36 | type="delete-mini" 37 | :size="40" 38 | @click.stop="deleteItem(index)" 39 | /> 40 | </span> 41 | <span v-else class="list-album">{{ item.album }}</span> 42 | </div> 43 | <slot name="listBtn"></slot> 44 | </div> 45 | </template> 46 | <mm-no-result v-else title="弄啥呢,怎么啥也没有!!!" /> 47 | </div> 48 | </template> 49 | 50 | <script> 51 | // import {getCheckMusic} from 'api' 52 | import { mapGetters, mapMutations } from 'vuex' 53 | import { format } from '@/utils/util' 54 | import MmNoResult from 'base/mm-no-result/mm-no-result' 55 | 56 | const LIST_TYPE_ALBUM = 'album' 57 | const LIST_TYPE_DURATION = 'duration' 58 | const LIST_TYPE_PULLUP = 'pullup' 59 | 60 | // 触发滚动加载的阈值 61 | const THRESHOLD = 100 62 | 63 | export default { 64 | name: 'MusicList', 65 | components: { 66 | MmNoResult, 67 | }, 68 | filters: { 69 | format, 70 | }, 71 | props: { 72 | // 歌曲数据 73 | list: { 74 | type: Array, 75 | default: () => [], 76 | }, 77 | /** 78 | * 列表类型 79 | * album: 显示专辑栏目(默认) 80 | * duration: 显示时长栏目 81 | * pullup: 开启上拉加载 82 | */ 83 | listType: { 84 | type: String, 85 | default: LIST_TYPE_ALBUM, 86 | }, 87 | }, 88 | data() { 89 | return { 90 | lockUp: true, // 是否锁定滚动加载事件,默认锁定 91 | } 92 | }, 93 | computed: { 94 | isDuration() { 95 | return this.listType === LIST_TYPE_DURATION 96 | }, 97 | ...mapGetters(['playing', 'currentMusic']), 98 | }, 99 | watch: { 100 | list(newList, oldList) { 101 | if (this.listType !== LIST_TYPE_PULLUP) { 102 | return 103 | } 104 | if (newList.length !== oldList.length) { 105 | this.lockUp = false 106 | } else if (newList[newList.length - 1].id !== oldList[oldList.length - 1].id) { 107 | this.lockUp = false 108 | } 109 | }, 110 | }, 111 | activated() { 112 | this.scrollTop && this.$refs.listContent && (this.$refs.listContent.scrollTop = this.scrollTop) 113 | }, 114 | methods: { 115 | // 滚动事件 116 | listScroll(e) { 117 | const scrollTop = e.target.scrollTop 118 | this.scrollTop = scrollTop 119 | if (this.listType !== LIST_TYPE_PULLUP || this.lockUp) { 120 | return 121 | } 122 | const { scrollHeight, offsetHeight } = e.target 123 | if (scrollTop + offsetHeight >= scrollHeight - THRESHOLD) { 124 | this.lockUp = true // 锁定滚动加载 125 | this.$emit('pullUp') // 触发滚动加载事件 126 | } 127 | }, 128 | // 回到顶部 129 | scrollTo() { 130 | this.$refs.listContent.scrollTop = 0 131 | }, 132 | // 播放暂停事件 133 | selectItem(item, index, e) { 134 | if (e && /list-menu-icon-del/.test(e.target.className)) { 135 | return 136 | } 137 | if (this.currentMusic.id && item.id === this.currentMusic.id) { 138 | this.setPlaying(!this.playing) 139 | return 140 | } 141 | 142 | /** 143 | * 为了修复 safari、 ios 微信、安卓 UC 无法播放问题,暂时移除接口校验直接播放 144 | */ 145 | this.$emit('select', item, index) // 触发点击播放事件 146 | 147 | // getMusicUrl(item.id) 148 | // .then(res => { 149 | // if (!res.data.data[0].url) { 150 | // this.$mmToast('当前音乐无法播放,请播放其他音乐') 151 | // } else { 152 | // this.$emit('select', item, index)//触发点击播放事件 153 | // } 154 | // }); 155 | // getCheckMusic(item.id) 156 | // .then(res => { 157 | // if (res.data.message !== 'ok') { 158 | // this.$mmToast('当前音乐无法播放,请播放其他音乐') 159 | // } else { 160 | // this.$emit('select', item, index)//触发点击播放事件 161 | // } 162 | // }).catch(error => { 163 | // this.$mmToast(error.response.data.message) 164 | // }) 165 | }, 166 | // 获取播放状态 type 167 | getPlayIconType({ id: itemId }) { 168 | const { 169 | playing, 170 | currentMusic: { id }, 171 | } = this 172 | return playing && id === itemId ? 'pause-mini' : 'play-mini' 173 | }, 174 | // 删除事件 175 | deleteItem(index) { 176 | this.$emit('del', index) // 触发删除事件 177 | }, 178 | ...mapMutations({ 179 | setPlaying: 'SET_PLAYING', 180 | }), 181 | }, 182 | } 183 | </script> 184 | 185 | <style lang="less" scoped> 186 | .music-list { 187 | height: 100%; 188 | } 189 | 190 | .list-header { 191 | border-bottom: 1px solid @list_head_line_color; 192 | color: @text_color_active; 193 | 194 | .list-name { 195 | padding-left: 40px; 196 | user-select: none; 197 | } 198 | } 199 | 200 | .list-content { 201 | flex: 1; 202 | overflow-x: hidden; 203 | overflow-y: auto; 204 | -webkit-overflow-scrolling: touch; 205 | } 206 | 207 | .list-no { 208 | display: flex; 209 | justify-content: center; 210 | align-items: center; 211 | width: 100%; 212 | height: 100%; 213 | color: @text_color; 214 | } 215 | 216 | .list-item { 217 | display: flex; 218 | width: 100%; 219 | height: 50px; 220 | border-bottom: 1px solid @list_item_line_color; 221 | line-height: 50px; 222 | overflow: hidden; 223 | 224 | &.list-item-no { 225 | justify-content: center; 226 | align-items: center; 227 | } 228 | 229 | &.on { 230 | color: #fff; 231 | 232 | .list-num { 233 | font-size: 0; 234 | background: url('~assets/img/wave.gif') no-repeat center center; 235 | } 236 | } 237 | 238 | &:hover { 239 | .list-name { 240 | padding-right: 80px; 241 | 242 | .list-menu { 243 | display: block; 244 | } 245 | } 246 | } 247 | 248 | &:not([class*='list-header']):hover { 249 | .list-name { 250 | padding-right: 80px; 251 | 252 | .list-menu { 253 | display: block; 254 | } 255 | } 256 | 257 | .list-time { 258 | font-size: 0; 259 | 260 | .list-menu-icon-del { 261 | display: block; 262 | } 263 | } 264 | } 265 | 266 | .list-num { 267 | display: block; 268 | width: 30px; 269 | margin-right: 10px; 270 | text-align: center; 271 | } 272 | 273 | .list-name { 274 | position: relative; 275 | flex: 1; 276 | box-sizing: border-box; 277 | 278 | & > span { 279 | text-overflow: ellipsis; 280 | overflow: hidden; 281 | display: -webkit-box; 282 | -webkit-line-clamp: 1; 283 | -webkit-box-orient: vertical; 284 | } 285 | 286 | small { 287 | margin-left: 5px; 288 | font-size: 12px; 289 | color: rgba(255, 255, 255, 0.5); 290 | } 291 | 292 | /*hover菜单*/ 293 | 294 | .list-menu { 295 | display: none; 296 | position: absolute; 297 | top: 50%; 298 | right: 10px; 299 | height: 40px; 300 | font-size: 0; 301 | transform: translateY(-50%); 302 | } 303 | } 304 | 305 | .list-artist, 306 | .list-album { 307 | display: block; 308 | width: 300px; 309 | .no-wrap(); 310 | @media (max-width: 1440px) { 311 | width: 200px; 312 | } 313 | @media (max-width: 1200px) { 314 | width: 150px; 315 | } 316 | } 317 | 318 | .list-time { 319 | display: block; 320 | width: 60px; 321 | position: relative; 322 | 323 | .list-menu-icon-del { 324 | display: none; 325 | position: absolute; 326 | top: 50%; 327 | left: 0; 328 | transform: translateY(-50%); 329 | } 330 | } 331 | } 332 | 333 | .list-btn { 334 | display: flex; 335 | justify-content: center; 336 | align-items: center; 337 | height: 50px; 338 | span { 339 | padding: 5px 20px; 340 | cursor: pointer; 341 | user-select: none; 342 | &:hover { 343 | color: @text_color_active; 344 | } 345 | } 346 | } 347 | 348 | @media (max-width: 960px) { 349 | .list-item .list-name { 350 | padding-right: 70px; 351 | } 352 | } 353 | 354 | @media (max-width: 768px) { 355 | .list-item { 356 | .list-name .list-menu { 357 | display: block; 358 | } 359 | 360 | .list-artist, 361 | .list-album { 362 | width: 20%; 363 | } 364 | } 365 | } 366 | 367 | @media (max-width: 640px) { 368 | .list-item { 369 | .list-artist { 370 | width: 80px; 371 | } 372 | 373 | .list-album, 374 | .list-time { 375 | display: none; 376 | } 377 | } 378 | } 379 | </style> 380 | -------------------------------------------------------------------------------- /src/components/volume/volume.vue: -------------------------------------------------------------------------------- 1 | <!-- 音量控制组件 --> 2 | <template> 3 | <div class="volume"> 4 | <mm-icon 5 | class="pointer volume-icon" 6 | :type="getVolumeIconType()" 7 | :size="30" 8 | @click="handleToggleVolume" 9 | /> 10 | <div class="volume-progress-wrapper"> 11 | <mm-progress 12 | :percent="volumeProgress" 13 | @percentChangeEnd="handleVolumeChange" 14 | @percentChange="handleVolumeChange" 15 | /> 16 | </div> 17 | </div> 18 | </template> 19 | 20 | <script> 21 | import MmProgress from 'base/mm-progress/mm-progress' 22 | 23 | export default { 24 | name: 'Volume', 25 | components: { 26 | MmProgress, 27 | }, 28 | props: { 29 | volume: { 30 | type: Number, 31 | required: true, 32 | }, 33 | }, 34 | computed: { 35 | volumeProgress() { 36 | return this.volume 37 | }, 38 | isMute: { 39 | get() { 40 | return this.volumeProgress === 0 41 | }, 42 | set(newMute) { 43 | const volume = newMute ? 0 : this.lastVolume 44 | if (newMute) { 45 | this.lastVolume = this.volumeProgress 46 | } 47 | this.handleVolumeChange(volume) 48 | }, 49 | }, 50 | }, 51 | methods: { 52 | getVolumeIconType() { 53 | return this.isMute ? 'volume-off' : 'volume' 54 | }, 55 | // 是否静音 56 | handleToggleVolume() { 57 | this.isMute = !this.isMute 58 | }, 59 | handleVolumeChange(percent) { 60 | this.$emit('volumeChange', percent) 61 | }, 62 | }, 63 | } 64 | </script> 65 | 66 | <style lang="less" scoped> 67 | .volume { 68 | display: flex; 69 | align-items: center; 70 | width: 150px; 71 | &-icon { 72 | margin-right: 5px; 73 | color: #fff; 74 | } 75 | &-progress-wrapper { 76 | flex: 1; 77 | } 78 | @media (max-width: 768px) { 79 | top: 2px; 80 | width: 36px; 81 | } 82 | } 83 | </style> 84 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | /* 版本号 */ 2 | export const VERSION = process.env.VUE_APP_VERSION 3 | 4 | /** 5 | * 访客统计 id 6 | * 具体使用文档 https://github.com/jwenjian/visitor-badge 7 | */ 8 | export const VISITOR_BADGE_ID = process.env.VUE_APP_VISITOR_BADGE_ID 9 | 10 | /* 背景图(可引入网络图或本地静态图) */ 11 | const requireAll = (requireContext) => requireContext.keys().map(requireContext) 12 | const BACKGROUNDS = requireAll(require.context('./assets/background', false)) 13 | 14 | /** 15 | * 播放模式 16 | * LIST_LOOP: 列表循环 17 | * ORDER: 顺序播放 18 | * RANDOM: 随机播放 19 | * LOOP: 单曲循环 20 | */ 21 | export const PLAY_MODE = { 22 | LIST_LOOP: 0, 23 | ORDER: 1, 24 | RANDOM: 2, 25 | LOOP: 3, 26 | } 27 | 28 | /** 29 | * 播放器默认配置 30 | */ 31 | export const MMPLAYER_CONFIG = { 32 | /** 33 | * 默认歌单ID (正在播放列表) 34 | * 默认为云音乐热歌榜 https://music.163.com/#/discover/toplist?id=3778678 35 | */ 36 | PLAYLIST_ID: 3778678, 37 | /* 默认播放模式 */ 38 | PLAY_MODE: PLAY_MODE.LIST_LOOP, 39 | /* 默认音量 */ 40 | VOLUME: 0.8, 41 | /* 默认背景 */ 42 | BACKGROUND: BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)], 43 | } 44 | 45 | /* 默认分页数量 */ 46 | export const DEFAULT_LIMIT = 30 47 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // The Vue build version to load with the `import` command 2 | // (runtime-only or standalone) has been set in webpack.base.conf with an alias. 3 | // import 'babel-polyfill' 4 | // import '@/utils/hack' 5 | import Vue from 'vue' 6 | import store from './store' 7 | import router from './router' 8 | import App from './App' 9 | import fastclick from 'fastclick' 10 | import mmToast from 'base/mm-toast' 11 | import Icon from 'base/mm-icon/mm-icon' 12 | import VueLazyload from 'vue-lazyload' 13 | import { VERSION } from './config' 14 | 15 | import '@/styles/index.less' 16 | 17 | // 优化移动端300ms点击延迟 18 | fastclick.attach(document.body) 19 | 20 | // 弹出层 21 | Vue.use(mmToast) 22 | 23 | // icon 组件 24 | Vue.component(Icon.name, Icon) 25 | 26 | // 懒加载 27 | Vue.use(VueLazyload, { 28 | preLoad: 1, 29 | loading: require('assets/img/default.png'), 30 | }) 31 | 32 | // 访问版本统计 33 | window._hmt && window._hmt.push(['_setCustomVar', 1, 'version', VERSION, 1]) 34 | 35 | const redirectList = ['/music/details', '/music/comment'] 36 | router.beforeEach((to, from, next) => { 37 | window._hmt && to.path && window._hmt.push(['_trackPageview', '/#' + to.fullPath]) 38 | if (redirectList.includes(to.path)) { 39 | next() 40 | } else { 41 | document.title = 42 | (to.meta.title && `${to.meta.title} - mmPlayer在线音乐播放器`) || 'mmPlayer在线音乐播放器' 43 | next() 44 | } 45 | }) 46 | 47 | // 版权信息 48 | window.mmPlayer = window.mmplayer = `欢迎使用 mmPlayer! 49 | 当前版本为:V${VERSION} 50 | 作者:茂茂 51 | Github:https://github.com/maomao1996/Vue-mmPlayer 52 | 歌曲来源于网易云音乐 (https://music.163.com)` 53 | // eslint-disable-next-line no-console 54 | console.info(`%c${window.mmplayer}`, `color:blue`) 55 | 56 | // eslint-disable-next-line no-new 57 | new Vue({ 58 | el: '#mmPlayer', 59 | store, 60 | router, 61 | render: (h) => h(App), 62 | }) 63 | -------------------------------------------------------------------------------- /src/pages/comment/comment.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--评论--> 3 | <div class="comment" @scroll="listScroll($event)"> 4 | <mm-loading v-model="mmLoadShow" /> 5 | <dl v-if="hotComments.length > 0" class="comment-list"> 6 | <!--精彩评论--> 7 | <dt class="comment-title">精彩评论</dt> 8 | <dd v-for="item in hotComments" :key="item.commentId" class="comment-item"> 9 | <a target="_blank" :href="`https://music.163.com/#/user/home?id=${item.user.userId}`"> 10 | <img v-lazy="`${item.user.avatarUrl}?param=50y50`" class="comment-item-pic" /> 11 | <h2 class="comment-item-title">{{ item.user.nickname }}</h2> 12 | </a> 13 | <p class="comment-item-disc">{{ item.content }}</p> 14 | <div class="comment-item-opt"> 15 | <span class="comment-opt-date">{{ item.time | format }}</span> 16 | <span class="comment-opt-liked"> 17 | <mm-icon type="good" /> 18 | {{ item.likedCount }} 19 | </span> 20 | </div> 21 | </dd> 22 | </dl> 23 | <!--最新评论--> 24 | <dl v-if="commentList.length > 0" class="comment-list"> 25 | <dt class="comment-title">最新评论({{ total }})</dt> 26 | <dd v-for="item in commentList" :key="item.commentId" class="comment-item"> 27 | <a 28 | class="comment-item-pic" 29 | target="_blank" 30 | :href="`https://music.163.com/#/user/home?id=${item.user.userId}`" 31 | > 32 | <img v-lazy="`${item.user.avatarUrl}?param=50y50`" class="cover-img" /> 33 | </a> 34 | <h2 class="comment-item-title"> 35 | <a target="_blank" :href="`https://music.163.com/#/user/home?id=${item.user.userId}`"> 36 | {{ item.user.nickname }} 37 | </a> 38 | </h2> 39 | <p class="comment-item-disc">{{ item.content }}</p> 40 | <div 41 | v-for="beReplied in item.beReplied" 42 | :key="beReplied.user.userId" 43 | class="comment-item-replied" 44 | > 45 | <a 46 | target="_blank" 47 | :href="`https://music.163.com/#/user/home?id=${beReplied.user.userId}`" 48 | > 49 | {{ beReplied.user.nickname }} 50 | </a> 51 | :{{ beReplied.content }} 52 | </div> 53 | <div class="comment-item-opt"> 54 | <span class="comment-opt-date">{{ item.time | format }}</span> 55 | <span v-if="item.likedCount > 0" class="comment-opt-liked"> 56 | <mm-icon type="good" /> 57 | {{ item.likedCount }} 58 | </span> 59 | </div> 60 | </dd> 61 | </dl> 62 | </div> 63 | </template> 64 | 65 | <script> 66 | import { getComment } from 'api' 67 | import { addZero } from '@/utils/util' 68 | import MmLoading from 'base/mm-loading/mm-loading' 69 | import { loadMixin } from '@/utils/mixin' 70 | 71 | export default { 72 | name: 'Comment', 73 | components: { 74 | MmLoading, 75 | }, 76 | filters: { 77 | // 格式化时间 78 | format(time) { 79 | let formatTime 80 | const date = new Date(time) 81 | const dateObj = { 82 | year: date.getFullYear(), 83 | month: date.getMonth(), 84 | date: date.getDate(), 85 | hours: date.getHours(), 86 | minutes: date.getMinutes(), 87 | } 88 | const newTime = new Date() 89 | const diff = newTime.getTime() - time 90 | if (newTime.getDate() === dateObj.date && diff < 60000) { 91 | formatTime = '刚刚' 92 | } else if (newTime.getDate() === dateObj.date && diff > 60000 && diff < 3600000) { 93 | formatTime = `${Math.floor(diff / 60000)}分钟前` 94 | } else if (newTime.getDate() === dateObj.date && diff > 3600000 && diff < 86400000) { 95 | formatTime = `${addZero(dateObj.hours)}:${addZero(dateObj.minutes)}` 96 | } else if (newTime.getDate() !== dateObj.date && diff < 86400000) { 97 | formatTime = `昨天${addZero(dateObj.hours)}:${addZero(dateObj.minutes)}` 98 | } else if (newTime.getFullYear() === dateObj.year) { 99 | formatTime = `${dateObj.month + 1}月${dateObj.date}日` 100 | } else { 101 | formatTime = `${dateObj.year}年${dateObj.month + 1}月${dateObj.date}日` 102 | } 103 | return formatTime 104 | }, 105 | }, 106 | mixins: [loadMixin], 107 | data() { 108 | return { 109 | lockUp: true, // 是否锁定滚动加载事件,默认锁定 110 | page: 0, // 分页 111 | hotComments: [], // 精彩评论 112 | commentList: [], // 最新评论 113 | total: null, // 评论总数 114 | } 115 | }, 116 | watch: { 117 | commentList(newList, oldList) { 118 | if (newList.length !== oldList.length) { 119 | this.lockUp = false 120 | } 121 | }, 122 | }, 123 | created() { 124 | this.initData() 125 | }, 126 | methods: { 127 | // 初始化数据 128 | initData() { 129 | getComment(this.$route.params.id, this.page).then((res) => { 130 | this.hotComments = res.hotComments 131 | this.commentList = res.comments 132 | this.total = res.total 133 | this.lockUp = true 134 | this._hideLoad() 135 | }) 136 | }, 137 | // 列表滚动事件 138 | listScroll(e) { 139 | if (this.lockUp) { 140 | return 141 | } 142 | const { scrollTop, scrollHeight, offsetHeight } = e.target 143 | if (scrollTop + offsetHeight >= scrollHeight - 100) { 144 | this.lockUp = true // 锁定滚动加载 145 | this.page += 1 146 | this.pullUp() // 触发滚动加载事件 147 | } 148 | }, 149 | // 滚动加载事件 150 | pullUp() { 151 | getComment(this.$route.params.id, this.page).then(({ comments }) => { 152 | this.commentList = [...this.commentList, ...comments] 153 | }) 154 | }, 155 | }, 156 | } 157 | </script> 158 | 159 | <style lang="less" scoped> 160 | .comment { 161 | .comment-list { 162 | padding: 0 10px; 163 | } 164 | 165 | .comment-title { 166 | position: sticky; 167 | top: 0; 168 | z-index: 1; 169 | margin: 0 -10px; 170 | padding: 10px; 171 | height: 34px; 172 | line-height: 34px; 173 | color: @text_color_active; 174 | background: @header_bg_color; 175 | backdrop-filter: @backdrop_filter; 176 | } 177 | .comment-item { 178 | position: relative; 179 | padding: 15px 0 15px 55px; 180 | & + .comment-item { 181 | border-top: 1px solid @comment_item_line_color; 182 | } 183 | &-pic { 184 | display: block; 185 | position: absolute; 186 | left: 0; 187 | top: 20px; 188 | width: 38px; 189 | height: 38px; 190 | border-radius: 50%; 191 | overflow: hidden; 192 | } 193 | &-title { 194 | height: 20px; 195 | margin-bottom: 6px; 196 | font-weight: 400; 197 | .no-wrap(); 198 | color: @text_color_active; 199 | } 200 | &-disc { 201 | overflow: hidden; 202 | word-break: break-all; 203 | word-wrap: break-word; 204 | line-height: 25px; 205 | text-align: justify; 206 | color: @text_color; 207 | img { 208 | position: relative; 209 | vertical-align: middle; 210 | top: -2px; 211 | } 212 | } 213 | &-replied { 214 | padding: 8px 19px; 215 | margin-top: 10px; 216 | line-height: 20px; 217 | border: 1px solid @comment_replied_line_color; 218 | a { 219 | color: @text_color_active; 220 | } 221 | } 222 | &-opt { 223 | margin-top: 10px; 224 | line-height: 25px; 225 | text-align: right; 226 | overflow: hidden; 227 | .comment-opt-date { 228 | float: left; 229 | line-height: 28px; 230 | } 231 | .comment-opt-liked { 232 | display: inline-block; 233 | height: 20px; 234 | line-height: 20px; 235 | } 236 | } 237 | } 238 | } 239 | </style> 240 | -------------------------------------------------------------------------------- /src/pages/details/details.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--歌单详情--> 3 | <div class="details"> 4 | <mm-loading v-model="mmLoadShow" /> 5 | <music-list :list="list" @select="selectItem" /> 6 | </div> 7 | </template> 8 | 9 | <script> 10 | import { mapActions } from 'vuex' 11 | import { getPlaylistDetail } from 'api' 12 | import MmLoading from 'base/mm-loading/mm-loading' 13 | import MusicList from 'components/music-list/music-list' 14 | import { loadMixin } from '@/utils/mixin' 15 | 16 | export default { 17 | name: 'Detail', 18 | components: { 19 | MmLoading, 20 | MusicList, 21 | }, 22 | mixins: [loadMixin], 23 | data() { 24 | return { 25 | list: [], // 列表 26 | } 27 | }, 28 | created() { 29 | // 获取歌单详情 30 | getPlaylistDetail(this.$route.params.id) 31 | .then((playlist) => { 32 | document.title = `${playlist.name} - mmPlayer在线音乐播放器` 33 | this.list = playlist.tracks 34 | this._hideLoad() 35 | }) 36 | .catch(() => { 37 | this._hideLoad() 38 | }) 39 | }, 40 | methods: { 41 | // 播放暂停事件 42 | selectItem(item, index) { 43 | this.selectPlay({ 44 | list: this.list, 45 | index, 46 | }) 47 | }, 48 | ...mapActions(['selectPlay']), 49 | }, 50 | } 51 | </script> 52 | 53 | <style lang="less" scoped> 54 | .details { 55 | .music-list { 56 | height: 100%; 57 | } 58 | } 59 | </style> 60 | -------------------------------------------------------------------------------- /src/pages/historyList/historyList.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--我听过的(播放历史)--> 3 | <div class="historyList"> 4 | <music-list :list="historyList" list-type="duration" @select="selectItem" @del="deleteItem"> 5 | <div slot="listBtn" class="list-btn"> 6 | <span @click="$refs.dialog.show()">清空列表</span> 7 | </div> 8 | </music-list> 9 | <mm-dialog 10 | ref="dialog" 11 | body-text="是否清空播放历史列表" 12 | confirm-btn-text="清空" 13 | @confirm="clearList" 14 | /> 15 | </div> 16 | </template> 17 | 18 | <script> 19 | import { mapGetters, mapMutations, mapActions } from 'vuex' 20 | import MusicList from 'components/music-list/music-list' 21 | import MmDialog from 'base/mm-dialog/mm-dialog' 22 | 23 | export default { 24 | name: 'HistoryList', 25 | components: { 26 | MusicList, 27 | MmDialog, 28 | }, 29 | computed: { 30 | ...mapGetters(['historyList', 'playing', 'currentMusic']), 31 | }, 32 | methods: { 33 | // 清空列表事件 34 | clearList() { 35 | this.clearHistory() 36 | this.$mmToast('列表清空成功') 37 | }, 38 | // 播放事件 39 | selectItem(item, index) { 40 | this.selectPlay({ 41 | list: this.historyList, 42 | index, 43 | }) 44 | }, 45 | // 删除事件 46 | deleteItem(index) { 47 | let list = [...this.historyList] 48 | list.splice(index, 1) 49 | this.removeHistory(list) 50 | this.$mmToast('删除成功') 51 | }, 52 | ...mapMutations({ 53 | setPlaying: 'SET_PLAYING', 54 | }), 55 | ...mapActions(['selectPlay', 'clearHistory', 'removeHistory']), 56 | }, 57 | } 58 | </script> 59 | -------------------------------------------------------------------------------- /src/pages/mmPlayer.js: -------------------------------------------------------------------------------- 1 | import { PLAY_MODE } from '@/config' 2 | 3 | // 重试次数 4 | let retry = 1 5 | 6 | const mmPlayerMusic = { 7 | initAudio(that) { 8 | const ele = that.audioEle 9 | // 音频缓冲事件 10 | ele.onprogress = () => { 11 | try { 12 | if (ele.buffered.length > 0) { 13 | const duration = that.currentMusic.duration 14 | let buffered = 0 15 | ele.buffered.end(0) 16 | buffered = ele.buffered.end(0) > duration ? duration : ele.buffered.end(0) 17 | that.currentProgress = buffered / duration 18 | } 19 | } catch (e) {} 20 | } 21 | // 开始播放音乐 22 | ele.onplay = () => { 23 | let timer 24 | clearTimeout(timer) 25 | timer = setTimeout(() => { 26 | that.musicReady = true 27 | }, 100) 28 | } 29 | // 获取当前播放时间 30 | ele.ontimeupdate = () => { 31 | that.currentTime = ele.currentTime 32 | } 33 | // 当前音乐播放完毕 34 | ele.onended = () => { 35 | if (that.mode === PLAY_MODE.LOOP) { 36 | that.loop() 37 | } else { 38 | that.next() 39 | } 40 | } 41 | // 音乐播放出错 42 | ele.onerror = () => { 43 | if (retry === 0) { 44 | let toastText = '当前音乐不可播放,已自动播放下一曲' 45 | if (that.playlist.length === 1) { 46 | toastText = '没有可播放的音乐哦~' 47 | } 48 | that.$mmToast(toastText) 49 | that.next(true) 50 | } else { 51 | // eslint-disable-next-line no-console 52 | console.log('重试一次') 53 | retry -= 1 54 | ele.url = that.currentMusic.url 55 | ele.load() 56 | } 57 | // console.log('播放出错啦!') 58 | } 59 | // 音乐进度拖动大于加载时重载音乐 60 | ele.onstalled = () => { 61 | ele.load() 62 | that.setPlaying(false) 63 | let timer 64 | clearTimeout(timer) 65 | timer = setTimeout(() => { 66 | that.setPlaying(true) 67 | }, 10) 68 | } 69 | // 将能播放的音乐加入播放历史 70 | ele.oncanplay = () => { 71 | retry = 1 72 | if (that.historyList.length === 0 || that.currentMusic.id !== that.historyList[0].id) { 73 | that.setHistory(that.currentMusic) 74 | } 75 | } 76 | // 音频数据不可用时 77 | ele.onstalled = () => { 78 | ele.load() 79 | that.setPlaying(false) 80 | let timer 81 | clearTimeout(timer) 82 | timer = setTimeout(() => { 83 | that.setPlaying(true) 84 | }, 10) 85 | } 86 | // 当音频已暂停时 87 | ele.onpause = () => { 88 | that.setPlaying(false) 89 | } 90 | }, 91 | } 92 | 93 | export default mmPlayerMusic 94 | -------------------------------------------------------------------------------- /src/pages/music.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <div class="music flex-col"> 3 | <div class="music-content"> 4 | <div class="music-left flex-col"> 5 | <music-btn @onClickLyric="handleOpenLyric" /> 6 | <keep-alive> 7 | <router-view v-if="$route.meta.keepAlive" class="router-view" /> 8 | </keep-alive> 9 | <router-view v-if="!$route.meta.keepAlive" :key="$route.path" class="router-view" /> 10 | </div> 11 | <div class="music-right" :class="{ show: lyricVisible }"> 12 | <div class="close-lyric" @click="handleCloseLyric">关闭歌词</div> 13 | <lyric ref="lyric" :lyric="lyric" :nolyric="nolyric" :lyric-index="lyricIndex" /> 14 | </div> 15 | </div> 16 | 17 | <!--播放器--> 18 | <div class="music-bar" :class="{ disable: !musicReady || !currentMusic.id }"> 19 | <div class="music-bar-btns"> 20 | <mm-icon class="pointer" type="prev" :size="36" title="上一曲 Ctrl + Left" @click="prev" /> 21 | <div class="control-play pointer" title="播放暂停 Ctrl + Space" @click="play"> 22 | <mm-icon :type="playing ? 'pause' : 'play'" :size="24" /> 23 | </div> 24 | <mm-icon class="pointer" type="next" :size="36" title="下一曲 Ctrl + Right" @click="next" /> 25 | </div> 26 | <div class="music-music"> 27 | <div class="music-bar-info"> 28 | <template v-if="currentMusic && currentMusic.id"> 29 | {{ currentMusic.name }} 30 | <span>- {{ currentMusic.singer }}</span> 31 | </template> 32 | <template v-else>欢迎使用mmPlayer在线音乐播放器</template> 33 | </div> 34 | <div v-if="currentMusic.id" class="music-bar-time"> 35 | {{ currentTime | format }}/{{ currentMusic.duration % 3600 | format }} 36 | </div> 37 | <mm-progress 38 | class="music-progress" 39 | :percent="percentMusic" 40 | :percent-progress="currentProgress" 41 | @percentChange="progressMusic" 42 | @percentChangeEnd="progressMusicEnd" 43 | /> 44 | </div> 45 | 46 | <!-- 播放模式 --> 47 | <mm-icon 48 | class="icon-color pointer mode" 49 | :type="getModeIconType()" 50 | :title="getModeIconTitle()" 51 | :size="30" 52 | @click="modeChange" 53 | /> 54 | 55 | <!-- 评论 --> 56 | <mm-icon class="icon-color pointer comment" type="comment" :size="30" @click="openComment" /> 57 | 58 | <!-- 音量控制 --> 59 | <div class="music-bar-volume" title="音量加减 [Ctrl + Up / Down]"> 60 | <volume :volume="volume" @volumeChange="volumeChange" /> 61 | </div> 62 | </div> 63 | 64 | <!--遮罩--> 65 | <div class="mmPlayer-bg" :style="{ backgroundImage: picUrl }"></div> 66 | <div class="mmPlayer-mask"></div> 67 | </div> 68 | </template> 69 | 70 | <script> 71 | import { getLyric } from 'api' 72 | import mmPlayerMusic from './mmPlayer' 73 | import { randomSortArray, parseLyric, format, silencePromise } from '@/utils/util' 74 | import { PLAY_MODE, MMPLAYER_CONFIG } from '@/config' 75 | import { getVolume, setVolume } from '@/utils/storage' 76 | import { mapGetters, mapMutations, mapActions } from 'vuex' 77 | 78 | import MmProgress from 'base/mm-progress/mm-progress' 79 | import MusicBtn from 'components/music-btn/music-btn' 80 | import Lyric from 'components/lyric/lyric' 81 | import Volume from 'components/volume/volume' 82 | 83 | export default { 84 | name: 'Music', 85 | components: { 86 | MmProgress, 87 | MusicBtn, 88 | Lyric, 89 | Volume, 90 | }, 91 | filters: { 92 | // 时间格式化 93 | format, 94 | }, 95 | data() { 96 | const volume = getVolume() 97 | return { 98 | musicReady: false, // 是否可以使用播放器 99 | currentTime: 0, // 当前播放时间 100 | currentProgress: 0, // 当前缓冲进度 101 | lyricVisible: false, // 移动端歌词显示 102 | lyric: [], // 歌词 103 | nolyric: false, // 是否有歌词 104 | lyricIndex: 0, // 当前播放歌词下标 105 | isMute: false, // 是否静音 106 | volume, // 音量大小 107 | } 108 | }, 109 | computed: { 110 | picUrl() { 111 | return this.currentMusic.id && this.currentMusic.image 112 | ? `url(${this.currentMusic.image}?param=300y300)` 113 | : `url(${MMPLAYER_CONFIG.BACKGROUND})` 114 | }, 115 | percentMusic() { 116 | const duration = this.currentMusic.duration 117 | return this.currentTime && duration ? this.currentTime / duration : 0 118 | }, 119 | ...mapGetters([ 120 | 'audioEle', 121 | 'mode', 122 | 'playing', 123 | 'playlist', 124 | 'orderList', 125 | 'currentIndex', 126 | 'currentMusic', 127 | 'historyList', 128 | ]), 129 | }, 130 | watch: { 131 | currentMusic(newMusic, oldMusic) { 132 | if (!newMusic.id) { 133 | this.lyric = [] 134 | return 135 | } 136 | if (newMusic.id === oldMusic.id) { 137 | return 138 | } 139 | this.audioEle.src = newMusic.url 140 | // 重置相关参数 141 | this.lyricIndex = this.currentTime = this.currentProgress = 0 142 | silencePromise(this.audioEle.play()) 143 | this.$nextTick(() => { 144 | this._getLyric(newMusic.id) 145 | }) 146 | }, 147 | playing(newPlaying) { 148 | const audio = this.audioEle 149 | this.$nextTick(() => { 150 | newPlaying ? silencePromise(audio.play()) : audio.pause() 151 | this.musicReady = true 152 | }) 153 | }, 154 | currentTime(newTime) { 155 | if (this.nolyric) { 156 | return 157 | } 158 | let lyricIndex = 0 159 | for (let i = 0; i < this.lyric.length; i++) { 160 | if (newTime > this.lyric[i].time) { 161 | lyricIndex = i 162 | } 163 | } 164 | this.lyricIndex = lyricIndex 165 | }, 166 | $route() { 167 | this.lyricVisible = false 168 | }, 169 | }, 170 | mounted() { 171 | this.$nextTick(() => { 172 | mmPlayerMusic.initAudio(this) 173 | this.initKeyDown() 174 | this.volumeChange(this.volume) 175 | }) 176 | }, 177 | methods: { 178 | // 按键事件 179 | initKeyDown() { 180 | document.onkeydown = (e) => { 181 | switch (e.ctrlKey && e.keyCode) { 182 | case 32: // 播放暂停Ctrl + Space 183 | this.play() 184 | break 185 | case 37: // 上一曲Ctrl + Left 186 | this.prev() 187 | break 188 | case 38: // 音量加Ctrl + Up 189 | let plus = Number((this.volume += 0.1).toFixed(1)) 190 | if (plus > 1) { 191 | plus = 1 192 | } 193 | this.volumeChange(plus) 194 | break 195 | case 39: // 下一曲Ctrl + Right 196 | this.next() 197 | break 198 | case 40: // 音量减Ctrl + Down 199 | let reduce = Number((this.volume -= 0.1).toFixed(1)) 200 | if (reduce < 0) { 201 | reduce = 0 202 | } 203 | this.volumeChange(reduce) 204 | break 205 | case 79: // 切换播放模式Ctrl + O 206 | this.modeChange() 207 | break 208 | } 209 | } 210 | }, 211 | // 上一曲 212 | prev() { 213 | if (!this.musicReady) { 214 | return 215 | } 216 | if (this.playlist.length === 1) { 217 | this.loop() 218 | } else { 219 | let index = this.currentIndex - 1 220 | if (index < 0) { 221 | index = this.playlist.length - 1 222 | } 223 | this.setCurrentIndex(index) 224 | if (!this.playing && this.musicReady) { 225 | this.setPlaying(true) 226 | } 227 | this.musicReady = false 228 | } 229 | }, 230 | // 播放暂停 231 | play() { 232 | if (!this.musicReady) { 233 | return 234 | } 235 | this.setPlaying(!this.playing) 236 | }, 237 | // 下一曲 238 | // 当 flag 为 true 时,表示上一曲播放失败 239 | next(flag = false) { 240 | if (!this.musicReady) { 241 | return 242 | } 243 | const { 244 | playlist: { length }, 245 | } = this 246 | if ( 247 | (length - 1 === this.currentIndex && this.mode === PLAY_MODE.ORDER) || 248 | (length === 1 && flag) 249 | ) { 250 | this.setCurrentIndex(-1) 251 | this.setPlaying(false) 252 | return 253 | } 254 | if (length === 1) { 255 | this.loop() 256 | } else { 257 | let index = this.currentIndex + 1 258 | if (index === length) { 259 | index = 0 260 | } 261 | if (!this.playing && this.musicReady) { 262 | this.setPlaying(true) 263 | } 264 | this.setCurrentIndex(index) 265 | this.musicReady = false 266 | } 267 | }, 268 | // 循环 269 | loop() { 270 | this.audioEle.currentTime = 0 271 | silencePromise(this.audioEle.play()) 272 | this.setPlaying(true) 273 | if (this.lyric.length > 0) { 274 | this.lyricIndex = 0 275 | } 276 | }, 277 | // 修改音乐显示时长 278 | progressMusic(percent) { 279 | this.currentTime = this.currentMusic.duration * percent 280 | }, 281 | // 修改音乐进度 282 | progressMusicEnd(percent) { 283 | this.audioEle.currentTime = this.currentMusic.duration * percent 284 | }, 285 | // 切换播放顺序 286 | modeChange() { 287 | const mode = (this.mode + 1) % 4 288 | this.setPlayMode(mode) 289 | if (mode === PLAY_MODE.LOOP) { 290 | return 291 | } 292 | let list = [] 293 | switch (mode) { 294 | case PLAY_MODE.LIST_LOOP: 295 | case PLAY_MODE.ORDER: 296 | list = this.orderList 297 | break 298 | case PLAY_MODE.RANDOM: 299 | list = randomSortArray(this.orderList) 300 | break 301 | } 302 | this.resetCurrentIndex(list) 303 | this.setPlaylist(list) 304 | }, 305 | // 修改当前歌曲索引 306 | resetCurrentIndex(list) { 307 | const index = list.findIndex((item) => { 308 | return item.id === this.currentMusic.id 309 | }) 310 | this.setCurrentIndex(index) 311 | }, 312 | // 打开音乐评论 313 | openComment() { 314 | if (!this.currentMusic.id) { 315 | this.$mmToast('还没有播放歌曲哦!') 316 | return false 317 | } 318 | this.$router.push(`/music/comment/${this.currentMusic.id}`) 319 | }, 320 | // 修改音量大小 321 | volumeChange(percent) { 322 | percent === 0 ? (this.isMute = true) : (this.isMute = false) 323 | this.volume = percent 324 | this.audioEle.volume = percent 325 | setVolume(percent) 326 | }, 327 | // 获取播放模式 icon 328 | getModeIconType() { 329 | return { 330 | [PLAY_MODE.LIST_LOOP]: 'loop', 331 | [PLAY_MODE.ORDER]: 'sequence', 332 | [PLAY_MODE.RANDOM]: 'random', 333 | [PLAY_MODE.LOOP]: 'loop-one', 334 | }[this.mode] 335 | }, 336 | // 获取播放模式 title 337 | getModeIconTitle() { 338 | const key = 'Ctrl + O' 339 | return { 340 | [PLAY_MODE.LIST_LOOP]: `列表循环 ${key}`, 341 | [PLAY_MODE.ORDER]: `顺序播放 ${key}`, 342 | [PLAY_MODE.RANDOM]: `随机播放 ${key}`, 343 | [PLAY_MODE.LOOP]: `单曲循环 ${key}`, 344 | }[this.mode] 345 | }, 346 | // 查看歌词 347 | handleOpenLyric() { 348 | this.lyricVisible = true 349 | this.$nextTick(() => { 350 | this.$refs.lyric.clacTop() 351 | }) 352 | }, 353 | // 关闭歌词 354 | handleCloseLyric() { 355 | this.lyricVisible = false 356 | }, 357 | // 获取歌词 358 | _getLyric(id) { 359 | getLyric(id).then((res) => { 360 | if (res.lrc && res.lrc.lyric) { 361 | this.nolyric = false 362 | this.lyric = parseLyric(res.lrc.lyric) 363 | } else { 364 | this.nolyric = true 365 | } 366 | silencePromise(this.audioEle.play()) 367 | }) 368 | }, 369 | ...mapMutations({ 370 | setPlaying: 'SET_PLAYING', 371 | setPlaylist: 'SET_PLAYLIST', 372 | setCurrentIndex: 'SET_CURRENTINDEX', 373 | }), 374 | ...mapActions(['setHistory', 'setPlayMode']), 375 | }, 376 | } 377 | </script> 378 | 379 | <style lang="less"> 380 | .router-view { 381 | flex: 1; 382 | overflow-x: hidden; 383 | overflow-y: auto; 384 | -webkit-overflow-scrolling: touch; 385 | } 386 | 387 | .music { 388 | padding: 75px 25px 25px 25px; 389 | width: 100%; 390 | max-width: 1750px; 391 | margin: 0 auto; 392 | height: 100%; 393 | box-sizing: border-box; 394 | overflow: hidden; 395 | .music-content { 396 | display: flex; 397 | flex: 1; 398 | overflow: hidden; 399 | width: 100%; 400 | .music-left { 401 | flex: 1; 402 | width: 100%; 403 | overflow: hidden; 404 | } 405 | .music-right { 406 | position: relative; 407 | width: 310px; 408 | margin-left: 10px; 409 | .close-lyric { 410 | position: absolute; 411 | top: 0; 412 | z-index: 1; 413 | cursor: pointer; 414 | } 415 | } 416 | } 417 | 418 | /*底部mmPlayer-bar*/ 419 | .music-bar { 420 | display: flex; 421 | align-items: center; 422 | width: 100%; 423 | padding: 15px 0; 424 | color: #fff; 425 | &.disable { 426 | pointer-events: none; 427 | opacity: 0.6; 428 | } 429 | .icon-color { 430 | color: #fff; 431 | } 432 | .music-bar-btns { 433 | display: flex; 434 | justify-content: space-between; 435 | align-items: center; 436 | width: 180px; 437 | .control-play { 438 | .flex-center; 439 | border-radius: 50%; 440 | width: 40px; 441 | height: 40px; 442 | color: #fff; 443 | background-color: rgba(255, 255, 255, 0.3); 444 | } 445 | } 446 | 447 | .flex-center; 448 | .btn-prev { 449 | width: 19px; 450 | min-width: 19px; 451 | height: 20px; 452 | background-position: 0 -30px; 453 | } 454 | .btn-play { 455 | width: 21px; 456 | min-width: 21px; 457 | height: 29px; 458 | margin: 0 50px; 459 | background-position: 0 0; 460 | &.btn-play-pause { 461 | background-position: -30px 0; 462 | } 463 | } 464 | .btn-next { 465 | width: 19px; 466 | min-width: 19px; 467 | height: 20px; 468 | background-position: 0 -52px; 469 | } 470 | .music-music { 471 | position: relative; 472 | width: 100%; 473 | flex: 1; 474 | box-sizing: border-box; 475 | padding-left: 40px; 476 | font-size: @font_size_small; 477 | color: @text_color_active; 478 | .music-bar-info { 479 | height: 15px; 480 | padding-right: 80px; 481 | line-height: 15px; 482 | text-overflow: ellipsis; 483 | overflow: hidden; 484 | display: -webkit-box; 485 | -webkit-line-clamp: 1; 486 | -webkit-box-orient: vertical; 487 | } 488 | .music-bar-time { 489 | position: absolute; 490 | top: 0; 491 | right: 5px; 492 | } 493 | } 494 | .mode, 495 | .comment, 496 | .music-bar-volume { 497 | margin-left: 20px; 498 | } 499 | 500 | // 音量控制 501 | .volume-wrapper { 502 | margin-left: 20px; 503 | width: 150px; 504 | } 505 | } 506 | 507 | /*遮罩*/ 508 | .mmPlayer-mask, 509 | .mmPlayer-bg { 510 | position: absolute; 511 | top: 0; 512 | right: 0; 513 | left: 0; 514 | bottom: 0; 515 | } 516 | 517 | .mmPlayer-mask { 518 | z-index: -1; 519 | background-color: @mask_color; 520 | } 521 | 522 | .mmPlayer-bg { 523 | z-index: -2; 524 | background-repeat: no-repeat; 525 | background-size: cover; 526 | background-position: 50%; 527 | filter: blur(12px); 528 | opacity: 0.7; 529 | transition: all 0.8s; 530 | transform: scale(1.1); 531 | } 532 | 533 | @media (min-width: 960px) { 534 | .close-lyric { 535 | display: none; 536 | } 537 | } 538 | 539 | //当屏幕小于960时 540 | @media (max-width: 960px) { 541 | .music-right { 542 | display: none; 543 | &.show { 544 | display: block; 545 | margin-left: 0; 546 | width: 100%; 547 | } 548 | } 549 | } 550 | //当屏幕小于768时 551 | @media (max-width: 768px) { 552 | padding: 75px 15px 5px 15px; 553 | 554 | .music-bar { 555 | padding-top: 10px; 556 | .music-bar-info span, 557 | .music-bar-volume .mmProgress { 558 | display: none; 559 | } 560 | } 561 | } 562 | //当屏幕小于520时 563 | @media (max-width: 520px) { 564 | .music-bar { 565 | position: relative; 566 | flex-direction: column; 567 | .music-bar-btns { 568 | width: 60%; 569 | margin-top: 10px; 570 | order: 2; 571 | } 572 | .music-music { 573 | padding-left: 0; 574 | order: 1; 575 | } 576 | .mode, 577 | .comment { 578 | position: absolute; 579 | bottom: 20px; 580 | margin: 0; 581 | } 582 | .mode { 583 | left: 5px; 584 | } 585 | .comment { 586 | right: 5px; 587 | } 588 | .music-bar-volume { 589 | display: none; 590 | } 591 | } 592 | } 593 | } 594 | </style> 595 | -------------------------------------------------------------------------------- /src/pages/playList/playList.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--正在播放--> 3 | <div class="playList"> 4 | <music-list :list="playlist" list-type="duration" @select="selectItem" @del="deleteItem"> 5 | <div slot="listBtn" class="list-btn"> 6 | <span @click="$refs.dialog.show()">清空列表</span> 7 | </div> 8 | </music-list> 9 | <mm-dialog 10 | ref="dialog" 11 | body-text="是否清空正在播放列表" 12 | confirm-btn-text="清空" 13 | @confirm="clearList" 14 | /> 15 | </div> 16 | </template> 17 | 18 | <script> 19 | import { mapGetters, mapMutations, mapActions } from 'vuex' 20 | import MusicList from 'components/music-list/music-list' 21 | import MmDialog from 'base/mm-dialog/mm-dialog' 22 | 23 | export default { 24 | name: 'PlayList', 25 | components: { 26 | MusicList, 27 | MmDialog, 28 | }, 29 | data() { 30 | return { 31 | show: false, 32 | } 33 | }, 34 | computed: { 35 | ...mapGetters(['playing', 'playlist', 'currentMusic']), 36 | }, 37 | methods: { 38 | // 清空列表事件 39 | clearList() { 40 | this.clearPlayList() 41 | this.$mmToast('列表清空成功') 42 | }, 43 | // 播放暂停事件 44 | selectItem(item, index) { 45 | if (item.id !== this.currentMusic.id) { 46 | this.setCurrentIndex(index) 47 | this.setPlaying(true) 48 | } 49 | }, 50 | // 删除事件 51 | deleteItem(index) { 52 | let list = [...this.playlist] 53 | list.splice(index, 1) 54 | this.removerPlayListItem({ list, index }) 55 | this.$mmToast('删除成功') 56 | }, 57 | ...mapMutations({ 58 | setPlaying: 'SET_PLAYING', 59 | setCurrentIndex: 'SET_CURRENTINDEX', 60 | clearPlaylist: 'CLEAR_PLAYLIST', 61 | }), 62 | ...mapActions(['removerPlayListItem', 'clearPlayList']), 63 | }, 64 | } 65 | </script> 66 | -------------------------------------------------------------------------------- /src/pages/search/search.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--搜索--> 3 | <div class="search flex-col"> 4 | <mm-loading v-model="mmLoadShow" /> 5 | <div class="search-head"> 6 | <span v-for="(item, index) in Artists" :key="index" @click="clickHot(item.first)"> 7 | {{ item.first }} 8 | </span> 9 | <input 10 | v-model.trim="searchValue" 11 | class="search-input" 12 | type="text" 13 | placeholder="音乐/歌手" 14 | @keyup.enter="onEnter" 15 | /> 16 | </div> 17 | <div class="flex-1 overflow-hidden"> 18 | <music-list 19 | ref="musicList" 20 | :list="list" 21 | list-type="pullup" 22 | @select="selectItem" 23 | @pullUp="pullUpLoad" 24 | /> 25 | </div> 26 | </div> 27 | </template> 28 | 29 | <script> 30 | import { mapGetters, mapActions, mapMutations } from 'vuex' 31 | import { search, searchHot, getMusicDetail } from 'api' 32 | import { formatSongs } from '@/utils/song' 33 | import MmLoading from 'base/mm-loading/mm-loading' 34 | import MusicList from 'components/music-list/music-list' 35 | import { loadMixin } from '@/utils/mixin' 36 | import { toHttps } from '@/utils/util' 37 | 38 | export default { 39 | name: 'Search', 40 | components: { 41 | MmLoading, 42 | MusicList, 43 | }, 44 | mixins: [loadMixin], 45 | data() { 46 | return { 47 | searchValue: '', // 搜索关键词 48 | Artists: [], // 热搜数组 49 | list: [], // 搜索数组 50 | page: 0, // 分页 51 | lockUp: true, // 是否锁定上拉加载事件,默认锁定 52 | } 53 | }, 54 | computed: { 55 | ...mapGetters(['playing', 'currentMusic']), 56 | }, 57 | watch: { 58 | list(newList, oldList) { 59 | if (newList.length !== oldList.length) { 60 | this.lockUp = false 61 | } else if (newList[newList.length - 1].id !== oldList[oldList.length - 1].id) { 62 | this.lockUp = false 63 | } 64 | }, 65 | }, 66 | created() { 67 | // 获取热搜 68 | searchHot().then(({ result }) => { 69 | this.Artists = result.hots.slice(0, 5) 70 | this.mmLoadShow = false 71 | }) 72 | }, 73 | methods: { 74 | // 点击热搜 75 | clickHot(name) { 76 | this.searchValue = name 77 | this.onEnter() 78 | }, 79 | // 搜索事件 80 | onEnter() { 81 | if (this.searchValue.replace(/(^\s+)|(\s+$)/g, '') === '') { 82 | this.$mmToast('搜索内容不能为空!') 83 | return 84 | } 85 | this.mmLoadShow = true 86 | this.page = 0 87 | if (this.list.length > 0) { 88 | this.$refs.musicList.scrollTo() 89 | } 90 | search(this.searchValue).then(({ result }) => { 91 | this.list = formatSongs(result.songs) 92 | this._hideLoad() 93 | }) 94 | }, 95 | // 滚动加载事件 96 | pullUpLoad() { 97 | this.page += 1 98 | search(this.searchValue, this.page).then(({ result }) => { 99 | if (!result.songs) { 100 | this.$mmToast('没有更多歌曲啦!') 101 | return 102 | } 103 | this.list = [...this.list, ...formatSongs(result.songs)] 104 | }) 105 | }, 106 | // 播放歌曲 107 | async selectItem(music) { 108 | try { 109 | const image = await this._getMusicDetail(music.id) 110 | music.image = toHttps(image) 111 | this.selectAddPlay(music) 112 | } catch (error) { 113 | this.$mmToast('哎呀,出错啦~') 114 | } 115 | }, 116 | // 获取歌曲详情 117 | _getMusicDetail(id) { 118 | return getMusicDetail(id).then((res) => res.songs[0].al.picUrl) 119 | }, 120 | ...mapMutations({ 121 | setPlaying: 'SET_PLAYING', 122 | }), 123 | ...mapActions(['selectAddPlay']), 124 | }, 125 | } 126 | </script> 127 | 128 | <style lang="less" scoped> 129 | .search { 130 | overflow: hidden; 131 | height: 100%; 132 | .search-head { 133 | display: flex; 134 | height: 40px; 135 | padding: 10px 15px; 136 | overflow: hidden; 137 | background: @search_bg_color; 138 | span { 139 | line-height: 40px; 140 | margin-right: 15px; 141 | cursor: pointer; 142 | &:hover { 143 | color: @text_color_active; 144 | } 145 | @media (max-width: 640px) { 146 | & { 147 | display: none; 148 | } 149 | } 150 | } 151 | .search-input { 152 | flex: 1; 153 | height: 40px; 154 | box-sizing: border-box; 155 | padding: 0 15px; 156 | border: 1px solid @btn_color; 157 | outline: 0; 158 | background: transparent; 159 | color: @text_color_active; 160 | font-size: @font_size_medium; 161 | box-shadow: 0 0 1px 0 #fff inset; 162 | &::placeholder { 163 | color: @text_color; 164 | } 165 | } 166 | } 167 | } 168 | </style> 169 | -------------------------------------------------------------------------------- /src/pages/topList/topList.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--排行榜--> 3 | <div class="topList"> 4 | <mm-loading v-model="mmLoadShow" /> 5 | <template v-if="!mmLoadShow"> 6 | <div class="topList-head">云音乐特色榜</div> 7 | <div class="topList-content"> 8 | <div 9 | v-for="(item, index) in list" 10 | :key="index" 11 | class="list-item" 12 | :title="`${item.name}-${item.updateFrequency}`" 13 | > 14 | <router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="topList-item"> 15 | <div class="topList-img"> 16 | <img v-lazy="`${item.coverImgUrl}?param=300y300`" class="cover-img" /> 17 | </div> 18 | <h3 class="name">{{ item.name }}</h3> 19 | </router-link> 20 | </div> 21 | </div> 22 | <div class="topList-head">热门歌单</div> 23 | <div class="topList-content"> 24 | <div v-for="(item, index) in hotList" :key="index" class="list-item" :title="item.name"> 25 | <router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="topList-item"> 26 | <div class="topList-img"> 27 | <img v-lazy="`${item.picUrl}?param=300y300`" class="cover-img" /> 28 | </div> 29 | <h3 class="name">{{ item.name }}</h3> 30 | </router-link> 31 | </div> 32 | </div> 33 | </template> 34 | </div> 35 | </template> 36 | 37 | <script> 38 | import { getToplistDetail, getPersonalized } from 'api' 39 | import MmLoading from 'base/mm-loading/mm-loading' 40 | import { loadMixin } from '@/utils/mixin' 41 | 42 | export default { 43 | name: 'PlayList', 44 | components: { 45 | MmLoading, 46 | }, 47 | mixins: [loadMixin], 48 | data() { 49 | return { 50 | list: [], // 云音乐特色榜 51 | hotList: [], // 热门歌单 52 | } 53 | }, 54 | created() { 55 | Promise.all([getToplistDetail(), getPersonalized()]) 56 | .then(([topList, hotList]) => { 57 | this.list = topList.list.filter((v) => v.ToplistType) 58 | this.hotList = hotList.result.slice() 59 | this._hideLoad() 60 | }) 61 | .catch(() => {}) 62 | }, 63 | } 64 | </script> 65 | 66 | <style lang="less" scoped> 67 | .topList { 68 | overflow-x: hidden; 69 | overflow-y: auto; 70 | -webkit-overflow-scrolling: touch; 71 | &-head { 72 | width: 100%; 73 | height: 34px; 74 | line-height: 34px; 75 | padding: 20px 0; 76 | font-size: @font_size_large; 77 | color: @text_color_active; 78 | } 79 | &-content { 80 | overflow: hidden; 81 | } 82 | .list-item { 83 | float: left; 84 | width: calc(~'100% / 7'); 85 | .topList-item { 86 | width: 130px; 87 | text-align: center; 88 | cursor: pointer; 89 | margin: 0 auto 20px; 90 | &:hover { 91 | color: #fff; 92 | } 93 | .name { 94 | height: 30px; 95 | line-height: 30px; 96 | font-size: @font_size_medium; 97 | .no-wrap(); 98 | } 99 | @media (max-width: 1100px) { 100 | width: 80%; 101 | } 102 | } 103 | @media (max-width: 1500px) { 104 | width: calc(~'100% / 6'); 105 | } 106 | @media (max-width: 1400px), (max-width: 960px) { 107 | width: calc(~'100% / 5'); 108 | } 109 | @media (max-width: 1280px), (max-width: 768px) { 110 | width: calc(~'100% / 4'); 111 | } 112 | @media (max-width: 540px) { 113 | width: calc(~'100% / 3'); 114 | } 115 | .topList-img { 116 | position: relative; 117 | padding-top: 100%; 118 | width: 100%; 119 | height: 0; 120 | .cover-img { 121 | position: absolute; 122 | top: 0; 123 | left: 0; 124 | } 125 | } 126 | } 127 | } 128 | </style> 129 | -------------------------------------------------------------------------------- /src/pages/userList/userList.vue: -------------------------------------------------------------------------------- 1 | <template> 2 | <!--我的歌单--> 3 | <div class="userList"> 4 | <mm-loading v-model="mmLoadShow" /> 5 | <template v-if="list.length > 0"> 6 | <div v-for="item in formatList" :key="item.id" class="list-item" :title="item.name"> 7 | <router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="userList-item"> 8 | <img v-lazy="`${item.coverImgUrl}?param=200y200`" class="cover-img" /> 9 | <h3 class="name">{{ item.name }}</h3> 10 | </router-link> 11 | </div> 12 | </template> 13 | <mm-no-result v-else title="啥也没有哦,快去登录看看吧!" /> 14 | </div> 15 | </template> 16 | 17 | <script> 18 | import { mapGetters } from 'vuex' 19 | 20 | import { getUserPlaylist } from 'api' 21 | import { loadMixin } from '@/utils/mixin' 22 | 23 | import MmLoading from 'base/mm-loading/mm-loading' 24 | import MmNoResult from 'base/mm-no-result/mm-no-result' 25 | 26 | export default { 27 | name: 'PlayList', 28 | components: { 29 | MmLoading, 30 | MmNoResult, 31 | }, 32 | mixins: [loadMixin], 33 | data() { 34 | return { 35 | list: [], // 列表 36 | } 37 | }, 38 | computed: { 39 | formatList() { 40 | return this.list.filter((item) => item.trackCount > 0) 41 | }, 42 | ...mapGetters(['uid']), 43 | }, 44 | watch: { 45 | uid(newUid) { 46 | if (newUid) { 47 | this.mmLoadShow = true 48 | this._getUserPlaylist(newUid) 49 | } else { 50 | this.list = [] 51 | } 52 | }, 53 | }, 54 | created() { 55 | if (!this.uid) { 56 | this.mmLoadShow = false 57 | } 58 | }, 59 | activated() { 60 | if (this.uid && this.list.length === 0) { 61 | this.mmLoadShow = true 62 | this._getUserPlaylist(this.uid) 63 | } else if (!this.uid && this.list.length !== 0) { 64 | this.list = [] 65 | } 66 | }, 67 | methods: { 68 | // 获取我的歌单详情 69 | _getUserPlaylist(uid) { 70 | getUserPlaylist(uid).then((res) => { 71 | if (res.playlist.length === 0) { 72 | return 73 | } 74 | this.list = res.playlist.slice(1) 75 | this._hideLoad() 76 | }) 77 | }, 78 | }, 79 | } 80 | </script> 81 | 82 | <style lang="less" scoped> 83 | .userList { 84 | overflow-x: hidden; 85 | overflow-y: auto; 86 | -webkit-overflow-scrolling: touch; 87 | &-head { 88 | height: 100px; 89 | } 90 | .list-item { 91 | float: left; 92 | width: calc(~'100% / 7'); 93 | .userList-item { 94 | width: 130px; 95 | text-align: center; 96 | cursor: pointer; 97 | margin: 0 auto 20px; 98 | &:hover { 99 | color: #fff; 100 | } 101 | .name { 102 | height: 30px; 103 | line-height: 30px; 104 | font-size: @font_size_medium; 105 | .no-wrap(); 106 | } 107 | @media (max-width: 1100px) { 108 | width: 80%; 109 | } 110 | } 111 | @media (max-width: 1500px) { 112 | width: calc(~'100% / 6'); 113 | } 114 | @media (max-width: 1400px), (max-width: 960px) { 115 | width: calc(~'100% / 5'); 116 | } 117 | @media (max-width: 1280px), (max-width: 768px) { 118 | width: calc(~'100% / 4'); 119 | } 120 | @media (max-width: 540px) { 121 | width: calc(~'100% / 3'); 122 | } 123 | } 124 | } 125 | </style> 126 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | Vue.use(Router) 4 | 5 | const routes = [ 6 | { 7 | path: '/', 8 | redirect: '/music', 9 | }, 10 | { 11 | path: '/music', 12 | component: () => import('pages/music'), 13 | redirect: '/music/playlist', 14 | children: [ 15 | { 16 | path: '/music/playlist', // 正在播放列表 17 | component: () => import('pages/playList/playList'), 18 | meta: { 19 | keepAlive: true, 20 | }, 21 | }, 22 | { 23 | path: '/music/userlist', // 我的歌单 24 | component: () => import('pages/userList/userList'), 25 | meta: { 26 | title: '我的歌单', 27 | keepAlive: true, 28 | }, 29 | }, 30 | { 31 | path: '/music/toplist', // 排行榜列表 32 | component: () => import('pages/topList/topList'), 33 | meta: { 34 | title: '排行榜', 35 | keepAlive: true, 36 | }, 37 | }, 38 | { 39 | path: '/music/details/:id', // 音乐详情列表 40 | component: () => import('pages/details/details'), 41 | }, 42 | { 43 | path: '/music/historylist', // 我听过的列表 44 | component: () => import('pages/historyList/historyList'), 45 | meta: { 46 | title: '我听过的', 47 | }, 48 | }, 49 | { 50 | path: '/music/search', // 搜索 51 | component: () => import('pages/search/search'), 52 | meta: { 53 | title: '搜索', 54 | keepAlive: true, 55 | }, 56 | }, 57 | { 58 | path: '/music/comment/:id', // 音乐评论 59 | component: () => import('pages/comment/comment'), 60 | meta: { 61 | title: '评论详情', 62 | }, 63 | }, 64 | ], 65 | }, 66 | ] 67 | 68 | export default new Router({ 69 | linkActiveClass: 'active', 70 | linkExactActiveClass: 'active', 71 | routes, 72 | }) 73 | -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | clearHistoryList, 3 | setHistoryList, 4 | removeHistoryList, 5 | setMode, 6 | setUserId, 7 | } from '@/utils/storage' 8 | import * as types from './mutation-types' 9 | 10 | function findIndex(list, music) { 11 | return list.findIndex((item) => { 12 | return item.id === music.id 13 | }) 14 | } 15 | 16 | // 设置播放列表 17 | export const setPlaylist = function ({ commit }, { list }) { 18 | commit(types.SET_PLAYLIST, list) 19 | commit(types.SET_ORDERLIST, list) 20 | } 21 | 22 | // 选择播放(会更新整个播放列表) 23 | export const selectPlay = function ({ commit }, { list, index }) { 24 | commit(types.SET_PLAYLIST, list) 25 | commit(types.SET_ORDERLIST, list) 26 | commit(types.SET_CURRENTINDEX, index) 27 | commit(types.SET_PLAYING, true) 28 | } 29 | // 选择播放(会插入一条到播放列表) 30 | export const selectAddPlay = function ({ commit, state }, music) { 31 | let list = [...state.playlist] 32 | // 查询当前播放列表是否有代插入的音乐,并返回其索引值 33 | let index = findIndex(list, music) 34 | // 当前播放列表有待插入的音乐时,直接改变当前播放音乐的索引值 35 | if (index > -1) { 36 | commit(types.SET_CURRENTINDEX, index) 37 | } else { 38 | list.unshift(music) 39 | commit(types.SET_PLAYLIST, list) 40 | commit(types.SET_ORDERLIST, list) 41 | commit(types.SET_CURRENTINDEX, 0) 42 | } 43 | commit(types.SET_PLAYING, true) 44 | } 45 | 46 | // 清空播放列表 47 | export const clearPlayList = function ({ commit }) { 48 | commit(types.SET_PLAYING, false) 49 | commit(types.SET_CURRENTINDEX, -1) 50 | commit(types.SET_PLAYLIST, []) 51 | commit(types.SET_ORDERLIST, []) 52 | } 53 | 54 | // 删除正在播放列表中的歌曲 55 | export const removerPlayListItem = function ({ commit, state }, { list, index }) { 56 | let currentIndex = state.currentIndex 57 | if (index < state.currentIndex || list.length === state.currentIndex) { 58 | currentIndex-- 59 | commit(types.SET_CURRENTINDEX, currentIndex) 60 | } 61 | commit(types.SET_PLAYLIST, list) 62 | commit(types.SET_ORDERLIST, list) 63 | if (!list.length) { 64 | commit(types.SET_PLAYING, false) 65 | } else { 66 | commit(types.SET_PLAYING, true) 67 | } 68 | } 69 | // 设置播放历史 70 | export const setHistory = function ({ commit }, music) { 71 | commit(types.SET_HISTORYLIST, setHistoryList(music)) 72 | } 73 | // 删除播放历史 74 | export const removeHistory = function ({ commit }, music) { 75 | commit(types.SET_HISTORYLIST, removeHistoryList(music)) 76 | } 77 | // 清空播放历史 78 | export const clearHistory = function ({ commit }) { 79 | commit(types.SET_HISTORYLIST, clearHistoryList()) 80 | } 81 | // 设置播放模式 82 | export const setPlayMode = function ({ commit }, mode) { 83 | commit(types.SET_PLAYMODE, setMode(mode)) 84 | } 85 | // 设置网易云用户UID 86 | export const setUid = function ({ commit }, uid) { 87 | commit(types.SET_UID, setUserId(uid)) 88 | } 89 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | // audio元素 2 | export const audioEle = (state) => state.audioEle 3 | // 播放模式 4 | export const mode = (state) => state.mode 5 | // 播放状态 6 | export const playing = (state) => state.playing 7 | // 播放列表 8 | export const playlist = (state) => state.playlist 9 | // 顺序列表 10 | export const orderList = (state) => state.orderList 11 | // 当前音乐索引 12 | export const currentIndex = (state) => state.currentIndex 13 | // 当前音乐 14 | export const currentMusic = (state) => { 15 | return state.playlist[state.currentIndex] || {} 16 | } 17 | // 播放历史列表 18 | export const historyList = (state) => state.historyList 19 | // 网易云用户UID 20 | export const uid = (state) => state.uid 21 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import state from './state' 4 | import * as getters from './getters' 5 | import * as actions from './actions' 6 | import mutations from './mutations' 7 | // vuex调试 8 | import createLogger from 'vuex/dist/logger' 9 | const debug = process.env.NODE_ENV !== 'production' 10 | 11 | Vue.use(Vuex) 12 | 13 | export default new Vuex.Store({ 14 | state, 15 | getters, 16 | mutations, 17 | actions, 18 | // vuex调试 19 | strict: debug, 20 | plugins: debug ? [createLogger()] : [], 21 | }) 22 | -------------------------------------------------------------------------------- /src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | export const SET_AUDIOELE = 'SET_AUDIOELE' // 修改audio元素 2 | export const SET_PLAYMODE = 'SET_PLAYMODE' // 修改播放模式 3 | export const SET_PLAYING = 'SET_PLAYING' // 修改播放状态 4 | export const SET_PLAYLIST = 'SET_PLAYLIST' // 修改播放列表 5 | export const SET_ORDERLIST = 'SET_ORDERLIST' // 修改顺序列表 6 | export const SET_CURRENTINDEX = 'SET_CURRENTINDEX' // 修改当前音乐索引 7 | export const SET_HISTORYLIST = 'SET_HISTORYLIST' // 修改播放历史列表 8 | export const SET_UID = 'SET_UID' // 修改网易云用户UID 9 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | import * as types from './mutation-types' 2 | 3 | const mutations = { 4 | // 修改audio元素 5 | [types.SET_AUDIOELE](state, audioEle) { 6 | state.audioEle = audioEle 7 | }, 8 | // 修改播放模式 9 | [types.SET_PLAYMODE](state, mode) { 10 | state.mode = mode 11 | }, 12 | // 修改播放状态 13 | [types.SET_PLAYING](state, playing) { 14 | state.playing = playing 15 | }, 16 | // 修改播放列表 17 | [types.SET_PLAYLIST](state, playlist) { 18 | state.playlist = playlist 19 | }, 20 | // 修改顺序列表 21 | [types.SET_ORDERLIST](state, orderList) { 22 | state.orderList = orderList 23 | }, 24 | // 修改当前音乐索引 25 | [types.SET_CURRENTINDEX](state, currentIndex) { 26 | state.currentIndex = currentIndex 27 | }, 28 | // 修改播放历史列表 29 | [types.SET_HISTORYLIST](state, historyList) { 30 | state.historyList = historyList 31 | }, 32 | // 修改网易云用户UID 33 | [types.SET_UID](state, uid) { 34 | state.uid = uid 35 | }, 36 | } 37 | 38 | export default mutations 39 | -------------------------------------------------------------------------------- /src/store/state.js: -------------------------------------------------------------------------------- 1 | import { getHistoryList, getMode, getUserId } from '@/utils/storage' 2 | 3 | const state = { 4 | audioEle: null, // audio元素 5 | mode: getMode(), // 播放模式,默认列表循环 6 | playing: false, // 播放状态 7 | playlist: [], // 播放列表 8 | orderList: [], // 顺序列表 9 | currentIndex: -1, // 当前音乐索引 10 | historyList: getHistoryList() || [], // 播放历史列表 11 | uid: getUserId() || null, // 网易云用户UID 12 | } 13 | 14 | export default state 15 | -------------------------------------------------------------------------------- /src/styles/index.less: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | @import 'var'; 3 | 4 | html, 5 | body, 6 | #app { 7 | width: 100%; 8 | height: 100%; 9 | overflow: hidden; 10 | } 11 | 12 | body { 13 | min-width: 320px; 14 | font-family: Arial; 15 | } 16 | 17 | #app { 18 | position: relative; 19 | } 20 | 21 | .cover-img { 22 | width: 100%; 23 | height: 100%; 24 | object-fit: cover; 25 | } 26 | 27 | //浮动 28 | .fl { 29 | float: left; 30 | } 31 | 32 | .fr { 33 | float: right; 34 | } 35 | 36 | .pointer { 37 | cursor: pointer; 38 | } 39 | 40 | .hover { 41 | color: @text_color; 42 | cursor: pointer; 43 | &:hover { 44 | color: @text_color_active; 45 | } 46 | } 47 | 48 | .text-left { 49 | text-align: left; 50 | } 51 | 52 | .clearfix { 53 | &:after { 54 | display: block; 55 | content: ''; 56 | clear: both; 57 | } 58 | } 59 | 60 | .flex { 61 | display: flex; 62 | } 63 | 64 | .flex-col { 65 | display: flex; 66 | flex-direction: column; 67 | } 68 | 69 | .flex-1 { 70 | flex: 1; 71 | } 72 | 73 | .overflow-hidden { 74 | overflow: hidden; 75 | } 76 | 77 | /* 滚动条相关样式 */ 78 | ::-webkit-scrollbar { 79 | /*滚动条整体部分,其中的属性有width,height,background,border(就和一个块级元素一样)等*/ 80 | background-color: @scrollbar_bg; 81 | width: @scrollbar_size; // 纵向滚动条 82 | height: @scrollbar_size; // 横向滚动条 83 | border-radius: @scrollbar_border_radius; 84 | } 85 | 86 | ::-webkit-scrollbar-button { 87 | /*滚动条两端的按钮。可以用display:none让其不显示,也可以添加背景图片,颜色改变显示效果。*/ 88 | display: none; 89 | } 90 | 91 | ::-webkit-scrollbar-track { 92 | /*外层轨道。可以用display:none让其不显示,也可以添加背景图片,颜色改变显示效果。*/ 93 | display: none; 94 | //background-color: rgba(255, 255, 255, 0.1); 95 | border-radius: @scrollbar_border_radius; 96 | } 97 | 98 | ::-webkit-scrollbar-track-piece { 99 | /*内层轨道,滚动条中间部分(除去)。*/ 100 | //background-color: rgba(255, 255, 255, .1); 101 | border-radius: @scrollbar_border_radius; 102 | } 103 | 104 | ::-webkit-scrollbar-thumb { 105 | /*滚动条里面可以拖动的那部分*/ 106 | background-color: @scrollbar_thumb; 107 | border-radius: @scrollbar_border_radius; 108 | } 109 | 110 | ::-webkit-scrollbar-corner { 111 | border-radius: @scrollbar_border_radius; 112 | } 113 | 114 | ::-webkit-resizer { 115 | /*定义右下角拖动块的样式*/ 116 | border-radius: @scrollbar_border_radius; 117 | } 118 | -------------------------------------------------------------------------------- /src/styles/mixin.less: -------------------------------------------------------------------------------- 1 | // 显示省略号 2 | .no-wrap() { 3 | text-overflow: ellipsis; 4 | overflow: hidden; 5 | white-space: nowrap; 6 | } 7 | 8 | .flex-center(@direction: row) { 9 | display: flex; 10 | flex-direction: @direction; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | -------------------------------------------------------------------------------- /src/styles/reset.less: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | margin: 0; 83 | padding: 0; 84 | border: 0; 85 | font-size: 100%; 86 | font: inherit; 87 | vertical-align: baseline; 88 | -webkit-tap-highlight-color: transparent; 89 | } 90 | 91 | /* HTML5 display-role reset for older browsers */ 92 | article, 93 | aside, 94 | details, 95 | figcaption, 96 | figure, 97 | footer, 98 | header, 99 | hgroup, 100 | menu, 101 | nav, 102 | section { 103 | display: block; 104 | } 105 | 106 | body { 107 | line-height: 1; 108 | } 109 | 110 | ol, 111 | ul { 112 | list-style: none; 113 | } 114 | 115 | blockquote, 116 | q { 117 | quotes: none; 118 | } 119 | 120 | blockquote:before, 121 | blockquote:after, 122 | q:before, 123 | q:after { 124 | content: ''; 125 | content: none; 126 | } 127 | 128 | table { 129 | border-collapse: collapse; 130 | border-spacing: 0; 131 | } 132 | 133 | a { 134 | text-decoration: none; 135 | color: inherit; 136 | } 137 | 138 | input[type='number']::-webkit-inner-spin-button, 139 | input[type='number']::-webkit-outer-spin-button { 140 | -webkit-appearance: none; 141 | } 142 | -------------------------------------------------------------------------------- /src/styles/var.less: -------------------------------------------------------------------------------- 1 | /* 颜色规范定义 */ 2 | @text_color: rgba(255, 255, 255, 0.6); 3 | @text_color_active: #fff; //重点部分 4 | 5 | // active 颜色 6 | @active_color: #fff; 7 | 8 | // 遮罩层颜色 9 | @mask_color: rgba(0, 0, 0, 0.4); 10 | // 背景滤镜 11 | @backdrop_filter: blur(6px); 12 | 13 | /* 字体大小规范定义*/ 14 | @font_size_small: 12px; 15 | @font_size_medium: 14px; 16 | @font_size_medium_x: 16px; 17 | @font_size_large: 18px; 18 | @font_size_large_x: 22px; 19 | 20 | /* 圆角规范定义 */ 21 | @border_radius_base: 2px; 22 | @border_radius: 4px; 23 | 24 | /* 滚动条相关 */ 25 | // 滚动条圆角 26 | @scrollbar_border_radius: 10px; 27 | // 滚动条大小 28 | @scrollbar_size: 5px; 29 | @scrollbar_bg: rgba(0, 0, 0, 0.3); 30 | @scrollbar_thumb: rgba(255, 255, 255, 0.5); 31 | 32 | /* loading */ 33 | @load_bg_color: rgba(0, 0, 0, 0.2); 34 | 35 | /* header */ 36 | @header_bg_color: rgba(0, 0, 0, 0.3); 37 | 38 | /* search-head */ 39 | @search_bg_color: rgba(0, 0, 0, 0.2); 40 | 41 | /* dialog 相关 */ 42 | @dialog_bg_color: rgba(0, 0, 0, 0.5); 43 | @dialog_content_bg_color: rgba(0, 0, 0, 0.6); 44 | @dialog_text_color: rgba(255, 255, 255, 0.7); 45 | @dialog_line_color: rgba(0, 0, 0, 0.35); 46 | @dialog_border_radius: @border_radius; 47 | @dialog_btn_border_radius: @border_radius; 48 | @dialog_mobile_border_radius: 10px; 49 | @dialog_btn_mobile_border_radius: @border_radius_base; 50 | 51 | /* btn 相关 */ 52 | @btn_color: rgba(255, 255, 255, 0.6); 53 | @btn_color_active: #fff; 54 | @btn_border_radius: @border_radius_base; 55 | 56 | /* 歌词高亮颜色 */ 57 | @lyric_color_active: #40ce8f; 58 | 59 | /* 进度条 */ 60 | @bar_color: rgba(255, 255, 255, 0.15); 61 | @line_color: #fff; 62 | @dot_color: #fff; 63 | 64 | /* 列表 */ 65 | @list_head_line_color: rgba(255, 255, 255, 0.8); 66 | @list_item_line_color: rgba(255, 255, 255, 0.1); 67 | 68 | /* 评论 */ 69 | @comment_head_line_color: rgba(255, 255, 255, 0.8); 70 | @comment_item_line_color: rgba(255, 255, 255, 0.1); 71 | @comment_replied_line_color: rgba(255, 255, 255, 0.3); 72 | -------------------------------------------------------------------------------- /src/utils/axios.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Vue from 'vue' 3 | 4 | const request = axios.create({ 5 | baseURL: process.env.VUE_APP_BASE_API_URL, 6 | }) 7 | 8 | request.interceptors.response.use( 9 | (response) => { 10 | window.response = response 11 | 12 | if (response.status === 200 && response.data.code === 200) { 13 | return response.data 14 | } 15 | return Promise.reject(response) 16 | }, 17 | (error) => { 18 | Vue.prototype.$mmToast(error.response ? error.response.data.message : error.message) 19 | return error 20 | }, 21 | ) 22 | 23 | export default request 24 | -------------------------------------------------------------------------------- /src/utils/hack.js: -------------------------------------------------------------------------------- 1 | // hack for global nextTick 2 | 3 | function noop() {} 4 | 5 | window.MessageChannel = noop 6 | window.setImmediate = noop 7 | -------------------------------------------------------------------------------- /src/utils/mixin.js: -------------------------------------------------------------------------------- 1 | import { mapGetters, mapMutations, mapActions } from 'vuex' 2 | 3 | /** 4 | * 歌曲列表 5 | */ 6 | export const listMixin = { 7 | computed: { 8 | ...mapGetters(['playing', 'currentMusic']), 9 | }, 10 | methods: { 11 | selectItem(item, index) { 12 | if (item.id === this.currentMusic.id && this.playing) { 13 | this.setPlaying(false) 14 | } else { 15 | this.selectPlay({ 16 | list: this.list, 17 | index, 18 | }) 19 | } 20 | }, 21 | ...mapMutations({ 22 | setPlaying: 'SET_PLAYING', 23 | }), 24 | ...mapActions(['selectPlay']), 25 | }, 26 | } 27 | 28 | /** 29 | * loading状态 30 | * @type {{data(): *, methods: {_hideLoad(): void}}} 31 | */ 32 | export const loadMixin = { 33 | data() { 34 | return { 35 | mmLoadShow: true, // loading状态 36 | } 37 | }, 38 | methods: { 39 | _hideLoad() { 40 | let timer 41 | clearTimeout(timer) 42 | timer = setTimeout(() => { 43 | this.mmLoadShow = false 44 | }, 200) 45 | }, 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/song.js: -------------------------------------------------------------------------------- 1 | import { toHttps } from './util' 2 | 3 | function filterSinger(singers) { 4 | if (!Array.isArray(singers) || !singers.length) { 5 | return '' 6 | } 7 | let arr = [] 8 | singers.forEach((item) => { 9 | arr.push(item.name) 10 | }) 11 | return arr.join('/') 12 | } 13 | 14 | export class Song { 15 | constructor({ id, name, singer, album, image, duration, url }) { 16 | this.id = id 17 | this.name = name 18 | this.singer = singer 19 | this.album = album 20 | this.image = image 21 | this.duration = duration 22 | this.url = url 23 | } 24 | } 25 | 26 | export function createSong(music) { 27 | const album = music.album || music.al || {} 28 | const duration = music.duration || music.dt 29 | return new Song({ 30 | id: music.id, 31 | name: music.name, 32 | singer: filterSinger(music.ar || music.artists), 33 | album: album.name, 34 | image: toHttps(album.picUrl) || null, 35 | duration: duration / 1000, 36 | url: `https://music.163.com/song/media/outer/url?id=${music.id}.mp3`, 37 | }) 38 | } 39 | 40 | // 歌曲数据格式化 41 | export function formatSongs(list) { 42 | const Songs = [] 43 | list.forEach((item) => { 44 | const musicData = item 45 | if (musicData.id) { 46 | Songs.push(createSong(musicData)) 47 | } 48 | }) 49 | return Songs 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/storage.js: -------------------------------------------------------------------------------- 1 | import { MMPLAYER_CONFIG } from '@/config' 2 | 3 | const STORAGE = window.localStorage 4 | const storage = { 5 | get(key, data = []) { 6 | if (STORAGE) { 7 | return STORAGE.getItem(key) 8 | ? Array.isArray(data) 9 | ? JSON.parse(STORAGE.getItem(key)) 10 | : STORAGE.getItem(key) 11 | : data 12 | } 13 | }, 14 | set(key, val) { 15 | if (STORAGE) { 16 | STORAGE.setItem(key, val) 17 | } 18 | }, 19 | clear(key) { 20 | if (STORAGE) { 21 | STORAGE.removeItem(key) 22 | } 23 | }, 24 | } 25 | 26 | /** 27 | * 播放历史 28 | * @type HISTORYLIST_KEY:key值 29 | * HistoryListMAX:最大长度 30 | */ 31 | const HISTORYLIST_KEY = '__mmPlayer_historyList__' 32 | const HistoryListMAX = 200 33 | // 获取播放历史 34 | export function getHistoryList() { 35 | return storage.get(HISTORYLIST_KEY) 36 | } 37 | 38 | // 更新播放历史 39 | export function setHistoryList(music) { 40 | let list = storage.get(HISTORYLIST_KEY) 41 | const index = list.findIndex((item) => { 42 | return item.id === music.id 43 | }) 44 | if (index === 0) { 45 | return list 46 | } 47 | if (index > 0) { 48 | list.splice(index, 1) 49 | } 50 | list.unshift(music) 51 | if (HistoryListMAX && list.length > HistoryListMAX) { 52 | list.pop() 53 | } 54 | storage.set(HISTORYLIST_KEY, JSON.stringify(list)) 55 | return list 56 | } 57 | 58 | // 删除一条播放历史 59 | export function removeHistoryList(music) { 60 | storage.set(HISTORYLIST_KEY, JSON.stringify(music)) 61 | return music 62 | } 63 | 64 | // 清空播放历史 65 | export function clearHistoryList() { 66 | storage.clear(HISTORYLIST_KEY) 67 | return [] 68 | } 69 | 70 | /** 71 | * 播放模式 72 | * @type MODE_KEY:key值 73 | * HistoryListMAX:最大长度 74 | */ 75 | const MODE_KEY = '__mmPlayer_mode__' 76 | // 获取播放模式 77 | export function getMode() { 78 | return Number(storage.get(MODE_KEY, MMPLAYER_CONFIG.PLAY_MODE)) 79 | } 80 | // 修改播放模式 81 | export function setMode(mode) { 82 | storage.set(MODE_KEY, mode) 83 | return mode 84 | } 85 | 86 | /** 87 | * 网易云用户uid 88 | * @type USERID_KEY:key值 89 | */ 90 | const USERID_KEY = '__mmPlayer_userID__' 91 | // 获取用户uid 92 | export function getUserId() { 93 | return Number(storage.get(USERID_KEY, null)) 94 | } 95 | // 修改用户uid 96 | export function setUserId(uid) { 97 | storage.set(USERID_KEY, uid) 98 | return uid 99 | } 100 | 101 | /** 102 | * 版本号 103 | * @type VERSION_KEY:key值 104 | */ 105 | const VERSION_KEY = '__mmPlayer_version__' 106 | // 获取版本号 107 | export function getVersion() { 108 | let version = storage.get(VERSION_KEY, null) 109 | return Array.isArray(version) ? null : version 110 | } 111 | // 修改版本号 112 | export function setVersion(version) { 113 | storage.set(VERSION_KEY, version) 114 | return version 115 | } 116 | 117 | /** 118 | * 音量 119 | * @type VOLUME_KEY:key值 120 | */ 121 | const VOLUME_KEY = '__mmPlayer_volume__' 122 | // 获取音量 123 | export function getVolume() { 124 | const volume = storage.get(VOLUME_KEY, MMPLAYER_CONFIG.VOLUME) 125 | return Number(volume) 126 | } 127 | // 修改音量 128 | export function setVolume(volume) { 129 | storage.set(VOLUME_KEY, volume) 130 | return volume 131 | } 132 | -------------------------------------------------------------------------------- /src/utils/util.js: -------------------------------------------------------------------------------- 1 | // 随机排序数组/洗牌函数 https://github.com/lodash/lodash/blob/master/shuffle.js 2 | function copyArray(source, array) { 3 | let index = -1 4 | const length = source.length 5 | array || (array = new Array(length)) 6 | while (++index < length) { 7 | array[index] = source[index] 8 | } 9 | return array 10 | } 11 | 12 | export const randomSortArray = function shuffle(array) { 13 | const length = array == null ? 0 : array.length 14 | if (!length) { 15 | return [] 16 | } 17 | let index = -1 18 | const lastIndex = length - 1 19 | const result = copyArray(array) 20 | while (++index < length) { 21 | const rand = index + Math.floor(Math.random() * (lastIndex - index + 1)) 22 | const value = result[rand] 23 | result[rand] = result[index] 24 | result[index] = value 25 | } 26 | return result 27 | } 28 | 29 | // 防抖函数 30 | export function debounce(func, delay) { 31 | let timer 32 | return function (...args) { 33 | if (timer) { 34 | clearTimeout(timer) 35 | } 36 | timer = setTimeout(() => { 37 | func.apply(this, args) 38 | }, delay) 39 | } 40 | } 41 | 42 | // 补0函数 43 | export function addZero(s) { 44 | return s < 10 ? '0' + s : s 45 | } 46 | 47 | // 歌词解析 48 | const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]/g 49 | export function parseLyric(lrc) { 50 | const lines = lrc.split('\n') 51 | const lyric = [] 52 | for (let i = 0; i < lines.length; i++) { 53 | const line = lines[i] 54 | const result = timeExp.exec(line) 55 | if (!result) { 56 | continue 57 | } 58 | const text = line.replace(timeExp, '').trim() 59 | if (text) { 60 | lyric.push({ 61 | time: (result[1] * 6e4 + result[2] * 1e3 + (result[3] || 0) * 1) / 1e3, 62 | text, 63 | }) 64 | } 65 | } 66 | return lyric 67 | } 68 | 69 | // 时间格式化 70 | export function format(value) { 71 | let minute = Math.floor(value / 60) 72 | let second = Math.floor(value % 60) 73 | return `${addZero(minute)}:${addZero(second)}` 74 | } 75 | 76 | /** 77 | * https://github.com/videojs/video.js/blob/master/src/js/utils/promise.js 78 | * Silence a Promise-like object. 79 | * 80 | * This is useful for avoiding non-harmful, but potentially confusing "uncaught 81 | * play promise" rejection error messages. 82 | * 83 | * @param {Object} value 84 | * An object that may or may not be `Promise`-like. 85 | */ 86 | export function isPromise(v) { 87 | return v !== undefined && v !== null && typeof v.then === 'function' 88 | } 89 | 90 | export function silencePromise(value) { 91 | if (isPromise(value)) { 92 | value.then(null, () => {}) 93 | } 94 | } 95 | 96 | // 判断 string 类型 97 | export function isString(v) { 98 | return typeof v === 'string' 99 | } 100 | 101 | // http 链接转化成 https 102 | export function toHttps(url) { 103 | if (!isString(url)) { 104 | return url 105 | } 106 | return url.replace('http://', 'https://') 107 | } 108 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('@vue/cli-service') 2 | const path = require('path') 3 | const dayjs = require('dayjs') 4 | 5 | function resolve(dir) { 6 | return path.join(__dirname, dir) 7 | } 8 | 9 | const isEnvProduction = process.env.NODE_ENV === 'production' 10 | 11 | // 注入版本信息 12 | process.env.VUE_APP_VERSION = require('./package.json').version 13 | // 注入版本更新时间 14 | process.env.VUE_APP_UPDATE_TIME = dayjs().locale('zh-cn').format('YYYY-MM-DD') 15 | 16 | module.exports = defineConfig({ 17 | publicPath: '', 18 | chainWebpack(config) { 19 | config.resolve.alias 20 | .set('api', resolve('src/api')) 21 | .set('assets', resolve('src/assets')) 22 | .set('base', resolve('src/base')) 23 | .set('components', resolve('src/components')) 24 | .set('pages', resolve('src/pages')) 25 | config.plugin('html').tap((args) => { 26 | if (isEnvProduction) { 27 | if (!args[0].minify) { 28 | /* 参考 https://github.com/jantimon/html-webpack-plugin#minification */ 29 | args[0].minify = { 30 | collapseWhitespace: true, 31 | keepClosingSlash: true, 32 | removeComments: true, 33 | removeRedundantAttributes: true, 34 | removeScriptTypeAttributes: true, 35 | removeStyleLinkTypeAttributes: true, 36 | useShortDoctype: true, 37 | trimCustomFragments: true, 38 | } 39 | } 40 | args[0].minify.minifyJS = true 41 | args[0].minify.minifyCSS = true 42 | } 43 | return args 44 | }) 45 | }, 46 | pluginOptions: { 47 | 'style-resources-loader': { 48 | preProcessor: 'less', 49 | patterns: [resolve('src/styles/var.less'), resolve('src/styles/mixin.less')], 50 | }, 51 | }, 52 | }) 53 | --------------------------------------------------------------------------------