Files
hgmj_h5/src/components/VideoHls/HorizontalVideo.vue
T
2026-10-08 21:20:52 +07:00

804 lines
23 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="vertical_video_warp" :data-hide-progress="state.hideProgress">
<div class="trial-seek-veil" v-show="trialSeekBlocking"></div>
<!-- 点击层必须在 playerShell 外:xgplayer destroy 会 root.innerHTML='',壳内节点会被清掉 -->
<div class="avoid_sliding" @click="togglePlayPause"></div>
<div :id="playerShellId" class="video-shell"></div>
</div>
</template>
<script lang="ts" setup>
import VideoApi from '@/service/video';
import { useConfigStore } from '@/stores/config';
import { useGlobalStore } from '@/stores/global';
import { useUserStore } from '@/stores/user';
import { onActivated, onBeforeUnmount, onDeactivated, onMounted, Ref, ref, toRefs, reactive } from 'vue';
import HlsJSPlayer from 'xgplayer-hls.js';
import UserApi from '@/service/user';
import { addVideoHistory } from '@/utils';
import { HistoryVideoType } from '@/enums/app';
import { judgeTime } from '@/utils/index';
import {
bindXgTrialProgressGuard,
setTrialVideoFrameHidden,
syncXgplayerSeekUi,
XG_HLS_FEED_OPTS,
} from '@/utils/xgHlsFeedOpts';
import { bindH265PlaybackFallback, resolvePlayResourceKey } from '@/utils/h265Playback';
import { setSharedFreeWatchCount } from '@/utils/sharedFreeWatchCount';
const props = defineProps<{
videoInfo: Types.Video.VInfosItem;
hasDarkWebTag: boolean;
searchAccessTicket?: string;
}>();
const { videoInfo } = toRefs(props);
const playerShellId = `horizontal-hls-${props.videoInfo.id}`.replace(/[^a-zA-Z0-9_-]/g, '_');
/** 试看拖动/seek 期间黑场遮罩,避免 iOS 先闪出越界帧 */
const trialSeekBlocking = ref(false);
const { isVip, userInfo } = useUserStore();
const userStore = useUserStore();
const globalStore = useGlobalStore();
let userFreeCount: Ref<Types.Video.UserFreeCount> = ref({
isCan: false,
watchCount: 0,
});
const emit = defineEmits(['getFreeCount', 'showPreviewTime', 'play', 'jumpOverHandle', 'timeupdate']);
let trialUnlockPopupPending = false;
const emitTrialUnlockPopup = () => {
if (trialUnlockPopupPending) return;
trialUnlockPopupPending = true;
emit('jumpOverHandle');
window.setTimeout(() => {
trialUnlockPopupPending = false;
}, 300);
};
let player: any = null; //播放器实例
let isPreview = ref(false); // 是否预览视频
let videoUrl = ref(''); // 播放地址
/** 当前是否使用 H.265 资源 */
let isPlayingH265 = false;
/** H.265 失败后强制 H.264,防止死循环 */
let forceH264 = false;
let pendingResumeAt = 0;
let unbindH265Fallback: (() => void) | null = null;
const state = reactive({
beginTime: new Date(), // 统计时使用:开始时间
playTimer: <any>'', // 统计时使用:时间计时器
playTime: 0, // 统计时使用:播放时间
hideProgress: false,
});
onMounted(() => {
getInfo();
//获取免费观看次数
getFreeWatchCount();
});
/**
* @description 暂停
*/
const pause = () => {
if (!player) return;
player.pause();
clearInterval(state.playTimer);
state.playTimer = '';
};
/** 点击画面切换播放/暂停 */
const togglePlayPause = () => {
if (!player) return;
if (player.paused) {
player.play?.();
return;
}
pause();
};
/**
* @description 是否拥有完整片源的观看权限,判定口径与 playCheck 保持一致
*/
const hasFullVideoPermission = () => {
// 已购买
if (videoInfo.value.vidStatus.hasPaid === true) return true;
// 免费视频
if (videoInfo.value.freeArea === true) return true;
// 金币视频免费看权限
if (videoInfo.value.originCoins != 0 && videoInfo.value.coins == 0) return true;
// vip>=2 可直接观看,暗网需 vip4
if (isVip && ((userInfo.vipLevel >= 2 && !props.hasDarkWebTag) || (userInfo.vipLevel == 4 && props.hasDarkWebTag))) {
return true;
}
// 会员视频(金币为 0)
if (videoInfo.value.originCoins === 0 && isVip) return true;
// 自己发布的视频
if (userInfo.uid === videoInfo.value.publisher.uid) return true;
// 免费观影次数
if (userFreeCount.value.isCan === true && videoInfo.value.originCoins === 0) return true;
// 预售金币视频免费次数
if (
useGlobalStore().presaleObj.status != 0 &&
globalStore.advanceStatus.privilegeLimit.hasLimit &&
useGlobalStore().preSaleCoinVideoPlay
) {
return true;
}
return false;
};
/**
* @description 判断是否预览视频
*/
const checkPreview = () => {
if (!hasFullVideoPermission() && videoInfo.value.previewURL) {
isPreview.value = true;
isPlayingH265 = false;
videoUrl.value = videoInfo.value.previewURL;
} else {
isPreview.value = false;
const resolved = resolvePlayResourceKey({
h264Key: videoInfo.value.sourceURL,
h265Key: videoInfo.value.h265Url,
forceH264,
});
isPlayingH265 = resolved.isH265;
videoUrl.value = resolved.resourceKey;
}
};
/**
* 获取用户信息
* @param uid
*/
const getInfo = async () => {
try {
let res: any = await UserApi.queryUserInfo(null);
let userInfo = res;
userStore.saveUserInfo(userInfo);
} catch (e) {
console.log(e);
}
};
/**
* @description 初始化播放器
*/
const previewStartFlag = ref(false); // 是否进行预览时间设置
let trialLimitHandled = false;
let unbindTrialProgressGuard: (() => void) | null = null;
const pageActive = ref(true);
const teardownTrialGuard = () => {
unbindTrialProgressGuard?.();
unbindTrialProgressGuard = null;
setTrialFrameBlocked(false);
trialLimitHandled = false;
};
let destroySilently = false;
const destroyPlayer = (silent = false) => {
teardownTrialGuard();
unbindH265Fallback?.();
unbindH265Fallback = null;
if (!player) return;
destroySilently = silent;
try {
player.src = '';
player.destroy(true);
} catch {
/* ignore */
}
destroySilently = false;
clearInterval(state.playTimer);
state.playTimer = '';
player = null;
};
const fallbackH265ToH264 = (resumeAt: number) => {
if (forceH264 || !isPlayingH265) return;
forceH264 = true;
isPlayingH265 = false;
pendingResumeAt = resumeAt;
destroyPlayer(true);
initVideo();
};
const initVideo = () => {
if (player) destroyPlayer();
checkPreview();
player = new HlsJSPlayer({
id: playerShellId,
url: getPath(),
fluid: true,
playsinline: true,
videoInit: true,
loop: true,
autoplay: true,
controls: true,
lang: 'zh-cn',
ignores: ['i18n', 'play', 'volume', 'definition'],
allowPlayAfterEnded: true,
allowSeekAfterEnded: true,
closeVideoPreventDefault: true,
closeVideoStopPropagation: true,
// 由壳外 avoid_sliding 接管单击播停;开启双击会吞掉单击
closeVideoClick: true,
closeInactive: true,
enableVideoDbltouch: false,
closeMoveSeek: needsTrialSeekLimit(),
hlsOpts: XG_HLS_FEED_OPTS,
});
unbindH265Fallback = bindH265PlaybackFallback(player, {
shouldFallback: () => isPlayingH265 && !forceH264,
onFallback: fallbackH265ToH264,
});
// 播放统计
const addPlayCount = () => {
//当视频滑走的时候调用 用作统计
let req: any = {
publisher: videoInfo.value.publisher.uid,
videoID: videoInfo.value.id,
beginTime: state.beginTime,
endTime: new Date(),
playWay: userFreeCount.value.isCan ? (userFreeCount.value.watchCount ? 0 : 1) : 2,
via: 1,
longer: state.playTime,
};
if (videoInfo.value.tags && videoInfo.value.tags.length > 0) {
req.tagID = videoInfo.value.tags[0].id;
}
if (props.searchAccessTicket) {
req.searchAccessTicket = props.searchAccessTicket;
}
VideoApi.getPlayVideo(req);
clearInterval(state.playTimer);
state.playTimer = '';
state.playTime = 1;
};
setTimeout(() => {
emit('showPreviewTime', player.video.duration);
bindTrialProgressGuard();
player.config.closeMoveSeek = needsTrialSeekLimit();
}, 500);
player.on('playing', () => {
playing();
});
// 设置开始播放时间
player.on('loadedmetadata', () => {
if (pendingResumeAt > 0) {
const resumeAt = pendingResumeAt;
pendingResumeAt = 0;
try {
player.currentTime = resumeAt;
} catch {
/* ignore */
}
}
//免费视频
if (videoInfo.value.freeArea === true) return;
// 视频已购买
if (videoInfo.value.vidStatus.hasPaid === true) return;
//金币视频免费看权限
if (videoInfo.value.originCoins != 0 && videoInfo.value.coins == 0) return;
// 会员视频(金币为0,freeArea = false)
if (videoInfo.value.originCoins === 0 && videoInfo.value.freeArea === false && isVip) return;
// VIP免费次数
if (userFreeCount.value.isCan === true && videoInfo.value.originCoins === 0) return;
//自己发布的视频
if (userInfo.uid === videoInfo.value.publisher.uid) return;
//预售金币视频免费次数能否观看
if (
useGlobalStore().presaleObj.status != 0 &&
globalStore.advanceStatus.privilegeLimit.hasLimit &&
useGlobalStore().preSaleCoinVideoPlay
)
return;
player.currentTime = videoInfo.value.previewStart;
previewStartFlag.value = true;
});
// 播放
player.on('play', () => {
emit('play');
});
/** 仅当明确回到试看区内才清标记;回弹到 limit-ε 时 playCheck 为 true,不能清,否则半屏关了又弹 */
const canResetTrialLimitHandled = () => {
const limit = getBackendTrialLimitSeconds();
if (limit == null) return true;
const t = Number(player?.video?.currentTime) || 0;
return t < limit - Math.max(0.5, TRIAL_SEEK_EPSILON * 2);
};
const handleTrialLimitReached = () => {
if (!pageActive.value) return;
if (!player.paused) {
pause();
}
if (!trialLimitHandled) {
trialLimitHandled = true;
revealTrialFrameForMask();
emitTrialUnlockPopup();
}
};
player.on('timeupdate', () => {
if (!pageActive.value || !player.hasStart) return;
emit('timeupdate', Number(player?.video?.currentTime) || 0);
if (playCheck()) {
if (canResetTrialLimitHandled()) {
trialLimitHandled = false;
}
return;
}
handleTrialLimitReached();
});
player.on('seeked', () => {
if (!player.hasStart) return;
const clamped = clampTrialSeek();
clearTrialSeekBlock();
// 钳制后再补发进度:拖动松手时未必有 timeupdate
if (pageActive.value) {
emit('timeupdate', Number(player?.video?.currentTime) || 0);
}
if (clamped) return;
if (!playCheck()) {
handleTrialLimitReached();
}
});
player.on('seeking', () => {
if (!player?.hasStart) return;
if (needsTrialSeekLimit()) {
setTrialFrameBlocked(true);
}
clampTrialSeek();
});
player.on('destroy', () => {
if (destroySilently) return;
addPlayCount();
});
};
/** 次数请求序号:忽略过期响应,避免卸载/重挂竞态把 watchCount 盖成 0 */
let freeCountReqSeq = 0;
const isFreeCountRequestCanceled = (err: unknown) => {
if (!err || typeof err !== 'object') return false;
const e = err as { code?: string; name?: string; message?: string };
return (
e.code === 'ERR_CANCELED' ||
e.name === 'CanceledError' ||
e.name === 'AbortError' ||
/cancel/i.test(String(e.message || ''))
);
};
/**
* @description 获取用户免费观看次数
*/
const getFreeWatchCount = () => {
const reqSeq = ++freeCountReqSeq;
VideoApi.getPlayCount(videoInfo.value.id)
.then((userCount) => {
if (reqSeq !== freeCountReqSeq) return;
// 必须抛接口原值:finally 里读 ref 在取消/竞态时可能仍是初始 { watchCount: 0 }
userFreeCount.value = userCount;
setSharedFreeWatchCount({
watchCount: Number(userCount?.watchCount),
isCan: userCount?.isCan,
videoInfo: videoInfo.value,
});
emit('getFreeCount', userCount);
})
.catch((err) => {
if (reqSeq !== freeCountReqSeq) return;
// 取消不兜底 0,避免 Network 已返回 3 却被后续 cancel 覆盖
if (isFreeCountRequestCanceled(err)) return;
// 请求失败按无免费次数兜底,保证父组件仍能展示跳过预览入口
emit('getFreeCount', { isCan: false, watchCount: 0 });
})
.finally(() => {
if (reqSeq !== freeCountReqSeq) return;
// 无论成功失败都初始化播放器,避免接口异常导致无法播放
initVideo();
});
};
const playing = () => {
if (!state.playTimer) {
state.playTimer = setInterval(() => {
state.playTime++;
}, 1000);
}
};
/**
* @description 获取播放链接
*/
const getPath = () => {
const { baseUrl } = useConfigStore();
return `${baseUrl}/api/app/vid/h5/m3u8/${videoUrl.value}?token=${getToken()}&c=${globalStore.videoRoadLine.url}`;
};
/** 当前视频是否需要试看时长限制(不依赖 video 元素) */
const isTrialPlaybackLimited = () => {
if (isPreview.value) return true;
if (videoInfo.value.vidStatus.hasPaid === true) return false;
if (videoInfo.value.freeArea === true) return false;
if (isVip && ((userInfo.vipLevel >= 2 && !props.hasDarkWebTag) || (userInfo.vipLevel == 4 && props.hasDarkWebTag))) {
return false;
}
if (videoInfo.value.originCoins != 0 && videoInfo.value.coins == 0) return false;
if (videoInfo.value.originCoins === 0 && videoInfo.value.freeArea === false && isVip) return false;
if (userInfo.uid === videoInfo.value.publisher.uid) return false;
if (userFreeCount.value.isCan === true && videoInfo.value.originCoins === 0) return false;
if (
useGlobalStore().presaleObj.status != 0 &&
globalStore.advanceStatus.privilegeLimit.hasLimit &&
useGlobalStore().preSaleCoinVideoPlay
) {
return false;
}
return true;
};
const TRIAL_SEEK_EPSILON = 0.05;
const getTrialMinTime = () => (previewStartFlag.value && !isPreview.value ? videoInfo.value.previewStart || 0 : 0);
/** 后端试看上限(秒):freeTime,有 previewStart 时为 previewStart + freeTime;预览片为 duration-3 */
const getBackendTrialLimitSeconds = (): number | null => {
if (!isTrialPlaybackLimited()) return null;
if (isPreview.value) {
const duration = Number(player?.video?.duration) || 0;
if (!duration) return null;
return Math.max(0, duration - 3);
}
let limit = videoInfo.value.freeTime ?? 0;
if (previewStartFlag.value) {
limit += videoInfo.value.previewStart ?? 0;
}
return limit;
};
/** 与 playCheck 一致:currentTime 须小于该值才可继续播放 */
const getTrialMaxTime = (): number | null => {
if (!player?.video) return null;
return getBackendTrialLimitSeconds();
};
const isSeekTimeBeyondTrialLimit = (time: number) => {
const limit = getBackendTrialLimitSeconds();
if (limit == null) return false;
return time >= limit - TRIAL_SEEK_EPSILON;
};
const isSeekTimeBeforeTrialMin = (time: number) => {
const min = getTrialMinTime();
return min > 0 && time < min - TRIAL_SEEK_EPSILON;
};
const needsTrialSeekLimit = () => isTrialPlaybackLimited();
const setPlayerCurrentTime = (time: number) => {
if (!player?.video) return;
try {
player.currentTime = time;
} catch {
try {
player.video.currentTime = time;
} catch {
/* ignore */
}
}
syncXgplayerSeekUi(player, time);
};
const setTrialFrameBlocked = (blocked: boolean) => {
trialSeekBlocking.value = blocked;
setTrialVideoFrameHidden(player, blocked);
};
/** 试看结束弹窗需要透出暂停画面,拖动防闪帧遮罩需在弹窗出现后撤掉 */
const revealTrialFrameForMask = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTrialFrameBlocked(false);
});
});
};
const clearTrialSeekBlock = () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!player?.video) return;
if (getTrialMaxTime() == null) {
setTrialFrameBlocked(false);
return;
}
if (
!isSeekTimeBeyondTrialLimit(player.video.currentTime) &&
!isSeekTimeBeforeTrialMin(player.video.currentTime)
) {
setTrialFrameBlocked(false);
}
});
});
};
const handleTrialSeekOverflow = (beyondMax: boolean): boolean => {
const limit = getBackendTrialLimitSeconds();
if (limit == null || !player?.video) return false;
const min = getTrialMinTime();
const snapTime = beyondMax ? Math.max(min, limit - TRIAL_SEEK_EPSILON) : min;
setTrialFrameBlocked(true);
player.pause();
setPlayerCurrentTime(snapTime);
player.config.disableProgress = true;
// 进度条松手后 xgplayer 还会改一次 DOM,延迟再同步时间与进度
requestAnimationFrame(() => syncXgplayerSeekUi(player, snapTime));
if (beyondMax) {
trialLimitHandled = true;
revealTrialFrameForMask();
emitTrialUnlockPopup();
} else {
revealTrialFrameForMask();
}
return true;
};
/** @returns 是否发生试看越界并已回弹 */
const clampTrialSeek = (): boolean => {
if (!player?.video) return false;
const t = player.video.currentTime;
if (isSeekTimeBeyondTrialLimit(t)) return handleTrialSeekOverflow(true);
if (isSeekTimeBeforeTrialMin(t)) return handleTrialSeekOverflow(false);
return false;
};
const bindTrialProgressGuard = () => {
unbindTrialProgressGuard?.();
unbindTrialProgressGuard = bindXgTrialProgressGuard({
getPlayer: () => player,
needsTrialSeekLimit,
getBackendTrialLimitSeconds,
getTrialMinTime,
isSeekTimeBeyondTrialLimit,
handleTrialSeekOverflow,
setPlayerCurrentTime,
setTrialFrameBlocked,
clearTrialSeekBlock,
trialSeekEpsilon: TRIAL_SEEK_EPSILON,
maskOnDragStart: () => isPreview.value,
});
};
/**
* @description 检测视频是否可以播放
*/
const playCheck = () => {
let freeTime = videoInfo.value.freeTime || 0;
if (isPreview.value) {
if (player.video.currentTime < player.video.duration - 3) {
player.config.disableProgress = false;
return true;
} else {
player.config.disableProgress = true;
return false;
}
} else {
//视频已购买
if (videoInfo.value.vidStatus.hasPaid === true) {
player.config.disableProgress = false;
state.hideProgress = false;
return true;
}
//免费视频
if (videoInfo.value.freeArea === true) {
state.hideProgress = false;
return true;
}
// vip>2可以直接观看
if (
isVip &&
((userInfo.vipLevel >= 2 && !props.hasDarkWebTag) || (userInfo.vipLevel == 4 && props.hasDarkWebTag))
) {
player.config.disableProgress = false;
state.hideProgress = false;
return true;
}
//金币视频免费看权限
if (videoInfo.value.originCoins != 0 && videoInfo.value.coins == 0) {
player.config.disableProgress = false;
state.hideProgress = false;
return true;
}
// 会员视频(金币为0,freeArea = false)
if (videoInfo.value.originCoins === 0 && videoInfo.value.freeArea === false && isVip) {
state.hideProgress = false;
player.config.disableProgress = false;
return true;
}
//自己发布的视频
if (userInfo.uid === videoInfo.value.publisher.uid) {
state.hideProgress = false;
player.config.disableProgress = false;
return true;
}
// VIP免费次数
if (userFreeCount.value.isCan === true && videoInfo.value.originCoins === 0) {
state.hideProgress = false;
player.config.disableProgress = false;
return true;
}
//免费时长
if (previewStartFlag.value) {
freeTime += videoInfo.value.previewStart;
if (player.video.currentTime < videoInfo.value.previewStart) {
player.config.disableProgress = true;
return false;
}
if (player.video.currentTime < freeTime) {
player.config.disableProgress = false;
return true;
}
} else {
if (player.video.currentTime < freeTime) {
state.hideProgress = false;
player.config.disableProgress = false;
return true;
}
}
// 预售金币视频免费次数能否观看
if (useGlobalStore().presaleObj.status != 0 && globalStore.advanceStatus.privilegeLimit.hasLimit) {
if (useGlobalStore().preSaleCoinVideoPlay) {
state.hideProgress = false;
return true;
}
player.config.disableProgress = true;
clearInterval(state.playTimer);
state.playTimer = '';
return false;
}
player.config.disableProgress = true;
clearInterval(state.playTimer);
state.playTimer = '';
return false;
}
};
//视频购买成功
function success() {
videoInfo.value.vidStatus.hasPaid = true;
player.config.disableProgress = false;
if (isPreview.value) {
forceH264 = false;
destroyPlayer(true);
initVideo();
// checkPreview();
// player.src = videoUrl.value;
}
player.play();
}
/**
* @description 获取token
*/
const getToken = () => {
const { token } = useUserStore();
return token;
};
onDeactivated(() => {
pageActive.value = false;
destroyPlayer(true);
});
onActivated(() => {
pageActive.value = true;
if (!player && videoUrl.value) {
initVideo();
}
});
onBeforeUnmount(() => {
pageActive.value = false;
if (player?.video) {
addVideoHistory(props.videoInfo, HistoryVideoType.VIDEO, Math.round(player.video.currentTime));
}
destroyPlayer();
});
defineExpose({
success,
pause,
play: () => {
if (!player && videoUrl.value) initVideo();
return player?.play?.();
},
});
</script>
<style lang="scss" scoped>
// @media (min-height: 700px) {
// .vertical_video_warp {
// padding-top: 18px;
// }
// }
.vertical_video_warp {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
background: #000000;
align-items: center;
position: relative;
.trial-seek-veil {
position: absolute;
inset: 0;
z-index: 4;
background: #000;
pointer-events: none;
}
:deep(.xgplayer-progress-played) {
background: #fff;
}
:deep(.xgplayer-progress) {
top: 9px;
}
// :deep(.xgplayer-start),
:deep(.xgplayer-controls) {
// transform: translate3d(0, 0, 0);
// -webkit-transform: translate3d(0, 0, 0);
z-index: 3;
background-image: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, #000 100%);
}
// :deep(.xgplayer-progress-btn) {
// display: none !important;
// }
:deep(.xgplayer-progress) {
left: 50%;
right: 0;
width: 54%;
transform: translate(-61%, 0);
}
:deep(.xgplayer-time) {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
span::after {
content: '';
display: none !important;
}
}
// :deep(.xgplayer-time-current::after) {
// content: '';
// display: none;
// }
.video-shell {
position: relative;
z-index: 1;
width: 100%;
height: 100% !important;
top: 0;
left: 0;
flex: 1;
min-height: 0;
:deep(video),
:deep(.xgplayer) {
width: 100%;
height: 100% !important;
}
:deep(.xgplayer-enter-spinner) {
width: 44px;
height: 44px;
}
}
.avoid_sliding {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 44px;
z-index: 2;
background: transparent;
}
&[data-hide-progress='true'] {
:deep(.xgplayer-progress),
:deep(.xgplayer-time) {
display: none;
}
}
}
</style>