Bug description
For example, with a 33-second audio clip, if playback reaches around the 31-second mark and you attempt to seek, it always automatically jumps back to the beginning and starts playing from the start. Even using the ws.play(start, end) method no longer works as expected.
https://github.com/user-attachments/assets/da3debc0-e9de-4788-8978-08abd47d8679
`
当前:{{ formatTime(currentTime) }} / 总时长:{{ formatTime(duration) }}
{{ rateLabel }}
0.5x
1x
1.5x
2x
<script setup>
import { ref, reactive, computed, onMounted, nextTick, onUnmounted } from 'vue'
import WaveSurfer from 'wavesurfer.js'
import Hover from 'wavesurfer.js/dist/plugins/hover.js'
import Timeline from 'wavesurfer.js/dist/plugins/timeline.js'
import Minimap from 'wavesurfer.js/dist/plugins/minimap.js'
import Regions from 'wavesurfer.js/dist/plugins/regions.js'
import { ElMessage } from 'element-plus'
import { string } from 'vue-types'
import { VideoPause, VideoPlay } from '@element-plus/icons-vue'
import { formatTime } from './utils'
defineOptions({ name: 'AudioCut' })
const props = defineProps({
src: {
type: String,
required: true,
default: ''
},
canEditOrNot: {
type: Boolean,
default: true
}
})
// 或者:声明带选项的 "modelValue" prop
const regions = defineModel()
const waveformRef = ref(null)
const timelineRef = ref(null)
const minimapRef = ref(null)
const ws = ref() // wavesurfer实例
const isPlay = ref(false) // 是否播放
const isLoop = ref(false) // 是否循环播放
const duration = ref(0) // 音频时长
const loading = ref(false) // 加载中
const rate = ref(1) // 播放速度
const rateLabel = computed(() => rate.value + 'x') // 播放速度标签
const pxPerSec = ref(10) // 每秒像素数
const currentTime = ref(0) // 当前播放时间
const currentRegion = ref(null) // 当前播放的区域
const currentClickedRegion = ref(null) // 当前点击的区域 用于计算指针在当前区域的位置
const wsRegions = Regions.create({
// 区域插件
dragSelection: { slop: 3 }, // 允许鼠标拖拽
maxRegions: 10, // 区域数量限制
removeButton: true // 显示删除按钮
})
/* ====== 历史记录与撤销 ====== */
const lastSnapshot = ref(null)
const isUndoing = ref(false)
const setLastSnapshot = (arr) => {
if (isUndoing.value) return
lastSnapshot.value = JSON.parse(JSON.stringify(arr))
}
const undo = () => {
if (!props.canEditOrNot || !lastSnapshot.value) return
isUndoing.value = true
wsRegions.clearRegions()
lastSnapshot.value.forEach((r) => {
wsRegions.addRegion({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
loop: true,
drag: props.canEditOrNot,
resize: props.canEditOrNot,
content: getRegionContent(r.id)
})
})
regions.value = lastSnapshot.value
lastSnapshot.value = null
isUndoing.value = false
}
const handleKeydown = (e) => {
// 检查焦点是否在输入框或文本区域
const activeElement = document.activeElement
const isInput =
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable)
if (isInput) return
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault()
undo()
}
}
/* ====== 生命周期 ====== */
onMounted(() => {
loading.value = true
window.addEventListener('keydown', handleKeydown)
nextTick(() => initWaveSurfer()) // 保证 DOM 已渲染
})
/* ====== 初始化 ====== */
function initWaveSurfer() {
const container = waveformRef.value
ws.value = WaveSurfer.create({
url: props.src,
barRadius: 5,
barWidth: 3,
barGap: 1,
container: container, // 音频容器
waveColor: '#7793a3', // 主波形颜色
progressColor: '#057373', // 进度条颜色
cursorColor: 'rgb(0,185,255)', // 光标颜色
cursorWidth: 1, // 光标宽度
height: 70, // 音频高度
width: '100%',
responsive: true, // 自适应
scrollParent: true, // 滚动父级
minPxPerSec: pxPerSec.value, // 每秒像素数
fillParent: false, // 填充父级
interaction: true, // 允许用户交互
plugins: (() => {
const arr = [Timeline.create({ container: timelineRef.value }), wsRegions]
arr.unshift(
Hover.create({
lineColor: 'rgb(23,171,225)',
lineWidth: 2,
labelBackground: '
#555',
labelColor: '#fff',
labelSize: '11px'
})
)
arr.push(
Minimap.create({
container: minimapRef.value,
waveColor: '#7793a3',
progressColor: '#3B8686',
height: 30,
waveShadowColor: 'rgb(0,183,200)',
waveStyle: 'bar',
overlayColor: 'rgba(202,202,202,0.73)'
})
)
return arr
})()
})
/* ===== 主波形图事件触发 ====== */
// 准备就绪时触发
ws.value.on('ready', () => {
/* ===== 1、获取音频时长 ====== */
duration.value = ws.value.getDuration()
regions.value.forEach((region) => {
wsRegions.addRegion({
id: region.id,
start: region.start, // 秒
end: region.end,
color: region.color, // 覆盖颜色
loop: true, // 是否循环
drag: props.canEditOrNot, // 能否拖动
resize: props.canEditOrNot, // 能否拉边缘
content: getRegionContent(region.id)
})
})
wsRegions.on('region-created', (region) => {
if (!props.canEditOrNot) return
const currentList = wsRegions.getRegions()
const prevPlain = currentList
.filter((r) => r.id !== region.id)
.map((r) => ({ id: r.id, start: r.start, end: r.end, color: r.color }))
setLastSnapshot(prevPlain)
regions.value = currentList
syncRegions()
})
wsRegions.on('region-update', () => {
if (!props.canEditOrNot) return
regions.value = wsRegions.getRegions()
syncRegions()
})
wsRegions.on('region-removed', (region) => {
if (!props.canEditOrNot) return
const currentList = wsRegions.getRegions()
const prevPlain = [
...currentList.map((r) => ({ id: r.id, start: r.start, end: r.end, color: r.color })),
{ id: region.id, start: region.start, end: region.end, color: region.color }
]
setLastSnapshot(prevPlain)
regions.value = currentList
syncRegions()
})
/* ===== 2、鼠标滚动逻辑 ====== */
container.addEventListener(
'wheel',
(e) => {
/* ---- 缩放逻辑 ---- */
if (e.ctrlKey) {
e.preventDefault() // 关键:阻止浏览器页面缩放
e.stopPropagation()
const rect = container.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width // 0~1
const delta = e.deltaY < 0 ? 1.15 : 0.87
const next = Math.max(10, Math.min(2000, (pxPerSec.value ?? 20) * delta))
pxPerSec.value = next
ws.value.zoom(next, x) // 真正让波形缩放
//TODO: 实现缩放逻辑居中,基于鼠标选中的时间点
} else {
/* ---- 滚动逻辑 ---- */
e.preventDefault()
//TODO: 实现左右滚动逻辑
}
},
{ passive: false }
) // passive:false 才能 preventDefault
loading.value = false
})
/* ===== 3、区域获取逻辑 ====== */
ws.value.on('dblclick', () => {
if (!props.canEditOrNot) {
return
}
// 获取双击时的选中时间
const time = ws.value.getCurrentTime()
const prefix = getNextRegionPrefix()
// 根据时间创建区域
wsRegions.addRegion({
id: prefix + '-' + Date.now(),
start: time, // 秒
end: time + 8,
color: 'rgb(255,64,64,.3)', // 覆盖颜色
loop: true, // 是否循环
drag: true, // 能否拖动
resize: true, // 能否拉边缘
content: getRegionContent(prefix + '-' + Date.now())
})
regions.value = wsRegions.getRegions()
syncRegions()
})
// 错误监听,出现错误时触发
ws.value.on('error', (error) => {
console.error('WaveSurfer error:', error)
loading.value = false
// ElMessage.error('音频播放器错误: ' + error)
})
// 开始播放时触发
ws.value.on('play', () => {
isPlay.value = true
})
// 暂停播放时触发
ws.value.on('pause', () => {
isPlay.value = false
})
// 音频进度监听
ws.value.on('audioprocess', () => {
currentTime.value = ws.value.getCurrentTime()
})
// 音频结束时触发 用于循环播放
ws.value.on('finish', () => {
// 如果开启了循环播放且有当前区域,则重新播放当前区域
if (isLoop.value) {
isPlay.value = false
if (currentRegion.value === null) {
// 播放全部
setTimeout(() => {
ws.value.play()
isPlay.value = true
}, 300) // 短暂延迟后重新播放
} else if (currentRegion.value) {
// 播放当前区域 这里就是音频段最后的区域
setTimeout(() => {
ws.value.play(currentRegion.value.start, currentRegion.value.end)
isPlay.value = true
}, 300) // 短暂延迟后重新播放
}
}
})
// 添加区域播放结束事件监听 用于循环播放
wsRegions.on('region-out', (region) => {
// 如果开启了循环播放且有当前区域,则重新播放当前区域
if (isLoop.value && currentRegion.value) {
if (region.id === currentRegion.value.audioClipId) {
isPlay.value = false
setTimeout(() => {
ws.value.play(currentRegion.value.start, currentRegion.value.end)
isPlay.value = true
}, 300) // 短暂延迟后重新播放
}
}
})
// 添加区域播放结束事件监听 用于循环播放
wsRegions.on('region-clicked', (region) => {
currentClickedRegion.value = region
})
}
/* ===== 2、主音频播放控制区 ===== */
// 切换播放状态
function togglePlay() {
if (ws.value) {
currentRegion.value = null
ws.value.playPause()
}
}
// 设置播放速度
function changeSpeed(val) {
rate.value = val
if (ws.value) {
ws.value.setPlaybackRate(val)
}
}
/* ====== 4、区域操作 ====== */
//更新区域信息
function syncRegions() {
regions.value = wsRegions.getRegions().map((r) => ({
id: r.id,
start: r.start,
end: r.end,
duration: r.end - r.start,
color: r.color
}))
}
// 删除区域
function removeRegion(region) {
wsRegions.getRegions().forEach((r) => {
if (r.id === region.id) r.remove() // 删除区域
})
syncRegions()
}
// 区域播放控制
function togglePlayRegion(region) {
if (isPlay.value) {
// 如果正在播放,暂停播放
ws.value.pause()
isPlay.value = false
currentRegion.value = null
} else {
// 如果没有播放,检查是否已经播放过这个区域
const currentPosition = ws.value.getCurrentTime()
currentRegion.value = region // 设置当前播放区域
// 如果当前播放位置在该区域范围内,则从当前位置继续播放
if (currentPosition >= region.start && currentPosition < region.end) {
ws.value.play(currentPosition, region.end)
} else {
// 否则从区域开始位置播放
ws.value.play(region.start, region.end)
}
isPlay.value = true
}
}
// 监听播放状态变化
watch(isPlay, (newVal, oldVal) => {
// 可以在这里添加其他逻辑,比如触发事件等
isPlayChange(newVal)
})
// 暴露给父组件的函数 用于监听播放状态变化
const emits = defineEmits(['isPlayChange'])
const isPlayChange = (val) => {
emits('isPlayChange', val)
}
// 获取下一个区域前缀的函数
const getNextRegionPrefix = () => {
const count = regions.value ? regions.value.length : 0
const letterIndex = Math.floor(count / 9999)
const letter = String.fromCharCode(65 + letterIndex)
const number = String((count % 9999) + 1).padStart(4, '0')
return `${letter}-${number}`
}
// 添加区域标题
const getRegionContent = (text) => {
const element = document.createElement('span')
element.textContent = text
element.style.fontSize = 'small'
element.style.marginLeft = '5px'
return element
}
const getCurrentTime = () => {
const currentTime = ws.value.getCurrentTime()
if (currentClickedRegion.value == null) {
return null
}
let time = currentTime - currentClickedRegion.value.start
return formatTime(time)
}
defineExpose({ togglePlayRegion, removeRegion, getCurrentTime })
/* ====== 释放内存 ====== */
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
ws.value?.destroy() // 销毁播放器
})
</script>
<style scoped lang="scss">
/* 自定义区域边界线样式 */
#waveform ::part(region) {
border-left: 1px solid rgba(112, 31, 31, 0.3);
border-right: 1px solid rgba(112, 31, 31, 0.3);
}
</style>
`
Environment
- Browser: Chrome
- Version: 7.10.1
Minimal code snippet
Expected result
Obtained result
Screenshots
