├── 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 | ![](./static/images/cover.png) 23 | ![](./static/images/cover2.png) 24 | ![](./static/images/cover3.png) 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 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /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 |
19 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 | 40 | 41 |
42 |
43 | 44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 |
52 |
53 | 54 | 55 |
56 |
57 | 58 | 59 |
60 |
61 | 62 | 63 |
64 |
65 | 66 | 67 |
68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 | VISinger Lab 78 |
79 |
80 | 81 |
82 |
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 | } --------------------------------------------------------------------------------