first commit
25
.gitignore
vendored
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/cli-plugin-babel/preset'
|
||||
]
|
||||
}
|
BIN
images/album.png
Normal file
After Width: | Height: | Size: 804 KiB |
BIN
images/artist.png
Normal file
After Width: | Height: | Size: 730 KiB |
BIN
images/explore.png
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
images/home.png
Normal file
After Width: | Height: | Size: 1.4 MiB |
BIN
images/library.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
images/logo.png
Normal file
After Width: | Height: | Size: 175 KiB |
BIN
images/playlist.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
BIN
images/search.png
Normal file
After Width: | Height: | Size: 1.0 MiB |
57
package.json
Normal 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
After Width: | Height: | Size: 15 KiB |
BIN
public/img/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
public/img/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 126 KiB |
BIN
public/img/icons/android-chrome-maskable-192x192.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
public/img/icons/android-chrome-maskable-512x512.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
public/img/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
public/img/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 4.0 KiB |
BIN
public/img/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 4.6 KiB |
BIN
public/img/icons/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/img/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
public/img/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
public/img/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/img/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/img/icons/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
BIN
public/img/icons/msapplication-icon-144x144.png
Normal file
After Width: | Height: | Size: 1.1 KiB |
BIN
public/img/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 10 KiB |
3
public/img/icons/safari-pinned-tab.svg
Normal 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
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
115
src/App.vue
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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,
|
||||
});
|
||||
}
|
41
src/assets/css/nprogress.css
Normal 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
|
@ -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;
|
||||
}
|
1
src/assets/icons/arrow-left.svg
Normal 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 |
1
src/assets/icons/arrow-right.svg
Normal 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 |
1
src/assets/icons/circle.svg
Normal 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 |
1
src/assets/icons/expand.svg
Normal 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 |
1
src/assets/icons/explicit.svg
Normal 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 |
1
src/assets/icons/github.svg
Normal 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 |
8
src/assets/icons/index.js
Normal 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);
|
1
src/assets/icons/list.svg
Normal 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 |
1
src/assets/icons/more.svg
Normal 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 |
1
src/assets/icons/next.svg
Normal 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 |
1
src/assets/icons/pause.svg
Normal 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 |
1
src/assets/icons/play.svg
Normal 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 |
1
src/assets/icons/previous.svg
Normal 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 |
1
src/assets/icons/repeat-1.svg
Normal 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>
|
1
src/assets/icons/repeat.svg
Normal 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>
|
1
src/assets/icons/search.svg
Normal 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 |
1
src/assets/icons/settings.svg
Normal 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 |
1
src/assets/icons/shuffle.svg
Normal 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 |
1
src/assets/icons/volume-half.svg
Normal 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 |
1
src/assets/icons/volume-mute.svg
Normal 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 |
1
src/assets/icons/volume.svg
Normal 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 |
33
src/components/ArtistsInLine.vue
Normal 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>
|
345
src/components/BottomBar.vue
Normal 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>
|
44
src/components/ButtonIcon.vue
Normal 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>
|
62
src/components/ButtonTwoTone.vue
Normal 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>
|
91
src/components/ContextMenu.vue
Normal 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
|
@ -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
|
@ -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>
|
33
src/components/ExplicitSymbol.vue
Normal 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
|
@ -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
|
@ -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>
|
39
src/components/SvgIcon.vue
Normal 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>
|
91
src/components/TrackList.vue
Normal 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>
|
250
src/components/TrackListItem.vue
Normal 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
|
@ -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");
|
32
src/registerServiceWorker.js
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
263
src/views/album.vue
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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>
|