PageSourceSearch

https://profjasq-stepaudio-3-music.hf.space/studio/assets/js/player.js

js profjasq-stepaudio-3-music.hf.space collected 2026-10-03 17:14:27 UTC 18,693 bytes, 453 lines download raw bytes

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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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.