const playIcon = ``; const pauseIcon = ``; 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 }; }