视频同步播放功能组件
This commit is contained in:
parent
315739f22d
commit
03277ce88f
|
|
@ -6,7 +6,7 @@ VITE_PUBLIC_PATH = /
|
|||
|
||||
|
||||
# 跨域代理,您可以配置多个 ,请注意,没有换行符
|
||||
VITE_PROXY = [["/nursing-unit_101","http://localhost:8091/nursing-unit_101"],["/upload","http://localhost:3300/upload"],["/opeexup","http://localhost:8081/opeapi/"]]
|
||||
VITE_PROXY = [["/nursing-unit_101","http://localhost:8091/nursing-unit_101"],["/upload","http://localhost:3300/upload"],["/opeexup","http://localhost:8081/opeapi/"],["/opemedia","https://www.focusnu.com/media101/upFiles101"]]
|
||||
|
||||
#后台接口全路径地址(必填)
|
||||
VITE_GLOB_DOMAIN_URL=http://localhost:8091/nursing-unit_101
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ VITE_USE_MOCK = false
|
|||
# 发布路径
|
||||
VITE_PUBLIC_PATH = /biz101
|
||||
|
||||
VITE_PROXY = [["/opemedia","https://www.focusnu.com/media101/upFiles101"]]
|
||||
|
||||
# 是否启用gzip或brotli压缩
|
||||
# 选项值: gzip | brotli | none
|
||||
# 如果需要多个可以使用“,”分隔
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div class="page">
|
||||
<!-- 公共控制器 -->
|
||||
<VideoSyncController ref="controller" />
|
||||
|
||||
<!-- 两个视频播放器 -->
|
||||
<div class="videos">
|
||||
<div class="video-item">
|
||||
<h3>视频1:</h3>
|
||||
<video ref="video1" controls preload="auto" :src="video1Url" crossorigin="anonymous" class="video-player"
|
||||
@loadeddata="onVideo1Loaded" @error="onVideo1Error">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="video-item">
|
||||
<h3>视频2</h3>
|
||||
<video ref="video2" controls preload="auto" :src="video2Url" crossorigin="anonymous" class="video-player"
|
||||
@loadeddata="onVideo2Loaded" @error="onVideo2Error">
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import VideoSyncController from './tongshibofangCom.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 在线视频地址
|
||||
// const video1Url = ref('/opemedia/temp/将进酒_1754615544253.mp4')
|
||||
// const video2Url = ref('/opemedia/temp/18.6m_1754636202719.mp4')
|
||||
const video1Url = ref('https://www.focusnu.com/media101/upFiles101/temp/testvideo1.mp4')
|
||||
const video2Url = ref('https://www.focusnu.com/media101/upFiles101/temp/testvideo2.mp4')
|
||||
|
||||
|
||||
// 引用
|
||||
const controller = ref()
|
||||
const video1 = ref<HTMLVideoElement>()
|
||||
const video2 = ref<HTMLVideoElement>()
|
||||
|
||||
// 状态
|
||||
const video1Loaded = ref(false)
|
||||
const video2Loaded = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 组件挂载时注册视频
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
registerVideos()
|
||||
}, 100) // 等DOM渲染完成
|
||||
})
|
||||
|
||||
// 注册视频
|
||||
const registerVideos = () => {
|
||||
if (video1.value && controller.value) {
|
||||
controller.value.registerVideo1(video1.value)
|
||||
}
|
||||
if (video2.value && controller.value) {
|
||||
controller.value.registerVideo2(video2.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 视频加载完成事件
|
||||
const onVideo1Loaded = () => {
|
||||
video1Loaded.value = true
|
||||
console.log('视频1加载完成')
|
||||
}
|
||||
|
||||
const onVideo2Loaded = () => {
|
||||
video2Loaded.value = true
|
||||
console.log('视频2加载完成')
|
||||
}
|
||||
|
||||
// 视频加载错误
|
||||
const onVideo1Error = (e: Event) => {
|
||||
video1Loaded.value = false
|
||||
error.value = '视频1加载失败,可能是网络或跨域问题'
|
||||
console.error('视频1错误:', e)
|
||||
}
|
||||
|
||||
const onVideo2Error = (e: Event) => {
|
||||
video2Loaded.value = false
|
||||
error.value = '视频2加载失败,可能是网络或跨域问题'
|
||||
console.error('视频2错误:', e)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
color: #1890ff;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.videos {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.video-item h3 {
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
@media (max-width: 768px) {
|
||||
.videos {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
<template>
|
||||
<div class="video-controller">
|
||||
<a-space>
|
||||
<!-- 播放控制 -->
|
||||
<a-button @click="playAll" type="primary" :icon="h(PlayCircleOutlined)">播放</a-button>
|
||||
<a-button @click="pauseAll" :icon="h(PauseCircleOutlined)">暂停</a-button>
|
||||
<a-button @click="replayAll" :icon="h(RedoOutlined)">重播</a-button>
|
||||
<a-button @click="stopAll" :icon="h(StopOutlined)">停止</a-button>
|
||||
|
||||
<!-- 快进快退控制 -->
|
||||
<a-button-group>
|
||||
<a-button @mousedown="startSeek(-3)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(-3)"
|
||||
@touchend="stopSeek">
|
||||
-3s
|
||||
</a-button>
|
||||
<a-button @mousedown="startSeek(-9)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(-9)"
|
||||
@touchend="stopSeek">
|
||||
-9s
|
||||
</a-button>
|
||||
<a-button @mousedown="startSeek(-15)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(-15)"
|
||||
@touchend="stopSeek">
|
||||
-15s
|
||||
</a-button>
|
||||
<a-button @mousedown="startSeek(3)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(3)"
|
||||
@touchend="stopSeek">
|
||||
+3s
|
||||
</a-button>
|
||||
<a-button @mousedown="startSeek(9)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(9)"
|
||||
@touchend="stopSeek">
|
||||
+9s
|
||||
</a-button>
|
||||
<a-button @mousedown="startSeek(15)" @mouseup="stopSeek" @mouseleave="stopSeek" @touchstart="startSeek(15)"
|
||||
@touchend="stopSeek">
|
||||
+15s
|
||||
</a-button>
|
||||
</a-button-group>
|
||||
|
||||
<!-- 倍速控制 -->
|
||||
<a-select v-model:value="playbackRate" style="width: 120px" @change="changeSpeed">
|
||||
<a-select-option value="0.5">0.5x 慢速</a-select-option>
|
||||
<a-select-option value="0.75">0.75x 较慢</a-select-option>
|
||||
<a-select-option value="1.0">1x 正常</a-select-option>
|
||||
<a-select-option value="1.25">1.25x 较快</a-select-option>
|
||||
<a-select-option value="1.5">1.5x 快速</a-select-option>
|
||||
<a-select-option value="2.0">2x 极快</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button @click="resetSpeed" :icon="h(UndoOutlined)">正常速度</a-button>
|
||||
|
||||
<!-- 按住倍速按钮 -->
|
||||
<a-button @mousedown="fastForwardStart" @mouseup="fastForwardStop" @mouseleave="fastForwardStop"
|
||||
@touchstart="fastForwardStart" @touchend="fastForwardStop" type="primary">
|
||||
按住加速(松开恢复)
|
||||
</a-button>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
<a-button-group>
|
||||
<a-tooltip title="音量+">
|
||||
<a-button @click="volumeUp" :icon="h(PlusOutlined)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="音量-">
|
||||
<a-button @click="volumeDown" :icon="h(MinusOutlined)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip title="静音">
|
||||
<a-button @click="toggleMute" :icon="h(muted ? SoundOutlined : SoundFilled)" />
|
||||
</a-tooltip>
|
||||
</a-button-group>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, h, onUnmounted } from 'vue'
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
RedoOutlined,
|
||||
StopOutlined,
|
||||
UndoOutlined,
|
||||
PlusOutlined,
|
||||
MinusOutlined,
|
||||
SoundOutlined,
|
||||
SoundFilled
|
||||
} from '@ant-design/icons-vue'
|
||||
|
||||
// 存储两个视频的引用
|
||||
const video1 = ref<HTMLVideoElement>()
|
||||
const video2 = ref<HTMLVideoElement>()
|
||||
const playbackRate = ref('1.0')
|
||||
const muted = ref(false)
|
||||
const volume = ref(0.7)
|
||||
|
||||
// 新增变量
|
||||
const seekInterval = ref<number | null>(null)
|
||||
const fastForwardInterval = ref<number | null>(null)
|
||||
const originalSpeed = ref(1.0)
|
||||
|
||||
// 注册视频元素
|
||||
const registerVideo1 = (el: HTMLVideoElement) => {
|
||||
video1.value = el
|
||||
if (video1.value) {
|
||||
video1.value.volume = volume.value
|
||||
}
|
||||
}
|
||||
const registerVideo2 = (el: HTMLVideoElement) => {
|
||||
video2.value = el
|
||||
if (video2.value) {
|
||||
video2.value.volume = volume.value
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 快进快退功能
|
||||
const startSeek = (seconds: number) => {
|
||||
if (seekInterval.value) {
|
||||
clearInterval(seekInterval.value)
|
||||
}
|
||||
|
||||
// 立即执行一次
|
||||
seekVideos(seconds)
|
||||
|
||||
// 然后每200ms执行一次
|
||||
seekInterval.value = window.setInterval(() => {
|
||||
seekVideos(seconds)
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const stopSeek = () => {
|
||||
if (seekInterval.value) {
|
||||
clearInterval(seekInterval.value)
|
||||
seekInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const seekVideos = (seconds: number) => {
|
||||
if (video1.value && !isNaN(video1.value.duration)) {
|
||||
const newTime = video1.value.currentTime + seconds
|
||||
video1.value.currentTime = Math.max(0, Math.min(newTime, video1.value.duration))
|
||||
}
|
||||
if (video2.value && !isNaN(video2.value.duration)) {
|
||||
const newTime = video2.value.currentTime + seconds
|
||||
video2.value.currentTime = Math.max(0, Math.min(newTime, video2.value.duration))
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 按住倍速播放功能
|
||||
const fastForwardStart = () => {
|
||||
if (video1.value) {
|
||||
originalSpeed.value = video1.value.playbackRate
|
||||
video1.value.playbackRate = 2.0
|
||||
}
|
||||
if (video2.value) {
|
||||
video2.value.playbackRate = 2.0
|
||||
}
|
||||
}
|
||||
|
||||
const fastForwardStop = () => {
|
||||
if (video1.value) {
|
||||
video1.value.playbackRate = originalSpeed.value
|
||||
}
|
||||
if (video2.value) {
|
||||
video2.value.playbackRate = originalSpeed.value
|
||||
}
|
||||
}
|
||||
|
||||
// 原有播放控制方法
|
||||
const playAll = () => {
|
||||
const playVideo1 = video1.value?.play()
|
||||
const playVideo2 = video2.value?.play()
|
||||
|
||||
Promise.all([playVideo1, playVideo2].filter(Boolean))
|
||||
.then(() => {
|
||||
console.log('两个视频都已开始播放')
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('播放失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
const pauseAll = () => {
|
||||
video1.value?.pause()
|
||||
video2.value?.pause()
|
||||
}
|
||||
|
||||
const replayAll = () => {
|
||||
if (video1.value) {
|
||||
video1.value.currentTime = 0
|
||||
}
|
||||
if (video2.value) {
|
||||
video2.value.currentTime = 0
|
||||
}
|
||||
playAll()
|
||||
}
|
||||
|
||||
const stopAll = () => {
|
||||
pauseAll()
|
||||
if (video1.value) video1.value.currentTime = 0
|
||||
if (video2.value) video2.value.currentTime = 0
|
||||
}
|
||||
|
||||
const changeSpeed = (speed: string) => {
|
||||
playbackRate.value = speed
|
||||
originalSpeed.value = parseFloat(speed)
|
||||
if (video1.value) video1.value.playbackRate = originalSpeed.value
|
||||
if (video2.value) video2.value.playbackRate = originalSpeed.value
|
||||
}
|
||||
|
||||
const resetSpeed = () => {
|
||||
playbackRate.value = '1.0'
|
||||
originalSpeed.value = 1.0
|
||||
if (video1.value) video1.value.playbackRate = 1.0
|
||||
if (video2.value) video2.value.playbackRate = 1.0
|
||||
}
|
||||
|
||||
// 音量控制
|
||||
const volumeUp = () => {
|
||||
volume.value = Math.min(1, volume.value + 0.1)
|
||||
updateVolume()
|
||||
}
|
||||
|
||||
const volumeDown = () => {
|
||||
volume.value = Math.max(0, volume.value - 0.1)
|
||||
updateVolume()
|
||||
}
|
||||
|
||||
const toggleMute = () => {
|
||||
muted.value = !muted.value
|
||||
updateVolume()
|
||||
}
|
||||
|
||||
const updateVolume = () => {
|
||||
const vol = muted.value ? 0 : volume.value
|
||||
if (video1.value) video1.value.volume = vol
|
||||
if (video2.value) video2.value.volume = vol
|
||||
}
|
||||
|
||||
// 组件卸载时清除定时器
|
||||
onUnmounted(() => {
|
||||
if (seekInterval.value) clearInterval(seekInterval.value)
|
||||
if (fastForwardInterval.value) clearInterval(fastForwardInterval.value)
|
||||
})
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ registerVideo1, registerVideo2 })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-controller {
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue