├── .gitignore
├── README.md
└── html
├── functions.js
├── index.html
├── fm.less
└── fm-lite.js
/.gitignore:
--------------------------------------------------------------------------------
1 | fm.css
2 | vue.2.6.11.min.js
3 | getLiteArray.js
4 | *.json
5 | fm.js
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 我的收藏电台
2 |
3 | 给我的收藏写了个简单的播放器界面
4 |
5 | https://lab.magiconch.com/fm/
6 |
7 | ## 功能
8 | - 播放音乐
9 | - 本地收藏
10 |
11 | ## 备忘
12 | 大概是为了这点醋包了饺子😳
13 |
14 | 终于把坑了好长时间的薅毛方案搞定了,顺手就再做个界面
15 |
16 | 时间长不写忘了好多东西,在网页上播放音乐麻烦事情好多,还有好多细节没法覆盖到
17 |
18 |
19 | ## 参考
20 | https://developers.cloudflare.com/workers/examples/cache-using-fetch/
21 | https://developers.cloudflare.com/workers/examples/fetch-html/
22 | https://developers.cloudflare.com/workers/examples/cache-tags/
23 | https://developers.cloudflare.com/cache/about/default-cache-behavior/#cloudflare-cache-responses
24 | https://community.cloudflare.com/t/cf-cache-status-dynamic-when-using-workers-to-cache/322564/2
25 | https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio
26 | https://www.zhangxinxu.com/wordpress/2010/03/javascript-hex-rgb-hsl-color-convert/
27 |
--------------------------------------------------------------------------------
/html/functions.js:
--------------------------------------------------------------------------------
1 |
2 | const $ = s => document.querySelector(s);
3 |
4 | const padLeft = (num, size = 2,w='0') => (w+w+w + num).slice(size * -1);
5 | const hax2rgb = (hex='333333') => String(hex).match(/\w{2}/g).map(x=>parseInt(x,16));
6 | const rgb2hax = rgb=>rgb.map(n=>padLeft(n.toString(16),'2')).join('')
7 |
8 | // hsl rgb 转换函数 来自
9 | // https://www.zhangxinxu.com/wordpress/2010/03/javascript-hex-rgb-hsl-color-convert/
10 | function rgb2hsl([r, g, b]) {
11 | r /= 255, g /= 255, b /= 255;
12 | let max = Math.max(r, g, b), min = Math.min(r, g, b);
13 | let h, s, l = (max + min) / 2;
14 |
15 | if (max == min) return [0,0,0];
16 |
17 | let d = max - min;
18 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
19 | switch(max) {
20 | case r: h = (g - b) / d + (g < b ? 6 : 0); break;
21 | case g: h = (b - r) / d + 2; break;
22 | case b: h = (r - g) / d + 4; break;
23 | }
24 | h /= 6;
25 |
26 | return [h, s, l];
27 | }
28 | function hue2rgb(p, q, t) {
29 | if(t < 0) t += 1;
30 | if(t > 1) t -= 1;
31 | if(t < 1/6) return p + (q - p) * 6 * t;
32 | if(t < 1/2) return q;
33 | if(t < 2/3) return p + (q - p) * (2/3 - t) * 6;
34 | return p;
35 | }
36 | function hsl2rgb([h, s, l]) {
37 | let r, g, b;
38 |
39 | if(s == 0) return [0,0,0];
40 |
41 | let q = l < 0.5 ? l * (1 + s) : l + s - l * s;
42 | let p = 2 * l - q;
43 | r = hue2rgb(p, q, h + 1/3);
44 | g = hue2rgb(p, q, h);
45 | b = hue2rgb(p, q, h - 1/3);
46 |
47 | return [
48 | Math.round(r * 255),
49 | Math.round(g * 255),
50 | Math.round(b * 255)
51 | ];
52 | }
53 | const loadImage = (src,cb)=>{
54 | const img = new Image();
55 | img.onload = ()=> cb(img);
56 | img.src = src;
57 | }
58 |
59 | const second2ms = s=>{
60 | const mm = Math.floor(s/60);
61 | const ss = Math.floor(s % 60);
62 |
63 | return `${mm}:${padLeft(ss)}`
64 | };
65 |
--------------------------------------------------------------------------------
/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 电台
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
{{ track.title }}
22 |
{{ fixSub(sub) }}
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
43 |
44 |
47 |
{{fav.title}}
48 | {{fixSub(fav.sub)}}
49 |
50 |
51 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/html/fm.less:
--------------------------------------------------------------------------------
1 | html{
2 | color: #333;
3 | // @media (max-width:500px) {
4 | // text-align: center;
5 | // .cover{
6 | // margin: 20px auto 10px;
7 | // }
8 | // }
9 | --current-color: #333;
10 | --background-color: #EEE;
11 | background-color: var(--background-color);
12 | --animation-time: 1s;
13 | transition: background-color var(--animation-time) ease;
14 | }
15 | .app{
16 | transition: color var(--animation-time) ease;
17 | color: var(--current-color);
18 | }
19 | .cover{
20 | margin: 20px 0 10px;
21 | width: 260px;
22 | height: 260px;
23 | border-radius: 4px;
24 | background: currentColor no-repeat 50%;
25 | background-size: 260px 260px;
26 | cursor: pointer;
27 | transform-origin: left bottom;
28 | transition:
29 | opacity 1s ease,
30 | transform .3s ease,
31 | box-shadow var(--animation-time) ease,
32 | background-color var(--animation-time) ease,
33 | background-image 2s ease;
34 | &[data-playing="false"]{
35 | opacity: .6;
36 | transform: scale(.9); // translateY(14px)
37 | }
38 | }
39 | body{
40 | margin: 0;
41 | padding: 20px;
42 | }
43 | h1,p{
44 | margin: 0;
45 | }
46 | audio{
47 | display: block;
48 | width: 100%;
49 | }
50 | button,a{
51 | display: inline-block;
52 | color: currentColor;
53 | text-decoration: none;
54 | appearance: none;
55 | -webkit-appearance: none;
56 | border: 2px solid currentColor;
57 | font:inherit;
58 | border-radius: 3px;
59 | line-height: 1;
60 | padding: 10px;
61 | margin: 0;
62 | font-weight: bold;
63 | background-color: transparent;
64 | cursor: pointer;
65 | outline: none;
66 | transition:
67 | background-color var(--animation-time) ease,
68 | border-color var(--animation-time) ease,
69 | color var(--animation-time) ease;
70 |
71 | &[data-active="true"]{
72 | background-color: var(--current-color);
73 | border-color: var(--current-color);
74 | color: var(--background-color);
75 | &:before{
76 | content: '取消';
77 | }
78 | }
79 | }
80 | .info-box{
81 | .content{
82 | padding: 10px 0 20px;
83 | white-space: nowrap;
84 |
85 | h1{
86 | font-size: 30px;
87 | line-height: 34px;
88 | padding: 10px 0;
89 | overflow: hidden;
90 | text-overflow: ellipsis;
91 | }
92 | p{
93 | opacity: 0.5;
94 | overflow: hidden;
95 | text-overflow: ellipsis;
96 | }
97 | }
98 | }
99 | .time-box{
100 | overflow: hidden;
101 | font-size: 12px;
102 | line-height: 20px;
103 | pointer-events: none;
104 | padding: 10px 0 0;
105 | margin-bottom: -10px;
106 | .current-time{
107 | float: left;
108 | }
109 | .duration-time{
110 | opacity: 0.2;
111 | float: right;
112 | &:empty{
113 | &:before{
114 | content: 'loading';
115 | }
116 | }
117 | }
118 | }
119 | .progress{
120 | position: relative;
121 | padding: 10px 0;
122 | cursor: pointer;
123 | i,b{
124 | pointer-events: none;
125 | display: block;
126 | height:4px;
127 | border-radius: 2px;
128 | background: currentColor;
129 | min-width: 4px;
130 | }
131 | b{
132 | position: absolute;
133 | opacity: 0.1;
134 | }
135 | i{
136 | transition: box-shadow .3s ease;
137 | }
138 | // transition: transform .3s ease;
139 | transition: opacity .3s ease;
140 | &:hover{
141 | i{
142 | box-shadow: 0 0 0 .5px currentColor;
143 | }
144 | // transform: scaleY(1.2);
145 | }
146 | &[data-loading="true"]{
147 | pointer-events: none;
148 | opacity: 0.5;
149 | }
150 | }
151 |
152 | .bottom-ctrl-box{
153 | padding-top: 20px;
154 | }
155 |
156 | .fav-track-list{
157 | padding: 40px 0;
158 | margin: 0 -20px;
159 | overflow: hidden;
160 | .item{
161 | cursor: pointer;
162 | padding: 10px 20px;
163 | line-height: 1.4;
164 |
165 | white-space: nowrap;
166 |
167 | h3{
168 | margin: 0;
169 | overflow: hidden;
170 | text-overflow: ellipsis;
171 | }
172 | span{
173 | opacity: 0.4;
174 | overflow: hidden;
175 | text-overflow: ellipsis;
176 | }
177 | &[data-active="true"]{
178 | background: var(--current-color);
179 | transition:
180 | background-color var(--animation-time) ease,
181 | border-color var(--animation-time) ease,
182 | color var(--animation-time) ease;
183 | color: var(--background-color);
184 | }
185 | }
186 | }
--------------------------------------------------------------------------------
/html/fm-lite.js:
--------------------------------------------------------------------------------
1 | const audio = new Audio();
2 | audio.controls = true;
3 | audio.preload = 'auto';
4 | // document.body.appendChild(audio);
5 | const iconLinkEl = $('link[rel="apple-touch-icon"]');
6 | const shortcutIconLinkEl = $('link[rel="shortcut icon"]');
7 |
8 |
9 | const mediaPath = 'https://un.sojo.im';
10 |
11 | const localStorageFavIdsKey = 'fm-lite-fav-ids';
12 | const favIds = (localStorage.getItem(localStorageFavIdsKey) || '').split(',').filter(i=>i);
13 |
14 | const canvas = document.createElement('canvas');
15 | canvas.width = canvas.height = 1;
16 |
17 | const trackCover = canvas.toDataURL();
18 |
19 | const play = (paused = audio.paused)=>{
20 | if(paused){
21 | audio.play();
22 | }else{
23 | audio.pause();
24 | }
25 | }
26 |
27 | const app = new Vue({
28 | el: '.app',
29 | data(){
30 | return {
31 | trackId: null,
32 | tracks: [],
33 | playing: false,
34 | loading: true,
35 | favIds,
36 | trackCover,
37 | }
38 | },
39 | computed:{
40 | Tracks(){
41 | return Object.fromEntries(this.tracks.map(a=>[a[0],a]))
42 | },
43 | trackArray(){
44 | return this.Tracks[this.trackId];
45 | },
46 | track(){
47 | if(!this.trackArray) return;
48 | return this.trackArrayToTrack(this.trackArray);
49 | },
50 | sub(){
51 | if(!this.track) return;
52 | return this.track.sub;
53 | },
54 | color(){
55 | if(!this.track) return;
56 | return '#'+this.track.color;
57 | },
58 | hsl(){
59 | const rgb = hax2rgb(this.track ? this.track.color : '333333');
60 | return rgb2hsl(rgb);
61 | },
62 | colorDark(){
63 | const { hsl } = this;
64 | // hsl[1] = 1;
65 | hsl[2] = .4;
66 | const hax = rgb2hax(hsl2rgb(hsl))
67 | return '#'+hax;
68 | },
69 | colorLight(){
70 | const { hsl } = this;
71 | hsl[1] = .3;
72 | hsl[2] = .9;
73 | const hax = rgb2hax(hsl2rgb(hsl))
74 | return '#'+hax;
75 | },
76 | // trackCover(){
77 | // if(!this.track) return;
78 | // return this.track.cover;
79 | // },
80 | boxShadow(){
81 | if(!this.colorDark) return;
82 | return `${this.colorDark} 0px 15px 45px -25px`;
83 | },
84 | src(){
85 | if(!this.trackId) return;
86 | return `${mediaPath}/music/${this.trackId}`
87 | },
88 | title(){
89 | if(!this.track) return;
90 | return this.track.title;
91 | },
92 | favTracks(){
93 | return this.favIds.map(id=>this.Tracks[id]).filter(t=>t).map(this.trackArrayToTrack).reverse()
94 | }
95 | },
96 | methods:{
97 | fixSub(sub){
98 | if(!sub) return 'ヽ(・ω・´メ)';
99 |
100 | if(/acg/i.test(sub)) return 'ヽ(・ω・´メ)';
101 |
102 | sub = sub.replace(/,/g,'、');
103 |
104 | return sub;
105 | },
106 | trackArrayToTrack(trackArray){
107 | const [id,title,sub,aid,color] = trackArray;
108 | const cover = `${mediaPath}/xiami/${aid}.jpg!w520h520`
109 | return {id,title,sub,aid,color,cover};
110 | },
111 | randOne(){
112 | const track = this.tracks[Math.floor(this.tracks.length * Math.random())];
113 | this.go(track[0]);
114 | },
115 | go(trackId,noHash){
116 | const hash = `#/${trackId}`;
117 |
118 | if(!noHash){
119 | location.hash = hash;
120 | }
121 |
122 | app.loading = true;
123 | app.playing = false;
124 |
125 |
126 | this.trackId = trackId;
127 |
128 | if(!this.track) return this.randOne();
129 |
130 | document.title = `${this.track.title} - ${this.sub}`;
131 | iconLinkEl.href = this.track.cover;
132 | shortcutIconLinkEl.href = this.track.cover;
133 |
134 | document.documentElement.style.setProperty('--background-color',this.colorLight);
135 |
136 | audio.src = this.src;
137 | audio.currentTime = 0;
138 |
139 | loadImage(this.track.cover,el=>{
140 | this.trackCover = this.track.cover;
141 | });
142 |
143 | const playPromise = this.play();
144 | if (playPromise !== undefined) {
145 | playPromise.then(_ => {
146 |
147 | })
148 | .catch(error => {
149 |
150 | });
151 | }
152 |
153 | },
154 | play,
155 | next(){
156 | this.randOne();
157 | },
158 | fav(){
159 | let { trackId } = this;
160 | trackId = String(trackId);
161 | const index = this.favIds.indexOf(trackId);
162 | if(index === -1){
163 | this.favIds.push(trackId);
164 | }else{
165 | this.favIds.splice(index,1);
166 | }
167 | localStorage.setItem(localStorageFavIdsKey,this.favIds.join(','));
168 | }
169 | }
170 | })
171 |
172 | audio.onended = _=>{
173 | app.next();
174 | }
175 |
176 | const progressBoxEl = $('.progress');
177 | const progressPlayingEl = progressBoxEl.children[1];
178 | const progressLoadedEl = progressBoxEl.children[0];
179 |
180 | let onClicking = false;
181 | let clickProgress = 0;
182 | const onMove = e=>{
183 | const { target, clientX } = e;
184 | const { offsetLeft, offsetWidth } = target;
185 |
186 | const isTouch = ['touchmove','touchstart'].includes(e.type);
187 |
188 | // console.log(e,e.type);
189 | const x = isTouch ? e.changedTouches[0].clientX : clientX;
190 | const w = x - offsetLeft;
191 | clickProgress = Math.min(1,Math.max(0,w / offsetWidth));
192 |
193 | progressPlayingEl.style.width = `${clickProgress * 100}%`;
194 | currentTimeEl.innerHTML = second2ms(clickProgress * audio.duration);
195 | }
196 | progressBoxEl.onmousedown =
197 | progressBoxEl.ontouchstart = e=>{
198 | if(e.which === 3) return;
199 | e.preventDefault();
200 | onClicking = true;
201 |
202 | document.onmousemove =
203 | document.ontouchmove = onMove;
204 |
205 | onMove(e);
206 |
207 | document.onmouseup =
208 | document.ontouchend =
209 | document.onmouseleave =
210 | document.onvisibilitychange = e=>{
211 | onClicking = false;
212 | audio.currentTime = clickProgress * audio.duration;
213 | document.onmousemove = null;
214 | document.ontouchmove = null;
215 | document.onmouseup = null;
216 | document.ontouchend = null;
217 | document.onmouseleave = null;
218 | document.onvisibilitychange = null;
219 | }
220 | }
221 |
222 | const timeEl = $('.time-box');
223 | const [ currentTimeEl , durationTimeEl ] = timeEl.children;
224 |
225 |
226 | audio.ontimeupdate = e=>{
227 | let { currentTime } = audio;
228 |
229 |
230 | const playingProgress = currentTime / audio.duration;
231 |
232 | const loadedProgress = audio.buffered.length ? (audio.buffered.end(0) / audio.duration) : 0;
233 | progressLoadedEl.style.width = `${loadedProgress * 100}%`;
234 |
235 | if(!onClicking){
236 | progressPlayingEl.style.width = `${playingProgress * 100}%`;
237 | currentTimeEl.innerHTML = second2ms(currentTime);
238 | }
239 | durationTimeEl.innerHTML = audio.duration ? second2ms(audio.duration) : '';
240 | };
241 | audio.onloadedmetadata = _=>{
242 | app.loading = false;
243 | }
244 |
245 | // audio.onprogress = e=>{
246 | // console.log('onprogress')
247 | // console.log(audio.buffered.end(0))
248 | // }
249 | audio.oncanplaythrough = e=>{
250 | console.log('canplaythrough');
251 | }
252 | audio.onplay = e=>{
253 | app.playing = true;
254 | }
255 | audio.onpause = e=>{
256 | app.playing = false;
257 | }
258 |
259 | fetch('fav-tracks.json').then(r=>r.json()).then(tracks=>{
260 | app.tracks = tracks;
261 | window.onhashchange = e=>{
262 | const id = +location.hash.match(/\d+/);
263 | if(id){
264 | app.go(id,true);
265 | }else if(!app.track){
266 | app.randOne();
267 | }
268 | }
269 | window.onhashchange();
270 | document.addEventListener('click', _=> play(true), { once: true});
271 | document.addEventListener('touchstart', _=> play(true), { once: true});
272 | })
--------------------------------------------------------------------------------