Compare commits
3 Commits
b15acbb984
...
ac60a863e3
| Author | SHA1 | Date | |
|---|---|---|---|
| ac60a863e3 | |||
| 754865e23e | |||
| e7fb2c61c1 |
@@ -41,9 +41,9 @@ export async function getVipInfo() {
|
|||||||
*/
|
*/
|
||||||
export async function getOrderList(current: number, limit: number, orderStatus: string) {
|
export async function getOrderList(current: number, limit: number, orderStatus: string) {
|
||||||
const res = await mainClient.request<IApiResponse<{ orders: IPageData<IOrder> }>>({
|
const res = await mainClient.request<IApiResponse<{ orders: IPageData<IOrder> }>>({
|
||||||
url: 'bookAbroad/home/getAbroadOrderList',
|
url: 'common/buyOrder/commonBuyOrderList',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { current, limit, orderStatus }
|
data: { current, limit, orderStatus, come: '10', userId: uni.getStorageSync('userInfo').id }
|
||||||
})
|
})
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|||||||
60
api/modules/video.ts
Normal file
60
api/modules/video.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// api/modules/video.ts
|
||||||
|
import { createRequestClient } from '../request'
|
||||||
|
import { SERVICE_MAP } from '../config'
|
||||||
|
import type { IApiResponse } from '../types'
|
||||||
|
import type { IVideoCheckResponse } from '@/types/video'
|
||||||
|
|
||||||
|
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频相关API
|
||||||
|
*/
|
||||||
|
export const videoApi = {
|
||||||
|
/**
|
||||||
|
* 获取视频播放信息
|
||||||
|
* 接口: sociology/course/checkVideo
|
||||||
|
* 方法: POST
|
||||||
|
*/
|
||||||
|
checkVideo(data: {
|
||||||
|
id: number
|
||||||
|
}) {
|
||||||
|
return client.request<IVideoCheckResponse>({
|
||||||
|
url: 'sociology/course/checkVideo',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存播放进度
|
||||||
|
* 接口: sociology/course/saveCoursePosition
|
||||||
|
* 方法: POST
|
||||||
|
*/
|
||||||
|
saveCoursePosition(data: {
|
||||||
|
videoId: number
|
||||||
|
position: number
|
||||||
|
}) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'sociology/course/saveCoursePosition',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报告错误视频
|
||||||
|
* 接口: medical/course/addErrorCourse
|
||||||
|
* 方法: POST
|
||||||
|
*/
|
||||||
|
addErrorCourse(data: {
|
||||||
|
chapterId: number
|
||||||
|
videoId: number
|
||||||
|
sort: number
|
||||||
|
}) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
|
url: 'medical/course/addErrorCourse',
|
||||||
|
method: 'POST',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
295
components/ali-video/PopupPlayer.vue
Normal file
295
components/ali-video/PopupPlayer.vue
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<u-popup key="1" :show="videoShow" :round="10" @close="closeVideo">
|
||||||
|
<view class="" style="padding: 10px;">
|
||||||
|
<view>
|
||||||
|
<view class="container">
|
||||||
|
<div
|
||||||
|
ref="videoContent"
|
||||||
|
@tap="renderScript.handleClick"
|
||||||
|
id="url-player-test"
|
||||||
|
:videoData="videoData"
|
||||||
|
:opname="opname"
|
||||||
|
:change:opname="renderScript.opnameChange"
|
||||||
|
:change:videoData="renderScript.receiveMsg"
|
||||||
|
></div>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<div class="fullScreenButton-container">
|
||||||
|
<div
|
||||||
|
:class="`prism-fullscreen-btn ${isFullScreen ? 'fullscreen' : ''}`"
|
||||||
|
@tap="renderScript.changeVideoScreen"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<view class="btn" style="text-align: center;">
|
||||||
|
<button type="primary" @click="closeVideo" size="mini">关闭视频</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</u-popup>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import type { IVideoData } from '@/types/video'
|
||||||
|
|
||||||
|
// Props 定义
|
||||||
|
interface Props {
|
||||||
|
videoData: IVideoData
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Emits 定义
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const videoShow = ref(true)
|
||||||
|
const isFullScreen = ref(false)
|
||||||
|
const opname = ref('')
|
||||||
|
|
||||||
|
// 方法: 关闭视频
|
||||||
|
const closeVideo = () => {
|
||||||
|
opname.value = 'close'
|
||||||
|
nextTick(() => {
|
||||||
|
emit('close')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script module="renderScript" lang="renderjs">
|
||||||
|
// RenderJS 模块 - 保持 Vue2 写法
|
||||||
|
// jQuery 在 RenderJS 中通过全局变量访问
|
||||||
|
var $ = window.jQuery || window.$
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mounted() {
|
||||||
|
console.log(this.options, '这是monted')
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
player: null,
|
||||||
|
curTime: null,
|
||||||
|
curStatus: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
curTime(val) {
|
||||||
|
if (this.curTime !== null && this.curStatus !== null) {
|
||||||
|
// 可以添加逻辑
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
handleClick(event, ownerInstance) {
|
||||||
|
console.log('event at line 165:', event)
|
||||||
|
},
|
||||||
|
|
||||||
|
emitData(event, ownerInstance) {
|
||||||
|
ownerInstance.callMethod('recordTime', {
|
||||||
|
time: this.curTime,
|
||||||
|
status: this.curStatus
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
changeVideoScreen(event, ownerInstance) {
|
||||||
|
var status = this.player.fullscreenService.getIsFullScreen()
|
||||||
|
|
||||||
|
ownerInstance.callMethod('screenChange', {
|
||||||
|
status: status,
|
||||||
|
primary: status ? 'portrait' : 'landscape'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
setTimeout(() => {
|
||||||
|
plus.screen.lockOrientation('portrait-primary')
|
||||||
|
this.player.fullscreenService.cancelFullScreen()
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
this.player.fullscreenService.requestFullScreen()
|
||||||
|
setTimeout(() => {
|
||||||
|
plus.screen.lockOrientation('landscape-primary')
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
endEmitData(event, ownerInstance) {
|
||||||
|
ownerInstance.callMethod('handleEnd')
|
||||||
|
},
|
||||||
|
|
||||||
|
getLive() {
|
||||||
|
if (this.videoData.type == 1) {
|
||||||
|
var fullScreenButtonComponent = Aliplayer.Component({
|
||||||
|
init: function(status, toAddress) {
|
||||||
|
this.fullScreenStatus = status
|
||||||
|
this.$html = $('.fullScreenButton-container')
|
||||||
|
},
|
||||||
|
|
||||||
|
createEl: function(el) {
|
||||||
|
this.$html.find('.ad').attr('src', this.adAddress)
|
||||||
|
var $adWrapper = this.$html.find('.ad-wrapper')
|
||||||
|
$adWrapper.attr('href', this.toAddress)
|
||||||
|
$adWrapper.click(function() {})
|
||||||
|
$(el).find('.prism-time-display').after(this.$html)
|
||||||
|
},
|
||||||
|
|
||||||
|
playing: function(player, e) {
|
||||||
|
this.$html.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('this.currentVideoList at line 456111111111111111111111:', this.videoList)
|
||||||
|
|
||||||
|
var player = new Aliplayer({
|
||||||
|
id: 'url-player-test',
|
||||||
|
vid: this.videoData.videoId,
|
||||||
|
playauth: this.videoData.playAuth,
|
||||||
|
encryptType: 1,
|
||||||
|
playConfig: {
|
||||||
|
EncryptType: 'AliyunVoDEncryption'
|
||||||
|
},
|
||||||
|
width: '100%',
|
||||||
|
height: '200px',
|
||||||
|
playsinline: true,
|
||||||
|
controlBarVisibility: 'click',
|
||||||
|
cover: '',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
name: 'adComponent',
|
||||||
|
type: fullScreenButtonComponent,
|
||||||
|
args: ['http://101.201.146.165:8088/Pf-EH/statics/uploadFile/2024-05-10/b0f420c7-9178-41ad-9dd6-f59a64a6e190.png']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
skinLayout: [
|
||||||
|
{ name: 'bigPlayButton', align: 'blabs', x: 30, y: 80 },
|
||||||
|
{ name: 'H5Loading', align: 'cc' },
|
||||||
|
{ name: 'errorDisplay', align: 'tlabs', x: 0, y: 0 },
|
||||||
|
{ name: 'infoDisplay' },
|
||||||
|
{ name: 'tooltip', align: 'blabs', x: 0, y: 56 },
|
||||||
|
{ name: 'thumbnail' },
|
||||||
|
{
|
||||||
|
name: 'controlBar',
|
||||||
|
align: 'blabs',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
children: [
|
||||||
|
{ name: 'progress', align: 'blabs', x: 0, y: 44 },
|
||||||
|
{ name: 'playButton', align: 'tl', x: 15, y: 12 },
|
||||||
|
{ name: 'timeDisplay', align: 'tl', x: 10, y: 7 },
|
||||||
|
{ name: 'prism-speed-selector', align: 'tr', x: 15, y: 12 },
|
||||||
|
{ name: 'volume', align: 'tr', x: 5, y: 10 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, function(player) {})
|
||||||
|
|
||||||
|
this.player = player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
opnameChange(newValue, oldValue, ownerVm, vm) {
|
||||||
|
console.log('opnameChange-----------', newValue)
|
||||||
|
if (newValue == 'close') {
|
||||||
|
if (this.player) {
|
||||||
|
this.player.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
receiveMsg(newValue, oldValue, ownerVm, vm) {
|
||||||
|
console.log('数据变化newValue', newValue)
|
||||||
|
if (newValue.playAuth) {
|
||||||
|
this.loadWebPlayerSDK()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkValue() {
|
||||||
|
console.log(this.videoData, this.videoData.playAuth, '1111888888')
|
||||||
|
if (!this.videoData.playAuth) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.checkValue()
|
||||||
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
console.log('this.videoList at line 这是这只只是594:', this.currentVideoList)
|
||||||
|
this.getLive()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadWebPlayerSDK() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 先加载 jQuery
|
||||||
|
if (!window.jQuery && !window.$) {
|
||||||
|
const jquery_tag = document.createElement('script')
|
||||||
|
jquery_tag.type = 'text/javascript'
|
||||||
|
jquery_tag.src = 'https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js'
|
||||||
|
jquery_tag.charset = 'utf-8'
|
||||||
|
jquery_tag.onload = () => {
|
||||||
|
$ = window.jQuery || window.$
|
||||||
|
this.loadAliPlayerSDK(resolve, reject)
|
||||||
|
}
|
||||||
|
jquery_tag.onerror = () => {
|
||||||
|
console.error('jQuery 加载失败')
|
||||||
|
reject(new Error('jQuery 加载失败'))
|
||||||
|
}
|
||||||
|
document.body.appendChild(jquery_tag)
|
||||||
|
} else {
|
||||||
|
$ = window.jQuery || window.$
|
||||||
|
this.loadAliPlayerSDK(resolve, reject)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
loadAliPlayerSDK(resolve, reject) {
|
||||||
|
const s_tag = document.createElement('script')
|
||||||
|
s_tag.type = 'text/javascript'
|
||||||
|
s_tag.src = 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/aliplayer-min.js'
|
||||||
|
s_tag.charset = 'utf-8'
|
||||||
|
s_tag.onload = () => {
|
||||||
|
const s_tag1 = document.createElement('script')
|
||||||
|
s_tag1.type = 'text/javascript'
|
||||||
|
s_tag1.src = 'https://player.alicdn.com/aliplayer/presentation/js/aliplayercomponents.min.js'
|
||||||
|
s_tag1.charset = 'utf-8'
|
||||||
|
s_tag1.onload = () => {
|
||||||
|
this.checkValue()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
s_tag1.onerror = () => {
|
||||||
|
console.error('阿里云播放器组件加载失败')
|
||||||
|
reject(new Error('阿里云播放器组件加载失败'))
|
||||||
|
}
|
||||||
|
document.body.appendChild(s_tag1)
|
||||||
|
}
|
||||||
|
s_tag.onerror = () => {
|
||||||
|
console.error('阿里云播放器 SDK 加载失败')
|
||||||
|
reject(new Error('阿里云播放器 SDK 加载失败'))
|
||||||
|
}
|
||||||
|
document.body.appendChild(s_tag)
|
||||||
|
|
||||||
|
const l_tag = document.createElement('link')
|
||||||
|
l_tag.rel = 'stylesheet'
|
||||||
|
l_tag.href = 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/skins/default/aliplayer-min.css'
|
||||||
|
document.body.appendChild(l_tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fullScreenButton-container {
|
||||||
|
color: #fff;
|
||||||
|
float: right;
|
||||||
|
height: 35px;
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-right: 5px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
components/ali-video/VideoList.vue
Normal file
123
components/ali-video/VideoList.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<view class="richDetail">
|
||||||
|
<view
|
||||||
|
scroll-x="true"
|
||||||
|
class="detail_title video_box"
|
||||||
|
style="background-color: #fff"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
v-for="(v, i) in dataList"
|
||||||
|
:key="v.id"
|
||||||
|
:class="`video_item ${currentVideo && currentVideo.id == v.id ? 'hot' : ''}`"
|
||||||
|
@click="handleClick(v, i)"
|
||||||
|
>
|
||||||
|
【{{ v.type == '2' ? '音频' : '视频' }}】{{ getNumber(i + 1) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<slot name="richHeadImg"></slot>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { IVideoInfo, IChapterDetail } from '@/types/video'
|
||||||
|
|
||||||
|
// Props 定义
|
||||||
|
interface Props {
|
||||||
|
detailInfo: IChapterDetail
|
||||||
|
dataList: IVideoInfo[]
|
||||||
|
currentVideo: IVideoInfo | null
|
||||||
|
changeVideoLock: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Emits 定义
|
||||||
|
const emit = defineEmits<{
|
||||||
|
open: [video: IVideoInfo]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 方法: 格式化序号为两位数
|
||||||
|
const getNumber = (num: number): string => {
|
||||||
|
if (num >= 10) {
|
||||||
|
return num.toString()
|
||||||
|
} else {
|
||||||
|
return `0${num}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法: 处理视频点击
|
||||||
|
const handleClick = (data: IVideoInfo, index: number) => {
|
||||||
|
if (props.changeVideoLock) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('data at line 35:', data)
|
||||||
|
emit('open', data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.commonPageBox {
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contentBox {
|
||||||
|
.headImage {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail_title {
|
||||||
|
padding: 0 20rpx 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 65rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich_box {
|
||||||
|
padding: 20rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
p {
|
||||||
|
display: block;
|
||||||
|
text-indent: 2em;
|
||||||
|
letter-spacing: 2px !important;
|
||||||
|
line-height: 46rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.richDetail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video_box {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.video_item {
|
||||||
|
width: 23%;
|
||||||
|
margin-right: 10rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
float: left;
|
||||||
|
border: 2rpx solid #2979ff;
|
||||||
|
background: #fff;
|
||||||
|
color: #2979ff;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
box-shadow: 0px 0px 6rpx 0px rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video_item:nth-child(4n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hot {
|
||||||
|
background-color: #2979ff !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1015
components/ali-video/index.vue
Normal file
1015
components/ali-video/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,7 @@ interface Props {
|
|||||||
chapters: IChapter[]
|
chapters: IChapter[]
|
||||||
catalogue: ICatalogue
|
catalogue: ICatalogue
|
||||||
userVip: IVipInfo | null
|
userVip: IVipInfo | null
|
||||||
|
showRenewBtn?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -136,6 +137,8 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: [chapter: IChapter],
|
click: [chapter: IChapter],
|
||||||
purchase: [catalogue: ICatalogue],
|
purchase: [catalogue: ICatalogue],
|
||||||
|
renew: [catalogue: ICatalogue],
|
||||||
|
toVip: [catalogue: ICatalogue],
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +156,16 @@ const goToVip = () => {
|
|||||||
emit('toVip', props.catalogue)
|
emit('toVip', props.catalogue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 续费/复读
|
||||||
|
const handleRenew = () => {
|
||||||
|
emit('renew', props.catalogue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 领取免费课程
|
||||||
|
const handleGetFreeCourse = () => {
|
||||||
|
emit('purchase', props.catalogue)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断章节是否可以访问
|
* 判断章节是否可以访问
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,242 +1,394 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="video-player">
|
<div class="ali-player-wrapper" :style="{ background: '#000' }">
|
||||||
<!-- 视频播放器 -->
|
<div v-if="showError" class="player-error">{{ errorText }}</div>
|
||||||
<video
|
|
||||||
v-if="currentVideo"
|
<div ref="playerContainer" class="player-container" :style="{ width: '100%', height: playerHeight }"></div>
|
||||||
:id="videoId"
|
|
||||||
:src="currentVideo.url"
|
<!-- 倒计时覆盖(可选,父层也可以自行实现) -->
|
||||||
:title="currentVideo.title"
|
<div v-if="showCountDown" class="countdown-overlay">
|
||||||
:controls="true"
|
<div class="countdown-text">{{ countDownSeconds }} 秒后播放下一个视频</div>
|
||||||
:show-fullscreen-btn="true"
|
<button class="btn-cancel" @click="cancelNext">取消下一个</button>
|
||||||
:show-play-btn="true"
|
</div>
|
||||||
:enable-progress-gesture="true"
|
|
||||||
:object-fit="objectFit"
|
<!-- 控制按钮示例(父层应该控制 UI,我仅提供常用API按钮用于调试) -->
|
||||||
class="video-element"
|
<div class="player-controls" style="display:none;">
|
||||||
@fullscreenchange="handleFullscreenChange"
|
<button @click="play()">播放</button>
|
||||||
@ended="handleVideoEnd"
|
<button @click="pause()">暂停</button>
|
||||||
@error="handleVideoError"
|
<button @click="replay()">重播</button>
|
||||||
/>
|
<button @click="enterFullscreen()">全屏</button>
|
||||||
|
</div>
|
||||||
<!-- 自动播放下一个提示 -->
|
</div>
|
||||||
<view v-if="showCountDown && hasNext" class="countdown-overlay">
|
|
||||||
<view class="countdown-content">
|
|
||||||
<text class="countdown-text">{{ countDownSeconds }}秒后自动播放下一个</text>
|
|
||||||
<wd-button size="small" @click="cancelAutoPlay">
|
|
||||||
取消自动播放
|
|
||||||
</wd-button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, PropType, nextTick } from 'vue';
|
||||||
import type { IVideo } from '@/types/course'
|
|
||||||
|
|
||||||
interface Props {
|
type Platform = 'web' | 'app-ios' | 'app-android';
|
||||||
videoList: IVideo[]
|
|
||||||
currentIndex: number
|
|
||||||
noRecored?: boolean // 是否为试听(不记录进度)
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
export default defineComponent({
|
||||||
|
name: 'AliyunPlayer',
|
||||||
|
props: {
|
||||||
|
// videoData: should include fields similar to original: type, m3u8Url, videoUrl, videoId, playAuth, firstTime, id...
|
||||||
|
videoData: { type: Object as PropType<Record<string, any>>, required: true },
|
||||||
|
// platform hint: affects screen lock behavior; default web
|
||||||
|
platform: { type: String as PropType<Platform>, default: 'web' },
|
||||||
|
// height for player area
|
||||||
|
height: { type: String, default: '200px' },
|
||||||
|
// auto start playback
|
||||||
|
autoplay: { type: Boolean, default: true },
|
||||||
|
// how often to auto-save progress in seconds (default 60)
|
||||||
|
autoSaveInterval: { type: Number, default: 60 },
|
||||||
|
// localStorage key prefix for resume data
|
||||||
|
storageKeyPrefix: { type: String, default: 'videoOssList' },
|
||||||
|
// flag: in APP environment should use WebView (true) or try to run player directly in page (false)
|
||||||
|
useWebViewForApp: { type: Boolean, default: false },
|
||||||
|
// urls for loading Aliplayer (allow overriding if needed)
|
||||||
|
playerScriptUrl: { type: String, default: 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/aliplayer-min.js' },
|
||||||
|
playerComponentsUrl: { type: String, default: 'https://player.alicdn.com/aliplayer/presentation/js/aliplayercomponents.min.js' },
|
||||||
|
playerCssUrl: { type: String, default: 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/skins/default/aliplayer-min.css' },
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'ready',
|
||||||
|
'play',
|
||||||
|
'pause',
|
||||||
|
'timeupdate',
|
||||||
|
'progress-save', // payload: { videoId, position }
|
||||||
|
'ended',
|
||||||
|
'error',
|
||||||
|
'request-playauth', // in case parent wants to fetch playAuth separately
|
||||||
|
'change-screen',
|
||||||
|
'load-next' // when ended and parent should load next
|
||||||
|
],
|
||||||
|
setup(props, { emit, expose }) {
|
||||||
|
const playerContainer = ref<HTMLElement | null>(null);
|
||||||
|
const playerInstance = ref<any | null>(null);
|
||||||
|
const scriptLoaded = ref(false);
|
||||||
|
const timerDiff = ref(0);
|
||||||
|
const currentSeconds = ref(0);
|
||||||
|
const pauseTime = ref(0);
|
||||||
|
const saveCounter = ref(0);
|
||||||
|
const autoSaveIntervalId = ref<number | null>(null);
|
||||||
|
const showCountDown = ref(false);
|
||||||
|
const countDownSeconds = ref(5);
|
||||||
|
const countdownTimerId = ref<number | null>(null);
|
||||||
|
const showError = ref(false);
|
||||||
|
const errorText = ref('');
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const playerHeight = props.height;
|
||||||
end: []
|
|
||||||
fullscreen: [isFullScreen: boolean]
|
|
||||||
change: [index: number]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const videoId = 'course-video-player'
|
// helper: localStorage save/load (simple array of {id, time})
|
||||||
const videoContext = ref<any>(null)
|
function loadResumeList(): Array<any> {
|
||||||
const objectFit = ref<'contain' | 'fill' | 'cover'>('contain')
|
try {
|
||||||
const showCountDown = ref(false)
|
const raw = localStorage.getItem(props.storageKeyPrefix);
|
||||||
const countDownSeconds = ref(10)
|
return raw ? JSON.parse(raw) : [];
|
||||||
const countDownTimer = ref<any>(null)
|
} catch (e) {
|
||||||
|
return [];
|
||||||
/**
|
|
||||||
* 当前视频
|
|
||||||
*/
|
|
||||||
const currentVideo = computed(() => {
|
|
||||||
if (props.videoList.length === 0) return null
|
|
||||||
return props.videoList[props.currentIndex] || null
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 是否有下一个视频
|
|
||||||
*/
|
|
||||||
const hasNext = computed(() => {
|
|
||||||
return props.currentIndex < props.videoList.length - 1
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化视频上下文
|
|
||||||
*/
|
|
||||||
const initVideoContext = () => {
|
|
||||||
videoContext.value = uni.createVideoContext(videoId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频播放结束
|
|
||||||
*/
|
|
||||||
const handleVideoEnd = () => {
|
|
||||||
emit('end')
|
|
||||||
|
|
||||||
// 如果有下一个视频,开始倒计时
|
|
||||||
if (hasNext.value) {
|
|
||||||
startCountDown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 开始倒计时
|
|
||||||
*/
|
|
||||||
const startCountDown = () => {
|
|
||||||
showCountDown.value = true
|
|
||||||
countDownSeconds.value = 10
|
|
||||||
|
|
||||||
countDownTimer.value = setInterval(() => {
|
|
||||||
countDownSeconds.value--
|
|
||||||
|
|
||||||
if (countDownSeconds.value <= 0) {
|
|
||||||
stopCountDown()
|
|
||||||
playNext()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止倒计时
|
|
||||||
*/
|
|
||||||
const stopCountDown = () => {
|
|
||||||
if (countDownTimer.value) {
|
|
||||||
clearInterval(countDownTimer.value)
|
|
||||||
countDownTimer.value = null
|
|
||||||
}
|
|
||||||
showCountDown.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 取消自动播放
|
|
||||||
*/
|
|
||||||
const cancelAutoPlay = () => {
|
|
||||||
stopCountDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 播放下一个视频
|
|
||||||
*/
|
|
||||||
const playNext = () => {
|
|
||||||
if (hasNext.value) {
|
|
||||||
emit('change', props.currentIndex + 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全屏变化
|
|
||||||
*/
|
|
||||||
const handleFullscreenChange = (e: any) => {
|
|
||||||
const isFullScreen = e.detail.fullScreen
|
|
||||||
emit('fullscreen', isFullScreen)
|
|
||||||
|
|
||||||
// 全屏时使用 cover 模式
|
|
||||||
objectFit.value = isFullScreen ? 'cover' : 'contain'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频错误
|
|
||||||
*/
|
|
||||||
const handleVideoError = (e: any) => {
|
|
||||||
console.error('视频播放错误:', e)
|
|
||||||
uni.showToast({
|
|
||||||
title: '课程视频播放组件正在开发中,现在还不能播放视频',
|
|
||||||
icon: 'none'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 播放视频
|
|
||||||
*/
|
|
||||||
const play = () => {
|
|
||||||
if (videoContext.value) {
|
|
||||||
videoContext.value.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 暂停视频
|
|
||||||
*/
|
|
||||||
const pause = () => {
|
|
||||||
if (videoContext.value) {
|
|
||||||
videoContext.value.pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止视频
|
|
||||||
*/
|
|
||||||
const stop = () => {
|
|
||||||
if (videoContext.value) {
|
|
||||||
videoContext.value.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听视频变化,重新播放
|
|
||||||
watch(() => props.currentIndex, () => {
|
|
||||||
stopCountDown()
|
|
||||||
// 延迟播放,确保视频元素已更新
|
|
||||||
setTimeout(() => {
|
|
||||||
play()
|
|
||||||
}, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initVideoContext()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopCountDown()
|
|
||||||
stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
play,
|
|
||||||
pause,
|
|
||||||
stop,
|
|
||||||
cancelAutoPlay: stopCountDown
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.video-player {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #000;
|
|
||||||
|
|
||||||
.video-element {
|
|
||||||
width: 100%;
|
|
||||||
height: 400rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.countdown-overlay {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
padding: 20rpx;
|
|
||||||
|
|
||||||
.countdown-content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.countdown-text {
|
|
||||||
color: #fff;
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function saveResumeItem(videoId: any, time: number) {
|
||||||
|
try {
|
||||||
|
const list = loadResumeList();
|
||||||
|
const idx = list.findIndex((i: any) => i.id === videoId);
|
||||||
|
if (idx >= 0) list[idx].time = time;
|
||||||
|
else list.push({ id: videoId, time });
|
||||||
|
localStorage.setItem(props.storageKeyPrefix, JSON.stringify(list));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// dynamic load aliplayer script + css
|
||||||
|
function loadAliplayer(): Promise<void> {
|
||||||
|
if ((window as any).Aliplayer) {
|
||||||
|
scriptLoaded.value = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// css
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = props.playerCssUrl;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// main script
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = props.playerScriptUrl;
|
||||||
|
s.onload = () => {
|
||||||
|
// components script
|
||||||
|
const s2 = document.createElement('script');
|
||||||
|
s2.src = props.playerComponentsUrl;
|
||||||
|
s2.onload = () => {
|
||||||
|
scriptLoaded.value = true;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
s2.onerror = () => reject(new Error('aliplayer components load failed'));
|
||||||
|
document.body.appendChild(s2);
|
||||||
|
};
|
||||||
|
s.onerror = () => reject(new Error('aliplayer load failed'));
|
||||||
|
document.body.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize player with videoData
|
||||||
|
async function initPlayer() {
|
||||||
|
showError.value = false;
|
||||||
|
if (props.useWebViewForApp && props.platform !== 'web') {
|
||||||
|
// In-app recommended to use WebView. Emit event so parent can take over.
|
||||||
|
emit('error', { message: 'App environment required WebView. Set useWebViewForApp=false to attempt in-page' });
|
||||||
|
showError.value = true;
|
||||||
|
errorText.value = 'App environment recommended to use WebView for Aliplayer';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAliplayer();
|
||||||
|
|
||||||
|
// choose options
|
||||||
|
const v = props.videoData || {};
|
||||||
|
let options: Record<string, any> = {
|
||||||
|
id: (playerContainer.value as HTMLElement).id || 'ali-player-' + Math.random().toString(36).slice(2),
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
autoplay: props.autoplay,
|
||||||
|
isLive: false,
|
||||||
|
rePlay: false,
|
||||||
|
playsinline: true,
|
||||||
|
controlBarVisibility: 'hover',
|
||||||
|
useH5Prism: true,
|
||||||
|
// skinLayout can be extended if needed
|
||||||
|
skinLayout: [
|
||||||
|
{ name: 'bigPlayButton', align: 'cc' },
|
||||||
|
{ name: 'H5Loading', align: 'cc' },
|
||||||
|
{ name: 'errorDisplay', align: 'tlabs' },
|
||||||
|
{ name: 'controlBar', align: 'blabs', children: [
|
||||||
|
{ name: 'progress', align: 'blabs' },
|
||||||
|
{ name: 'playButton', align: 'tl' },
|
||||||
|
{ name: 'timeDisplay', align: 'tl' },
|
||||||
|
{ name: 'prism-speed-selector', align: 'tr' },
|
||||||
|
{ name: 'volume', align: 'tr' }
|
||||||
|
] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// decide source mode
|
||||||
|
if (v.type === 1) {
|
||||||
|
if (!v.m3u8Url) {
|
||||||
|
// private encrypted: require vid+playAuth
|
||||||
|
if (!v.videoId || !v.playAuth) {
|
||||||
|
// parent might need to request playAuth
|
||||||
|
emit('request-playauth', v);
|
||||||
|
showError.value = true;
|
||||||
|
errorText.value = '播放凭证缺失';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options = {
|
||||||
|
...options,
|
||||||
|
vid: v.videoId,
|
||||||
|
playauth: v.playAuth,
|
||||||
|
encryptType: 1,
|
||||||
|
playConfig: { EncryptType: 'AliyunVoDEncryption' }
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
options = { ...options, source: v.m3u8Url };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// not encrypted
|
||||||
|
options = { ...options, source: v.videoUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// add rate component by default
|
||||||
|
options.components = [
|
||||||
|
{ name: 'RateComponent', type: (window as any).AliPlayerComponent?.RateComponent }
|
||||||
|
];
|
||||||
|
|
||||||
|
// create player
|
||||||
|
try {
|
||||||
|
// ensure container has an id
|
||||||
|
if (playerContainer.value && !(playerContainer.value as HTMLElement).id) {
|
||||||
|
(playerContainer.value as HTMLElement).id = 'ali-player-' + Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
const player = new (window as any).Aliplayer(options, function (p: any) {
|
||||||
|
// ready
|
||||||
|
});
|
||||||
|
playerInstance.value = player;
|
||||||
|
|
||||||
|
// event binds
|
||||||
|
player.on('ready', () => {
|
||||||
|
emit('ready');
|
||||||
|
if (props.autoplay) player.play();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('play', () => {
|
||||||
|
emit('play');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('pause', () => {
|
||||||
|
pauseTime.value = Math.floor(player.getCurrentTime() || 0);
|
||||||
|
emit('pause');
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('timeupdate', () => {
|
||||||
|
const t = Math.floor(player.getCurrentTime() || 0);
|
||||||
|
if (currentSeconds.value !== t) {
|
||||||
|
currentSeconds.value = t;
|
||||||
|
emit('timeupdate', { time: t, status: player.getStatus?.() });
|
||||||
|
saveCounter.value++;
|
||||||
|
// every autoSaveInterval seconds -> emit progress-save
|
||||||
|
if (saveCounter.value >= props.autoSaveInterval) {
|
||||||
|
saveCounter.value = 0;
|
||||||
|
emit('progress-save', { videoId: props.videoData.id, position: currentSeconds.value });
|
||||||
|
// also local save
|
||||||
|
saveResumeItem(props.videoData.id, currentSeconds.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('ended', () => {
|
||||||
|
emit('ended', { videoId: props.videoData.id });
|
||||||
|
// default behavior: start countdown then emit load-next
|
||||||
|
startNextCountdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
player.on('error', (e: any) => {
|
||||||
|
showError.value = true;
|
||||||
|
errorText.value = '播放出错';
|
||||||
|
emit('error', e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// seek to resume pos if present
|
||||||
|
nextTick(() => {
|
||||||
|
const list = loadResumeList();
|
||||||
|
const idx = list.findIndex(item => item.id === props.videoData.id);
|
||||||
|
const resumeTime = idx >= 0 ? list[idx].time : (props.videoData.firstTime || 0);
|
||||||
|
const dur = player.getDuration ? Math.floor(player.getDuration() || 0) : 0;
|
||||||
|
if (resumeTime && dur && resumeTime < dur) {
|
||||||
|
player.seek(resumeTime);
|
||||||
|
} else if (resumeTime && !dur) {
|
||||||
|
// if duration unknown yet, attempt seek once canplay
|
||||||
|
player.one && player.one('canplay', () => {
|
||||||
|
const d2 = Math.floor(player.getDuration() || 0);
|
||||||
|
if (resumeTime < d2) player.seek(resumeTime);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// periodic autosave fallback (in case events miss)
|
||||||
|
if (autoSaveIntervalId.value) window.clearInterval(autoSaveIntervalId.value);
|
||||||
|
autoSaveIntervalId.value = window.setInterval(() => {
|
||||||
|
if (currentSeconds.value > 0) {
|
||||||
|
emit('progress-save', { videoId: props.videoData.id, position: currentSeconds.value });
|
||||||
|
saveResumeItem(props.videoData.id, currentSeconds.value);
|
||||||
|
}
|
||||||
|
}, props.autoSaveInterval * 1000);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showError.value = true;
|
||||||
|
errorText.value = '播放器初始化失败';
|
||||||
|
emit('error', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start next countdown
|
||||||
|
function startNextCountdown(seconds = 5) {
|
||||||
|
showCountDown.value = true;
|
||||||
|
countDownSeconds.value = seconds;
|
||||||
|
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
|
||||||
|
countdownTimerId.value = window.setInterval(() => {
|
||||||
|
countDownSeconds.value -= 1;
|
||||||
|
if (countDownSeconds.value <= 0) {
|
||||||
|
// trigger parent to load next
|
||||||
|
window.clearInterval(countdownTimerId.value!);
|
||||||
|
showCountDown.value = false;
|
||||||
|
emit('load-next', { videoId: props.videoData.id });
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelNext() {
|
||||||
|
showCountDown.value = false;
|
||||||
|
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
|
||||||
|
emit('change-screen', { action: 'cancel-next' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// control API exposed
|
||||||
|
function play() {
|
||||||
|
playerInstance.value && playerInstance.value.play && playerInstance.value.play();
|
||||||
|
}
|
||||||
|
function pause() {
|
||||||
|
playerInstance.value && playerInstance.value.pause && playerInstance.value.pause();
|
||||||
|
}
|
||||||
|
function seek(sec: number) {
|
||||||
|
playerInstance.value && playerInstance.value.seek && playerInstance.value.seek(sec);
|
||||||
|
}
|
||||||
|
function replay() {
|
||||||
|
if (playerInstance.value) {
|
||||||
|
playerInstance.value.seek(0);
|
||||||
|
playerInstance.value.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function enterFullscreen() {
|
||||||
|
if (!playerInstance.value) return;
|
||||||
|
const status = playerInstance.value.fullscreenService.getIsFullScreen && playerInstance.value.fullscreenService.getIsFullScreen();
|
||||||
|
if (status) {
|
||||||
|
playerInstance.value.fullscreenService.cancelFullScreen && playerInstance.value.fullscreenService.cancelFullScreen();
|
||||||
|
emit('change-screen', { status: false });
|
||||||
|
// example: lock portrait if in app (needs plus.* or native)
|
||||||
|
} else {
|
||||||
|
playerInstance.value.fullscreenService.requestFullScreen && playerInstance.value.fullscreenService.requestFullScreen();
|
||||||
|
emit('change-screen', { status: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watch videoData changes
|
||||||
|
watch(() => props.videoData, async (nv) => {
|
||||||
|
// dispose old player
|
||||||
|
if (playerInstance.value && playerInstance.value.dispose) {
|
||||||
|
try { playerInstance.value.dispose(); } catch (e) { console.warn(e); }
|
||||||
|
playerInstance.value = null;
|
||||||
|
}
|
||||||
|
// reset states
|
||||||
|
currentSeconds.value = 0;
|
||||||
|
pauseTime.value = 0;
|
||||||
|
showError.value = false;
|
||||||
|
// init new
|
||||||
|
await initPlayer();
|
||||||
|
}, { immediate: true, deep: true });
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// ensure container has unique id for Aliplayer
|
||||||
|
if (playerContainer.value && !(playerContainer.value as HTMLElement).id) {
|
||||||
|
(playerContainer.value as HTMLElement).id = 'ali-player-' + Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (playerInstance.value && playerInstance.value.dispose) {
|
||||||
|
try { playerInstance.value.dispose(); } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
if (autoSaveIntervalId.value) window.clearInterval(autoSaveIntervalId.value);
|
||||||
|
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// expose methods to parent via ref
|
||||||
|
expose({
|
||||||
|
play, pause, seek, replay, startNextCountdown
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerContainer,
|
||||||
|
playerHeight,
|
||||||
|
play, pause, seek, replay,
|
||||||
|
showCountDown, countDownSeconds, cancelNext,
|
||||||
|
showError, errorText
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ali-player-wrapper { position: relative; width: 100%; }
|
||||||
|
.player-container { background: #000; }
|
||||||
|
.countdown-overlay {
|
||||||
|
position: absolute; top: 0; right: 10px; z-index: 50;
|
||||||
|
background: rgba(0,0,0,0.6); color: #fff; padding: 10px; border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.btn-cancel { margin-top: 8px; background: #fff; color: #000; border: none; padding:6px 12px; border-radius:4px; }
|
||||||
|
.player-error { color: #fff; text-align:center; padding: 20px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
162
components/video-player/components/ControlBar.vue
Normal file
162
components/video-player/components/ControlBar.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<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>
|
||||||
183
components/video-player/components/ProgressBar.vue
Normal file
183
components/video-player/components/ProgressBar.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<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>
|
||||||
122
components/video-player/components/SpeedControl.vue
Normal file
122
components/video-player/components/SpeedControl.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<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>
|
||||||
112
components/video-player/components/VolumeControl.vue
Normal file
112
components/video-player/components/VolumeControl.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<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>
|
||||||
73
components/video-player/composables/useVideoAPI.ts
Normal file
73
components/video-player/composables/useVideoAPI.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// 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',
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
189
components/video-player/composables/useVideoPlayer.ts
Normal file
189
components/video-player/composables/useVideoPlayer.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
131
components/video-player/composables/useVideoProgress.ts
Normal file
131
components/video-player/composables/useVideoProgress.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// 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)
|
||||||
|
|
||||||
|
// 如果播放位置接近视频结尾(最后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
|
||||||
|
}
|
||||||
|
}
|
||||||
519
components/video-player/index.vue
Normal file
519
components/video-player/index.vue
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
<template>
|
||||||
|
<view class="video-player-container">
|
||||||
|
<!-- 视频播放器 -->
|
||||||
|
<view class="video-wrapper">
|
||||||
|
<video
|
||||||
|
v-if="!player.showError.value && player.videoUrl.value"
|
||||||
|
:id="videoId"
|
||||||
|
:src="player.videoUrl.value"
|
||||||
|
:poster="player.posterUrl.value"
|
||||||
|
:controls="true"
|
||||||
|
:page-gesture="true"
|
||||||
|
:vslide-gesture="true"
|
||||||
|
:vslide-gesture-in-fullscreen="true"
|
||||||
|
:show-center-play-btn="true"
|
||||||
|
:show-fullscreen-btn="true"
|
||||||
|
:show-play-btn="true"
|
||||||
|
:show-progress="true"
|
||||||
|
:enable-play-gesture="true"
|
||||||
|
:enable-progress-gesture="true"
|
||||||
|
:object-fit="objectFit"
|
||||||
|
:autoplay="true"
|
||||||
|
:initial-time="initialTime"
|
||||||
|
class="video-element"
|
||||||
|
@play="play"
|
||||||
|
@pause="pause"
|
||||||
|
@ended="ended"
|
||||||
|
@timeupdate="timeupdate"
|
||||||
|
@error="error"
|
||||||
|
@fullscreenchange="fullscreenChange"
|
||||||
|
@waiting="waiting"
|
||||||
|
@loadedmetadata="loadedmetadata"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 倒计时遮罩 - 使用 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 v-if="player.isLoading.value" class="loading-display">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useVideoPlayer } from './composables/useVideoPlayer'
|
||||||
|
import { videoStorage } from '@/utils/videoStorage'
|
||||||
|
import type { IVideoPlayerProps, IVideoInfo } from '@/types/video'
|
||||||
|
|
||||||
|
// 页面生命周期
|
||||||
|
// @ts-ignore
|
||||||
|
const onHide = typeof uni !== 'undefined' ? uni.onHide || (() => {}) : () => {}
|
||||||
|
|
||||||
|
// Props
|
||||||
|
const props = withDefaults(defineProps<IVideoPlayerProps>(), {
|
||||||
|
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 确保视频已加载
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始保存进度
|
||||||
|
player.videoProgress.startSaving(videoInfo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面隐藏时保存进度
|
||||||
|
onHide(() => {
|
||||||
|
console.log('Page hidden, saving progress...')
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = (e: any) => {
|
||||||
|
console.error('Video error:', e)
|
||||||
|
player.showError.value = true
|
||||||
|
player.errorMessage.value = '视频播放出错,请稍后重试'
|
||||||
|
player.canRetry.value = true
|
||||||
|
|
||||||
|
emit('error', {
|
||||||
|
type: 'VIDEO_LOAD_ERROR',
|
||||||
|
message: '视频播放出错'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullscreenChange = (e: any) => {
|
||||||
|
isFullScreen.value = e.detail.fullScreen
|
||||||
|
|
||||||
|
// #ifdef APP-PLUS
|
||||||
|
if (e.detail.fullScreen) {
|
||||||
|
// 进入全屏,锁定横屏
|
||||||
|
setTimeout(() => {
|
||||||
|
plus.screen.lockOrientation('landscape-primary')
|
||||||
|
}, 100)
|
||||||
|
} else {
|
||||||
|
// 退出全屏,恢复竖屏
|
||||||
|
setTimeout(() => {
|
||||||
|
plus.screen.lockOrientation('portrait-primary')
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
emit('fullscreen', isFullScreen.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting = () => {
|
||||||
|
// 视频缓冲中
|
||||||
|
console.log('Video waiting...')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadedmetadata = (e: any) => {
|
||||||
|
duration.value = e.detail.duration
|
||||||
|
console.log('Video metadata loaded, duration:', duration.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 倒计时相关
|
||||||
|
const startCountdown = () => {
|
||||||
|
showCountdown.value = true
|
||||||
|
countdownRemaining.value = props.countdownSeconds
|
||||||
|
emit('countdown-start')
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.video-player-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-element {
|
||||||
|
width: 100%;
|
||||||
|
height: 400rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
background-color: #1989fa;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 20rpx 40rpx;
|
||||||
|
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;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 600rpx;
|
||||||
|
margin-left: -300rpx;
|
||||||
|
margin-top: -150rpx;
|
||||||
|
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;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel,
|
||||||
|
.btn-replay,
|
||||||
|
.btn-next {
|
||||||
|
padding: 0 20px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 5px;
|
||||||
|
background-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
// .btn-cancel {
|
||||||
|
// background-color: #666;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .btn-replay {
|
||||||
|
// background-color: #ff9800;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// .btn-next {
|
||||||
|
// background-color: #1989fa;
|
||||||
|
// }
|
||||||
|
</style>
|
||||||
@@ -9,13 +9,16 @@
|
|||||||
<view v-if="videoList.length > 0" class="video-section">
|
<view v-if="videoList.length > 0" class="video-section">
|
||||||
<VideoPlayer
|
<VideoPlayer
|
||||||
ref="videoPlayerRef"
|
ref="videoPlayerRef"
|
||||||
:videoList="videoList"
|
v-model:current-index="currentVideoIndex"
|
||||||
:currentIndex="currentVideoIndex"
|
:video-list="videoList"
|
||||||
:noRecored="noRecored"
|
:countdown-seconds="5"
|
||||||
@end="handleVideoEnd"
|
|
||||||
@fullscreen="handleFullscreen"
|
|
||||||
@change="handleVideoChange"
|
|
||||||
/>
|
/>
|
||||||
|
<!-- <AliyunPlayer
|
||||||
|
ref="videoPlayerRef"
|
||||||
|
:currentVideo="videoList[currentVideoIndex]"
|
||||||
|
:currentVideoList="videoList"
|
||||||
|
@unlockChangeVideo="changeVideoLock = false"
|
||||||
|
/> -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 课程和章节信息 -->
|
<!-- 课程和章节信息 -->
|
||||||
@@ -42,12 +45,6 @@
|
|||||||
>
|
>
|
||||||
<view class="video-info">
|
<view class="video-info">
|
||||||
<text class="video-title">【{{ video.type == "2" ? "音频" : "视频" }}】{{ index + 1 }}</text>
|
<text class="video-title">【{{ video.type == "2" ? "音频" : "视频" }}】{{ index + 1 }}</text>
|
||||||
<!-- <wd-icon
|
|
||||||
v-if="currentVideoIndex === index"
|
|
||||||
name="play-circle"
|
|
||||||
color="#258feb"
|
|
||||||
size="14px"
|
|
||||||
/> -->
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -107,10 +104,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
import { courseApi } from '@/api/modules/course'
|
import { courseApi } from '@/api/modules/course'
|
||||||
import VideoPlayer from '@/components/course/VideoPlayer.vue'
|
import VideoPlayer from '@/components/video-player/index.vue'
|
||||||
import type { IChapterDetail, IVideo } from '@/types/course'
|
import type { IChapterDetail, IVideo } from '@/types/course'
|
||||||
|
|
||||||
// 页面参数
|
// 页面参数
|
||||||
@@ -124,6 +121,7 @@ const noRecored = ref(false)
|
|||||||
const chapterDetail = ref<IChapterDetail | null>(null)
|
const chapterDetail = ref<IChapterDetail | null>(null)
|
||||||
const videoList = ref<IVideo[]>([])
|
const videoList = ref<IVideo[]>([])
|
||||||
const currentVideoIndex = ref(0)
|
const currentVideoIndex = ref(0)
|
||||||
|
const activeVideoIndex = ref(0)
|
||||||
const currentTab = ref(0)
|
const currentTab = ref(0)
|
||||||
const isFullScreen = ref(false)
|
const isFullScreen = ref(false)
|
||||||
|
|
||||||
@@ -166,69 +164,34 @@ onLoad((options: any) => {
|
|||||||
* 加载章节详情
|
* 加载章节详情
|
||||||
*/
|
*/
|
||||||
const loadChapterDetail = async () => {
|
const loadChapterDetail = async () => {
|
||||||
try {
|
try {
|
||||||
uni.showLoading({ title: '加载中...' })
|
|
||||||
|
|
||||||
const res = await courseApi.getChapterDetail(chapterId.value)
|
const res = await courseApi.getChapterDetail(chapterId.value)
|
||||||
if (res.code === 0 && res.data) {
|
if (res.code === 0 && res.data) {
|
||||||
chapterDetail.value = res.data.detail
|
chapterDetail.value = res.data.detail
|
||||||
videoList.value = res.data.videos || []
|
videoList.value = res.data.videos || []
|
||||||
|
|
||||||
// 如果有历史播放记录,定位到对应视频
|
// 如果有历史播放记录,定位到对应视频
|
||||||
if (res.data.current) {
|
if (res.data.current) {
|
||||||
const index = videoList.value.findIndex(v => v.id === res.data.current)
|
const index = videoList.value.findIndex(v => v.id === res.data.current)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
currentVideoIndex.value = index
|
currentVideoIndex.value = index
|
||||||
|
activeVideoIndex.value = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载章节详情失败:', error)
|
console.error('加载章节详情失败:', error)
|
||||||
} finally {
|
|
||||||
uni.hideLoading()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选择视频
|
* 选择视频
|
||||||
*/
|
*/
|
||||||
const selectVideo = (index: number) => {
|
const selectVideo = async (index: number) => {
|
||||||
if (index === currentVideoIndex.value) return
|
if (index === currentVideoIndex.value) return
|
||||||
currentVideoIndex.value = index
|
currentVideoIndex.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频切换
|
|
||||||
*/
|
|
||||||
const handleVideoChange = (index: number) => {
|
|
||||||
currentVideoIndex.value = index
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频播放结束
|
|
||||||
*/
|
|
||||||
const handleVideoEnd = () => {
|
|
||||||
// 视频播放结束的处理已在 VideoPlayer 组件中完成
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全屏变化
|
|
||||||
*/
|
|
||||||
const handleFullscreen = (fullscreen: boolean) => {
|
|
||||||
isFullScreen.value = fullscreen
|
|
||||||
|
|
||||||
// 全屏时锁定屏幕方向
|
|
||||||
// #ifdef APP-PLUS
|
|
||||||
if (fullscreen) {
|
|
||||||
plus.screen.unlockOrientation()
|
|
||||||
plus.screen.lockOrientation('landscape-primary')
|
|
||||||
} else {
|
|
||||||
plus.screen.unlockOrientation()
|
|
||||||
plus.screen.lockOrientation('portrait-primary')
|
|
||||||
}
|
|
||||||
// #endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换选项卡
|
* 切换选项卡
|
||||||
*/
|
*/
|
||||||
@@ -245,42 +208,6 @@ const previewImage = (url: string) => {
|
|||||||
current: url
|
current: url
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 页面显示
|
|
||||||
*/
|
|
||||||
onShow(() => {
|
|
||||||
// 锁定竖屏
|
|
||||||
// #ifdef APP-PLUS
|
|
||||||
plus.screen.unlockOrientation()
|
|
||||||
plus.screen.lockOrientation('portrait-primary')
|
|
||||||
// #endif
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 页面隐藏
|
|
||||||
*/
|
|
||||||
onHide(() => {
|
|
||||||
// 暂停视频
|
|
||||||
if (videoPlayerRef.value) {
|
|
||||||
videoPlayerRef.value.pause()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 页面卸载
|
|
||||||
*/
|
|
||||||
onUnload(() => {
|
|
||||||
// 停止视频
|
|
||||||
if (videoPlayerRef.value) {
|
|
||||||
videoPlayerRef.value.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解锁屏幕方向
|
|
||||||
// #ifdef APP-PLUS
|
|
||||||
plus.screen.unlockOrientation()
|
|
||||||
// #endif
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -340,7 +267,7 @@ onUnload(() => {
|
|||||||
gap: 10rpx;
|
gap: 10rpx;
|
||||||
|
|
||||||
.video-item {
|
.video-item {
|
||||||
padding: 20rpx;
|
padding: 18rpx;
|
||||||
margin-bottom: 10rpx;
|
margin-bottom: 10rpx;
|
||||||
background-color: #f7f8f9;
|
background-color: #f7f8f9;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
|
|||||||
@@ -32,8 +32,10 @@
|
|||||||
:chapters="chapterList"
|
:chapters="chapterList"
|
||||||
:catalogue="currentCatalogue"
|
:catalogue="currentCatalogue"
|
||||||
:userVip="userVip"
|
:userVip="userVip"
|
||||||
|
:showRenewBtn="showRenewBtn"
|
||||||
@purchase="handlePurchase"
|
@purchase="handlePurchase"
|
||||||
@toVip="goToVip"
|
@toVip="goToVip"
|
||||||
|
@renew="handleRenew"
|
||||||
@click="handleChapterClick"
|
@click="handleChapterClick"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
class="payment-icon"
|
class="payment-icon"
|
||||||
/>
|
/>
|
||||||
<text class="price">{{ order.orderMoney }}</text>
|
<text class="price">{{ order.orderMoney }}</text>
|
||||||
<text v-if="order.paymentMethod === '5'" class="currency">NZD</text>
|
<text class="currency">NZD</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="order-sn">
|
<text class="order-sn">
|
||||||
@@ -90,7 +90,7 @@ const getData = async (page: number, pageSize: number) => {
|
|||||||
try {
|
try {
|
||||||
// 添加订单状态参数
|
// 添加订单状态参数
|
||||||
const res = await getOrderList(page, pageSize, orderStatus.value)
|
const res = await getOrderList(page, pageSize, orderStatus.value)
|
||||||
paging.value.complete(res.orders.records)
|
paging.value.complete(res.data.records)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
paging.value.complete(false)
|
paging.value.complete(false)
|
||||||
console.error('获取订单列表失败:', error)
|
console.error('获取订单列表失败:', error)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||||
"Courier New", monospace;
|
"Courier New", monospace;
|
||||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||||
--spacing: 0.25rem;
|
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-transition-duration: 150ms;
|
--default-transition-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -259,15 +258,9 @@
|
|||||||
.bg-\[transparent\] {
|
.bg-\[transparent\] {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
.pt-\[5px\] {
|
|
||||||
padding-top: 5px;
|
|
||||||
}
|
|
||||||
.pt-\[40px\] {
|
.pt-\[40px\] {
|
||||||
padding-top: 40px;
|
padding-top: 40px;
|
||||||
}
|
}
|
||||||
.pb-2\.5 {
|
|
||||||
padding-bottom: calc(var(--spacing) * 2.5);
|
|
||||||
}
|
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
92
types/video.d.ts
vendored
Normal file
92
types/video.d.ts
vendored
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// types/video.d.ts
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频信息
|
||||||
|
*/
|
||||||
|
export interface IVideoInfo {
|
||||||
|
id: number
|
||||||
|
chapterId: number
|
||||||
|
type: 0 | 1 // 0: MP4, 1: M3U8
|
||||||
|
video: string // 视频ID
|
||||||
|
sort: number
|
||||||
|
duration: number // 视频时长(秒)
|
||||||
|
createTime: string
|
||||||
|
delFlag: number
|
||||||
|
playAuth?: string // 阿里云播放授权
|
||||||
|
videoUrl?: string // 视频URL(兼容字段)
|
||||||
|
m3u8Url?: string // M3U8流地址
|
||||||
|
mp4Url?: string // MP4视频地址
|
||||||
|
mtsHlsUriToken?: string // M3U8 Token
|
||||||
|
userCourseVideoPositionEntity?: IVideoPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放位置
|
||||||
|
*/
|
||||||
|
export interface IVideoPosition {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
videoId: number
|
||||||
|
position: number // 播放位置(秒)
|
||||||
|
createTime: string
|
||||||
|
updateTime: string
|
||||||
|
delFlag: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频检查接口响应
|
||||||
|
*/
|
||||||
|
export interface IVideoCheckResponse {
|
||||||
|
msg: string
|
||||||
|
code: number
|
||||||
|
video: IVideoInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放器组件 Props
|
||||||
|
*/
|
||||||
|
export interface IVideoPlayerProps {
|
||||||
|
videoList: Array<{
|
||||||
|
id: number
|
||||||
|
chapterId: number
|
||||||
|
video: string
|
||||||
|
sort: number
|
||||||
|
type?: 0 | 1
|
||||||
|
duration?: number
|
||||||
|
}>
|
||||||
|
currentIndex: number
|
||||||
|
countdownSeconds?: number
|
||||||
|
showWatermark?: boolean
|
||||||
|
watermarkText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放器组件状态
|
||||||
|
*/
|
||||||
|
export interface IVideoPlayerState {
|
||||||
|
currentVideoData: IVideoInfo | null
|
||||||
|
currentTime: number
|
||||||
|
firstTime: number
|
||||||
|
isSetFirstTime: boolean
|
||||||
|
isFullScreen: boolean
|
||||||
|
showCountdown: boolean
|
||||||
|
countdownRemaining: number
|
||||||
|
showError: boolean
|
||||||
|
errorMessage: string
|
||||||
|
isLoading: boolean
|
||||||
|
isChanging: boolean
|
||||||
|
playbackRate: number
|
||||||
|
volume: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频错误类型
|
||||||
|
*/
|
||||||
|
export enum VideoErrorType {
|
||||||
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
|
VIDEO_LOAD_ERROR = 'VIDEO_LOAD_ERROR',
|
||||||
|
ENCRYPTION_ERROR = 'ENCRYPTION_ERROR',
|
||||||
|
PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED',
|
||||||
|
INVALID_PARAMS = 'INVALID_PARAMS',
|
||||||
|
API_ERROR = 'API_ERROR'
|
||||||
|
}
|
||||||
118
utils/videoStorage.ts
Normal file
118
utils/videoStorage.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
// utils/videoStorage.ts
|
||||||
|
|
||||||
|
interface VideoPosition {
|
||||||
|
id: number
|
||||||
|
time: number
|
||||||
|
updateTime: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放位置本地存储服务
|
||||||
|
*/
|
||||||
|
class VideoStorageService {
|
||||||
|
private readonly STORAGE_KEY = 'videoOssList'
|
||||||
|
private readonly MAX_STORAGE_COUNT = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频播放位置
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @returns 播放位置(秒)或 null
|
||||||
|
*/
|
||||||
|
getVideoPosition(videoId: number): number | null {
|
||||||
|
const list = this.getVideoList()
|
||||||
|
const video = list.find((v) => v.id === videoId)
|
||||||
|
return video?.time || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存视频播放位置
|
||||||
|
* @param videoId 视频ID
|
||||||
|
* @param time 播放位置(秒)
|
||||||
|
* @param videoData 视频数据
|
||||||
|
*/
|
||||||
|
saveVideoPosition(videoId: number, time: number, videoData: any): void {
|
||||||
|
const list = this.getVideoList()
|
||||||
|
const index = list.findIndex((v) => v.id === videoId)
|
||||||
|
|
||||||
|
const newItem = {
|
||||||
|
...videoData,
|
||||||
|
id: videoId,
|
||||||
|
time,
|
||||||
|
updateTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
list[index] = newItem
|
||||||
|
} else {
|
||||||
|
list.push(newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setVideoList(list)
|
||||||
|
|
||||||
|
// 自动清理旧数据
|
||||||
|
this.cleanOldData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理旧数据(保留最近 50 个)
|
||||||
|
*/
|
||||||
|
cleanOldData(): void {
|
||||||
|
const list = this.getVideoList()
|
||||||
|
|
||||||
|
if (list.length > this.MAX_STORAGE_COUNT) {
|
||||||
|
// 按更新时间排序,保留最新的
|
||||||
|
const sorted = list.sort((a, b) => {
|
||||||
|
const timeA = new Date(b.updateTime || 0).getTime()
|
||||||
|
const timeB = new Date(a.updateTime || 0).getTime()
|
||||||
|
return timeA - timeB
|
||||||
|
})
|
||||||
|
|
||||||
|
this.setVideoList(sorted.slice(0, this.MAX_STORAGE_COUNT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除指定视频的播放位置
|
||||||
|
* @param videoId 视频ID
|
||||||
|
*/
|
||||||
|
clearVideoPosition(videoId: number): void {
|
||||||
|
const list = this.getVideoList()
|
||||||
|
const filtered = list.filter((v) => v.id !== videoId)
|
||||||
|
this.setVideoList(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有播放位置
|
||||||
|
*/
|
||||||
|
clearAll(): void {
|
||||||
|
uni.removeStorageSync(this.STORAGE_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频列表
|
||||||
|
*/
|
||||||
|
private getVideoList(): VideoPosition[] {
|
||||||
|
try {
|
||||||
|
const data = uni.getStorageSync(this.STORAGE_KEY)
|
||||||
|
return data ? JSON.parse(data) : []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get video list from storage:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置视频列表
|
||||||
|
*/
|
||||||
|
private setVideoList(list: VideoPosition[]): void {
|
||||||
|
try {
|
||||||
|
uni.setStorageSync(this.STORAGE_KEY, JSON.stringify(list))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set video list to storage:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const videoStorage = new VideoStorageService()
|
||||||
Reference in New Issue
Block a user