Files
taimed-international-app/components/course/VideoPlayer.vue

395 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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 lang="ts">
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, PropType, nextTick } from 'vue';
type Platform = 'web' | 'app-ios' | 'app-android';
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 playerHeight = props.height;
// 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>