Реалізація аудіоплеєра на сайті
Нативний <audio> елемент везде працює по-різному, не піддається стилізації й не дає ніяких хуків для аналітики або розширеного UX. Кастомний плеєр будується поверх Web Audio API або готових бібліотек.
Вибір підходу
Три рівні складності:
Рівень 1 — styled audio — приховуємо нативний елемент і рисуємо кастомні контролі поверх нього. Мінімум коду, втрачаємо прогресс-бар з хвилею.
Рівень 2 — бібліотека — Wavesurfer.js, Howler.js, Plyr. Wavesurfer рисує waveform, Howler дає низькорівневий контроль з підтримкою спрайтів, Plyr — гарний UI для простих випадків.
Рівень 3 — Web Audio API напрямки — повний контроль: еквалайзер, аналізатор частот, ефекти. Використовується для музичних сервісів, платформ подкастів.
Wavesurfer.js: плеєр з візуалізацією хвилі
npm install wavesurfer.js
import WaveSurfer from 'wavesurfer.js'
import { useEffect, useRef, useState } from 'react'
interface AudioPlayerProps {
url: string
title: string
waveColor?: string
progressColor?: string
}
export function WaveAudioPlayer({
url, title,
waveColor = '#94a3b8',
progressColor = '#6366f1',
}: AudioPlayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer>()
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!containerRef.current) return
wsRef.current = WaveSurfer.create({
container: containerRef.current,
waveColor,
progressColor,
height: 64,
barWidth: 2,
barGap: 1,
barRadius: 2,
cursorWidth: 1,
cursorColor: progressColor,
normalize: true,
backend: 'WebAudio',
})
wsRef.current.load(url)
wsRef.current.on('ready', () => {
setDuration(wsRef.current!.getDuration())
setLoading(false)
})
wsRef.current.on('audioprocess', () => {
setCurrentTime(wsRef.current!.getCurrentTime())
})
wsRef.current.on('finish', () => setPlaying(false))
wsRef.current.on('error', (err) => {
console.error('WaveSurfer error:', err)
setLoading(false)
})
return () => wsRef.current?.destroy()
}, [url, waveColor, progressColor])
const togglePlay = () => {
wsRef.current?.playPause()
setPlaying(p => !p)
}
const formatTime = (sec: number) => {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
return (
<div className="audio-player">
<div className="audio-player__meta">
<span>{title}</span>
<span>{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
{loading && <div className="audio-player__loading">Завантаження...</div>}
<div ref={containerRef} className="audio-player__wave" />
<button onClick={togglePlay} disabled={loading} aria-label={playing ? 'Пауза' : 'Грати'}>
{playing ? '⏸' : '▶'}
</button>
</div>
)
}
Передгенерація peaks на сервері
Завантажувати повний аудіофайл для отрисовки хвилі — дорого для користувача. Peaks генеруємо один раз при завантаженні файлу:
// Laravel: генерація peaks через audiowaveform (C++ утиліта)
// Встановлення: apt-get install audiowaveform
use Illuminate\Support\Facades\Process;
class AudioService
{
public function generatePeaks(string $audioPath): array
{
$jsonPath = sys_get_temp_dir() . '/' . uniqid() . '.json';
$result = Process::run([
'audiowaveform',
'-i', $audioPath,
'-o', $jsonPath,
'--bits', '8',
'--pixels-per-second', '20',
]);
if ($result->failed()) {
throw new \RuntimeException('audiowaveform failed: ' . $result->errorOutput());
}
$data = json_decode(file_get_contents($jsonPath), true);
unlink($jsonPath);
return $data['data'] ?? [];
}
}
// Передаємо peaks через props — нема завантаження аудіо для отрисовки
wsRef.current = WaveSurfer.create({
container: containerRef.current,
peaks: track.peaks, // масив з БД
duration: track.duration,
// Аудіофайл завантажується тільки при натисненні Play
})
wsRef.current.on('interaction', () => {
wsRef.current!.load(url)
})
Howler.js: спрайти та управління кількома треками
npm install howler @types/howler
import { Howl, Howler } from 'howler'
// Глобальний контроль громкості
Howler.volume(0.8)
// Аудіоспрайт — один файл, кілька звуків (для ігор, UI-звуків)
const sprite = new Howl({
src: ['/sounds/ui-sounds.webm', '/sounds/ui-sounds.mp3'],
sprite: {
click: [0, 150],
success: [300, 800],
error: [1200, 500],
notification: [1800, 1200],
},
})
sprite.play('success')
// Плеєр з чергою треків
class AudioQueue {
private queue: string[] = []
private current: Howl | null = null
private currentIndex = 0
constructor(tracks: string[]) {
this.queue = tracks
}
play(index = this.currentIndex) {
this.current?.stop()
this.currentIndex = index
this.current = new Howl({
src: [this.queue[index]],
html5: true, // стрімінг для великих файлів
onend: () => this.next(),
onloaderror: (id, err) => console.error('Load error:', err),
})
this.current.play()
}
next() {
if (this.currentIndex < this.queue.length - 1) {
this.play(this.currentIndex + 1)
}
}
prev() {
if (this.currentIndex > 0) {
this.play(this.currentIndex - 1)
}
}
}
Візуалізатор частот через Web Audio API
function createFrequencyVisualizer(audioElement: HTMLAudioElement, canvas: HTMLCanvasElement) {
const ctx = new AudioContext()
const source = ctx.createMediaElementSource(audioElement)
const analyser = ctx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
analyser.connect(ctx.destination)
const bufferLength = analyser.frequencyBinCount // 128
const dataArray = new Uint8Array(bufferLength)
const canvasCtx = canvas.getContext('2d')!
function draw() {
requestAnimationFrame(draw)
analyser.getByteFrequencyData(dataArray)
canvasCtx.fillStyle = '#0f172a'
canvasCtx.fillRect(0, 0, canvas.width, canvas.height)
const barWidth = canvas.width / bufferLength * 2.5
let x = 0
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height
const hue = (i / bufferLength) * 240 + 180
canvasCtx.fillStyle = `hsl(${hue}, 70%, 60%)`
canvasCtx.fillRect(x, canvas.height - barHeight, barWidth, barHeight)
x += barWidth + 1
}
}
draw()
}
Гарячі клавіши та доступність
function setupKeyboardControls(player: WaveSurfer) {
document.addEventListener('keydown', (e) => {
// Тільки якщо фокус не в input/textarea
if (['INPUT', 'TEXTAREA'].includes((e.target as Element).tagName)) return
switch (e.code) {
case 'Space':
e.preventDefault()
player.playPause()
break
case 'ArrowLeft':
player.skip(-5)
break
case 'ArrowRight':
player.skip(5)
break
case 'ArrowUp':
player.setVolume(Math.min(1, player.getVolume() + 0.1))
break
case 'ArrowDown':
player.setVolume(Math.max(0, player.getVolume() - 0.1))
break
}
})
}
MediaSession API: інтеграція з OS і Bluetooth
function setupMediaSession(track: { title: string; artist: string; artwork: string }) {
if (!('mediaSession' in navigator)) return
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
artwork: [
{ src: track.artwork, sizes: '512x512', type: 'image/webp' },
],
})
navigator.mediaSession.setActionHandler('play', () => wavesurfer.play())
navigator.mediaSession.setActionHandler('pause', () => wavesurfer.pause())
navigator.mediaSession.setActionHandler('previoustrack', () => queue.prev())
navigator.mediaSession.setActionHandler('nexttrack', () => queue.next())
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime !== undefined) {
wavesurfer.seekTo(details.seekTime / wavesurfer.getDuration())
}
})
}
Після цього треки керуються кнопками на навушниках, на lock screen iOS/Android, у системному медіаплеєрі Windows.
Строки виконання
Простий плеєр на Plyr для одного треку — пів дня. Wavesurfer з візуалізацією, передгенерацією peaks і MediaSession API — 2–3 дні. Повноцінний музичний плеєр з чергою, плейлистами та візуалізатором — 1–1.5 тижня.







