feat: 初始化项目

This commit is contained in:
= 2024-04-29 19:16:51 +08:00
commit 6489bc41cb
78 changed files with 12385 additions and 0 deletions

3
.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not ie <= 8

13
.editorconfig Normal file
View File

@ -0,0 +1,13 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

7
.env Normal file
View File

@ -0,0 +1,7 @@
# 前端实际请求的API
VUE_APP_BASE_API_URL=/api
# dev环境使用的api
VUE_APP_DEV_API_URL=http://192.168.31.208:3002
# 访客统计 id (默认不开启,设置值后展示)
VUE_APP_VISITOR_BADGE_ID=

39
.eslintrc.js Normal file
View File

@ -0,0 +1,39 @@
module.exports = {
root: true,
env: {
node: true,
},
parserOptions: {
parser: '@babel/eslint-parser',
},
extends: ['plugin:vue/recommended', 'prettier', 'plugin:prettier/recommended'],
rules: {
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': [
'error',
{
singleline: 10,
multiline: {
max: 1,
},
},
],
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': [
'warn',
{
html: {
void: 'always',
normal: 'never',
},
svg: 'never',
math: 'never',
},
],
'vue/multiline-html-element-content-newline': 'off',
'vue/name-property-casing': ['error', 'PascalCase'],
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
'no-sequences': 2,
},
}

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Editor directories and files
.idea
# dependencies
node_modules
# lock files
yarn.lock
package-lock.json
# log files
*-debug.log
*-error.log
# compile
dist
# local env files
.env.local
.env.*.local
# misc
.DS_Store

4
.husky/commit-msg Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx femm-verify-commit $1

4
.npmrc Normal file
View File

@ -0,0 +1,4 @@
auto-install-peers=true
strict-peer-dependencies=false
registry=https://registry.npmmirror.com

6
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"files.eol": "\n"
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 yigencong
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

196
README.md Normal file
View File

@ -0,0 +1,196 @@
# wangyiyun(网抑云)
> wangyiyun 是一款web端的"缝合怪"播放器具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能此项目是对本人毕设项目的重新修改再发布之前的git记录太乱了并且上传过许多乱七八糟的大文件使得clone时会把大文件的上传记录给clone下来从2024年1月开始网易云开始严查NeteaseCloudMusicApi此项目已放入私人仓库。
模仿 QQ 音乐网页版界面,采用 `flexbox``position` 布局;<br />
wangyiyun 虽然是响应式,但主要以 PC 端为主,移动端只做相应适配;<br />
本项目没有做兼容性的处理基本上只要是现代浏览器都能访问IE除外
- [在线演示地址](https://music.icoding.fun/)
## 免责声明
1. 本项目是一个**毕业设计项目**,旨在**只用于完成毕业设计**。
2. 本项目**不提供任何音频存储和贩卖服务**。所有音频内容均由网易云音乐的第三方 API 提供,**仅供个人学习研究使用,严禁将其用于任何商业及非法用途**,版权归原始平台所有。
3. 使用本项目造成的任何纠纷、责任或损失**由使用者自行承担**。本项目开发者不对因使用本项目而产生的任何直接或间接责任承担责任,并保留追究使用者违法行为的权利。
4. **请使用者在使用本项目时遵守相关法律法规,不得将本项目用于任何商业及非法用途**。如有违反,一切后果由使用者自负。同时,使用者应该自行承担因使用本项目而带来的风险和责任。
5. 本项目使用了网易云音乐的[第三方 API 服务](https://github.com/Binaryify/NeteaseCloudMusicApi),对于该第三方 API 服务造成的任何问题,本项目开发者不承担责任。
6. 自2024年1月开始网易云已经将NeteaseCloudMusicApi封停之后再也不会更新此项目后期可能无法使用或者改为一款本地播放源的项目。
请在使用本项目之前仔细阅读以上免责声明,并确保您已完全理解并接受其中的所有条款和条件。如果您不同意或无法遵守这些规定,请不要使用本项目。
## 安装与使用
### 检查 node 版本
```sh
# 查看 node 版本,确保 node 版本高于 12 版本
node -v
```
### wangyiyun
```sh
# 下载 wangyiyun
git clone https://git.icoding.fun/yigencong/wangyiyun
# 进入 wangyiyun 播放器目录
cd wangyiyun
# 安装依赖 推荐使用 pnpm
pnpm install
# 或者
npm install
# 本地运行 wangyiyun
npm run serve
# 编译打包
npm run build
```
### 后台 api 服务(本地开发)
[网易云音乐 NodeJS 版 API](https://binaryify.github.io/NeteaseCloudMusicApi)
```sh
# 下载 NeteaseCloudMusicApi
git clone --depth=1 https://github.com/Binaryify/NeteaseCloudMusicApi
# 进入 NeteaseCloudMusicApi 后台服务目录
cd NeteaseCloudMusicApi
# 安装依赖
npm install
# 运行后台 api 服务 访问 http://localhost:3000
node app.js
```
### 注意点
**运行 wangyiyun 后无法获取音乐请检查后台 `api` 服务是否启动(即控制台请求报 404)**<br />
**线上部署不是直接将整个项目丢到服务器,再去运行 `npm run serve` 命令**<br />
**项目打包前 `VUE_APP_BASE_API_URL` 必须改后台 `api` 服务地址为线上地址,不能是本地地址**
### docker一键化部署
```sh
docker pull yigencong/wangyiyun:v1.0
docker run -d -p 3001:3001 --name wangyiyun yigencong/wangyiyun:v1.0
```
## 技术栈
- [Vue Cli](https://cli.vuejs.org/zh/) Vue 脚手架工具
- [Vue 2.x](https://v2.cn.vuejs.org/) 核心框架
- [Vue Router](https://router.vuejs.org/zh/) 页面路由
- [Vuex](https://vuex.vuejs.org/zh/) 状态管理
- ES6 JavaScript 语言的下一代标准)
- LessCSS 预处理器)
- Axios网络请求
- FastClick解决移动端 300ms 点击延迟)
## 项目结构目录图(使用 tree 生成)
<details>
<summary>展开查看</summary>
<pre><code>
├── public // 静态资源目录
│ └─index.html // 入口 html 文件
├── screenshots // 项目截图
├── src // 项目源码目录
│ ├── api // 数据交互目录
│ │ └── index.js // 获取数据
│ ├── assets // 资源目录
│ │ └── background // 启动背景图目录
│ │ └── img // 静态图片目录
│ ├── base // 公共基础组件目录
│ │ ├── wyy-dialog
│ │ │ └── wyy-dialog.vue // 对话框组件
│ │ ├── wyy-icon
│ │ │ └── wyy-icon.vue // icon 组件
│ │ ├── wyy-loading
│ │ │ └── wyy-loading.vue // 加载动画组件
│ │ ├── wyy-no-result
│ │ │ └── wyy-no-result.vue // 暂无数据提示组件
│ │ ├── wyy-progress
│ │ │ └── wyy-progress.vue // 进度条拖动组件
│ │ └── wyy-toast
│ │ ├── index.js // wyy-toast 组件插件化配置
│ │ └── wyy-toast.vue // 弹出层提示组件
│ ├── components // 公共项目组件目录
│ │ ├── lyric
│ │ │ └── lyric // 歌词和封面组件
│ │ └── wyy-header
│ │ │ └── wyy-header.vue // 头部组件
│ │ ├── music-btn
│ │ │ └── music-btn.vue // 按钮组件
│ │ ├── music-list
│ │ │ └── music-list.vue // 列表组件
│ │ └── volume
│ │ └── volume.vue // 音量控制组件
│ ├── pages // 页面组件目录
│ │ ├── comment
│ │ │ └── comment.vue // 评论
│ │ ├── details
│ │ │ └── details.vue // 排行榜详情
│ │ ├── historyList
│ │ │ └── historyList.vue // 我听过的(播放历史)
│ │ ├── playList
│ │ │ └── playList.vue // 正在播放
│ │ ├── search
│ │ │ └── search.vue // 搜索
│ │ ├── topList
│ │ │ └── topList.vue // 排行榜页面
│ │ ├── userList
│ │ │ └── userList.vue // 我的歌单
│ │ ├── wangyiyun.js // 播放器事相关件绑定
│ │ └── music.vue // 播放器主页面
│ ├── router
│ │ └── index.js // 路由配置
│ ├── store // vuex 的状态管理
│ │ ├── actions.js // 配置 actions
│ │ ├── getters.js // 配置 getters
│ │ ├── index.js // 引用 vuex创建 store
│ │ ├── mutation-types.js // 定义常量 mutations 名
│ │ ├── mutations.js // 配置 mutations
│ │ └── state.js // 配置 state
│ ├── styles // 样式文件目录
│ │ ├── index.less // wangyiyun 相关基础样式
│ │ ├── mixin.less // 样式混合
│ │ ├── reset.less // 样式重置
│ │ └── var.less // 样式变量(字体大小、字体颜色、背景颜色)
│ ├── js // 数据交互目录
│ │ ├── axios.js // axios 简单封装
│ │ ├── hack.js // 修改 nextTick
│ │ ├── mixin.js // 组件混合
│ │ ├── song.js // 数据处理
│ │ ├── storage.js // localStorage 配置
│ │ └── util.js // 公用 js 方法
│ ├── App.vue // 根组件
│ ├── config.js // 配置文件(播放器默认配置、版本号等)
│ └── main.js // 入口主文件
└── vue.config.js // vue-cli 配置文件
</code></pre>
</details>
## 功能与界面
- 播放器
- 快捷键操作
- 歌词滚动
- 正在播放
- 排行榜
- 歌单详情
- 搜索
- 播放历史
- 查看评论
- 同步网易云歌单

3
babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: ['@vue/cli-plugin-babel/preset'],
}

34
jsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
],
"api/*": [
"src/api/*"
],
"assets/*": [
"src/assets/*"
],
"base/*": [
"src/base/*"
],
"components/*": [
"src/components/*"
],
"pages/*": [
"src/pages/*"
],
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "wangyiyun",
"version": "1.8.3",
"private": true,
"description": "Online music player",
"author": "yigencong <yigencong@yahoo.com>",
"bugs": {
"url": "https://git.icoding.fun/yigencong/wangyiyun/issues"
},
"repository": {
"type": "git",
"url": "https://git.icoding.fun/yigencong/wangyiyun"
},
"license": "MIT",
"scripts": {
"dev": "vue-cli-service serve",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"prepare": "husky install"
},
"dependencies": {
"axios": "^1.6.2",
"core-js": "^3.33.3",
"fastclick": "^1.0.6",
"vue": "^2.6.14",
"vue-lazyload": "^1.3.4",
"vue-router": "^3.5.1",
"vuex": "^3.6.2"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@babel/eslint-parser": "^7.23.3",
"@femm/prettier": "^1.1.0",
"@femm/verify-commit": "^1.0.1",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"dayjs": "^1.11.10",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^8.7.1",
"husky": "^8.0.3",
"less": "^4.2.0",
"less-loader": "^8.0.0",
"prettier": "^2.8.1",
"style-resources-loader": "^1.5.0",
"vue-cli-plugin-style-resources-loader": "^0.1.5",
"vue-template-compiler": "^2.6.14"
},
"prettier": "@femm/prettier"
}

7243
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
postcss.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

BIN
public/img/warn.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

162
public/index.html Normal file
View File

@ -0,0 +1,162 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="renderer" content="webkit" />
<meta name="force-rendering" content="webkit" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no"
/>
<title>网抑云 在线音乐播放器</title>
<meta
name="keywords"
content="网抑云,播放器,在线音乐,在线播放器,音乐播放器,在线音乐播放器,wangyiyun 在线音乐播放器"
/>
<meta
name="description"
content="网抑云 是由yigencong开源的一款在线音乐播放器具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能"
/>
<link
rel="stylesheet"
href="//at.alicdn.com/t/font_1367495_eza6utwbiqn.css"
/>
<style type="text/css">
noscript {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1996520;
background: #fff;
text-align: center;
font-weight: 700;
font-size: 34px;
line-height: 100px;
}
#appLoading {
position: fixed;
top: 0;
left: 0;
z-index: 1996;
width: 100%;
height: 100%;
font-size: 20px;
background: rgba(255, 255, 255, 1);
}
#appLoading.removeAnimate {
animation: removeAnimate 0.3s 0.5s 1 both;
}
#appLoading .loader {
position: absolute;
top: 50%;
left: 50%;
width: 5em;
height: 5em;
transform: translate(-50%, -50%) rotate(165deg);
}
#appLoading .loader::before,
#appLoading .loader::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
display: block;
width: 1em;
height: 1em;
border-radius: 0.5em;
transform: translate(-50%, -50%);
}
#appLoading .loader::before {
animation: before 2s infinite;
}
#appLoading .loader::after {
animation: after 2s infinite;
}
@keyframes before {
0% {
width: 1em;
box-shadow: 2em -1em rgba(225, 20, 98, 0.75),
-2em 1em rgba(111, 202, 220, 0.75);
}
35% {
width: 5em;
box-shadow: 0 -1em rgba(225, 20, 98, 0.75),
0 1em rgba(111, 202, 220, 0.75);
}
70% {
width: 1em;
box-shadow: -2em -1em rgba(225, 20, 98, 0.75),
2em 1em rgba(111, 202, 220, 0.75);
}
100% {
box-shadow: 2em -1em rgba(225, 20, 98, 0.75),
-2em 1em rgba(111, 202, 220, 0.75);
}
}
@keyframes after {
0% {
height: 1em;
box-shadow: 1em 2em rgba(61, 184, 143, 0.75),
-1em -2em rgba(233, 169, 32, 0.75);
}
35% {
height: 5em;
box-shadow: 1em 0 rgba(61, 184, 143, 0.75),
-1em 0 rgba(233, 169, 32, 0.75);
}
70% {
height: 1em;
box-shadow: 1em -2em rgba(61, 184, 143, 0.75),
-1em 2em rgba(233, 169, 32, 0.75);
}
100% {
box-shadow: 1em 2em rgba(61, 184, 143, 0.75),
-1em -2em rgba(233, 169, 32, 0.75);
}
}
@keyframes removeAnimate {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
<script>
;(function () {
if (!!window.ActiveXObject || 'ActiveXObject' in window) {
window.location = './prompt.html'
return false
}
})()
</script>
<% if ( NODE_ENV === 'production' ) { %>
<script>
var _hmt = _hmt || []
window._hmt = _hmt
;(function () {
var hm = document.createElement('script')
hm.src = 'https://hm.baidu.com/hm.js?71e62b6d09afa9deac7bfa5c60ad06dd'
var s = document.getElementsByTagName('script')[0]
s.parentNode.insertBefore(hm, s)
})()
</script>
<% } %>
</head>
<body>
<noscript>
网抑云 在线音乐播放器<br />为了更好的体验请开启 script
</noscript>
<div id="appLoading">
<div class="loader"></div>
</div>
<div id="wangyiyun">
网抑云
是由yigencong开源的一款在线音乐播放器具有音乐搜索、播放、歌词显示、播放历史、查看歌曲评论、网易云用户歌单播放同步等功能
</div>
<!-- built files will be auto injected -->
</body>
</html>

31
public/prompt.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex,nofollow"/>
<title>wangyiyun | 温馨提示</title>
<style>
body{font-size: 14px;font-family: 'helvetica neue',tahoma,arial,'hiragino sans gb','microsoft yahei','Simsun',sans-serif; background-color:#fff; color:#808080;}
.wrap{margin:200px auto;width:510px;}
td{text-align:left; padding:2px 10px;}
td.header{font-size:22px; padding-bottom:10px; color:#000;}
td.check-info{padding-top:20px;}
a{color:#328ce5; text-decoration:none;}
a:hover{text-decoration:underline;}
</style>
</head>
<body>
<div class="wrap">
<table>
<tr>
<td rowspan="5"><img src="./img/warn.png"></td>
<td class="header">wangyiyun | 温馨提示</td>
</tr>
<tr><td></td></tr>
<tr><td>很抱歉!为了更好的体验,本站限制以下浏览器访问:</td></tr>
<tr><td>IE浏览器和使用IE内核的浏览器</td></tr>
<tr><td>解决办法:下载其他主流浏览器或者切换浏览器内核为极速内核</td></tr>
</table>
</div>
</body>
</html>

BIN
screenshots/1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

BIN
screenshots/2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 KiB

BIN
screenshots/3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

BIN
screenshots/4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 658 KiB

BIN
screenshots/5.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

BIN
screenshots/6.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

BIN
screenshots/7.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

BIN
screenshots/8.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

109
src/App.vue Normal file
View File

@ -0,0 +1,109 @@
<template>
<div id="app">
<!--主体-->
<wyy-header />
<router-view />
<!--更新说明-->
<wyy-dialog ref="versionDialog" type="alert" head-text="更新提示" :body-text="versionInfo" />
<!--播放器-->
<audio ref="wangyiyun"></audio>
</div>
</template>
<script>
import { mapMutations, mapActions } from 'vuex'
import { getPlaylistDetail } from 'api'
import { WYYPLAYER_CONFIG, VERSION } from '@/config'
import WyyHeader from 'components/wyy-header/wyy-header'
import WyyDialog from 'base/wyy-dialog/wyy-dialog'
import { getVersion, setVersion } from '@/utils/storage'
const VERSION_INFO = `<div class="wyy-dialog-text text-left">
版本号V1.0 2024-2-20<br/>
重要通知binaryify/netease_cloud_music_api<br/>
已被网易和谐以后不再维护和更新下个版本会改为nas的web端播放器
docker镜像获取方式 docker pull yigencong/wangyiyun:v1.0当前版本
</div>`
export default {
name: 'App',
components: {
WyyHeader,
WyyDialog,
},
created() {
//
this.versionInfo = VERSION_INFO
//
getPlaylistDetail(WYYPLAYER_CONFIG.PLAYLIST_ID).then((playlist) => {
const list = playlist.tracks.slice(0, 100)
this.setPlaylist({ list })
})
// title
let OriginTitile = document.title
let titleTime
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
document.title = '网抑云需要你点歌!'
clearTimeout(titleTime)
} else {
document.title = '点首歌吧!'
titleTime = setTimeout(function () {
document.title = OriginTitile
}, 2000)
}
})
// audio
this.$nextTick(() => {
this.setAudioele(this.$refs.wangyiyun)
})
//
let loadDOM = document.querySelector('#appLoading')
if (loadDOM) {
const animationendFunc = function () {
loadDOM.removeEventListener('animationend', animationendFunc)
loadDOM.removeEventListener('webkitAnimationEnd', animationendFunc)
document.body.removeChild(loadDOM)
loadDOM = null
const version = getVersion()
if (version !== null) {
setVersion(VERSION)
if (version !== VERSION) {
this.$refs.versionDialog.show()
}
} else {
setVersion(VERSION)
this.$refs.versionDialog.show()
}
}.bind(this)
loadDOM.addEventListener('animationend', animationendFunc)
loadDOM.addEventListener('webkitAnimationEnd', animationendFunc)
loadDOM.classList.add('removeAnimate')
}
},
methods: {
...mapMutations({
setAudioele: 'SET_AUDIOELE',
}),
...mapActions(['setPlaylist']),
},
}
</script>
<style lang="less">
#app {
position: relative;
width: 100%;
height: 100%;
color: @text_color;
font-size: @font_size_medium;
audio {
position: fixed;
}
}
</style>

119
src/api/index.js Normal file
View File

@ -0,0 +1,119 @@
import axios from '@/utils/axios'
import { DEFAULT_LIMIT } from '@/config'
import { formatSongs } from '@/utils/song'
// 排行榜列表
export function getToplistDetail() {
return axios.get('/toplist/detail')
}
// 推荐歌单
export function getPersonalized() {
return axios.get('/personalized')
}
// 歌单详情
export function getPlaylistDetail(id) {
return new Promise((resolve, reject) => {
axios
.get('/playlist/detail', {
params: { id },
})
.then(({ playlist }) => playlist || {})
.then((playlist) => {
const { trackIds, tracks } = playlist
if (!Array.isArray(trackIds)) {
reject(new Error('获取歌单详情失败'))
return
}
// 过滤完整歌单 如排行榜
if (tracks.length === trackIds.length) {
playlist.tracks = formatSongs(playlist.tracks)
resolve(playlist)
return
}
// 限制歌单详情最大 500
const ids = trackIds
.slice(0, 500)
.map((v) => v.id)
.toString()
getMusicDetail(ids).then(({ songs }) => {
playlist.tracks = formatSongs(songs)
resolve(playlist)
})
})
})
}
// 搜索
export function search(keywords, page = 0, limit = DEFAULT_LIMIT) {
return axios.get('/search', {
params: {
offset: page * limit,
limit: limit,
keywords,
},
})
}
// 热搜
export function searchHot() {
return axios.get('/search/hot')
}
// 获取用户歌单详情
export function getUserPlaylist(uid) {
return axios.get('/user/playlist', {
params: {
uid,
},
})
}
// 获取歌曲详情
export function getMusicDetail(ids) {
return axios.get('/song/detail', {
params: {
ids,
},
})
}
// 获取音乐是否可以用
export function getCheckMusic(id) {
return axios.get('/check/music', {
params: {
id,
},
})
}
// 获取音乐地址
export function getMusicUrl(id) {
return axios.get('/song/url', {
params: {
id,
},
})
}
// 获取歌词
export function getLyric(id) {
const url = '/lyric'
return axios.get(url, {
params: {
id,
},
})
}
// 获取音乐评论
export function getComment(id, page, limit = DEFAULT_LIMIT) {
return axios.get('/comment/music', {
params: {
offset: page * limit,
limit: limit,
id,
},
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
src/assets/img/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src/assets/img/wave.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1,224 @@
<template>
<!--对话框-->
<transition name="wyy-dialog-fade">
<div v-show="dialogShow" class="wyy-dialog-box">
<div class="wyy-dialog-wrapper">
<div class="wyy-dialog-content">
<div class="wyy-dialog-head" v-text="headText"></div>
<slot>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="wyy-dialog-text" v-html="bodyText"></div>
</slot>
<div class="wyy-dialog-btns">
<div
v-if="dialogType !== 'alert'"
class="wyy-btn-cancel"
@click="cancel"
v-text="cancelBtnText"
></div>
<slot name="btn"></slot>
<div class="wyy-btn-confirm" @click="confirm" v-text="confirmBtnText"></div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script>
export default {
name: 'WyyDialog',
props: {
// typeconfirmalert
type: {
type: String,
default: 'confirm',
},
//
headText: {
type: String,
default: '提示',
},
// html
bodyText: {
type: String,
default: '',
},
//
cancelBtnText: {
type: String,
default: '取消',
},
//
confirmBtnText: {
type: String,
default: '确定',
},
// Dialog body
appendToBody: {
type: Boolean,
default: true,
},
},
data() {
return {
dialogShow: false, //
}
},
computed: {
dialogType() {
return this.type.toLowerCase()
},
},
watch: {
dialogShow(val) {
if (val && this.appendToBody) {
document.body.appendChild(this.$el)
}
},
},
mounted() {
if (this.dialogShow && this.appendToBody) {
document.body.appendChild(this.$el)
}
},
destroyed() {
if (this.appendToBody && this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
}
},
methods: {
//
show() {
this.dialogShow = true
},
//
hide() {
this.dialogShow = false
},
//
cancel() {
this.hide()
this.$emit('cancel')
},
//
confirm() {
this.hide()
this.$emit('confirm')
},
},
}
</script>
<style lang="less">
@dialog-prefix-cls: wyy-dialog;
.@{dialog-prefix-cls}-box {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 1996;
background-color: @dialog_bg_color;
user-select: none;
backdrop-filter: @backdrop_filter;
&.@{dialog-prefix-cls}-fade-enter-active {
animation: wyy-dialog-fadein 0.3s;
.@{dialog-prefix-cls}-content {
animation: wyy-dialog-zoom 0.3s;
}
}
.@{dialog-prefix-cls}-wrapper {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1996;
.@{dialog-prefix-cls}-content {
width: 420px;
border-radius: @dialog_border_radius;
background: @dialog_content_bg_color;
@media (max-width: 767px) {
width: 270px;
border-radius: @dialog_mobile_border_radius;
text-align: center;
}
.@{dialog-prefix-cls}-head {
padding: 15px;
padding-bottom: 0;
font-size: @font_size_large;
color: @text_color_active;
}
.@{dialog-prefix-cls}-text {
padding: 20px 15px;
line-height: 22px;
font-size: @font_size_medium;
color: @dialog_text_color;
}
.@{dialog-prefix-cls}-btns {
display: flex;
align-items: center;
padding: 0 15px 10px;
text-align: center;
color: @dialog_text_color;
@media (min-width: 768px) {
justify-content: flex-end;
div {
display: block;
padding: 8px 15px;
border-radius: @dialog_btn_mobile_border_radius;
border: 1px solid @btn_color;
font-size: @font_size_medium;
cursor: pointer;
&:not(:nth-of-type(1)) {
margin-left: 10px;
}
&:hover {
color: @text_color_active;
border: 1px solid @btn_color_active;
}
}
}
@media (max-width: 767px) {
& {
padding: 0;
justify-content: center;
div {
flex: 1;
line-height: 22px;
padding: 10px 0;
border-top: 1px solid @dialog_line_color;
font-size: @font_size_large;
&:not(:nth-of-type(1)) {
border-left: 1px solid @dialog_line_color;
}
}
}
}
}
}
}
}
@keyframes wyy-dialog-fadein {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes wyy-dialog-zoom {
0% {
transform: scale(0);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
</style>

View File

@ -0,0 +1,53 @@
<!-- icon 组件 -->
<script>
export default {
name: 'WyyIcon',
props: {
type: {
type: String,
required: true,
},
size: {
type: Number,
default: 16,
},
},
methods: {
getIconCls() {
return `icon-${this.type}`
},
getIconStyle() {
return { fontSize: this.size + 'px' }
},
onClick(e) {
this.$emit('click', e)
},
},
render() {
const Icon = (
<i
onClick={this.onClick}
class={`iconfont ${this.getIconCls()}`}
style={this.getIconStyle()}
/>
)
return Icon
},
}
</script>
<style lang="less">
.iconfont {
display: inline-block;
font-style: normal;
font-weight: normal;
font-variant: normal;
line-height: 1;
vertical-align: baseline;
text-transform: none;
speak: none;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@ -0,0 +1,82 @@
<template>
<!--加载动画-->
<div v-show="value" class="wyy-loading" :style="{ backgroundColor: loadingBgColor }">
<div class="wyy-loading-content">
<svg class="circular" viewBox="25 25 50 50">
<circle class="path" cx="50" cy="50" r="20" fill="none"></circle>
</svg>
</div>
</div>
</template>
<script>
export default {
name: 'WyyLoading',
props: {
//
value: {
type: Boolean,
default: true,
},
//
loadingBgColor: {
type: String,
default: '',
},
},
}
</script>
<style lang="less">
.wyy-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1996;
background: @load_bg_color;
.wyy-loading-content {
position: absolute;
top: 50%;
width: 100%;
transform: translateY(-50%);
text-align: center;
.circular {
height: 50px;
width: 50px;
animation: loading-rotate 2s linear infinite;
.path {
animation: loading-dash 1.5s ease-in-out infinite;
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-width: 2;
stroke: @text_color;
stroke-linecap: round;
}
}
}
}
//
@keyframes loading-rotate {
100% {
transform: rotate(360deg);
}
}
@keyframes loading-dash {
0% {
stroke-dasharray: 1, 200;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40px;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120px;
}
}
</style>

View File

@ -0,0 +1,34 @@
<template>
<!--暂无数据提示-->
<div class="wyy-no-result">
<p class="wyy-no-result-text">{{ title }}</p>
</div>
</template>
<script>
export default {
name: 'WyyNoResult',
props: {
//
title: {
type: String,
default: '',
},
},
}
</script>
<style lang="less">
.wyy-no-result {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
&-text {
margin-top: 30px;
font-size: @font_size_medium;
color: @text_color;
}
}
</style>

View File

@ -0,0 +1,174 @@
<template>
<!--进度条拖动-->
<div ref="wyyProgress" class="wyyProgress" @click="barClick">
<div class="wyyProgress-bar"></div>
<div ref="wyyPercentProgress" class="wyyProgress-outer"></div>
<div ref="wyyProgressInner" class="wyyProgress-inner">
<div class="wyyProgress-dot" @mousedown="barDown" @touchstart.prevent="barDown"></div>
</div>
</div>
</template>
<script>
const dotWidth = 10
export default {
name: 'WyyProgress',
props: {
//
percent: {
type: [Number],
default: 0,
},
//
percentProgress: {
type: [Number],
default: 0,
},
},
data() {
return {
move: {
status: false, //
startX: 0, // X
left: 0, //
},
}
},
watch: {
percent(newPercent) {
if (newPercent >= 0 && !this.move.status) {
const barWidth = this.$refs.wyyProgress.clientWidth - dotWidth
const offsetWidth = newPercent * barWidth
this.moveSilde(offsetWidth)
}
},
percentProgress(newValue) {
let offsetWidth = this.$refs.wyyProgress.clientWidth * newValue
this.$refs.wyyPercentProgress.style.width = `${offsetWidth}px`
},
},
mounted() {
this.$nextTick(() => {
this.bindEvents()
const barWidth = this.$refs.wyyProgress.clientWidth - dotWidth
const offsetWidth = this.percent * barWidth
this.moveSilde(offsetWidth)
})
},
beforeDestroy() {
this.unbindEvents()
},
methods: {
//
bindEvents() {
document.addEventListener('mousemove', this.barMove)
document.addEventListener('mouseup', this.barUp)
document.addEventListener('touchmove', this.barMove)
document.addEventListener('touchend', this.barUp)
},
//
unbindEvents() {
document.removeEventListener('mousemove', this.barMove)
document.removeEventListener('mouseup', this.barUp)
document.removeEventListener('touchmove', this.barMove)
document.removeEventListener('touchend', this.barUp)
},
//
barClick(e) {
let rect = this.$refs.wyyProgress.getBoundingClientRect()
let offsetWidth = Math.min(
this.$refs.wyyProgress.clientWidth - dotWidth,
Math.max(0, e.clientX - rect.left),
)
this.moveSilde(offsetWidth)
this.commitPercent(true)
},
//
barDown(e) {
this.move.status = true
this.move.startX = e.clientX || e.touches[0].pageX
this.move.left = this.$refs.wyyProgressInner.clientWidth
},
// /
barMove(e) {
if (!this.move.status) {
return false
}
e.preventDefault()
let endX = e.clientX || e.touches[0].pageX
let dist = endX - this.move.startX
let offsetWidth = Math.min(
this.$refs.wyyProgress.clientWidth - dotWidth,
Math.max(0, this.move.left + dist),
)
this.moveSilde(offsetWidth)
this.commitPercent()
},
// /
barUp(e) {
if (this.move.status) {
this.commitPercent(true)
this.move.status = false
}
},
//
moveSilde(offsetWidth) {
this.$refs.wyyProgressInner.style.width = `${offsetWidth}px`
},
// percent
commitPercent(isEnd = false) {
const { wyyProgress, wyyProgressInner } = this.$refs
const lineWidth = wyyProgress.clientWidth - dotWidth
const percent = wyyProgressInner.clientWidth / lineWidth
this.$emit(isEnd ? 'percentChangeEnd' : 'percentChange', percent)
},
},
}
</script>
<style lang="less">
.wyyProgress {
position: relative;
padding: 5px;
user-select: none;
cursor: pointer;
overflow: hidden;
.wyyProgress-bar {
height: 2px;
width: 100%;
background: @bar_color;
}
.wyyProgress-outer {
position: absolute;
top: 50%;
left: 5px;
display: inline-block;
width: 0;
height: 2px;
margin-top: -1px;
background: rgba(255, 255, 255, 0.2);
}
.wyyProgress-inner {
position: absolute;
top: 50%;
left: 5px;
display: inline-block;
width: 0;
height: 2px;
margin-top: -1px;
background: @line_color;
.wyyProgress-dot {
position: absolute;
top: 50%;
right: -5px;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: @dot_color;
transform: translateY(-50%);
}
}
}
</style>

View File

@ -0,0 +1,39 @@
import TempToast from './wyy-toast.vue'
let instance
let showToast = false
let time // 存储toast显示状态
const wyyToast = {
install(Vue, options = {}) {
let opt = TempToast.data() // 获取组件中的默认配置
Object.assign(opt, options) // 合并配置
Vue.prototype.$wyyToast = (message, position) => {
if (showToast) {
clearTimeout(time)
instance.vm.visible = showToast = false
document.body.removeChild(instance.vm.$el)
// return;// 如果toast还在则不再执行
}
if (message) {
opt.message = message // 如果有传message则使用所传的message
}
if (position) {
opt.position = position // 如果有传type则使用所传的type
}
let TempToastConstructor = Vue.extend(TempToast)
instance = new TempToastConstructor({
data: opt,
})
instance.vm = instance.$mount()
document.body.appendChild(instance.vm.$el)
instance.vm.visible = showToast = true
time = setTimeout(function () {
instance.vm.visible = showToast = false
document.body.removeChild(instance.vm.$el)
}, opt.duration)
}
},
}
export default wyyToast

View File

@ -0,0 +1,75 @@
<template>
<!--弹出层提示-->
<transition name="toast-fade">
<div v-show="visible" class="wyy-toast" :class="positionClasss">
{{ message }}
</div>
</transition>
</template>
<script>
export default {
name: 'WyyToast',
data() {
return {
position: 'center', //
message: '', //
duration: 1500, // ,
visible: false, //
}
},
computed: {
positionClasss() {
return 'wyy-toast-' + this.position
},
},
}
</script>
<style lang="less">
@prefix-cls: wyy-toast;
.@{prefix-cls} {
position: fixed;
left: 50%;
z-index: 1996;
max-width: 80%;
box-sizing: border-box;
border-radius: @border_radius;
padding: 10px 20px;
overflow: hidden;
text-align: center;
min-height: 40px;
line-height: 20px;
font-size: 14px;
color: #fff;
background: rgba(0, 0, 0, 0.5);
user-select: none;
transform: translateX(-50%);
&&-top {
top: 10%;
}
&&-center {
top: 50%;
margin-top: -20px;
}
&&-bottom {
bottom: 10%;
}
}
.toast-fade-enter {
opacity: 0;
transform: translate3d(-50%, -10px, 0);
}
.toast-fade-enter-active {
will-change: transform;
transition: all 0.2s;
}
.toast-fade-enter-to {
opacity: 1;
transform: translate3d(-50%, 0, 0);
}
</style>

View File

@ -0,0 +1,171 @@
<template>
<div>
<!--封面-->
<dl class="music-info">
<dt>
<img :src="musicPicUrl" />
</dt>
<template v-if="currentMusic.id">
<dd>歌曲名{{ currentMusic.name }}</dd>
<dd>歌手名{{ currentMusic.singer }}</dd>
<dd>专辑名{{ currentMusic.album }}</dd>
</template>
<template v-else>
<dd>网抑云在线音乐播放器</dd>
<dd>
<a class="hover" target="_blank" href="https://github.com/yigencong">
<wyy-icon type="github" :size="14" />
&nbsp;yigencong
</a>
</dd>
</template>
</dl>
<!--歌词-->
<div ref="musicLyric" class="music-lyric">
<div class="music-lyric-items" :style="lyricTop">
<p v-if="!currentMusic.id">还没有播放音乐哦</p>
<p v-else-if="nolyric">暂无歌词</p>
<template v-else-if="lyric.length > 0">
<p v-for="(item, index) in lyric" :key="index" :class="{ on: lyricIndex === index }">
{{ item.text }}
</p>
</template>
<p v-else>歌词加载失败</p>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Lyric',
props: {
//
lyric: {
type: Array,
default: () => [],
},
//
nolyric: {
type: Boolean,
default: false,
},
//
lyricIndex: {
type: Number,
default: 0,
},
},
data() {
return {
top: 0, //
}
},
computed: {
musicPicUrl() {
return this.currentMusic.id
? `${this.currentMusic.image}?param=300y300`
: require('../../assets/img/player_cover.png')
},
lyricTop() {
return `transform :translate3d(0, ${-34 * (this.lyricIndex - this.top)}px, 0)`
},
...mapGetters(['currentMusic']),
},
mounted() {
window.addEventListener('resize', () => {
clearTimeout(this.resizeTimer)
this.resizeTimer = setTimeout(() => this.clacTop(), 60)
})
this.$nextTick(() => this.clacTop())
},
methods: {
// top
clacTop() {
const dom = this.$refs.musicLyric
const { display = '' } = window.getComputedStyle(dom)
if (display === 'none') {
return
}
const height = dom.offsetHeight
this.top = Math.floor(height / 34 / 2)
},
},
}
</script>
<style lang="less" scoped>
.music-info {
padding-bottom: 20px;
text-align: center;
font-size: @font_size_medium;
dt {
position: relative;
width: 186px;
height: 186px;
margin: 0 auto 15px;
&:after {
content: '';
position: absolute;
left: 9px;
top: 0;
width: 201px;
height: 180px;
background: url('~assets/img/album_cover_player.png') 0 0 no-repeat;
}
img {
vertical-align: middle;
width: 186px;
height: 186px;
}
}
dd {
height: 30px;
line-height: 30px;
.no-wrap();
}
}
/*歌词部分*/
.music-lyric {
position: absolute;
top: 315px;
right: 0;
bottom: 0;
left: 0;
overflow: hidden;
text-align: center;
mask-image: linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.6) 15%,
rgba(255, 255, 255, 1) 25%,
rgba(255, 255, 255, 1) 75%,
rgba(255, 255, 255, 0.6) 85%,
rgba(255, 255, 255, 0) 100%
);
.music-lyric-items {
text-align: center;
line-height: 34px;
font-size: @font_size_small;
transform: translate3d(0, 0, 0);
transition: transform 0.6s ease-out;
.no-wrap();
.on {
color: @lyric_color_active;
}
}
}
// 960
@media (max-width: 960px) {
.music-info {
display: none;
}
.music-lyric {
top: 0;
}
}
</style>

View File

@ -0,0 +1,67 @@
<template>
<!--选项-->
<div class="music-btn">
<router-link to="/music/playlist" tag="span">正在播放</router-link>
<router-link to="/music/toplist" tag="span">推荐</router-link>
<router-link to="/music/search" tag="span">搜索</router-link>
<router-link to="/music/userlist" tag="span">我的歌单</router-link>
<span class="show-960" @click="$emit('onClickLyric')">歌词</span>
<router-link to="/music/historylist" tag="span">我听过的</router-link>
</div>
</template>
<script>
export default {}
</script>
<style lang="less" scoped>
.music-btn {
width: 100%;
height: 60px;
font-size: 0;
white-space: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
span {
display: inline-block;
height: 40px;
box-sizing: border-box;
margin-right: 8px;
padding: 0 23px;
border: 1px solid @btn_color;
color: @btn_color;
border-radius: @btn_border_radius;
font-size: 14px;
line-height: 40px;
overflow: hidden;
cursor: pointer;
&:nth-last-of-type(1) {
margin: 0;
}
&:hover,
&.active {
border-color: @btn_color_active;
color: @btn_color_active;
}
}
@media (min-width: 960px) {
span.show-960 {
display: none;
}
}
@media (max-width: 960px) {
span.show-960 {
display: inline-block;
}
}
@media (max-width: 768px) {
height: 50px;
span {
height: 35px;
padding: 0 10px;
margin-right: 6px;
line-height: 35px;
}
}
}
</style>

View File

@ -0,0 +1,379 @@
<template>
<!--歌曲列表-->
<div class="music-list flex-col">
<template v-if="list.length > 0">
<div class="list-item list-header">
<span class="list-name">歌曲</span>
<span class="list-artist">歌手</span>
<span v-if="isDuration" class="list-time">时长</span>
<span v-else class="list-album">专辑</span>
</div>
<div ref="listContent" class="list-content" @scroll="listScroll($event)">
<div
v-for="(item, index) in list"
:key="item.id"
class="list-item"
:class="{ on: playing && currentMusic.id === item.id }"
@dblclick="selectItem(item, index, $event)"
>
<span class="list-num" v-text="index + 1"></span>
<div class="list-name">
<span>{{ item.name }}</span>
<div class="list-menu">
<wyy-icon
class="hover"
:type="getPlayIconType(item)"
:size="40"
@click.stop="selectItem(item, index)"
/>
</div>
</div>
<span class="list-artist">{{ item.singer }}</span>
<span v-if="isDuration" class="list-time">
{{ item.duration % 3600 | format }}
<wyy-icon
class="hover list-menu-icon-del"
type="delete-mini"
:size="40"
@click.stop="deleteItem(index)"
/>
</span>
<span v-else class="list-album">{{ item.album }}</span>
</div>
<slot name="listBtn"></slot>
</div>
</template>
<wyy-no-result v-else title="弄啥呢,怎么啥也没有!!!" />
</div>
</template>
<script>
// import {getCheckMusic} from 'api'
import { mapGetters, mapMutations } from 'vuex'
import { format } from '@/utils/util'
import WyyNoResult from 'base/wyy-no-result/wyy-no-result'
const LIST_TYPE_ALBUM = 'album'
const LIST_TYPE_DURATION = 'duration'
const LIST_TYPE_PULLUP = 'pullup'
//
const THRESHOLD = 100
export default {
name: 'MusicList',
components: {
WyyNoResult,
},
filters: {
format,
},
props: {
//
list: {
type: Array,
default: () => [],
},
/**
* 列表类型
* album: 显示专辑栏目默认
* duration: 显示时长栏目
* pullup: 开启上拉加载
*/
listType: {
type: String,
default: LIST_TYPE_ALBUM,
},
},
data() {
return {
lockUp: true, // ,
}
},
computed: {
isDuration() {
return this.listType === LIST_TYPE_DURATION
},
...mapGetters(['playing', 'currentMusic']),
},
watch: {
list(newList, oldList) {
if (this.listType !== LIST_TYPE_PULLUP) {
return
}
if (newList.length !== oldList.length) {
this.lockUp = false
} else if (newList[newList.length - 1].id !== oldList[oldList.length - 1].id) {
this.lockUp = false
}
},
},
activated() {
this.scrollTop && this.$refs.listContent && (this.$refs.listContent.scrollTop = this.scrollTop)
},
methods: {
//
listScroll(e) {
const scrollTop = e.target.scrollTop
this.scrollTop = scrollTop
if (this.listType !== LIST_TYPE_PULLUP || this.lockUp) {
return
}
const { scrollHeight, offsetHeight } = e.target
if (scrollTop + offsetHeight >= scrollHeight - THRESHOLD) {
this.lockUp = true //
this.$emit('pullUp') //
}
},
//
scrollTo() {
this.$refs.listContent.scrollTop = 0
},
//
selectItem(item, index, e) {
if (e && /list-menu-icon-del/.test(e.target.className)) {
return
}
if (this.currentMusic.id && item.id === this.currentMusic.id) {
this.setPlaying(!this.playing)
return
}
/**
* 为了修复 safari ios 微信安卓 UC 无法播放问题暂时移除接口校验直接播放
*/
this.$emit('select', item, index) //
// getMusicUrl(item.id)
// .then(res => {
// if (!res.data.data[0].url) {
// this.$wyyToast('')
// } else {
// this.$emit('select', item, index)//
// }
// });
// getCheckMusic(item.id)
// .then(res => {
// if (res.data.message !== 'ok') {
// this.$wyyToast('')
// } else {
// this.$emit('select', item, index)//
// }
// }).catch(error => {
// this.$wyyToast(error.response.data.message)
// })
},
// type
getPlayIconType({ id: itemId }) {
const {
playing,
currentMusic: { id },
} = this
return playing && id === itemId ? 'pause-mini' : 'play-mini'
},
//
deleteItem(index) {
this.$emit('del', index) //
},
...mapMutations({
setPlaying: 'SET_PLAYING',
}),
},
}
</script>
<style lang="less" scoped>
.music-list {
height: 100%;
}
.list-header {
border-bottom: 1px solid @list_head_line_color;
color: @text_color_active;
.list-name {
padding-left: 40px;
user-select: none;
}
}
.list-content {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.list-no {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: @text_color;
}
.list-item {
display: flex;
width: 100%;
height: 50px;
border-bottom: 1px solid @list_item_line_color;
line-height: 50px;
overflow: hidden;
&.list-item-no {
justify-content: center;
align-items: center;
}
&.on {
color: #fff;
.list-num {
font-size: 0;
background: url('~assets/img/wave.gif') no-repeat center center;
}
}
&:hover {
.list-name {
padding-right: 80px;
.list-menu {
display: block;
}
}
}
&:not([class*='list-header']):hover {
.list-name {
padding-right: 80px;
.list-menu {
display: block;
}
}
.list-time {
font-size: 0;
.list-menu-icon-del {
display: block;
}
}
}
.list-num {
display: block;
width: 30px;
margin-right: 10px;
text-align: center;
}
.list-name {
position: relative;
flex: 1;
box-sizing: border-box;
& > span {
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
small {
margin-left: 5px;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
/*hover菜单*/
.list-menu {
display: none;
position: absolute;
top: 50%;
right: 10px;
height: 40px;
font-size: 0;
transform: translateY(-50%);
}
}
.list-artist,
.list-album {
display: block;
width: 300px;
.no-wrap();
@media (max-width: 1440px) {
width: 200px;
}
@media (max-width: 1200px) {
width: 150px;
}
}
.list-time {
display: block;
width: 60px;
position: relative;
.list-menu-icon-del {
display: none;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
}
}
}
.list-btn {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
span {
padding: 5px 20px;
cursor: pointer;
user-select: none;
&:hover {
color: @text_color_active;
}
}
}
@media (max-width: 960px) {
.list-item .list-name {
padding-right: 70px;
}
}
@media (max-width: 768px) {
.list-item {
.list-name .list-menu {
display: block;
}
.list-artist,
.list-album {
width: 20%;
}
}
}
@media (max-width: 640px) {
.list-item {
.list-artist {
width: 80px;
}
.list-album,
.list-time {
display: none;
}
}
}
</style>

View File

@ -0,0 +1,83 @@
<!-- 音量控制组件 -->
<template>
<div class="volume">
<wyy-icon
class="pointer volume-icon"
:type="getVolumeIconType()"
:size="30"
@click="handleToggleVolume"
/>
<div class="volume-progress-wrapper">
<wyy-progress
:percent="volumeProgress"
@percentChangeEnd="handleVolumeChange"
@percentChange="handleVolumeChange"
/>
</div>
</div>
</template>
<script>
import WyyProgress from 'base/wyy-progress/wyy-progress'
export default {
name: 'Volume',
components: {
WyyProgress,
},
props: {
volume: {
type: Number,
required: true,
},
},
computed: {
volumeProgress() {
return this.volume
},
isMute: {
get() {
return this.volumeProgress === 0
},
set(newMute) {
const volume = newMute ? 0 : this.lastVolume
if (newMute) {
this.lastVolume = this.volumeProgress
}
this.handleVolumeChange(volume)
},
},
},
methods: {
getVolumeIconType() {
return this.isMute ? 'volume-off' : 'volume'
},
//
handleToggleVolume() {
this.isMute = !this.isMute
},
handleVolumeChange(percent) {
this.$emit('volumeChange', percent)
},
},
}
</script>
<style lang="less" scoped>
.volume {
display: flex;
align-items: center;
width: 150px;
&-icon {
margin-right: 5px;
color: #fff;
}
&-progress-wrapper {
flex: 1;
}
@media (max-width: 768px) {
top: 2px;
width: 36px;
}
}
</style>

View File

@ -0,0 +1,247 @@
<template>
<!--头部-->
<header class="wyy-header">
<h1 class="header">
<a href="https://git.icoding.fun/yigencong/wangyiyun" target="_blank">
网抑云 在线音乐播放器
</a>
<img
v-if="visitorBadge"
:src="visitorBadge"
alt="累计访问数"
class="visitor"
onerror="this.style.display='none'"
/>
</h1>
<dl class="user">
<template v-if="user.userId">
<router-link class="user-info" to="/music/userlist" tag="dt">
<img class="avatar" :src="`${user.avatarUrl}?param=50y50`" />
<span>{{ user.nickname }}</span>
</router-link>
<dd class="user-btn" @click="openDialog(2)">退出</dd>
</template>
<dd v-else class="user-btn" @click="openDialog(0)">登录</dd>
</dl>
<!--登录-->
<wyy-dialog
ref="loginDialog"
head-text="登录"
confirm-btn-text="登录"
cancel-btn-text="关闭"
@confirm="login"
>
<div class="wyy-dialog-text">
<input
v-model.trim="uidValue"
class="wyy-dialog-input"
type="number"
autofocus
placeholder="请输入您的网易云 UID"
@keyup.enter="login"
/>
</div>
<div slot="btn" @click="openDialog(1)">帮助</div>
</wyy-dialog>
<!--帮助-->
<wyy-dialog
ref="helpDialog"
head-text="登录帮助"
confirm-btn-text="去登录"
cancel-btn-text="关闭"
@confirm="openDialog(0)"
>
<div class="wyy-dialog-text">
<p>
1
<a target="_blank" href="https://music.163.com">点我(https://music.163.com)</a>
打开网易云音乐官网
</p>
<p>2点击页面右上角的登录</p>
<p>3点击您的头像进入我的主页</p>
<p>4复制浏览器地址栏 /user/home?id= 后面的数字网易云 UID</p>
</div>
</wyy-dialog>
<!--退出-->
<wyy-dialog ref="outDialog" body-text="确定退出当前用户吗" @confirm="out" />
</header>
</template>
<script>
import { getUserPlaylist } from 'api'
import { mapGetters, mapActions } from 'vuex'
import WyyDialog from 'base/wyy-dialog/wyy-dialog'
import { toHttps } from '@/utils/util'
import { VISITOR_BADGE_ID } from '@/config'
export default {
name: 'WyyHeader',
components: {
WyyDialog,
},
data() {
return {
user: {}, //
uidValue: '', // UID
}
},
computed: {
visitorBadge() {
if (VISITOR_BADGE_ID) {
return `https://visitor-badge.laobi.icu/badge?left_color=transparent&right_color=transparent&page_id=${VISITOR_BADGE_ID}`
}
return ''
},
...mapGetters(['uid']),
},
created() {
this.uid && this._getUserPlaylist(this.uid)
},
methods: {
//
openDialog(key) {
switch (key) {
case 0:
this.$refs.loginDialog.show()
break
case 1:
this.$refs.loginDialog.hide()
this.$refs.helpDialog.show()
break
case 2:
this.$refs.outDialog.show()
break
case 3:
this.$refs.loginDialog.hide()
break
}
},
// 退
out() {
this.user = {}
this.setUid(null)
this.$wyyToast('退出成功!')
},
//
login() {
if (this.uidValue === '') {
this.$wyyToast('UID 不能为空')
this.openDialog(0)
return
}
this.openDialog(3)
this._getUserPlaylist(this.uidValue)
},
//
_getUserPlaylist(uid) {
getUserPlaylist(uid).then(({ playlist = [] }) => {
this.uidValue = ''
if (playlist.length === 0 || !playlist[0].creator) {
this.$wyyToast(`未查询找 UID 为 ${uid} 的用户信息`)
return
}
const creator = playlist[0].creator
this.setUid(uid)
creator.avatarUrl = toHttps(creator.avatarUrl)
this.user = creator
setTimeout(() => {
this.$wyyToast(`${this.user.nickname} 欢迎使用 wangyiyun`)
}, 200)
})
},
...mapActions(['setUid']),
},
}
</script>
<style lang="less">
.wyy-header {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 60px;
@media (max-width: 768px) {
background: @header_bg_color;
}
.header {
.flex-center;
line-height: 60px;
color: @text_color_active;
font-size: @font_size_large;
@media (max-width: 768px) {
padding-left: 15px;
justify-content: flex-start;
}
@media (max-width: 414px) {
font-size: @font_size_medium;
}
.visitor {
margin-left: 6px;
height: 20px;
@media (max-width: 414px) {
display: none;
}
}
}
.user {
position: absolute;
top: 50%;
right: 15px;
line-height: 30px;
text-align: right;
transform: translateY(-50%);
&-info {
float: left;
margin-right: 15px;
cursor: pointer;
.avatar {
width: 30px;
height: 30px;
border-radius: 50%;
vertical-align: middle;
}
span {
margin-left: 10px;
color: @text_color_active;
}
}
&-btn {
float: left;
cursor: pointer;
&:hover {
color: @text_color_active;
}
}
@media (max-width: 768px) {
&-info {
margin-right: 10px;
span {
display: none;
}
}
}
}
}
.wyy-dialog-text {
text-align: left;
.wyy-dialog-input {
width: 100%;
height: 40px;
box-sizing: border-box;
padding: 0 15px;
border: 1px solid @btn_color;
outline: 0;
background: transparent;
color: @text_color_active;
font-size: @font_size_medium;
box-shadow: 0 0 1px 0 #fff inset;
&::placeholder {
color: @text_color;
}
}
a:hover {
color: #d43c33;
}
}
</style>

46
src/config.js Normal file
View File

@ -0,0 +1,46 @@
/* 版本号 */
export const VERSION = process.env.VUE_APP_VERSION
/**
* 访客统计 id
* 具体使用文档 https://github.com/jwenjian/visitor-badge
*/
export const VISITOR_BADGE_ID = process.env.VUE_APP_VISITOR_BADGE_ID
/* 背景图(可引入网络图或本地静态图) */
const requireAll = (requireContext) => requireContext.keys().map(requireContext)
const BACKGROUNDS = requireAll(require.context('./assets/background', false))
/**
* 播放模式
* LIST_LOOP: 列表循环
* ORDER: 顺序播放
* RANDOM: 随机播放
* LOOP: 单曲循环
*/
export const PLAY_MODE = {
LIST_LOOP: 0,
ORDER: 1,
RANDOM: 2,
LOOP: 3,
}
/**
* 播放器默认配置
*/
export const WYYPLAYER_CONFIG = {
/**
* 默认歌单ID 正在播放列表
* 默认为云音乐热歌榜 https://music.163.com/#/discover/toplist?id=3778678
*/
PLAYLIST_ID: 3778678,
/* 默认播放模式 */
PLAY_MODE: PLAY_MODE.LIST_LOOP,
/* 默认音量 */
VOLUME: 0.8,
/* 默认背景 */
BACKGROUND: BACKGROUNDS[Math.floor(Math.random() * BACKGROUNDS.length)],
}
/* 默认分页数量 */
export const DEFAULT_LIMIT = 30

62
src/main.js Normal file
View File

@ -0,0 +1,62 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
// import 'babel-polyfill'
// import '@/utils/hack'
import Vue from 'vue'
import store from './store'
import router from './router'
import App from './App'
import fastclick from 'fastclick'
import WyyToast from 'base/wyy-toast'
import Icon from 'base/wyy-icon/wyy-icon'
import VueLazyload from 'vue-lazyload'
import { VERSION } from './config'
import '@/styles/index.less'
// 优化移动端300ms点击延迟
fastclick.attach(document.body)
// 弹出层
Vue.use(WyyToast)
// icon 组件
Vue.component(Icon.name, Icon)
// 懒加载
Vue.use(VueLazyload, {
preLoad: 1,
loading: require('assets/img/default.png'),
})
// 访问版本统计
window._hmt && window._hmt.push(['_setCustomVar', 1, 'version', VERSION, 1])
const redirectList = ['/music/details', '/music/comment']
router.beforeEach((to, from, next) => {
window._hmt && to.path && window._hmt.push(['_trackPageview', '/#' + to.fullPath])
if (redirectList.includes(to.path)) {
next()
} else {
document.title =
(to.meta.title && `${to.meta.title} - 网抑云在线音乐播放器`) || 'wangyiyun在线音乐播放器'
next()
}
})
// 版权信息
window.wangyiyun = window.wangyiyun = `欢迎使用 wangyiyun!
当前版本为V${VERSION}
作者yigencong
Githubhttps://git.icoding.fun/yigencong/wangyiyun
歌曲来源于网易云音乐 (https://music.163.com)`
// eslint-disable-next-line no-console
console.info(`%c${window.wangyiyun}`, `color:blue`)
// eslint-disable-next-line no-new
new Vue({
el: '#wangyiyun',
store,
router,
render: (h) => h(App),
})

View File

@ -0,0 +1,244 @@
<template>
<!--评论-->
<div class="comment" @scroll="listScroll($event)">
<wyy-loading v-model="wyyLoadShow" />
<dl v-if="hotComments.length > 0" class="comment-list">
<!--精彩评论-->
<dt class="comment-title">精彩评论</dt>
<dd v-for="item in hotComments" :key="item.commentId" class="comment-item">
<a target="_blank" :href="`https://music.163.com/#/user/home?id=${item.user.userId}`">
<img v-lazy="`${item.user.avatarUrl}?param=50y50`" class="comment-item-pic" />
<h2 class="comment-item-title">{{ item.user.nickname }}</h2>
</a>
<p class="comment-item-disc">{{ item.content }}</p>
<div class="comment-item-opt">
<span class="comment-opt-date">{{ item.time | format }}</span>
<span class="comment-opt-liked">
<wyy-icon type="good" />
{{ item.likedCount }}
</span>
</div>
</dd>
</dl>
<!--最新评论-->
<dl v-if="commentList.length > 0" class="comment-list">
<dt class="comment-title">最新评论{{ total }}</dt>
<dd v-for="item in commentList" :key="item.commentId" class="comment-item">
<a
class="comment-item-pic"
target="_blank"
:href="`https://music.163.com/#/user/home?id=${item.user.userId}`"
>
<img v-lazy="`${item.user.avatarUrl}?param=50y50`" class="cover-img" />
</a>
<h2 class="comment-item-title">
<a target="_blank" :href="`https://music.163.com/#/user/home?id=${item.user.userId}`">
{{ item.user.nickname }}
</a>
</h2>
<p class="comment-item-disc">{{ item.content }}</p>
<div
v-for="beReplied in item.beReplied"
:key="beReplied.user.userId"
class="comment-item-replied"
>
<a
target="_blank"
:href="`https://music.163.com/#/user/home?id=${beReplied.user.userId}`"
>
{{ beReplied.user.nickname }}
</a>
{{ beReplied.content }}
</div>
<div class="comment-item-opt">
<span class="comment-opt-date">{{ item.time | format }}</span>
<span v-if="item.likedCount > 0" class="comment-opt-liked">
<wyy-icon type="good" />
{{ item.likedCount }}
</span>
</div>
</dd>
</dl>
</div>
</template>
<script>
import { getComment } from 'api'
import { addZero } from '@/utils/util'
import WyyLoading from 'base/wyy-loading/wyy-loading'
import { loadMixin } from '@/utils/mixin'
export default {
name: 'Comment',
components: {
WyyLoading,
},
filters: {
//
format(time) {
let formatTime
const date = new Date(time)
const dateObj = {
year: date.getFullYear(),
month: date.getMonth(),
date: date.getDate(),
hours: date.getHours(),
minutes: date.getMinutes(),
}
const newTime = new Date()
const diff = newTime.getTime() - time
if (newTime.getDate() === dateObj.date && diff < 60000) {
formatTime = '刚刚'
} else if (newTime.getDate() === dateObj.date && diff > 60000 && diff < 3600000) {
formatTime = `${Math.floor(diff / 60000)}分钟前`
} else if (newTime.getDate() === dateObj.date && diff > 3600000 && diff < 86400000) {
formatTime = `${addZero(dateObj.hours)}:${addZero(dateObj.minutes)}`
} else if (newTime.getDate() !== dateObj.date && diff < 86400000) {
formatTime = `昨天${addZero(dateObj.hours)}:${addZero(dateObj.minutes)}`
} else if (newTime.getFullYear() === dateObj.year) {
formatTime = `${dateObj.month + 1}${dateObj.date}`
} else {
formatTime = `${dateObj.year}${dateObj.month + 1}${dateObj.date}`
}
return formatTime
},
},
mixins: [loadMixin],
data() {
return {
lockUp: true, // ,
page: 0, //
hotComments: [], //
commentList: [], //
total: null, //
}
},
watch: {
commentList(newList, oldList) {
if (newList.length !== oldList.length) {
this.lockUp = false
}
},
},
created() {
this.initData()
},
methods: {
//
initData() {
getComment(this.$route.params.id, this.page).then(
(res) => {
this.hotComments = res.hotComments
this.commentList = res.comments
this.total = res.total
this.lockUp = true
this._hideLoad()
},
(err) => {
this._hideLoad()
},
)
},
//
listScroll(e) {
if (this.lockUp) {
return
}
const { scrollTop, scrollHeight, offsetHeight } = e.target
if (scrollTop + offsetHeight >= scrollHeight - 100) {
this.lockUp = true //
this.page += 1
this.pullUp() //
}
},
//
pullUp() {
getComment(this.$route.params.id, this.page).then(({ comments }) => {
this.commentList = [...this.commentList, ...comments]
})
},
},
}
</script>
<style lang="less" scoped>
.comment {
.comment-list {
padding: 0 10px;
}
.comment-title {
position: sticky;
top: 0;
z-index: 1;
margin: 0 -10px;
padding: 10px;
height: 34px;
line-height: 34px;
color: @text_color_active;
background: @header_bg_color;
backdrop-filter: @backdrop_filter;
}
.comment-item {
position: relative;
padding: 15px 0 15px 55px;
& + .comment-item {
border-top: 1px solid @comment_item_line_color;
}
&-pic {
display: block;
position: absolute;
left: 0;
top: 20px;
width: 38px;
height: 38px;
border-radius: 50%;
overflow: hidden;
}
&-title {
height: 20px;
margin-bottom: 6px;
font-weight: 400;
.no-wrap();
color: @text_color_active;
}
&-disc {
overflow: hidden;
word-break: break-all;
word-wrap: break-word;
line-height: 25px;
text-align: justify;
color: @text_color;
img {
position: relative;
vertical-align: middle;
top: -2px;
}
}
&-replied {
padding: 8px 19px;
margin-top: 10px;
line-height: 20px;
border: 1px solid @comment_replied_line_color;
a {
color: @text_color_active;
}
}
&-opt {
margin-top: 10px;
line-height: 25px;
text-align: right;
overflow: hidden;
.comment-opt-date {
float: left;
line-height: 28px;
}
.comment-opt-liked {
display: inline-block;
height: 20px;
line-height: 20px;
}
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<!--歌单详情-->
<div class="details">
<wyy-loading v-model="wyyLoadShow" />
<music-list :list="list" @select="selectItem" />
</div>
</template>
<script>
import { mapActions } from 'vuex'
import { getPlaylistDetail } from 'api'
import WyyLoading from 'base/wyy-loading/wyy-loading'
import MusicList from 'components/music-list/music-list'
import { loadMixin } from '@/utils/mixin'
export default {
name: 'Detail',
components: {
WyyLoading,
MusicList,
},
mixins: [loadMixin],
data() {
return {
list: [], //
}
},
created() {
//
getPlaylistDetail(this.$route.params.id)
.then((playlist) => {
document.title = `${playlist.name} - 网抑云在线音乐播放器`
this.list = playlist.tracks
this._hideLoad()
})
.catch(() => {
this._hideLoad()
})
},
methods: {
//
selectItem(item, index) {
this.selectPlay({
list: this.list,
index,
})
},
...mapActions(['selectPlay']),
},
}
</script>
<style lang="less" scoped>
.details {
.music-list {
height: 100%;
}
}
</style>

View File

@ -0,0 +1,58 @@
<template>
<!--我听过的播放历史-->
<div class="historyList">
<music-list :list="historyList" list-type="duration" @select="selectItem" @del="deleteItem">
<div slot="listBtn" class="list-btn">
<span @click="$refs.dialog.show()">清空列表</span>
</div>
</music-list>
<wyy-dialog
ref="dialog"
body-text="是否清空播放历史列表"
confirm-btn-text="清空"
@confirm="clearList"
/>
</div>
</template>
<script>
import { mapGetters, mapMutations, mapActions } from 'vuex'
import MusicList from 'components/music-list/music-list'
import WyyDialog from 'base/wyy-dialog/wyy-dialog'
export default {
name: 'HistoryList',
components: {
MusicList,
WyyDialog,
},
computed: {
...mapGetters(['historyList', 'playing', 'currentMusic']),
},
methods: {
//
clearList() {
this.clearHistory()
this.$wyyToast('列表清空成功')
},
//
selectItem(item, index) {
this.selectPlay({
list: this.historyList,
index,
})
},
//
deleteItem(index) {
let list = [...this.historyList]
list.splice(index, 1)
this.removeHistory(list)
this.$wyyToast('删除成功')
},
...mapMutations({
setPlaying: 'SET_PLAYING',
}),
...mapActions(['selectPlay', 'clearHistory', 'removeHistory']),
},
}
</script>

600
src/pages/music.vue Normal file
View File

@ -0,0 +1,600 @@
<template>
<div class="music flex-col">
<div class="music-content">
<div class="music-left flex-col">
<music-btn @onClickLyric="handleOpenLyric" />
<keep-alive>
<router-view v-if="$route.meta.keepAlive" class="router-view" />
</keep-alive>
<router-view v-if="!$route.meta.keepAlive" :key="$route.path" class="router-view" />
</div>
<div class="music-right" :class="{ show: lyricVisible }">
<div class="close-lyric" @click="handleCloseLyric">关闭歌词</div>
<lyric ref="lyric" :lyric="lyric" :nolyric="nolyric" :lyric-index="lyricIndex" />
</div>
</div>
<!--播放器-->
<div class="music-bar" :class="{ disable: !musicReady || !currentMusic.id }">
<div class="music-bar-btns">
<wyy-icon class="pointer" type="prev" :size="36" title="上一曲 Ctrl + Left" @click="prev" />
<div class="control-play pointer" title="播放暂停 Ctrl + Space" @click="play">
<wyy-icon :type="playing ? 'pause' : 'play'" :size="24" />
</div>
<wyy-icon
class="pointer"
type="next"
:size="36"
title="下一曲 Ctrl + Right"
@click="next"
/>
</div>
<div class="music-music">
<div class="music-bar-info">
<template v-if="currentMusic && currentMusic.id">
{{ currentMusic.name }}
<span>- {{ currentMusic.singer }}</span>
</template>
<template v-else>欢迎使用网抑云在线音乐播放器</template>
</div>
<div v-if="currentMusic.id" class="music-bar-time">
{{ currentTime | format }}/{{ currentMusic.duration % 3600 | format }}
</div>
<wyy-progress
class="music-progress"
:percent="percentMusic"
:percent-progress="currentProgress"
@percentChange="progressMusic"
@percentChangeEnd="progressMusicEnd"
/>
</div>
<!-- 播放模式 -->
<wyy-icon
class="icon-color pointer mode"
:type="getModeIconType()"
:title="getModeIconTitle()"
:size="30"
@click="modeChange"
/>
<!-- 评论 -->
<wyy-icon class="icon-color pointer comment" type="comment" :size="30" @click="openComment" />
<!-- 音量控制 -->
<div class="music-bar-volume" title="音量加减 [Ctrl + Up / Down]">
<volume :volume="volume" @volumeChange="volumeChange" />
</div>
</div>
<!--遮罩-->
<div class="wangyiyun-bg" :style="{ backgroundImage: picUrl }"></div>
<div class="wangyiyun-mask"></div>
</div>
</template>
<script>
import { getLyric } from 'api'
import wangyiyunMusic from './wyyPlayer'
import { randomSortArray, parseLyric, format, silencePromise } from '@/utils/util'
import { PLAY_MODE, WYYPLAYER_CONFIG } from '@/config'
import { getVolume, setVolume } from '@/utils/storage'
import { mapGetters, mapMutations, mapActions } from 'vuex'
import WyyProgress from 'base/wyy-progress/wyy-progress'
import MusicBtn from 'components/music-btn/music-btn'
import Lyric from 'components/lyric/lyric'
import Volume from 'components/volume/volume'
export default {
name: 'Music',
components: {
WyyProgress,
MusicBtn,
Lyric,
Volume,
},
filters: {
//
format,
},
data() {
const volume = getVolume()
return {
musicReady: false, // 使
currentTime: 0, //
currentProgress: 0, //
lyricVisible: false, //
lyric: [], //
nolyric: false, //
lyricIndex: 0, //
isMute: false, //
volume, //
}
},
computed: {
picUrl() {
return this.currentMusic.id && this.currentMusic.image
? `url(${this.currentMusic.image}?param=300y300)`
: `url(${WYYPLAYER_CONFIG.BACKGROUND})`
},
percentMusic() {
const duration = this.currentMusic.duration
return this.currentTime && duration ? this.currentTime / duration : 0
},
...mapGetters([
'audioEle',
'mode',
'playing',
'playlist',
'orderList',
'currentIndex',
'currentMusic',
'historyList',
]),
},
watch: {
currentMusic(newMusic, oldMusic) {
if (!newMusic.id) {
this.lyric = []
return
}
if (newMusic.id === oldMusic.id) {
return
}
this.audioEle.src = newMusic.url
//
this.lyricIndex = this.currentTime = this.currentProgress = 0
silencePromise(this.audioEle.play())
this.$nextTick(() => {
this._getLyric(newMusic.id)
})
},
playing(newPlaying) {
const audio = this.audioEle
this.$nextTick(() => {
newPlaying ? silencePromise(audio.play()) : audio.pause()
this.musicReady = true
})
},
currentTime(newTime) {
if (this.nolyric) {
return
}
let lyricIndex = 0
for (let i = 0; i < this.lyric.length; i++) {
if (newTime > this.lyric[i].time) {
lyricIndex = i
}
}
this.lyricIndex = lyricIndex
},
$route() {
this.lyricVisible = false
},
},
mounted() {
this.$nextTick(() => {
wangyiyunMusic.initAudio(this)
this.initKeyDown()
this.volumeChange(this.volume)
})
},
methods: {
//
initKeyDown() {
document.onkeydown = (e) => {
switch (e.ctrlKey && e.keyCode) {
case 32: // Ctrl + Space
this.play()
break
case 37: // Ctrl + Left
this.prev()
break
case 38: // Ctrl + Up
let plus = Number((this.volume += 0.1).toFixed(1))
if (plus > 1) {
plus = 1
}
this.volumeChange(plus)
break
case 39: // Ctrl + Right
this.next()
break
case 40: // Ctrl + Down
let reduce = Number((this.volume -= 0.1).toFixed(1))
if (reduce < 0) {
reduce = 0
}
this.volumeChange(reduce)
break
case 79: // Ctrl + O
this.modeChange()
break
}
}
},
//
prev() {
if (!this.musicReady) {
return
}
if (this.playlist.length === 1) {
this.loop()
} else {
let index = this.currentIndex - 1
if (index < 0) {
index = this.playlist.length - 1
}
this.setCurrentIndex(index)
if (!this.playing && this.musicReady) {
this.setPlaying(true)
}
this.musicReady = false
}
},
//
play() {
if (!this.musicReady) {
return
}
this.setPlaying(!this.playing)
},
//
// flag true
next(flag = false) {
if (!this.musicReady) {
return
}
const {
playlist: { length },
} = this
if (
(length - 1 === this.currentIndex && this.mode === PLAY_MODE.ORDER) ||
(length === 1 && flag)
) {
this.setCurrentIndex(-1)
this.setPlaying(false)
return
}
if (length === 1) {
this.loop()
} else {
let index = this.currentIndex + 1
if (index === length) {
index = 0
}
if (!this.playing && this.musicReady) {
this.setPlaying(true)
}
this.setCurrentIndex(index)
this.musicReady = false
}
},
//
loop() {
this.audioEle.currentTime = 0
silencePromise(this.audioEle.play())
this.setPlaying(true)
if (this.lyric.length > 0) {
this.lyricIndex = 0
}
},
//
progressMusic(percent) {
this.currentTime = this.currentMusic.duration * percent
},
//
progressMusicEnd(percent) {
this.audioEle.currentTime = this.currentMusic.duration * percent
},
//
modeChange() {
const mode = (this.mode + 1) % 4
this.setPlayMode(mode)
if (mode === PLAY_MODE.LOOP) {
return
}
let list = []
switch (mode) {
case PLAY_MODE.LIST_LOOP:
case PLAY_MODE.ORDER:
list = this.orderList
break
case PLAY_MODE.RANDOM:
list = randomSortArray(this.orderList)
break
}
this.resetCurrentIndex(list)
this.setPlaylist(list)
},
//
resetCurrentIndex(list) {
const index = list.findIndex((item) => {
return item.id === this.currentMusic.id
})
this.setCurrentIndex(index)
},
//
openComment() {
if (!this.currentMusic.id) {
this.$wyyToast('还没有播放歌曲哦!')
return false
}
this.$router.push(`/music/comment/${this.currentMusic.id}`)
},
//
volumeChange(percent) {
percent === 0 ? (this.isMute = true) : (this.isMute = false)
this.volume = percent
this.audioEle.volume = percent
setVolume(percent)
},
// icon
getModeIconType() {
return {
[PLAY_MODE.LIST_LOOP]: 'loop',
[PLAY_MODE.ORDER]: 'sequence',
[PLAY_MODE.RANDOM]: 'random',
[PLAY_MODE.LOOP]: 'loop-one',
}[this.mode]
},
// title
getModeIconTitle() {
const key = 'Ctrl + O'
return {
[PLAY_MODE.LIST_LOOP]: `列表循环 ${key}`,
[PLAY_MODE.ORDER]: `顺序播放 ${key}`,
[PLAY_MODE.RANDOM]: `随机播放 ${key}`,
[PLAY_MODE.LOOP]: `单曲循环 ${key}`,
}[this.mode]
},
//
handleOpenLyric() {
this.lyricVisible = true
this.$nextTick(() => {
this.$refs.lyric.clacTop()
})
},
//
handleCloseLyric() {
this.lyricVisible = false
},
//
_getLyric(id) {
getLyric(id).then((res) => {
if (res.lrc && res.lrc.lyric) {
this.nolyric = false
this.lyric = parseLyric(res.lrc.lyric)
} else {
this.nolyric = true
}
silencePromise(this.audioEle.play())
})
},
...mapMutations({
setPlaying: 'SET_PLAYING',
setPlaylist: 'SET_PLAYLIST',
setCurrentIndex: 'SET_CURRENTINDEX',
}),
...mapActions(['setHistory', 'setPlayMode']),
},
}
</script>
<style lang="less">
.router-view {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.music {
padding: 75px 25px 25px 25px;
width: 100%;
max-width: 1750px;
margin: 0 auto;
height: 100%;
box-sizing: border-box;
overflow: hidden;
.music-content {
display: flex;
flex: 1;
overflow: hidden;
width: 100%;
.music-left {
flex: 1;
width: 100%;
overflow: hidden;
}
.music-right {
position: relative;
width: 310px;
margin-left: 10px;
.close-lyric {
position: absolute;
top: 0;
z-index: 1;
cursor: pointer;
}
}
}
/*底部wangyiyun-bar*/
.music-bar {
display: flex;
align-items: center;
width: 100%;
padding: 15px 0;
color: #fff;
&.disable {
pointer-events: none;
opacity: 0.6;
}
.icon-color {
color: #fff;
}
.music-bar-btns {
display: flex;
justify-content: space-between;
align-items: center;
width: 180px;
.control-play {
.flex-center;
border-radius: 50%;
width: 40px;
height: 40px;
color: #fff;
background-color: rgba(255, 255, 255, 0.3);
}
}
.flex-center;
.btn-prev {
width: 19px;
min-width: 19px;
height: 20px;
background-position: 0 -30px;
}
.btn-play {
width: 21px;
min-width: 21px;
height: 29px;
margin: 0 50px;
background-position: 0 0;
&.btn-play-pause {
background-position: -30px 0;
}
}
.btn-next {
width: 19px;
min-width: 19px;
height: 20px;
background-position: 0 -52px;
}
.music-music {
position: relative;
width: 100%;
flex: 1;
box-sizing: border-box;
padding-left: 40px;
font-size: @font_size_small;
color: @text_color_active;
.music-bar-info {
height: 15px;
padding-right: 80px;
line-height: 15px;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
}
.music-bar-time {
position: absolute;
top: 0;
right: 5px;
}
}
.mode,
.comment,
.music-bar-volume {
margin-left: 20px;
}
//
.volume-wrapper {
margin-left: 20px;
width: 150px;
}
}
/*遮罩*/
.wangyiyun-mask,
.wangyiyun-bg {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
}
.wangyiyun-mask {
z-index: -1;
background-color: @mask_color;
}
.wangyiyun-bg {
z-index: -2;
background-repeat: no-repeat;
background-size: cover;
background-position: 50%;
filter: blur(12px);
opacity: 0.7;
transition: all 0.8s;
transform: scale(1.1);
}
@media (min-width: 960px) {
.close-lyric {
display: none;
}
}
//960
@media (max-width: 960px) {
.music-right {
display: none;
&.show {
display: block;
margin-left: 0;
width: 100%;
}
}
}
//768
@media (max-width: 768px) {
padding: 75px 15px 5px 15px;
.music-bar {
padding-top: 10px;
.music-bar-info span,
.music-bar-volume .mmProgress {
display: none;
}
}
}
//520
@media (max-width: 520px) {
.music-bar {
position: relative;
flex-direction: column;
.music-bar-btns {
width: 60%;
margin-top: 10px;
order: 2;
}
.music-music {
padding-left: 0;
order: 1;
}
.mode,
.comment {
position: absolute;
bottom: 20px;
margin: 0;
}
.mode {
left: 5px;
}
.comment {
right: 5px;
}
.music-bar-volume {
display: none;
}
}
}
}
</style>

View File

@ -0,0 +1,65 @@
<template>
<!--正在播放-->
<div class="playList">
<music-list :list="playlist" list-type="duration" @select="selectItem" @del="deleteItem">
<div slot="listBtn" class="list-btn">
<span @click="$refs.dialog.show()">清空列表</span>
</div>
</music-list>
<wyy-dialog
ref="dialog"
body-text="是否清空正在播放列表"
confirm-btn-text="清空"
@confirm="clearList"
/>
</div>
</template>
<script>
import { mapGetters, mapMutations, mapActions } from 'vuex'
import MusicList from 'components/music-list/music-list'
import WyyDialog from 'base/wyy-dialog/wyy-dialog'
export default {
name: 'PlayList',
components: {
MusicList,
WyyDialog,
},
data() {
return {
show: false,
}
},
computed: {
...mapGetters(['playing', 'playlist', 'currentMusic']),
},
methods: {
//
clearList() {
this.clearPlayList()
this.$wyyToast('列表清空成功')
},
//
selectItem(item, index) {
if (item.id !== this.currentMusic.id) {
this.setCurrentIndex(index)
this.setPlaying(true)
}
},
//
deleteItem(index) {
let list = [...this.playlist]
list.splice(index, 1)
this.removerPlayListItem({ list, index })
this.$wyyToast('删除成功')
},
...mapMutations({
setPlaying: 'SET_PLAYING',
setCurrentIndex: 'SET_CURRENTINDEX',
clearPlaylist: 'CLEAR_PLAYLIST',
}),
...mapActions(['removerPlayListItem', 'clearPlayList']),
},
}
</script>

168
src/pages/search/search.vue Normal file
View File

@ -0,0 +1,168 @@
<template>
<!--搜索-->
<div class="search flex-col">
<wyy-loading v-model="wyyLoadShow" />
<div class="search-head">
<span v-for="(item, index) in Artists" :key="index" @click="clickHot(item.first)">
{{ item.first }}
</span>
<input
v-model.trim="searchValue"
class="search-input"
type="text"
placeholder="音乐/歌手"
@keyup.enter="onEnter"
/>
</div>
<div class="flex-1 overflow-hidden">
<music-list
ref="musicList"
:list="list"
list-type="pullup"
@select="selectItem"
@pullUp="pullUpLoad"
/>
</div>
</div>
</template>
<script>
import { mapGetters, mapActions, mapMutations } from 'vuex'
import { search, searchHot, getMusicDetail } from 'api'
import { formatSongs } from '@/utils/song'
import WyyLoading from 'base/wyy-loading/wyy-loading'
import MusicList from 'components/music-list/music-list'
import { loadMixin } from '@/utils/mixin'
import { toHttps } from '@/utils/util'
export default {
name: 'Search',
components: {
WyyLoading,
MusicList,
},
mixins: [loadMixin],
data() {
return {
searchValue: '', //
Artists: [], //
list: [], //
page: 0, //
lockUp: true, // ,
}
},
computed: {
...mapGetters(['playing', 'currentMusic']),
},
watch: {
list(newList, oldList) {
if (newList.length !== oldList.length) {
this.lockUp = false
} else if (newList[newList.length - 1].id !== oldList[oldList.length - 1].id) {
this.lockUp = false
}
},
},
created() {
//
searchHot().then(({ result }) => {
this.Artists = result.hots.slice(0, 5)
this.wyyLoadShow = false
})
},
methods: {
//
clickHot(name) {
this.searchValue = name
this.onEnter()
},
//
onEnter() {
if (this.searchValue.replace(/(^\s+)|(\s+$)/g, '') === '') {
this.$wyyToast('搜索内容不能为空!')
return
}
this.wyyLoadShow = true
this.page = 0
if (this.list.length > 0) {
this.$refs.musicList.scrollTo()
}
search(this.searchValue).then(({ result }) => {
this.list = formatSongs(result.songs)
this._hideLoad()
})
},
//
pullUpLoad() {
this.page += 1
search(this.searchValue, this.page).then(({ result }) => {
if (!result.songs) {
this.$wyyToast('没有更多歌曲啦!')
return
}
this.list = [...this.list, ...formatSongs(result.songs)]
})
},
//
async selectItem(music) {
try {
const image = await this._getMusicDetail(music.id)
music.image = toHttps(image)
this.selectAddPlay(music)
} catch (error) {
this.$wyyToast('哎呀,出错啦~')
}
},
//
_getMusicDetail(id) {
return getMusicDetail(id).then((res) => res.songs[0].al.picUrl)
},
...mapMutations({
setPlaying: 'SET_PLAYING',
}),
...mapActions(['selectAddPlay']),
},
}
</script>
<style lang="less" scoped>
.search {
overflow: hidden;
height: 100%;
.search-head {
display: flex;
height: 40px;
padding: 10px 15px;
overflow: hidden;
background: @search_bg_color;
span {
line-height: 40px;
margin-right: 15px;
cursor: pointer;
&:hover {
color: @text_color_active;
}
@media (max-width: 640px) {
& {
display: none;
}
}
}
.search-input {
flex: 1;
height: 40px;
box-sizing: border-box;
padding: 0 15px;
border: 1px solid @btn_color;
outline: 0;
background: transparent;
color: @text_color_active;
font-size: @font_size_medium;
box-shadow: 0 0 1px 0 #fff inset;
&::placeholder {
color: @text_color;
}
}
}
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<!--排行榜-->
<div class="topList">
<wyy-loading v-model="wyyLoadShow" />
<template v-if="!wyyLoadShow">
<div class="topList-head">云音乐特色榜</div>
<div class="topList-content">
<div
v-for="(item, index) in list"
:key="index"
class="list-item"
:title="`${item.name}-${item.updateFrequency}`"
>
<router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="topList-item">
<div class="topList-img">
<img v-lazy="`${item.coverImgUrl}?param=300y300`" class="cover-img" />
</div>
<h3 class="name">{{ item.name }}</h3>
</router-link>
</div>
</div>
<div class="topList-head">热门歌单</div>
<div class="topList-content">
<div v-for="(item, index) in hotList" :key="index" class="list-item" :title="item.name">
<router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="topList-item">
<div class="topList-img">
<img v-lazy="`${item.picUrl}?param=300y300`" class="cover-img" />
</div>
<h3 class="name">{{ item.name }}</h3>
</router-link>
</div>
</div>
</template>
</div>
</template>
<script>
import { getToplistDetail, getPersonalized } from 'api'
import WyyLoading from 'base/wyy-loading/wyy-loading'
import { loadMixin } from '@/utils/mixin'
export default {
name: 'PlayList',
components: {
WyyLoading,
},
mixins: [loadMixin],
data() {
return {
list: [], //
hotList: [], //
}
},
created() {
Promise.all([getToplistDetail(), getPersonalized()])
.then(([topList, hotList]) => {
this.list = topList.list.filter((v) => v.ToplistType)
this.hotList = hotList.result.slice()
this._hideLoad()
})
.catch(() => {})
},
}
</script>
<style lang="less" scoped>
.topList {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
&-head {
width: 100%;
height: 34px;
line-height: 34px;
padding: 20px 0;
font-size: @font_size_large;
color: @text_color_active;
}
&-content {
overflow: hidden;
}
.list-item {
float: left;
width: calc(~'100% / 7');
.topList-item {
width: 130px;
text-align: center;
cursor: pointer;
margin: 0 auto 20px;
&:hover {
color: #fff;
}
.name {
height: 30px;
line-height: 30px;
font-size: @font_size_medium;
.no-wrap();
}
@media (max-width: 1100px) {
width: 80%;
}
}
@media (max-width: 1500px) {
width: calc(~'100% / 6');
}
@media (max-width: 1400px), (max-width: 960px) {
width: calc(~'100% / 5');
}
@media (max-width: 1280px), (max-width: 768px) {
width: calc(~'100% / 4');
}
@media (max-width: 540px) {
width: calc(~'100% / 3');
}
.topList-img {
position: relative;
padding-top: 100%;
width: 100%;
height: 0;
.cover-img {
position: absolute;
top: 0;
left: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,125 @@
<template>
<!--我的歌单-->
<div class="userList">
<wyy-loading v-model="wyyLoadShow" />
<template v-if="list.length > 0">
<div v-for="item in formatList" :key="item.id" class="list-item" :title="item.name">
<router-link :to="{ path: `/music/details/${item.id}` }" tag="div" class="userList-item">
<img v-lazy="`${item.coverImgUrl}?param=200y200`" class="cover-img" />
<h3 class="name">{{ item.name }}</h3>
</router-link>
</div>
</template>
<wyy-no-result v-else title="啥也没有哦,快去登录看看吧!" />
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { getUserPlaylist } from 'api'
import { loadMixin } from '@/utils/mixin'
import WyyLoading from 'base/wyy-loading/wyy-loading'
import WyyNoResult from 'base/wyy-no-result/wyy-no-result'
export default {
name: 'PlayList',
components: {
WyyLoading,
WyyNoResult,
},
mixins: [loadMixin],
data() {
return {
list: [], //
}
},
computed: {
formatList() {
return this.list.filter((item) => item.trackCount > 0)
},
...mapGetters(['uid']),
},
watch: {
uid(newUid) {
if (newUid) {
this.wyyLoadShow = true
this._getUserPlaylist(newUid)
} else {
this.list = []
}
},
},
created() {
if (!this.uid) {
this.wyyLoadShow = false
}
},
activated() {
if (this.uid && this.list.length === 0) {
this.wyyLoadShow = true
this._getUserPlaylist(this.uid)
} else if (!this.uid && this.list.length !== 0) {
this.list = []
}
},
methods: {
//
_getUserPlaylist(uid) {
getUserPlaylist(uid).then((res) => {
if (res.playlist.length === 0) {
return
}
this.list = res.playlist.slice(1)
this._hideLoad()
})
},
},
}
</script>
<style lang="less" scoped>
.userList {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
&-head {
height: 100px;
}
.list-item {
float: left;
width: calc(~'100% / 7');
.userList-item {
width: 130px;
text-align: center;
cursor: pointer;
margin: 0 auto 20px;
&:hover {
color: #fff;
}
.name {
height: 30px;
line-height: 30px;
font-size: @font_size_medium;
.no-wrap();
}
@media (max-width: 1100px) {
width: 80%;
}
}
@media (max-width: 1500px) {
width: calc(~'100% / 6');
}
@media (max-width: 1400px), (max-width: 960px) {
width: calc(~'100% / 5');
}
@media (max-width: 1280px), (max-width: 768px) {
width: calc(~'100% / 4');
}
@media (max-width: 540px) {
width: calc(~'100% / 3');
}
}
}
</style>

93
src/pages/wyyPlayer.js Normal file
View File

@ -0,0 +1,93 @@
import { PLAY_MODE } from '@/config'
// 重试次数
let retry = 1
const wangyiyunMusic = {
initAudio(that) {
const ele = that.audioEle
// 音频缓冲事件
ele.onprogress = () => {
try {
if (ele.buffered.length > 0) {
const duration = that.currentMusic.duration
let buffered = 0
ele.buffered.end(0)
buffered = ele.buffered.end(0) > duration ? duration : ele.buffered.end(0)
that.currentProgress = buffered / duration
}
} catch (e) {}
}
// 开始播放音乐
ele.onplay = () => {
let timer
clearTimeout(timer)
timer = setTimeout(() => {
that.musicReady = true
}, 100)
}
// 获取当前播放时间
ele.ontimeupdate = () => {
that.currentTime = ele.currentTime
}
// 当前音乐播放完毕
ele.onended = () => {
if (that.mode === PLAY_MODE.LOOP) {
that.loop()
} else {
that.next()
}
}
// 音乐播放出错
ele.onerror = () => {
if (retry === 0) {
let toastText = '当前音乐不可播放,已自动播放下一曲'
if (that.playlist.length === 1) {
toastText = '没有可播放的音乐哦~'
}
that.$wyyToast(toastText)
that.next(true)
} else {
// eslint-disable-next-line no-console
console.log('重试一次')
retry -= 1
ele.url = that.currentMusic.url
ele.load()
}
// console.log('播放出错啦!')
}
// 音乐进度拖动大于加载时重载音乐
ele.onstalled = () => {
ele.load()
that.setPlaying(false)
let timer
clearTimeout(timer)
timer = setTimeout(() => {
that.setPlaying(true)
}, 10)
}
// 将能播放的音乐加入播放历史
ele.oncanplay = () => {
retry = 1
if (that.historyList.length === 0 || that.currentMusic.id !== that.historyList[0].id) {
that.setHistory(that.currentMusic)
}
}
// 音频数据不可用时
ele.onstalled = () => {
ele.load()
that.setPlaying(false)
let timer
clearTimeout(timer)
timer = setTimeout(() => {
that.setPlaying(true)
}, 10)
}
// 当音频已暂停时
ele.onpause = () => {
that.setPlaying(false)
}
},
}
export default wangyiyunMusic

72
src/router/index.js Normal file
View File

@ -0,0 +1,72 @@
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
const routes = [
{
path: '/',
redirect: '/music',
},
{
path: '/music',
component: () => import('pages/music'),
redirect: '/music/playlist',
children: [
{
path: '/music/playlist', // 正在播放列表
component: () => import('pages/playList/playList'),
meta: {
keepAlive: true,
},
},
{
path: '/music/userlist', // 我的歌单
component: () => import('pages/userList/userList'),
meta: {
title: '我的歌单',
keepAlive: true,
},
},
{
path: '/music/toplist', // 排行榜列表
component: () => import('pages/topList/topList'),
meta: {
title: '排行榜',
keepAlive: true,
},
},
{
path: '/music/details/:id', // 音乐详情列表
component: () => import('pages/details/details'),
},
{
path: '/music/historylist', // 我听过的列表
component: () => import('pages/historyList/historyList'),
meta: {
title: '我听过的',
},
},
{
path: '/music/search', // 搜索
component: () => import('pages/search/search'),
meta: {
title: '搜索',
keepAlive: true,
},
},
{
path: '/music/comment/:id', // 音乐评论
component: () => import('pages/comment/comment'),
meta: {
title: '评论详情',
},
},
],
},
]
export default new Router({
linkActiveClass: 'active',
linkExactActiveClass: 'active',
routes,
})

88
src/store/actions.js Normal file
View File

@ -0,0 +1,88 @@
import {
clearHistoryList,
setHistoryList,
removeHistoryList,
setMode,
setUserId,
} from '@/utils/storage'
import * as types from './mutation-types'
function findIndex(list, music) {
return list.findIndex((item) => {
return item.id === music.id
})
}
// 设置播放列表
export const setPlaylist = function ({ commit }, { list }) {
commit(types.SET_PLAYLIST, list)
commit(types.SET_ORDERLIST, list)
}
// 选择播放(会更新整个播放列表)
export const selectPlay = function ({ commit }, { list, index }) {
commit(types.SET_PLAYLIST, list)
commit(types.SET_ORDERLIST, list)
commit(types.SET_CURRENTINDEX, index)
commit(types.SET_PLAYING, true)
}
// 选择播放(会插入一条到播放列表)
export const selectAddPlay = function ({ commit, state }, music) {
let list = [...state.playlist]
// 查询当前播放列表是否有代插入的音乐,并返回其索引值
let index = findIndex(list, music)
// 当前播放列表有待插入的音乐时,直接改变当前播放音乐的索引值
if (index > -1) {
commit(types.SET_CURRENTINDEX, index)
} else {
list.unshift(music)
commit(types.SET_PLAYLIST, list)
commit(types.SET_ORDERLIST, list)
commit(types.SET_CURRENTINDEX, 0)
}
commit(types.SET_PLAYING, true)
}
// 清空播放列表
export const clearPlayList = function ({ commit }) {
commit(types.SET_PLAYING, false)
commit(types.SET_CURRENTINDEX, -1)
commit(types.SET_PLAYLIST, [])
commit(types.SET_ORDERLIST, [])
}
// 删除正在播放列表中的歌曲
export const removerPlayListItem = function ({ commit, state }, { list, index }) {
let currentIndex = state.currentIndex
if (index < state.currentIndex || list.length === state.currentIndex) {
currentIndex--
commit(types.SET_CURRENTINDEX, currentIndex)
}
commit(types.SET_PLAYLIST, list)
commit(types.SET_ORDERLIST, list)
if (!list.length) {
commit(types.SET_PLAYING, false)
} else {
commit(types.SET_PLAYING, true)
}
}
// 设置播放历史
export const setHistory = function ({ commit }, music) {
commit(types.SET_HISTORYLIST, setHistoryList(music))
}
// 删除播放历史
export const removeHistory = function ({ commit }, music) {
commit(types.SET_HISTORYLIST, removeHistoryList(music))
}
// 清空播放历史
export const clearHistory = function ({ commit }) {
commit(types.SET_HISTORYLIST, clearHistoryList())
}
// 设置播放模式
export const setPlayMode = function ({ commit }, mode) {
commit(types.SET_PLAYMODE, setMode(mode))
}
// 设置网易云用户UID
export const setUid = function ({ commit }, uid) {
commit(types.SET_UID, setUserId(uid))
}

20
src/store/getters.js Normal file
View File

@ -0,0 +1,20 @@
// audio元素
export const audioEle = (state) => state.audioEle
// 播放模式
export const mode = (state) => state.mode
// 播放状态
export const playing = (state) => state.playing
// 播放列表
export const playlist = (state) => state.playlist
// 顺序列表
export const orderList = (state) => state.orderList
// 当前音乐索引
export const currentIndex = (state) => state.currentIndex
// 当前音乐
export const currentMusic = (state) => {
return state.playlist[state.currentIndex] || {}
}
// 播放历史列表
export const historyList = (state) => state.historyList
// 网易云用户UID
export const uid = (state) => state.uid

21
src/store/index.js Normal file
View File

@ -0,0 +1,21 @@
import Vue from 'vue'
import Vuex from 'vuex'
import state from './state'
import * as getters from './getters'
import * as actions from './actions'
import mutations from './mutations'
// vuex调试
import createLogger from 'vuex/dist/logger'
const debug = process.env.NODE_ENV !== 'production'
Vue.use(Vuex)
export default new Vuex.Store({
state,
getters,
mutations,
actions,
// vuex调试
strict: debug,
plugins: debug ? [createLogger()] : [],
})

View File

@ -0,0 +1,8 @@
export const SET_AUDIOELE = 'SET_AUDIOELE' // 修改audio元素
export const SET_PLAYMODE = 'SET_PLAYMODE' // 修改播放模式
export const SET_PLAYING = 'SET_PLAYING' // 修改播放状态
export const SET_PLAYLIST = 'SET_PLAYLIST' // 修改播放列表
export const SET_ORDERLIST = 'SET_ORDERLIST' // 修改顺序列表
export const SET_CURRENTINDEX = 'SET_CURRENTINDEX' // 修改当前音乐索引
export const SET_HISTORYLIST = 'SET_HISTORYLIST' // 修改播放历史列表
export const SET_UID = 'SET_UID' // 修改网易云用户UID

38
src/store/mutations.js Normal file
View File

@ -0,0 +1,38 @@
import * as types from './mutation-types'
const mutations = {
// 修改audio元素
[types.SET_AUDIOELE](state, audioEle) {
state.audioEle = audioEle
},
// 修改播放模式
[types.SET_PLAYMODE](state, mode) {
state.mode = mode
},
// 修改播放状态
[types.SET_PLAYING](state, playing) {
state.playing = playing
},
// 修改播放列表
[types.SET_PLAYLIST](state, playlist) {
state.playlist = playlist
},
// 修改顺序列表
[types.SET_ORDERLIST](state, orderList) {
state.orderList = orderList
},
// 修改当前音乐索引
[types.SET_CURRENTINDEX](state, currentIndex) {
state.currentIndex = currentIndex
},
// 修改播放历史列表
[types.SET_HISTORYLIST](state, historyList) {
state.historyList = historyList
},
// 修改网易云用户UID
[types.SET_UID](state, uid) {
state.uid = uid
},
}
export default mutations

14
src/store/state.js Normal file
View File

@ -0,0 +1,14 @@
import { getHistoryList, getMode, getUserId } from '@/utils/storage'
const state = {
audioEle: null, // audio元素
mode: getMode(), // 播放模式,默认列表循环
playing: false, // 播放状态
playlist: [], // 播放列表
orderList: [], // 顺序列表
currentIndex: -1, // 当前音乐索引
historyList: getHistoryList() || [], // 播放历史列表
uid: getUserId() || null, // 网易云用户UID
}
export default state

117
src/styles/index.less Normal file
View File

@ -0,0 +1,117 @@
@import 'reset';
@import 'var';
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
min-width: 320px;
font-family: Arial;
}
#app {
position: relative;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
//浮动
.fl {
float: left;
}
.fr {
float: right;
}
.pointer {
cursor: pointer;
}
.hover {
color: @text_color;
cursor: pointer;
&:hover {
color: @text_color_active;
}
}
.text-left {
text-align: left;
}
.clearfix {
&:after {
display: block;
content: '';
clear: both;
}
}
.flex {
display: flex;
}
.flex-col {
display: flex;
flex-direction: column;
}
.flex-1 {
flex: 1;
}
.overflow-hidden {
overflow: hidden;
}
/* 滚动条相关样式 */
::-webkit-scrollbar {
/*滚动条整体部分其中的属性有width,height,background,border就和一个块级元素一样等*/
background-color: @scrollbar_bg;
width: @scrollbar_size; // 纵向滚动条
height: @scrollbar_size; // 横向滚动条
border-radius: @scrollbar_border_radius;
}
::-webkit-scrollbar-button {
/*滚动条两端的按钮。可以用display:none让其不显示也可以添加背景图片颜色改变显示效果。*/
display: none;
}
::-webkit-scrollbar-track {
/*外层轨道。可以用display:none让其不显示也可以添加背景图片颜色改变显示效果。*/
display: none;
//background-color: rgba(255, 255, 255, 0.1);
border-radius: @scrollbar_border_radius;
}
::-webkit-scrollbar-track-piece {
/*内层轨道,滚动条中间部分(除去)。*/
//background-color: rgba(255, 255, 255, .1);
border-radius: @scrollbar_border_radius;
}
::-webkit-scrollbar-thumb {
/*滚动条里面可以拖动的那部分*/
background-color: @scrollbar_thumb;
border-radius: @scrollbar_border_radius;
}
::-webkit-scrollbar-corner {
border-radius: @scrollbar_border_radius;
}
::-webkit-resizer {
/*定义右下角拖动块的样式*/
border-radius: @scrollbar_border_radius;
}

13
src/styles/mixin.less Normal file
View File

@ -0,0 +1,13 @@
// 显示省略号
.no-wrap() {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.flex-center(@direction: row) {
display: flex;
flex-direction: @direction;
justify-content: center;
align-items: center;
}

141
src/styles/reset.less Normal file
View File

@ -0,0 +1,141 @@
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
hgroup,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
-webkit-tap-highlight-color: transparent;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
menu,
nav,
section {
display: block;
}
body {
line-height: 1;
}
ol,
ul {
list-style: none;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
a {
text-decoration: none;
color: inherit;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
}

71
src/styles/var.less Normal file
View File

@ -0,0 +1,71 @@
/* 颜色规范定义 */
@text_color: rgba(255, 255, 255, 0.6);
@text_color_active: #fff; //重点部分
// active 颜色
@active_color: #fff;
// 遮罩层颜色
@mask_color: rgba(0, 0, 0, 0.4);
// 背景滤镜
@backdrop_filter: blur(6px);
/* 字体大小规范定义*/
@font_size_small: 12px;
@font_size_medium: 14px;
@font_size_medium_x: 16px;
@font_size_large: 18px;
@font_size_large_x: 22px;
/* 圆角规范定义 */
@border_radius_base: 2px;
@border_radius: 4px;
/* 滚动条相关 */
// 滚动条圆角
@scrollbar_border_radius: 10px;
// 滚动条大小
@scrollbar_size: 5px;
@scrollbar_bg: rgba(0, 0, 0, 0.3);
@scrollbar_thumb: rgba(255, 255, 255, 0.5);
/* loading */
@load_bg_color: rgba(0, 0, 0, 0.2);
/* header */
@header_bg_color: rgba(0, 0, 0, 0.3);
/* search-head */
@search_bg_color: rgba(0, 0, 0, 0.2);
/* dialog 相关 */
@dialog_bg_color: rgba(0, 0, 0, 0.5);
@dialog_content_bg_color: rgba(0, 0, 0, 0.6);
@dialog_text_color: rgba(255, 255, 255, 0.7);
@dialog_line_color: rgba(0, 0, 0, 0.35);
@dialog_border_radius: @border_radius;
@dialog_btn_border_radius: @border_radius;
@dialog_mobile_border_radius: 10px;
@dialog_btn_mobile_border_radius: @border_radius_base;
/* btn 相关 */
@btn_color: rgba(255, 255, 255, 0.6);
@btn_color_active: #fff;
@btn_border_radius: @border_radius_base;
/* 歌词高亮颜色 */
@lyric_color_active: #40ce8f;
/* 进度条 */
@bar_color: rgba(255, 255, 255, 0.15);
@line_color: #fff;
@dot_color: #fff;
/* 列表 */
@list_head_line_color: rgba(255, 255, 255, 0.8);
@list_item_line_color: rgba(255, 255, 255, 0.1);
/* 评论 */
@comment_head_line_color: rgba(255, 255, 255, 0.8);
@comment_item_line_color: rgba(255, 255, 255, 0.1);
@comment_replied_line_color: rgba(255, 255, 255, 0.3);

23
src/utils/axios.js Normal file
View File

@ -0,0 +1,23 @@
import axios from 'axios'
import Vue from 'vue'
const request = axios.create({
baseURL: process.env.VUE_APP_BASE_API_URL,
})
request.interceptors.response.use(
(response) => {
window.response = response
if (response.status === 200 && response.data.code === 200) {
return response.data
}
return Promise.reject(response)
},
(error) => {
Vue.prototype.$wyyToast(error.response ? error.response.data.message : error.message)
return Promise.reject(error)
},
)
export default request

6
src/utils/hack.js Normal file
View File

@ -0,0 +1,6 @@
// hack for global nextTick
function noop() {}
window.MessageChannel = noop
window.setImmediate = noop

47
src/utils/mixin.js Normal file
View File

@ -0,0 +1,47 @@
import { mapGetters, mapMutations, mapActions } from 'vuex'
/**
* 歌曲列表
*/
export const listMixin = {
computed: {
...mapGetters(['playing', 'currentMusic']),
},
methods: {
selectItem(item, index) {
if (item.id === this.currentMusic.id && this.playing) {
this.setPlaying(false)
} else {
this.selectPlay({
list: this.list,
index,
})
}
},
...mapMutations({
setPlaying: 'SET_PLAYING',
}),
...mapActions(['selectPlay']),
},
}
/**
* loading状态
* @type {{data(): *, methods: {_hideLoad(): void}}}
*/
export const loadMixin = {
data() {
return {
wyyLoadShow: true, // loading状态
}
},
methods: {
_hideLoad() {
let timer
clearTimeout(timer)
timer = setTimeout(() => {
this.wyyLoadShow = false
}, 200)
},
},
}

50
src/utils/song.js Normal file
View File

@ -0,0 +1,50 @@
import { toHttps } from './util'
function filterSinger(singers) {
if (!Array.isArray(singers) || !singers.length) {
return ''
}
let arr = []
singers.forEach((item) => {
arr.push(item.name)
})
return arr.join('/')
}
export class Song {
constructor({ id, name, singer, album, image, duration, url }) {
this.id = id
this.name = name
this.singer = singer
this.album = album
this.image = image
this.duration = duration
this.url = url
}
}
export function createSong(music) {
const album = music.album || music.al || {}
const duration = music.duration || music.dt
return new Song({
id: music.id,
name: music.name,
singer: filterSinger(music.ar || music.artists),
album: album.name,
image: toHttps(album.picUrl) || null,
duration: duration / 1000,
url: `https://music.163.com/song/media/outer/url?id=${music.id}.mp3`,
})
}
// 歌曲数据格式化
export function formatSongs(list) {
const Songs = []
list.forEach((item) => {
const musicData = item
if (musicData.id) {
Songs.push(createSong(musicData))
}
})
return Songs
}

131
src/utils/storage.js Normal file
View File

@ -0,0 +1,131 @@
import { WYYPLAYER, WYYPLAYER_CONFIG } from '@/config'
const STORAGE = window.localStorage
const storage = {
get(key, data = []) {
if (STORAGE) {
return STORAGE.getItem(key)
? Array.isArray(data)
? JSON.parse(STORAGE.getItem(key))
: STORAGE.getItem(key)
: data
}
},
set(key, val) {
if (STORAGE) {
STORAGE.setItem(key, val)
}
},
clear(key) {
if (STORAGE) {
STORAGE.removeItem(key)
}
},
}
/**
* 播放历史
* @type HISTORYLIST_KEYkey值
* HistoryListMAX最大长度
*/
const HISTORYLIST_KEY = '__wangyiyun_historyList__'
const HistoryListMAX = 200
// 获取播放历史
export function getHistoryList() {
return storage.get(HISTORYLIST_KEY)
}
// 更新播放历史
export function setHistoryList(music) {
let list = storage.get(HISTORYLIST_KEY)
const index = list.findIndex((item) => {
return item.id === music.id
})
if (index === 0) {
return list
}
if (index > 0) {
list.splice(index, 1)
}
list.unshift(music)
if (HistoryListMAX && list.length > HistoryListMAX) {
list.pop()
}
storage.set(HISTORYLIST_KEY, JSON.stringify(list))
return list
}
// 删除一条播放历史
export function removeHistoryList(music) {
storage.set(HISTORYLIST_KEY, JSON.stringify(music))
return music
}
// 清空播放历史
export function clearHistoryList() {
storage.clear(HISTORYLIST_KEY)
return []
}
/**
* 播放模式
* @type MODE_KEYkey值
* HistoryListMAX最大长度
*/
const MODE_KEY = '__wangyiyun_mode__'
// 获取播放模式
export function getMode() {
return Number(storage.get(MODE_KEY, WYYPLAYER_CONFIG.PLAY_MODE))
}
// 修改播放模式
export function setMode(mode) {
storage.set(MODE_KEY, mode)
return mode
}
/**
* 网易云用户uid
* @type USERID_KEYkey值
*/
const USERID_KEY = '__wangyiyun_userID__'
// 获取用户uid
export function getUserId() {
return Number(storage.get(USERID_KEY, null))
}
// 修改用户uid
export function setUserId(uid) {
storage.set(USERID_KEY, uid)
return uid
}
/**
* 版本号
* @type VERSION_KEYkey值
*/
const VERSION_KEY = '__wangyiyun_version__'
// 获取版本号
export function getVersion() {
let version = storage.get(VERSION_KEY, null)
return Array.isArray(version) ? null : version
}
// 修改版本号
export function setVersion(version) {
storage.set(VERSION_KEY, version)
return version
}
/**
* 音量
* @type VOLUME_KEYkey值
*/
const VOLUME_KEY = '__wangyiyun_volume__'
// 获取音量
export function getVolume() {
const volume = storage.get(VOLUME_KEY, WYYPLAYER_CONFIG.VOLUME)
return Number(volume)
}
// 修改音量
export function setVolume(volume) {
storage.set(VOLUME_KEY, volume)
return volume
}

107
src/utils/util.js Normal file
View File

@ -0,0 +1,107 @@
// 随机排序数组/洗牌函数 https://github.com/lodash/lodash/blob/master/shuffle.js
function copyArray(source, array) {
let index = -1
const length = source.length
array || (array = new Array(length))
while (++index < length) {
array[index] = source[index]
}
return array
}
export const randomSortArray = function shuffle(array) {
const length = array == null ? 0 : array.length
if (!length) {
return []
}
let index = -1
const lastIndex = length - 1
const result = copyArray(array)
while (++index < length) {
const rand = index + Math.floor(Math.random() * (lastIndex - index + 1))
const value = result[rand]
result[rand] = result[index]
result[index] = value
}
return result
}
// 防抖函数
export function debounce(func, delay) {
let timer
return function (...args) {
if (timer) {
clearTimeout(timer)
}
timer = setTimeout(() => {
func.apply(this, args)
}, delay)
}
}
// 补0函数
export function addZero(s) {
return s < 10 ? '0' + s : s
}
// 歌词解析
const timeExp = /\[(\d{2,}):(\d{2})(?:\.(\d{2,3}))?]/g
export function parseLyric(lrc) {
const lines = lrc.split('\n')
const lyric = []
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
const result = timeExp.exec(line)
if (!result) {
continue
}
const text = line.replace(timeExp, '').trim()
if (text) {
lyric.push({
time: (result[1] * 6e4 + result[2] * 1e3 + (result[3] || 0) * 1) / 1e3,
text,
})
}
}
return lyric
}
// 时间格式化
export function format(value) {
let minute = Math.floor(value / 60)
let second = Math.floor(value % 60)
return `${addZero(minute)}:${addZero(second)}`
}
/**
* https://github.com/videojs/video.js/blob/master/src/js/utils/promise.js
* Silence a Promise-like object.
*
* This is useful for avoiding non-harmful, but potentially confusing "uncaught
* play promise" rejection error messages.
*
* @param {Object} value
* An object that may or may not be `Promise`-like.
*/
export function isPromise(v) {
return v !== undefined && v !== null && typeof v.then === 'function'
}
export function silencePromise(value) {
if (isPromise(value)) {
value.then(null, () => {})
}
}
// 判断 string 类型
export function isString(v) {
return typeof v === 'string'
}
// http 链接转化成 https
export function toHttps(url) {
if (!isString(url)) {
return url
}
return url.replace('http://', 'https://')
}

62
vue.config.js Normal file
View File

@ -0,0 +1,62 @@
const { defineConfig } = require('@vue/cli-service')
const path = require('path')
const dayjs = require('dayjs')
function resolve(dir) {
return path.join(__dirname, dir)
}
const isEnvProduction = process.env.NODE_ENV === 'production'
// 注入版本信息
process.env.VUE_APP_VERSION = require('./package.json').version
// 注入版本更新时间
process.env.VUE_APP_UPDATE_TIME = dayjs().locale('zh-cn').format('YYYY-mm-DD')
module.exports = defineConfig({
publicPath: '',
chainWebpack(config) {
config.resolve.alias
.set('api', resolve('src/api'))
.set('assets', resolve('src/assets'))
.set('base', resolve('src/base'))
.set('components', resolve('src/components'))
.set('pages', resolve('src/pages'))
config.plugin('html').tap((args) => {
if (isEnvProduction) {
if (!args[0].minify) {
/* 参考 https://github.com/jantimon/html-webpack-plugin#minification */
args[0].minify = {
collapseWhitespace: true,
keepClosingSlash: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
trimCustomFragments: true,
}
}
args[0].minify.minifyJS = true
args[0].minify.minifyCSS = true
}
return args
})
},
pluginOptions: {
'style-resources-loader': {
preProcessor: 'less',
patterns: [resolve('src/styles/var.less'), resolve('src/styles/mixin.less')],
},
},
devServer: {
port: 3001,
proxy: {
'/api': {
target: process.env.VUE_APP_DEV_API_URL,
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
},
},
})