Files
aigen/components/AudioPlayer.vue

220 lines
5.7 KiB
Vue

<template>
<div class="space-y-3">
<div class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80 p-3">
<canvas
ref="canvasEl"
class="h-16 w-full cursor-pointer"
height="64"
@click="onWaveClick"
/>
<input
type="range"
class="aigen-slider mt-2 w-full"
min="0"
:max="Math.max(duration, 0.01)"
step="0.01"
:value="current"
:disabled="!ready"
:aria-label="label || 'Playback'"
@input="onScrub"
>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-full border border-amber-300/40 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 disabled:opacity-50"
:disabled="!ready"
@click="toggle"
>
{{ playing ? 'Pause' : 'Play' }}
</button>
<p class="min-w-[6.5rem] text-xs tabular-nums text-zinc-400">{{ clock }}</p>
<label class="ml-auto flex items-center gap-2 text-xs text-zinc-400">
Vol
<input
v-model.number="volume"
type="range"
class="aigen-slider w-24"
min="0"
max="1"
step="0.01"
aria-label="Volume"
>
</label>
<a
v-if="src"
:href="src"
:download="downloadName"
class="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 hover:text-white"
>Download</a>
</div>
<audio
ref="audioEl"
:src="src || undefined"
preload="metadata"
class="hidden"
@loadedmetadata="onMeta"
@timeupdate="onTime"
@ended="playing = false"
@error="onError"
/>
<p v-if="error" class="text-xs text-red-300">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
src: string
downloadName?: string
label?: string
}>(), {
downloadName: 'track.flac',
label: ''
})
const audioEl = ref<HTMLAudioElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const current = ref(0)
const duration = ref(0)
const playing = ref(false)
const ready = ref(false)
const error = ref('')
const volume = ref(0.9)
const peaks = ref<number[]>([])
const clock = computed(() => `${fmt(current.value)} / ${fmt(duration.value)}`)
function fmt(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function toggle() {
const audio = audioEl.value
if (!audio) return
if (playing.value) {
audio.pause()
playing.value = false
return
}
void audio.play().then(() => { playing.value = true }).catch(() => {
error.value = 'Could not play this file'
})
}
function onMeta() {
const audio = audioEl.value
if (!audio) return
duration.value = audio.duration || 0
ready.value = true
error.value = ''
void decodeWave()
}
function onTime() {
const audio = audioEl.value
if (!audio) return
current.value = audio.currentTime || 0
draw()
}
function onScrub(event: Event) {
const audio = audioEl.value
if (!audio) return
const next = Number((event.target as HTMLInputElement).value)
audio.currentTime = next
current.value = next
draw()
}
function onWaveClick(event: MouseEvent) {
const canvas = canvasEl.value
const audio = audioEl.value
if (!canvas || !audio || !duration.value) return
const rect = canvas.getBoundingClientRect()
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
audio.currentTime = ratio * duration.value
current.value = audio.currentTime
draw()
}
function onError() {
ready.value = false
error.value = 'Could not load this audio file'
}
function draw() {
const canvas = canvasEl.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const width = canvas.clientWidth || 320
const height = canvas.height
if (canvas.width !== width) canvas.width = width
ctx.clearRect(0, 0, width, height)
const samples = peaks.value.length ? peaks.value : Array.from({ length: 80 }, () => 0.12)
const barW = Math.max(1, width / samples.length)
const progress = duration.value > 0 ? current.value / duration.value : 0
samples.forEach((amp, index) => {
const x = index * barW
const h = Math.max(2, amp * (height - 8))
const y = (height - h) / 2
ctx.fillStyle = (index / samples.length) <= progress ? '#fbbf24' : 'rgba(255,255,255,0.18)'
ctx.fillRect(x, y, Math.max(1, barW - 1), h)
})
}
async function decodeWave() {
if (!props.src || typeof window === 'undefined') return
try {
const res = await fetch(props.src)
if (!res.ok) return
const buf = await res.arrayBuffer()
const ctx = new AudioContext()
const decoded = await ctx.decodeAudioData(buf.slice(0))
await ctx.close()
const data = decoded.getChannelData(0)
const buckets = 120
const size = Math.floor(data.length / buckets) || 1
const next: number[] = []
for (let i = 0; i < buckets; i++) {
let sum = 0
const start = i * size
for (let j = 0; j < size; j++) sum += Math.abs(data[start + j] || 0)
next.push(Math.min(1, (sum / size) * 3.2))
}
peaks.value = next
draw()
} catch {
draw()
}
}
watch(() => props.src, () => {
current.value = 0
duration.value = 0
playing.value = false
ready.value = false
error.value = ''
peaks.value = []
draw()
})
watch(volume, (value) => {
if (audioEl.value) audioEl.value.volume = value
})
onMounted(() => {
if (audioEl.value) audioEl.value.volume = volume.value
draw()
window.addEventListener('resize', draw)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', draw)
audioEl.value?.pause()
})
</script>