first commit

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

115
src/App.vue Normal file
View file

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

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

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

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

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

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

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

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

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

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

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

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

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

View file

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

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

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

View file

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

After

Width:  |  Height:  |  Size: 427 B

View file

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

After

Width:  |  Height:  |  Size: 430 B

View file

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

After

Width:  |  Height:  |  Size: 301 B

View file

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

After

Width:  |  Height:  |  Size: 749 B

View file

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

After

Width:  |  Height:  |  Size: 246 B

View file

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

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

View file

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

After

Width:  |  Height:  |  Size: 735 B

View file

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

After

Width:  |  Height:  |  Size: 457 B

View file

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

After

Width:  |  Height:  |  Size: 427 B

View file

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

After

Width:  |  Height:  |  Size: 444 B

View file

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

After

Width:  |  Height:  |  Size: 339 B

View file

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

After

Width:  |  Height:  |  Size: 428 B

View file

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

View file

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

View file

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

After

Width:  |  Height:  |  Size: 577 B

View file

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

After

Width:  |  Height:  |  Size: 1 KiB

View file

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

After

Width:  |  Height:  |  Size: 904 B

View file

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

After

Width:  |  Height:  |  Size: 679 B

View file

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

After

Width:  |  Height:  |  Size: 781 B

View file

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

After

Width:  |  Height:  |  Size: 944 B

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

View file

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

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

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

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

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

View file

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

View file

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

View file

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

24
src/main.js Normal file
View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4454
src/utils/staticPlaylist.js Normal file

File diff suppressed because it is too large Load diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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