395 lines
14 KiB
Vue
395 lines
14 KiB
Vue
<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>
|