feat(renderer): audio waveform player with WaveSurfer.js (M10.8)

- Lazy-loads WaveSurfer.js on first audio render
- Bitcoin orange waveform on dark background
- Click-to-seek, play/pause, time display
- Responsive width, touch-friendly

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 23:58:39 +00:00
co-authored by Claude Opus 4.6
parent 7c6a0565d5
commit 9404085668
3 changed files with 121 additions and 1 deletions
+2 -1
View File
@@ -29,7 +29,8 @@
"pinia": "^3.0.4",
"plyr": "^3.8.4",
"vue": "^3.5.29",
"vue-router": "^5.0.3"
"vue-router": "^5.0.3",
"wavesurfer.js": "^7.12.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -0,0 +1,111 @@
<template>
<div class="audio-waveform rounded-xl bg-white/5 border border-white/10 overflow-hidden">
<div class="px-4 py-3 flex items-center gap-3">
<!-- Play/Pause -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full bg-accent/15 text-accent hover:bg-accent/25 transition-colors shrink-0"
:disabled="loading"
@click="togglePlay"
>
<svg v-if="!isPlaying" class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75A.75.75 0 007.25 3h-1.5zM12.75 3a.75.75 0 00-.75.75v12.5c0 .414.336.75.75.75h1.5a.75.75 0 00.75-.75V3.75a.75.75 0 00-.75-.75h-1.5z" />
</svg>
</button>
<!-- Waveform container -->
<div class="flex-1 min-w-0">
<div ref="waveformRef" class="waveform-container" />
<div class="flex justify-between mt-1">
<span class="text-[10px] text-white/30 tabular-nums">{{ formatTime(currentTime) }}</span>
<span class="text-[10px] text-white/30 tabular-nums">{{ formatTime(duration) }}</span>
</div>
</div>
</div>
<div v-if="title" class="px-4 pb-3 -mt-1">
<p class="text-xs text-white/60 truncate">{{ title }}</p>
</div>
<div v-if="loading" class="px-4 pb-3">
<p class="text-[10px] text-white/30">Loading audio...</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, shallowRef } from 'vue'
const props = defineProps<{
url: string
title?: string
}>()
const waveformRef = ref<HTMLElement | null>(null)
const isPlaying = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const loading = ref(true)
type WaveSurferInstance = import('wavesurfer.js').default
const ws = shallowRef<WaveSurferInstance | null>(null)
async function initWaveSurfer() {
if (!waveformRef.value) return
const WaveSurfer = (await import('wavesurfer.js')).default
const instance = WaveSurfer.create({
container: waveformRef.value,
waveColor: '#F7931A44',
progressColor: '#F7931A',
cursorColor: '#F7931Acc',
barWidth: 2,
barGap: 1,
barRadius: 2,
height: 48,
normalize: true,
url: props.url,
backend: 'WebAudio',
})
instance.on('ready', () => {
loading.value = false
duration.value = instance.getDuration()
})
instance.on('timeupdate', (time: number) => {
currentTime.value = time
})
instance.on('play', () => { isPlaying.value = true })
instance.on('pause', () => { isPlaying.value = false })
instance.on('finish', () => { isPlaying.value = false })
instance.on('error', () => {
loading.value = false
})
ws.value = instance
}
function togglePlay() {
ws.value?.playPause()
}
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
onMounted(() => {
initWaveSurfer()
})
onBeforeUnmount(() => {
ws.value?.destroy()
})
</script>
+8
View File
@@ -54,6 +54,9 @@ importers:
vue-router:
specifier: ^5.0.3
version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.8.3)(vue@3.5.29(typescript@5.8.3)))(vue@3.5.29(typescript@5.8.3))
wavesurfer.js:
specifier: ^7.12.1
version: 7.12.1
devDependencies:
'@eslint/js':
specifier: ^10.0.1
@@ -3419,6 +3422,9 @@ packages:
typescript:
optional: true
wavesurfer.js@7.12.1:
resolution: {integrity: sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==}
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@@ -7028,6 +7034,8 @@ snapshots:
optionalDependencies:
typescript: 5.8.3
wavesurfer.js@7.12.1: {}
webidl-conversions@4.0.2: {}
webpack-virtual-modules@0.6.2: {}