File size: 7,464 Bytes
148af80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
const playIcon = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.3v13.4c0 .8.9 1.3 1.6.8l10.1-6.7a1 1 0 0 0 0-1.6L9.6 4.5A1 1 0 0 0 8 5.3Z"/></svg>`;
const pauseIcon = `<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4.5" height="14" rx="1"/><rect x="13.5" y="5" width="4.5" height="14" rx="1"/></svg>`;

function formatTime(value) {
  const seconds = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}

function userError(error) {
  if (error?.name === "NotAllowedError") return "再生がブラウザにより停止されました。再生ボタンをもう一度押してください。";
  if (error?.name === "NotSupportedError") return "この音声形式はブラウザで再生できません。別の音声ファイルをお試しください。";
  return "音声を再生できませんでした。音声ファイルを確認して、もう一度お試しください。";
}

/** Controls a native audio element and draws decoded mono 16 kHz samples locally. */
export function createAudioPlayer({ audio, container, canvas, playButton, seek, time, speed, onError } = {}) {
  if (!audio || !container || !canvas || !playButton || !seek || !time || !speed) {
    throw new Error("音声プレーヤーのUI要素が不足しています。");
  }

  const context = canvas.getContext("2d");
  let samples = new Float32Array();
  let animationFrame = 0;
  let lastDrawAt = 0;
  let disposed = false;
  let resizeObserver = null;

  canvas.setAttribute("role", "img");
  canvas.setAttribute("aria-label", "音声の波形と再生位置");
  seek.min = "0"; seek.step = "0.01"; seek.value = "0";
  seek.setAttribute("aria-label", "再生位置");

  function duration() { return Number.isFinite(audio.duration) ? audio.duration : 0; }
  function setState(value) { container.dataset.playerState = value; }
  function setButton(playing) {
    playButton.setAttribute("aria-label", playing ? "一時停止" : "再生");
    playButton.setAttribute("aria-pressed", String(playing));
    playButton.innerHTML = playing ? pauseIcon : playIcon;
  }
  function updateText() { time.textContent = `${formatTime(audio.currentTime)} / ${formatTime(duration())}`; }
  function updateSeek() { seek.max = String(duration()); seek.value = String(Math.min(duration(), audio.currentTime || 0)); updateText(); }

  function canvasSize() {
    const rect = canvas.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    const width = Math.max(1, Math.round(rect.width * dpr));
    const height = Math.max(1, Math.round(rect.height * dpr));
    if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
    return { width, height, dpr };
  }

  function draw() {
    const { width, height, dpr } = canvasSize();
    context.clearRect(0, 0, width, height);
    const style = getComputedStyle(container);
    const brand = style.getPropertyValue("--brand").trim() || "#7203ff";
    const muted = style.getPropertyValue("--wave-muted").trim() || "#c6c8d0";
    const binCount = Math.max(24, Math.min(140, Math.round((width / dpr) / 4)));
    const progress = duration() ? Math.max(0, Math.min(1, audio.currentTime / duration())) : 0;
    const gap = Math.max(1, Math.round(dpr));
    const barWidth = Math.max(1, (width - gap * (binCount - 1)) / binCount);
    const minHeight = Math.max(2 * dpr, height * 0.07);
    for (let bin = 0; bin < binCount; bin += 1) {
      const start = Math.floor(bin * samples.length / binCount);
      const end = Math.max(start + 1, Math.floor((bin + 1) * samples.length / binCount));
      let peak = 0;
      for (let index = start; index < end; index += 1) peak = Math.max(peak, Math.abs(samples[index] || 0));
      const barHeight = Math.max(minHeight, peak * (height * 0.9));
      const x = bin * (barWidth + gap);
      context.fillStyle = (bin + 0.5) / binCount <= progress ? brand : muted;
      context.beginPath();
      if (typeof context.roundRect === "function") context.roundRect(x, (height - barHeight) / 2, barWidth, barHeight, Math.min(barWidth / 2, 3 * dpr));
      else context.rect(x, (height - barHeight) / 2, barWidth, barHeight);
      context.fill();
    }
  }

  function animate(now) {
    if (audio.paused || disposed) { animationFrame = 0; return; }
    animationFrame = requestAnimationFrame(animate);
    if (now - lastDrawAt >= 50) { lastDrawAt = now; updateSeek(); draw(); }
  }
  function startAnimation() {
    if (!animationFrame) { lastDrawAt = 0; animationFrame = requestAnimationFrame(animate); }
  }
  function stopAnimation() { cancelAnimationFrame(animationFrame); animationFrame = 0; }

  async function togglePlay() {
    if (!audio.paused) { audio.pause(); return; }
    setState("waiting"); playButton.disabled = true;
    try { await audio.play(); }
    catch (error) { setState("error"); setButton(false); onError?.(new Error(userError(error))); }
    finally { playButton.disabled = false; }
  }

  function onMetadata() {
    container.hidden = false; updateSeek(); setButton(false); setState("ready"); draw();
  }
  function onSourceReset() {
    stopAnimation(); seek.value = "0"; time.textContent = "0:00 / 0:00"; setButton(false); setState("loading"); draw();
  }
  function onPlay() { setState("playing"); setButton(true); startAnimation(); }
  function onPause() { setState("paused"); setButton(false); stopAnimation(); updateSeek(); draw(); }
  function onEnded() { setState("ended"); setButton(false); stopAnimation(); updateSeek(); draw(); }
  function onWaiting() { setState("waiting"); }
  function onErrorEvent() { setState("error"); setButton(false); stopAnimation(); onError?.(new Error(userError(audio.error))); }

  const listeners = [
    ["loadedmetadata", onMetadata], ["durationchange", updateSeek], ["loadstart", onSourceReset], ["emptied", onSourceReset],
    ["timeupdate", () => { updateSeek(); if (audio.paused) draw(); }], ["play", onPlay], ["pause", onPause], ["ended", onEnded], ["waiting", onWaiting], ["error", onErrorEvent]
  ];
  listeners.forEach(([event, handler]) => audio.addEventListener(event, handler));

  playButton.addEventListener("click", togglePlay);
  seek.addEventListener("input", () => { const position = Number(seek.value); if (Number.isFinite(position)) { audio.currentTime = position; updateText(); draw(); } });
  speed.addEventListener("change", () => { const rate = Number(speed.value); if (Number.isFinite(rate) && rate > 0) audio.playbackRate = rate; });
  if (typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(draw); resizeObserver.observe(canvas); }

  function setSamples(value) {
    if (!(value instanceof Float32Array)) throw new TypeError("波形データはFloat32Arrayで指定してください。");
    samples = value.slice(); draw();
  }
  function dispose() {
    disposed = true; stopAnimation(); resizeObserver?.disconnect(); resizeObserver = null;
    listeners.forEach(([event, handler]) => audio.removeEventListener(event, handler));
    playButton.removeEventListener("click", togglePlay);
    audio.pause();
  }

  const initialRate = Number(speed.value); if (Number.isFinite(initialRate) && initialRate > 0) audio.playbackRate = initialRate;
  setButton(false); updateText(); setState("idle"); draw();
  return { setSamples, refreshTheme: draw, dispose };
}