hookehuyr

feat(播放器): 实现音视频互斥播放功能并优化默认封面

在VideoPlayer和AudioPlayer组件中添加互斥播放逻辑,当播放视频时自动暂停音频,反之亦然
为音频播放器添加默认封面图片
更新所有测试图片和音频资源URL为正式CDN地址
添加组件卸载时的清理逻辑
<!--
* @Date: 2025-04-07 12:35:35
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-16 16:50:23
* @LastEditTime: 2025-05-30 15:26:15
* @FilePath: /mlaj/src/components/ui/AudioPlayer.vue
* @Description: 音频播放器组件,支持播放控制、进度条调节、音量控制、播放列表等功能
-->
......@@ -12,7 +12,7 @@
<div class="flex flex-col items-center mb-4">
<!-- 歌曲封面 -->
<div class="w-24 h-24 rounded-lg overflow-hidden mb-2">
<img :src="currentSong?.cover" alt="封面" class="w-full h-full object-cover">
<img :src="currentSong?.cover ? currentSong?.cover : 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'" alt="封面" class="w-full h-full object-cover">
</div>
<!-- 歌曲信息 -->
......@@ -21,7 +21,7 @@
<p class="text-xs text-gray-500">{{ currentSong?.artist }}</p>
</div>
</div>
</div>
<!-- 进度条与时间:显示当前播放时间、总时长和可拖动的进度条 -->
<div class="mt-4 mb-6">
......@@ -105,7 +105,7 @@
>
<div class="absolute top-0 left-0 right-0 h-[1px] bg-gray-200"></div>
<div class="w-16 h-16 rounded-lg overflow-hidden mr-4 flex-shrink-0">
<img :src="song?.cover" alt="封面" class="w-full h-full object-cover">
<img :src="song?.cover ? song?.cover : 'https://cdn.ipadbiz.cn/mlaj/images/audio_d_cover.jpg'" alt="封面" class="w-full h-full object-cover">
</div>
<div class="flex-1">
<div class="font-medium">{{ song?.title }}</div>
......@@ -177,6 +177,9 @@ const loadAudio = async () => {
}
}
// 定义组件事件
const emit = defineEmits(['play', 'pause'])
// 播放控制:切换播放/暂停状态
const togglePlay = async () => {
if (isLoading.value) return
......@@ -186,8 +189,10 @@ const togglePlay = async () => {
}
if (isPlaying.value) {
await audio.value?.pause()
emit('pause', audio.value)
} else {
await audio.value?.play()
emit('play', audio.value)
}
isPlaying.value = !isPlaying.value
} catch (error) {
......@@ -195,6 +200,19 @@ const togglePlay = async () => {
}
}
// 暴露给父组件的方法
defineExpose({
togglePlay,
pause: () => {
if (isPlaying.value && audio.value) {
audio.value.pause();
isPlaying.value = false;
emit('pause', audio.value);
}
},
isPlaying: () => isPlaying.value
})
// 进度更新
const updateProgress = () => {
if (!audio.value) return
......@@ -223,6 +241,8 @@ const prevSong = async () => {
const normalizedVolume = Math.pow(volume.value / 100, 2)
audio.value.volume = normalizedVolume
isPlaying.value = true
// 发射播放事件
emit('play', audio.value)
}
}
......@@ -241,6 +261,8 @@ const nextSong = async () => {
const normalizedVolume = Math.pow(volume.value / 100, 2)
audio.value.volume = normalizedVolume
isPlaying.value = true
// 发射播放事件
emit('play', audio.value)
}
}
......@@ -312,6 +334,8 @@ const selectSong = async (index) => {
if (audio.value) {
await audio.value.play()
isPlaying.value = true
// 发射播放事件
emit('play', audio.value)
// 关闭播放列表
isPlaylistVisible.value = false
// 滚动到当前播放的音频
......
......@@ -119,12 +119,12 @@ onBeforeUnmount(() => {
defineExpose({
pause() {
if (player.value) {
player.value.pause();
player.value?.pause();
}
},
play() {
if (player.value) {
player.value.play();
player.value?.play();
}
},
});
......
<!--
* @Date: 2025-05-29 15:34:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-05-30 15:11:36
* @LastEditTime: 2025-05-30 15:54:33
* @FilePath: /mlaj/src/views/checkin/IndexCheckInPage.vue
* @Description: 文件描述
-->
......@@ -38,7 +38,7 @@
<van-progress :percentage="progress2" color="#4caf50" :show-pivot="false" />
</div>
<div style="padding: 0.75rem 1rem;">
<van-image round width="2.8rem" height="2.8rem" src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
<van-image round width="2.8rem" height="2.8rem" src="https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg"
v-for="(item, index) in teamAvatars" :key="index"
:style="{ marginLeft: index > 0 ? '-0.5rem' : '', border: '2px solid #FFF' }" />
</div>
......@@ -86,7 +86,7 @@
<!-- 视频封面和播放按钮 -->
<div v-if="post.video && !post.isPlaying" class="relative w-full rounded-lg overflow-hidden"
style="aspect-ratio: 16/9;">
<img :src="post.videoCover || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'"
<img :src="post.videoCover || 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'"
:alt="post.content" class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
@click="startPlay(post)">
......@@ -98,9 +98,31 @@
</div>
<!-- 视频播放器 -->
<VideoPlayer v-if="post.video && post.isPlaying" :video-url="post.video"
class="post-video rounded-lg overflow-hidden" ref="(el) => { if(el) videoPlayers.value.push(el) }"
@onPlay="(player) => handleVideoPlay(player, post)" @onPause="() => handleVideoPause(post)" />
<AudioPlayer v-if="post.audio.length" :songs="post.audio" class="post-audio" />
class="post-video rounded-lg overflow-hidden"
:ref="el => {
if(el) {
// 确保不重复添加
if (!videoPlayers?.includes(el)) {
videoPlayers?.push(el);
}
}
}"
@onPlay="(player) => handleVideoPlay(player, post)"
@onPause="() => handleVideoPause(post)" />
<AudioPlayer
v-if="post.audio.length"
:songs="post.audio"
class="post-audio"
:ref="el => {
if(el) {
// 确保不重复添加
if (!audioPlayers?.includes(el)) {
audioPlayers?.push(el);
}
}
}"
@play="(player) => handleAudioPlay(player, post)"
/>
</div>
</div>
<div class="post-footer">
......@@ -116,7 +138,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from "@/components/layout/AppLayout.vue";
import FrostedGlass from "@/components/ui/FrostedGlass.vue";
......@@ -126,17 +148,43 @@ import AudioPlayer from "@/components/ui/AudioPlayer.vue";
// 存储所有视频播放器的引用
const videoPlayers = ref([]);
// 存储所有音频播放器的引用
const audioPlayers = ref([]);
// 组件卸载前清理播放器引用
onBeforeUnmount(() => {
// 停止所有视频和音频播放
if (videoPlayers.value) {
videoPlayers.value.forEach(player => {
if (player && typeof player.pause === 'function') {
player.pause();
}
});
}
stopAllAudio();
// 清空引用数组
if (videoPlayers.value) videoPlayers.value = [];
if (audioPlayers.value) audioPlayers.value = [];
});
/**
* 开始播放指定帖子的视频
* @param {Object} post - 要播放视频的帖子对象
*/
const startPlay = (post) => {
// 先暂停所有其他视频
mockPosts.value.forEach(p => {
if (p.id !== post.id) {
p.isPlaying = false;
}
});
// 确保mockPosts.value是一个数组
if (mockPosts.value) {
// 先暂停所有其他视频
mockPosts.value.forEach(p => {
if (p.id !== post.id) {
p.isPlaying = false;
}
});
}
// 设置当前视频为播放状态
post.isPlaying = true;
......@@ -167,12 +215,15 @@ const handleVideoPause = (post) => {
* @param {Object} currentPost - 当前播放的帖子对象
*/
const stopOtherVideos = (currentPlayer, currentPost) => {
// 暂停其他视频播放器
videoPlayers.value.forEach(player => {
if (player !== currentPlayer && player.pause) {
player.pause();
}
});
// 确保videoPlayers.value是一个数组
if (videoPlayers.value) {
// 暂停其他视频播放器
videoPlayers.value.forEach(player => {
if (player !== currentPlayer && player.pause) {
player.pause();
}
});
}
// 更新其他帖子的播放状态
mockPosts.value.forEach(post => {
......@@ -180,6 +231,91 @@ const stopOtherVideos = (currentPlayer, currentPost) => {
post.isPlaying = false;
}
});
// 同时暂停所有音频播放器
stopAllAudio();
};
/**
* 处理音频播放事件
* @param {Object} player - 音频播放器实例
* @param {Object} post - 包含音频的帖子对象
*/
const handleAudioPlay = (player, post) => {
// 停止其他音频播放
stopOtherAudio(player, post);
// 同时暂停所有视频
if (videoPlayers.value) {
videoPlayers.value.forEach(videoPlayer => {
if (videoPlayer.pause) {
videoPlayer.pause();
}
});
}
// 更新所有视频的播放状态为false
mockPosts.value.forEach(p => {
p.isPlaying = false;
});
};
/**
* 停止除当前播放器外的所有其他音频
* @param {Object} currentPlayer - 当前播放的音频播放器实例
* @param {Object} currentPost - 当前播放的帖子对象
*/
const stopOtherAudio = (currentPlayer, currentPost) => {
// 确保audioPlayers.value是一个数组
if (!audioPlayers.value) return;
// 暂停其他音频播放器
audioPlayers.value.forEach(player => {
// 只处理不是当前播放器的实例
if (player !== currentPlayer) {
// 使用组件暴露的pause方法
if (typeof player.pause === 'function') {
player.pause();
}
// 如果没有pause方法,尝试使用togglePlay方法
else if (typeof player.togglePlay === 'function' && player.isPlaying && player.isPlaying()) {
player.togglePlay();
}
// 最后尝试直接操作DOM
else if (player.$el && player.$el.querySelector('audio')) {
const audioElement = player.$el.querySelector('audio');
if (audioElement) {
audioElement.pause();
}
}
}
});
};
/**
* 停止所有音频播放
*/
const stopAllAudio = () => {
// 确保audioPlayers.value是一个数组
if (!audioPlayers.value) return;
audioPlayers.value.forEach(player => {
// 使用组件暴露的pause方法
if (typeof player.pause === 'function') {
player.pause();
}
// 如果没有pause方法,尝试使用togglePlay方法
else if (typeof player.togglePlay === 'function' && player.isPlaying && player.isPlaying()) {
player.togglePlay();
}
// 最后尝试直接操作DOM
else if (player.$el && player.$el.querySelector('audio')) {
const audioElement = player.$el.querySelector('audio');
if (audioElement) {
audioElement.pause();
}
}
});
};
// Mock数据
......@@ -187,8 +323,8 @@ const mockPosts = ref([
{
id: 1,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
name: '图片预览',
avatar: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
......@@ -208,13 +344,13 @@ const mockPosts = ref([
id: 2,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
avatar: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
videoCover: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
isPlaying: false,
audio: [],
likes: 12
......@@ -223,7 +359,7 @@ const mockPosts = ref([
id: 3,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
avatar: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
......@@ -235,8 +371,8 @@ const mockPosts = ref([
{
title: '学习心得分享',
artist: '小林',
url: 'https://example.com/audio.mp3',
cover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
url: 'https://cdn.ipadbiz.cn/space/816560/双手合十迎客来_Fs2W-5mnQSFL8S5CDsHho-_xcvaY.mp3',
cover: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'
}
],
likes: 12
......@@ -245,17 +381,39 @@ const mockPosts = ref([
id: 4,
user: {
name: '小林',
avatar: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
avatar: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: 'https://cdn.ipadbiz.cn/space/lk3DmvLO02dUC2zPiFwiClDe3nKL.mp4',
videoCover: 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
videoCover: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
isPlaying: false,
audio: [],
likes: 12
},
{
id: 5,
user: {
name: '小林',
avatar: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
time: '2小时前'
},
content: '今天完成了React基础课程的学习,收获满满!',
images: [],
video: '',
videoCover: '',
isPlaying: false,
audio: [
{
title: '学习心得分享',
artist: '小林',
url: 'https://cdn.ipadbiz.cn/space/816560/双手合十迎客来_Fs2W-5mnQSFL8S5CDsHho-_xcvaY.mp3',
cover: 'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'
}
],
likes: 12
},
]);
const themeVars = {
......@@ -266,10 +424,10 @@ const progress1 = ref(50);
const progress2 = ref(76);
const teamAvatars = ref([
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg',
'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg',
'https://cdn.ipadbiz.cn/space/816560/大国少年_FhnF8lsFMPnTDNTBlM6hYa-UFBlW.jpg'
])
// 图片预览相关
......