更新:阿里云视频播放器

This commit is contained in:
2025-11-21 10:54:43 +08:00
parent e7fb2c61c1
commit 754865e23e
9 changed files with 2132 additions and 250 deletions

View File

@@ -11,9 +11,6 @@ import type {
IPageData
} from '@/types/user'
import { SERVICE_MAP } from '@/api/config'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
/**
* 获取用户信息
@@ -46,7 +43,7 @@ export async function getOrderList(current: number, limit: number, orderStatus:
const res = await mainClient.request<IApiResponse<{ orders: IPageData<IOrder> }>>({
url: 'common/buyOrder/commonBuyOrderList',
method: 'POST',
data: { current, limit, orderStatus, come: '10', userId: userStore.id }
data: { current, limit, orderStatus, come: '10', userId: uni.getStorageSync('userInfo').id }
})
return res
}

68
api/modules/video.ts Normal file
View File

@@ -0,0 +1,68 @@
// api/modules/video.ts
/**
* 视频相关 API
* 完全保持原项目接口路径和调用方式
*/
import { createRequestClient } from '../request'
import { SERVICE_MAP } from '../config'
import type {
ICheckVideoRequest,
ICheckVideoResponse,
ISaveCoursePositionRequest,
ISaveCoursePositionResponse,
IAddErrorCourseRequest,
IAddErrorCourseResponse
} from '@/types/video'
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
/**
* 视频相关 API
*/
export const videoApi = {
/**
* 检查视频并获取播放凭证
* 原接口: sociology/course/checkVideo
*
* @param data 视频信息(id, video, courseId, catalogueId, chapterId 等)
* @returns 视频详细信息,包含 playAuth, videoId, m3u8Url, videoUrl 等
*/
checkVideo(data: ICheckVideoRequest) {
return client.request<ICheckVideoResponse>({
url: 'sociology/course/checkVideo',
method: 'POST',
data
})
},
/**
* 保存课程播放位置
* 原接口: sociology/course/saveCoursePosition
*
* @param data 包含 videoId 和 position(秒数)
* @returns 保存结果
*/
saveCoursePosition(data: ISaveCoursePositionRequest) {
return client.request<ISaveCoursePositionResponse>({
url: 'sociology/course/saveCoursePosition',
method: 'POST',
data
})
},
/**
* 记录 iOS 不支持的视频
* 原接口: medical/course/addErrorCourse
*
* @param data 包含 chapterId, videoId, sort
* @returns 记录结果
*/
addErrorCourse(data: IAddErrorCourseRequest) {
return client.request<IAddErrorCourseResponse>({
url: 'medical/course/addErrorCourse',
method: 'POST',
data
})
}
}

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +1,394 @@
<template>
<view class="video-player">
<!-- 视频播放器 -->
<video
v-if="currentVideo"
:id="videoId"
:src="currentVideo.url"
:title="currentVideo.title"
:controls="true"
:show-fullscreen-btn="true"
:show-play-btn="true"
:enable-progress-gesture="true"
:object-fit="objectFit"
class="video-element"
@fullscreenchange="handleFullscreenChange"
@ended="handleVideoEnd"
@error="handleVideoError"
/>
<!-- 自动播放下一个提示 -->
<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>
<div class="ali-player-wrapper" :style="{ background: '#000' }">
<div v-if="showError" class="player-error">{{ errorText }}</div>
<div ref="playerContainer" class="player-container" :style="{ width: '100%', height: playerHeight }"></div>
<!-- 倒计时覆盖可选父层也可以自行实现 -->
<div v-if="showCountDown" class="countdown-overlay">
<div class="countdown-text">{{ countDownSeconds }} 秒后播放下一个视频</div>
<button class="btn-cancel" @click="cancelNext">取消下一个</button>
</div>
<!-- 控制按钮示例父层应该控制 UI我仅提供常用API按钮用于调试 -->
<div class="player-controls" style="display:none;">
<button @click="play()">播放</button>
<button @click="pause()">暂停</button>
<button @click="replay()">重播</button>
<button @click="enterFullscreen()">全屏</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import type { IVideo } from '@/types/course'
<script lang="ts">
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, PropType, nextTick } from 'vue';
interface Props {
videoList: IVideo[]
currentIndex: number
noRecored?: boolean // 是否为试听(不记录进度)
}
type Platform = 'web' | 'app-ios' | 'app-android';
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<{
end: []
fullscreen: [isFullScreen: boolean]
change: [index: number]
}>()
const playerHeight = props.height;
const videoId = 'course-video-player'
const videoContext = ref<any>(null)
const objectFit = ref<'contain' | 'fill' | 'cover'>('contain')
const showCountDown = ref(false)
const countDownSeconds = ref(10)
const countDownTimer = ref<any>(null)
/**
* 当前视频
*/
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;
// helper: localStorage save/load (simple array of {id, time})
function loadResumeList(): Array<any> {
try {
const raw = localStorage.getItem(props.storageKeyPrefix);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
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>

View File

@@ -7,7 +7,7 @@
<view class="page-content" :style="{ height: contentHeight }">
<!-- 视频播放器 -->
<view v-if="videoList.length > 0" class="video-section">
<VideoPlayer
<!-- <VideoPlayer
ref="videoPlayerRef"
:videoList="videoList"
:currentIndex="currentVideoIndex"
@@ -15,6 +15,12 @@
@end="handleVideoEnd"
@fullscreen="handleFullscreen"
@change="handleVideoChange"
/> -->
<AliyunPlayer
ref="videoPlayerRef"
:currentVideo="videoList[currentVideoIndex]"
:currentVideoList="videoList"
@unlockChangeVideo="changeVideoLock = false"
/>
</view>
@@ -107,10 +113,11 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
import { courseApi } from '@/api/modules/course'
import VideoPlayer from '@/components/course/VideoPlayer.vue'
// import VideoPlayer from '@/components/course/VideoPlayer.vue'
import AliyunPlayer from '@/components/ali-video/index.vue'
import type { IChapterDetail, IVideo } from '@/types/course'
// 页面参数
@@ -166,14 +173,12 @@ onLoad((options: any) => {
* 加载章节详情
*/
const loadChapterDetail = async () => {
try {
uni.showLoading({ title: '加载中...' })
try {
const res = await courseApi.getChapterDetail(chapterId.value)
if (res.code === 0 && res.data) {
chapterDetail.value = res.data.detail
videoList.value = res.data.videos || []
// 如果有历史播放记录,定位到对应视频
if (res.data.current) {
const index = videoList.value.findIndex(v => v.id === res.data.current)
@@ -181,20 +186,37 @@ const loadChapterDetail = async () => {
currentVideoIndex.value = index
}
}
// 使用 nextTick 确保组件已经渲染完成
await nextTick()
if (videoPlayerRef.value) {
console.log('准备调用 init 方法')
await videoPlayerRef.value.init({
currentVideo: videoList.value[currentVideoIndex.value],
currentVideoList: videoList.value
}, false)
} else {
console.error('videoPlayerRef.value 为空,无法初始化播放器')
}
}
} catch (error) {
console.error('加载章节详情失败:', error)
} finally {
uni.hideLoading()
}
}
/**
* 选择视频
*/
const selectVideo = (index: number) => {
const selectVideo = async (index: number) => {
if (index === currentVideoIndex.value) return
console.log('切换视频:', index, videoList.value[index])
currentVideoIndex.value = index
// 调用播放器的 changeVideo 方法
if (videoPlayerRef.value) {
await videoPlayerRef.value.changeVideo(videoList.value[index])
}
}
/**

View File

@@ -237,6 +237,9 @@
.flex-shrink {
flex-shrink: 1;
}
.border-collapse {
border-collapse: collapse;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -259,15 +262,9 @@
.bg-\[transparent\] {
background-color: transparent;
}
.pt-\[5px\] {
padding-top: 5px;
}
.pt-\[40px\] {
padding-top: 40px;
}
.pb-2\.5 {
padding-bottom: calc(var(--spacing) * 2.5);
}
.text-center {
text-align: center;
}
@@ -290,6 +287,9 @@
--tw-ordinal: ordinal;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
}
.underline {
text-decoration-line: underline;
}
.ring {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);

210
types/video.d.ts vendored Normal file
View File

@@ -0,0 +1,210 @@
// types/video.d.ts
/**
* 视频相关类型定义
* 完全基于原项目 medicine_app 的数据结构
*/
import type { IApiResponse } from './book'
/**
* 视频基本信息
* 对应原项目中的视频数据结构
*/
export interface IVideoInfo {
id: number // 视频 ID
title: string // 视频标题
type: '1' | '2' | string // 类型: 1-视频 2-音频
video?: string // 阿里云视频 ID(原始)
videoId?: string // 阿里云视频 ID(处理后,移除等号)
videoUrl?: string // 视频 URL(普通视频)
m3u8Url?: string | null // M3U8 地址(标准加密)
playAuth?: string // 播放凭证
sort?: number // 排序
chapterId?: number // 章节 ID
courseId?: number // 课程 ID
catalogueId?: number // 目录 ID
userCourseVideoPositionEntity?: {
position: number // 服务器记录的播放位置(秒)
}
[key: string]: any // 允许其他字段
}
/**
* 视频数据(扩展)
* 包含播放时间等额外信息
*/
export interface IVideoData extends IVideoInfo {
firstTime?: number // 初始播放时间(秒)
time?: number // 当前播放时间(秒)
}
/**
* 本地存储的视频列表项
* 存储在 localStorage 的 videoOssList 中
*/
export interface IVideoStorageItem {
id: number // 视频 ID
time: number // 播放时间(秒)
[key: string]: any // 其他视频信息
}
/**
* 检查视频请求参数
* 对应 sociology/course/checkVideo 接口
*/
export interface ICheckVideoRequest {
id?: number // 视频 ID
video?: string // 视频标识
courseId?: number // 课程 ID
catalogueId?: number // 目录 ID
chapterId?: number // 章节 ID
[key: string]: any // 允许其他参数
}
/**
* 检查视频响应
* 对应 sociology/course/checkVideo 接口返回
*/
export interface ICheckVideoResponse extends IApiResponse {
video: IVideoInfo // 视频信息
}
/**
* 保存播放位置请求参数
* 对应 sociology/course/saveCoursePosition 接口
*/
export interface ISaveCoursePositionRequest {
videoId: number // 视频 ID
position: number // 播放位置(秒)
}
/**
* 保存播放位置响应
* 对应 sociology/course/saveCoursePosition 接口返回
*/
export interface ISaveCoursePositionResponse extends IApiResponse {
// 继承基础响应结构
}
/**
* 记录错误视频请求参数
* 对应 medical/course/addErrorCourse 接口
* 用于记录 iOS 不支持的视频
*/
export interface IAddErrorCourseRequest {
chapterId: number // 章节 ID
videoId: number // 视频 ID
sort: number // 排序
}
/**
* 记录错误视频响应
* 对应 medical/course/addErrorCourse 接口返回
*/
export interface IAddErrorCourseResponse extends IApiResponse {
// 继承基础响应结构
}
/**
* 章节详情
* 用于视频列表组件
*/
export interface IChapterDetail {
id: number // 章节 ID
title: string // 章节标题
imgUrl?: string // 章节图片
content?: string // 章节内容
questions?: string // 思考题
[key: string]: any // 其他字段
}
/**
* 平台类型
*/
export type PlatformType = 'ios' | 'android' | 'h5' | null
/**
* 屏幕方向类型
*/
export type ScreenOrientationType = 'portrait-primary' | 'landscape-primary'
/**
* 播放器配置选项
* 对应阿里云播放器配置
*/
export interface IPlayerOptions {
id: string // 容器 ID
width: string // 宽度
height: string // 高度
autoplay?: boolean // 自动播放
playsinline?: boolean // 内联播放
controlBarVisibility?: 'hover' | 'click' | 'always' // 控制栏显示方式
useH5Prism?: boolean // 使用 H5 播放器
qualitySort?: 'asc' | 'desc' // 清晰度排序
isLive?: boolean // 是否直播
rePlay?: boolean // 是否重播
cover?: string // 封面图
// 私有加密视频配置
vid?: string // 视频 ID
playauth?: string // 播放凭证
encryptType?: number // 加密类型: 1-私有加密
playConfig?: {
EncryptType: string // 加密类型名称
}
// 标准加密/普通视频配置
source?: string // 视频地址
// 组件配置
components?: Array<{
name: string
type: any
args?: any[]
}>
// 皮肤布局
skinLayout?: Array<{
name: string
align?: string
x?: number
y?: number
children?: Array<{
name: string
align?: string
x?: number
y?: number
}>
}>
}
/**
* 全屏状态变化数据
*/
export interface IScreenChangeData {
status: boolean // 全屏状态
primary: ScreenOrientationType // 屏幕方向
}
/**
* 播放时间记录数据
*/
export interface IRecordTimeData {
time: number // 播放时间(秒)
status?: string // 播放状态
}
/**
* 错误提示数据
*/
export interface IOpenShowData {
msg?: string // 错误消息
}
/**
* 初始化数据
*/
export interface IInitData {
currentVideo: IVideoInfo // 当前视频
currentVideoList?: IVideoInfo[] // 视频列表
}