├── static
├── playlist.json
├── .DS_Store
├── favicon.ico
├── fonts
│ ├── .DS_Store
│ ├── icomoon.eot
│ ├── icomoon.ttf
│ ├── icomoon.woff
│ └── icomoon.svg
├── images
│ ├── cover.png
│ ├── cover2.png
│ └── cover3.png
├── lyrics
│ └── 我多想说再见啊.txt
├── js
│ ├── index.js
│ ├── DomVisual.js
│ └── AudioVisual.js
└── css
│ ├── setting.css
│ ├── index.css
│ ├── reset.css
│ └── widget.css
├── .gitignore
├── README.md
├── LICENSE
├── main.py
└── templates
└── index.html
/static/playlist.json:
--------------------------------------------------------------------------------
1 | [
2 | {}
3 | ]
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | static/wavs/*
2 | .gitignore
3 | **/.DS_Store
4 |
--------------------------------------------------------------------------------
/static/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/.DS_Store
--------------------------------------------------------------------------------
/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/favicon.ico
--------------------------------------------------------------------------------
/static/fonts/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/fonts/.DS_Store
--------------------------------------------------------------------------------
/static/images/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/images/cover.png
--------------------------------------------------------------------------------
/static/fonts/icomoon.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/fonts/icomoon.eot
--------------------------------------------------------------------------------
/static/fonts/icomoon.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/fonts/icomoon.ttf
--------------------------------------------------------------------------------
/static/fonts/icomoon.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/fonts/icomoon.woff
--------------------------------------------------------------------------------
/static/images/cover2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/images/cover2.png
--------------------------------------------------------------------------------
/static/images/cover3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/swagger-coder/visinger_lab/HEAD/static/images/cover3.png
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # VISinger Lab
2 | 为了展示VISinger能力设计的在线音乐播放器
3 |
4 | ## 主要技术点
5 | 前端
6 | - [Web Audio API](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext)
7 | - eventBus
8 |
9 | 前端 [music-radio](https://github.com/miiiku/music-radio) 轮子造的不错,本身已有音频播放、可视化功能,我主要在此基础上实现了进度条控制(音频、歌词随进度条点击事件改变等)、音频下载功能。
10 |
11 | 后端
12 | - Flask
13 |
14 | 拿flask写了个超级简易的后端,功能仅有传音频和歌词(一开始想实现实时生成的功能,但想了想只是演示没必要,于是偷懒了:)
15 | ## 运行
16 |
17 | - `pip install flask`
18 | - `python main.py`
19 | - 加载自己的音频和歌词需要修改1. `main.py`中`get_wavs()`和`get_lyric()`方法;2. `static/js/AudioVisual.js`中`af.fetch`相关部分
20 |
21 | ## 预览
22 | 
23 | 
24 | 
25 | ## 参考
26 | https://github.com/miiiku/music-radio
27 |
--------------------------------------------------------------------------------
/static/lyrics/我多想说再见啊.txt:
--------------------------------------------------------------------------------
1 | [00:00.00] 词曲:柯立可
2 | [00:10.00] 原唱:星尘Infinity
3 | [00:20.00] 翻唱:Mobvoi 虚拟歌姬
4 | [00:26.00]试着掬一把星辰在手心
5 | [00:32.40]却遮住迷恋遥远的眼睛
6 | [00:38.80]窗外传来记忆的声音
7 | [00:45.20]在半夜迷失在房间消失去幻想着夜晚之前的
8 | [00:53.56]一种逃离
9 | [00:55.00]印象中少年的身影
10 | [00:57.56]有着清澈的眼睛嘴里还说着
11 | [01:01]因为我们还年轻所以总有再一次的权力
12 | [01:7.48]我也想说再见啊风月梦话把想与念留下
13 | [01:13.88]可看到窗台微微摇曳的花却难以自拔
14 | [01:20.28]曾经路上的风吹雨打有一个灯塔我就不必害怕
15 | [01:26.28]小的温暖也能被放大
16 | [01:46.00]空气中的广播声在回荡着
17 | [01:52.40]黄昏的站台已被阳光淹没
18 | [01:58.80]我拖着过往在人群中穿梭
19 | [02:5]找一个位置等一段未知
20 | [02:9]去期待着固执如我的那些声音
21 | [02:15]思绪随一阵风迷离
22 | [02:17]在春天的原野里在指间缝隙
23 | [02:21]看见惊世的美丽在晨曦里笑靥如花的你
24 | [02:27.48]我多想说再见啊捧起雪花把爱与恨留下
25 | [02:33.88]只看着眼下匆匆一簇繁华在手中融化
26 | [02:40.28]兵荒马乱的青春年华扬起的风沙倒让人放不下
27 | [02:46.28]心的呼喊你听见了吗
28 | [02:54.28]当命中的雨滴在手中蒸发干净
29 | [03:0.68]我总在思考存在和消失的意义
30 | [03:7.08]错愕于一个习惯本不属于自己
31 | [03:13.48]才发现有时候我其实
32 | [03:18.68]是你
33 | [03:24.80]想说再见啊是再见啊把你和我留下
34 | [03:31.52]与自己重叠定格在一刹那短暂交叉
35 | [03:37.88]在另外一个春秋冬夏继续这喧哗而我重新出发
36 | [03:43.88]灰烬里重新生根发芽
37 | [03:50.52]我曾做过的梦啊光和蝉鸣装满整个盛夏
38 | [03:57.16]你望着晚霞轻声和我说话听我的回答
39 | [04:3.48]谁都想一生浪漫无暇雪月和风花去思念一个他
40 | [04:9.48]却再也无法完全停下
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Sola
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | import os
2 | from flask import Flask, Response, abort, render_template, redirect, jsonify, request, send_file
3 |
4 | # 创建flask实例 注意为两个英文下划线 左右都是
5 | app = Flask(__name__, template_folder = '.',static_folder='static',static_url_path='')
6 | # template_folder = '.'表示在同一级目录中搜索渲染模板文件中寻找模板文件
7 | # static 必须指定,否则会无法加载css js等静态文件
8 | # static_folder='', # 空 表示为当前目录 (myproject/A/) 开通虚拟资源入口
9 | # static_url_path='',
10 |
11 | # 自定义setting.py中的BasedConfig类,用于传入项目开发阶段的路径、状态等,最重要的:DEBUG = True / False 。
12 | # app.config.from_object('setting.DevelopmentConfig')
13 |
14 | @app.route('/')
15 | def index():
16 | return render_template("templates/index.html")
17 |
18 | @app.route("/playlist")
19 | def get_playlist():
20 | data = [{}]
21 | return jsonify(data)
22 |
23 | @app.route("/wavs")
24 | def get_wav():
25 | singer = request.args.get("singer")
26 | song = request.args.get("song")
27 | mode = request.args.get("mode")
28 | wavs = "static/wavs/我多想说再见啊.wav"
29 | return send_file(wavs)
30 |
31 | @app.route("/lyrics")
32 | def get_lyric():
33 | singer = request.args.get("singer")
34 | song = request.args.get("song")
35 | mode = request.args.get("mode")
36 | lyric_dir = "static/lyrics/我多想说再见啊.txt"
37 | with open(lyric_dir, 'r', encoding='utf-8') as f:
38 | lyric = f.read()
39 | print(lyric)
40 | return Response(lyric, mimetype="text/plain")
41 |
42 | if __name__ == '__main__':
43 | app.run(host="0.0.0.0", port=8072, debug=True)
44 |
--------------------------------------------------------------------------------
/static/js/index.js:
--------------------------------------------------------------------------------
1 | window.onload = async function () {
2 |
3 | dv = new DomVisual([
4 | 'https://qiniu.sukoshi.xyz/src/images/68135789_p0.jpg',
5 | 'https://qiniu.sukoshi.xyz/src/images/68686407_p0.jpg',
6 | 'https://qiniu.sukoshi.xyz/src/images/banner-1.jpg',
7 | ])
8 | av = new AudioVisual()
9 |
10 |
11 | eventBus.on('play', () => {
12 | av.source ? av.togglePlay() : dv.alert()
13 | })
14 |
15 | eventBus.on('submit', () => av.play(false))
16 |
17 | eventBus.on('download', () => {
18 | av.source ? av.download() : dv.alert()
19 | })
20 | eventBus.on('progress', () => {})
21 |
22 | }
23 |
24 | const eventBus = {
25 | events: {},
26 | on (event, fn) {
27 | if (!this.events[event]) {
28 | this.events[event] = []
29 | }
30 | this.events[event].push(fn)
31 | },
32 | emit() {
33 | let e = this.events[[].shift.call(arguments)]
34 | if (!e || e.length < 1) return
35 | e.forEach(fn => {
36 | fn.apply(this, arguments)
37 | })
38 | }
39 | }
40 |
41 |
42 | function AbortFetch() {
43 | const controller = new AbortController()
44 |
45 | return {
46 | abort: controller.abort.bind(controller),
47 | fetch: function (url, params = {}) {
48 | return new Promise((reslove, reject) => {
49 | fetch(url, { signal: controller.signal, ...params }).then(result => {
50 | if (result.ok) return reslove(result)
51 | throw new Error('Network response was not ok.')
52 | }).catch(error => {
53 | reject(error)
54 | })
55 | })
56 | }
57 | }
58 | }
59 |
60 |
--------------------------------------------------------------------------------
/static/css/setting.css:
--------------------------------------------------------------------------------
1 | #setting-wrap {
2 | position: absolute;
3 | top: 0;
4 | left: 0;
5 | right: 0;
6 | z-index: 9;
7 | background-color: #ffffff;
8 | padding: 40px 20px 20px;
9 | display: flex;
10 | transition: all 0.3s linear;
11 | opacity: 0;
12 | transform: translateY(-100%) translateZ(0);
13 | }
14 |
15 | #setting-wrap.hide {
16 | animation: hide 0.4s ease-out forwards;
17 | }
18 |
19 |
20 | #setting-wrap.show {
21 | animation: show 0.4s ease-out forwards;
22 | }
23 |
24 | #setting-wrap .iconfont.icon-close1 {
25 | position: absolute;
26 | right: 20px;
27 | top: 40px;
28 | font-size: 36px;
29 | font-weight: bolder;
30 | cursor: pointer;
31 | transition: all 0.3s linear;
32 | }
33 |
34 | #setting-wrap .iconfont.icon-close1:hover {
35 | transform: rotate(180deg);
36 | }
37 |
38 | #setting-wrap .input-row {
39 | display: flex;
40 | align-items: center;
41 | height: 30px;
42 | line-height: 30px;
43 | font-family: 'Rajdhani', sans-serif;
44 | }
45 |
46 | #setting-wrap .input-row label {
47 | width: 120px;
48 | text-align: right;
49 | padding-right: 20px;
50 | font-family: 'Rajdhani', sans-serif;
51 | }
52 |
53 | #setting-wrap .input-row input[type="radio"] {
54 | margin: 0;
55 | }
56 |
57 | @keyframes show {
58 | 0% {
59 | opacity: 0;
60 | transform: translateY(-100%) translateZ(0);
61 | }
62 |
63 | 80% {
64 | opacity: 1;
65 | transform: translateY(0) translateZ(0);
66 | }
67 |
68 | 100% {
69 | opacity: 1;
70 | transform: translateY(-20px) translateZ(0);
71 | }
72 | }
73 |
74 | @keyframes hide {
75 | from {
76 | opacity: 1;
77 | transform: translateY(-20px) translateZ(0);
78 | }
79 |
80 | to {
81 | opacity: 0;
82 | transform: translateY(-100%) translateZ(0);
83 | }
84 | }
--------------------------------------------------------------------------------
/static/fonts/icomoon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/static/css/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --theme-color: #ffffff;
3 | }
4 |
5 | .container-wrap {
6 | position: relative;
7 | width: 100vw;
8 | height: 100vh;
9 | min-width: calc(1093px);
10 | min-height: calc(615px);
11 | max-width: calc(2048px);
12 | max-height: calc(1536px);
13 | background-color: #2e323f;
14 | overflow: hidden;
15 | }
16 |
17 | .container-wrap .bg {
18 | width: 100%;
19 | height: 100%;
20 | transform: scale(1.19);
21 | background-image: url(https://qiniu.sukoshi.xyz/src/images/68135789_p0.jpg);
22 | background-repeat: no-repeat;
23 | background-size: cover;
24 | background-position: center;
25 | filter: blur(10px);
26 | }
27 |
28 | .container-wrap .card-wrap {
29 | position: absolute;
30 | top: 0;
31 | right: 0;
32 | bottom: 0;
33 | left: 0;
34 | margin: auto;
35 | width: 80%;
36 | height: 80%;
37 | color: var(--theme-color);
38 | border-radius: 46px;
39 | box-shadow: 20px 20px 60px #2c2e3b, -20px -20px 60px #3c3e4f;
40 | }
41 |
42 | .card-header {
43 | display: flex;
44 | align-items: center;
45 | height: 120px;
46 | padding: 40px 23px;
47 | }
48 |
49 | .card-header--title {
50 | flex: 1;
51 | display: flex;
52 | align-items: center;
53 | line-height: 32px;
54 | font-size: 32px;
55 | font-weight: bolder;
56 | font-family: monospace;
57 | overflow: hidden;
58 | padding-right: 20px;
59 | }
60 |
61 | .card-header--title .iconfont.icon-music {
62 | margin-right: 20px;
63 | font-size: 28px;
64 | }
65 |
66 | .card-header--title #song-title {
67 | overflow: hidden;
68 | text-overflow: ellipsis;
69 | white-space: nowrap;
70 | display: block;
71 | user-select: none;
72 | }
73 |
74 | .card-header--options {
75 | width: 90px;
76 | display: flex;
77 | align-items: center;
78 | justify-content: flex-end;
79 | }
80 |
81 | .card-header--options #setting-menu {
82 | transition: all 0.3s linear;
83 | cursor: pointer;
84 | font-size: 20px;
85 | font-weight: bolder;
86 | }
87 |
88 | .card-header--options #setting-menu:hover {
89 | opacity: 0.7;
90 | }
91 |
92 | .card-body {
93 | width: 100%;
94 | height: calc(100% - 120px);
95 | border-radius: 0 0 46px 46px;
96 | overflow: hidden;
97 | }
98 |
99 | .card-body canvas#music-canvas {
100 | display: block;
101 | width: 100%;
102 | height: 100%;
103 | }
104 |
105 | .card-body .lrc-box {
106 | position: absolute;
107 | top: 30%;
108 | left: 0;
109 | right: 0;
110 | height: 90px;
111 | overflow: hidden;
112 | }
113 |
114 | .card-body #music-lrc {
115 | text-align: center;
116 | transition: transform 0.3s ease-out;
117 | }
118 |
119 | .card-body #music-lrc p {
120 | line-height: 30px;
121 | height: 30px;
122 | opacity: 0.6;
123 | transition: all 0.3s ease-out;
124 | overflow: hidden;
125 | text-overflow: ellipsis;
126 | white-space: nowrap;
127 | user-select: none;
128 | }
129 |
130 | .card-body #music-lrc p.current {
131 | opacity: 1;
132 | font-size: 18px;
133 | font-weight: bolder;
134 | }
135 |
136 | /* Custom styles for the select element */
137 | .card-selector {
138 | display: inline-block;
139 | padding: 2px;
140 | background-color: #fff;
141 | border: none;
142 | border-radius: 5px;
143 | box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
144 | font-size: 16px;
145 | color: #333;
146 | cursor: pointer;
147 | transition: background-color 0.3s ease;
148 | width: 150px; /* 设置宽度,可以根据需要调整 */
149 | white-space: nowrap; /* 防止文本换行 */
150 | overflow: hidden; /* 隐藏溢出的文本 */
151 | text-overflow: ellipsis; /* 文本溢出时省略显示为省略号 */
152 | }
153 |
154 | /* Custom styles for the select dropdown options */
155 | .card-selector option {
156 | padding: 10px;
157 | background-color: #fff;
158 | color: #333;
159 | cursor: pointer;
160 | transition: background-color 0.3s ease;
161 | }
162 |
163 | /* Custom styles for the select dropdown options on hover */
164 | .card-selector option:hover {
165 | background-color: #f0f0f0;
166 | }
167 |
168 | /* Custom styles for the select dropdown options on selection */
169 | .card-selector option:checked {
170 | background-color: #007bff;
171 | color: #fff;
172 | }
173 |
174 | /* Custom styles for the select dropdown arrow */
175 | .card-selector::after {
176 | content: "\25BC";
177 | position: absolute;
178 | top: 50%;
179 | right: 10px;
180 | transform: translateY(-50%);
181 | font-size: 18px;
182 | color: #333;
183 | pointer-events: none;
184 | }
185 |
186 | /* Custom styles for the select dropdown arrow on hover */
187 | .card-selector:hover::after {
188 | color: #007bff;
189 | }
190 |
191 | #alert {
192 | position: fixed;
193 | top: 20px;
194 | left: 50%;
195 | transform: translateX(-50%);
196 | background-color: #fff;
197 | border: 1px solid #ddd;
198 | border-radius: 5px;
199 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
200 | opacity: 0;
201 | visibility: hidden;
202 | transition: opacity 0.3s ease, visibility 0.3s ease;
203 | }
204 |
205 | #alert.show {
206 | opacity: 1;
207 | visibility: visible;
208 | }
209 |
210 | .alert-content {
211 | padding: 10px 20px;
212 | color: #d89595;
213 | }
--------------------------------------------------------------------------------
/static/css/reset.css:
--------------------------------------------------------------------------------
1 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a,
2 | abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q,
3 | s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul,
4 | li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td,
5 | article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
6 | menu, nav, output, ruby, section, summary, time, mark, audio, video {
7 | margin: 0;
8 | padding: 0;
9 | border: 0;
10 | vertical-align: baseline;
11 | font-family: cursive, sans-serif;
12 | font-size: 100%;
13 | }
14 |
15 | html {
16 | box-sizing: border-box;
17 | font-family: cursive, sans-serif;
18 | -ms-text-size-adjust: 100%;
19 | -webkit-text-size-adjust: 100%;
20 | }
21 |
22 | body {
23 | font-size: 14px;
24 | line-height: 1;
25 | }
26 |
27 | *, *:before, *:after {
28 | box-sizing: inherit;
29 | }
30 |
31 | ol, ul {
32 | list-style: none;
33 | }
34 |
35 | blockquote, q {
36 | quotes: none;
37 | }
38 |
39 | blockquote:before, blockquote:after, q:before, q:after {
40 | content: "";
41 | content: none;
42 | }
43 |
44 | table {
45 | border-spacing: 0;
46 | border-collapse: collapse;
47 | }
48 |
49 | img {
50 | max-width: 100%;
51 | }
52 |
53 | a {
54 | background-color: transparent;
55 | text-decoration: none;
56 | color: inherit;
57 | }
58 |
59 | a:active, a:hover {
60 | outline: 0;
61 | }
62 |
63 | b, strong {
64 | font-weight: bold;
65 | }
66 |
67 | i, em, dfn {
68 | font-style: italic;
69 | }
70 |
71 | @font-face {
72 | font-family: 'iconfont';
73 | src: url('../fonts/icomoon.eot?bpwfg8');
74 | src: url('../fonts/icomoon.eot?bpwfg8#iefix') format('embedded-opentype'),
75 | url('../fonts/icomoon.ttf?bpwfg8') format('truetype'),
76 | url('../fonts/icomoon.woff?bpwfg8') format('woff'),
77 | url('../fonts/icomoon.svg?bpwfg8#icomoon') format('svg');
78 | font-weight: normal;
79 | font-style: normal;
80 | font-display: block;
81 | }
82 |
83 | .iconfont {
84 | /* use !important to prevent issues with browser extensions that change fonts */
85 | font-family: 'iconfont' !important;
86 | font-style: normal;
87 | /* Better Font Rendering =========== */
88 | -webkit-font-smoothing: antialiased;
89 | -moz-osx-font-smoothing: grayscale;
90 | }
91 |
92 | /* @font-face {
93 | font-family: "iconfont";
94 | src: url('//at.alicdn.com/t/font_690510_jq4ykoccir.woff2?t=1621500300300') format('woff2'),
95 | url('//at.alicdn.com/t/font_690510_jq4ykoccir.woff?t=1621500300300') format('woff'),
96 | url('//at.alicdn.com/t/font_690510_jq4ykoccir.ttf?t=1621500300300') format('truetype');
97 | }
98 |
99 | .iconfont {
100 | font-family: "iconfont" !important;
101 | font-size: 16px;
102 | font-style: normal;
103 | -webkit-font-smoothing: antialiased;
104 | -moz-osx-font-smoothing: grayscale;
105 | }
106 | */
107 |
108 |
109 |
110 |
111 | .icon-play-next:before {
112 | content: "\ea19";
113 | }
114 |
115 | .icon-play1:before {
116 | content: "\ea15";
117 | }
118 |
119 | .icon-download:before {
120 | content: "\e960";
121 | }
122 |
123 | .icon-play1.click:before {
124 | content: "\ea16";
125 | }
126 |
127 | .icon-play-previous:before {
128 | content: "\ea18";
129 | }
130 |
131 | .icon-1_music80:before {
132 | content: "\e6ab";
133 | }
134 |
135 | .icon-music:before {
136 | content: "\e9ef";
137 | }
138 |
139 | .icon-MusicEntertainment:before {
140 | content: "\e68f";
141 | }
142 |
143 | .icon-music-albums:before {
144 | content: "\e652";
145 | }
146 |
147 | .icon-search:before {
148 | content: "\e614";
149 | }
150 |
151 | .icon-menu--fill:before {
152 | content: "\e9c0";
153 | }
154 |
155 | .icon-jingtou1:before {
156 | content: "\e6a5";
157 | }
158 |
159 | .icon-jiantou3:before {
160 | content: "\e624";
161 | }
162 |
163 | .icon-jiantou:before {
164 | content: "\e622";
165 | }
166 |
167 | .icon-map:before {
168 | content: "\e606";
169 | }
170 |
171 | .icon-close1:before {
172 | content: "\ea0f";
173 | }
174 |
175 | .icon-camera1:before {
176 | content: "\e61a";
177 | }
178 |
179 | .icon-jingtou:before {
180 | content: "\e604";
181 | }
182 |
183 | .icon-iso:before {
184 | content: "\e66e";
185 | }
186 |
187 | .icon-guangquan:before {
188 | content: "\e603";
189 | }
190 |
191 | .icon-B:before {
192 | content: "\e705";
193 | }
194 |
195 | .icon-top1:before {
196 | content: "\e625";
197 | }
198 |
199 | .icon-top2:before {
200 | content: "\e62c";
201 | }
202 |
203 | .icon-twitter:before {
204 | content: "\e8a8";
205 | }
206 |
207 | .icon-email:before {
208 | content: "\e648";
209 | }
210 |
211 | .icon-qq:before {
212 | content: "\e676";
213 | }
214 |
215 | .icon-github:before {
216 | content: "\e602";
217 | }
218 |
219 | .icon-weibo:before {
220 | content: "\e882";
221 | }
222 |
223 | .icon-link:before {
224 | content: "\e601";
225 | }
226 |
227 | .icon-close:before {
228 | content: "\e6df";
229 | }
230 |
231 | .icon-crude:before {
232 | content: "\e65a";
233 | }
234 |
235 | .icon-image:before {
236 | content: "\e6b4";
237 | }
238 |
239 | .icon-code:before {
240 | content: "\e626";
241 | }
242 |
243 | .icon-reply:before {
244 | content: "\e717";
245 | }
246 |
247 | .icon-type:before {
248 | content: "\e617";
249 | }
250 |
251 | .icon-video:before {
252 | content: "\e630";
253 | }
254 |
255 | .icon-photo:before {
256 | content: "\e70d";
257 | }
258 |
259 | .icon-see:before {
260 | content: "\e637";
261 | }
262 |
263 | .icon-top:before {
264 | content: "\e605";
265 | }
266 |
267 | .icon-time:before {
268 | content: "\e73a";
269 | }
270 |
271 | .icon-tag:before {
272 | content: "\e629";
273 | }
274 |
275 | .icon-play:before {
276 | content: "\e600";
277 | }
278 |
279 |
--------------------------------------------------------------------------------
/templates/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | Cloud Music
16 |
17 |
18 |
71 |
72 |
73 |
74 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
129 |
167 |
168 |
169 | 请点击 submit 选择你想听的歌曲(◔‸◔)
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
--------------------------------------------------------------------------------
/static/css/widget.css:
--------------------------------------------------------------------------------
1 | .info-widget {
2 | display: flex;
3 | align-items: center;
4 | position: absolute;
5 | width: 320px;
6 | height: 120px;
7 | user-select: none;
8 | top: 20%;
9 | left: -60px;
10 | border-radius: 18px;
11 | background-color: rgba(255, 255, 255, 0.2);
12 | box-shadow: 20px 20px 60px #2c2e3b, -20px -20px 60px #3c3e4f;
13 | }
14 |
15 | .info-widget .info-list {
16 | flex: 1;
17 | display: flex;
18 | flex-direction: column;
19 | justify-content: space-evenly;
20 | height: 100%;
21 | }
22 |
23 | .info-widget .info-list dl {
24 | display: flex;
25 | align-items: center;
26 | font-size: 18px;
27 | }
28 |
29 | .info-widget .info-list dt {
30 | width: 110px;
31 | text-align: right;
32 | font-family: 'Rajdhani', sans-serif;
33 | }
34 |
35 | .info-widget .info-list dd {
36 | padding-left: 20px;
37 | flex: 1;
38 | overflow: hidden;
39 | text-overflow: ellipsis;
40 | white-space: nowrap;
41 | font-family: 'Rajdhani', sans-serif;
42 | }
43 |
44 | .info-widget #info-cover {
45 | width: 80px;
46 | height: 80px;
47 | border-radius: 14px;
48 | margin-right: 20px;
49 | overflow: hidden;
50 | background-repeat: no-repeat;
51 | background-position: center;
52 | background-size: cover;
53 | }
54 |
55 | .time-widget {
56 | display: flex;
57 | align-items: center;
58 | justify-content: center;
59 | position: absolute;
60 | width: 300px;
61 | height: 60px;
62 | top: calc(20% + 180px);
63 | left: -50px;
64 | border-radius: 9px;
65 | background-color: rgba(255, 255, 255, 0.2);
66 | box-shadow: 20px 20px 60px #2c2e3b, -20px -20px 60px #3c3e4f;
67 | }
68 |
69 | .time-widget span {
70 | font-family: 'Rajdhani', sans-serif;
71 | font-size: 32px;
72 | text-align: center;
73 | line-height: 60px;
74 | font-weight: bolder;
75 | user-select: none;
76 | }
77 |
78 | .time-widget span:nth-child(odd) {
79 | flex: 1;
80 | }
81 |
82 |
83 |
84 | .file-widget {
85 | position: absolute;
86 | /* right: -30px; */
87 | /* top: 53%; */
88 | left: 50%;
89 | transform: translateX(-50%);
90 | bottom: -35px;
91 | display: flex;
92 | align-items: center;
93 | justify-content: space-evenly;
94 | }
95 |
96 | .file-widget .file-btn {
97 | height: 30px;
98 | width: 80px;
99 | line-height: 30px;
100 | text-align: center;
101 | cursor: pointer;
102 | margin: 0 15px;
103 | border-radius: 4px;
104 | background-color: rgba(255, 255, 255, 0.2);
105 | box-shadow: 5px 5px 15px #2c2e3b, -5px -5px 15px #3c3e4f;
106 | transition: all 0.3s ease-out;
107 | }
108 |
109 | .file-widget .file-btn:hover {
110 | transform: scale(1.1);
111 | }
112 |
113 | .file-widget .file-btn i {
114 | font-size: 40px;
115 | font-weight: bolder;
116 | color: var(--theme-color);
117 | }
118 |
119 | .file-widget .file-btn.animation {
120 | animation: zoom 3.5s linear forwards infinite;
121 | }
122 |
123 | @keyframes zoom {
124 | 0% {
125 | transform: scale(1.0) translateZ(0);
126 | }
127 |
128 | 60% {
129 | transform: scale(1.2) translateZ(0);
130 | }
131 |
132 | 100% {
133 | transform: scale(1.0) translateZ(0);
134 | }
135 | }
136 |
137 | .control-widget {
138 | /* position: absolute; */
139 | /* right: -30px;
140 | top: 48%; */
141 | /* width: 100%;
142 | height: 30px; */
143 | /* border-radius: 0 0 46px 46px; */
144 | /* height: 100vh; */
145 | /* bottom: -45px; */
146 | display: flex;
147 | /* align-items: center; */
148 | /* justify-content: space-evenly; */
149 | position: absolute;
150 | width: 94%;
151 | height: 50px;
152 | bottom: 0px;
153 | left: 50%;
154 | transform: translateX(-50%);
155 | margin: auto;
156 | color: var(--theme-color);
157 | border-radius: 10px;
158 | /* box-shadow: 20px 20px 60px #3b2c2e, -20px -20px 60px #3c3e4f; */
159 | background-color: rgba(69, 67, 67, 0.2);
160 | box-shadow: 20px 20px 80px rgba(59, 44, 46, 0.8), -20px -20px 80px rgba(60, 62, 79, 0.8);
161 |
162 | }
163 |
164 | .control-widget .control-btn {
165 | height: 50px;
166 | width: 50px;
167 | line-height: 50px;
168 | left: 5px;
169 | text-align: center;
170 | cursor: pointer;
171 | margin: 0 5px;
172 | border-radius: 4px;
173 | /* background-color: rgba(255, 255, 255, 0.2); */
174 | /* box-shadow: 5px 5px 15px #2c2e3b, -5px -5px 15px #3c3e4f; */
175 | transition: all 0.3s ease-out;
176 | }
177 |
178 | /* .control-widget .control-btn:hover {
179 | transform: scale(1.1);
180 | } */
181 |
182 | .control-widget .control-btn i {
183 | font-size: 32px;
184 | font-weight: bolder;
185 | color: var(--theme-color);
186 | }
187 |
188 | .control-widget .control-btn.animation {
189 | animation: zoom 3.5s linear forwards infinite;
190 | }
191 |
192 | .control-box {
193 | position: absolute;
194 | left: 60px;
195 | height: 100%;
196 | /* width: calc(); */
197 | width: 91%;
198 | }
199 | .control-box1 {
200 | position: relative;
201 | display: flex;
202 | height: 25px;
203 | width: 100%;
204 | line-height: 25px;
205 | text-align: left;
206 | cursor: pointer;
207 | margin: 0 5px;
208 | font-size: 18px;
209 | /* background: #fff; */
210 | /* border-radius: 20px; */
211 | /* box-shadow: 0 30px 80px #656565; */
212 | }
213 |
214 | /* 时间 */
215 | .time{
216 | /* font-family: 'Rajdhani', sans-serif;
217 | font-size:12px;
218 | line-height: 25px;
219 | width: 60px;
220 | height: 25px;
221 | position: absolute;
222 | right: 40px;
223 | text-align: center;
224 | color: #e8f5ff; */
225 | display: flex;
226 | align-items: center;
227 | justify-content: center;
228 | position: absolute;
229 | width: 96px;
230 | height: 25px;
231 | right: 32px;
232 | font-size:16px;
233 | border-radius: 9px;
234 | /* background-color: rgba(255, 255, 255, 0.2);
235 | box-shadow: 20px 20px 60px #2c2e3b, -20px -20px 60px #3c3e4f; */
236 | }
237 |
238 | .time div {
239 | font-family: 'Rajdhani', sans-serif;
240 | /* width: 34px; */
241 | padding: 0 2px;
242 | font-size: 14px;
243 | text-align: center;
244 | line-height: 25px;
245 | font-weight: bolder;
246 | user-select: none;
247 | }
248 | .time span {
249 | font-family: 'Rajdhani', sans-serif;
250 | font-size: 14px;
251 | text-align: center;
252 | line-height: 25px;
253 | font-weight: bolder;
254 | user-select: none;
255 | }
256 |
257 | .time.active .current-time, .time.active .total-time{
258 | color: rgb(18, 2, 4);
259 | background-color: transparent;
260 | }
261 |
262 | #s-area, #seek-bar{
263 | position: relative;
264 | height: 4px;
265 | border-radius: 4px;
266 | }
267 |
268 | #s-area{
269 | display: flex;
270 | background-color:#ffffff;
271 | cursor: pointer;
272 | width: calc(100% - 140px);
273 | position: absolute;
274 | top: 50%;
275 | transform: translateY(-50%);
276 | }
277 |
278 | #ins-time{
279 | display: flex;
280 | align-items: center;
281 | justify-content: center;
282 | position: absolute;
283 | top: -20px;
284 | color: #ffffff;
285 | font-size: 12px;
286 | width: 40px;
287 | height: 20px;
288 | text-align: center;
289 | align-items: center;
290 | white-space: pre;
291 | padding: 0px 6px;
292 | border-radius: 4px;
293 | visibility: hidden;
294 | /* display:none; */
295 | }
296 |
297 | #s-hover{
298 | position: absolute;
299 | top: 0;
300 | bottom: 0;
301 | left: 0;
302 | width: 0;
303 | opacity: 0.2;
304 | z-index: 2;
305 | }
306 |
307 | #ins-time, #s-hover{
308 | background-color: #4b4d5c;
309 | /* Set opacity to 50% */
310 | opacity: 0.7;
311 | }
312 |
313 | #seek-bar{
314 | content: '';
315 | position: absolute;
316 | top: 0;
317 | left: 0;
318 | width: 0;
319 | background-color: rgb(226, 113, 158);
320 | border-radius: 4px;
321 | transition: 0.2s ease width;
322 | }
323 |
324 | .control-download {
325 | display: flex;
326 | position: absolute;
327 | height: 20px;
328 | width: 20px;
329 | line-height: 12px;
330 | right: 3px;
331 | top: 5px;
332 | font-size: 12px;
333 | text-align: center;
334 | cursor: pointer;
335 | margin: 0 3px;
336 | border-radius: 4px;
337 | }
338 |
339 | .fade-in {
340 | /* opacity: 0; */
341 | transition: opacity 0.5s ease-in-out;
342 | }
343 |
344 | .fade-in.show {
345 | opacity: 1;
346 | }
--------------------------------------------------------------------------------
/static/js/DomVisual.js:
--------------------------------------------------------------------------------
1 |
2 | class DomVisual {
3 | constructor (bgs) {
4 | this.domTextMap = new Map()
5 | this.domInputMap = new Map()
6 | this.domControlMap = new Map()
7 | this.domContainerMap = new Map()
8 |
9 | this.domTextSelector = [
10 | '#song-title',
11 | '#info-state', '#info-duration', '#info-current-time',
12 | '#total-time-minute', '#total-time-second',
13 | '#current-time-minute', '#current-time-second',
14 | '#seek-bar','#s-area', '#s-hover', '#ins-time'
15 | ]
16 | this.domInputSelector = [
17 | '#centerX', '#centerY', '#lineWidth', '#lineSpacing', '#lineColor',
18 | '#shadowColor', '#lineColorO', '#shadowColor', '#shadowColorO',
19 | '#shadowBlur',
20 | '#isRoundY', '#isRoundN'
21 | ]
22 | this.domControlSelector = [
23 | {
24 | selector: '#setting-menu',
25 | event: {
26 | click: () => {
27 | let dom = this.getContainerDom('#setting-wrap')
28 | dom.classList.toggle('show')
29 | dom.classList.toggle('hide')
30 | }
31 | }
32 | },
33 | {
34 | selector: '#setting-close',
35 | event: {
36 | click: () => {
37 | let dom = this.getContainerDom('#setting-wrap')
38 | dom.classList.remove('show')
39 | dom.classList.add('hide')
40 | }
41 | }
42 | },
43 | {
44 | selector: '#playBtn',
45 | event: {
46 | click: () => {
47 | // this.removePlayAnimation()
48 | let dom = document.querySelector('.icon-play1')
49 | // dom.classList.toggle('click')
50 | eventBus.emit('play')
51 | }
52 | }
53 | },
54 | {
55 | selector: '#prevBtn',
56 | event: {
57 | click: () => {
58 | this.removePlayAnimation()
59 | eventBus.emit('prev')
60 | }
61 | }
62 | },
63 | {
64 | selector: '#nextBtn',
65 | event: {
66 | click: () => {
67 | this.removePlayAnimation()
68 | eventBus.emit('next')
69 | }
70 | }
71 | },
72 | {
73 | selector: '#downloadBtn',
74 | event: {
75 | click: () => {
76 | eventBus.emit('download')
77 | }
78 | }
79 | },
80 | {
81 | selector: '#submitBtn',
82 | event: {
83 | click: () => {
84 | this.removePlayAnimation()
85 | eventBus.emit('submit')
86 | }
87 | }
88 | },
89 | {
90 | selector: '#s-area',
91 | event: {
92 | click: () => {
93 | eventBus.emit('clickBar')
94 | },
95 | mousemove: (event) => {
96 | let sArea = this.getDom('#s-area', this.domTextMap)
97 | let sHover = this.getDom('#s-hover', this.domTextMap)
98 | let insTime = this.getDom('#ins-time', this.domTextMap)
99 | eventBus.emit('showHover', {event, sArea, sHover, insTime})
100 | },
101 | mouseout: () => {
102 | let sHover = this.getDom('#s-hover', this.domTextMap)
103 | let insTime = this.getDom('#ins-time', this.domTextMap)
104 | eventBus.emit('hideHover', { sHover, insTime})
105 | }
106 | }
107 | }
108 | ]
109 | this.domContainerSelector = ['#bg', '#info-cover', '#music-lrc', '#setting-wrap', '#alert']
110 |
111 | this.af = null
112 | this.bgs = bgs || []
113 | this.lrcList = []
114 | this.lrcIndex = 0
115 | this.lrcRowH = 30
116 | this.findDom('domTextSelector', 'domTextMap')
117 | this.findDom('domInputSelector', 'domInputMap')
118 | this.findDom('domControlSelector', 'domControlMap')
119 | this.findDom('domContainerSelector', 'domContainerMap')
120 | this.handleInit()
121 | this.handleChange()
122 | this.bindInputChange()
123 | this.loadBG()
124 |
125 | eventBus.on('echosetting', data => {
126 | for (let item of this.domInputMap) {
127 | /** 对 radio 特殊处理 */
128 | if (item[0].startsWith('#isRound')) {
129 | let v = data['isRound']
130 | let key = `#isRound${v ? 'Y' : 'N'}`
131 | if (item[0] === key) {
132 | item[1].checked = true
133 | }
134 | } else {
135 | item[1].value = data[item[0].replace('#', '')]
136 | }
137 | }
138 | })
139 | }
140 |
141 | removePlayAnimation (dom) {
142 | let d = dom || this.getControlDom('#submitBtn')
143 | if (d.classList.contains('animation')) {
144 | d.classList.remove('animation')
145 | }
146 | }
147 |
148 | handleChange () {
149 | eventBus.on('change', ({ state, duration, currentTime }) => {
150 | // this.setDomText('#info-state', state)
151 | // console.log(duration)
152 | let durationFormat = parseFloat(duration).toFixed(2)
153 | let currentTimeFormat = parseFloat(currentTime).toFixed(2)
154 | if (isNaN(duration) || isNaN(currentTimeFormat)) {
155 | return;
156 | } else {
157 | this.setDomText('#current-time-minute', this.add0(parseInt(Math.max(Math.floor(currentTimeFormat / 60), 0))))
158 | this.setDomText('#current-time-second', this.add0(parseInt(Math.max(currentTimeFormat % 60, 0))))
159 | let playProgress = (currentTime / duration) * 100
160 | let seekBar = this.getDom('#seek-bar', this.domTextMap)
161 | seekBar.style.width = playProgress + '%';
162 | if (currentTime >= this.nextLrcTime() || currentTime < this.lrcList[this.lrcIndex][0]) {
163 | console.log("change2")
164 | this.nextLrc(currentTime)
165 | }
166 |
167 | // if (currentTime < this.lrcList[this.lrcIndex][0]) {
168 | // this.nextLrc(currentTime)
169 | // }
170 | }
171 | })
172 | }
173 |
174 | handleInit () {
175 | eventBus.on('init', ({ title, lrc , duration}) => {
176 | console.log('init')
177 | this.initSongInfo ({ title})
178 | this.setDomText('#total-time-minute', this.add0(parseInt(Math.max(Math.floor(duration / 60), 0))))
179 | this.setDomText('#total-time-second', this.add0(parseInt(Math.max(duration % 60, 0))))
180 | this.loadData (lrc)
181 | })
182 | }
183 |
184 | bindInputChange () {
185 | for (let item of this.domInputMap) {
186 | (function (key, dom) {
187 | dom.addEventListener('input', e => {
188 | let { type, value } = e.target
189 | if (type === 'range') {
190 | value = parseFloat(value)
191 | }
192 | if (type === 'radio' && key.startsWith('isRound')) {
193 | key = 'isRound'
194 | value = value === '0' ? false : true
195 | }
196 | if (key === 'lineColor') {
197 | document.documentElement.style.setProperty('--theme-color', value)
198 | }
199 | eventBus.emit('setting', { [key]: value })
200 | }, false)
201 | })(item[0].replace('#', '').replace('.', ''), item[1])
202 | }
203 | }
204 |
205 | findDom (domSelector, domMap) {
206 | if (this[domSelector].length < 1) return
207 | if (!this[domMap]) return
208 | this[domSelector].forEach(selector => {
209 | let type = Object.prototype.toString.call(selector)
210 | if (type === '[object String]') {
211 | this[domMap].set(selector, document.querySelector(selector))
212 | }
213 | if (type === '[object Object]') {
214 | let dom = document.querySelector(selector.selector)
215 | if (!dom) return
216 | this[domMap].set(selector.selector, dom)
217 | if (selector.event && Object.keys(selector.event).length > 0) {
218 | for (let key in selector.event) {
219 | dom.addEventListener(key, selector.event[key])
220 | }
221 | }
222 | }
223 | })
224 | }
225 |
226 | setDomText (selector, value) {
227 | let dom = this.domTextMap.get(selector)
228 | if (!dom) return
229 | dom.innerText = value
230 | }
231 |
232 | getContainerDom (selector) {
233 | return this.getDom(selector, this.domContainerMap)
234 | }
235 |
236 | getControlDom (selector) {
237 | return this.getDom(selector, this.domControlMap)
238 | }
239 |
240 | getDom (selector, domMap) {
241 | return domMap.get(selector) || null
242 | }
243 |
244 | initSongInfo ({ title}) {
245 | this.lrcList = []
246 | this.lrcIndex = 0
247 | this.setDomText('#song-title', title)
248 | }
249 |
250 | loadBG () {
251 | if (this.bgs.length < 1) return
252 | let index = Math.floor(Math.random() * this.bgs.length)
253 | let image = this.bgs[index]
254 | this.getContainerDom('#bg').style = `background-image: url(${image});`
255 | }
256 |
257 | async loadData (url) {
258 | console.log(url)
259 | if (!url) {
260 | this.lrcList = [[0, '当前歌曲暂无歌词,闭上眼睛静静聆听~']]
261 | this.initLrcDom()
262 | return
263 | }
264 | if (this.af) {
265 | this.af.abort()
266 | this.af = null
267 | }
268 | this.af = AbortFetch()
269 | let list = [], text = await this.af.fetch(url)
270 | .then(result => result.text())
271 | .catch(({ name }) => {
272 | if (name === 'AbortError') return console.log('cancel')
273 | list.push([0, '加载歌词出错,我也不知道问题出在哪里(⑉・̆-・̆⑉)'])
274 | })
275 | text && text.split('\n').forEach(row => {
276 | if (!row.includes('[')) return
277 | let chunk = row.replace('[', '').split(']')
278 | let times = chunk[0].split(':')
279 | list.push([times[0] * 60 + parseFloat(times[1] + ''), chunk[1]])
280 | })
281 | this.lrcList = list
282 | this.initLrcDom()
283 | }
284 |
285 | initLrcDom () {
286 | const { lrcIndex, lrcList } = this
287 | let lrcContainer = this.getContainerDom('#music-lrc')
288 | let df = document.createDocumentFragment()
289 | lrcContainer.innerHTML = ""
290 | for (let i = 0; i < lrcList.length; i++) {
291 | let row = lrcList[i]
292 | let p = document.createElement('p')
293 | p.dataset.time = row[0]
294 | p.innerText = row[1]
295 | if (i === lrcIndex) p.classList.add('current')
296 | df.appendChild(p)
297 | lrcContainer.appendChild(df)
298 | }
299 | this.rollLrc()
300 | }
301 |
302 | currentLrc () {
303 | const { lrcIndex, lrcList } = this
304 | return lrcList[lrcIndex]
305 | }
306 |
307 |
308 |
309 | nextLrcTime () {
310 | const { lrcIndex, lrcList } = this
311 | let end = lrcList.length - 1
312 | let nextIndex = lrcIndex + 1
313 | if (nextIndex >= end || end < 0 ) return null
314 | return lrcList[nextIndex][0]
315 | }
316 |
317 | nextLrc (currentTime) {
318 | const { lrcIndex, lrcList } = this
319 | // if (lrcIndex >= lrcList.length - 1) return
320 | let lrcContainer = this.getContainerDom('#music-lrc')
321 | // 查找当前歌词
322 | for (let i = 0; i < lrcList.length; i++) {
323 | let row = lrcList[i]
324 | if (i === lrcList.length - 1) {
325 | this.lrcIndex = i
326 | break;
327 | } else if (row[0] <= currentTime && currentTime < lrcList[i + 1][0]){
328 | this.lrcIndex = i
329 | break;
330 | }
331 | }
332 | lrcContainer.querySelectorAll('p').forEach((p, index) => {
333 | if (index !== this.lrcIndex) {
334 | p.classList.remove('current')
335 | } else {
336 | p.classList.add('current')
337 | }
338 | })
339 | this.rollLrc()
340 | }
341 |
342 |
343 | add0 (n) {
344 | return n > 9 ? n : `0${n}`
345 | }
346 |
347 | rollLrc () {
348 | const { lrcIndex, lrcRowH } = this
349 | let lrcContainer = this.getContainerDom('#music-lrc')
350 | if (lrcIndex === 0) {
351 | lrcContainer.style = `transform: translateY(${lrcRowH}px)`
352 | } else {
353 | let y = (lrcIndex - 1) * lrcRowH
354 | lrcContainer.style = `transform: translateY(-${y}px)`
355 | }
356 | }
357 |
358 | alert() {
359 | console.log('alert');
360 | this.getContainerDom('#alert').classList.add('show')
361 | setTimeout(() => {
362 | this.getContainerDom('#alert').classList.remove('show')
363 | }, 2000)
364 | }
365 | }
--------------------------------------------------------------------------------
/static/js/AudioVisual.js:
--------------------------------------------------------------------------------
1 | class AudioVisual {
2 | constructor (options) {
3 | this.canvas = document.querySelector('#music-canvas')
4 | this.ctx = this.canvas.getContext('2d')
5 |
6 | this.ac = new AudioContext()
7 | this.analyser = this.ac.createAnalyser()
8 | this.analyser.fftSize = 128
9 | this.analyser.connect(this.ac.destination)
10 |
11 | this.sourceDuration = 0
12 | this.startTime = 0
13 | this.loading = false
14 | this.started = false
15 | this.songInfo = null
16 | this.af = null
17 | this.abf = null
18 | this.beginTime = (new Date()).getTime() / 1000
19 | this.passTime = 0
20 | this.currentTime = 0
21 | this.seekLoc = 0
22 | this.endNature = true
23 |
24 | this.defaultSetting = {
25 | centerX: 0.5,
26 | centerY: 0.7,
27 | lineWidth: 6,
28 | lineSpacing: 2,
29 | lineColor: '#e93b81',
30 | lineColorO: 1,
31 | shadowColor: '#231018',
32 | shadowColorO: 1,
33 | shadowBlur: 2,
34 | isRound: true
35 | }
36 | this.opt = Object.assign({}, this.defaultSetting, options)
37 |
38 | this.handleEvent()
39 | this.resizeCavnas()
40 |
41 | window.addEventListener('resize', this.resizeCavnas.bind(this))
42 | }
43 |
44 | colorToRGB (color) {
45 | if (color.length !== 7 && !color.startsWith('#')) return [0, 0, 0]
46 | let rgb = []
47 | color = color.replace('#', '')
48 | for (let i = 0; i < 3; i++) {
49 | rgb.push(parseInt(color.substring(i * 2, i * 2 + 2), 16))
50 | }
51 | return rgb
52 | }
53 |
54 | handleEvent () {
55 | eventBus.emit('echosetting', this.defaultSetting)
56 | eventBus.on('setting', data => {
57 | this.opt = Object.assign({}, this.opt, data)
58 | })
59 |
60 | eventBus.on('showHover', ({event, sArea, sHover, insTime}) => {
61 |
62 | var seekBarPos = sArea.getBoundingClientRect().left;
63 | // let seekBarPos = sArea.offset() // 获取进度条长度
64 | var seekT = event.clientX - seekBarPos //获取当前鼠标在进度条上的位置
65 | this.seekLoc = this.sourceDuration * (seekT / sArea.offsetWidth) //当前鼠标位置的音频播放秒数: 音频长度(单位:s)*(鼠标在进度条上的位置/进度条的宽度)
66 | // console.log(seekT)
67 | sHover.style.width = seekT + 'px' //设置鼠标移动到进度条上变暗的部分宽度
68 | var cM = this.seekLoc / 60 // 计算播放了多少分钟: 音频播放秒速/60
69 |
70 | let ctMinutes = Math.floor(cM) // 向下取整
71 | let ctSeconds = Math.floor(this.seekLoc - ctMinutes * 60) // 计算播放秒数
72 |
73 | if( (ctMinutes < 0) || (ctSeconds < 0) )
74 | return;
75 |
76 | if( (ctMinutes < 0) || (ctSeconds < 0) )
77 | return;
78 |
79 | if(ctMinutes < 10)
80 | ctMinutes = '0'+ctMinutes;
81 | if(ctSeconds < 10)
82 | ctSeconds = '0'+ctSeconds;
83 | if( isNaN(ctMinutes) || isNaN(ctSeconds) )
84 | insTime.innerText = '--:--';
85 | else
86 | insTime.innerText = ctMinutes+':'+ctSeconds; // 设置鼠标移动到进度条上显示的信息
87 | // console.log(insTime.innerText)
88 | // insTime.css({'left':seekT,'margin-left':'-21px'}).fadeIn(0);
89 | insTime.style.left = seekT + 'px';
90 | insTime.style.marginLeft = '-21px';
91 | insTime.style.opacity = 0.7;
92 | insTime.style.visibility = 'visible';
93 | // insTime.classList.add('fade-in');
94 | })
95 |
96 | eventBus.on('hideHover', ({sHover, insTime}) => {
97 | sHover.style.width = 0;
98 | insTime.innerText = '00:00'
99 | insTime.style.left = '0px'
100 | insTime.style.marginLeft = '0px'
101 | insTime.style.opacity = 0;
102 | })
103 |
104 | eventBus.on('timeupdate', () => {
105 | nTime = new Date(); // 获取当前时间
106 | nTime = nTime.getTime(); // 将该时间转化为毫秒数
107 |
108 | // 计算当前音频播放的时间
109 | curMinutes = Math.floor(audio.currentTime / 60);
110 | curSeconds = Math.floor(audio.currentTime - curMinutes * 60);
111 |
112 | // 计算当前音频总时间
113 | durMinutes = Math.floor(audio.duration / 60);
114 | durSeconds = Math.floor(audio.duration - durMinutes * 60);
115 |
116 | // 计算播放进度百分比
117 | playProgress = (audio.currentTime / audio.duration) * 100;
118 |
119 | // 如果时间为个位数,设置其格式
120 | if(curMinutes < 10)
121 | curMinutes = '0'+curMinutes;
122 | if(curSeconds < 10)
123 | curSeconds = '0'+curSeconds;
124 |
125 | if(durMinutes < 10)
126 | durMinutes = '0'+durMinutes;
127 | if(durSeconds < 10)
128 | durSeconds = '0'+durSeconds;
129 |
130 | if( isNaN(curMinutes) || isNaN(curSeconds) )
131 | tProgress.text('00:00');
132 | else
133 | tProgress.text(curMinutes+':'+curSeconds);
134 |
135 | if( isNaN(durMinutes) || isNaN(durSeconds) )
136 | totalTime.text('00:00');
137 | else
138 | totalTime.text(durMinutes+':'+durSeconds);
139 |
140 | if( isNaN(curMinutes) || isNaN(curSeconds) || isNaN(durMinutes) || isNaN(durSeconds) )
141 | time.removeClass('active');
142 | else
143 | time.addClass('active');
144 |
145 | // 设置播放进度条的长度
146 | seekBar.width(playProgress+'%');
147 |
148 | // 进度条为100 即歌曲播放完时
149 | if( playProgress == 100 )
150 | {
151 | playPauseBtn.attr('class','btn play-pause icon-jiediankaishi iconfont'); // 显示播放按钮
152 | seekBar.width(0); // 播放进度条重置为0
153 | tProgress.text('00:00'); // 播放时间重置为 00:00
154 | musicImgs.removeClass('buffering').removeClass('active'); // 移除相关类名
155 | clearInterval(buffInterval); // 清除定时器
156 | selectTrack(1); // 添加这一句,可以实现自动播放
157 | }
158 |
159 | })
160 |
161 | eventBus.on('clickBar', () => {
162 | this.currentTime = this.seekLoc
163 | this.beginTime = (new Date()).getTime() / 1000
164 | console.log(this.source)
165 | console.log(this.ac.state)
166 | if (this.source && this.ac.state === 'suspended') {
167 | this.togglePlay()
168 | }else if(!this.source){
169 | return;
170 | }
171 | this.endNature = false
172 | this.source.stop()
173 | this.started = false
174 | })
175 | }
176 |
177 | async loadData () {
178 | // const { songInfo } = this
179 | this.singer = this.getOption("#singer-list")
180 | this.song = this.getOption("#song-list")
181 | this.mode = this.getOption("#mode-list")
182 |
183 | if (this.af) {
184 | this.af.abort()
185 | this.af = null
186 | }
187 |
188 | this.af = AbortFetch()
189 | this.loading = true
190 | if(this.source){
191 | this.source.stop()
192 | this.started = false
193 | }
194 |
195 | // "/wavs?"+"singer="+this.singer+"&song="+this.song+"&mode="+this.mode
196 | let ab = await this.af.fetch("https://qiniu.sukoshi.xyz/cloud-music/Aimer%20-%20%E8%8A%B1%E3%81%B2%E3%82%99%E3%82%89%E3%81%9F%E3%81%A1%E3%81%AE%E3%83%9E%E3%83%BC%E3%83%81.mp3")
197 | .then(result => result.arrayBuffer())
198 | .catch(({ name }) => {
199 | if (name === 'AbortError') return console.log('cancel')
200 | this.loading = false
201 | eventBus.emit('change', {
202 | state: "error",
203 | duration: "T_T",
204 | currentTime: "T_T",
205 | })
206 | return alert("初始化数据失败,请尝试刷新页面(◔‸◔)")
207 | })
208 | console.log(ab)
209 | this.abf = ab// 必须复制,否则会解析失败
210 | console.log()
211 | if (!ab) return
212 | this.decodeAudioData(ab.slice(0), true)
213 | }
214 |
215 | decodeAudioData(ab, isInit = false) {
216 | let { ac, analyser } = this
217 | this.source = ac.createBufferSource()
218 | ac.decodeAudioData(ab, buffer => {
219 | if(isInit || this.endNature) {
220 | this.currentTime = 0
221 | }
222 | this.endNature = true
223 | this.beginTime = (new Date()).getTime() / 1000
224 | this.source.buffer = buffer
225 | this.buffer = buffer
226 | this.source.connect(analyser)
227 | this.source.start(0, this.currentTime)
228 | this.source.onended = () => {
229 | this.onended && this.onended()
230 | this.decodeAudioData(this.abf.slice(0))
231 | }
232 | if(isInit) {
233 | console.log("init")
234 | // lrc: "/lyrics?"+"singer="+this.singer+"&song="+this.song+"&mode="+this.mode
235 | // title: this.singer + " - " + this.song
236 | eventBus.emit('init', { title: "Aimer - 花びらたちのマーチ",
237 | lrc:"https://qiniu.sukoshi.xyz/cloud-music/Aimer%20-%20%E8%8A%B1%E3%81%B2%E3%82%99%E3%82%89%E3%81%9F%E3%81%A1%E3%81%AE%E3%83%9E%E3%83%BC%E3%83%81.txt", duration: this.source.buffer.duration})
238 | let dom = document.querySelector('.icon-play1')
239 | dom.classList.add('click')
240 | }
241 | this.loading = false
242 | this.started = true
243 | this.startTime = this.currentTime
244 | this.sourceDuration = buffer.duration
245 | console.log(ac.currentTime);
246 | this.refreshUI()
247 | }, error => {
248 | console.log(error)
249 | })
250 | }
251 |
252 | getOption(id) {
253 | const select = document.querySelector(id);
254 | return select.options[select.selectedIndex].value
255 | }
256 |
257 | stop () {
258 | let { source, started } = this
259 | if (source && started) {
260 | console.log("stopped")
261 | source.onended = null
262 | source.stop()
263 | }
264 | this.source = null
265 | this.started = false
266 | }
267 |
268 | play (isReload = true) {
269 | if (!isReload && this.loading) return console.log("loading...")
270 | this.stop()
271 | this.loadData()
272 | }
273 |
274 | togglePlay () {
275 | const { ac } = this
276 | let dom = document.querySelector('.icon-play1')
277 | if (ac.state === 'running') {
278 |
279 | dom.classList.remove('click')
280 | return ac.suspend()
281 | }else if (ac.state === 'suspended') {
282 | this.currentTime += this.passTime
283 | this.beginTime = (new Date()).getTime() / 1000
284 | dom.classList.add('click')
285 | return ac.resume()
286 | }else {
287 | this.source.start(0)
288 | }
289 |
290 | }
291 |
292 | resizeCavnas () {
293 | const { canvas } = this
294 | this.cw = canvas.width = canvas.clientWidth
295 | this.ch = canvas.height = canvas.clientHeight
296 | }
297 |
298 | draw () {
299 | const { ctx, cw, ch, analyser } = this
300 | const { lineColor, lineColorO, shadowColor, shadowColorO, shadowBlur, lineWidth, lineSpacing, isRound } = this.opt
301 |
302 | let bufferLen = analyser.frequencyBinCount
303 | let buffer = new Uint8Array(bufferLen)
304 | analyser.getByteFrequencyData(buffer)
305 |
306 | let cx = this.cw * this.opt.centerX
307 | let cy = this.ch * this.opt.centerY
308 | let sp = (lineWidth + lineSpacing) / 2
309 |
310 | ctx.clearRect(0, 0, cw, ch)
311 | ctx.beginPath()
312 | ctx.lineWidth = lineWidth
313 | ctx.shadowBlur = shadowBlur
314 | ctx.strokeStyle = `rgba(${this.colorToRGB(lineColor).join(',')}, ${lineColorO})`
315 | ctx.shadowColor = `rgba(${this.colorToRGB(shadowColor).join(',')}, ${shadowColorO})`
316 | if (isRound) {
317 | ctx.lineCap = "round"
318 | } else {
319 | ctx.lineCap = "butt"
320 | }
321 |
322 | for (let i = 0; i < bufferLen; i++) {
323 | let h = buffer[i] + 1
324 | let xl = cx - i * (lineWidth + lineSpacing) - sp
325 | let xr = cx + i * (lineWidth + lineSpacing) + sp
326 | let y1 = cy - h / 2
327 | let y2 = cy + h / 2
328 | ctx.moveTo(xl, y1)
329 | ctx.lineTo(xl, y2)
330 | ctx.moveTo(xr, y1)
331 | ctx.lineTo(xr, y2)
332 | }
333 |
334 | ctx.stroke()
335 | ctx.closePath()
336 | }
337 |
338 | refreshUI () {
339 | const { ac: { state, currentTime }, source, loading, started, startTime } = this
340 |
341 | this.draw()
342 | try {
343 | if (state === 'running' && !loading && started) {
344 | this.passTime = (new Date()).getTime() / 1000 - this.beginTime
345 | // this.currentTime = this.nowTime
346 | // console.log(this.currentTime - this.beginTime)
347 | eventBus.emit('change', {
348 | state,
349 | duration: source.buffer.duration,
350 | currentTime: this.currentTime + this.passTime,
351 | })
352 | }
353 | } catch (error) {
354 | console.log(error)
355 | }
356 | requestAnimationFrame(this.refreshUI.bind(this))
357 | }
358 |
359 | download() {
360 | // buffer必须是arraybuffer对象, 传audiobuffer对象会报错
361 | const buffer = this.abf;
362 |
363 | console.log(buffer);
364 | // 创建一个 Blob 对象来表示二进制数据
365 | let blob = new Blob([buffer], { type: 'audio/wav' });
366 |
367 | // 创建一个下载链接
368 | let url = URL.createObjectURL(blob);
369 |
370 | // 创建一个下载链接元素
371 | let downloadLink = document.createElement('a');
372 | downloadLink.href = url;
373 | downloadLink.download = 'audio.wav'; // 设置下载的文件名
374 | downloadLink.style.display = 'none';
375 |
376 | // 添加到 DOM 中并触发点击事件进行下载
377 | document.body.appendChild(downloadLink);
378 | downloadLink.click();
379 |
380 | // 清理下载链接和 Blob URL
381 | document.body.removeChild(downloadLink);
382 | URL.revokeObjectURL(url);
383 | }
384 |
385 |
386 |
387 | }
--------------------------------------------------------------------------------