first commit

This commit is contained in:
qier222 2020-10-10 19:54:44 +08:00
commit e4ba16b9a2
102 changed files with 19066 additions and 0 deletions

25
.gitignore vendored Normal file
View File

@ -0,0 +1,25 @@
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.vercel

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 qier222
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.

93
README.md Normal file
View File

@ -0,0 +1,93 @@
<br />
<p align="center">
<a href="https://music.bluepill.one" target="blank">
<img src="images/logo.png" alt="Logo" width="156" height="156">
</a>
<h2 align="center" style="font-weight: 600">YesPlayMusic</h2>
<p align="center">
可能是最好看的第三方网易云播放器
<br />
<a href="https://music.bluepill.one" target="blank"><strong>⏩️ 访问 DEMO ⏪</strong></a>
<br />
<br />
</p>
</p>
[![Library][library-screenshot]](https://music.bluepill.one)
## ✨ 特性
- ✅ 使用 Vue.js 全家桶开发
- ⭐ 简洁美观的 UI
- ⏭️ 支持 MediaSession API可以使用系统快捷键操作上一首下一首
- 😾 不能播放的歌曲会显示为灰色
- 🖥️ 支持 PWA可在 Chrome/Edge 里点击地址栏右边的 安装到电脑
- 🙉 支持显示歌曲和专辑的 Explicit 标志
- 🚫🤝 无任何社交功能
- 🛠 更多特性开发中
## ⚙️ 部署
1. 部署网易云 API详情参见 [Binaryify/NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi)
2. 克隆本仓库
```sh
git clone https://github.com/qier222/YesPlayMusic.git
```
3. 安装依赖
```sh
npm install
```
4. 替换 `/src/utils/request.js` 里面 `baseURL` 的值为网易云 API 地址
```JS
baseURL: "http://example.com",
```
5. 编译打包
```sh
npm build
```
6. 将 `/dist` 目录下的文件上传到你的 Web 服务器
## ☑️ Todo
- 中文支持
- MV 播放
- Dark Mode
- 网易云账号登录(真·登录)
- 私人 FM
- 播放记录
- 无限播放模式(播放完列表后自动播放相似歌曲)
欢迎提 issue 和 pull request。
## 📜 开源许可
基于 [MIT license](https://opensource.org/licenses/MIT) 许可进行开源。
## 🖼️ 截图
[![artist][artist-screenshot]](https://music.bluepill.one)
[![album][album-screenshot]](https://music.bluepill.one)
[![playlist][playlist-screenshot]](https://music.bluepill.one)
[![explore][explore-screenshot]](https://music.bluepill.one)
[![search][search-screenshot]](https://music.bluepill.one)
[![home][home-screenshot]](https://music.bluepill.one)
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[album-screenshot]: images/album.png
[artist-screenshot]: images/artist.png
[explore-screenshot]: images/explore.png
[home-screenshot]: images/home.png
[library-screenshot]: images/library.png
[playlist-screenshot]: images/playlist.png
[search-screenshot]: images/search.png

5
babel.config.js Normal file
View File

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

BIN
images/album.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 KiB

BIN
images/artist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

BIN
images/explore.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
images/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
images/library.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

BIN
images/playlist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
images/search.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

57
package.json Normal file
View File

@ -0,0 +1,57 @@
{
"name": "music-app",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"axios": "^0.20.0",
"core-js": "^3.6.5",
"dayjs": "^1.8.36",
"howler": "^2.2.0",
"nprogress": "^0.2.0",
"register-service-worker": "^1.7.1",
"svg-sprite-loader": "^5.0.0",
"vue": "^2.6.11",
"vue-analytics": "^5.22.1",
"vue-global-events": "^1.2.1",
"vue-router": "^3.4.3",
"vue-slider-component": "^3.2.5",
"vuex": "^3.4.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-pwa": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.26.11",
"sass-loader": "^10.0.2",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00251 14.9297L0 1.07422H6.14651L8.00251 4.27503L9.84583 1.07422H16L8.00251 14.9297Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

21
public/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Disallow:

115
src/App.vue Normal file
View File

@ -0,0 +1,115 @@
<template>
<div id="app">
<Navbar />
<main>
<keep-alive>
<router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive>
<router-view v-if="!$route.meta.keepAlive"></router-view>
</main>
<transition name="slide-up">
<BottomBar v-if="this.$store.state.player.enable" ref="player"
/></transition>
<GlobalEvents
:filter="(event, handler, eventName) => event.target.tagName !== 'INPUT'"
@keydown.space="play"
/>
</div>
</template>
<script>
import Navbar from "./components/Navbar.vue";
import BottomBar from "./components/BottomBar.vue";
import GlobalEvents from "vue-global-events";
import { mapState } from "vuex";
export default {
name: "App",
components: {
Navbar,
BottomBar,
GlobalEvents,
},
computed: {
...mapState(["loading"]),
},
methods: {
play(e) {
e.preventDefault();
this.$refs.player.play();
},
},
};
</script>
<style lang="scss">
@import url("https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,500;0,600;0,700;0,800;0,900;1,500;1,600;1,700;1,800;1,900&display=swap");
#app {
font-family: "Barlow", -apple-system, BlinkMacSystemFont, Helvetica Neue,
PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC,
WenQuanYi Micro Hei, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
// margin-top: 60px;
width: 100%;
}
html {
overflow-y: overlay;
min-width: 1000px;
}
main {
margin-top: 96px;
margin-bottom: 96px;
padding: {
right: 10vw;
left: 10vw;
}
}
button {
background: none;
border: none;
cursor: pointer;
}
input,
button {
font-family: "Barlow", sans-serif;
&:focus {
outline: none;
}
}
a {
color: inherit;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Let's get this party started */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
background: rgb(216, 216, 216);
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.4s;
}
.slide-up-enter, .slide-up-leave-to /* .fade-leave-active below version 2.1.8 */ {
transform: translateY(100%);
}
</style>

22
src/api/album.js Normal file
View File

@ -0,0 +1,22 @@
import request from "@/utils/request";
export function getAlbum(id) {
return request({
url: "/album",
method: "get",
params: {
id,
},
});
}
export function newAlbums(params) {
// limit : 返回数量 , 默认为 30
// offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
// area : ALL:全部,ZH:华语,EA:欧美,KR:韩国,JP:日本
return request({
url: "/album/new",
method: "get",
params,
});
}

36
src/api/artist.js Normal file
View File

@ -0,0 +1,36 @@
import request from "@/utils/request";
export function getArtist(id) {
return request({
url: "/artists",
method: "get",
params: {
id,
},
});
}
export function getArtistAlbum(params) {
// 必选参数 : id: 歌手 id
// 可选参数 : limit: 取出数量 , 默认为 50
// offset: 偏移数量 , 用于分页 , 如 :( 页数 -1)*50, 其中 50 为 limit 的值 , 默认 为 0
return request({
url: "/artist/album",
method: "get",
params,
});
}
export function toplistOfArtists(type = null) {
// type : 地区
// 1: 华语
// 2: 欧美
// 3: 韩国
// 4: 日本
return request({
url: "/toplist/artist",
method: "get",
params: {
type,
},
});
}

9
src/api/others.js Normal file
View File

@ -0,0 +1,9 @@
import request from "@/utils/request";
export function search(params) {
return request({
url: "/search",
method: "get",
params,
});
}

65
src/api/playlist.js Normal file
View File

@ -0,0 +1,65 @@
import request from "@/utils/request";
export function recommendPlaylist(params) {
// limit: 取出数量 , 默认为 30
return request({
url: "/personalized",
method: "get",
params,
});
}
export function dailyRecommendPlaylist(params) {
// limit: 取出数量 , 默认为 30
return request({
url: "/recommend/resource",
method: "get",
params,
});
}
export function getPlaylistDetail(id) {
return request({
url: "/playlist/detail",
method: "get",
params: {
id,
},
});
}
export function highQualityPlaylist(params) {
// 可选参数: cat: tag, 比如 " 华语 "、" 古风 " 、" 欧美 "、" 流行 ", 默认为 "全部", 可从精品歌单标签列表接口获取(/playlist/highquality / tags)
// limit: 取出歌单数量 , 默认为 20
// before: 分页参数,取上一页最后一个歌单的 updateTime 获取下一页数据
return request({
url: "/top/playlist/highquality",
method: "get",
params,
});
}
export function topPlaylist(params) {
// 可选参数 : order: 可选值为 'new' 和 'hot', 分别对应最新和最热 , 默认为 'hot'
// cat:cat: tag, 比如 " 华语 "、" 古风 " 、" 欧美 "、" 流行 ", 默认为 "全部",可从歌单分类接口获取(/playlist/catlist)
// limit: 取出歌单数量 , 默认为 50
// offset: 偏移数量 , 用于分页 , 如 :( 评论页数 -1)*50, 其中 50 为 limit 的值
return request({
url: "/top/playlist",
method: "get",
params,
});
}
export function playlistCatlist() {
return request({
url: "/playlist/catlist",
method: "get",
});
}
export function toplists() {
return request({
url: "/toplist",
method: "get",
});
}

47
src/api/track.js Normal file
View File

@ -0,0 +1,47 @@
import request from "@/utils/request";
export function getMP3(id) {
return request({
url: "/song/url",
method: "get",
params: {
id,
},
});
}
export function getTrackDetail(id) {
return request({
url: "/song/detail",
method: "get",
params: {
ids: id,
},
});
}
export function getLyric(id) {
return request({
url: "/lyric",
method: "get",
params: {
id: id,
},
});
}
export function topSong(type) {
// type: 地区类型 id,对应以下:
// 全部:0
// 华语:7
// 欧美:96
// 日本:8
// 韩国:16
return request({
url: "/top/song",
method: "get",
params: {
type,
},
});
}

43
src/api/user.js Normal file
View File

@ -0,0 +1,43 @@
import request from "@/utils/request";
export function login(params) {
// 必选参数 :
// phone: 手机号码
// password: 密码
// 可选参数 :
// countrycode: 国家码用于国外手机号登陆例如美国传入1
// md5_password: md5加密后的密码,传入后 password 将失效
return request({
url: "/login/cellphone",
method: "get",
params,
});
}
export function userDetail(uid) {
return request({
url: "/user/detail",
method: "get",
params: {
uid,
},
});
}
export function userPlaylist(params) {
// limit : 返回数量 , 默认为 30
// offset : 偏移数量,用于分页 , 如 :( 页数 -1)*30, 其中 30 为 limit 的值 , 默认为 0
return request({
url: "/user/playlist",
method: "get",
params,
});
}
export function userLikedSongsIDs(uid) {
return request({
url: "/likelist",
method: "get",
uid,
});
}

View File

@ -0,0 +1,41 @@
/* Make clicks pass-through */
#nprogress {
pointer-events: none;
}
#nprogress .bar {
background: #335eea;
position: fixed;
z-index: 1031;
top: 0;
left: 0;
width: 100%;
height: 2px;
}
/* Fancy blur effect */
#nprogress .peg {
display: block;
position: absolute;
right: 0px;
width: 100px;
height: 100%;
box-shadow: 0 0 10px #335eea,
0 0 5px #335eea;
opacity: 1.0;
-webkit-transform: rotate(3deg) translate(0px, -4px);
-ms-transform: rotate(3deg) translate(0px, -4px);
transform: rotate(3deg) translate(0px, -4px);
}
.nprogress-custom-parent {
overflow: hidden;
position: relative;
}
.nprogress-custom-parent #nprogress .bar {
position: absolute;
}

65
src/assets/css/slider.css Normal file
View File

@ -0,0 +1,65 @@
/* rail style */
.vue-slider-rail {
background-color: #eee;
border-radius: 15px;
}
/* process style */
.vue-slider-process {
background-color: #335eea;
border-radius: 15px;
}
/* dot style */
.vue-slider-dot-handle {
cursor: pointer;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #fff;
box-sizing: border-box;
box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.12);
visibility: hidden;
}
/* tooltip style */
.vue-slider-dot-tooltip-wrapper {
opacity: 0;
transition: all 1s;
}
.vue-slider-dot-tooltip-wrapper-show {
opacity: 1;
}
.vue-slider-dot-tooltip-inner {
font-size: 14px;
white-space: nowrap;
padding: 2px 6px;
min-width: 20px;
text-align: center;
color: #000;
border-radius: 5px;
border-color: #fff;
background-color: #fff;
box-sizing: content-box;
box-shadow: 0.5px 0.5px 2px 1px rgba(0, 0, 0, 0.08);
}
/* hover */
.vue-slider:hover .vue-slider-dot-handle,
.vue-slider:active .vue-slider-dot-handle {
visibility: visible;
}
/* volume style */
.volume-control .vue-slider-process {
background-color: rgba(0, 0, 0, 0.8);
border-radius: 15px;
}
.volume-control:hover .vue-slider-process {
background-color: #335eea;
}

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-left" class="svg-inline--fa fa-angle-left fa-w-8" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path fill="currentColor" d="M31.7 239l136-136c9.4-9.4 24.6-9.4 33.9 0l22.6 22.6c9.4 9.4 9.4 24.6 0 33.9L127.9 256l96.4 96.4c9.4 9.4 9.4 24.6 0 33.9L201.7 409c-9.4 9.4-24.6 9.4-33.9 0l-136-136c-9.5-9.4-9.5-24.6-.1-34z"></path></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="angle-right" class="svg-inline--fa fa-angle-right fa-w-8" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 512"><path fill="currentColor" d="M224.3 273l-136 136c-9.4 9.4-24.6 9.4-33.9 0l-22.6-22.6c-9.4-9.4-9.4-24.6 0-33.9l96.4-96.4-96.4-96.4c-9.4-9.4-9.4-24.6 0-33.9L54.3 103c9.4-9.4 24.6-9.4 33.9 0l136 136c9.5 9.4 9.5 24.6.1 34z"></path></svg>

After

Width:  |  Height:  |  Size: 430 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="circle" class="svg-inline--fa fa-circle fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8z"></path></svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="expand-alt" class="svg-inline--fa fa-expand-alt fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M212.686 315.314L120 408l32.922 31.029c15.12 15.12 4.412 40.971-16.97 40.971h-112C10.697 480 0 469.255 0 456V344c0-21.382 25.803-32.09 40.922-16.971L72 360l92.686-92.686c6.248-6.248 16.379-6.248 22.627 0l25.373 25.373c6.249 6.248 6.249 16.378 0 22.627zm22.628-118.628L328 104l-32.922-31.029C279.958 57.851 290.666 32 312.048 32h112C437.303 32 448 42.745 448 56v112c0 21.382-25.803 32.09-40.922 16.971L376 152l-92.686 92.686c-6.248 6.248-16.379 6.248-22.627 0l-25.373-25.373c-6.249-6.248-6.249-16.378 0-22.627z"></path></svg>

After

Width:  |  Height:  |  Size: 749 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 6h-4v2h4v2h-4v2h4v2H9V7h6v2z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="github" class="svg-inline--fa fa-github fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><path fill="currentColor" d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,8 @@
import Vue from "vue";
import SvgIcon from "@/components/SvgIcon";
Vue.component("svg-icon", SvgIcon);
const requireAll = (requireContext) =>
requireContext.keys().map(requireContext);
const req = require.context("./", true, /\.svg$/);
requireAll(req);

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="list-music" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-list-music fa-w-16 fa-9x"><path fill="currentColor" d="M16 256h256a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16zm0-128h256a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16H16A16 16 0 0 0 0 80v32a16 16 0 0 0 16 16zm128 192H16a16 16 0 0 0-16 16v32a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-32a16 16 0 0 0-16-16zM470.94 1.33l-96.53 28.51A32 32 0 0 0 352 60.34V360a148.76 148.76 0 0 0-48-8c-61.86 0-112 35.82-112 80s50.14 80 112 80 112-35.82 112-80V148.15l73-21.39a32 32 0 0 0 23-30.71V32a32 32 0 0 0-41.06-30.67z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 735 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="ellipsis-h" class="svg-inline--fa fa-ellipsis-h fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M328 256c0 39.8-32.2 72-72 72s-72-32.2-72-72 32.2-72 72-72 72 32.2 72 72zm104-72c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72zm-352 0c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72z"></path></svg>

After

Width:  |  Height:  |  Size: 457 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="step-forward" class="svg-inline--fa fa-step-forward fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M384 44v424c0 6.6-5.4 12-12 12h-48c-6.6 0-12-5.4-12-12V291.6l-195.5 181C95.9 489.7 64 475.4 64 448V64c0-27.4 31.9-41.7 52.5-24.6L312 219.3V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12z"></path></svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="pause" class="svg-inline--fa fa-pause fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z"></path></svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="play" class="svg-inline--fa fa-play fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M424.4 214.7L72.4 6.6C43.8-10.3 0 6.1 0 47.9V464c0 37.5 40.7 60.1 72.4 41.3l352-208c31.4-18.5 31.5-64.1 0-82.6z"></path></svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="step-backward" class="svg-inline--fa fa-step-backward fa-w-14" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M64 468V44c0-6.6 5.4-12 12-12h48c6.6 0 12 5.4 12 12v176.4l195.5-181C352.1 22.3 384 36.6 384 64v384c0 27.4-31.9 41.7-52.5 24.6L136 292.7V468c0 6.6-5.4 12-12 12H76c-6.6 0-12-5.4-12-12z"></path></svg>

After

Width:  |  Height:  |  Size: 428 B

View File

@ -0,0 +1 @@
<span class="dn color-inherit link hover-indigo"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="repeat-1" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-repeat-1 fa-w-16 fa-1x"><path fill="currentColor" d="M512 256c0 88.224-71.775 160-160 160H170.067l34.512 32.419c9.875 9.276 10.119 24.883.539 34.464l-10.775 10.775c-9.373 9.372-24.568 9.372-33.941 0l-92.686-92.686c-9.373-9.373-9.373-24.568 0-33.941l80.269-80.27c9.373-9.373 24.568-9.373 33.941 0l10.775 10.775c9.581 9.581 9.337 25.187-.539 34.464l-22.095 20H352c52.935 0 96-43.065 96-96 0-13.958-2.996-27.228-8.376-39.204-4.061-9.039-2.284-19.626 4.723-26.633l12.183-12.183c11.499-11.499 30.965-8.526 38.312 5.982C505.814 205.624 512 230.103 512 256zM72.376 295.204C66.996 283.228 64 269.958 64 256c0-52.935 43.065-96 96-96h181.933l-22.095 20.002c-9.875 9.276-10.119 24.883-.539 34.464l10.775 10.775c9.373 9.372 24.568 9.372 33.941 0l80.269-80.27c9.373-9.373 9.373-24.568 0-33.941l-92.686-92.686c-9.373-9.373-24.568-9.373-33.941 0l-10.775 10.775c-9.581 9.581-9.337 25.187.539 34.464L341.933 96H160C71.775 96 0 167.776 0 256c0 25.897 6.186 50.376 17.157 72.039 7.347 14.508 26.813 17.481 38.312 5.982l12.183-12.183c7.008-7.008 8.786-17.595 4.724-26.634zm154.887 4.323c0-7.477 3.917-11.572 11.573-11.572h15.131v-39.878c0-5.163.534-10.503.534-10.503h-.356s-1.779 2.67-2.848 3.738c-4.451 4.273-10.504 4.451-15.666-1.068l-5.518-6.231c-5.342-5.341-4.984-11.216.534-16.379l21.72-19.939c4.449-4.095 8.366-5.697 14.42-5.697h12.105c7.656 0 11.749 3.916 11.749 11.572v84.384h15.488c7.655 0 11.572 4.094 11.572 11.572v8.901c0 7.477-3.917 11.572-11.572 11.572h-67.293c-7.656 0-11.573-4.095-11.573-11.572v-8.9z" class=""></path></svg></span>

View File

@ -0,0 +1 @@
<span class="dn color-inherit link hover-pink"><svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="repeat" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="svg-inline--fa fa-repeat fa-w-16 fa-1x"><path fill="currentColor" d="M512 256c0 88.224-71.775 160-160 160H170.067l34.512 32.419c9.875 9.276 10.119 24.883.539 34.464l-10.775 10.775c-9.373 9.372-24.568 9.372-33.941 0l-92.686-92.686c-9.373-9.373-9.373-24.568 0-33.941l92.686-92.686c9.373-9.373 24.568-9.373 33.941 0l10.775 10.775c9.581 9.581 9.337 25.187-.539 34.464L170.067 352H352c52.935 0 96-43.065 96-96 0-13.958-2.996-27.228-8.376-39.204-4.061-9.039-2.284-19.626 4.723-26.633l12.183-12.183c11.499-11.499 30.965-8.526 38.312 5.982C505.814 205.624 512 230.103 512 256zM72.376 295.204C66.996 283.228 64 269.958 64 256c0-52.935 43.065-96 96-96h181.933l-34.512 32.419c-9.875 9.276-10.119 24.883-.539 34.464l10.775 10.775c9.373 9.372 24.568 9.372 33.941 0l92.686-92.686c9.373-9.373 9.373-24.568 0-33.941l-92.686-92.686c-9.373-9.373-24.568-9.373-33.941 0L306.882 29.12c-9.581 9.581-9.337 25.187.539 34.464L341.933 96H160C71.775 96 0 167.776 0 256c0 25.897 6.186 50.376 17.157 72.039 7.347 14.508 26.813 17.481 38.312 5.982l12.183-12.183c7.008-7.008 8.786-17.595 4.724-26.634z" class=""></path></svg></span>

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="search" class="svg-inline--fa fa-search fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M505 442.7L405.3 343c-4.5-4.5-10.6-7-17-7H372c27.6-35.3 44-79.7 44-128C416 93.1 322.9 0 208 0S0 93.1 0 208s93.1 208 208 208c48.3 0 92.7-16.4 128-44v16.3c0 6.4 2.5 12.5 7 17l99.7 99.7c9.4 9.4 24.6 9.4 33.9 0l28.3-28.3c9.4-9.4 9.4-24.6.1-34zM208 336c-70.7 0-128-57.2-128-128 0-70.7 57.2-128 128-128 70.7 0 128 57.2 128 128 0 70.7-57.2 128-128 128z"></path></svg>

After

Width:  |  Height:  |  Size: 577 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="cog" class="svg-inline--fa fa-cog fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M487.4 315.7l-42.6-24.6c4.3-23.2 4.3-47 0-70.2l42.6-24.6c4.9-2.8 7.1-8.6 5.5-14-11.1-35.6-30-67.8-54.7-94.6-3.8-4.1-10-5.1-14.8-2.3L380.8 110c-17.9-15.4-38.5-27.3-60.8-35.1V25.8c0-5.6-3.9-10.5-9.4-11.7-36.7-8.2-74.3-7.8-109.2 0-5.5 1.2-9.4 6.1-9.4 11.7V75c-22.2 7.9-42.8 19.8-60.8 35.1L88.7 85.5c-4.9-2.8-11-1.9-14.8 2.3-24.7 26.7-43.6 58.9-54.7 94.6-1.7 5.4.6 11.2 5.5 14L67.3 221c-4.3 23.2-4.3 47 0 70.2l-42.6 24.6c-4.9 2.8-7.1 8.6-5.5 14 11.1 35.6 30 67.8 54.7 94.6 3.8 4.1 10 5.1 14.8 2.3l42.6-24.6c17.9 15.4 38.5 27.3 60.8 35.1v49.2c0 5.6 3.9 10.5 9.4 11.7 36.7 8.2 74.3 7.8 109.2 0 5.5-1.2 9.4-6.1 9.4-11.7v-49.2c22.2-7.9 42.8-19.8 60.8-35.1l42.6 24.6c4.9 2.8 11 1.9 14.8-2.3 24.7-26.7 43.6-58.9 54.7-94.6 1.5-5.5-.7-11.3-5.6-14.1zM256 336c-44.1 0-80-35.9-80-80s35.9-80 80-80 80 35.9 80 80-35.9 80-80 80z"></path></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="random" class="svg-inline--fa fa-random fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M504.971 359.029c9.373 9.373 9.373 24.569 0 33.941l-80 79.984c-15.01 15.01-40.971 4.49-40.971-16.971V416h-58.785a12.004 12.004 0 0 1-8.773-3.812l-70.556-75.596 53.333-57.143L352 336h32v-39.981c0-21.438 25.943-31.998 40.971-16.971l80 79.981zM12 176h84l52.781 56.551 53.333-57.143-70.556-75.596A11.999 11.999 0 0 0 122.785 96H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12zm372 0v39.984c0 21.46 25.961 31.98 40.971 16.971l80-79.984c9.373-9.373 9.373-24.569 0-33.941l-80-79.981C409.943 24.021 384 34.582 384 56.019V96h-58.785a12.004 12.004 0 0 0-8.773 3.812L96 336H12c-6.627 0-12 5.373-12 12v56c0 6.627 5.373 12 12 12h110.785c3.326 0 6.503-1.381 8.773-3.812L352 176h32z"></path></svg>

After

Width:  |  Height:  |  Size: 904 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-down" class="svg-inline--fa fa-volume-down fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M215.03 72.04L126.06 161H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V89.02c0-21.47-25.96-31.98-40.97-16.98zm123.2 108.08c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 229.28 336 242.62 336 257c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.87z"></path></svg>

After

Width:  |  Height:  |  Size: 679 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-mute" class="svg-inline--fa fa-volume-mute fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM461.64 256l45.64-45.64c6.3-6.3 6.3-16.52 0-22.82l-22.82-22.82c-6.3-6.3-16.52-6.3-22.82 0L416 210.36l-45.64-45.64c-6.3-6.3-16.52-6.3-22.82 0l-22.82 22.82c-6.3 6.3-6.3 16.52 0 22.82L370.36 256l-45.63 45.63c-6.3 6.3-6.3 16.52 0 22.82l22.82 22.82c6.3 6.3 16.52 6.3 22.82 0L416 301.64l45.64 45.64c6.3 6.3 16.52 6.3 22.82 0l22.82-22.82c6.3-6.3 6.3-16.52 0-22.82L461.64 256z"></path></svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="svg-inline--fa fa-volume fa-w-15 fa-2x"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.53 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z" class=""></path></svg>

After

Width:  |  Height:  |  Size: 944 B

View File

@ -0,0 +1,33 @@
<template>
<span class="artist-in-line">
<span v-for="(ar, index) in slicedArtists" :key="ar.id">
<router-link :to="`/artist/${ar.id}`">{{ ar.name }}</router-link>
<span v-if="index !== slicedArtists.length - 1">, </span>
</span>
</span>
</template>
<script>
export default {
name: "ArtistInLine",
props: {
artists: {
type: Array,
required: true,
},
showFirstArtist: {
type: Boolean,
default: true,
},
},
computed: {
slicedArtists() {
return this.showFirstArtist
? this.artists
: this.artists.slice(1, this.artists.length);
},
},
};
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,345 @@
<template>
<div class="player">
<div class="progress-bar">
<vue-slider
v-model="progress"
:min="0"
:max="progressMax"
:interval="1"
:drag-on-click="true"
:duration="0"
:dotSize="12"
:height="2"
:tooltipFormatter="formatTrackTime"
@drag-end="setSeek"
ref="progress"
></vue-slider>
</div>
<div class="controls">
<div class="playing">
<router-link :to="`/album/${player.currentTrack.album.id}`"
><img :src="player.currentTrack.album.picUrl | resizeImage" />
</router-link>
<div class="track-info">
<div class="name">
<router-link
:to="'/' + player.listInfo.type + '/' + player.listInfo.id"
>{{ player.currentTrack.name }}</router-link
>
</div>
<div class="artist">
<span
v-for="(ar, index) in player.currentTrack.artists"
:key="ar.id"
>
<router-link :to="`/artist/${ar.id}`">{{ ar.name }}</router-link>
<span v-if="index !== player.currentTrack.artists.length - 1"
>,
</span>
</span>
</div>
</div>
</div>
<div class="middle-control-buttons">
<button-icon @click.native="previous" title="Previous Song"
><svg-icon icon-class="previous"
/></button-icon>
<button-icon
class="play"
@click.native="play"
:title="playing ? 'Pause' : 'Play'"
>
<svg-icon :iconClass="playing ? 'pause' : 'play'"
/></button-icon>
<button-icon @click.native="next" title="Next Song"
><svg-icon icon-class="next"
/></button-icon>
</div>
<div class="right-control-buttons">
<button-icon
@click.native="goToNextTracksPage"
title="Next Up"
:class="{ active: this.$route.name === 'next' }"
><svg-icon icon-class="list"
/></button-icon>
<button-icon
title="Repeat"
@click.native="repeat"
:class="{ active: player.repeat !== 'off' }"
>
<svg-icon icon-class="repeat" v-show="player.repeat !== 'one'" />
<svg-icon icon-class="repeat-1" v-show="player.repeat === 'one'" />
</button-icon>
<button-icon
@click.native="shuffle"
:class="{ active: player.shuffle }"
title="Shuffle"
><svg-icon icon-class="shuffle"
/></button-icon>
<div class="volume-control">
<button-icon title="Mute" @click.native="mute">
<svg-icon icon-class="volume" v-show="volume > 0.5" />
<svg-icon icon-class="volume-mute" v-show="volume === 0" />
<svg-icon
icon-class="volume-half"
v-show="volume <= 0.5 && volume !== 0"
/>
</button-icon>
<div class="volume-bar">
<vue-slider
v-model="volume"
:min="0"
:max="1"
:interval="0.01"
:drag-on-click="true"
:duration="0"
:tooltip="`none`"
:dotSize="12"
></vue-slider>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapMutations, mapActions } from "vuex";
import "@/assets/css/slider.css";
import ButtonIcon from "@/components/ButtonIcon.vue";
import VueSlider from "vue-slider-component";
export default {
name: "Player",
components: {
ButtonIcon,
VueSlider,
},
data() {
return {
interval: null,
progress: 0,
oldVolume: 0.5,
};
},
created() {
setInterval(() => {
this.progress = ~~this.howler.seek();
}, 1000);
},
computed: {
...mapState(["player", "howler", "Howler"]),
volume: {
get() {
return this.player.volume;
},
set(value) {
this.updatePlayerState({ key: "volume", value });
this.Howler.volume(value);
},
},
playing() {
if (this.howler.state() === "loading") {
return true;
}
return this.howler.playing();
},
progressMax() {
let max = ~~(this.player.currentTrack.time / 1000);
return max > 1 ? max - 1 : max;
},
},
methods: {
...mapMutations([
"updatePlayingStatus",
"updateShuffleStatus",
"updatePlayerList",
"shuffleTheList",
"updatePlayerState",
"updateRepeatStatus",
]),
...mapActions(["nextTrack", "previousTrack", "playTrackOnListByID"]),
play() {
if (this.playing) {
this.howler.pause();
} else {
if (this.howler.state() === "unloaded") {
this.playTrackOnListByID(this.player.currentTrack.id);
}
this.howler.play();
}
},
next() {
this.nextTrack(true);
this.progress = 0;
},
previous() {
this.previousTrack();
this.progress = 0;
},
shuffle() {
if (this.player.shuffle === true) {
this.updateShuffleStatus(false);
this.updatePlayerList(this.player.notShuffledList);
} else {
this.updateShuffleStatus(true);
this.shuffleTheList();
}
},
repeat() {
if (this.player.repeat === "on") {
this.updateRepeatStatus("one");
} else if (this.player.repeat === "one") {
this.updateRepeatStatus("off");
} else {
this.updateRepeatStatus("on");
}
},
mute() {
if (this.volume === 0) {
this.volume = this.oldVolume;
} else {
this.oldVolume = this.volume;
this.volume = 0;
}
},
setSeek() {
this.progress = this.$refs.progress.getValue();
this.howler.seek(this.$refs.progress.getValue());
},
goToNextTracksPage() {
this.$route.name === "next"
? this.$router.go(-1)
: this.$router.push({ name: "next" });
},
formatTrackTime(value) {
if (!value) return "";
let min = ~~((value / 60) % 60);
let sec = (~~(value % 60)).toString().padStart(2, "0");
return `${min}:${sec}`;
},
},
};
</script>
<style lang="scss" scoped>
.player {
position: fixed;
bottom: 0;
right: 0;
left: 0;
display: flex;
flex-direction: column;
justify-content: space-around;
height: 64px;
backdrop-filter: saturate(180%) blur(30px);
background-color: rgba(255, 255, 255, 0.86);
z-index: 100;
}
.progress-bar {
margin-top: -6px;
margin-bottom: -4px;
width: 100%;
}
.controls {
flex: 1;
display: flex;
justify-content: flex;
align-items: center;
padding: {
right: 10vw;
left: 10vw;
}
}
.playing {
flex: 1;
display: flex;
align-items: center;
img {
height: 46px;
border-radius: 5px;
box-shadow: 0 6px 8px -2px rgba(0, 0, 0, 0.16);
}
.track-info {
height: 46px;
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
.name {
font-weight: 600;
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 4px;
cursor: pointer;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
&:hover {
text-decoration: underline;
}
}
.artist {
font-size: 12px;
color: rgba(0, 0, 0, 0.58);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
}
.middle-control-buttons {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
.button-icon {
margin: 0 8px;
}
.play {
height: 48px;
width: 48px;
.svg-icon {
width: 28px;
height: 28px;
}
}
}
.right-control-buttons {
flex: 1;
display: flex;
justify-content: flex-end;
align-items: center;
.expand {
margin-left: 24px;
.svg-icon {
height: 24px;
width: 24px;
}
}
.active .svg-icon {
color: #335eea;
}
.volume-control {
margin-left: 4px;
display: flex;
align-items: center;
.volume-bar {
width: 84px;
}
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<button class="button-icon"><slot></slot></button>
</template>
<script>
export default {
name: "ButtonIcon",
};
</script>
<style lang="scss" scoped>
button {
display: flex;
justify-content: center;
align-items: center;
padding: 8px;
background: transparent;
margin: 4px;
border-radius: 25%;
transition: 0.2s;
.svg-icon {
color: rgba(0, 0, 0, 0.88);
height: 16px;
width: 16px;
}
&:first-child {
margin-left: 0;
}
&:hover {
// background: #eaeffd;
// .svg-icon {
// color: #335eea;
// }
background: #f5f5f7;
}
&:active {
transform: scale(0.92);
// background: #eaeffd;
// .svg-icon {
// color: #335eea;
// }
}
}
</style>

View File

@ -0,0 +1,62 @@
<template>
<button :style="{ padding: `8px ${horizontalPadding}px` }" :class="color">
<svg-icon
v-if="iconClass !== null"
:iconClass="iconClass"
:style="{ marginRight: iconButton ? '0px' : '8px' }"
/>
<slot></slot>
</button>
</template>
<script>
export default {
name: "ButtonTwoTone",
props: {
iconClass: {
type: String,
default: null,
},
iconButton: {
type: Boolean,
default: false,
},
horizontalPadding: {
type: Number,
default: 16,
},
color: {
type: String,
default: "blue",
},
},
};
</script>
<style lang="scss" scoped>
button {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
background-color: rgba(51, 94, 234, 0.1);
color: #335eea;
border-radius: 8px;
margin-right: 12px;
transition: 0.2s;
.svg-icon {
width: 16px;
height: 16px;
}
&:hover {
transform: scale(1.06);
}
&:active {
transform: scale(0.94);
}
}
button.grey {
background-color: #f5f5f7;
color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div class="context-menu">
<div
class="menu"
tabindex="-1"
ref="menu"
v-if="showMenu"
@blur="closeMenu"
:style="{ top: top, left: left }"
@click="closeMenu"
>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: "ContextMenu",
data() {
return {
showMenu: false,
top: "0px",
left: "0px",
};
},
methods: {
setMenu: function(top, left) {
let largestHeight =
window.innerHeight - this.$refs.menu.offsetHeight - 25;
let largestWidth = window.innerWidth - this.$refs.menu.offsetWidth - 25;
if (top > largestHeight) top = largestHeight;
if (left > largestWidth) left = largestWidth;
this.top = top + "px";
this.left = left + "px";
},
closeMenu: function() {
this.showMenu = false;
},
openMenu: function(e) {
this.showMenu = true;
this.$nextTick(
function() {
this.$refs.menu.focus();
this.setMenu(e.y, e.x);
}.bind(this)
);
e.preventDefault();
},
},
};
</script>
<style lang="scss" scoped>
.context-menu {
width: 100%;
height: 100%;
}
.menu {
position: fixed;
min-width: 136px;
list-style: none;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
backdrop-filter: blur(12px);
border-radius: 8px;
box-sizing: border-box;
padding: 6px;
z-index: 1000;
&:focus {
outline: none;
}
}
.menu .item {
font-weight: 600;
font-size: 14px;
padding: 10px 14px;
border-radius: 7px;
cursor: default;
&:hover {
background: #eaeffd;
color: #335eea;
}
}
</style>

196
src/components/Cover.vue Normal file
View File

@ -0,0 +1,196 @@
<template>
<div style="position: relative">
<transition name="zoom">
<div
class="cover"
@mouseover="focus = true"
@mouseleave="focus = false"
:style="coverStyle"
:class="{
'hover-float': hoverEffect,
'hover-play-button': showPlayButton,
}"
@click="clickToPlay ? play() : goTo()"
>
<button
class="play-button"
v-if="showPlayButton"
:style="playButtonStyle"
@click.stop="playButtonClicked"
>
<svg-icon icon-class="play" />
</button>
</div>
</transition>
<transition name="fade" v-if="hoverEffect">
<img class="shadow" v-show="focus" :src="url" :style="shadowStyle"
/></transition>
<img
class="shadow"
v-if="alwaysShowShadow"
:src="url"
:style="shadowStyle"
/>
</div>
</template>
<script>
import { playAlbumByID, playPlaylistByID, playArtistByID } from "@/utils/play";
export default {
name: "Cover",
props: {
id: Number,
type: String,
url: String,
hoverEffect: Boolean,
showPlayButton: Boolean,
alwaysShowShadow: Boolean,
showBlackShadow: Boolean,
clickToPlay: Boolean,
size: {
type: Number,
default: 208,
},
shadowMargin: {
type: Number,
default: 12,
},
radius: {
type: Number,
default: 12,
},
playButtonSize: {
type: Number,
default: 48,
},
},
data() {
return {
focus: false,
shadowStyle: {},
playButtonStyle: {},
};
},
created() {
this.shadowStyle = {
height: `${this.size}px`,
width: `${this.size}px`,
top: `${this.shadowMargin}px`,
borderRadius: `${this.radius}px`,
};
this.playButtonStyle = {
height: `${this.playButtonSize}px`,
width: `${this.playButtonSize}px`,
};
},
computed: {
coverStyle() {
return {
backgroundImage: `url('${this.url}')`,
boxShadow: this.showBlackShadow
? "0 12px 16px -8px rgba(0, 0, 0, 0.2)"
: "",
height: `${this.size}px`,
width: `${this.size}px`,
borderRadius: `${this.radius}px`,
cursor: this.clickToPlay ? "default" : "pointer",
};
},
},
methods: {
play() {
if (this.type === "album") {
playAlbumByID(this.id);
} else if (this.type === "playlist") {
playPlaylistByID(this.id);
}
},
playButtonClicked() {
if (this.type === "album") {
playAlbumByID(this.id);
} else if (this.type === "playlist") {
playPlaylistByID(this.id);
} else if (this.type === "artist") {
playArtistByID(this.id);
}
},
goTo() {
this.$router.push({ name: this.type, params: { id: this.id } });
},
},
};
</script>
<style lang="scss" scoped>
.cover {
position: relative;
padding: 0;
background-size: cover;
display: flex;
justify-content: center;
align-items: center;
transition: transform 0.3s;
}
.hover-float {
&:hover {
transform: scale(1.02);
box-shadow: 0 12px 16px -8px rgba(0, 0, 0, 0.05);
}
}
.hover-play-button {
&:hover {
.play-button {
visibility: visible;
transform: unset;
}
}
.play-button {
&:hover {
transform: scale(1.06);
}
&:active {
transform: scale(0.94);
}
}
}
.shadow {
position: absolute;
filter: blur(16px) opacity(0.6);
z-index: -1;
height: 208px;
}
.play-button {
visibility: hidden;
display: flex;
justify-content: center;
align-items: center;
// right: 72px;
// top: 72px;
border: none;
backdrop-filter: blur(12px) brightness(96%);
background: transparent;
color: white;
border-radius: 50%;
cursor: default;
transition: 0.2s;
.svg-icon {
height: 50%;
margin: {
left: 3px;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

177
src/components/CoverRow.vue Normal file
View File

@ -0,0 +1,177 @@
<template>
<div class="cover-row">
<div
class="item"
:class="{ artist: type === 'artist' }"
v-for="item in items"
:key="item.id"
:style="{ marginBottom: subText === 'none' ? '32px' : '24px' }"
>
<Cover
class="cover"
:id="item.id"
:type="type === 'chart' ? 'playlist' : type"
:url="getUrl(item) | resizeImage(imageSize)"
:hoverEffect="true"
:showBlackShadow="true"
:showPlayButton="showPlayButton"
:radius="type === 'artist' ? 100 : 12"
:size="type === 'artist' ? 192 : 208"
/>
<div class="text">
<div class="info" v-if="showPlayCount">
<span class="play-count"
><svg-icon icon-class="play" />{{
item.playCount | formatPlayCount
}}
</span>
</div>
<div class="name">
<span
class="explicit-symbol"
v-if="type === 'album' && item.mark === 1056768"
><ExplicitSymbol
/></span>
<router-link
:to="`/${type === 'chart' ? 'playlist' : type}/${item.id}`"
>{{ item.name }}</router-link
>
</div>
<div class="info" v-if="type !== 'artist' && subText !== 'none'">
<span v-html="getSubText(item)"></span>
</div>
</div>
</div>
</div>
</template>
<script>
import ExplicitSymbol from "@/components/ExplicitSymbol.vue";
import Cover from "@/components/Cover.vue";
export default {
name: "CoverRow",
components: {
Cover,
ExplicitSymbol,
},
props: {
items: Array,
type: String,
subText: {
type: String,
default: "none",
},
imageSize: {
type: Number,
default: 512,
},
showPlayButton: {
type: Boolean,
default: false,
},
showPlayCount: {
type: Boolean,
default: false,
},
},
methods: {
getUrl(item) {
if (item.picUrl !== undefined) return item.picUrl;
if (item.coverImgUrl !== undefined) return item.coverImgUrl;
if (item.img1v1Url !== undefined) return item.img1v1Url;
},
getSubText(item) {
if (this.subText === "copywriter") return item.copywriter;
if (this.subText === "description") return item.description;
if (this.subText === "updateFrequency") return item.updateFrequency;
if (this.subText === "creator") return "by " + item.creator.nickname;
if (this.subText === "releaseYear")
return new Date(item.publishTime).getFullYear();
if (this.subText === "artist")
return `<a href="/#/artist/${item.artist.id}">${item.artist.name}</a>`;
if (this.subText === "albumType+releaseYear")
return `${item.size === 1 ? "Single" : "EP"} · ${new Date(
item.publishTime
).getFullYear()}`;
if (this.subText === "appleMusic") return "by Apple Music";
},
},
};
</script>
<style lang="scss" scoped>
.cover-row {
display: flex;
flex-wrap: wrap;
margin: {
right: -12px;
left: -12px;
}
.index-playlist {
margin: 12px 12px 24px 12px;
}
}
.item {
margin: 12px 12px 24px 12px;
.text {
width: 208px;
margin-top: 8px;
.name {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.info {
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
line-height: 18px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
// margin-top: 4px;
}
}
}
.item.artist {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
.cover {
display: flex;
}
.name {
margin-top: 4px;
}
}
.explicit-symbol {
color: rgba(0, 0, 0, 0.28);
float: right;
.svg-icon {
margin-bottom: -3px;
}
}
.play-count {
font-weight: 600;
color: rgba(0, 0, 0, 0.58);
font-size: 12px;
.svg-icon {
margin-right: 3px;
height: 8px;
width: 8px;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<svg-icon icon-class="explicit" :style="svgStyle"></svg-icon>
</template>
<script>
import SvgIcon from "@/components/SvgIcon.vue";
export default {
name: "ExplicitSymbol",
components: {
SvgIcon,
},
props: {
size: {
type: Number,
default: 16,
},
},
data() {
return {
svgStyle: {},
};
},
created() {
this.svgStyle = {
height: this.size + "px",
width: this.size + "px",
};
},
};
</script>
<style lang="scss" scoped></style>

26
src/components/Footer.vue Normal file
View File

@ -0,0 +1,26 @@
<template>
<footer>
<ButtonTwoTone :iconClass="'settings'" :color="'grey'">
Settings
</ButtonTwoTone>
</footer>
</template>
<script>
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
export default {
name: "Footer",
components: {
ButtonTwoTone,
},
};
</script>
<style lang="scss" scoped>
footer {
display: flex;
justify-content: center;
margin-top: 48px;
}
</style>

196
src/components/Navbar.vue Normal file
View File

@ -0,0 +1,196 @@
<template>
<nav>
<div class="navigation-buttons">
<button-icon @click.native="go('back')"
><svg-icon icon-class="arrow-left"
/></button-icon>
<button-icon @click.native="go('forward')"
><svg-icon icon-class="arrow-right"
/></button-icon>
</div>
<div class="navigation-links">
<router-link to="/" :class="{ active: this.$route.name === 'home' }"
>Home</router-link
>
<router-link
to="/explore"
:class="{ active: this.$route.name === 'explore' }"
>Explore</router-link
>
<router-link
to="/library"
:class="{ active: this.$route.name === 'library' }"
>Library</router-link
>
</div>
<div class="right-part">
<a href="https://github.com/qier222/YesPlayMusic" target="blank"
><svg-icon icon-class="github" class="github"
/></a>
<div class="search-box">
<div class="container" :class="{ active: inputFocus }">
<svg-icon icon-class="search" />
<div class="input">
<input
:placeholder="inputFocus ? '' : 'Search'"
v-model="keywords"
@keydown.enter="goToSearchPage"
@focus="inputFocus = true"
@blur="inputFocus = false"
/>
</div>
</div>
</div>
</div>
</nav>
</template>
<script>
import ButtonIcon from "@/components/ButtonIcon.vue";
export default {
name: "Navbar",
components: {
ButtonIcon,
},
data() {
return {
inputFocus: false,
keywords: "",
};
},
methods: {
go(where) {
if (where === "back") this.$router.go(-1);
else this.$router.go(1);
},
goToSearchPage() {
this.$router.push({
name: "search",
query: { keywords: this.keywords },
});
},
},
};
</script>
<style lang="scss" scoped>
nav {
position: fixed;
top: 0;
right: 0;
left: 0;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
padding: {
right: 10vw;
left: 10vw;
}
backdrop-filter: saturate(180%) blur(30px);
background-color: rgba(255, 255, 255, 0.86);
z-index: 100;
// border-bottom: 1px solid rgba(0, 0, 0, 0.02);
}
.navigation-buttons {
flex: 1;
display: flex;
align-items: center;
.svg-icon {
height: 24px;
width: 24px;
}
}
.navigation-links {
flex: 1;
display: flex;
justify-content: center;
text-transform: uppercase;
a {
font-size: 18px;
font-weight: 700;
text-decoration: none;
border-radius: 6px;
padding: 6px 10px;
color: black;
transition: 0.2s;
margin: {
right: 12px;
left: 12px;
}
&:hover {
background: #eaeffd;
color: #335eea;
}
&:active {
transform: scale(0.92);
transition: 0.2s;
}
}
a.active {
color: #335eea;
}
}
.search {
.svg-icon {
height: 18px;
width: 18px;
}
}
.search-box {
display: flex;
justify-content: flex-end;
.container {
display: flex;
align-items: center;
height: 32px;
background: rgba(0, 0, 0, 0.06);
border-radius: 8px;
width: 200px;
}
.svg-icon {
height: 15px;
width: 15px;
color: #aaaaaa;
margin: {
left: 8px;
right: 4px;
}
}
input {
font-size: 16px;
border: none;
background: transparent;
width: 96%;
font-weight: 600;
margin-top: -1px;
}
.active {
background: #eaeffd;
input,
.svg-icon {
color: #335eea;
}
}
}
.right-part {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
.github {
margin-right: 16px;
height: 24px;
width: 24px;
}
}
</style>

View File

@ -0,0 +1,39 @@
<template>
<svg :class="svgClass" aria-hidden="true">
<use :xlink:href="iconName" />
</svg>
</template>
<script>
export default {
name: "SvgIcon",
props: {
iconClass: {
type: String,
required: true,
},
className: {
type: String,
default: "",
},
},
computed: {
iconName() {
return `#icon-${this.iconClass}`;
},
svgClass() {
if (this.className) {
return "svg-icon " + this.className;
} else {
return "svg-icon";
}
},
},
};
</script>
<style scoped>
.svg-icon {
fill: currentColor;
}
</style>

View File

@ -0,0 +1,91 @@
<template>
<div class="track-list" :style="listStyles">
<ContextMenu ref="menu">
<div class="item" @click="play">Play</div>
<div class="item" @click="playNext">Play Next</div>
</ContextMenu>
<TrackListItem
v-for="track in tracks"
:track="track"
:key="track.id"
@dblclick.native="playThisList(track.id)"
@click.right.native="openMenu($event, track)"
/>
</div>
</template>
<script>
import { mapActions } from "vuex";
import {
playPlaylistByID,
playAlbumByID,
playAList,
appendTrackToPlayerList,
} from "@/utils/play";
import TrackListItem from "@/components/TrackListItem.vue";
import ContextMenu from "@/components/ContextMenu.vue";
export default {
name: "TrackList",
components: {
TrackListItem,
ContextMenu,
},
props: {
tracks: Array,
type: String,
id: Number,
itemWidth: {
type: Number,
default: -1,
},
dbclickTrackFunc: {
type: String,
default: "none",
},
},
data() {
return {
clickTrack: null,
listStyles: {},
};
},
created() {
if (this.type === "tracklist")
this.listStyles = { display: "flex", flexWrap: "wrap" };
},
methods: {
...mapActions(["nextTrack"]),
openMenu(e, track) {
if (!track.playable) {
return;
}
this.clickTrack = track;
this.$refs.menu.openMenu(e);
},
playThisList(trackID) {
if (this.type === "playlist") {
playPlaylistByID(this.id, trackID);
} else if (this.type === "album") {
playAlbumByID(this.id, trackID);
} else if (this.type === "tracklist") {
if (this.dbclickTrackFunc === "none") {
playAList(this.tracks, this.tracks[0].ar[0].id, "artist", trackID);
} else {
if (this.dbclickTrackFunc === "playPlaylistByID")
playPlaylistByID(this.id, trackID);
}
}
},
play() {
appendTrackToPlayerList(this.clickTrack, true);
},
playNext() {
appendTrackToPlayerList(this.clickTrack);
},
},
};
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,250 @@
<template>
<div class="track" :class="trackClass" :style="trackStyle">
<img :src="imgUrl | resizeImage" v-if="!isAlbum" @click="goToAlbum" />
<div class="no" v-if="isAlbum">{{ track.no }}</div>
<div class="title-and-artist">
<div class="container">
<div class="title">
{{ track.name }}
<span class="featured" v-if="isAlbum && track.ar.length > 1">
-
<ArtistsInLine :artists="track.ar" :showFirstArtist="false"
/></span>
<span v-if="isAlbum && track.mark === 1318912" class="explicit-symbol"
><ExplicitSymbol
/></span>
</div>
<div class="artist" v-if="!isAlbum">
<span
v-if="track.mark === 1318912"
class="explicit-symbol before-artist"
><ExplicitSymbol
/></span>
<ArtistsInLine :artists="artists" />
</div>
</div>
<div></div>
</div>
<div class="album" v-if="!isTracklist && !isAlbum">
<div class="container">
<router-link :to="`/album/${track.al.id}`">{{
track.al.name
}}</router-link>
</div>
<div></div>
</div>
<div class="time" v-if="!isTracklist">
{{ track.dt | formatTime }}
</div>
</div>
</template>
<script>
import ArtistsInLine from "@/components/ArtistsInLine.vue";
import ExplicitSymbol from "@/components/ExplicitSymbol.vue";
export default {
name: "TrackListItem",
components: { ArtistsInLine, ExplicitSymbol },
props: {
track: Object,
},
data() {
return {
trackClass: [],
trackStyle: {},
};
},
created() {
this.trackClass.push(this.type);
if (!this.track.playable) this.trackClass.push("disable");
if (this.$parent.itemWidth !== -1)
this.trackStyle = { width: this.$parent.itemWidth + "px" };
},
computed: {
imgUrl() {
if (this.track.al !== undefined) return this.track.al.picUrl;
if (this.track.album !== undefined) return this.track.album.picUrl;
return "";
},
artists() {
if (this.track.ar !== undefined) return this.track.ar;
if (this.track.artists !== undefined) return this.track.artists;
return [];
},
type() {
return this.$parent.type;
},
isAlbum() {
return this.type === "album";
},
isTracklist() {
return this.type === "tracklist";
},
isPlaylist() {
return this.type === "playlist";
},
},
methods: {
goToAlbum() {
this.$router.push({ path: "/album/" + this.track.al.id });
},
},
};
</script>
<style lang="scss" scoped>
.track {
display: flex;
align-items: center;
padding: 8px;
border-radius: 12px;
.no {
display: flex;
justify-content: center;
align-items: center;
border-radius: 8px;
margin: 0 20px 0 10px;
width: 12px;
color: rgba(0, 0, 0, 0.58);
cursor: default;
}
.explicit-symbol {
color: rgba(0, 0, 0, 0.28);
.svg-icon {
margin-bottom: -3px;
}
}
.explicit-symbol.before-artist {
margin-right: 2px;
.svg-icon {
margin-bottom: -3px;
}
}
img {
border-radius: 8px;
height: 56px;
width: 56px;
margin-right: 20px;
border: 1px solid rgba(0, 0, 0, 0.04);
cursor: pointer;
}
.title-and-artist {
flex: 1;
display: flex;
.container {
display: flex;
flex-direction: column;
}
.title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
cursor: default;
padding-right: 16px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
word-break: break-all;
.featured {
margin-right: 2px;
font-weight: 500;
font-size: 14px;
color: rgba(0, 0, 0, 0.72);
}
}
.artist {
margin-top: 2px;
font-size: 13px;
color: rgba(0, 0, 0, 0.68);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
a {
span {
margin-right: 3px;
color: rgba(0, 0, 0, 0.8);
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
}
.album {
flex: 1;
display: flex;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
.container {
display: flex;
flex-direction: column;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
}
.time {
font-size: 16px;
width: 50px;
cursor: default;
display: flex;
justify-content: flex-end;
margin-right: 10px;
font-variant-numeric: tabular-nums;
}
&:hover {
transition: all 0.3s;
background: #f5f5f7;
}
}
.track.disable {
img {
filter: grayscale(1) opacity(0.6);
}
.title,
.artist,
.album,
.time,
.featured {
color: rgba(0, 0, 0, 0.28) !important;
}
&:hover {
background: none;
}
}
.track.tracklist {
width: 256px;
img {
height: 36px;
width: 36px;
border-radius: 6px;
margin-right: 14px;
cursor: pointer;
}
.title {
font-size: 16px;
}
.artist {
font-size: 12px;
}
}
.track.album {
height: 32px;
}
</style>

24
src/main.js Normal file
View File

@ -0,0 +1,24 @@
import Vue from "vue";
import VueAnalytics from "vue-analytics";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import "@/assets/icons";
import "@/utils/filters";
import { initMediaSession } from "@/utils/mediaSession";
import "./registerServiceWorker";
Vue.use(VueAnalytics, {
id: "UA-180189423-1",
router,
});
Vue.config.productionTip = false;
initMediaSession();
new Vue({
store,
router,
render: (h) => h(App),
}).$mount("#app");

View File

@ -0,0 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated () {
console.log('New content is available; please refresh.')
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

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

@ -0,0 +1,114 @@
import Vue from "vue";
import VueRouter from "vue-router";
import store from "@/store";
import NProgress from "nprogress";
import "@/assets/css/nprogress.css";
NProgress.configure({ showSpinner: false, trickleSpeed: 100 });
Vue.use(VueRouter);
const routes = [
{
path: "/",
name: "home",
component: () => import("@/views/home"),
meta: {
keepAlive: true,
},
},
{ path: "/login", name: "login", component: () => import("@/views/login") },
{
path: "/playlist/:id",
name: "playlist",
component: () => import("@/views/playlist"),
},
{
path: "/album/:id",
name: "album",
component: () => import("@/views/album"),
},
{
path: "/artist/:id",
name: "artist",
component: () => import("@/views/artist"),
},
{
path: "/next",
name: "next",
component: () => import("@/views/next"),
meta: {
keepAlive: true,
},
},
{
path: "/search",
name: "search",
component: () => import("@/views/search"),
},
{
path: "/new-album",
name: "newAlbum",
component: () => import("@/views/newAlbum"),
},
{
path: "/explore",
name: "explore",
component: () => import("@/views/explore"),
meta: {
keepAlive: true,
},
},
{
path: "/library",
name: "library",
component: () => import("@/views/library"),
meta: {
requireLogin: true,
keepAlive: true,
},
},
{
path: "/library/liked-songs",
name: "likedSongs",
component: () => import("@/views/likedSongs"),
meta: {
requireLogin: true,
},
},
];
const router = new VueRouter({
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
// return new Promise((resolve) => {
// setTimeout(() => {
// resolve(savedPosition);
// }, 100);
// });
return savedPosition;
} else {
return { x: 0, y: 0 };
}
},
});
router.beforeEach((to, from, next) => {
if (to.meta.requireLogin) {
if (store.state.settings.user.nickname === undefined) {
next({ path: "/login" });
} else {
next();
}
} else {
next();
}
});
router.afterEach((to) => {
if (to.matched.some((record) => !record.meta.keepAlive)) {
NProgress.start();
}
});
export default router;

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

@ -0,0 +1,65 @@
// import { getMP3 } from "@/api/track";
import { updateMediaSessionMetaData } from "@/utils/mediaSession";
export default {
switchTrack({ state, dispatch, commit }, track) {
commit("updateCurrentTrack", track);
commit("updatePlayingStatus", true);
if (track.playable === false) {
dispatch("nextTrack");
return;
}
updateMediaSessionMetaData(track);
document.title = `${track.name} · ${track.artists[0].name} - YesPlayMusic`;
commit(
"replaceMP3",
`https://music.163.com/song/media/outer/url?id=${track.id}`
);
state.howler.once("end", () => {
dispatch("nextTrack");
});
},
playFirstTrackOnList({ state, dispatch }) {
dispatch("switchTrack", state.player.list[0]);
},
playTrackOnListByID(context, trackID) {
let track = context.state.player.list.find((t) => t.id === trackID);
if (track.playable === false) return;
context.dispatch("switchTrack", track);
},
nextTrack({ state, dispatch, commit }, realNext = false) {
let nextTrack = state.player.list.find(
(track) => track.sort === state.player.currentTrack.sort + 1
);
if (state.player.repeat === "on" && nextTrack === undefined) {
nextTrack = state.player.list.find((t) => t.sort === 0);
}
if (state.player.repeat === "one" && realNext === false) {
nextTrack = state.player.currentTrack;
}
if (state.player.repeat === "off" && nextTrack === undefined) {
commit("updatePlayingStatus", false);
state.howler.stop();
return;
}
dispatch("switchTrack", nextTrack);
},
previousTrack({ state, dispatch }) {
let previousTrack = state.player.list.find(
(track) => track.sort === state.player.currentTrack.sort - 1
);
previousTrack =
previousTrack === null || previousTrack === undefined
? state.player.list[-1]
: previousTrack;
dispatch("switchTrack", previousTrack);
},
};

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

@ -0,0 +1,40 @@
import Vue from "vue";
import Vuex from "vuex";
import state from "./state";
import mutations from "./mutations";
import actions from "./actions";
import initState from "./initState";
import { Howl } from "howler";
if (localStorage.getItem("appVersion") === null) {
localStorage.setItem("player", JSON.stringify(initState.player));
localStorage.setItem("settings", JSON.stringify(initState.settings));
localStorage.setItem("appVersion", "0.1");
window.location.reload();
}
Vue.use(Vuex);
const saveToLocalStorage = (store) => {
store.subscribe((mutation, state) => {
// console.log(mutation);
localStorage.setItem("player", JSON.stringify(state.player));
localStorage.setItem("settings", JSON.stringify(state.settings));
});
};
const store = new Vuex.Store({
state: state,
mutations,
actions,
plugins: [saveToLocalStorage],
});
store.state.howler = new Howl({
src: [
`https://music.163.com/song/media/outer/url?id=${store.state.player.currentTrack.id}`,
],
html5: true,
format: ["mp3"],
});
export default store;

91
src/store/initState.js Normal file
View File

@ -0,0 +1,91 @@
import { Howler } from "howler";
const initState = {
loading: true,
Howler: Howler,
howler: null,
contextMenu: {
clickObjectID: 0,
showMenu: false,
},
player: {
enable: false,
show: true,
playing: false,
shuffle: false,
volume: 1,
repeat: "off", // on | off | one
currentTrack: {
sort: 0,
name: "Happiness",
id: 1478005597,
artists: [{ id: 12931567, name: "John K", tns: [], alias: [] }],
album: {
id: 95187944,
name: "Happiness",
picUrl:
"https://p1.music.126.net/kHNNN-VxufjlBtyNPIP3kg==/109951165306614548.jpg",
tns: [],
pic_str: "109951165306614548",
pic: 109951165306614540,
},
time: 196022,
playable: true,
},
notShuffledList: [],
list: [],
listInfo: {
type: "",
id: "",
},
},
settings: {
playlistCategories: [
{
name: "全部",
enable: true,
},
{
name: "推荐歌单",
enable: true,
},
{
name: "精品歌单",
enable: true,
},
{
name: "官方",
enable: true,
},
{
name: "流行",
enable: true,
},
{
name: "电子",
enable: true,
},
{
name: "摇滚",
enable: true,
},
{
name: "ACG",
enable: true,
},
// {
// name: "最新专辑",
// enable: true,
// },
{
name: "排行榜",
enable: true,
},
],
user: {
id: 1,
},
},
};
export default initState;

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

@ -0,0 +1,92 @@
import { Howl } from "howler";
import state from "./state";
export default {
updatePlayerState(state, { key, value }) {
state.player[key] = value;
},
updatePlayingStatus(state, status) {
state.player.playing = status;
},
updateCurrentTrack(state, track) {
state.player.currentTrack = track;
},
replaceMP3(state, mp3) {
state.Howler.unload();
state.howler = new Howl({
src: [mp3],
autoplay: true,
html5: true,
});
state.howler.play();
},
updatePlayerList(state, list) {
state.player.list = list;
if (state.player.enable !== true) state.player.enable = true;
},
updateListInfo(state, info) {
state.player.listInfo = info;
},
updateShuffleStatus(state, status) {
state.player.shuffle = status;
},
updateRepeatStatus(state, status) {
state.player.repeat = status;
},
appendTrackToPlayerList(state, { track, playNext = false }) {
let existTrack = state.player.list.find((t) => t.id === track.id);
if (
(existTrack === null || existTrack === undefined) &&
playNext === false
) {
state.player.list.push(track);
return;
}
// 把track加入到正在播放歌曲的下一首位置
state.player.list = state.player.list.map((t) => {
if (t.sort > state.player.currentTrack.sort) {
t.sort = t.sort + 1;
}
return t;
});
track.sort = state.player.currentTrack.sort + 1;
state.player.list.push(track);
},
shuffleTheList(state) {
let getOneRandomly = (arr) => arr[Math.floor(Math.random() * arr.length)];
state.player.notShuffledList = JSON.parse(
JSON.stringify(state.player.list)
);
let sorts = Array.from(new Array(state.player.list.length).keys());
sorts = sorts.filter((no) => no != 0);
let shuffledList = state.player.list.map((track) => {
if (track.id === state.player.currentTrack.id) {
// 确保正在播放的歌的sort是第一个
track.sort = 0;
return track;
}
let sortNo = getOneRandomly(sorts);
sorts = sorts.filter((no) => no != sortNo);
track.sort = sortNo;
return track;
});
state.player.list = shuffledList;
// 更新当前播放歌曲的sort
let currentTrack = state.player.list.find(
(t) => t.id === state.player.currentTrack.id
);
state.player.currentTrack.sort = currentTrack.sort;
state.player.shuffle = true;
},
updateUser(state, user) {
state.settings.user = user;
},
updateUserInfo(sate, { key, value }) {
state.settings.user[key] = value;
},
};

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

@ -0,0 +1,12 @@
import { Howler } from "howler";
export default {
Howler: Howler,
howler: null,
contextMenu: {
clickObjectID: 0,
showMenu: false,
},
player: JSON.parse(localStorage.getItem("player")),
settings: JSON.parse(localStorage.getItem("settings")),
};

40
src/utils/common.js Normal file
View File

@ -0,0 +1,40 @@
export function isTrackPlayable(track) {
let result = {
playable: true,
reason: "",
};
if (track.fee === 1 || track.privilege?.fee === 1) {
result.playable = false;
result.reason = "VIP Only";
} else if (track.fee === 4 || track.privilege?.fee === 4) {
result.playable = false;
result.reason = "Paid Album";
} else if (
track.noCopyrightRcmd !== null &&
track.noCopyrightRcmd !== undefined
) {
result.playable = false;
result.reason = "No Copyright";
}
return result;
}
export function mapTrackPlayableStatus(tracks) {
return tracks.map((t) => {
let result = isTrackPlayable(t);
t.playable = result.playable;
t.reason = result.reason;
return t;
});
}
export function randomNum(minNum, maxNum) {
switch (arguments.length) {
case 1:
return parseInt(Math.random() * minNum + 1, 10);
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10);
default:
return 0;
}
}

64
src/utils/filters.js Normal file
View File

@ -0,0 +1,64 @@
import Vue from "vue";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
Vue.filter("formatTime", (Milliseconds, format = "HH:MM:SS") => {
if (!Milliseconds) return "";
dayjs.extend(duration);
dayjs.extend(relativeTime);
let time = dayjs.duration(Milliseconds);
let hours = time.hours().toString();
let mins = time.minutes().toString();
let seconds = time
.seconds()
.toString()
.padStart(2, "0");
if (format === "HH:MM:SS") {
return hours !== "0"
? `${hours}:${mins.padStart(2, "0")}:${seconds}`
: `${mins}:${seconds}`;
} else if (format === "Human") {
return hours !== "0" ? `${hours} hr ${mins} min` : `${mins} min`;
}
});
Vue.filter("formatDate", (timestamp, format = "MMM D, YYYY") => {
if (!timestamp) return "";
return dayjs(timestamp).format(format);
});
Vue.filter("formatAlbumType", (type, album) => {
if (!type) return "";
if (type === "EP/Single") {
return album.size === 1 ? "Single" : "EP";
} else if (type === "Single") {
return "Single";
} else if (type === "专辑") {
return "Album";
} else {
return type;
}
});
Vue.filter("resizeImage", (imgUrl, size = 512) => {
if (!imgUrl) return "";
let httpsImgUrl = imgUrl;
if (imgUrl.slice(0, 5) !== "https") {
httpsImgUrl = "https" + imgUrl.slice(4);
}
return `${httpsImgUrl}?param=${size}y${size}`;
});
Vue.filter("formatPlayCount", (count) => {
if (!count) return "";
if (count > 100000000) {
return `${~~(count / 100000000)}亿`;
}
if (count > 10000) {
return `${~~(count / 10000)}`;
}
return count;
});

39
src/utils/mediaSession.js Normal file
View File

@ -0,0 +1,39 @@
import store from "@/store";
export function initMediaSession() {
if ("mediaSession" in navigator) {
navigator.mediaSession.setActionHandler("play", function() {
store.state.howler.play();
});
navigator.mediaSession.setActionHandler("pause", function() {
store.state.howler.pause();
});
navigator.mediaSession.setActionHandler("previoustrack", function() {
store.dispatch("previousTrack");
});
navigator.mediaSession.setActionHandler("nexttrack", function() {
store.dispatch("nextTrack");
});
navigator.mediaSession.setActionHandler("stop", () => {
store.state.howler.stop();
});
}
}
export function updateMediaSessionMetaData(track) {
if ("mediaSession" in navigator) {
let artists = track.artists.map((a) => a.name);
navigator.mediaSession.metadata = new window.MediaMetadata({
title: track.name,
artist: artists.join(","),
album: track.album.name,
artwork: [
{
src: track.album.picUrl + "?param=512y512",
type: "image/jpg",
sizes: "512x512",
},
],
});
}
}

68
src/utils/play.js Normal file
View File

@ -0,0 +1,68 @@
import store from "@/store";
import { getAlbum } from "@/api/album";
import { getPlaylistDetail } from "@/api/playlist";
import { getTrackDetail } from "@/api/track";
import { getArtist } from "@/api/artist";
import { trackFee } from "@/utils/common";
export function playAList(list, id, type, trackID = "first") {
let filteredList = list.map((track, index) => {
return {
sort: index,
name: track.name,
id: track.id,
artists: track.ar,
album: track.al,
time: track.dt,
playable: trackFee(track).playable,
};
});
store.commit("updatePlayerList", filteredList);
if (trackID === "first") store.dispatch("playFirstTrackOnList");
else store.dispatch("playTrackOnListByID", trackID);
store.commit("updateListInfo", { type, id });
}
export function playAlbumByID(id, trackID = "first") {
getAlbum(id).then((data) => {
playAList(data.songs, id, "album", trackID);
});
}
export function playPlaylistByID(id, trackID = "first") {
getPlaylistDetail(id).then((data) => {
let trackIDs = data.playlist.trackIds.map((t) => t.id);
getTrackDetail(trackIDs.join(",")).then((data) => {
playAList(data.songs, id, "playlist", trackID);
});
});
}
export function playArtistByID(id, trackID = "first") {
getArtist(id).then((data) => {
playAList(data.hotSongs, id, "artist", trackID);
});
}
export function appendTrackToPlayerList(track, playNext = false) {
let filteredTrack = {
sort: 0,
name: track.name,
id: track.id,
artists: track.ar,
album: track.al,
time: track.dt,
playable: track.playable,
};
store.commit("appendTrackToPlayerList", {
track: filteredTrack,
playNext,
});
if (playNext) {
store.dispatch("nextTrack", true);
}
}

29
src/utils/request.js Normal file
View File

@ -0,0 +1,29 @@
import axios from "axios";
const service = axios.create({
baseURL: "/api",
withCredentials: true,
timeout: 15000,
});
service.interceptors.response.use(
(response) => {
const res = response.data;
if (res.code !== 200) {
if (res.code === 401) {
alert("token expired");
} else {
alert("unknow error");
}
} else {
return res;
}
},
(error) => {
console.log("err" + error);
alert("err " + error);
return Promise.reject(error);
}
);
export default service;

4454
src/utils/staticPlaylist.js Normal file

File diff suppressed because it is too large Load Diff

263
src/views/album.vue Normal file
View File

@ -0,0 +1,263 @@
<template>
<div class="album" v-show="show">
<div class="playlist-info">
<Cover
:url="album.picUrl | resizeImage(1024)"
:showPlayButton="true"
:alwaysShowShadow="true"
:clickToPlay="true"
:size="288"
:type="'album'"
:id="album.id"
/>
<div class="info">
<div class="title">
{{ album.name }}
</div>
<div class="artist">
<span>{{ album.type | formatAlbumType(album) }} by </span
><router-link :to="`/artist/${album.artist.id}`">{{
album.artist.name
}}</router-link>
</div>
<div class="date-and-count">
<span class="explicit-symbol" v-if="album.mark === 1056768"
><ExplicitSymbol
/></span>
<span :title="album.publishTime | formatDate">{{
new Date(album.publishTime).getFullYear()
}}</span>
<span> · {{ album.size }} songs</span>,
{{ albumTime | formatTime("Human") }}
</div>
<div class="description" @click="showFullDescription = true">
{{ album.description }}
</div>
<div class="buttons" style="margin-top:32px">
<ButtonTwoTone
@click.native="playAlbumByID(album.id)"
:iconClass="`play`"
>
PLAY
</ButtonTwoTone>
</div>
</div>
</div>
<TrackList :tracks="tracks" :type="'album'" :id="album.id" />
<div class="extra-info">
<div class="album-time"></div>
<div class="release-date">
Released {{ album.publishTime | formatDate("MMMM D, YYYY") }}
</div>
<div class="copyright" v-if="album.company !== null">
© {{ album.company }}
</div>
</div>
<transition name="fade">
<div
class="shade"
@click="showFullDescription = false"
v-show="showFullDescription"
>
<div class="description-full" @click.stop>
<span>{{ album.description }}</span>
<span class="close" @click="showFullDescription = false">Close</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { mapMutations, mapActions, mapState } from "vuex";
import NProgress from "nprogress";
import { getTrackDetail } from "@/api/track";
import { playAlbumByID } from "@/utils/play";
import { mapTrackPlayableStatus } from "@/utils/common";
import { getAlbum } from "@/api/album";
import ExplicitSymbol from "@/components/ExplicitSymbol.vue";
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
import TrackList from "@/components/TrackList.vue";
import Cover from "@/components/Cover.vue";
export default {
name: "Album",
components: {
Cover,
ButtonTwoTone,
TrackList,
ExplicitSymbol,
},
data() {
return {
album: {
id: 0,
picUrl: "",
artist: {
id: 0,
},
},
tracks: [],
showFullDescription: false,
show: false,
};
},
created() {
getAlbum(this.$route.params.id)
.then((data) => {
this.album = data.album;
this.tracks = data.songs;
this.tracks = mapTrackPlayableStatus(this.tracks);
NProgress.done();
this.show = true;
return this.tracks;
})
.then((tracks) => {
let trackIDs = tracks.map((t) => t.id);
getTrackDetail(trackIDs.join(",")).then((data) => {
this.tracks = data.songs;
this.tracks = mapTrackPlayableStatus(this.tracks);
});
});
},
computed: {
...mapState(["player", "loading"]),
albumTime() {
let time = 0;
this.tracks.map((t) => (time = time + t.dt));
return time;
},
},
methods: {
...mapMutations([
"updatePlayerList",
"appendTrackToPlayerList",
"shuffleTheList",
]),
...mapActions(["playFirstTrackOnList", "playTrackOnListByID"]),
playAlbumByID(id, trackID = "first") {
if (this.tracks.find((t) => t.playable !== false) === undefined) {
return;
}
playAlbumByID(id, trackID);
},
},
};
</script>
<style lang="scss" scoped>
.playlist-info {
display: flex;
width: 78vw;
margin-bottom: 72px;
.info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
margin-left: 56px;
.title {
font-size: 56px;
font-weight: 700;
display: inline-flex;
align-items: center;
}
.artist {
font-size: 18px;
color: rgba(0, 0, 0, 0.88);
margin-top: 24px;
a {
font-weight: 600;
}
}
.date-and-count {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 2px;
}
.description {
user-select: none;
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 24px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
cursor: pointer;
&:hover {
transition: color 0.3s;
color: rgba(0, 0, 0, 0.88);
}
}
}
}
.shade {
background: rgba(255, 255, 255, 0.38);
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
.description-full {
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 12px 16px -8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
padding: 32px;
border-radius: 12px;
width: 50vw;
margin: auto 0;
font-size: 14px;
z-index: 100;
display: flex;
flex-direction: column;
.close {
display: flex;
justify-content: flex-end;
font-size: 16px;
margin-top: 20px;
color: #335eea;
cursor: pointer;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.explicit-symbol {
color: rgba(0, 0, 0, 0.28);
margin-right: 4px;
.svg-icon {
margin-bottom: -3px;
}
}
.extra-info {
margin-top: 36px;
font-size: 14px;
color: rgba(0, 0, 0, 0.48);
div {
margin-bottom: 8px;
}
.album-time {
color: rgba(0, 0, 0, 0.68);
}
}
</style>

312
src/views/artist.vue Normal file
View File

@ -0,0 +1,312 @@
<template>
<div class="artist" v-show="show">
<div class="artist-info">
<div class="head">
<img :src="artist.img1v1Url | resizeImage(1024)" />
</div>
<div>
<div class="name">{{ artist.name }}</div>
<div class="artist">Artist</div>
<div class="statistics">
{{ artist.musicSize }} Songs · {{ artist.albumSize }} Albums ·
{{ artist.mvSize }} Music Videos
</div>
<div class="buttons">
<ButtonTwoTone @click.native="playPopularSongs()" :iconClass="`play`">
PLAY
</ButtonTwoTone>
</div>
</div>
</div>
<div class="latest-release">
<div class="section-title">Latest Release</div>
<div class="release">
<div class="container">
<Cover
:url="latestRelease.picUrl | resizeImage"
:showPlayButton="true"
:showBlackShadow="true"
:type="`album`"
:id="latestRelease.id"
:hoverEffect="true"
:size="128"
:playButtonSize="36"
:shadowMargin="8"
:radius="8"
/>
<div class="info">
<div class="name">
<router-link :to="`/album/${latestRelease.id}`">{{
latestRelease.name
}}</router-link>
</div>
<div class="date">
{{ latestRelease.publishTime | formatDate }}
</div>
<div class="type">
{{ latestRelease.type | formatAlbumType(latestRelease) }} ·
{{ latestRelease.size }} Songs
</div>
</div>
</div>
<div></div>
</div>
</div>
<div class="popular-tracks">
<div class="section-title">Popular Songs</div>
<TrackList
:tracks="popularTracks.slice(0, showMorePopTracks ? 24 : 12)"
:type="'tracklist'"
/>
<div class="show-more">
<button @click="showMorePopTracks = !showMorePopTracks">
<span v-show="!showMorePopTracks">SHOW MORE</span>
<span v-show="showMorePopTracks">SHOW LESS</span>
</button>
</div>
</div>
<div class="albums" v-if="albums.length !== 0">
<div class="section-title">Albums</div>
<CoverRow
:type="'album'"
:items="albums"
:subText="'releaseYear'"
:showPlayButton="true"
/>
</div>
<div class="eps">
<div class="section-title">EPs & Singles</div>
<CoverRow
:type="'album'"
:items="eps"
:subText="'albumType+releaseYear'"
:showPlayButton="true"
/>
</div>
</div>
</template>
<script>
import { mapMutations, mapActions, mapState } from "vuex";
import { getArtist, getArtistAlbum } from "@/api/artist";
import { mapTrackPlayableStatus } from "@/utils/common";
import { playAList } from "@/utils/play";
import NProgress from "nprogress";
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
import TrackList from "@/components/TrackList.vue";
import CoverRow from "@/components/CoverRow.vue";
import Cover from "@/components/Cover.vue";
export default {
name: "Artist",
components: { Cover, ButtonTwoTone, TrackList, CoverRow },
data() {
return {
artist: {
img1v1Url:
"https://p1.music.126.net/VnZiScyynLG7atLIZ2YPkw==/18686200114669622.jpg",
},
popularTracks: [],
albumsData: [],
latestRelease: {
picUrl: "",
publishTime: 0,
id: 0,
name: "",
type: "",
size: "",
},
showMorePopTracks: false,
show: false,
};
},
created() {
this.loadData(this.$route.params.id);
},
computed: {
...mapState(["player"]),
albums() {
return this.albumsData.filter((a) => a.type === "专辑");
},
eps() {
return this.albumsData.filter((a) =>
["EP/Single", "EP", "Single"].includes(a.type)
);
},
},
methods: {
...mapMutations([
"updatePlayerList",
"appendTrackToPlayerList",
"shuffleTheList",
]),
...mapActions(["playFirstTrackOnList", "playTrackOnListByID"]),
loadData(id, next = undefined) {
getArtist(id).then((data) => {
this.artist = data.artist;
this.popularTracks = data.hotSongs;
if (next !== undefined) next();
this.popularTracks = mapTrackPlayableStatus(this.popularTracks);
NProgress.done();
this.show = true;
});
getArtistAlbum({ id: id, limit: 200 }).then((data) => {
this.albumsData = data.hotAlbums;
this.latestRelease = data.hotAlbums[0];
});
},
goToAlbum(id) {
this.$router.push({
name: "album",
params: { id },
});
},
playPopularSongs(trackID = "first") {
playAList(this.popularTracks, this.artist.id, "artist", trackID);
},
},
beforeRouteUpdate(to, from, next) {
NProgress.start();
this.artist.img1v1Url =
"https://p1.music.126.net/VnZiScyynLG7atLIZ2YPkw==/18686200114669622.jpg";
this.loadData(to.params.id, next);
},
};
</script>
<style lang="scss" scoped>
.artist-info {
display: flex;
align-items: center;
margin-bottom: 72px;
img {
height: 192px;
width: 192px;
border-radius: 50%;
margin-right: 56px;
box-shadow: rgba(0, 0, 0, 0.2) 0px 12px 16px -8px;
}
.name {
font-size: 56px;
font-weight: 700;
}
.artist {
font-size: 18px;
color: rgba(0, 0, 0, 0.88);
margin-top: 24px;
}
.statistics {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 2px;
}
.buttons {
margin-top: 26px;
display: flex;
.shuffle {
padding: 8px 11px;
.svg-icon {
margin: 0;
}
}
}
}
.section-title {
font-weight: 600;
font-size: 22px;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 16px;
margin-top: 46px;
}
.latest-release {
.release {
display: flex;
}
.container {
display: flex;
align-items: center;
border-radius: 12px;
}
img {
height: 96px;
border-radius: 8px;
}
.info {
margin-left: 24px;
}
.name {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 8px;
}
.date {
font-size: 14px;
color: rgba(0, 0, 0, 0.78);
}
.type {
margin-top: 2px;
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
}
}
.popular-tracks {
.show-more {
display: flex;
button {
padding: 4px 8px;
margin-top: 8px;
border-radius: 6px;
font-size: 12px;
color: rgba(0, 0, 0, 0.78);
font-weight: 600;
&:hover {
background: #f5f5f7;
color: rgba(0, 0, 0, 0.96);
}
}
}
}
.cover-row {
&:first-child {
margin-top: 0;
}
}
.covers {
display: flex;
flex-wrap: wrap;
margin: {
right: -12px;
left: -12px;
}
.cover {
margin: 12px 12px 24px 12px;
.text {
width: 208px;
margin-top: 8px;
.name {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 20px;
}
.info {
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
line-height: 18px;
// margin-top: 4px;
}
}
}
}
</style>

195
src/views/explore.vue Normal file
View File

@ -0,0 +1,195 @@
<template>
<div class="explore">
<h1>Explore</h1>
<div class="buttons">
<div
class="button"
v-for="cat in settings.playlistCategories"
:key="cat.name"
:class="{ active: cat.name === activeCategory }"
@click="goToCategory(cat.name)"
>
{{ cat.name }}
</div>
<div class="button more">
<svg-icon icon-class="more"></svg-icon>
</div>
</div>
<div class="playlists">
<CoverRow
type="playlist"
:items="playlists"
:subText="subText"
:showPlayButton="true"
:showPlayCount="activeCategory !== '排行榜' ? true : false"
:imageSize="activeCategory !== '排行榜' ? 512 : 1024"
/>
</div>
<div
class="load-more"
v-show="['推荐歌单', '排行榜'].includes(activeCategory) === false"
>
<ButtonTwoTone
v-show="showLoadMoreButton && hasMore"
@click.native="getPlaylist"
color="grey"
:loading="loadingMore"
>Load More</ButtonTwoTone
>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
import NProgress from "nprogress";
import {
topPlaylist,
highQualityPlaylist,
recommendPlaylist,
toplists,
} from "@/api/playlist";
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
import CoverRow from "@/components/CoverRow.vue";
import SvgIcon from "@/components/SvgIcon.vue";
export default {
name: "Explore",
components: {
CoverRow,
ButtonTwoTone,
SvgIcon,
},
data() {
return {
playlists: [],
activeCategory: "全部",
loadingMore: false,
showLoadMoreButton: false,
hasMore: true,
};
},
computed: {
...mapState(["settings"]),
subText() {
if (this.activeCategory === "排行榜") return "updateFrequency";
if (this.activeCategory === "推荐歌单") return "copywriter";
return "none";
},
},
methods: {
loadData() {
this.activeCategory =
this.$route.query.category === undefined
? "全部"
: this.$route.query.category;
this.getPlaylist();
},
goToCategory(Category) {
this.$router.push({ path: "/explore?category=" + Category });
},
updatePlaylist(playlists) {
this.playlists.push(...playlists);
this.loadingMore = false;
this.showLoadMoreButton = true;
NProgress.done();
},
getPlaylist() {
this.loadingMore = true;
if (this.activeCategory === "推荐歌单") {
recommendPlaylist({ limit: 100 }).then((data) => {
this.updatePlaylist(data.result);
});
} else if (this.activeCategory === "精品歌单") {
let playlists = this.playlists;
let before =
playlists.length !== 0
? playlists[playlists.length - 1].updateTime
: 0;
highQualityPlaylist({ limit: 50, before }).then((data) => {
this.updatePlaylist(data.playlists);
this.hasMore = data.more;
});
} else if (this.activeCategory === "排行榜") {
toplists().then((data) => {
this.updatePlaylist(data.list);
});
} else {
topPlaylist({
cat: this.activeCategory,
offset: this.playlists.length,
}).then((data) => {
this.updatePlaylist(data.playlists);
this.hasMore = data.more;
});
}
},
},
activated() {
this.loadData();
},
beforeRouteUpdate(to, from, next) {
NProgress.start();
this.showLoadMoreButton = false;
this.hasMore = true;
this.playlists = [];
this.offset = 1;
this.activeCategory = to.query.category;
this.getPlaylist();
next();
},
};
</script>
<style lang="scss" scoped>
h1 {
font-size: 56px;
}
.buttons {
display: flex;
flex-wrap: wrap;
}
.button {
user-select: none;
cursor: pointer;
padding: 8px 16px;
margin: 10px 16px 6px 0;
display: flex;
justify-content: center;
align-items: center;
font-weight: 600;
font-size: 18px;
border-radius: 10px;
color: rgb(0, 0, 0);
background-color: #f5f5f7;
color: rgba(0, 0, 0, 0.68);
transition: 0.2s;
&:hover {
background-color: rgba(51, 94, 234, 0.1);
color: #335eea;
}
}
.button.active {
background-color: rgba(51, 94, 234, 0.1);
color: #335eea;
}
.playlists {
margin-top: 24px;
}
.load-more {
display: flex;
justify-content: center;
}
.button.more {
.svg-icon {
height: 24px;
width: 24px;
}
}
</style>

183
src/views/home.vue Normal file
View File

@ -0,0 +1,183 @@
<template>
<div class="home">
<div class="index-row">
<div class="title">
by Apple Music
</div>
<CoverRow
:type="'playlist'"
:items="byAppleMusic"
:subText="'appleMusic'"
:imageSize="1024"
/>
</div>
<div class="index-row">
<div class="title">
{{ recommendPlaylist.name }}
<router-link to="/explore?category=推荐歌单">SEE MORE</router-link>
</div>
<CoverRow
:type="'playlist'"
:items="recommendPlaylist.items"
:subText="'copywriter'"
/>
</div>
<div class="index-row">
<div class="title">{{ recommendArtists.name }}</div>
<CoverRow type="artist" :items="recommendArtists.items" />
</div>
<div class="index-row">
<div class="title">
{{ newReleasesAlbum.name }}
<router-link to="/new-album">SEE MORE</router-link>
</div>
<CoverRow type="album" :items="newReleasesAlbum.items" subText="artist" />
</div>
<div class="index-row">
<div class="title">
{{ topList.name }}
<router-link to="/explore?category=排行榜">SEE MORE</router-link>
</div>
<CoverRow
:type="'chart'"
:items="topList.items"
:subText="'updateFrequency'"
:imageSize="1024"
/>
</div>
</div>
</template>
<script>
import { toplists, recommendPlaylist } from "@/api/playlist";
import { toplistOfArtists } from "@/api/artist";
import { byAppleMusic } from "@/utils/staticPlaylist";
import { newAlbums } from "@/api/album";
import CoverRow from "@/components/CoverRow.vue";
export default {
name: "Home",
components: { CoverRow },
data() {
return {
recommendPlaylist: { name: "推荐歌单", items: [] },
newReleasesAlbum: { name: "新专速递", items: [] },
topList: {
name: "排行榜",
items: [],
ids: [19723756, 180106, 60198, 3812895, 60131],
},
recommendArtists: {
name: "推荐歌手",
items: [],
indexs: [],
},
};
},
computed: {
byAppleMusic() {
return byAppleMusic;
},
},
methods: {
loadData() {
recommendPlaylist({
limit: 10,
}).then((data) => {
this.recommendPlaylist.items = data.result;
});
newAlbums({
area: "EA",
limit: 10,
}).then((data) => {
this.newReleasesAlbum.items = data.albums;
});
toplistOfArtists(2).then((data) => {
let indexs = [];
while (indexs.length < 5) {
let tmp = ~~(Math.random() * 100);
if (!indexs.includes(tmp)) indexs.push(tmp);
}
this.recommendArtists.indexs = indexs;
this.recommendArtists.items = data.list.artists.filter((l, index) =>
indexs.includes(index)
);
});
toplists().then((data) => {
this.topList.items = data.list.filter((l) =>
this.topList.ids.includes(l.id)
);
});
},
},
activated() {
this.loadData();
},
};
</script>
<style lang="scss" scoped>
.index-row {
margin-top: 54px;
}
.playlists {
display: flex;
flex-wrap: wrap;
margin: {
right: -12px;
left: -12px;
}
.index-playlist {
margin: 12px 12px 24px 12px;
}
}
.title {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 20px;
font-size: 28px;
font-weight: 700;
a {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.68);
}
}
.item {
margin: 12px 12px 24px 12px;
.text {
width: 208px;
margin-top: 8px;
.name {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.info {
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
line-height: 18px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
// margin-top: 4px;
}
}
}
</style>

272
src/views/library.vue Normal file
View File

@ -0,0 +1,272 @@
<template>
<div>
<h1>
<img class="head" :src="user.profile.avatarUrl | resizeImage" />{{
user.profile.nickname
}}'s Library
</h1>
<div class="section-one">
<div class="liked-songs" @click="goToLikedSongsList">
<div class="top">
<p>
<span
v-for="(line, index) in pickedLyric"
:key="`${line}${index}`"
v-show="line !== ''"
>{{ line }}<br
/></span>
</p>
</div>
<div class="bottom">
<div class="titles">
<div class="title">Liked Songs</div>
<div class="sub-title">{{ likedSongs.trackCount }} songs</div>
</div>
<button @click.stop="playLikedSongs">
<svg-icon icon-class="play" />
</button>
</div>
</div>
<div class="songs">
<TrackList
:tracks="likedSongs.tracks"
:type="'tracklist'"
:itemWidth="220"
:id="likedSongs.id"
dbclickTrackFunc="playPlaylistByID"
/>
</div>
</div>
<div class="playlists" v-if="playlists.length > 1">
<div class="title">Playlists</div>
<div>
<CoverRow
:items="playlists.slice(1)"
type="playlist"
subText="creator"
:showPlayButton="true"
/>
</div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
import { getTrackDetail, getLyric } from "@/api/track";
import { userDetail, userPlaylist } from "@/api/user";
import { mapTrackPlayableStatus, randomNum } from "@/utils/common";
import { getPlaylistDetail } from "@/api/playlist";
import { playPlaylistByID } from "@/utils/play";
import TrackList from "@/components/TrackList.vue";
import CoverRow from "@/components/CoverRow.vue";
import SvgIcon from "@/components/SvgIcon.vue";
export default {
name: "Library",
components: { SvgIcon, CoverRow, TrackList },
data() {
return {
user: {
profile: {
avatarUrl: "",
nickname: "",
},
},
playlists: [],
hasMorePlaylists: true,
likedSongs: [],
lyric: undefined,
};
},
created() {
userDetail(this.settings.user.userId).then((data) => {
this.user = data;
});
},
activated() {
this.loadData();
},
computed: {
...mapState(["settings"]),
pickedLyric() {
if (this.lyric === undefined) return "";
let lyric = this.lyric.split("\n");
lyric = lyric.filter((l) => {
if (l.includes("作词") || l.includes("作曲")) {
return false;
}
return true;
});
let lineIndex = randomNum(0, lyric.length - 1);
while (lineIndex + 4 > lyric.length) {
lineIndex = randomNum(0, lyric.length - 1);
}
return [
lyric[lineIndex].split("]")[1],
lyric[lineIndex + 1].split("]")[1],
lyric[lineIndex + 2].split("]")[1],
];
},
},
methods: {
playLikedSongs() {
playPlaylistByID(this.likedSongs.id);
},
goToLikedSongsList() {
this.$router.push({ path: "/library/liked-songs" });
},
loadData() {
if (this.hasMorePlaylists) {
userPlaylist({
uid: this.settings.user.userId,
offset: this.playlists.length,
}).then((data) => {
this.playlists.push(...data.playlist);
this.hasMorePlaylists = data.more;
});
}
this.getLikedSongs();
},
getLikedSongs() {
getPlaylistDetail(this.settings.user.likedSongPlaylistID).then((data) => {
let oldTracks = this.likedSongs.tracks;
this.likedSongs = data.playlist;
this.likedSongs.tracks = oldTracks;
this.getMoreLikedSongs();
this.getRandomLyric();
});
},
getMoreLikedSongs() {
let TrackIDs = this.likedSongs.trackIds.slice(0, 20).map((t) => t.id);
getTrackDetail(TrackIDs.join(",")).then((data) => {
this.likedSongs.tracks = data.songs;
this.likedSongs.tracks = mapTrackPlayableStatus(this.likedSongs.tracks);
});
},
getRandomLyric() {
getLyric(
this.likedSongs.trackIds[
randomNum(0, this.likedSongs.trackIds.length - 1)
].id
).then((data) => {
if (data.lrc !== undefined) this.lyric = data.lrc.lyric;
});
},
},
};
</script>
<style lang="scss" scoped>
h1 {
font-size: 42px;
.head {
height: 44px;
margin-right: 12px;
vertical-align: -7px;
border-radius: 50%;
border: rgba(0, 0, 0, 0.2);
}
}
.section-one {
display: flex;
margin-top: 24px;
.songs {
flex: 7;
margin-top: 8px;
margin-left: 36px;
height: 216px;
overflow: hidden;
}
}
.liked-songs {
flex: 3;
margin-top: 8px;
cursor: pointer;
height: 216px;
width: 300px;
border-radius: 16px;
padding: 18px 24px;
display: flex;
flex-direction: column;
transition: all 0.4s;
box-sizing: border-box;
background: #eaeffd;
// background: linear-gradient(-30deg, #60a6f7, #4364f7, #0052d4);
// color: white;
// background: linear-gradient(149.46deg, #450af5, #8e8ee5 99.16%);
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
.title {
font-size: 24px;
font-weight: 700;
color: #335eea;
}
.sub-title {
font-size: 15px;
margin-top: 2px;
color: #335eea;
}
button {
margin-bottom: 2px;
display: flex;
justify-content: center;
align-items: center;
height: 44px;
width: 44px;
// background: rgba(255, 255, 255, 1);
background: #335eea;
border-radius: 50%;
transition: 0.2s;
box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.2);
cursor: default;
.svg-icon {
// color: #3f63f5;
color: #eaeffd;
margin-left: 4px;
height: 16px;
width: 16px;
}
&:hover {
transform: scale(1.06);
box-shadow: 0 6px 12px -4px rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.94);
}
}
}
.top {
flex: 1;
display: flex;
flex-wrap: wrap;
font-size: 14px;
color: rgba(51, 94, 234, 0.88);
p {
margin-top: 2px;
}
}
}
.playlists {
margin-top: 54px;
.title {
color: rgba(0, 0, 0, 0.88);
margin-bottom: 8px;
font-size: 24px;
font-weight: 600;
}
}
</style>

235
src/views/likedSongs.vue Normal file
View File

@ -0,0 +1,235 @@
<template>
<div>
<h1>
<img class="head" :src="settings.user.avatarUrl | resizeImage" />{{
settings.user.nickname
}}'s Liked Songs
</h1>
<TrackList :tracks="tracks" :type="'playlist'" :id="playlist.id" />
</div>
</template>
<script>
import { mapMutations, mapActions, mapState } from "vuex";
import NProgress from "nprogress";
import { getPlaylistDetail } from "@/api/playlist";
import { playPlaylistByID } from "@/utils/play";
import { getTrackDetail } from "@/api/track";
import { mapTrackPlayableStatus } from "@/utils/common";
import TrackList from "@/components/TrackList.vue";
export default {
name: "Playlist",
components: {
TrackList,
},
data() {
return {
playlist: {
trackIds: [],
},
tracks: [],
loadingMore: false,
lastLoadedTrackIndex: 9,
};
},
created() {
this.id = this.settings.user.likedSongPlaylistID;
getPlaylistDetail(this.id)
.then((data) => {
this.playlist = data.playlist;
this.tracks = data.playlist.tracks;
this.tracks = mapTrackPlayableStatus(this.tracks);
NProgress.done();
if (this.playlist.trackCount > this.tracks.length) {
window.addEventListener("scroll", this.handleScroll, true);
}
return data;
})
.then(() => {
if (this.playlist.trackCount > this.tracks.length) {
this.loadingMore = true;
this.loadMore();
}
});
},
destroyed() {
window.removeEventListener("scroll", this.handleScroll, true);
},
computed: {
...mapState(["player", "settings"]),
},
methods: {
...mapMutations([
"updatePlayerList",
"appendTrackToPlayerList",
"shuffleTheList",
]),
...mapActions(["playFirstTrackOnList", "playTrackOnListByID"]),
playPlaylistByID(trackID = "first") {
playPlaylistByID(this.playlist.id, trackID);
},
shufflePlay() {
this.playPlaylistByID();
this.shuffleTheList();
},
loadMore() {
let trackIDs = this.playlist.trackIds.filter((t, index) => {
if (
index > this.lastLoadedTrackIndex &&
index <= this.lastLoadedTrackIndex + 50
)
return t;
});
trackIDs = trackIDs.map((t) => t.id);
getTrackDetail(trackIDs.join(",")).then((data) => {
this.tracks.push(...data.songs);
this.tracks = mapTrackPlayableStatus(this.tracks);
this.lastLoadedTrackIndex += trackIDs.length;
this.loadingMore = false;
});
},
handleScroll(e) {
let dom = document.querySelector("html");
let scrollHeight = Math.max(dom.scrollHeight, dom.scrollHeight);
let scrollTop = e.target.scrollingElement.scrollTop;
let clientHeight =
dom.innerHeight || Math.min(dom.clientHeight, dom.clientHeight);
if (clientHeight + scrollTop + 200 >= scrollHeight) {
if (
this.lastLoadedTrackIndex + 1 === this.playlist.trackIds.length ||
this.loadingMore
)
return;
this.loadingMore = true;
this.loadMore();
}
},
},
};
</script>
<style lang="scss" scoped>
h1 {
font-size: 42px;
.head {
height: 44px;
margin-right: 12px;
vertical-align: -7px;
border-radius: 50%;
border: rgba(0, 0, 0, 0.2);
}
}
.playlist-info {
display: flex;
width: 78vw;
margin-bottom: 72px;
.info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
margin-left: 56px;
.title {
font-size: 36px;
font-weight: 700;
}
.artist {
font-size: 18px;
color: rgba(0, 0, 0, 0.88);
margin-top: 24px;
}
.date-and-count {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 2px;
}
.description {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 24px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
cursor: pointer;
&:hover {
transition: color 0.3s;
color: rgba(0, 0, 0, 0.88);
}
}
.buttons {
margin-top: 32px;
display: flex;
button {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
background-color: rgba(51, 94, 234, 0.1);
color: #335eea;
padding: 8px 16px;
border-radius: 8px;
margin-right: 12px;
.svg-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
}
.shuffle {
padding: 8px 11px;
.svg-icon {
margin: 0;
}
}
}
}
}
.shade {
background: rgba(255, 255, 255, 0.38);
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
.description-full {
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 12px 16px -8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
padding: 32px;
border-radius: 12px;
width: 50vw;
margin: auto 0;
font-size: 14px;
z-index: 100;
display: flex;
flex-direction: column;
.close {
display: flex;
justify-content: flex-end;
font-size: 16px;
margin-top: 20px;
color: #335eea;
cursor: pointer;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

192
src/views/login.vue Normal file
View File

@ -0,0 +1,192 @@
<template>
<div class="login">
<div>
<div class="title">Login</div>
<div class="step">
<div class="search-box">
<div class="container">
<svg-icon icon-class="search" />
<div class="input">
<input
placeholder="请输入你的用户名"
v-model="keyword"
@keydown.enter="search"
/>
</div>
</div>
</div>
</div>
<div class="step">
<div class="name" v-show="activeUser.nickname === undefined">
按Enter搜索
</div>
<div class="name" v-show="activeUser.nickname !== undefined">
在列表中选中你的账号
</div>
<div class="user-list">
<div
class="user"
v-for="user in result"
:key="user.id"
:class="{ active: user.nickname === activeUser.nickname }"
@click="activeUser = user"
>
<img class="head" :src="user.avatarUrl | resizeImage" />
<div class="nickname">
{{ user.nickname }}
</div>
</div>
</div>
</div>
<ButtonTwoTone
@click.native="confirm"
v-show="activeUser.nickname !== undefined"
>确定</ButtonTwoTone
>
</div>
</div>
</template>
<script>
import { mapMutations } from "vuex";
import NProgress from "nprogress";
import { search } from "@/api/others";
import { userPlaylist } from "@/api/user";
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
export default {
name: "Login",
components: {
ButtonTwoTone,
},
data() {
return {
keyword: "",
result: [],
activeUser: {},
};
},
created() {
NProgress.done();
},
methods: {
...mapMutations(["updateUser"]),
search() {
search({ keywords: this.keyword, limit: 9, type: 1002 }).then((data) => {
this.result = data.result.userprofiles;
this.activeUser = this.result[0];
});
},
confirm() {
this.updateUser(this.activeUser);
userPlaylist({
uid: this.activeUser.userId,
limit: 1,
}).then((data) => {
this.$store.commit("updateUserInfo", {
key: "likedSongPlaylistID",
value: data.playlist[0].id,
});
this.$router.push({ path: "/library" });
});
},
},
};
</script>
<style lang="scss" scoped>
.login {
display: flex;
}
.title {
font-size: 42px;
font-weight: 700;
margin-bottom: 48px;
}
.step {
margin-top: 18px;
.name {
font-size: 14px;
font-weight: 500;
margin-bottom: 8px;
color: rgba(0, 0, 0, 0.78);
}
}
.search-box {
.container {
display: flex;
align-items: center;
height: 48px;
border-radius: 11px;
width: 326px;
background: #eaeffd;
}
.svg-icon {
height: 22px;
width: 22px;
color: #335eea;
margin: {
left: 12px;
right: 8px;
}
}
input {
flex: 1;
font-size: 22px;
border: none;
background: transparent;
width: 100%;
font-weight: 600;
margin-top: -1px;
color: #335eea;
&::placeholder {
color: #335eeac4;
}
}
}
.user-list {
display: flex;
flex-wrap: wrap;
margin-top: 24px;
margin-bottom: 24px;
}
.user {
margin-right: 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
padding: 12px 12px 12px 16px;
border-radius: 8px;
width: 256px;
transition: 0.2s;
user-select: none;
.head {
border-radius: 50%;
height: 44px;
width: 44px;
}
.nickname {
font-size: 18px;
margin-left: 12px;
}
&:hover {
background: #f5f5f7;
}
}
.user.active {
transition: 0.2s;
background: #eaeffd;
.name {
color: #335eea;
}
}
</style>

106
src/views/newAlbum.vue Normal file
View File

@ -0,0 +1,106 @@
<template>
<div class="newAlbum">
<h1>新专速递</h1>
<div class="playlist-row">
<div class="playlists">
<div class="item" v-for="album in albums" :key="album.id">
<Cover
:id="album.id"
:type="'album'"
:url="album.picUrl | resizeImage"
:hoverEffect="true"
:showBlackShadow="true"
/>
<div class="text">
<div class="name">{{ album.name }}</div>
<div class="info">{{ album.artist.name }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { newAlbums } from "@/api/album";
import NProgress from "nprogress";
import Cover from "@/components/Cover.vue";
export default {
data() {
return {
albums: [],
};
},
components: {
Cover,
},
created() {
newAlbums({
area: "EA",
limit: 100,
}).then((data) => {
this.albums = data.albums;
NProgress.done();
});
},
};
</script>
<style lang="scss" scoped>
h1 {
span {
color: rgba(0, 0, 0, 0.58);
}
}
.playlist-row {
margin-top: 36px;
&:first-child {
margin-top: 0;
}
}
.playlists {
display: flex;
flex-wrap: wrap;
margin: {
right: -12px;
left: -12px;
}
.index-playlist {
margin: 12px 12px 24px 12px;
}
}
.item {
margin: 12px 12px 24px 12px;
.text {
width: 208px;
margin-top: 8px;
.name {
font-size: 16px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.info {
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
line-height: 18px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
// margin-top: 4px;
}
}
}
</style>

218
src/views/next.vue Normal file
View File

@ -0,0 +1,218 @@
<template>
<div class="next-tracks">
<h1>Now Playing</h1>
<div class="track-list">
<div class="track playing">
<img :src="currentTrack.album.picUrl | resizeImage" />
<div class="title-and-artist">
<div class="container">
<div class="title">
{{ currentTrack.name }}
</div>
<div class="artist">
<span v-for="(ar, index) in currentTrack.artists" :key="ar.id">
<router-link :to="`/artist/${ar.id}`">{{ ar.name }}</router-link
><span v-if="index !== currentTrack.artists.length - 1"
>,
</span>
</span>
</div>
</div>
<div></div>
</div>
<div class="album">
<div class="container">
<router-link :to="`/album/${currentTrack.album.id}`">{{
currentTrack.album.name
}}</router-link>
</div>
<div></div>
</div>
<div class="time">
{{ currentTrack.time | formatTime }}
</div>
</div>
</div>
<h1>Next Up</h1>
<div class="track-list">
<div
class="track"
v-for="track in tracks"
:class="{ disable: !track.playable }"
:title="!track.playable ? track.reason : ''"
:key="`${track.id}-${track.sort}`"
@dblclick="playTrackOnListByID(track.id)"
>
<img :src="track.album.picUrl | resizeImage" />
<div class="title-and-artist">
<div class="container">
<div class="title">
{{ track.name }}
</div>
<div class="artist">
<span v-for="(ar, index) in track.artists" :key="ar.id">
<router-link :to="`/artist/${ar.id}`">{{ ar.name }}</router-link
><span v-if="index !== track.artists.length - 1">, </span>
</span>
</div>
</div>
<div></div>
</div>
<div class="album">
<div class="container">
<router-link :to="`/album/${track.album.id}`">{{
track.album.name
}}</router-link>
</div>
<div></div>
</div>
<div class="time">
{{ parseInt((track.time % (1000 * 60 * 60)) / (1000 * 60)) }}:{{
parseInt((track.time % (1000 * 60)) / 1000)
.toString()
.padStart(2, "0")
}}
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from "vuex";
export default {
name: "Next",
computed: {
...mapState(["player"]),
currentTrack() {
return this.player.currentTrack;
},
tracks() {
function compare(property) {
return function(obj1, obj2) {
var value1 = obj1[property];
var value2 = obj2[property];
return value1 - value2;
};
}
return this.player.list
.filter(
(t) => t.sort > this.player.currentTrack.sort // && t.playable === true
)
.sort(compare("sort"));
},
},
methods: {
...mapActions(["playTrackOnListByID"]),
},
};
</script>
<style lang="scss" scoped>
.next-tracks {
width: 78vw;
}
h1 {
margin-top: 36px;
margin-bottom: 18px;
cursor: default;
}
.track-list {
user-select: none;
.track {
display: flex;
align-items: center;
padding: 8px;
border-radius: 12px;
img {
border-radius: 8px;
height: 56px;
margin-right: 20px;
}
.title-and-artist {
flex: 1;
display: flex;
.container {
display: flex;
flex-direction: column;
}
.title {
font-size: 18px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
cursor: default;
}
.artist {
margin-top: 2px;
font-size: 13px;
color: rgba(0, 0, 0, 0.68);
a {
span {
margin-right: 3px;
color: rgba(0, 0, 0, 0.8);
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
}
.album {
flex: 1;
display: flex;
.container {
display: flex;
flex-direction: column;
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
font-size: 16px;
color: rgba(0, 0, 0, 0.88);
}
.time {
font-size: 16px;
width: 50px;
cursor: default;
display: flex;
justify-content: flex-end;
margin-right: 10px;
font-variant-numeric: tabular-nums;
}
&:hover {
transition: all 0.3s;
background: #f5f5f7;
}
}
.track.playing {
background: #eaeffd;
.title,
.time,
.album {
color: #335eea;
}
.artist {
color: rgba(51, 94, 234, 0.88);
}
}
.track.disable {
img {
filter: grayscale(1) opacity(0.6);
}
.title,
.artist,
.time,
.album {
color: rgba(0, 0, 0, 0.28);
}
&:hover {
background: none;
}
}
}
</style>

298
src/views/playlist.vue Normal file
View File

@ -0,0 +1,298 @@
<template>
<div v-show="show">
<div class="playlist-info">
<Cover
:url="playlist.coverImgUrl | resizeImage(1024)"
:showPlayButton="true"
:alwaysShowShadow="true"
:clickToPlay="true"
:size="288"
/>
<div class="info">
<div class="title">{{ playlist.name }}</div>
<div class="artist">
Playlist by
<span
style="font-weight:600"
v-if="
[
5277771961,
5277965913,
5277969451,
5277778542,
5278068783,
].includes(playlist.id)
"
>Apple Music</span
>
<a
v-else
:href="
`https://music.163.com/#/user/home?id=${playlist.creator.userId}`
"
target="blank"
>{{ playlist.creator.nickname }}</a
>
</div>
<div class="date-and-count">
Updated at {{ playlist.updateTime | formatDate }} ·
{{ playlist.trackCount }} Songs
</div>
<div class="description" @click="showFullDescription = true">
{{ playlist.description }}
</div>
<div class="buttons">
<ButtonTwoTone @click.native="playPlaylistByID()" :iconClass="`play`">
PLAY
</ButtonTwoTone>
<ButtonTwoTone
@click.native="shufflePlay"
:iconClass="`shuffle`"
:iconButton="true"
:horizontalPadding="11"
>
</ButtonTwoTone>
</div>
</div>
</div>
<TrackList :tracks="tracks" :type="'playlist'" :id="playlist.id" />
<transition name="fade">
<div
class="shade"
@click="showFullDescription = false"
v-show="showFullDescription"
>
<div class="description-full" @click.stop>
<span>{{ playlist.description }}</span>
<span class="close" @click="showFullDescription = false">Close</span>
</div>
</div>
</transition>
</div>
</template>
<script>
import { mapMutations, mapActions, mapState } from "vuex";
import NProgress from "nprogress";
import { getPlaylistDetail } from "@/api/playlist";
import { playPlaylistByID } from "@/utils/play";
import { getTrackDetail } from "@/api/track";
import { mapTrackPlayableStatus } from "@/utils/common";
import ButtonTwoTone from "@/components/ButtonTwoTone.vue";
import TrackList from "@/components/TrackList.vue";
import Cover from "@/components/Cover.vue";
export default {
name: "Playlist",
components: {
Cover,
ButtonTwoTone,
TrackList,
},
data() {
return {
playlist: {
coverImgUrl: "",
creator: {
userId: "",
},
trackIds: [],
},
showFullDescription: false,
tracks: [],
loadingMore: false,
lastLoadedTrackIndex: 9,
show: false,
};
},
created() {
this.id = this.$route.params.id;
getPlaylistDetail(this.id)
.then((data) => {
this.playlist = data.playlist;
this.tracks = data.playlist.tracks;
this.tracks = mapTrackPlayableStatus(this.tracks);
NProgress.done();
this.show = true;
if (this.playlist.trackCount > this.tracks.length) {
window.addEventListener("scroll", this.handleScroll, true);
}
return data;
})
.then(() => {
if (this.playlist.trackCount > this.tracks.length) {
this.loadingMore = true;
this.loadMore();
}
});
},
destroyed() {
window.removeEventListener("scroll", this.handleScroll, true);
},
computed: {
...mapState(["player"]),
},
methods: {
...mapMutations([
"updatePlayerList",
"appendTrackToPlayerList",
"shuffleTheList",
]),
...mapActions(["playFirstTrackOnList", "playTrackOnListByID"]),
playPlaylistByID(trackID = "first") {
playPlaylistByID(this.playlist.id, trackID);
},
shufflePlay() {
this.playPlaylistByID();
this.shuffleTheList();
},
loadMore() {
let trackIDs = this.playlist.trackIds.filter((t, index) => {
if (
index > this.lastLoadedTrackIndex &&
index <= this.lastLoadedTrackIndex + 50
)
return t;
});
trackIDs = trackIDs.map((t) => t.id);
getTrackDetail(trackIDs.join(",")).then((data) => {
this.tracks.push(...data.songs);
this.tracks = mapTrackPlayableStatus(this.tracks);
this.lastLoadedTrackIndex += trackIDs.length;
this.loadingMore = false;
});
},
handleScroll(e) {
let dom = document.querySelector("html");
let scrollHeight = Math.max(dom.scrollHeight, dom.scrollHeight);
let scrollTop = e.target.scrollingElement.scrollTop;
let clientHeight =
dom.innerHeight || Math.min(dom.clientHeight, dom.clientHeight);
if (clientHeight + scrollTop + 200 >= scrollHeight) {
if (
this.lastLoadedTrackIndex + 1 === this.playlist.trackIds.length ||
this.loadingMore
)
return;
this.loadingMore = true;
this.loadMore();
}
},
},
};
</script>
<style lang="scss" scoped>
.playlist-info {
display: flex;
width: 78vw;
margin-bottom: 72px;
.info {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
margin-left: 56px;
.title {
font-size: 36px;
font-weight: 700;
}
.artist {
font-size: 18px;
color: rgba(0, 0, 0, 0.88);
margin-top: 24px;
}
.date-and-count {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 2px;
}
.description {
font-size: 14px;
color: rgba(0, 0, 0, 0.68);
margin-top: 24px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
cursor: pointer;
&:hover {
transition: color 0.3s;
color: rgba(0, 0, 0, 0.88);
}
}
.buttons {
margin-top: 32px;
display: flex;
button {
display: flex;
align-items: center;
font-size: 18px;
font-weight: 600;
background-color: rgba(51, 94, 234, 0.1);
color: #335eea;
padding: 8px 16px;
border-radius: 8px;
margin-right: 12px;
.svg-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
}
.shuffle {
padding: 8px 11px;
.svg-icon {
margin: 0;
}
}
}
}
}
.shade {
background: rgba(255, 255, 255, 0.38);
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
.description-full {
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 12px 16px -8px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
padding: 32px;
border-radius: 12px;
width: 50vw;
margin: auto 0;
font-size: 14px;
z-index: 100;
display: flex;
flex-direction: column;
.close {
display: flex;
justify-content: flex-end;
font-size: 16px;
margin-top: 20px;
color: #335eea;
cursor: pointer;
}
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
</style>

259
src/views/search.vue Normal file
View File

@ -0,0 +1,259 @@
<template>
<div class="search">
<h1><span>Search for</span> "{{ keywords }}"</h1>
<div class="result" v-if="result !== undefined">
<div class="row">
<div class="artists" v-if="result.hasOwnProperty('artist')">
<div class="section-title">Artists</div>
<div class="artists-list">
<div
class="artist"
v-for="artist in result.artist.artists.slice(0, 3)"
:key="artist.id"
>
<Cover
:url="artist.img1v1Url | resizeImage"
:showBlackShadow="true"
:hoverEffect="true"
:showPlayButton="true"
:type="`artist`"
:id="artist.id"
:size="128"
:playButtonSize="36"
:shadowMargin="8"
:radius="100"
/>
<div class="name">
<router-link :to="`/artist/${artist.id}`">{{
artist.name
}}</router-link>
</div>
</div>
</div>
</div>
<div class="albums" v-if="result.hasOwnProperty('album')">
<div class="section-title">Albums</div>
<div class="albums-list">
<div
class="album"
v-for="album in result.album.albums.slice(0, 4)"
:key="album.id"
>
<div>
<Cover
:url="album.picUrl | resizeImage"
:showBlackShadow="true"
:hoverEffect="true"
:showPlayButton="true"
:type="`album`"
:id="album.id"
:size="128"
:playButtonSize="36"
:shadowMargin="8"
:radius="8"
/>
</div>
<div class="name">
<router-link :to="`/album/${album.id}`">{{
album.name
}}</router-link>
</div>
<div class="artist">
<router-link :to="`/artist/${album.artist.id}`">{{
album.artist.name
}}</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="tracks" v-if="result.hasOwnProperty('song')">
<div class="section-title">Songs</div>
<TrackList :tracks="tracks" :type="'tracklist'" />
</div>
<div class="playlists" v-if="result.hasOwnProperty('playList')">
<div class="section-title">Playlists</div>
<div class="albums-list">
<div
class="album"
v-for="playlist in result.playList.playLists.slice(0, 12)"
:key="playlist.id"
>
<div>
<Cover
:url="playlist.coverImgUrl | resizeImage"
:showBlackShadow="true"
:hoverEffect="true"
:showPlayButton="true"
:type="`playlist`"
:id="playlist.id"
:size="128"
:playButtonSize="36"
:shadowMargin="8"
:radius="8"
/>
</div>
<div class="name">
<router-link :to="`/playlist/${playlist.id}`">{{
playlist.name
}}</router-link>
</div>
</div>
</div>
</div>
</div>
<div class="no-results" v-else>
No Results
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
import NProgress from "nprogress";
import { appendTrackToPlayerList } from "@/utils/play";
import { mapTrackPlayableStatus } from "@/utils/common";
import { search } from "@/api/others";
import Cover from "@/components/Cover.vue";
import TrackList from "@/components/TrackList.vue";
export default {
name: "Search",
components: {
Cover,
TrackList,
},
data() {
return {
result: {},
type: 1,
limit: 30,
offset: 0,
};
},
computed: {
...mapState(["search"]),
keywords() {
return this.$route.query.keywords;
},
tracks() {
let tracks = mapTrackPlayableStatus(this.result.song.songs.slice(0, 12));
return tracks;
},
},
methods: {
goToAlbum(id) {
this.$router.push({ name: "album", params: { id } });
},
playTrackInSearchResult(id) {
let track = this.tracks.find((t) => t.id === id);
appendTrackToPlayerList(track, true);
},
},
created() {
search({ keywords: this.$route.query.keywords, type: 1018 }).then(
(data) => {
this.result = data.result;
NProgress.done();
}
);
},
beforeRouteUpdate(to, from, next) {
NProgress.start();
search({ keywords: to.query.keywords, type: 1018 }).then((data) => {
this.result = data.result;
next();
NProgress.done();
});
},
};
</script>
<style lang="scss" scoped>
.search {
width: 78vw;
}
h1 {
margin-top: -10px;
margin-bottom: 0;
span {
color: rgba(0, 0, 0, 0.58);
}
}
.section-title {
font-weight: 600;
font-size: 22px;
color: rgba(0, 0, 0, 0.88);
margin-bottom: 16px;
margin-top: 46px;
}
.row {
display: flex;
}
.artists,
.albums {
flex: 1;
}
.artists-list {
display: flex;
padding-right: 48px;
font-size: 16px;
font-weight: 600;
.artist {
display: flex;
align-items: center;
flex-direction: column;
border-radius: 8px;
margin: {
left: 8px;
right: 24px;
}
.name {
margin-top: 8px;
}
}
}
.albums-list {
display: flex;
.album {
img {
height: 128px;
border-radius: 8px;
}
border-radius: 8px;
margin: {
right: 14px;
left: 4px;
}
.name {
margin-top: 6px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
font-size: 14px;
width: 128px;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.artist {
font-size: 12px;
color: rgba(0, 0, 0, 0.68);
}
}
}
.no-results {
margin-top: 24px;
font-size: 24px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More