视频同步播放功能组件

This commit is contained in:
1378012178@qq.com 2026-01-06 14:36:27 +08:00
parent 315739f22d
commit 03277ce88f
4 changed files with 427 additions and 1 deletions

View File

@ -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 VITE_GLOB_DOMAIN_URL=http://localhost:8091/nursing-unit_101

View File

@ -4,6 +4,8 @@ VITE_USE_MOCK = false
# 发布路径 # 发布路径
VITE_PUBLIC_PATH = /biz101 VITE_PUBLIC_PATH = /biz101
VITE_PROXY = [["/opemedia","https://www.focusnu.com/media101/upFiles101"]]
# 是否启用gzip或brotli压缩 # 是否启用gzip或brotli压缩
# 选项值: gzip | brotli | none # 选项值: gzip | brotli | none
# 如果需要多个可以使用“,”分隔 # 如果需要多个可以使用“,”分隔

View File

@ -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>

View File

@ -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>