├── .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 | }) --------------------------------------------------------------------------------