1/* ============================================================================= 2 player.js · å±çé©±å¨ + 峰弿³¢å½¢ + 宿¶é¢è°± + ææ¾è¶å + å±å¼é¢æ¿ 3 ============================================================================= */ 4 5import * as A from './audio.js'; 6import { t, locField, onLangChange } from './i18n.js'; 7import { toast } from './ui.js'; 8import { likes, toggleLike } from './store.js'; 9 10const $ = id => document.getElementById(id); 11const css = v => getComputedStyle(document.documentElement).getPropertyValue(v).trim(); 12 13/* å±å¼é¢æ¿å·¦ä¸è§çåè½åãç¨ãåè½ãè䏿¯æ¥å£ç task åæ®µ ââ 14 åä¸ä¸ª taskï¼text_to_musicï¼å¯¹åºä¸¤ç§åè½ï¼ææ²åä½ / 纯é³ä¹ï¼ã */ 15const TASK_LABEL = { 16 song: 'Song', instrumental: 'Instrumental', 17 vocal2music: 'Vocal2Music', cover: 'Cover', 18}; 19 20const ICO_WAVES = `<svg class="icon-sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" 21 stroke-width="1.8" stroke-linecap="round"><use href="#i-waves"/></svg>`; 22 23/* è¾å ¥é³é¢ç¨ç objectURLï¼éç»/å ³éæ¶åæ¶ï¼é¿å æ³æ¼ */ 24let inputUrls = [];
25function revokeInputUrls() { inputUrls.forEach(URL.revokeObjectURL); inputUrls = []; } 26 27const plTT = $('plTT'), npTT = $('npTT'); 28const npEl = $('np'), npScrim = $('npScrim'); 29let npOpen = false; 30let raf = null; 31 32/* ââ å±çï¼ç¶æ + å±èè§åº¦ ââââââââââââââââââââââââââââââââââââââââââââââ 33 å±èåªå¨**ææ¾ä¸**è½ä¸ãæåæ¶æåæ¯æ¶å¹¶æ¬éï¼700ms çå¯è§æå¨ï¼ï¼ 34 æ¢å¤æ¶åæåå½åè¿åº¦å¯¹åºçè§åº¦ã */ 35function setTT(el, stateName, p) { 36 if (!el) return; 37 el.dataset.state = stateName; 38 el.style.setProperty('--down', stateName === 'playing' ? 1 : 0); 39 if (p != null) el.style.setProperty('--p', p.toFixed(4)); 40} 41 42/* ââ 峰弿³¢å½¢ï¼seek æ¡ï¼ââââââââââââââââââââââââââââââââââââââââââââââââââ */ 43function drawPeaks(cv, peaks, p) { 44 if (!cv) return; 45 const dpr = window.devicePixelRatio || 1; 46 // ç¨ clientWidth/clientHeightï¼ä¸è¦è¯» width/height 屿§ ââ 47 // 设置 cv.height ä¼åæ¶æ¹å屿§ï¼ä¸æ¬¡è¯»åæ¥ä¼è¢« dpr å夿¾å¤§ã 48 const w = cv.clientWidth || 240, h = cv.clientHeight || 24; 49 if (!w) return; 50 if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { 51 cv.width = Math.round(w * dpr); 52 cv.height = Math.round(h * dpr); 53 } 54 const g = cv.getContext('2d'); 55 g.setTransform(dpr, 0, 0, dpr, 0, 0); 56 g.clearRect(0, 0, w, h); 57 58 const on = css('--art') || '#6B6F76'; 59 const off = css('--pk-off') || 'rgba(20,22,26,.22)'; 60 const bw = 2, gap = 1; 61 const n = Math.max(8, Math.floor(w / (bw + gap))); 62 for (let i = 0; i < n; i++) { 63 let v; 64 if (peaks?.length) { 65 // æ peaks ééæ ·å° n 个å¯è§æ¡ 66 const a = Math.floor(i / n * peaks.length); 67 const b = Math.max(a + 1, Math.floor((i + 1) / n * peaks.length)); 68 let m = 0; 69 for (let j = a; j < b; j++) if (peaks[j] > m) m = peaks[j]; 70 v = m; 71 } else { 72 v = 0.34; // è¿æ²¡è§£åºå³°å¼ï¼çé«å ä½ï¼ä¸é å 73 } 74 const bh = Math.max(2, v * (h - 2)); 75 const x = i * (bw + gap), y = (h - bh) / 2; 76 g.fillStyle = (i / n) <= p ? on : off; 77 g.beginPath(); 78 g.roundRect(x, y, bw, bh, bw / 2); 79 g.fill(); 80 } 81} 82 83/* ââ 宿¶é¢è°±ï¼å¾å¨ï¼ââââââââââââââââââââââââââââââââââââââââââââââââââââ 84 éåæ±ç¶ï¼ä¸ä¸é¢ç峰弿³¢å½¢å»æåæä¸¤ç§å½¢æï¼ 85 é¢è°±æ¯"æ¤å»"ï¼æ³¢å½¢æ¯"æ´é¦ + ä½ç½®"ã */ 86const specSmooth = new Float32Array(48); 87function drawSpectrum(cv) { 88 if (!cv) return; 89 const dpr = window.devicePixelRatio || 1; 90 const w = cv.clientWidth || 300, h = cv.clientHeight || 46; 91 if (!w) return; 92 if (cv.width !== Math.round(w * dpr) || cv.height !== Math.round(h * dpr)) { 93 cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr); 94 } 95 const g = cv.getContext('2d'); 96 g.setTransform(dpr, 0, 0, dpr, 0, 0); 97 g.clearRect(0, 0, w, h); 98 99 const bars = 40; 100 const data = A.state.playing ? A.spectrum(bars) : null; 101 const mid = h / 2, bw = Math.max(2, (w / bars) - 3); 102 g.fillStyle = css('--art') || '#6B6F76'; 103 for (let i = 0; i < bars; i++) { 104 const target = data ? data[i] : 0; 105 // èªå·±ååä¸å±è¡°åï¼åææ¶ä¼é è½ä¸è䏿¯ç¬é´å½é¶ 106 specSmooth[i] += (target - specSmooth[i]) * (target > specSmooth[i] ? 0.55 : 0.12); 107 const bh = Math.max(2, specSmooth[i] * (h - 4)); 108 const x = i * (w / bars) + 1.5; 109 g.globalAlpha = 0.35 + 0.65 * specSmooth[i]; 110 g.beginPath(); 111 g.roundRect(x, mid - bh / 2, bw, bh, bw / 2); 112 g.fill(); 113 } 114 g.globalAlpha = 1; 115} 116 117/* ââ æ¯å¸§å¾ªç¯ ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 118function tick() { 119 const p = A.progress(); 120 setTT(plTT, A.state.playing ? 'playing' : (A.state.track ? 'cued' : 'idle'), p); 121 if (npOpen) setTT(npTT, A.state.playing ? 'playing' : (A.state.track ? 'cued' : 'idle'), p); 122 123 const peaks = A.peaksOf(A.state.track); 124 drawPeaks($('plPeaks'), peaks, p); 125 $('plCur').textContent = A.fmt(A.audioEl.currentTime); 126 $('plDur').textContent = A.fmt(A.duration()); 127 setSlider($('plPeaks'), p); 128 129 if (npOpen) { 130 drawSpectrum($('npSpectrum')); 131 const line = $('npLine'); 132 line.querySelector('.np-line-fill').style.width = (p * 100).toFixed(2) + '%'; 133 line.querySelector('.np-line-dot').style.left = (p * 100).toFixed(2) + '%'; 134 $('npCur').textContent = A.fmt(A.audioEl.currentTime); 135 $('npDur').textContent = A.fmt(A.duration()); 136 setSlider(line, p); 137 } 138 raf = requestAnimationFrame(tick); 139} 140function startLoop() { if (!raf) raf = requestAnimationFrame(tick); }
141function stopLoop() { if (raf) { cancelAnimationFrame(raf); raf = null; } } 142 143function setSlider(cv, p) { 144 if (!cv) return; 145 cv.setAttribute('aria-valuenow', String(Math.round(p * 100))); 146 cv.setAttribute('aria-valuetext', `${A.fmt(A.audioEl.currentTime)} / ${A.fmt(A.duration())}`); 147} 148 149/* ââ 徿 /ç¶æåæ¥ âââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 150function icon(useEl, id) { useEl?.setAttribute('href', '#' + id); } 151 152function syncPlayIcons() { 153 const p = A.state.playing; 154 icon($('plPlayIcon'), p ? 'i-pause' : 'i-play'); 155 icon($('npPlayIcon'), p ? 'i-pause' : 'i-play'); 156 $('plPlay').setAttribute('aria-label', t(p ? 'player.pause' : 'player.play')); 157 $('npPlay').setAttribute('aria-label', t(p ? 'player.pause' : 'player.play')); 158 if (A.state.track) document.body.dataset.playing = p ? '1' : '0'; 159} 160 161function syncLike() { 162 const id = A.state.track?.id; 163 const on = id ? likes.has(id) : false; 164 for (const b of [$('plLike'), $('npLike')]) { 165 b.classList.toggle('liked', on); 166 b.querySelector('use')?.setAttribute('href', '#i-heart'); 167 b.style.setProperty('--_f', on ? 1 : 0); 168 b.querySelector('svg').setAttribute('fill', on ? 'currentColor' : 'none'); 169 b.setAttribute('aria-label', t(on ? 'player.unlike' : 'player.like')); 170 } 171} 172 173function syncRepeat() { 174 const r = A.state.repeat; 175 const id = r === 'one' ? 'i-repeat-1' : 'i-repeat'; 176 icon($('plRepeatIcon'), id); 177 icon($('npRepeatIcon'), id); 178 for (const b of [$('plRepeat'), $('npRepeat')]) { 179 b.style.color = r === 'off' ? '' : 'var(--art)'; 180 b.setAttribute('aria-label', t('player.repeat.' + r)); 181 } 182} 183 184function syncVolume() { 185 const m = A.state.muted || A.state.volume === 0; 186 icon($('plMuteIcon'), m ? 'i-volume-x' : 'i-volume'); 187 $('plMute').setAttribute('aria-label', t(m ? 'player.unmute' : 'player.mute')); 188 $('plVol').value = String(Math.round(A.state.volume * 100)); 189} 190 191/* ââ æ²ç®åæ´ ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 192function syncTrack() { 193 const tr = A.state.track; 194 if (!tr) return; 195 const title = locField(tr, 'title'); 196 const tags = (locField(tr, 'tags') || []).join(' · '); 197 $('plTitle').textContent = title; 198 $('plTags').textContent = tags; 199 $('plLabel').src = tr.cover; 200 A.applyArt(tr.cover); 201 A.updateMediaSession(tr, title, tags); 202 document.body.dataset.playing = A.state.playing ? '1' : '0'; 203 if (!document.body.hasAttribute('data-playing')) document.body.dataset.playing = '0'; 204 if (npOpen) renderNP(); 205 syncLike(); 206 // 让å½åææ¾çå¡çæ¾ç¤ºå¤ç¯ä¸åè¡¡å¨ 207 document.querySelectorAll('.card[aria-current="true"]').forEach(c => c.setAttribute('aria-current', 'false')); 208 document.querySelectorAll(`.card[data-id="${tr.id}"]`).forEach(c => c.setAttribute('aria-current', 'true')); 209} 210 211 212/* ââ æè¯è§£æ ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 213 éè¡è§£æï¼ä¸æç©ºè¡å段ã 214 ä¹åæ /\n{2,}/ ååãåªè®¤æ¯å第ä¸è¡æ¯ç»ææ ç¾ ââ ç¨æ·æææè¯æ¶ 215 段è½ä¹é´é常ä¸ç©ºè¡ï¼äºæ¯åªæç¬¬ä¸ä¸ª [Verse 1] 被è¯å«ï¼åé¢ç [Chorus] 216 å ¨è¢«å½ææ®éæè¯ææ¬ï¼æ¢æ²¡é è²ä¹æ²¡å¤§åã 217 åæ¶å ¼å®¹æ¥å£å¶å°è¿å**åé¢é \n**ï¼ä¸¤ä¸ªå符ï¼è䏿¯çæ¢è¡çæ åµã */ 218function normalizeLyrics(raw) { 219 return String(raw || '') 220 .replace(/\r\n?/g, '\n') // CRLF / CR â LF 221 .replace(/\\r\\n|\\n|\\r/g, '\n'); // åé¢é "\n" â çæ¢è¡ 222} 223 224function renderLyrics(raw) { 225 const out = []; 226 let buf = []; 227 const flush = () => { 228 if (buf.length) { out.push(`<p class="lyr-line">${buf.map(esc).join('<br>')}</p>`); buf = []; } 229 }; 230 for (const line of normalizeLyrics(raw).split('\n')) { 231 const s = line.trim(); 232 if (!s) { flush(); continue; } 233 // è¡é¦ç [xxx] é½ç®ç»ææ ç¾ï¼æ ç¾åé¢è¥è¿ææåï¼å¦èµ·ä¸è¡å½æè¯ 234 const m = s.match(/^\[([^\]]+)\]\s*(.*)$/); 235 if (m) { 236 flush(); 237 out.push(`<p class="lyr-sec">[${esc(m[1].trim())}]</p>`); // ä¿çæ¹æ¬å·ï¼CSS 转大å 238 if (m[2]) buf.push(m[2]); 239 continue; 240 } 241 buf.push(s); 242 } 243 flush(); 244 return out.join(''); 245} 246 247/* ââ å±å¼é¢æ¿ ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 248function renderNP() { 249 const tr = A.state.track; 250 if (!tr) return; 251 revokeInputUrls(); 252 const inst = !!tr.instrumental; 253 npEl.dataset.mode = inst ? 'inst' : 'song'; 254 $('npTask').textContent = TASK_LABEL[inst ? 'instrumental' : tr.section] || TASK_LABEL.song; 255 $('npTitle').textContent = locField(tr, 'title'); 256 $('npTags').textContent = [...(locField(tr, 'tags') || []), A.fmt(A.duration())].join(' · '); 257 $('npLabel').src = tr.cover; 258 $('npBg').style.backgroundImage = `url("${tr.cover}")`; 259 260 const words = $('npWords'); 261 const parts = []; 262 const cap = locField(tr, 'caption'); 263 264 /* 峿 æ¯ä¸ä¸ªçºµåæ ï¼æè¯ï¼éé«ãå 鍿»å¨ï¼â æè¿° â è¾å ¥é³é¢ã 265 æè¯ä¸åå æ»¡æ´æ ââ å¦åæè¯ä¸é¿ï¼æè¿°åè¾å ¥é³é¢å°±è¢«æ¨å°çä¸è§çå°æ¹ã */ 266 if (!inst && tr.lyrics) { 267 parts.push(`<section class="np-lyrics"> 268 <h4>${t('np.lyrics')}</h4> 269 <div class="np-lyrics-scroll">${renderLyrics(tr.lyrics)}</div> 270 </section>`); 271 } 272 273 // æè¿°ï¼ä¸æ¾ç¤º rewritten_caption ââ 飿¯å é¨ä¿¡æ¯ï¼ä¸ç»ç¨æ·çï¼ 274 if (cap) { 275 parts.push(`<section class="np-block"><p class="k">${t('np.caption')}</p> 276 <p class="v">${esc(cap)}</p></section>`); 277 } 278 279 // è¾å ¥é³é¢ï¼ç¤ºä¾ä½åç¨éæè·¯å¾ï¼åä½åºæ¥çä½åç¨ IndexedDB éç Blob 280 const inputs = { ...(tr.inputs || {}) }; 281 if (tr.inputBlob && tr.inputKind) { 282 const url = URL.createObjectURL(tr.inputBlob); 283 inputUrls.push(url); 284 inputs[tr.inputKind] = url; 285 } 286 const keys = Object.keys(inputs).filter(k => inputs[k]); 287 if (keys.length) { 288 parts.push(`<section class="np-block"><p class="k">${t('np.input')}</p>` + keys.map(k => ` 289 <div class="np-input">
290 <span class="lab">${ICO_WAVES}${t('np.input.' + k)}</span> 291 <audio controls preload="none" src="${esc(inputs[k])}"></audio> 292 </div>`).join('') + `</section>`); 293 } 294 295 if (!parts.length) { 296 parts.push(`<p class="np-none">${t(inst ? 'np.noLyrics' : 'np.noLyricsGiven')}</p>`); 297 } 298 299 words.innerHTML = parts.join(''); 300} 301 302const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); 303 304export function openNP() { 305 if (!A.state.track) return; 306 npOpen = true; 307 renderNP(); 308 npEl.classList.add('on'); 309 npScrim.classList.add('on'); 310 $('npClose').focus(); 311} 312export function closeNP() { 313 npOpen = false; 314 npEl.classList.remove('on'); 315 npScrim.classList.remove('on'); 316 // å ³æè¾å ¥é³é¢ï¼é¿å ä¸ä¸»ææ¾å¨åæ¶åºå£° 317 npEl.querySelectorAll('audio').forEach(a => a.pause()); 318 revokeInputUrls(); 319 $('plExpand').focus(); 320} 321 322/* ââ seek äº¤äº âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 323function wireSeek(cv) { 324 if (!cv) return; 325 const at = e => { 326 const r = cv.getBoundingClientRect(); 327 A.seekRatio(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width))); 328 }; 329 cv.addEventListener('pointerdown', e => { 330 cv.setPointerCapture(e.pointerId); 331 at(e); 332 const move = ev => { if (ev.buttons) at(ev); }; 333 const up = () => { 334 cv.removeEventListener('pointermove', move); 335 cv.removeEventListener('pointerup', up); 336 }; 337 cv.addEventListener('pointermove', move); 338 cv.addEventListener('pointerup', up); 339 }); 340 cv.addEventListener('keydown', e => { 341 const d = { ArrowLeft: -5, ArrowRight: 5, Home: -1e9, End: 1e9 }[e.key]; 342 if (d == null) return; 343 e.preventDefault(); 344 A.seek(A.audioEl.currentTime + d); 345 }); 346} 347 348/* ââ åå§å ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 349export function initPlayer() { 350 wireSeek($('plPeaks')); 351 wireSeek($('npLine')); 352 353 $('plPlay').onclick = A.toggle; 354 $('npPlay').onclick = A.toggle; 355 $('plPrev').onclick = () => A.step(-1); 356 $('plNext').onclick = () => A.step(1); 357 $('npPrev').onclick = () => A.step(-1); 358 $('npNext').onclick = () => A.step(1); 359 $('plExpand').onclick = openNP; 360 $('plTT').onclick = openNP; // ç¹é»è¶ä¹å±å¼ 361 $('npClose').onclick = closeNP; 362 npScrim.onclick = closeNP; 363 364 // å ³éææ¾æ ï¼åæ¢ææ¾å¹¶æ¶èµ·æ´æ¡ 365 $('plClose').onclick = () => { 366 closeNP(); 367 A.stop(); 368 }; 369 370 const like = () => { 371 const tr = A.state.track; if (!tr) return; 372 const on = toggleLike(tr.id); 373 syncLike(); 374 toast(t(on ? 'toast.liked' : 'toast.unliked')); 375 }; 376 $('plLike').onclick = like; 377 $('npLike').onclick = like; 378 379 const dl = () => { 380 const tr = A.state.track; if (!tr) return; 381 const a = document.createElement('a'); 382 a.href = tr.blob ? URL.createObjectURL(tr.blob) : tr.audio; 383 a.download = `${locField(tr, 'title') || tr.id}.mp3`; 384 a.click(); 385 if (tr.blob) setTimeout(() => URL.revokeObjectURL(a.href), 30000); 386 toast(t('toast.downloaded')); 387 }; 388 $('plDownload').onclick = dl; 389 $('npDownload').onclick = dl; 390 391 const rep = () => { A.cycleRepeat(); syncRepeat(); toast(t('player.repeat.' + A.state.repeat)); }; 392 $('plRepeat').onclick = rep; 393 $('npRepeat').onclick = rep; 394 395 $('plMute').onclick = () => { A.setMuted(!A.state.muted); syncVolume(); }; 396 $('plVol').oninput = e => { A.setVolume(e.target.value / 100); syncVolume(); }; 397 398 A.onAudio(type => { 399 if (type === 'stop') { 400 document.body.removeAttribute('data-playing'); 401 $('plTitle').textContent = ''; 402 $('plTags').textContent = ''; 403 setTT(plTT, 'idle', 0); 404 document.querySelectorAll('.card[aria-current="true"]') 405 .forEach(c => c.setAttribute('aria-current', 'false')); 406 syncPlayIcons(); 407 document.body.removeAttribute('data-playing'); 408 return; 409 } 410 if (type === 'track') syncTrack(); 411 if (type === 'play' || type === 'pause') { syncPlayIcons(); syncTrack(); } 412 if (type === 'error') toast(t('toast.playFailed')); 413 if (type === 'volume') syncVolume(); 414 if (type === 'repeat') syncRepeat(); 415 }); 416 417 onLangChange(() => { syncPlayIcons(); syncRepeat(); syncVolume(); syncTrack(); if (npOpen) renderNP(); }); 418 419 // 页é¢åå°åå°æ¶åæç»å¶å¾ªç¯ï¼é³é¢ç»§ç»æ 420 document.addEventListener('visibilitychange', () => { 421 if (document.hidden) stopLoop(); else startLoop(); 422 }); 423 424 // é®çï¼ç¦ç¹ä¸å¨è¾å ¥æ¡æ¶æååº 425 addEventListener('keydown', e => { 426 const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName) || e.target.isContentEditable; 427 if (e.key === 'Escape' && npOpen) { closeNP(); return; } 428 if (inField || e.metaKey || e.ctrlKey || e.altKey) return; 429 switch (e.key) { 430 case ' ': if (A.state.track) { e.preventDefault();
430 A.toggle(); } break; 431 case 'ArrowLeft': if (A.state.track) { e.preventDefault(); A.seek(A.audioEl.currentTime - 5); } break; 432 case 'ArrowRight': if (A.state.track) { e.preventDefault(); A.seek(A.audioEl.currentTime + 5); } break; 433 case 'ArrowUp': e.preventDefault(); A.setVolume(A.state.volume + 0.1); syncVolume(); break; 434 case 'ArrowDown': e.preventDefault(); A.setVolume(A.state.volume - 0.1); syncVolume(); break; 435 case 'j': case 'J': A.step(-1); break; 436 case 'l': case 'L': A.step(1); break; 437 case 'm': case 'M': A.setMuted(!A.state.muted); syncVolume(); break; 438 case 'e': case 'E': if (A.state.track) (npOpen ? closeNP() : openNP()); break; 439 } 440 }); 441 442 A.setVolume(A.state.volume); 443 syncPlayIcons(); syncRepeat(); syncVolume(); 444 document.body.dataset.playing = '0'; 445 document.body.removeAttribute('data-playing'); 446 startLoop(); 447} 448 449/** ææ¾ä¸é¦ï¼ä¾é¡µé¢è°ç¨ï¼ãqueue å³å®ä¸ä¸é¦/ä¸ä¸é¦çèå´ã */ 450export function playTrack(track, queue) { 451 A.load(track, queue); 452 A.play(); 453}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.