<template>
|
<div class="audio-player">
|
<!-- 主播放器 -->
|
<div class="audio-container" v-if="showDefaultPlayer">
|
<audio
|
ref="audioElement"
|
:src="audioSource"
|
preload="metadata"
|
@timeupdate="updateTime"
|
@loadedmetadata="updateDuration"
|
@play="handlePlay"
|
@pause="handlePause"
|
@ended="handleEnded"
|
@error="handleError"
|
style="display: none"
|
></audio>
|
|
<!-- 播放控制 -->
|
<div class="audio-controls">
|
<el-button
|
v-if="!isPlaying"
|
type="text"
|
icon="el-icon-video-play"
|
class="play-btn"
|
@click="playAudio"
|
:loading="loading"
|
></el-button>
|
<el-button
|
v-else
|
type="text"
|
icon="el-icon-video-pause"
|
class="pause-btn"
|
@click="pauseAudio"
|
></el-button>
|
|
<!-- 进度条 -->
|
<div class="progress-container" @click="seekAudio" @mousemove="updateHoverTime">
|
<div class="progress-background">
|
<!-- 缓冲进度 -->
|
<div
|
v-if="buffered.length > 0"
|
class="buffered-progress"
|
:style="{ width: `${bufferedPercent}%` }"
|
></div>
|
|
<!-- 播放进度 -->
|
<div
|
class="played-progress"
|
:style="{ width: `${progressPercent}%` }"
|
></div>
|
|
<!-- 悬停预览 -->
|
<div
|
v-if="showHoverPreview"
|
class="hover-preview"
|
:style="{ left: `${hoverPercent}%` }"
|
>
|
<span class="hover-time">{{ formatTime(hoverTime) }}</span>
|
</div>
|
|
<!-- 播放点 -->
|
<div
|
class="playhead"
|
:style="{ left: `${progressPercent}%` }"
|
></div>
|
</div>
|
</div>
|
|
<!-- 时间显示 -->
|
<div class="time-display">
|
<span class="current-time">{{ formatTime(currentTime) }}</span>
|
<span class="time-separator">/</span>
|
<span class="duration">{{ formatTime(duration) }}</span>
|
</div>
|
|
<!-- 音量控制 -->
|
<div class="volume-control" v-if="showVolumeControl">
|
<el-popover
|
placement="top"
|
width="40"
|
trigger="hover"
|
popper-class="volume-popover"
|
>
|
<div class="volume-slider-container" @click.stop>
|
<el-slider
|
v-model="volume"
|
vertical
|
height="100px"
|
:show-tooltip="false"
|
@input="changeVolume"
|
></el-slider>
|
</div>
|
|
<el-button
|
slot="reference"
|
type="text"
|
:icon="volumeIcon"
|
class="volume-btn"
|
@click.stop
|
></el-button>
|
</el-popover>
|
</div>
|
|
<!-- 播放速度 -->
|
<el-select
|
v-if="showPlaybackRate"
|
v-model="playbackRate"
|
size="mini"
|
class="playback-rate"
|
@change="changePlaybackRate"
|
>
|
<el-option
|
v-for="rate in playbackRates"
|
:key="rate"
|
:label="`${rate}x`"
|
:value="rate"
|
></el-option>
|
</el-select>
|
|
<!-- 下载按钮 -->
|
<el-button
|
v-if="showDownload"
|
type="text"
|
icon="el-icon-download"
|
class="download-btn"
|
@click="downloadAudio"
|
title="下载音频"
|
></el-button>
|
|
<!-- 更多功能按钮 -->
|
<el-dropdown
|
v-if="showMoreOptions"
|
trigger="click"
|
class="more-options"
|
>
|
<el-button type="text" icon="el-icon-more" class="more-btn"></el-button>
|
<el-dropdown-menu slot="dropdown">
|
<el-dropdown-item @click.native="loopAudio = !loopAudio">
|
<i :class="loopAudio ? 'el-icon-refresh' : 'el-icon-refresh-left'"></i>
|
{{ loopAudio ? '关闭循环' : '开启循环' }}
|
</el-dropdown-item>
|
<el-dropdown-item @click.native="resetAudio">
|
<i class="el-icon-refresh-right"></i>
|
重置
|
</el-dropdown-item>
|
<el-dropdown-item v-if="showDetails" @click.native="showAudioInfo">
|
<i class="el-icon-info"></i>
|
音频信息
|
</el-dropdown-item>
|
</el-dropdown-menu>
|
</el-dropdown>
|
</div>
|
</div>
|
|
<!-- 音频信息对话框 -->
|
<el-dialog
|
v-if="showDetailsDialog"
|
title="音频信息"
|
:visible.sync="showDetailsDialog"
|
width="400px"
|
>
|
<div class="audio-info">
|
<div class="info-item">
|
<span class="label">文件大小:</span>
|
<span class="value">{{ fileSize }}</span>
|
</div>
|
<div class="info-item">
|
<span class="label">音频时长:</span>
|
<span class="value">{{ formatTime(duration) }}</span>
|
</div>
|
<div class="info-item">
|
<span class="label">文件格式:</span>
|
<span class="value">{{ audioFormat }}</span>
|
</div>
|
<div class="info-item">
|
<span class="label">音频地址:</span>
|
<a :href="audioSource" target="_blank" class="audio-url">{{ truncateUrl(audioSource) }}</a>
|
</div>
|
</div>
|
</el-dialog>
|
|
<!-- 简易播放器(仅显示播放按钮) -->
|
<div v-else class="simple-player">
|
<el-button
|
v-if="!isPlaying"
|
type="text"
|
icon="el-icon-video-play"
|
size="small"
|
class="simple-play-btn"
|
@click="playAudio"
|
:loading="loading"
|
>
|
播放录音
|
</el-button>
|
<el-button
|
v-else
|
type="text"
|
icon="el-icon-video-pause"
|
size="small"
|
class="simple-pause-btn"
|
@click="pauseAudio"
|
>
|
暂停播放
|
</el-button>
|
</div>
|
</div>
|
</template>
|
|
<script>
|
export default {
|
name: 'AudioPlayer',
|
props: {
|
// 音频源地址
|
audioSource: {
|
type: String,
|
default: ''
|
},
|
|
// 是否显示完整播放器
|
showDefaultPlayer: {
|
type: Boolean,
|
default: true
|
},
|
|
// 是否显示音量控制
|
showVolumeControl: {
|
type: Boolean,
|
default: true
|
},
|
|
// 是否显示播放速度控制
|
showPlaybackRate: {
|
type: Boolean,
|
default: true
|
},
|
|
// 是否显示下载按钮
|
showDownload: {
|
type: Boolean,
|
default: true
|
},
|
|
// 是否显示更多选项
|
showMoreOptions: {
|
type: Boolean,
|
default: true
|
},
|
|
// 是否显示音频信息
|
showDetails: {
|
type: Boolean,
|
default: true
|
},
|
|
// 初始音量(0-100)
|
initialVolume: {
|
type: Number,
|
default: 80,
|
validator: (value) => value >= 0 && value <= 100
|
},
|
|
// 初始播放速度
|
initialPlaybackRate: {
|
type: Number,
|
default: 1.0
|
},
|
|
// 自动播放
|
autoplay: {
|
type: Boolean,
|
default: false
|
},
|
|
// 循环播放
|
loop: {
|
type: Boolean,
|
default: false
|
}
|
},
|
|
data() {
|
return {
|
// 播放状态
|
isPlaying: false,
|
isLoading: false,
|
loading: false,
|
error: false,
|
|
// 时间相关
|
currentTime: 0,
|
duration: 0,
|
buffered: [],
|
|
// 音量
|
volume: this.initialVolume,
|
isMuted: false,
|
|
// 播放速度
|
playbackRate: this.initialPlaybackRate,
|
playbackRates: [0.5, 0.75, 1.0, 1.25, 1.5, 2.0],
|
|
// 循环播放
|
loopAudio: this.loop,
|
|
// 进度条悬停
|
hoverTime: 0,
|
hoverPercent: 0,
|
showHoverPreview: false,
|
|
// 音频信息
|
showDetailsDialog: false,
|
fileSize: '未知',
|
audioFormat: '未知',
|
|
// 监听器引用
|
resizeObserver: null
|
};
|
},
|
|
computed: {
|
// 播放进度百分比
|
progressPercent() {
|
if (this.duration <= 0) return 0;
|
return (this.currentTime / this.duration) * 100;
|
},
|
|
// 缓冲进度百分比
|
bufferedPercent() {
|
if (this.duration <= 0) return 0;
|
if (this.buffered.length === 0) return 0;
|
|
const bufferedEnd = this.buffered.end(this.buffered.length - 1);
|
return (bufferedEnd / this.duration) * 100;
|
},
|
|
// 音量图标
|
volumeIcon() {
|
if (this.isMuted || this.volume === 0) {
|
return 'el-icon-turn-off-microphone';
|
} else if (this.volume <= 30) {
|
return 'el-icon-microphone';
|
} else if (this.volume <= 70) {
|
return 'el-icon-microphone';
|
} else {
|
return 'el-icon-microphone';
|
}
|
}
|
},
|
|
watch: {
|
// 监听音频源变化
|
audioSource(newSource) {
|
if (newSource) {
|
this.resetAudio();
|
this.loadAudio();
|
}
|
},
|
|
// 监听自动播放
|
autoplay(newVal) {
|
if (newVal && this.audioSource) {
|
this.playAudio();
|
}
|
},
|
|
// 监听循环播放
|
loopAudio(newVal) {
|
this.$emit('loop-change', newVal);
|
}
|
},
|
|
mounted() {
|
if (this.audioSource) {
|
this.loadAudio();
|
}
|
|
// 监听窗口大小变化
|
this.resizeObserver = new ResizeObserver(() => {
|
this.updateBuffered();
|
});
|
|
if (this.$refs.audioElement) {
|
this.resizeObserver.observe(this.$refs.audioElement);
|
}
|
},
|
|
beforeDestroy() {
|
if (this.resizeObserver) {
|
this.resizeObserver.disconnect();
|
}
|
|
// 停止音频播放
|
this.pauseAudio();
|
},
|
|
methods: {
|
// 加载音频
|
async loadAudio() {
|
this.loading = true;
|
this.error = false;
|
|
try {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
// 设置音量
|
audio.volume = this.volume / 100;
|
audio.playbackRate = this.playbackRate;
|
audio.loop = this.loopAudio;
|
|
// 尝试获取文件信息
|
this.getAudioInfo();
|
|
// 如果设置了自动播放,开始播放
|
if (this.autoplay) {
|
await this.playAudio();
|
}
|
} catch (error) {
|
console.error('加载音频失败:', error);
|
this.error = true;
|
this.$emit('error', error);
|
} finally {
|
this.loading = false;
|
}
|
},
|
|
// 播放音频
|
async playAudio() {
|
try {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
await audio.play();
|
this.isPlaying = true;
|
this.$emit('play');
|
} catch (error) {
|
console.error('播放失败:', error);
|
this.error = true;
|
this.isPlaying = false;
|
this.$emit('error', error);
|
|
// 如果是用户交互引起的错误,提示用户
|
if (error.name === 'NotAllowedError') {
|
this.$message.warning('请手动点击播放按钮开始播放');
|
}
|
}
|
},
|
|
// 暂停音频
|
pauseAudio() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
audio.pause();
|
this.isPlaying = false;
|
this.$emit('pause');
|
},
|
|
// 跳转到指定时间
|
seekAudio(event) {
|
const audio = this.$refs.audioElement;
|
if (!audio || this.duration <= 0) return;
|
|
const progressContainer = event.currentTarget;
|
const rect = progressContainer.getBoundingClientRect();
|
const x = event.clientX - rect.left;
|
const width = rect.width;
|
|
const percent = Math.max(0, Math.min(1, x / width));
|
const time = percent * this.duration;
|
|
audio.currentTime = time;
|
this.currentTime = time;
|
this.$emit('seek', time);
|
},
|
|
// 更新悬停时间
|
updateHoverTime(event) {
|
const progressContainer = event.currentTarget;
|
const rect = progressContainer.getBoundingClientRect();
|
const x = event.clientX - rect.left;
|
const width = rect.width;
|
|
const percent = Math.max(0, Math.min(1, x / width));
|
this.hoverPercent = percent * 100;
|
this.hoverTime = percent * this.duration;
|
this.showHoverPreview = true;
|
},
|
|
// 隐藏悬停预览
|
hideHoverPreview() {
|
this.showHoverPreview = false;
|
},
|
|
// 更新时间
|
updateTime() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
this.currentTime = audio.currentTime;
|
this.$emit('timeupdate', this.currentTime);
|
},
|
|
// 更新总时长
|
updateDuration() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
this.duration = audio.duration;
|
this.updateBuffered();
|
this.$emit('loadedmetadata', this.duration);
|
},
|
|
// 更新缓冲数据
|
updateBuffered() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
this.buffered = audio.buffered;
|
},
|
|
// 处理播放
|
handlePlay() {
|
this.isPlaying = true;
|
this.$emit('play');
|
},
|
|
// 处理暂停
|
handlePause() {
|
this.isPlaying = false;
|
this.$emit('pause');
|
},
|
|
// 处理播放结束
|
handleEnded() {
|
this.isPlaying = false;
|
this.currentTime = 0;
|
this.$emit('ended');
|
},
|
|
// 处理错误
|
handleError(event) {
|
console.error('音频播放错误:', event);
|
this.error = true;
|
this.isPlaying = false;
|
this.$emit('error', event);
|
},
|
|
// 改变音量
|
changeVolume(value) {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
audio.volume = value / 100;
|
this.volume = value;
|
this.isMuted = value === 0;
|
this.$emit('volume-change', value);
|
},
|
|
// 静音/取消静音
|
toggleMute() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
this.isMuted = !this.isMuted;
|
audio.muted = this.isMuted;
|
this.$emit('mute-change', this.isMuted);
|
},
|
|
// 改变播放速度
|
changePlaybackRate(rate) {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
audio.playbackRate = rate;
|
this.playbackRate = rate;
|
this.$emit('playbackrate-change', rate);
|
},
|
|
// 重置音频
|
resetAudio() {
|
const audio = this.$refs.audioElement;
|
if (!audio) return;
|
|
audio.currentTime = 0;
|
this.currentTime = 0;
|
this.pauseAudio();
|
this.$emit('reset');
|
},
|
|
// 下载音频
|
downloadAudio() {
|
if (!this.audioSource) {
|
this.$message.warning('音频地址无效');
|
return;
|
}
|
|
const link = document.createElement('a');
|
link.href = this.audioSource;
|
link.download = this.getFileNameFromUrl(this.audioSource);
|
document.body.appendChild(link);
|
link.click();
|
document.body.removeChild(link);
|
|
this.$emit('download');
|
},
|
|
// 显示音频信息
|
async showAudioInfo() {
|
this.showDetailsDialog = true;
|
await this.getAudioInfo();
|
},
|
|
// 获取音频信息
|
async getAudioInfo() {
|
if (!this.audioSource) return;
|
|
try {
|
// 获取文件大小
|
const response = await fetch(this.audioSource, { method: 'HEAD' });
|
const contentLength = response.headers.get('content-length');
|
|
if (contentLength) {
|
this.fileSize = this.formatFileSize(contentLength);
|
}
|
|
// 获取文件格式
|
const url = this.audioSource.toLowerCase();
|
if (url.endsWith('.mp3')) this.audioFormat = 'MP3';
|
else if (url.endsWith('.wav')) this.audioFormat = 'WAV';
|
else if (url.endsWith('.ogg')) this.audioFormat = 'OGG';
|
else if (url.endsWith('.m4a')) this.audioFormat = 'M4A';
|
else if (url.endsWith('.aac')) this.audioFormat = 'AAC';
|
else this.audioFormat = '未知格式';
|
|
} catch (error) {
|
console.error('获取音频信息失败:', error);
|
this.fileSize = '未知';
|
this.audioFormat = '未知';
|
}
|
},
|
|
// 格式化时间
|
formatTime(time) {
|
if (isNaN(time) || time < 0) return '00:00';
|
|
const hours = Math.floor(time / 3600);
|
const minutes = Math.floor((time % 3600) / 60);
|
const seconds = Math.floor(time % 60);
|
|
if (hours > 0) {
|
return `${hours.toString().padStart(2, '0')}:${minutes
|
.toString()
|
.padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
} else {
|
return `${minutes.toString().padStart(2, '0')}:${seconds
|
.toString()
|
.padStart(2, '0')}`;
|
}
|
},
|
|
// 格式化文件大小
|
formatFileSize(bytes) {
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
},
|
|
// 截断URL显示
|
truncateUrl(url, maxLength = 40) {
|
if (!url) return '';
|
if (url.length <= maxLength) return url;
|
|
const start = url.substring(0, maxLength / 2 - 3);
|
const end = url.substring(url.length - maxLength / 2 + 3);
|
return start + '...' + end;
|
},
|
|
// 从URL获取文件名
|
getFileNameFromUrl(url) {
|
if (!url) return 'audio';
|
|
const fileName = url.split('/').pop();
|
return fileName || 'audio';
|
}
|
}
|
};
|
</script>
|
|
<style lang="scss" scoped>
|
.audio-player {
|
width: 100%;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
.audio-container {
|
background: #fff;
|
border-radius: 8px;
|
padding: 12px 16px;
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
border: 1px solid #ebeef5;
|
|
.audio-controls {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
|
.play-btn,
|
.pause-btn {
|
width: 36px;
|
height: 36px;
|
padding: 0;
|
font-size: 20px;
|
color: #409EFF;
|
|
&:hover {
|
color: #66b1ff;
|
}
|
|
&:active {
|
color: #3a8ee6;
|
}
|
}
|
|
.progress-container {
|
flex: 1;
|
height: 36px;
|
display: flex;
|
align-items: center;
|
cursor: pointer;
|
|
.progress-background {
|
position: relative;
|
width: 100%;
|
height: 6px;
|
background: #e4e7ed;
|
border-radius: 3px;
|
overflow: visible;
|
|
.buffered-progress {
|
position: absolute;
|
top: 0;
|
left: 0;
|
height: 100%;
|
background: #c0c4cc;
|
border-radius: 3px;
|
transition: width 0.2s ease;
|
}
|
|
.played-progress {
|
position: absolute;
|
top: 0;
|
left: 0;
|
height: 100%;
|
background: #409EFF;
|
border-radius: 3px;
|
transition: width 0.2s ease;
|
z-index: 2;
|
}
|
|
.hover-preview {
|
position: absolute;
|
top: -30px;
|
width: 1px;
|
height: 20px;
|
background: #909399;
|
transform: translateX(-50%);
|
z-index: 3;
|
pointer-events: none;
|
|
.hover-time {
|
position: absolute;
|
bottom: 100%;
|
left: 50%;
|
transform: translateX(-50%);
|
background: rgba(0, 0, 0, 0.8);
|
color: white;
|
padding: 2px 6px;
|
border-radius: 3px;
|
font-size: 12px;
|
white-space: nowrap;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
top: 100%;
|
left: 50%;
|
transform: translateX(-50%);
|
width: 0;
|
height: 0;
|
border-left: 4px solid transparent;
|
border-right: 4px solid transparent;
|
border-top: 4px solid rgba(0, 0, 0, 0.8);
|
}
|
}
|
|
.playhead {
|
position: absolute;
|
top: 50%;
|
width: 12px;
|
height: 12px;
|
background: #fff;
|
border: 2px solid #409EFF;
|
border-radius: 50%;
|
transform: translate(-50%, -50%);
|
z-index: 4;
|
cursor: pointer;
|
transition: all 0.2s ease;
|
|
&:hover {
|
transform: translate(-50%, -50%) scale(1.2);
|
box-shadow: 0 0 8px rgba(64, 158, 255, 0.5);
|
}
|
}
|
}
|
|
&:hover {
|
.progress-background {
|
height: 8px;
|
|
.playhead {
|
transform: translate(-50%, -50%) scale(1.1);
|
}
|
}
|
}
|
}
|
|
.time-display {
|
min-width: 100px;
|
font-size: 12px;
|
color: #606266;
|
display: flex;
|
align-items: center;
|
gap: 2px;
|
|
.current-time {
|
color: #303133;
|
font-weight: 500;
|
}
|
|
.time-separator {
|
opacity: 0.6;
|
}
|
|
.duration {
|
opacity: 0.8;
|
}
|
}
|
|
.volume-control {
|
.volume-btn {
|
padding: 0;
|
width: 24px;
|
height: 24px;
|
font-size: 16px;
|
color: #606266;
|
|
&:hover {
|
color: #409EFF;
|
}
|
}
|
}
|
|
.playback-rate {
|
width: 60px;
|
|
::v-deep .el-input__inner {
|
height: 24px;
|
line-height: 24px;
|
padding: 0 5px;
|
font-size: 12px;
|
}
|
}
|
|
.download-btn,
|
.more-btn {
|
padding: 0;
|
width: 24px;
|
height: 24px;
|
font-size: 16px;
|
color: #606266;
|
|
&:hover {
|
color: #409EFF;
|
}
|
}
|
}
|
}
|
|
.simple-player {
|
.simple-play-btn,
|
.simple-pause-btn {
|
padding: 4px 8px;
|
font-size: 12px;
|
color: #409EFF;
|
|
&:hover {
|
color: #66b1ff;
|
}
|
}
|
}
|
|
.audio-info {
|
.info-item {
|
margin-bottom: 12px;
|
display: flex;
|
|
.label {
|
width: 80px;
|
font-weight: 500;
|
color: #303133;
|
}
|
|
.value {
|
flex: 1;
|
color: #606266;
|
}
|
|
.audio-url {
|
color: #409EFF;
|
text-decoration: none;
|
|
&:hover {
|
text-decoration: underline;
|
}
|
}
|
}
|
}
|
}
|
|
// 音量弹窗样式
|
::v-deep .volume-popover {
|
padding: 10px;
|
min-width: 40px;
|
|
.volume-slider-container {
|
height: 100px;
|
display: flex;
|
justify-content: center;
|
|
.el-slider {
|
height: 100%;
|
|
.el-slider__runway {
|
background: #e4e7ed;
|
}
|
|
.el-slider__bar {
|
background: #409EFF;
|
}
|
|
.el-slider__button {
|
border-color: #409EFF;
|
width: 12px;
|
height: 12px;
|
}
|
}
|
}
|
}
|
</style>
|