feat: 更新视频播放器组件,添加章节视频功能并优化返回逻辑
This commit is contained in:
235
components/course/chapter-video.vue
Normal file
235
components/course/chapter-video.vue
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<view class="course-video">
|
||||||
|
<view v-if="videoList.length > 0" class="video-player" :class="{'full-screen': isFullScreen}">
|
||||||
|
<VideoPlayer
|
||||||
|
ref="commonVideo"
|
||||||
|
:videoTitle="videoTitle"
|
||||||
|
:currentVideoIndex="currentVideoIndex"
|
||||||
|
:currentVideoList="videoList"
|
||||||
|
:http="http"
|
||||||
|
:poster="cover"
|
||||||
|
@unlockChangeVideo="unlockChangeVideo"
|
||||||
|
@changeVideo="changeVideo"
|
||||||
|
@fullScreenChange="changeScreen"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view v-if="videoList.length > 0" style="height: 400rpx;"></view>
|
||||||
|
<view class="course-info">
|
||||||
|
<view class="course-flag">视频教学</view>
|
||||||
|
<view class="course-title">课程:{{ course.courseTitle || '' }}</view>
|
||||||
|
<view class="chapter-title">章节:{{ course.chapterTitle || '' }}</view>
|
||||||
|
<view v-if="videoList.length > 0" scroll-x="true" class="video-list">
|
||||||
|
<view v-for="(v, i) in videoList" :class="`video_item ${currentVideo.id == v.id ? 'active' : ''}`"
|
||||||
|
@click="handleClick(v,i)">
|
||||||
|
【{{ v.type == "2" ? "音频" : "视频" }}】{{ getNumber(i + 1) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
import VideoPlayer from '@/components/video-player/index.vue'
|
||||||
|
import { BEFORE_NAVIGATE_BACK_EVENT } from '@/components/nav-bar/back-events'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
VideoPlayer,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
course: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
courseTitle: '',
|
||||||
|
chapterTitle: '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
videoList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
videoTitle() {
|
||||||
|
return (this.course.chapterTitle || '') + ' ' + (this.getNumber(this.currentVideoIndex + 1))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
videoList: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.currentVideoIndex = this.currentIndex;
|
||||||
|
if (newVal.length > 0) {
|
||||||
|
this.currentVideo = newVal[this.currentVideoIndex];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
currentVideoIndex: this.currentIndex,
|
||||||
|
currentVideo: this.videoList[this.currentVideoIndex] || {},
|
||||||
|
screenLoading: false,
|
||||||
|
isFullScreen: false,
|
||||||
|
changeVideoLock: false,
|
||||||
|
_backHandler: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this._bindBackEvent()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this._unbindBackEvent()
|
||||||
|
this.unload()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this._unbindBackEvent()
|
||||||
|
this.unload()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
_bindBackEvent() {
|
||||||
|
if (this._backHandler) return
|
||||||
|
this._backHandler = () => { this.unload() }
|
||||||
|
uni.$on(BEFORE_NAVIGATE_BACK_EVENT, this._backHandler)
|
||||||
|
},
|
||||||
|
_unbindBackEvent() {
|
||||||
|
if (!this._backHandler) return
|
||||||
|
uni.$off(BEFORE_NAVIGATE_BACK_EVENT, this._backHandler)
|
||||||
|
this._backHandler = null
|
||||||
|
},
|
||||||
|
unload() {
|
||||||
|
this.$refs.commonVideo && this.$refs.commonVideo.unload && this.$refs.commonVideo.unload()
|
||||||
|
},
|
||||||
|
handleClick(v, i) {
|
||||||
|
if (this.changeVideoLock) {
|
||||||
|
uni.showToast({
|
||||||
|
title: '操作太频繁了',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.currentVideoIndex = i;
|
||||||
|
this.currentVideo = v;
|
||||||
|
this.changeVideo(this.currentVideoIndex);
|
||||||
|
},
|
||||||
|
changeVideo(currentVideoIndex, type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'next':
|
||||||
|
this.currentVideoIndex = currentVideoIndex + 1;
|
||||||
|
break;
|
||||||
|
case 'prev':
|
||||||
|
this.currentVideoIndex = currentVideoIndex - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.currentVideoIndex = currentVideoIndex;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.currentVideo = this.videoList[this.currentVideoIndex];
|
||||||
|
},
|
||||||
|
getNumber(num) {
|
||||||
|
return num >= 10 ? num : `0${num}`;
|
||||||
|
},
|
||||||
|
changeScreenLoading(status) {
|
||||||
|
this.screenLoading = status;
|
||||||
|
},
|
||||||
|
changeScreen(status) {
|
||||||
|
this.isFullScreen = status;
|
||||||
|
},
|
||||||
|
unlockChangeVideo() {
|
||||||
|
this.changeVideoLock = false
|
||||||
|
},
|
||||||
|
initVideo() {
|
||||||
|
this.changeVideoLock = true
|
||||||
|
this.screenLoading = false;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.currentVideo.type != 2 && this.$refs.commonVideo && this.$refs.commonVideo.init) {
|
||||||
|
this.$refs.commonVideo.init({
|
||||||
|
currentVideo: this.currentVideo,
|
||||||
|
currentVideoList: this.videoList,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.changeVideoLock = false
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.video-player {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 400rpx;
|
||||||
|
z-index: 2;
|
||||||
|
background-color: #000;
|
||||||
|
|
||||||
|
&.full-screen {
|
||||||
|
z-index: 9999999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.course-info {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
.course-flag {
|
||||||
|
font-size: 40rpx; color: #3983FF;
|
||||||
|
font-family: PangMenZhengDaoBiaoTiTiMianFeiBan;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
.course-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
.chapter-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
.video-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
|
||||||
|
.video_item {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff;
|
||||||
|
color: #5188e5;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid #5188e5;
|
||||||
|
box-shadow: 0px 0px 6rpx 0px rgba(255, 255, 255, 1);
|
||||||
|
padding: 18rpx 20rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 28rpx;
|
||||||
|
|
||||||
|
&:nth-child(4n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #5188e5 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
components/nav-bar/back-events.ts
Normal file
1
components/nav-bar/back-events.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const BEFORE_NAVIGATE_BACK_EVENT = '__edu_before_navigate_back__'
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { getStatusBarHeight, getTitleBarHeight, getNavBarHeight } from "@/utils/system"
|
import { getStatusBarHeight, getTitleBarHeight, getNavBarHeight } from "@/utils/system"
|
||||||
|
import { BEFORE_NAVIGATE_BACK_EVENT } from "./back-events"
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
title:{
|
title:{
|
||||||
type:String,
|
type:String,
|
||||||
default:""
|
default:""
|
||||||
@@ -24,11 +25,31 @@ defineProps({
|
|||||||
leftArrow:{
|
leftArrow:{
|
||||||
type:Boolean,
|
type:Boolean,
|
||||||
default:true
|
default:true
|
||||||
|
},
|
||||||
|
autoBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
safeBack: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['click-left'])
|
||||||
|
|
||||||
const handleClickLeft = () => {
|
const handleClickLeft = () => {
|
||||||
|
emit('click-left')
|
||||||
|
if (props.safeBack) {
|
||||||
|
uni.$emit(BEFORE_NAVIGATE_BACK_EVENT)
|
||||||
|
setTimeout(() => {
|
||||||
uni.navigateBack()
|
uni.navigateBack()
|
||||||
|
}, 80)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (props.autoBack) {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
46
components/nav-bar/use-safe-back.ts
Normal file
46
components/nav-bar/use-safe-back.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { onBackPress } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
|
import { BEFORE_NAVIGATE_BACK_EVENT } from './back-events'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全返回组合式函数。
|
||||||
|
* 在页面 setup 中调用,自动注册 onBackPress 拦截。
|
||||||
|
* 返回前先通过事件通知所有订阅组件(如 VideoPlayer)执行清理,
|
||||||
|
* 延迟后再执行 navigateBack,避免 renderjs 组件销毁竞态。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const { safeNavigateBack } = useSafeBack()
|
||||||
|
* // 配合 <nav-bar safe-back /> 即可,无需额外代码
|
||||||
|
*/
|
||||||
|
export function useSafeBack(options?: { delay?: number }) {
|
||||||
|
const leaving = ref(false)
|
||||||
|
const delay = options?.delay ?? 80
|
||||||
|
|
||||||
|
const safeNavigateBack = () => {
|
||||||
|
if (leaving.value) return
|
||||||
|
leaving.value = true
|
||||||
|
|
||||||
|
uni.$emit(BEFORE_NAVIGATE_BACK_EVENT)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
onBackPress((event: any) => {
|
||||||
|
if (event?.from === 'navigateBack') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (leaving.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
safeNavigateBack()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaving,
|
||||||
|
safeNavigateBack,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="control-bar" :class="{ 'control-bar--visible': showControls }" @click.stop>
|
|
||||||
<view class="control-bar__top">
|
|
||||||
<!-- 进度条 -->
|
|
||||||
<ProgressBar
|
|
||||||
:current="currentTime"
|
|
||||||
:duration="duration"
|
|
||||||
@seek="handleSeek"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="control-bar__bottom">
|
|
||||||
<!-- 播放/暂停按钮 -->
|
|
||||||
<view class="control-button" @click="togglePlay">
|
|
||||||
<text class="icon">{{ isPlaying ? '⏸' : '▶️' }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 时间显示 -->
|
|
||||||
<view class="time-display">
|
|
||||||
<text>{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="control-bar__right">
|
|
||||||
<!-- 速率控制 -->
|
|
||||||
<SpeedControl
|
|
||||||
:rate="playbackRate"
|
|
||||||
@change="handleSpeedChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 音量控制 -->
|
|
||||||
<VolumeControl
|
|
||||||
:volume="volume"
|
|
||||||
@change="handleVolumeChange"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 全屏按钮 -->
|
|
||||||
<view class="control-button" @click="toggleFullscreen">
|
|
||||||
<text class="icon">{{ isFullscreen ? '⛶' : '⛶' }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import ProgressBar from './ProgressBar.vue'
|
|
||||||
import SpeedControl from './SpeedControl.vue'
|
|
||||||
import VolumeControl from './VolumeControl.vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
currentTime: number
|
|
||||||
duration: number
|
|
||||||
isPlaying: boolean
|
|
||||||
isFullscreen: boolean
|
|
||||||
playbackRate: number
|
|
||||||
volume: number
|
|
||||||
showControls: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
currentTime: 0,
|
|
||||||
duration: 0,
|
|
||||||
isPlaying: false,
|
|
||||||
isFullscreen: false,
|
|
||||||
playbackRate: 1.0,
|
|
||||||
volume: 100,
|
|
||||||
showControls: true
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'toggle-play': []
|
|
||||||
'seek': [time: number]
|
|
||||||
'toggle-fullscreen': []
|
|
||||||
'speed-change': [rate: number]
|
|
||||||
'volume-change': [volume: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
if (!seconds || isNaN(seconds)) return '00:00'
|
|
||||||
|
|
||||||
const mins = Math.floor(seconds / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
emit('toggle-play')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSeek = (time: number) => {
|
|
||||||
emit('seek', time)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
emit('toggle-fullscreen')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSpeedChange = (rate: number) => {
|
|
||||||
emit('speed-change', rate)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVolumeChange = (volume: number) => {
|
|
||||||
emit('volume-change', volume)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.control-bar {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
|
||||||
padding: 20rpx;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
z-index: 10;
|
|
||||||
|
|
||||||
&--visible {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-bar__top {
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-bar__bottom {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-bar__right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20rpx;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-button {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 40rpx;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-display {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 24rpx;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="progress-bar-container">
|
|
||||||
<view
|
|
||||||
class="progress-bar"
|
|
||||||
@touchstart="handleTouchStart"
|
|
||||||
@touchmove="handleTouchMove"
|
|
||||||
@touchend="handleTouchEnd"
|
|
||||||
@click="handleClick"
|
|
||||||
>
|
|
||||||
<!-- 背景轨道 -->
|
|
||||||
<view class="track"></view>
|
|
||||||
|
|
||||||
<!-- 已播放进度 -->
|
|
||||||
<view class="played" :style="{ width: playedPercent + '%' }"></view>
|
|
||||||
|
|
||||||
<!-- 拖动滑块 -->
|
|
||||||
<view class="thumb" :style="{ left: playedPercent + '%' }"></view>
|
|
||||||
|
|
||||||
<!-- 时间提示 -->
|
|
||||||
<view
|
|
||||||
v-if="showTimeTooltip"
|
|
||||||
class="time-tooltip"
|
|
||||||
:style="{ left: tooltipPosition + '%' }"
|
|
||||||
>
|
|
||||||
{{ formatTime(tooltipTime) }}
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
current: number
|
|
||||||
duration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
current: 0,
|
|
||||||
duration: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
seek: [time: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isDragging = ref(false)
|
|
||||||
const showTimeTooltip = ref(false)
|
|
||||||
const tooltipPosition = ref(0)
|
|
||||||
const tooltipTime = ref(0)
|
|
||||||
const progressBarWidth = ref(0)
|
|
||||||
|
|
||||||
const playedPercent = computed(() => {
|
|
||||||
if (!props.duration) return 0
|
|
||||||
return (props.current / props.duration) * 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
if (!seconds || isNaN(seconds)) return '00:00'
|
|
||||||
|
|
||||||
const mins = Math.floor(seconds / 60)
|
|
||||||
const secs = Math.floor(seconds % 60)
|
|
||||||
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchStart = (e: any) => {
|
|
||||||
isDragging.value = true
|
|
||||||
showTimeTooltip.value = true
|
|
||||||
|
|
||||||
// 获取进度条宽度
|
|
||||||
const query = uni.createSelectorQuery()
|
|
||||||
query.select('.progress-bar').boundingClientRect()
|
|
||||||
query.exec((res) => {
|
|
||||||
if (res[0]) {
|
|
||||||
progressBarWidth.value = res[0].width
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchMove = (e: any) => {
|
|
||||||
if (!isDragging.value || !progressBarWidth.value) return
|
|
||||||
|
|
||||||
const touch = e.touches[0]
|
|
||||||
const query = uni.createSelectorQuery()
|
|
||||||
query.select('.progress-bar').boundingClientRect()
|
|
||||||
query.exec((res) => {
|
|
||||||
if (res[0]) {
|
|
||||||
const rect = res[0]
|
|
||||||
const offsetX = touch.clientX - rect.left
|
|
||||||
const percent = Math.max(0, Math.min(100, (offsetX / rect.width) * 100))
|
|
||||||
|
|
||||||
tooltipPosition.value = percent
|
|
||||||
tooltipTime.value = (percent / 100) * props.duration
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTouchEnd = (e: any) => {
|
|
||||||
if (!isDragging.value) return
|
|
||||||
|
|
||||||
isDragging.value = false
|
|
||||||
showTimeTooltip.value = false
|
|
||||||
|
|
||||||
// 发送 seek 事件
|
|
||||||
emit('seek', tooltipTime.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClick = (e: any) => {
|
|
||||||
const query = uni.createSelectorQuery()
|
|
||||||
query.select('.progress-bar').boundingClientRect()
|
|
||||||
query.exec((res) => {
|
|
||||||
if (res[0]) {
|
|
||||||
const rect = res[0]
|
|
||||||
const offsetX = e.detail.x - rect.left
|
|
||||||
const percent = Math.max(0, Math.min(100, (offsetX / rect.width) * 100))
|
|
||||||
const time = (percent / 100) * props.duration
|
|
||||||
|
|
||||||
emit('seek', time)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.progress-bar-container {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10rpx 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 6rpx;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 3rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.played {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #1989fa;
|
|
||||||
border-radius: 3rpx;
|
|
||||||
transition: width 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
width: 24rpx;
|
|
||||||
height: 24rpx;
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
|
||||||
transition: left 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 30rpx;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
padding: 8rpx 16rpx;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 24rpx;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
white-space: nowrap;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="speed-control">
|
|
||||||
<view class="speed-button" @click="toggleMenu">
|
|
||||||
<text class="speed-text">{{ currentRate }}x</text>
|
|
||||||
<text class="arrow">▼</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="showMenu" class="speed-menu">
|
|
||||||
<view
|
|
||||||
v-for="rate in rateOptions"
|
|
||||||
:key="rate"
|
|
||||||
class="speed-option"
|
|
||||||
:class="{ active: rate === currentRate }"
|
|
||||||
@click="selectRate(rate)"
|
|
||||||
>
|
|
||||||
{{ rate }}x
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
rate: 1.0
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
change: [rate: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const rateOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
|
||||||
const showMenu = ref(false)
|
|
||||||
const currentRate = ref(props.rate)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.rate,
|
|
||||||
(newRate) => {
|
|
||||||
currentRate.value = newRate
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleMenu = () => {
|
|
||||||
showMenu.value = !showMenu.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectRate = (rate: number) => {
|
|
||||||
currentRate.value = rate
|
|
||||||
showMenu.value = false
|
|
||||||
emit('change', rate)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.speed-control {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8rpx;
|
|
||||||
padding: 10rpx 20rpx;
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 8rpx;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-text {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.arrow {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-menu {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 100%;
|
|
||||||
right: 0;
|
|
||||||
margin-bottom: 10rpx;
|
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
|
||||||
border-radius: 8rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-option {
|
|
||||||
padding: 20rpx 40rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
text-align: center;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
color: #1989fa;
|
|
||||||
background-color: rgba(25, 137, 250, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<template>
|
|
||||||
<view class="volume-control">
|
|
||||||
<view class="volume-icon" @click="toggleMute">
|
|
||||||
<text class="icon">{{ isMuted ? '🔇' : '🔊' }}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="showSlider" class="volume-slider-wrapper">
|
|
||||||
<slider
|
|
||||||
class="volume-slider"
|
|
||||||
:value="currentVolume"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
@change="handleVolumeChange"
|
|
||||||
@changing="handleVolumeChanging"
|
|
||||||
activeColor="#1989fa"
|
|
||||||
backgroundColor="rgba(255, 255, 255, 0.3)"
|
|
||||||
block-size="12"
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
volume: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
volume: 100
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
change: [volume: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const showSlider = ref(false)
|
|
||||||
const currentVolume = ref(props.volume)
|
|
||||||
const isMuted = ref(false)
|
|
||||||
const volumeBeforeMute = ref(props.volume)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.volume,
|
|
||||||
(newVolume) => {
|
|
||||||
currentVolume.value = newVolume
|
|
||||||
isMuted.value = newVolume === 0
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
if (isMuted.value) {
|
|
||||||
// 取消静音
|
|
||||||
const volume = volumeBeforeMute.value || 50
|
|
||||||
currentVolume.value = volume
|
|
||||||
emit('change', volume)
|
|
||||||
isMuted.value = false
|
|
||||||
} else {
|
|
||||||
// 静音
|
|
||||||
volumeBeforeMute.value = currentVolume.value
|
|
||||||
currentVolume.value = 0
|
|
||||||
emit('change', 0)
|
|
||||||
isMuted.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVolumeChange = (e: any) => {
|
|
||||||
const volume = e.detail.value
|
|
||||||
currentVolume.value = volume
|
|
||||||
isMuted.value = volume === 0
|
|
||||||
emit('change', volume)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleVolumeChanging = (e: any) => {
|
|
||||||
currentVolume.value = e.detail.value
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击音量图标时切换滑块显示
|
|
||||||
const toggleSlider = () => {
|
|
||||||
showSlider.value = !showSlider.value
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.volume-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10rpx;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-icon {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
font-size: 36rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider-wrapper {
|
|
||||||
width: 150rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.volume-slider {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
// components/video-player/composables/useVideoAPI.ts
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { videoApi } from '@/api/modules/video'
|
|
||||||
import type { IVideoInfo, VideoErrorType } from '@/types/video'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频 API 调用管理
|
|
||||||
*/
|
|
||||||
export function useVideoAPI() {
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const error = ref<{ type: VideoErrorType; message: string } | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取视频播放信息
|
|
||||||
*/
|
|
||||||
const fetchVideoInfo = async (params: {
|
|
||||||
id: number
|
|
||||||
}): Promise<IVideoInfo | null> => {
|
|
||||||
isLoading.value = true
|
|
||||||
error.value = null
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await videoApi.checkVideo(params)
|
|
||||||
|
|
||||||
if (response.code === 0 && response.video) {
|
|
||||||
console.log('Video info fetched:', response.video)
|
|
||||||
return response.video
|
|
||||||
} else {
|
|
||||||
throw new Error(response.msg || '获取视频信息失败')
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Failed to fetch video info:', err)
|
|
||||||
error.value = {
|
|
||||||
type: 'API_ERROR' as VideoErrorType,
|
|
||||||
message: err.message || '获取视频信息失败,请稍后重试'
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 报告错误视频
|
|
||||||
*/
|
|
||||||
const reportErrorVideo = async (params: {
|
|
||||||
chapterId: number
|
|
||||||
videoId: number
|
|
||||||
sort: number
|
|
||||||
}): Promise<void> => {
|
|
||||||
try {
|
|
||||||
await videoApi.addErrorCourse(params)
|
|
||||||
console.log('Error video reported:', params)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to report error video:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除错误
|
|
||||||
*/
|
|
||||||
const clearError = () => {
|
|
||||||
error.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
fetchVideoInfo,
|
|
||||||
reportErrorVideo,
|
|
||||||
clearError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
// components/video-player/composables/useVideoPlayer.ts
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { useVideoAPI } from './useVideoAPI'
|
|
||||||
import { useVideoProgress } from './useVideoProgress'
|
|
||||||
import type { IVideoInfo, IVideoPlayerProps, VideoErrorType } from '@/types/video'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频播放器核心逻辑
|
|
||||||
*/
|
|
||||||
export function useVideoPlayer(props: IVideoPlayerProps) {
|
|
||||||
const videoAPI = useVideoAPI()
|
|
||||||
const videoProgress = useVideoProgress()
|
|
||||||
|
|
||||||
// 状态
|
|
||||||
const currentVideoData = ref<IVideoInfo | null>(null)
|
|
||||||
const currentIndex = ref(props.currentIndex)
|
|
||||||
const isSetFirstTime = ref(false)
|
|
||||||
const isChanging = ref(false)
|
|
||||||
const showError = ref(false)
|
|
||||||
const errorMessage = ref('')
|
|
||||||
const canRetry = ref(false)
|
|
||||||
const platform = ref('')
|
|
||||||
|
|
||||||
// 获取平台信息
|
|
||||||
// #ifdef APP-PLUS
|
|
||||||
platform.value = uni.getSystemInfoSync().platform
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef H5
|
|
||||||
platform.value = 'h5'
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算视频 URL
|
|
||||||
*/
|
|
||||||
const videoUrl = computed(() => {
|
|
||||||
if (!currentVideoData.value) return ''
|
|
||||||
|
|
||||||
if (currentVideoData.value.type === 0) {
|
|
||||||
// MP4 视频
|
|
||||||
return currentVideoData.value.mp4Url || currentVideoData.value.videoUrl || ''
|
|
||||||
} else {
|
|
||||||
// M3U8 视频
|
|
||||||
return currentVideoData.value.m3u8Url || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算封面图 URL
|
|
||||||
*/
|
|
||||||
const posterUrl = computed(() => {
|
|
||||||
if (!currentVideoData.value) return ''
|
|
||||||
|
|
||||||
// 如果是 MP4 视频,可以使用 OSS 视频截图功能
|
|
||||||
if (currentVideoData.value.type === 0 && currentVideoData.value.mp4Url) {
|
|
||||||
return `${currentVideoData.value.mp4Url}?x-oss-process=video/snapshot,t_1,f_jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查视频是否可以播放
|
|
||||||
*/
|
|
||||||
const canPlayVideo = (videoInfo: IVideoInfo): boolean => {
|
|
||||||
// iOS 平台检查
|
|
||||||
if (platform.value === 'ios') {
|
|
||||||
if (videoInfo.type === 1 && !videoInfo.m3u8Url) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 加载视频
|
|
||||||
*/
|
|
||||||
const loadVideo = async (index: number) => {
|
|
||||||
if (index < 0 || index >= props.videoList.length) {
|
|
||||||
showError.value = true
|
|
||||||
errorMessage.value = '视频索引超出范围'
|
|
||||||
canRetry.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const videoItem = props.videoList[index]
|
|
||||||
currentIndex.value = index
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
isSetFirstTime.value = false
|
|
||||||
showError.value = false
|
|
||||||
errorMessage.value = ''
|
|
||||||
canRetry.value = false
|
|
||||||
|
|
||||||
// 调用 API 获取视频信息
|
|
||||||
const videoInfo = await videoAPI.fetchVideoInfo({
|
|
||||||
id: videoItem.id
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!videoInfo) {
|
|
||||||
showError.value = true
|
|
||||||
errorMessage.value = videoAPI.error.value?.message || '获取视频信息失败'
|
|
||||||
canRetry.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查视频类型和 URL
|
|
||||||
if (videoInfo.type === 1 && !videoInfo.m3u8Url) {
|
|
||||||
// M3U8 视频但没有 URL,报告错误
|
|
||||||
await videoAPI.reportErrorVideo({
|
|
||||||
chapterId: videoInfo.chapterId,
|
|
||||||
videoId: videoInfo.id,
|
|
||||||
sort: videoInfo.sort
|
|
||||||
})
|
|
||||||
|
|
||||||
showError.value = true
|
|
||||||
errorMessage.value = '抱歉,本视频加密信息错误,已经提交错误信息,请过一段时间再来观看或联系客服'
|
|
||||||
canRetry.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置当前视频数据
|
|
||||||
currentVideoData.value = videoInfo
|
|
||||||
|
|
||||||
// 获取初始播放位置
|
|
||||||
const initialPosition = videoProgress.getInitialPosition(videoInfo)
|
|
||||||
videoProgress.updateCurrentTime(initialPosition)
|
|
||||||
|
|
||||||
return videoInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 切换视频
|
|
||||||
*/
|
|
||||||
const changeVideo = async (newIndex: number) => {
|
|
||||||
if (isChanging.value) return
|
|
||||||
|
|
||||||
isChanging.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 保存当前视频进度
|
|
||||||
if (currentVideoData.value) {
|
|
||||||
await videoProgress.saveNow(currentVideoData.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 停止进度保存
|
|
||||||
videoProgress.stopSaving()
|
|
||||||
|
|
||||||
// 加载新视频
|
|
||||||
await loadVideo(newIndex)
|
|
||||||
} finally {
|
|
||||||
isChanging.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重试加载视频
|
|
||||||
*/
|
|
||||||
const retry = () => {
|
|
||||||
loadVideo(currentIndex.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
currentVideoData,
|
|
||||||
currentIndex,
|
|
||||||
isSetFirstTime,
|
|
||||||
isChanging,
|
|
||||||
showError,
|
|
||||||
errorMessage,
|
|
||||||
canRetry,
|
|
||||||
platform,
|
|
||||||
isLoading: videoAPI.isLoading,
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
videoUrl,
|
|
||||||
posterUrl,
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
loadVideo,
|
|
||||||
changeVideo,
|
|
||||||
retry,
|
|
||||||
canPlayVideo,
|
|
||||||
|
|
||||||
// 进度管理
|
|
||||||
videoProgress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
// components/video-player/composables/useVideoProgress.ts
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { videoStorage } from '@/utils/videoStorage'
|
|
||||||
import { videoApi } from '@/api/modules/video'
|
|
||||||
import type { IVideoInfo } from '@/types/video'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频播放进度管理
|
|
||||||
*/
|
|
||||||
export function useVideoProgress() {
|
|
||||||
const currentTime = ref(0)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
let saveTimer: any = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取初始播放位置
|
|
||||||
* 合并本地和服务器的播放位置,取较大值
|
|
||||||
* 如果视频已经看完(播放位置 >= 总时长 - 5秒),则从头开始
|
|
||||||
*/
|
|
||||||
const getInitialPosition = (videoInfo: IVideoInfo): number => {
|
|
||||||
// 从服务器获取播放位置
|
|
||||||
const serverPosition = videoInfo.userCourseVideoPositionEntity?.position || 0
|
|
||||||
|
|
||||||
// 从本地存储获取播放位置
|
|
||||||
const localPosition = videoStorage.getVideoPosition(videoInfo.id) || 0
|
|
||||||
|
|
||||||
// 返回较大的值
|
|
||||||
// let position = Math.max(serverPosition, localPosition)
|
|
||||||
// 采用服务器记录的播放位置
|
|
||||||
let position = serverPosition
|
|
||||||
|
|
||||||
// 如果播放位置接近视频结尾(最后5秒内),则从头开始
|
|
||||||
const videoDuration = videoInfo.duration || 0
|
|
||||||
if (videoDuration > 0 && position >= videoDuration - 5) {
|
|
||||||
position = 0
|
|
||||||
console.log('Video already finished, reset to start')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Initial position:', {
|
|
||||||
server: serverPosition,
|
|
||||||
local: localPosition,
|
|
||||||
duration: videoDuration,
|
|
||||||
final: position
|
|
||||||
})
|
|
||||||
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始保存进度
|
|
||||||
* 每秒保存到本地
|
|
||||||
*/
|
|
||||||
const startSaving = (videoInfo: IVideoInfo) => {
|
|
||||||
// 清除之前的定时器
|
|
||||||
stopSaving()
|
|
||||||
|
|
||||||
// 每秒保存到本地
|
|
||||||
saveTimer = setInterval(() => {
|
|
||||||
if (currentTime.value > 0) {
|
|
||||||
videoStorage.saveVideoPosition(
|
|
||||||
videoInfo.id,
|
|
||||||
Math.floor(currentTime.value),
|
|
||||||
videoInfo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止保存进度
|
|
||||||
*/
|
|
||||||
const stopSaving = () => {
|
|
||||||
if (saveTimer) {
|
|
||||||
clearInterval(saveTimer)
|
|
||||||
saveTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 保存进度到服务器
|
|
||||||
*/
|
|
||||||
const saveToServer = async (videoId: number, position: number) => {
|
|
||||||
if (isSaving.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
isSaving.value = true
|
|
||||||
await videoApi.saveCoursePosition({
|
|
||||||
videoId,
|
|
||||||
position
|
|
||||||
})
|
|
||||||
console.log('Progress saved to server:', { videoId, position })
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save progress to server:', error)
|
|
||||||
} finally {
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 立即保存当前进度
|
|
||||||
*/
|
|
||||||
const saveNow = async (videoInfo: IVideoInfo) => {
|
|
||||||
if (currentTime.value > 0) {
|
|
||||||
// 保存到本地 注释掉: 不再保存到本地
|
|
||||||
// videoStorage.saveVideoPosition(
|
|
||||||
// videoInfo.id,
|
|
||||||
// Math.floor(currentTime.value),
|
|
||||||
// videoInfo
|
|
||||||
// )
|
|
||||||
|
|
||||||
// 保存到服务器
|
|
||||||
await saveToServer(videoInfo.id, Math.floor(currentTime.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新当前播放时间
|
|
||||||
*/
|
|
||||||
const updateCurrentTime = (time: number) => {
|
|
||||||
currentTime.value = time
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentTime,
|
|
||||||
isSaving,
|
|
||||||
getInitialPosition,
|
|
||||||
startSaving,
|
|
||||||
stopSaving,
|
|
||||||
saveToServer,
|
|
||||||
saveNow,
|
|
||||||
updateCurrentTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,523 +1,466 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="video-player-container">
|
<view>
|
||||||
<!-- 视频播放器 -->
|
<yb-video
|
||||||
<view class="video-wrapper">
|
ref="video" :title="videoTitle" height="400rpx"
|
||||||
<video
|
autoplay
|
||||||
v-if="!player.showError.value && player.videoUrl.value"
|
render-type="renderjs"
|
||||||
:id="videoId"
|
:crossOrigin="crossOrigin"
|
||||||
:src="player.videoUrl.value"
|
:src="src"
|
||||||
:poster="player.posterUrl.value"
|
:initialTime="initialTime"
|
||||||
:controls="true"
|
:custom="custom"
|
||||||
:page-gesture="true"
|
:poster="currentVideo.type == 2 ? poster : ''"
|
||||||
:vslide-gesture="true"
|
:alwaysShowControls="currentVideo.type == 2"
|
||||||
:vslide-gesture-in-fullscreen="true"
|
header
|
||||||
:show-center-play-btn="true"
|
controls
|
||||||
:show-fullscreen-btn="true"
|
open-direction="landscape-primary"
|
||||||
:show-play-btn="true"
|
exitDirection="portrait"
|
||||||
:show-progress="true"
|
object-fit="contain"
|
||||||
:enable-play-gesture="true"
|
@loadeddata="handleLoaded"
|
||||||
:enable-progress-gesture="true"
|
@timeupdate="handleTimeUpdate"
|
||||||
:object-fit="objectFit"
|
@ended="handleEnded"
|
||||||
:autoplay="true"
|
@loadstart="handleLoadStart"
|
||||||
:initial-time="initialTime"
|
@canplay="handleCanPlay"
|
||||||
class="video-element"
|
@play="handlePlay"
|
||||||
@play="play"
|
@fullscreenchange="handleFullscreenChange"
|
||||||
@pause="pause"
|
@error="handleError"
|
||||||
@ended="ended"
|
>
|
||||||
@timeupdate="timeupdate"
|
<view v-if="!canPlay && !videoError" class="video-mask">{{ loadingText }}</view>
|
||||||
@error="error"
|
<view v-if="canNext && !videoError" class="video-mask">
|
||||||
@fullscreenchange="fullscreenChange"
|
<view>播放完成,倒计时 <text style="font-size: 40rpx; font-weight: bold;">{{ countdown }}</text>秒 后播放下一条</view>
|
||||||
@waiting="waiting"
|
<view class="video-control-btn-group">
|
||||||
@loadedmetadata="loadedmetadata"
|
<view class="video-control-btn" @click="handleReplay">重播</view>
|
||||||
/>
|
<view class="video-control-btn" @click="handleNext">立即播放</view>
|
||||||
|
|
||||||
<!-- 倒计时遮罩 - 使用 cover-view 在 APP 中显示 -->
|
|
||||||
<cover-view v-if="showCountdown" class="countdown-overlay">
|
|
||||||
<cover-view class="countdown-content">
|
|
||||||
<cover-view class="countdown-timer">
|
|
||||||
<cover-view class="countdown-number">{{ countdownRemaining }}</cover-view>
|
|
||||||
<cover-view class="countdown-text">秒后播放下一个视频</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
|
|
||||||
<cover-view class="countdown-actions">
|
|
||||||
<cover-view class="btn-cancel" @click="cancelCountdown">取消</cover-view>
|
|
||||||
<cover-view class="btn-replay" @click="replayVideo">重新播放</cover-view>
|
|
||||||
<cover-view class="btn-next" @click="playNext">立即播放</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
</cover-view>
|
|
||||||
|
|
||||||
<!-- 错误显示 -->
|
|
||||||
<view v-if="player.showError.value" class="error-display">
|
|
||||||
<view class="error-content">
|
|
||||||
<view class="error-message">{{ player.errorMessage.value }}</view>
|
|
||||||
<button v-if="player.canRetry.value" class="btn-retry" @click="retry">重试</button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="videoError && videoErrorMsg !== ''" class="video-mask">
|
||||||
<!-- 加载中 -->
|
<view>{{ videoErrorMsg }}</view>
|
||||||
<view v-if="player.isLoading.value" class="loading-display">
|
|
||||||
<view class="loading-spinner"></view>
|
|
||||||
<text class="loading-text">加载中...</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
</yb-video>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script>
|
||||||
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
import { BEFORE_NAVIGATE_BACK_EVENT } from "@/components/nav-bar/back-events"
|
||||||
import { useVideoPlayer } from './composables/useVideoPlayer'
|
export default {
|
||||||
import { videoStorage } from '@/utils/videoStorage'
|
props: {
|
||||||
import type { IVideoPlayerProps, IVideoInfo } from '@/types/video'
|
videoTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
poster: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
currentVideoIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
currentVideoList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
canNext() {
|
||||||
|
return this.isEnded && this.currentVideoIndex < this.currentVideoList.length - 1
|
||||||
|
},
|
||||||
|
currentVideo() {
|
||||||
|
return this.currentVideoList[this.currentVideoIndex] || {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
currentVideo: {
|
||||||
|
handler(newVal, oldVal) {
|
||||||
|
if (newVal && newVal.id && newVal.id !== (oldVal && oldVal.id)) {
|
||||||
|
if (oldVal && oldVal.id) {
|
||||||
|
this._savePositionSnapshot(oldVal.id, parseInt(this.currentTime))
|
||||||
|
}
|
||||||
|
this._resetState();
|
||||||
|
this.updateCustomConfig();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
immediate: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
src: '',
|
||||||
|
isDestroyed: false,
|
||||||
|
beforeBackHandler: null,
|
||||||
|
canPlay: false,
|
||||||
|
videoError: false,
|
||||||
|
videoErrorMsg: '',
|
||||||
|
loadingText: '媒体文件加载中...',
|
||||||
|
currentTime: 0,
|
||||||
|
initialTime: 0,
|
||||||
|
isEnded: false,
|
||||||
|
countdown: 10,
|
||||||
|
countdownTimer: null,
|
||||||
|
endedTimer: null,
|
||||||
|
_requestVersion: 0,
|
||||||
|
three: '',
|
||||||
|
crossOrigin: '',
|
||||||
|
danmu: [],
|
||||||
|
quality: [],
|
||||||
|
works: [],
|
||||||
|
custom: {
|
||||||
|
slots: [{
|
||||||
|
innerHTML: '<div class="fast-progress-btn-box" style="left:15%;"><view class="yb-player-btn">-15s</view></div>',
|
||||||
|
followControls: true,
|
||||||
|
click: () => {
|
||||||
|
console.log('快退 基准时间 ' + this.currentTime)
|
||||||
|
this.$refs.video.seek(this.currentTime - 15)
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
innerHTML: '<div class="fast-progress-btn-box" style="right:15%;"><view class="yb-player-btn">+15s</view></div>',
|
||||||
|
followControls: true,
|
||||||
|
click: () => {
|
||||||
|
console.log('快进 基准时间 ' + this.currentTime)
|
||||||
|
this.$refs.video.seek(this.currentTime + 15)
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
header: {
|
||||||
|
disableMore: true,
|
||||||
|
disableBack: true,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
disableDanmuSend: true,
|
||||||
|
disableDanmuVisible: true,
|
||||||
|
disableFullscreen: false,
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
rightSlots: [],
|
||||||
|
disableFullscreen: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this._unbindBeforeBack()
|
||||||
|
this._cleanup()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this._unbindBeforeBack()
|
||||||
|
this._cleanup()
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this._bindBeforeBack()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
_resetState() {
|
||||||
|
this.canPlay = false;
|
||||||
|
this.isEnded = false;
|
||||||
|
this.videoError = false;
|
||||||
|
this.videoErrorMsg = '';
|
||||||
|
this.loadingText = '媒体文件加载中...';
|
||||||
|
this.currentTime = 0;
|
||||||
|
this.initialTime = 0;
|
||||||
|
this.isDestroyed = false;
|
||||||
|
this._requestVersion++;
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = null;
|
||||||
|
}
|
||||||
|
if (this.endedTimer) {
|
||||||
|
clearTimeout(this.endedTimer);
|
||||||
|
this.endedTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_bindBeforeBack() {
|
||||||
|
if (this.beforeBackHandler || !uni.$on) return
|
||||||
|
this.beforeBackHandler = () => {
|
||||||
|
this._cleanup()
|
||||||
|
}
|
||||||
|
uni.$on(BEFORE_NAVIGATE_BACK_EVENT, this.beforeBackHandler)
|
||||||
|
},
|
||||||
|
_unbindBeforeBack() {
|
||||||
|
if (!this.beforeBackHandler || !uni.$off) return
|
||||||
|
uni.$off(BEFORE_NAVIGATE_BACK_EVENT, this.beforeBackHandler)
|
||||||
|
this.beforeBackHandler = null
|
||||||
|
},
|
||||||
|
unload() {
|
||||||
|
this._cleanup()
|
||||||
|
},
|
||||||
|
_cleanup() {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.isDestroyed = true
|
||||||
|
const videoId = this.currentVideo ? this.currentVideo.id : null
|
||||||
|
const position = parseInt(this.currentTime)
|
||||||
|
if (this.$refs.video && this.$refs.video.unload) {
|
||||||
|
this.$refs.video.unload()
|
||||||
|
}
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = null;
|
||||||
|
}
|
||||||
|
if (this.endedTimer) {
|
||||||
|
clearTimeout(this.endedTimer);
|
||||||
|
this.endedTimer = null;
|
||||||
|
}
|
||||||
|
this._savePositionSnapshot(videoId, position)
|
||||||
|
},
|
||||||
|
_savePositionSnapshot(videoId, position) {
|
||||||
|
if (!videoId) return
|
||||||
|
this._saveLocalVideoPosition(videoId, position)
|
||||||
|
this._saveRemoteVideoPosition(videoId, position)
|
||||||
|
},
|
||||||
|
_saveLocalVideoPosition(videoId, position) {
|
||||||
|
const videoPosition = JSON.parse(uni.getStorageSync('videoPosition') || '{"order":[], "position":{}}')
|
||||||
|
if (!videoPosition.position) videoPosition.position = {}
|
||||||
|
if (!videoPosition.order) videoPosition.order = []
|
||||||
|
|
||||||
// 页面生命周期
|
const videoKeys = new Set(videoPosition.order)
|
||||||
// @ts-ignore
|
if (videoKeys.size > 20) {
|
||||||
const onHide = typeof uni !== 'undefined' ? uni.onHide || (() => {}) : () => {}
|
const firstId = videoKeys.values().next().value
|
||||||
|
delete videoPosition.position[firstId]
|
||||||
// Props
|
videoKeys.delete(firstId)
|
||||||
const props = withDefaults(defineProps<IVideoPlayerProps>(), {
|
videoPosition.order = Array.from(videoKeys)
|
||||||
countdownSeconds: 5,
|
|
||||||
showWatermark: false,
|
|
||||||
watermarkText: '本课程版权归天津众妙之门科技有限公司所有,翻版必究!'
|
|
||||||
})
|
|
||||||
|
|
||||||
// Emits
|
|
||||||
const emit = defineEmits<{
|
|
||||||
play: [videoInfo: IVideoInfo]
|
|
||||||
pause: [position: number]
|
|
||||||
fullscreen: [isFullscreen: boolean]
|
|
||||||
error: [error: { type: string; message: string }]
|
|
||||||
ended: [videoInfo: IVideoInfo]
|
|
||||||
timeupdate: [data: { currentTime: number; duration: number }]
|
|
||||||
'video-change': [newIndex: number]
|
|
||||||
'update:current-index': [newIndex: number]
|
|
||||||
'countdown-start': []
|
|
||||||
'countdown-cancel': []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 使用 composables
|
|
||||||
const player = useVideoPlayer(props)
|
|
||||||
|
|
||||||
// 视频相关状态
|
|
||||||
const videoId = 'video-player-' + Date.now()
|
|
||||||
const videoContext = ref<any>(null)
|
|
||||||
const duration = ref(0)
|
|
||||||
const isPlaying = ref(false)
|
|
||||||
const isFullScreen = ref(false)
|
|
||||||
const showCountdown = ref(false)
|
|
||||||
const countdownRemaining = ref(props.countdownSeconds)
|
|
||||||
const objectFit = ref<'contain' | 'cover'>('contain')
|
|
||||||
const initialTime = ref(0)
|
|
||||||
let countdownTimer: any = null
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(async () => {
|
|
||||||
// 初始化视频上下文
|
|
||||||
videoContext.value = uni.createVideoContext(videoId)
|
|
||||||
|
|
||||||
// 加载视频信息
|
|
||||||
const videoInfo = await player.loadVideo(props.currentIndex)
|
|
||||||
|
|
||||||
if (videoInfo) {
|
|
||||||
// 设置初始播放位置(loadVideo 内部已经调用过 getInitialPosition)
|
|
||||||
// 这里再次获取是为了确保 initialTime 和 video 组件同步
|
|
||||||
const position = player.videoProgress.getInitialPosition(videoInfo)
|
|
||||||
initialTime.value = position
|
|
||||||
console.log('Set initial time:', position, 'for video:', videoInfo.id)
|
|
||||||
|
|
||||||
// 如果有历史进度,使用 seek 跳转(因为 initial-time 可能不生效)
|
|
||||||
if (position > 0) {
|
|
||||||
setTimeout(() => {
|
|
||||||
videoContext.value?.seek(position)
|
|
||||||
console.log('Seek to position:', position)
|
|
||||||
}, 500) // 延迟 500ms 确保视频已加载
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始保存进度
|
videoPosition.position[videoId] = position
|
||||||
// player.videoProgress.startSaving(videoInfo) // 注释掉 不再保存到本地
|
videoKeys.add(videoId)
|
||||||
}
|
uni.setStorageSync('videoPosition', JSON.stringify(videoPosition))
|
||||||
})
|
},
|
||||||
|
_saveRemoteVideoPosition(videoId, position) {
|
||||||
// 页面隐藏时保存进度
|
const http = this.http
|
||||||
onHide(() => {
|
if (!http || !http.request) return
|
||||||
console.log('Page hidden, saving progress...')
|
http.request({
|
||||||
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
|
url: "sociology/course/saveCoursePosition",
|
||||||
// 立即保存到本地 注释掉: 不再保存到本地
|
method: "POST",
|
||||||
// videoStorage.saveVideoPosition(
|
data: { videoId, position },
|
||||||
// player.currentVideoData.value.id,
|
header: { 'Content-Type': 'application/json' },
|
||||||
// Math.floor(player.videoProgress.currentTime.value),
|
}).then(() => {
|
||||||
// player.currentVideoData.value
|
console.log('保存视频播放位置成功:', videoId, position)
|
||||||
// )
|
}).catch(() => {
|
||||||
// 保存到服务器
|
console.log('保存视频播放位置失败')
|
||||||
player.videoProgress.saveToServer(
|
|
||||||
player.currentVideoData.value.id,
|
|
||||||
Math.floor(player.videoProgress.currentTime.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
console.log('Component unmounting, saving progress...')
|
|
||||||
|
|
||||||
// 停止进度保存
|
|
||||||
player.videoProgress.stopSaving()
|
|
||||||
|
|
||||||
// 立即保存当前播放进度(同步保存到本地,异步保存到服务器)
|
|
||||||
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
|
|
||||||
// 立即保存到本地 注释掉: 不再保存到本地
|
|
||||||
// videoStorage.saveVideoPosition(
|
|
||||||
// player.currentVideoData.value.id,
|
|
||||||
// Math.floor(player.videoProgress.currentTime.value),
|
|
||||||
// player.currentVideoData.value
|
|
||||||
// )
|
|
||||||
// 保存到服务器(不等待完成)
|
|
||||||
player.videoProgress.saveToServer(
|
|
||||||
player.currentVideoData.value.id,
|
|
||||||
Math.floor(player.videoProgress.currentTime.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除倒计时
|
|
||||||
if (countdownTimer) {
|
|
||||||
clearInterval(countdownTimer)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 恢复竖屏
|
|
||||||
// #ifdef APP-PLUS
|
|
||||||
plus.screen.lockOrientation('portrait-primary')
|
|
||||||
// #endif
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听 currentIndex 变化(仅当外部主动修改时才触发)
|
|
||||||
watch(
|
|
||||||
() => props.currentIndex,
|
|
||||||
async (newIndex, oldIndex) => {
|
|
||||||
// 只有当新值与组件内部维护的索引不同时才处理
|
|
||||||
// 这样可以避免组件内部切换视频时触发 watch
|
|
||||||
if (newIndex !== player.currentIndex.value) {
|
|
||||||
await player.changeVideo(newIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const play = () => {
|
|
||||||
isPlaying.value = true
|
|
||||||
if (player.currentVideoData.value) {
|
|
||||||
emit('play', player.currentVideoData.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pause = () => {
|
|
||||||
isPlaying.value = false
|
|
||||||
emit('pause', player.videoProgress.currentTime.value)
|
|
||||||
|
|
||||||
// 暂停时保存进度
|
|
||||||
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
|
|
||||||
// 保存进度到本地 注释掉: 不再保存到本地
|
|
||||||
// videoStorage.saveVideoPosition(
|
|
||||||
// player.currentVideoData.value.id,
|
|
||||||
// Math.floor(player.videoProgress.currentTime.value),
|
|
||||||
// player.currentVideoData.value
|
|
||||||
// )
|
|
||||||
// 保存进度到服务器
|
|
||||||
player.videoProgress.saveToServer(
|
|
||||||
player.currentVideoData.value.id,
|
|
||||||
Math.floor(player.videoProgress.currentTime.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ended = () => {
|
|
||||||
if (player.currentVideoData.value) {
|
|
||||||
emit('ended', player.currentVideoData.value)
|
|
||||||
|
|
||||||
// 检查是否有下一个视频
|
|
||||||
if (player.currentIndex.value < props.videoList.length - 1) {
|
|
||||||
startCountdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeupdate = (e: any) => {
|
|
||||||
const currentTime = e.detail.currentTime
|
|
||||||
const videoDuration = e.detail.duration
|
|
||||||
|
|
||||||
player.videoProgress.updateCurrentTime(currentTime)
|
|
||||||
duration.value = videoDuration
|
|
||||||
|
|
||||||
emit('timeupdate', {
|
|
||||||
currentTime,
|
|
||||||
duration: videoDuration
|
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
updateCustomConfig() {
|
||||||
|
if (this.custom && this.custom.controls) {
|
||||||
|
this.custom.controls.disableFullscreen = this.currentVideo.type == 2;
|
||||||
|
this.custom.progress.disableFullscreen = this.currentVideo.type == 2;
|
||||||
|
}
|
||||||
|
this.changeSrc()
|
||||||
|
},
|
||||||
|
handleTimeUpdate (time) {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
if (!this.canPlay) return
|
||||||
|
if (this.isEnded && time > 0) {
|
||||||
|
this.isEnded = false
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer)
|
||||||
|
this.countdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currentTime = time
|
||||||
|
this._saveLocalVideoPosition(this.currentVideo.id, parseInt(time))
|
||||||
|
},
|
||||||
|
handleEnded () {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.endedTimer = setTimeout(() => {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.isEnded = true;
|
||||||
|
this.startCountdown();
|
||||||
|
this.currentTime = 0
|
||||||
|
}, 300);
|
||||||
|
|
||||||
const error = (e: any) => {
|
this._savePositionSnapshot(this.currentVideo.id, parseInt(this.currentTime))
|
||||||
console.error('Video error:', e)
|
|
||||||
player.showError.value = true
|
|
||||||
player.errorMessage.value = '视频播放出错,请稍后重试'
|
|
||||||
player.canRetry.value = true
|
|
||||||
|
|
||||||
emit('error', {
|
if (!this.isDestroyed && this.$refs.video && this.$refs.video.exitFullscreen) {
|
||||||
type: 'VIDEO_LOAD_ERROR',
|
this.$refs.video.exitFullscreen()
|
||||||
message: '视频播放出错'
|
}
|
||||||
|
},
|
||||||
|
startCountdown() {
|
||||||
|
this.countdown = 10;
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
}
|
||||||
|
this.countdownTimer = setInterval(() => {
|
||||||
|
this.countdown--;
|
||||||
|
if (this.countdown <= 0) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.handleNext();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
handleLoadStart () {
|
||||||
|
this.loadingText = '媒体文件加载完成,准备播放'
|
||||||
|
},
|
||||||
|
handleCanPlay () {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
this.canPlay = true
|
||||||
|
this.videoError = false
|
||||||
|
},
|
||||||
|
handlePlay () {
|
||||||
|
if (this.isDestroyed) return
|
||||||
|
if (this.isEnded) {
|
||||||
|
this.isEnded = false
|
||||||
|
}
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer)
|
||||||
|
this.countdownTimer = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleError () {
|
||||||
|
this.videoError = true
|
||||||
|
this.canPlay = false
|
||||||
|
},
|
||||||
|
handleNext () {
|
||||||
|
if (this.canNext) {
|
||||||
|
this.$emit('changeVideo', this.currentVideoIndex, 'next');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleReplay () {
|
||||||
|
if (this.countdownTimer) {
|
||||||
|
clearInterval(this.countdownTimer);
|
||||||
|
this.countdownTimer = null;
|
||||||
|
}
|
||||||
|
this.$refs.video.seek(0)
|
||||||
|
this.isEnded = false
|
||||||
|
this.$refs.video.play()
|
||||||
|
},
|
||||||
|
changeSrc () {
|
||||||
|
this.getPlayAuth()
|
||||||
|
},
|
||||||
|
handleFullscreenChange (data) {
|
||||||
|
console.log('全屏状态改变:' + JSON.stringify(data))
|
||||||
|
this.$emit('fullScreenChange', data.fullscreen)
|
||||||
|
},
|
||||||
|
handleLoaded (e) {
|
||||||
|
console.log('视频加载状态:'+JSON.stringify(e))
|
||||||
|
if (e.type == 'init') {
|
||||||
|
this.loadingText = '媒体文件加载完成,立即播放'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getPlayAuth () {
|
||||||
|
const version = ++this._requestVersion
|
||||||
|
const videoId = this.currentVideo.id
|
||||||
|
this.loadingText = '获取播放凭证...'
|
||||||
|
|
||||||
|
const http = this.http
|
||||||
|
if (!http || !http.request) return
|
||||||
|
|
||||||
|
let res_check
|
||||||
|
try {
|
||||||
|
res_check = await http.request({
|
||||||
|
url: `sociology/course/checkVideo`,
|
||||||
|
method: "Post",
|
||||||
|
data: { id: videoId },
|
||||||
|
header: { "Content-Type": "application/json" },
|
||||||
})
|
})
|
||||||
}
|
} catch (e) {
|
||||||
|
if (version !== this._requestVersion) return
|
||||||
|
this.videoError = true
|
||||||
|
this.videoErrorMsg = '获取播放凭证失败'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const fullscreenChange = (e: any) => {
|
if (version !== this._requestVersion) return
|
||||||
isFullScreen.value = e.detail.fullScreen
|
|
||||||
|
|
||||||
// #ifdef APP-PLUS
|
this.loadingText = '验证媒体文件格式...'
|
||||||
if (e.detail.fullScreen) {
|
console.log("checkVideo接口返回视频信息" + JSON.stringify(res_check.video));
|
||||||
// 进入全屏,锁定横屏
|
if (this.$platform == 'ios') {
|
||||||
setTimeout(() => {
|
if (res_check.video.type == 1 && (res_check.video.m3u8Url == null || res_check.video.m3u8Url == '')) {
|
||||||
plus.screen.lockOrientation('landscape-primary')
|
await this.recordErrorVideo(res_check.video, http)
|
||||||
}, 100)
|
this.videoErrorMsg = '抱歉,苹果手机不支持此加密视频格式,您可以在安卓端观看本视频'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version !== this._requestVersion) return
|
||||||
|
|
||||||
|
this.loadingText = '媒体文件加载中...'
|
||||||
|
const options = {
|
||||||
|
...res_check.video,
|
||||||
|
videoId: res_check.video.video,
|
||||||
|
}
|
||||||
|
this.handlePlayAuth(options)
|
||||||
|
},
|
||||||
|
handlePlayAuth (options) {
|
||||||
|
if (this.currentVideo.type == 1) {
|
||||||
|
this.src = options.m3u8Url
|
||||||
} else {
|
} else {
|
||||||
// 退出全屏,恢复竖屏
|
this.src = options.videoUrl
|
||||||
setTimeout(() => {
|
|
||||||
plus.screen.lockOrientation('portrait-primary')
|
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
// #endif
|
|
||||||
|
|
||||||
emit('fullscreen', isFullScreen.value)
|
const localPosition = JSON.parse(uni.getStorageSync('videoPosition') || '{"order":[], "position":{}}')
|
||||||
}
|
this.initialTime = localPosition.position[this.currentVideo.id] !== undefined
|
||||||
|
? localPosition.position[this.currentVideo.id]
|
||||||
const waiting = () => {
|
: (options.userCourseVideoPositionEntity ? options.userCourseVideoPositionEntity.position || 0 : 0)
|
||||||
// 视频缓冲中
|
},
|
||||||
console.log('Video waiting...')
|
recordErrorVideo (video, http) {
|
||||||
}
|
if (!http || !http.request) return
|
||||||
|
http.request({
|
||||||
const loadedmetadata = (e: any) => {
|
url: "medical/course/addErrorCourse",
|
||||||
duration.value = e.detail.duration
|
method: "POST",
|
||||||
console.log('Video metadata loaded, duration:', duration.value)
|
data: {
|
||||||
}
|
"chapterId": video.chapterId,
|
||||||
|
"videoId": video.id,
|
||||||
// 倒计时相关
|
"sort": video.sort
|
||||||
const startCountdown = () => {
|
},
|
||||||
showCountdown.value = true
|
header: { 'Content-Type': 'application/json' },
|
||||||
countdownRemaining.value = props.countdownSeconds
|
}).then(() => {
|
||||||
emit('countdown-start')
|
console.log('记录IOS不能看的视频成功 ' + JSON.stringify(video))
|
||||||
|
}).catch(() => { console.log('数据报错') })
|
||||||
countdownTimer = setInterval(() => {
|
},
|
||||||
countdownRemaining.value--
|
|
||||||
|
|
||||||
if (countdownRemaining.value <= 0) {
|
|
||||||
clearInterval(countdownTimer)
|
|
||||||
playNext()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelCountdown = () => {
|
|
||||||
if (countdownTimer) {
|
|
||||||
clearInterval(countdownTimer)
|
|
||||||
countdownTimer = null
|
|
||||||
}
|
|
||||||
showCountdown.value = false
|
|
||||||
emit('countdown-cancel')
|
|
||||||
}
|
|
||||||
|
|
||||||
const replayVideo = () => {
|
|
||||||
cancelCountdown()
|
|
||||||
|
|
||||||
// 重置播放位置并播放
|
|
||||||
initialTime.value = 0
|
|
||||||
player.videoProgress.updateCurrentTime(0)
|
|
||||||
|
|
||||||
// 重新加载当前视频
|
|
||||||
player.loadVideo(player.currentIndex.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const playNext = async () => {
|
|
||||||
cancelCountdown()
|
|
||||||
|
|
||||||
const nextIndex = player.currentIndex.value + 1
|
|
||||||
if (nextIndex < props.videoList.length) {
|
|
||||||
await player.changeVideo(nextIndex)
|
|
||||||
|
|
||||||
// 通知父组件更新 currentIndex
|
|
||||||
emit('update:current-index', nextIndex)
|
|
||||||
emit('video-change', nextIndex)
|
|
||||||
|
|
||||||
// 获取新视频的初始播放位置
|
|
||||||
if (player.currentVideoData.value) {
|
|
||||||
initialTime.value = player.videoProgress.getInitialPosition(player.currentVideoData.value)
|
|
||||||
// player.videoProgress.startSaving(player.currentVideoData.value) // 注释掉 不再保存到本地
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const retry = () => {
|
|
||||||
player.retry()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss">
|
||||||
.video-player-container {
|
::v-deep .fast-progress-btn-box {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
background-color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
height: 400rpx;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-element {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-display {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-content {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
font-size: 80rpx;
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 30rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-retry {
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #1989fa;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
padding: 20rpx 40rpx;
|
|
||||||
line-height: 1;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-display {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
width: 60rpx;
|
|
||||||
height: 60rpx;
|
|
||||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
margin-top: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-content {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
transform: translateY(-50%);
|
||||||
width: 600rpx;
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
margin-left: -300rpx;
|
border-radius: 14rpx;
|
||||||
margin-top: -150rpx;
|
padding: 2px;
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-timer {
|
|
||||||
margin-bottom: 60rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-number {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: bold;
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-text {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 50rpx;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-cancel,
|
.yb-player-btn {
|
||||||
.btn-replay,
|
|
||||||
.btn-next {
|
|
||||||
padding: 0 20px;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
height: 26px;
|
|
||||||
line-height: 26px;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin: 0 5px;
|
font-size: 28rpx;
|
||||||
background-color: #666;
|
line-height: 1;
|
||||||
}
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
// .btn-cancel {
|
}
|
||||||
// background-color: #666;
|
::v-deep .video-mask {
|
||||||
// }
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 1);
|
||||||
|
color: #fff;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
|
||||||
// .btn-replay {
|
.video-control-btn-group {
|
||||||
// background-color: #ff9800;
|
display: flex;
|
||||||
// }
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
// .btn-next {
|
.video-control-btn {
|
||||||
// background-color: #1989fa;
|
line-height: 1;
|
||||||
// }
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,15 +3,20 @@
|
|||||||
"appid" : "__UNI__1250B39",
|
"appid" : "__UNI__1250B39",
|
||||||
"description" : "吴门国际",
|
"description" : "吴门国际",
|
||||||
"sassImplementationName" : "node-sass",
|
"sassImplementationName" : "node-sass",
|
||||||
"versionName" : "1.1.18",
|
"versionName" : "1.1.19",
|
||||||
"versionCode" : 1118,
|
"versionCode" : 1119,
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
"app-plus" : {
|
"app-plus" : {
|
||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"nvueStyleCompiler" : "uni-app",
|
"nvueStyleCompiler" : "uni-app",
|
||||||
"compilerVersion" : 3,
|
"compilerVersion" : 3,
|
||||||
"screenOrientation" : [ "portrait-primary" ],
|
"screenOrientation" : [
|
||||||
|
"portrait-primary",
|
||||||
|
"portrait-secondary",
|
||||||
|
"landscape-primary",
|
||||||
|
"landscape-secondary"
|
||||||
|
],
|
||||||
"compatible" : {
|
"compatible" : {
|
||||||
"ignoreVersion" : true
|
"ignoreVersion" : true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
<view class="page-content">
|
<view class="page-content">
|
||||||
<!-- 课程视频 -->
|
<!-- 课程视频 -->
|
||||||
<AliPlayer
|
<CourseVideo
|
||||||
:video-list="videoList"
|
:video-list="videoList"
|
||||||
:current-index="currentVideoIndex !== null ? currentVideoIndex : 0"
|
:current-index="currentVideoIndex !== null ? currentVideoIndex : 0"
|
||||||
:course="{courseTitle:courseTitle, chapterTitle: chapterTitle}"
|
:course="{courseTitle:courseTitle, chapterTitle: chapterTitle}"
|
||||||
:cover="curriculumImgUrl || ''"
|
:cover="curriculumImgUrl || ''"
|
||||||
|
:http="mainClient"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 选项卡 -->
|
<!-- 选项卡 -->
|
||||||
@@ -49,10 +50,15 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { courseApi } from '@/api/modules/course'
|
import { courseApi } from '@/api/modules/course'
|
||||||
import AliPlayer from '@/components/ali-video/index.vue'
|
|
||||||
import type { IChapterDetail } from '@/types/course'
|
import type { IChapterDetail } from '@/types/course'
|
||||||
import type { IVideoInfo } from '@/types/video'
|
import type { IVideoInfo } from '@/types/video'
|
||||||
|
|
||||||
|
import { mainClient } from '@/api/clients'
|
||||||
|
|
||||||
|
import CourseVideo from '@/components/course/chapter-video.vue'
|
||||||
|
import { useSafeBack } from '@/components/nav-bar/use-safe-back'
|
||||||
|
useSafeBack()
|
||||||
|
|
||||||
// 页面参数
|
// 页面参数
|
||||||
const chapterId = ref<number>(0)
|
const chapterId = ref<number>(0)
|
||||||
const courseTitle = ref('')
|
const courseTitle = ref('')
|
||||||
|
|||||||
@@ -254,9 +254,21 @@
|
|||||||
methods: {
|
methods: {
|
||||||
dataIdWatcher (newVal) {
|
dataIdWatcher (newVal) {
|
||||||
if ( newVal ) {
|
if ( newVal ) {
|
||||||
|
// 延迟一下再查找 DOM 元素,确保 DOM 已经渲染完成
|
||||||
|
setTimeout(() => {
|
||||||
this.dom = document.querySelector('.rvideo' + newVal)
|
this.dom = document.querySelector('.rvideo' + newVal)
|
||||||
this.domSlot = document.querySelector('.rvideoslot' + newVal)
|
this.domSlot = document.querySelector('.rvideoslot' + newVal)
|
||||||
|
// 检查 DOM 元素是否存在
|
||||||
|
if ( this.dom && this.domSlot ) {
|
||||||
this.init()
|
this.init()
|
||||||
|
} else {
|
||||||
|
console.error('yb-video: DOM 元素未找到,可能渲染失败', {
|
||||||
|
dataId: newVal,
|
||||||
|
dom: this.dom,
|
||||||
|
domSlot: this.domSlot
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async init () {
|
async init () {
|
||||||
@@ -348,8 +360,9 @@
|
|||||||
parseSrc (path) {
|
parseSrc (path) {
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const isHash = window.location.hash
|
const isHash = window.location.hash
|
||||||
const route = this.dom.getAttribute('data-route')
|
// 安全检查 this.dom 是否存在
|
||||||
const pathName = isHash ? window.location.pathname : window.location.pathname.replace(route, '')
|
const route = this.dom ? this.dom.getAttribute('data-route') : ''
|
||||||
|
const pathName = isHash ? window.location.pathname : window.location.pathname.replace(route || '', '')
|
||||||
return window.location.origin + pathName + path.substring(1)
|
return window.location.origin + pathName + path.substring(1)
|
||||||
// #endif
|
// #endif
|
||||||
// #ifdef APP-VUE
|
// #ifdef APP-VUE
|
||||||
@@ -455,6 +468,7 @@
|
|||||||
isLive: params.isLive,
|
isLive: params.isLive,
|
||||||
header: params.header,
|
header: params.header,
|
||||||
controls: params.controls,
|
controls: params.controls,
|
||||||
|
alwaysShowControls: params.alwaysShowControls,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: params.objectFit,
|
objectFit: params.objectFit,
|
||||||
crossOrigin: params.crossOrigin,
|
crossOrigin: params.crossOrigin,
|
||||||
@@ -465,6 +479,7 @@
|
|||||||
workIndex: params.workIndex,
|
workIndex: params.workIndex,
|
||||||
subtitles: params.subtitles,
|
subtitles: params.subtitles,
|
||||||
subtitleIndex: params.subtitleIndex,
|
subtitleIndex: params.subtitleIndex,
|
||||||
|
playbackRates: params.playbackRates,
|
||||||
custom,
|
custom,
|
||||||
decoder: {
|
decoder: {
|
||||||
hls: {
|
hls: {
|
||||||
|
|||||||
@@ -105,6 +105,16 @@
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 1
|
default: 1
|
||||||
},
|
},
|
||||||
|
//倍速列表配置
|
||||||
|
playbackRates: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
//是否显示倍速提示
|
||||||
|
showRateTips: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
//循环播放
|
//循环播放
|
||||||
loop: {
|
loop: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -219,8 +229,13 @@
|
|||||||
return 'web'
|
return 'web'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
// #endif
|
// #endif
|
||||||
|
//是否一直显示控件
|
||||||
|
alwaysShowControls: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
boxStyle () {
|
boxStyle () {
|
||||||
@@ -526,9 +541,11 @@
|
|||||||
muted: this.muted,
|
muted: this.muted,
|
||||||
loop: this.loop,
|
loop: this.loop,
|
||||||
playbackRate: this.playbackRate,
|
playbackRate: this.playbackRate,
|
||||||
|
playbackRates: this.playbackRates,
|
||||||
isLive: this.isLive,
|
isLive: this.isLive,
|
||||||
header: this.header,
|
header: this.header,
|
||||||
controls: this.controls,
|
controls: this.controls,
|
||||||
|
alwaysShowControls: this.alwaysShowControls,
|
||||||
objectFit: this.objectFit,
|
objectFit: this.objectFit,
|
||||||
crossOrigin: this.crossOrigin,
|
crossOrigin: this.crossOrigin,
|
||||||
openDirection: this.openDirection,
|
openDirection: this.openDirection,
|
||||||
@@ -608,7 +625,8 @@
|
|||||||
this.updateTimer = setTimeout(() => {
|
this.updateTimer = setTimeout(() => {
|
||||||
const arg = {
|
const arg = {
|
||||||
header: this.header,
|
header: this.header,
|
||||||
controls: this.controls
|
controls: this.controls,
|
||||||
|
alwaysShowControls: this.alwaysShowControls
|
||||||
}
|
}
|
||||||
this.evalJS('updateConfig', arg)
|
this.evalJS('updateConfig', arg)
|
||||||
}, 200)
|
}, 200)
|
||||||
@@ -778,6 +796,12 @@
|
|||||||
playbackRate (newVal) {
|
playbackRate (newVal) {
|
||||||
this.setVideo('playbackRate', newVal)
|
this.setVideo('playbackRate', newVal)
|
||||||
},
|
},
|
||||||
|
//监听倍速列表
|
||||||
|
playbackRates (newVal) {
|
||||||
|
if (newVal && newVal.length > 0) {
|
||||||
|
this.reloadVideo()
|
||||||
|
}
|
||||||
|
},
|
||||||
//监听循环属性
|
//监听循环属性
|
||||||
loop (newVal) {
|
loop (newVal) {
|
||||||
this.setVideo('loop', newVal)
|
this.setVideo('loop', newVal)
|
||||||
@@ -793,6 +817,10 @@
|
|||||||
//监听controls
|
//监听controls
|
||||||
controls () {
|
controls () {
|
||||||
this.updateConfig()
|
this.updateConfig()
|
||||||
|
},
|
||||||
|
//监听alwaysShowControls
|
||||||
|
alwaysShowControls () {
|
||||||
|
this.updateConfig()
|
||||||
},
|
},
|
||||||
//深度监听custom
|
//深度监听custom
|
||||||
custom: {
|
custom: {
|
||||||
|
|||||||
@@ -53,6 +53,14 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.yb-player-time {
|
||||||
|
min-width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .yb-player-duration {
|
||||||
|
width: 35px;
|
||||||
|
} */
|
||||||
|
|
||||||
.yb-player-bottom-progress {
|
.yb-player-bottom-progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -120,22 +128,22 @@
|
|||||||
width: 0;
|
width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 2px;
|
height: 30px;
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
}
|
}
|
||||||
.yb-player-range-track {
|
.yb-player-range-track {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 14px;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 14px;
|
||||||
background-color: #999;
|
background-color: #999;
|
||||||
}
|
}
|
||||||
.yb-player-range-focus, .yb-player-range-preload {
|
.yb-player-range-focus, .yb-player-range-preload {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 14px;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 14px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
@@ -162,7 +170,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 30px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -501,6 +509,11 @@
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
background-color: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.yb-player-center svg {
|
.yb-player-center svg {
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
isLive = _ref.isLive,
|
isLive = _ref.isLive,
|
||||||
header = _ref.header,
|
header = _ref.header,
|
||||||
controls = _ref.controls,
|
controls = _ref.controls,
|
||||||
|
alwaysShowControls = _ref.alwaysShowControls,
|
||||||
height = _ref.height,
|
height = _ref.height,
|
||||||
crossOrigin = _ref.crossOrigin,
|
crossOrigin = _ref.crossOrigin,
|
||||||
objectFit = _ref.objectFit,
|
objectFit = _ref.objectFit,
|
||||||
@@ -50,7 +51,8 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
works = _ref.works,
|
works = _ref.works,
|
||||||
workIndex = _ref.workIndex,
|
workIndex = _ref.workIndex,
|
||||||
subtitles = _ref.subtitles,
|
subtitles = _ref.subtitles,
|
||||||
subtitleIndex = _ref.subtitleIndex;
|
subtitleIndex = _ref.subtitleIndex,
|
||||||
|
playbackRates = _ref.playbackRates;
|
||||||
_classCallCheck(this, YbPlayer);
|
_classCallCheck(this, YbPlayer);
|
||||||
this.container = typeof container == 'string' ? document.querySelector(container) : container;
|
this.container = typeof container == 'string' ? document.querySelector(container) : container;
|
||||||
this.src = src; //播放链接
|
this.src = src; //播放链接
|
||||||
@@ -63,10 +65,36 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
this.loop = loop; //是否循环播放
|
this.loop = loop; //是否循环播放
|
||||||
this.muted = muted; //是否静音
|
this.muted = muted; //是否静音
|
||||||
this.playbackRate = playbackRate || 1; //默认播放倍速
|
this.playbackRate = playbackRate || 1; //默认播放倍速
|
||||||
|
this.playbackRates = playbackRates && playbackRates.length > 0 ? playbackRates : [{
|
||||||
|
text: '0.5倍速',
|
||||||
|
value: 0.5
|
||||||
|
}, {
|
||||||
|
text: '0.75倍速',
|
||||||
|
value: 0.75
|
||||||
|
}, {
|
||||||
|
text: '正常倍速',
|
||||||
|
value: 1
|
||||||
|
}, {
|
||||||
|
text: '1.25倍速',
|
||||||
|
value: 1.25
|
||||||
|
}, {
|
||||||
|
text: '1.5倍速',
|
||||||
|
value: 1.5
|
||||||
|
}, {
|
||||||
|
text: '1.75倍速',
|
||||||
|
value: 1.75
|
||||||
|
}, {
|
||||||
|
text: '2倍速',
|
||||||
|
value: 2
|
||||||
|
}, {
|
||||||
|
text: '3倍速',
|
||||||
|
value: 3
|
||||||
|
}]; //倍速列表配置
|
||||||
this.preload = preload; //是否预加载
|
this.preload = preload; //是否预加载
|
||||||
this.isLive = isLive; //是否直播
|
this.isLive = isLive; //是否直播
|
||||||
this.header = header; //显示头部控制栏
|
this.header = header; //显示头部控制栏
|
||||||
this.controls = controls; //显示底部控制栏
|
this.controls = controls; //显示底部控制栏
|
||||||
|
this.alwaysShowControls = alwaysShowControls; //是否一直显示控件
|
||||||
this.height = height || 'auto'; //视频高度
|
this.height = height || 'auto'; //视频高度
|
||||||
this.crossOrigin = crossOrigin; //跨域属性 anonymous-它有一个默认值。它定义了将在不传递凭据信息的情况下发送CORS请求 use-credentials-将发送带有凭据、cookie 和证书的 cross-origin 请求
|
this.crossOrigin = crossOrigin; //跨域属性 anonymous-它有一个默认值。它定义了将在不传递凭据信息的情况下发送CORS请求 use-credentials-将发送带有凭据、cookie 和证书的 cross-origin 请求
|
||||||
this.objectFit = objectFit; //当视频宽高超出容器时的表现形式 fill-内容拉伸填充 contain-保持比例内容缩放 cover-保持比例内容可能被剪切 none-内容不重置 scale-down-保持比例从none或contain选一个 initial-默认值
|
this.objectFit = objectFit; //当视频宽高超出容器时的表现形式 fill-内容拉伸填充 contain-保持比例内容缩放 cover-保持比例内容可能被剪切 none-内容不重置 scale-down-保持比例从none或contain选一个 initial-默认值
|
||||||
@@ -97,6 +125,9 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
this._toastTimer = null; //消息隐藏定时器
|
this._toastTimer = null; //消息隐藏定时器
|
||||||
this._danmuTimer = null; //弹幕定时器
|
this._danmuTimer = null; //弹幕定时器
|
||||||
this._seizingTimer = null; //卡死定时器(播放一些直播源的时候,可能会出现卡死无反应的情况,需要做出处理)
|
this._seizingTimer = null; //卡死定时器(播放一些直播源的时候,可能会出现卡死无反应的情况,需要做出处理)
|
||||||
|
this._rateTimer = null; //倍速显示定时器
|
||||||
|
this._errorRetryCount = 0; //错误重试次数
|
||||||
|
this._maxErrorRetry = 0; //最大重试次数
|
||||||
this._event = {};
|
this._event = {};
|
||||||
}
|
}
|
||||||
//开启全屏按钮
|
//开启全屏按钮
|
||||||
@@ -111,7 +142,12 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
}, {
|
}, {
|
||||||
key: "load",
|
key: "load",
|
||||||
value: function load() {
|
value: function load() {
|
||||||
this.container.innerHTML = "\n\t\t\t<div class=\"yb-player-wrapper\" style=\"height: ".concat(this.height, "\">\n\t\t\t\t<div class=\"yb-player-header\"></div>\n\t\t\t\t<div class=\"yb-player-controls\">\n\t\t\t\t\t<div class=\"yb-player-progress\"></div>\n\t\t\t\t\t<div class=\"yb-player-controls-bottom yb-player-hide yb-player-full\"></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"yb-player-locks yb-player-hide\">\n\t\t\t\t\t<div class=\"yb-player-lock yb-player-lock-left\">").concat(YbPlayer.lockIcon, "</div>\n\t\t\t\t\t<div class=\"yb-player-lock yb-player-lock-right\">").concat(YbPlayer.lockIcon, "</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"yb-player-danmu\"></div>\n\t\t\t\t<div class=\"yb-player-toast\"></div>\n\t\t\t\t<div class=\"yb-player-bottom-progress yb-player-hide\"></div>\n\t\t\t</div>\n\t\t");
|
var posterHtml = '';
|
||||||
|
// 针对 iOS 平台添加备用封面显示
|
||||||
|
if (this.poster && /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream) {
|
||||||
|
posterHtml = '<div class="yb-player-poster" style="position:absolute;top:0;left:0;width:100%;height:100%;background-image:url(' + this.poster + ');background-size:cover;background-position:center;"></div>';
|
||||||
|
}
|
||||||
|
this.container.innerHTML = posterHtml + "\n\t\t\t<div class=\"yb-player-wrapper\" style=\"height: ".concat(this.height, "\">\n\t\t\t\t<div class=\"yb-player-header\"></div>\n\t\t\t\t<div class=\"yb-player-controls\">\n\t\t\t\t\t<div class=\"yb-player-progress\"></div>\n\t\t\t\t\t<div class=\"yb-player-controls-bottom yb-player-hide yb-player-full\"></div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"yb-player-locks yb-player-hide\">\n\t\t\t\t\t<div class=\"yb-player-lock yb-player-lock-left\">").concat(YbPlayer.lockIcon, "</div>\n\t\t\t\t\t<div class=\"yb-player-lock yb-player-lock-right\">").concat(YbPlayer.lockIcon, "</div>\n\t\t\t\t</div>\n\t\t\t\t<div class=\"yb-player-danmu\"></div>\n\t\t\t\t<div class=\"yb-player-toast\"></div>\n\t\t\t\t<div class=\"yb-player-bottom-progress yb-player-hide\"></div>\n\t\t\t</div>\n\t\t");
|
||||||
this._bindfullscreenerror = this._fullscreenerror.bind(this);
|
this._bindfullscreenerror = this._fullscreenerror.bind(this);
|
||||||
this._bindfullscreenchanged = this._fullscreenchanged.bind(this);
|
this._bindfullscreenchanged = this._fullscreenchanged.bind(this);
|
||||||
this.container.addEventListener('fullscreenerror', this._bindfullscreenerror);
|
this.container.addEventListener('fullscreenerror', this._bindfullscreenerror);
|
||||||
@@ -129,6 +165,7 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
this._clearDanmuTimer();
|
this._clearDanmuTimer();
|
||||||
this._clearToastTimer();
|
this._clearToastTimer();
|
||||||
this._clearControlsTimer();
|
this._clearControlsTimer();
|
||||||
|
this._clearRateTimer();
|
||||||
this._removeBackbuttonListener();
|
this._removeBackbuttonListener();
|
||||||
this._event = {}; //卸载所有监听事件
|
this._event = {}; //卸载所有监听事件
|
||||||
if (this.container) {
|
if (this.container) {
|
||||||
@@ -198,8 +235,8 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
_this2.emit('durationchange', _this2.getDuration());
|
_this2.emit('durationchange', _this2.getDuration());
|
||||||
};
|
};
|
||||||
this.video.onloadeddata = function () {
|
this.video.onloadeddata = function () {
|
||||||
//非直播时初始化播放时长
|
//非直播时初始化播放时长(错误恢复时不执行)
|
||||||
if (_this2.initialTime && !_this2.isLive) _this2.seek(_this2.initialTime);
|
if (_this2.initialTime && !_this2.isLive && !_this2._isErrorRecovering) _this2.seek(_this2.initialTime);
|
||||||
_this2.emit('loadeddata', {
|
_this2.emit('loadeddata', {
|
||||||
duration: _this2.getDuration(),
|
duration: _this2.getDuration(),
|
||||||
videoWidth: _this2.video.videoWidth,
|
videoWidth: _this2.video.videoWidth,
|
||||||
@@ -227,12 +264,18 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
if (_this2.cm) _this2.cm.setConfig('playbackRate', playbackRate);
|
if (_this2.cm) _this2.cm.setConfig('playbackRate', playbackRate);
|
||||||
_this2.emit('ratechange', playbackRate);
|
_this2.emit('ratechange', playbackRate);
|
||||||
_this2.setInnerHTML('yb-player-header-rate', '倍速x' + playbackRate);
|
_this2.setInnerHTML('yb-player-header-rate', '倍速x' + playbackRate);
|
||||||
|
_this2._clearRateTimer();
|
||||||
var rateEl = _this2.container.getElementsByClassName('yb-player-rate')[0] || document.createElement('DIV');
|
var rateEl = _this2.container.getElementsByClassName('yb-player-rate')[0] || document.createElement('DIV');
|
||||||
if (![1, 1.0].includes(playbackRate)) {
|
if (![1, 1.0].includes(playbackRate)) {
|
||||||
rateEl.setAttribute('class', 'yb-player-rate');
|
rateEl.setAttribute('class', 'yb-player-rate');
|
||||||
rateEl.innerHTML = "\n\t\t\t\t\t<div class=\"yb-player-rate-icon\">\n\t\t\t\t\t\t<i></i><i></i><i></i>\n\t\t\t\t\t</div>\n\t\t\t\t\t<span class=\"yb-player-rate-span\">".concat(playbackRate + '倍速播放中', "</span>\n\t\t\t\t");
|
rateEl.innerHTML = "\n\t\t\t\t\t<div class=\"yb-player-rate-icon\">\n\t\t\t\t\t\t<i></i><i></i><i></i>\n\t\t\t\t\t</div>\n\t\t\t\t\t<span class=\"yb-player-rate-span\">".concat(playbackRate + '倍速播放中', "</span>\n\t\t\t\t");
|
||||||
var wrapperEl = _this2.container.getElementsByClassName('yb-player-wrapper')[0];
|
var wrapperEl = _this2.container.getElementsByClassName('yb-player-wrapper')[0];
|
||||||
if (wrapperEl) wrapperEl.appendChild(rateEl);
|
if (wrapperEl) wrapperEl.appendChild(rateEl);
|
||||||
|
_this2._rateTimer = window.setTimeout(function () {
|
||||||
|
if (rateEl && rateEl.parentNode) {
|
||||||
|
rateEl.parentNode.removeChild(rateEl);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
} else {
|
} else {
|
||||||
if (rateEl) rateEl.remove();
|
if (rateEl) rateEl.remove();
|
||||||
}
|
}
|
||||||
@@ -325,7 +368,6 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
};
|
};
|
||||||
this.video.onerror = function (e) {
|
this.video.onerror = function (e) {
|
||||||
if (e && e.target.error) {
|
if (e && e.target.error) {
|
||||||
// 网络问题或其他不可恢复的错误
|
|
||||||
var code = e.target.error.code;
|
var code = e.target.error.code;
|
||||||
var errorMsg = '';
|
var errorMsg = '';
|
||||||
switch (code) {
|
switch (code) {
|
||||||
@@ -342,10 +384,26 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
errorMsg = '视频源不支持或地址无效';
|
errorMsg = '视频源不支持或地址无效';
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
console.log('[video.onerror] 检测到视频错误,错误码:' + code + ', 错误信息:' + errorMsg);
|
||||||
|
|
||||||
|
// 错误码 3 尝试跳过问题区间后继续播放
|
||||||
|
if (code === 3) {
|
||||||
|
_this2._skipErrorAndRetry(errorMsg);
|
||||||
|
} else {
|
||||||
|
// 其他错误直接提示错误信息
|
||||||
_this2.showError(errorMsg);
|
_this2.showError(errorMsg);
|
||||||
_this2.unloadVideo();
|
_this2.unloadVideo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// emit 错误事件,传递详细的错误信息
|
||||||
|
_this2.emit('error', {
|
||||||
|
code: code,
|
||||||
|
message: errorMsg,
|
||||||
|
error: e
|
||||||
|
});
|
||||||
|
} else {
|
||||||
_this2.emit('error', e);
|
_this2.emit('error', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
//视频长按菜单取消
|
//视频长按菜单取消
|
||||||
this.video.oncontextmenu = function (e) {
|
this.video.oncontextmenu = function (e) {
|
||||||
@@ -382,6 +440,7 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
key: "unloadVideo",
|
key: "unloadVideo",
|
||||||
value: function unloadVideo() {
|
value: function unloadVideo() {
|
||||||
this._clearSeizingTimer();
|
this._clearSeizingTimer();
|
||||||
|
this._errorRetryCount = 0; // 重置错误计数
|
||||||
this.unloadCustom();
|
this.unloadCustom();
|
||||||
this.unloadDecoder();
|
this.unloadDecoder();
|
||||||
this.unloadPano();
|
this.unloadPano();
|
||||||
@@ -857,12 +916,13 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
wrapperEl.appendChild(toolbarEl); //先插入不显示的工具栏,因为需要计算工具栏尺寸
|
wrapperEl.appendChild(toolbarEl); //先插入不显示的工具栏,因为需要计算工具栏尺寸
|
||||||
var element = this.container.querySelector(selector); //获取点击选择器
|
var element = this.container.querySelector(selector); //获取点击选择器
|
||||||
var rect = element.getBoundingClientRect(); //获取点击元素的尺寸布局信息
|
var rect = element.getBoundingClientRect(); //获取点击元素的尺寸布局信息
|
||||||
|
var containerRect = this.container.getBoundingClientRect(); //获取容器相对于视口的位置
|
||||||
var boxWidth = this.container.offsetWidth;
|
var boxWidth = this.container.offsetWidth;
|
||||||
var boxHeight = this.container.offsetHeight;
|
var boxHeight = this.container.offsetHeight;
|
||||||
var top = rect.top + rect.height; //计算顶部定位
|
var top = rect.top - containerRect.top + rect.height; //计算顶部定位(相对于容器)
|
||||||
var left = rect.left; //计算左边定位
|
var left = rect.left - containerRect.left; //计算左边定位(相对于容器)
|
||||||
var right = boxWidth - rect.right; //计算右边定位
|
var right = containerRect.right - rect.right; //计算右边定位
|
||||||
var bottom = boxHeight - rect.bottom + rect.height; //计算底部定位
|
var bottom = containerRect.bottom - rect.bottom + rect.height; //计算底部定位
|
||||||
var isTop = true; //是否定位顶部 true-定位顶部 false-定位底部
|
var isTop = true; //是否定位顶部 true-定位顶部 false-定位底部
|
||||||
var isLeft = true; //是否定位左边 true-定位左边 false-定位右边
|
var isLeft = true; //是否定位左边 true-定位左边 false-定位右边
|
||||||
if (top > boxHeight / 2) isTop = false; //判断点击元素是否在盒子上半部
|
if (top > boxHeight / 2) isTop = false; //判断点击元素是否在盒子上半部
|
||||||
@@ -1072,25 +1132,7 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
key: "showPlaybackRate",
|
key: "showPlaybackRate",
|
||||||
value: function showPlaybackRate() {
|
value: function showPlaybackRate() {
|
||||||
var _this10 = this;
|
var _this10 = this;
|
||||||
var arr = [{
|
var arr = this.playbackRates;
|
||||||
text: '0.5倍速',
|
|
||||||
value: 0.5
|
|
||||||
}, {
|
|
||||||
text: '正常倍速',
|
|
||||||
value: 1
|
|
||||||
}, {
|
|
||||||
text: '1.5倍速',
|
|
||||||
value: 1.5
|
|
||||||
}, {
|
|
||||||
text: '1.75倍速',
|
|
||||||
value: 1.75
|
|
||||||
}, {
|
|
||||||
text: '2倍速',
|
|
||||||
value: 2
|
|
||||||
}, {
|
|
||||||
text: '3倍速',
|
|
||||||
value: 3
|
|
||||||
}];
|
|
||||||
var list = arr.map(function (a, k) {
|
var list = arr.map(function (a, k) {
|
||||||
return {
|
return {
|
||||||
text: a.text,
|
text: a.text,
|
||||||
@@ -1419,7 +1461,7 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
if (['timeDiffrence', 'disableScroll', 'disableTop', 'disableBottom', 'fontScale'].includes(key)) _this.cm.reset();
|
if (['timeDiffrence', 'disableScroll', 'disableTop', 'disableBottom', 'fontScale'].includes(key)) _this.cm.reset();
|
||||||
}
|
}
|
||||||
_this.emit('danmuconfigchange', config); //派发弹幕配置更改事件,以便开发者外部记录
|
_this.emit('danmuconfigchange', config); //派发弹幕配置更改事件,以便开发者外部记录
|
||||||
}, 500);
|
}, 2000);
|
||||||
}
|
}
|
||||||
this.showPopup('弹幕设置', div);
|
this.showPopup('弹幕设置', div);
|
||||||
}
|
}
|
||||||
@@ -1653,8 +1695,13 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
});
|
});
|
||||||
var children = progressEl.children;
|
var children = progressEl.children;
|
||||||
for (var i = 0; i < children.length; i++) {
|
for (var i = 0; i < children.length; i++) {
|
||||||
// children[i].onclick = arr[i].click
|
(function(index) {
|
||||||
YbPlayer.tap(children[i], arr[i].click);
|
var originalClick = arr[index].click;
|
||||||
|
YbPlayer.tap(children[index], function() {
|
||||||
|
if (originalClick) originalClick();
|
||||||
|
_this17.showControls();
|
||||||
|
});
|
||||||
|
})(i);
|
||||||
//如果标记了非全屏元素,则在全屏时需要隐藏
|
//如果标记了非全屏元素,则在全屏时需要隐藏
|
||||||
if (children[i].classList.contains('yb-player-unfull') && this.getFullscreen()) {
|
if (children[i].classList.contains('yb-player-unfull') && this.getFullscreen()) {
|
||||||
children[i].classList.add('yb-player-hide');
|
children[i].classList.add('yb-player-hide');
|
||||||
@@ -1771,8 +1818,13 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
});
|
});
|
||||||
var children = controlsBottomEl.children;
|
var children = controlsBottomEl.children;
|
||||||
for (var _i = 0; _i < children.length; _i++) {
|
for (var _i = 0; _i < children.length; _i++) {
|
||||||
// children[i].onclick = arr[i].click
|
(function(index) {
|
||||||
YbPlayer.tap(children[_i], arr[_i].click);
|
var originalClick = arr[index].click;
|
||||||
|
YbPlayer.tap(children[index], function() {
|
||||||
|
if (originalClick) originalClick();
|
||||||
|
_this17.showControls();
|
||||||
|
});
|
||||||
|
})(_i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1844,8 +1896,13 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
});
|
});
|
||||||
var children = heightEl.children;
|
var children = heightEl.children;
|
||||||
for (var _i2 = 0; _i2 < children.length; _i2++) {
|
for (var _i2 = 0; _i2 < children.length; _i2++) {
|
||||||
// children[i].onclick = arr[i].click
|
(function(index) {
|
||||||
YbPlayer.tap(children[_i2], arr[_i2].click);
|
var originalClick = arr[index].click;
|
||||||
|
YbPlayer.tap(children[index], function() {
|
||||||
|
if (originalClick) originalClick();
|
||||||
|
_this17.showControls();
|
||||||
|
});
|
||||||
|
})(_i2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1855,13 +1912,19 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
if (slots.length > 0 && wrapperEl) {
|
if (slots.length > 0 && wrapperEl) {
|
||||||
var slotEl = document.createElement('DIV');
|
var slotEl = document.createElement('DIV');
|
||||||
slotEl.setAttribute('class', 'yb-player-slot');
|
slotEl.setAttribute('class', 'yb-player-slot');
|
||||||
|
var _this = this;
|
||||||
slots.forEach(function (slot, key) {
|
slots.forEach(function (slot, key) {
|
||||||
slotEl.innerHTML += slot.innerHTML;
|
slotEl.innerHTML += slot.innerHTML;
|
||||||
});
|
});
|
||||||
var children = slotEl.children;
|
var children = slotEl.children;
|
||||||
for (var _i3 = 0; _i3 < children.length; _i3++) {
|
for (var _i3 = 0; _i3 < children.length; _i3++) {
|
||||||
// children[i].onclick = slots[i].click
|
(function(index) {
|
||||||
YbPlayer.tap(children[_i3], slots[_i3].click);
|
var originalClick = slots[index].click;
|
||||||
|
YbPlayer.tap(children[index], function() {
|
||||||
|
if (originalClick) originalClick();
|
||||||
|
_this.showControls();
|
||||||
|
});
|
||||||
|
})(_i3);
|
||||||
if (slots[_i3].followControls) {
|
if (slots[_i3].followControls) {
|
||||||
children[_i3].setAttribute('data-controls', 1);
|
children[_i3].setAttribute('data-controls', 1);
|
||||||
if (!this.getControls()) children[_i3].classList.add('yb-player-hide');
|
if (!this.getControls()) children[_i3].classList.add('yb-player-hide');
|
||||||
@@ -1871,6 +1934,10 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
}
|
}
|
||||||
//重新设置一次全屏状态
|
//重新设置一次全屏状态
|
||||||
this._setFullscreenStatus();
|
this._setFullscreenStatus();
|
||||||
|
// 如果设置了alwaysShowControls,自动显示控件
|
||||||
|
if (this.alwaysShowControls) {
|
||||||
|
this.showControls();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//卸载自定义
|
//卸载自定义
|
||||||
}, {
|
}, {
|
||||||
@@ -2089,7 +2156,13 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
value: function setConfig(key, value) {
|
value: function setConfig(key, value) {
|
||||||
var _this20 = this;
|
var _this20 = this;
|
||||||
Object.keys(this).forEach(function (k) {
|
Object.keys(this).forEach(function (k) {
|
||||||
if (k == key) _this20[k] = value;
|
if (k == key) {
|
||||||
|
_this20[k] = value;
|
||||||
|
//如果更新的是alwaysShowControls属性,需要重新显示控件
|
||||||
|
if (k == 'alwaysShowControls' && value) {
|
||||||
|
_this20.showControls();
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
//动态手势事件配置
|
//动态手势事件配置
|
||||||
@@ -2409,7 +2482,14 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
value: function hideLoading() {
|
value: function hideLoading() {
|
||||||
var div = this.container.getElementsByClassName('yb-player-loading')[0];
|
var div = this.container.getElementsByClassName('yb-player-loading')[0];
|
||||||
if (div) div.remove();
|
if (div) div.remove();
|
||||||
if (this.video.paused) this.showCenterPlay();else this.hideCenter();
|
// if (this.video.paused) this.showCenterPlay();else this.hideCenter();
|
||||||
|
if (this._isErrorRecovering) {
|
||||||
|
this.hideCenter();
|
||||||
|
} else if (this.video.paused) {
|
||||||
|
this.showCenterPlay();
|
||||||
|
} else {
|
||||||
|
this.hideCenter();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
//展示中间播放按钮
|
//展示中间播放按钮
|
||||||
}, {
|
}, {
|
||||||
@@ -2519,6 +2599,152 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
value: function seek(time) {
|
value: function seek(time) {
|
||||||
if (this.video) this.video.currentTime = time;
|
if (this.video) this.video.currentTime = time;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 跳过错误区间并重试播放
|
||||||
|
* @param {String} errorMsg 错误信息
|
||||||
|
*/
|
||||||
|
}, {
|
||||||
|
key: "_skipErrorAndRetry",
|
||||||
|
value: function _skipErrorAndRetry(errorMsg) {
|
||||||
|
var _this = this;
|
||||||
|
|
||||||
|
// 检查 video 对象是否存在
|
||||||
|
if (!this.video) {
|
||||||
|
console.log('[错误恢复] video 对象不存在,无法恢复');
|
||||||
|
this.showError(errorMsg);
|
||||||
|
this.unloadVideo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超过最大重试次数,显示错误并卸载视频
|
||||||
|
if (this._errorRetryCount >= this._maxErrorRetry) {
|
||||||
|
this.showError(errorMsg);
|
||||||
|
this.unloadVideo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._errorRetryCount++;
|
||||||
|
|
||||||
|
// 第 1 次错误静默处理,显示 loading
|
||||||
|
if (this._errorRetryCount === 1) {
|
||||||
|
this.showLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前播放时间,向后跳过 1 秒(跳过损坏的 GOP 区间)
|
||||||
|
var currentTime = this.video.currentTime || 0;
|
||||||
|
// 确保 currentTime 是有效数字
|
||||||
|
if (isNaN(currentTime) || currentTime < 0) {
|
||||||
|
currentTime = 0;
|
||||||
|
}
|
||||||
|
var duration = this.getDuration() || 0;
|
||||||
|
var skipTime = Math.min(currentTime + 1, duration);
|
||||||
|
|
||||||
|
console.log('[错误恢复] 开始处理,当前时间:' + currentTime + 's, 跳转到:' + skipTime + 's');
|
||||||
|
|
||||||
|
// 关键:保存当前 src,然后重新加载视频来恢复状态
|
||||||
|
// var currentSrc = this.video.currentSrc || this.video.src;
|
||||||
|
console.log('[错误恢复] 重新加载视频...');
|
||||||
|
|
||||||
|
// 保存当前倍速设置
|
||||||
|
var currentPlaybackRate = this.video.playbackRate || 1;
|
||||||
|
console.log('[错误恢复] 保存倍速:' + currentPlaybackRate);
|
||||||
|
|
||||||
|
// 设置标志,告诉 loadeddata 不要执行 seek
|
||||||
|
this._isErrorRecovering = true;
|
||||||
|
|
||||||
|
// 重新设置 src 并加载
|
||||||
|
// this.video.src = currentSrc;
|
||||||
|
this.video.load();
|
||||||
|
|
||||||
|
// 清除之前的定时器(如果有)
|
||||||
|
if (this._errorRecoveryTimer) {
|
||||||
|
clearTimeout(this._errorRecoveryTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等待元数据加载完成
|
||||||
|
var onLoadedMetadata = function() {
|
||||||
|
// 检查 video 对象是否仍然存在
|
||||||
|
if (!_this.video) {
|
||||||
|
console.log('[错误恢复] loadedmetadata 触发,但 video 对象已不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[错误恢复] loadedmetadata 触发,设置 currentTime = ' + skipTime);
|
||||||
|
_this.video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
|
||||||
|
// 清除超时定时器
|
||||||
|
if (_this._errorRecoveryTimer) {
|
||||||
|
clearTimeout(_this._errorRecoveryTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 恢复倍速设置
|
||||||
|
_this.video.playbackRate = currentPlaybackRate;
|
||||||
|
console.log('[错误恢复] 恢复倍速:' + currentPlaybackRate);
|
||||||
|
|
||||||
|
// 设置跳转位置
|
||||||
|
_this.video.currentTime = skipTime;
|
||||||
|
|
||||||
|
// 等待 seek 完成后立即播放
|
||||||
|
var onSeeked = function() {
|
||||||
|
// 检查 video 对象是否仍然存在
|
||||||
|
if (!_this2.video) {
|
||||||
|
console.log('[错误恢复] seeked 触发,但 video 对象已不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[错误恢复] seeked 触发,实际位置:' + _this2.video.currentTime);
|
||||||
|
_this2.video.removeEventListener('seeked', onSeeked);
|
||||||
|
_this2.tryPlayAfterSeek(errorMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
_this.video.addEventListener('seeked', onSeeked);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
|
||||||
|
// loadedmetadata 超时处理
|
||||||
|
this._errorRecoveryTimer = setTimeout(function() {
|
||||||
|
console.log('[错误恢复] loadedmetadata 超时,直接尝试播放');
|
||||||
|
if (_this.video) {
|
||||||
|
_this.video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
}
|
||||||
|
_this.tryPlayAfterSeek(errorMsg);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试播放
|
||||||
|
}, {
|
||||||
|
key: "tryPlayAfterSeek",
|
||||||
|
value: function tryPlayAfterSeek(errorMsg) {
|
||||||
|
var _this2 = this;
|
||||||
|
|
||||||
|
console.log('[错误恢复] 准备播放,当前状态:paused=' + this.video.paused + ', readyState=' + this.video.readyState + ', currentTime=' + this.video.currentTime);
|
||||||
|
|
||||||
|
// 直接调用 play(),Promise 会处理缓冲
|
||||||
|
var playPromise = this.video.play();
|
||||||
|
console.log('[错误恢复] play() 已调用');
|
||||||
|
|
||||||
|
if (playPromise && typeof playPromise.then === 'function') {
|
||||||
|
playPromise.then(function() {
|
||||||
|
console.log('[错误恢复] 播放成功!');
|
||||||
|
_this2._errorRetryCount = 0;
|
||||||
|
_this2._isErrorRecovering = false; // 清除错误恢复标志
|
||||||
|
// 清除所有定时器
|
||||||
|
if (_this2._errorRecoveryTimer) {
|
||||||
|
clearTimeout(_this2._errorRecoveryTimer);
|
||||||
|
_this2._errorRecoveryTimer = null;
|
||||||
|
}
|
||||||
|
_this2.hideLoading();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.log('[错误恢复] 播放失败:', err);
|
||||||
|
_this2._skipErrorAndRetry(errorMsg);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_this2._errorRetryCount = 0;
|
||||||
|
_this2._isErrorRecovering = false;
|
||||||
|
_this2.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
//播放上一个视频
|
//播放上一个视频
|
||||||
}, {
|
}, {
|
||||||
key: "prev",
|
key: "prev",
|
||||||
@@ -2669,19 +2895,27 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
value: function exitFullscreen() {
|
value: function exitFullscreen() {
|
||||||
var _this26 = this;
|
var _this26 = this;
|
||||||
this.setDirection(false).then(function () {
|
this.setDirection(false).then(function () {
|
||||||
var cfs = document.exitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen || document.webkitExitFullscreen;
|
// #ifdef APP-PLUS
|
||||||
if (typeof cfs != 'undefined' && cfs) {
|
// 不使用浏览器的退出全屏了,全部通过css模拟退出全屏
|
||||||
cfs.call(document);
|
|
||||||
} else if (typeof window.ActiveXObject !== "undefined") {
|
|
||||||
//IE浏览器,模拟按下F11键退出全屏
|
|
||||||
var wscript = new ActiveXObject("WScript.Shell");
|
|
||||||
if (wscript != null) {
|
|
||||||
wscript.SendKeys("{F11}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_this26.container.classList.remove('yb-player-openfull');
|
_this26.container.classList.remove('yb-player-openfull');
|
||||||
_this26._fullscreenchanged();
|
_this26._fullscreenchanged();
|
||||||
}
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
// var cfs = document.exitFullscreen || document.mozCancelFullScreen || document.msExitFullscreen || document.webkitExitFullscreen;
|
||||||
|
// if (typeof cfs != 'undefined' && cfs) {
|
||||||
|
// cfs.call(document);
|
||||||
|
// } else if (typeof window.ActiveXObject !== "undefined") {
|
||||||
|
// //IE浏览器,模拟按下F11键退出全屏
|
||||||
|
// var wscript = new ActiveXObject("WScript.Shell");
|
||||||
|
// if (wscript != null) {
|
||||||
|
// wscript.SendKeys("{F11}");
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// _this26.container.classList.remove('yb-player-openfull');
|
||||||
|
// _this26._fullscreenchanged();
|
||||||
|
// }
|
||||||
|
// #endif
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
//开启全屏
|
//开启全屏
|
||||||
@@ -2691,18 +2925,25 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
var _this27 = this;
|
var _this27 = this;
|
||||||
this.openDirection = direction || this.openDirection;
|
this.openDirection = direction || this.openDirection;
|
||||||
this.setDirection(true).then(function () {
|
this.setDirection(true).then(function () {
|
||||||
var rfs = document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen || document.documentElement.mozRequestFullscreen || document.documentElement.requestFullScreen || document.documentElement.webkitRequestFullScreen || document.documentElement.mozRequestFullScreen;
|
// #ifdef APP-PLUS
|
||||||
if (typeof rfs != 'undefined' && rfs) {
|
// 不使用浏览器的全屏了,全部通过css模拟全屏
|
||||||
rfs.call(_this27.container);
|
|
||||||
} else if (typeof window.ActiveXObject !== "undefined") {
|
|
||||||
//IE浏览器,模拟按下F11全屏
|
|
||||||
var wscript = new ActiveXObject("WScript.Shell");
|
|
||||||
if (wscript != null) {
|
|
||||||
wscript.SendKeys("{F11}");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_this27._fullscreenerror();
|
_this27._fullscreenerror();
|
||||||
}
|
// #endif
|
||||||
|
|
||||||
|
// #ifndef APP-PLUS
|
||||||
|
// var rfs = document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen || document.documentElement.mozRequestFullscreen || document.documentElement.requestFullScreen || document.documentElement.webkitRequestFullScreen || document.documentElement.mozRequestFullScreen;
|
||||||
|
// if (typeof rfs != 'undefined' && rfs) {
|
||||||
|
// rfs.call(_this27.container);
|
||||||
|
// } else if (typeof window.ActiveXObject !== "undefined") {
|
||||||
|
// //IE浏览器,模拟按下F11全屏
|
||||||
|
// var wscript = new ActiveXObject("WScript.Shell");
|
||||||
|
// if (wscript != null) {
|
||||||
|
// wscript.SendKeys("{F11}");
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
// _this27._fullscreenerror();
|
||||||
|
// }
|
||||||
|
// #endif
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
//是否支持全屏API
|
//是否支持全屏API
|
||||||
@@ -2759,14 +3000,17 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
this.emit('controlschange', {
|
this.emit('controlschange', {
|
||||||
show: true
|
show: true
|
||||||
});
|
});
|
||||||
|
if (!this.alwaysShowControls) {
|
||||||
this._controlsTimer = window.setTimeout(function () {
|
this._controlsTimer = window.setTimeout(function () {
|
||||||
_this28.hideControls();
|
_this28.hideControls();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
//关闭控制栏
|
//关闭控制栏
|
||||||
}, {
|
}, {
|
||||||
key: "hideControls",
|
key: "hideControls",
|
||||||
value: function hideControls(item, timer) {
|
value: function hideControls(item, timer) {
|
||||||
|
if (this.alwaysShowControls) return;
|
||||||
this._clearControlsTimer();
|
this._clearControlsTimer();
|
||||||
this.container.getElementsByClassName('yb-player-controls')[0].classList.remove('yb-player-controls-show');
|
this.container.getElementsByClassName('yb-player-controls')[0].classList.remove('yb-player-controls-show');
|
||||||
this.container.getElementsByClassName('yb-player-header')[0].classList.remove('yb-player-header-show');
|
this.container.getElementsByClassName('yb-player-header')[0].classList.remove('yb-player-header-show');
|
||||||
@@ -2902,6 +3146,14 @@ var YbPlayer = /*#__PURE__*/function () {
|
|||||||
this._seizingTimer = null;
|
this._seizingTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
key: "_clearRateTimer",
|
||||||
|
value: function _clearRateTimer() {
|
||||||
|
if (this._rateTimer) {
|
||||||
|
window.clearTimeout(this._rateTimer);
|
||||||
|
this._rateTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}, {
|
}, {
|
||||||
key: "_removeBackbuttonListener",
|
key: "_removeBackbuttonListener",
|
||||||
value: function _removeBackbuttonListener() {
|
value: function _removeBackbuttonListener() {
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
isLive: params.isLive,
|
isLive: params.isLive,
|
||||||
header: params.header,
|
header: params.header,
|
||||||
controls: params.controls,
|
controls: params.controls,
|
||||||
|
alwaysShowControls: params.alwaysShowControls,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: params.objectFit,
|
objectFit: params.objectFit,
|
||||||
crossOrigin: params.crossOrigin,
|
crossOrigin: params.crossOrigin,
|
||||||
@@ -79,6 +80,7 @@
|
|||||||
workIndex: params.workIndex,
|
workIndex: params.workIndex,
|
||||||
subtitles: params.subtitles,
|
subtitles: params.subtitles,
|
||||||
subtitleIndex: params.subtitleIndex,
|
subtitleIndex: params.subtitleIndex,
|
||||||
|
playbackRates: params.playbackRates,
|
||||||
custom: params.custom,
|
custom: params.custom,
|
||||||
decoder: {
|
decoder: {
|
||||||
hls: {
|
hls: {
|
||||||
|
|||||||
Reference in New Issue
Block a user