ai-clone/frontend-ai/pages/video-player/video-player.vue

1704 lines
54 KiB
Vue
Raw Normal View History

2026-03-05 14:29:21 +08:00
<template>
<view class="player-container">
<view class="header">
<view class="back-btn" @click="goBack"> 返回</view>
<text class="title">{{ videoTitle }}</text>
<view class="placeholder"></view>
</view>
<!-- 错误提示 -->
<view v-if="videoError" class="error-overlay">
<view class="error-content">
<view class="error-icon"></view>
<text class="error-title">视频加载失败</text>
<text class="error-message">{{ errorMessage }}</text>
<view class="error-actions">
<view class="error-btn retry-btn" @click="retryVideo">重新加载</view>
<view class="error-btn back-btn-error" @click="goBack">返回</view>
</view>
</view>
</view>
<!-- 加载提示 -->
<view v-if="showLoadingOverlay && !videoError" class="loading-overlay" @click="handleLoadingOverlayClick">
<view class="loading-content">
<view class="loading-spinner"></view>
<text class="loading-text">{{ loadingText }}</text>
<text class="loading-hint" v-if="loadingHint">{{ loadingHint }}</text>
<view v-if="isVideoDownloading && downloadProgress > 0" class="progress-bar">
<view class="progress-fill" :style="{ width: downloadProgress + '%' }"></view>
</view>
</view>
</view>
<!-- 使用原生video组件播放视频静音使用单独的音频 -->
<video
v-if="localVideoPath"
id="videoPlayer"
:src="localVideoPath"
class="video-player"
:controls="false"
:muted="!!audioUrl"
:show-center-play-btn="false"
:enable-play-gesture="true"
:object-fit="'contain'"
:show-fullscreen-btn="false"
:show-play-btn="false"
:show-progress="false"
:enable-progress-gesture="false"
:enable-auto-rotation="false"
:show-mute-btn="false"
:show-loading="false"
@error="onVideoError"
@play="onVideoPlay"
@pause="onVideoPause"
@waiting="onVideoWaiting"
@ended="onVideoEnded"
@timeupdate="onTimeUpdate"
@loadedmetadata="onVideoLoadedMetadata"
></video>
<!-- 自定义播放按钮替代系统按钮避免右下角时间叠层 -->
<view v-if="localVideoPath && !showLoadingOverlay" class="play-overlay" @click="handleTogglePlay">
<view v-if="shouldShowPlayButton" class="play-button"></view>
</view>
</view>
</template>
<script>
import { API_BASE } from '@/config/api.js';
export default {
computed: {
showLoadingOverlay() {
// 有错误时不显示加载遮罩
if (this.hasVideoError) return false;
// 视频已结束不显示加载遮罩
if (this.videoEnded || this.pausedByAudioEnd) return false;
if (!this.localVideoPath) return true;
// 兜底:只要已判定在播放,就不要显示加载遮罩(部分端 play 事件可能延迟/丢失)
if (this.isVideoPlaying) return false;
// 等待用户点击播放(小程序禁止自动播放):不要显示“加载中”遮罩,避免挡住播放入口
if (this.userPaused && !this.videoHasStarted && !this.isVideoDownloading && !this.videoWaiting) return false;
if (this.isVideoDownloading && !this.videoHasStarted) return true;
if (!this.videoHasStarted) return true;
// 关键修复:如果有 timeupdatelastVideoTime > 0说明视频已经在播放优先判断
if (this.lastVideoTime > 0) return false;
if (this.videoWaiting) return true;
return false;
},
shouldShowPlayButton() {
// 首次未开始播放:始终显示(即使处于 waiting保证用户有入口点按播放
if (!this.videoHasStarted) return true;
// 循环重播/缓冲等自动状态下不显示按钮(仅在已开始播放后生效)
if (this.isLoopingRestart || this.videoWaiting) return false;
// 播放中不显示
if (this.isVideoPlaying) return false;
// 用户主动暂停:显示
return !!this.userPaused;
},
loadingText() {
if (this.isVideoDownloading) {
return this.downloadProgress > 0 ? `下载中 ${this.downloadProgress}%` : '准备下载...';
}
if (this.videoWaiting) {
return '缓冲中...';
}
return '加载中...';
},
loadingHint() {
if (this.waitingNeedsUserAction) {
return '点击屏幕重试';
}
if (this.isVideoDownloading && this.downloadProgress > 0) {
return '首次播放需要下载,请稍候';
}
return '';
},
videoError() {
return this.hasVideoError;
},
errorMessage() {
return this.videoErrorMessage || '请检查网络连接后重试';
}
},
data() {
return {
API_BASE,
videoId: '',
videoUrl: '',
audioUrl: '',
videoTitle: '视频播放',
audioInVideo: false,
hasAudio: false,
audioContext: null,
videoContext: null,
audioDuration: 0,
videoDuration: 0,
isAudioPlaying: false,
videoLoopTimer: null,
audioStarted: false,
audioReady: false,
audioEnded: false,
pendingAudioStart: false,
pendingVideoPlay: false,
audioStartDelaySec: 1,
lastVideoTime: 0,
enableSilencePause: false,
silenceSegments: [],
silenceLoaded: false,
pausedBySilence: false,
lastAudioTime: 0,
isLoopingRestart: false,
userPaused: false,
pausedByAudioEnd: false,
videoEndedWaitingAudio: false,
videoWaiting: false,
videoEnded: false,
lastVideoTimeUpdateAt: 0,
playbackWatchdogTimer: null,
lastWaitingRecoverAt: 0,
localVideoPath: '',
isVideoDownloading: false,
isVideoReady: false,
downloadProgress: 0,
downloadTask: null,
pendingCacheSaveTempPath: '',
pendingCacheSaveTimer: null,
waitingTimer: null,
waitingSince: 0,
waitingNeedsUserAction: false,
videoHasStarted: false,
isVideoPlaying: false,
appNetworkFallbackTimer: null,
appFallbackTriggered: false,
appNetworkFallbackDelayMs: 900,
hasShownStorageLimitToast: false,
cacheKey: '', // 视频缓存key
audioCacheKey: '',
audioStartTime: 0, // 音频开始播放的时间戳
videoStartTime: 0, // 视频开始播放的时间戳
audioPauseTime: 0, // 音频暂停时的播放位置
hasVideoError: false,
videoErrorMessage: '',
retryCount: 0,
maxRetryCount: 3,
isRestarting: false // 标记是否正在重新播放
};
},
onLoad(options) {
console.log('[VideoPlayer] 接收参数:', options);
console.log('[VideoPlayer] BUILD_MARK_NO_EXTERNAL_AUDIO=2026-01-08-01');
const audioInVideoParam = options.audioInVideo === '1' || options.audioInVideo === 'true';
if (options.id !== undefined && options.id !== null && String(options.id).trim() !== '') {
this.videoId = String(options.id).trim();
}
if (options.videoUrl) {
this.videoUrl = decodeURIComponent(options.videoUrl);
} else if (options.url) {
this.videoUrl = decodeURIComponent(options.url);
}
if (options.audioDelay !== undefined && options.audioDelay !== null && String(options.audioDelay).trim() !== '') {
const n = Number(options.audioDelay);
if (!Number.isNaN(n) && n >= 0) {
this.audioStartDelaySec = n;
}
}
if (options.silencePause !== undefined && options.silencePause !== null && String(options.silencePause).trim() !== '') {
const sp = String(options.silencePause).trim();
this.enableSilencePause = sp === '1' || sp === 'true';
}
this.videoUrl = this.normalizeMediaUrl(this.videoUrl);
this.videoEnded = false;
// #ifdef MP-WEIXIN
// 微信小程序审核要求:禁止多媒体自动播放。进入页面默认不自动播放,需用户点击后再播放。
this.userPaused = true;
// #endif
// 若传入videoId则优先从数据库读取 edited_video_url 作为播放源
if (this.videoId) {
this.loadVideoFromDb(audioInVideoParam, options);
} else {
const matchInVideo = !!(this.videoUrl && this.videoUrl.indexOf('/static/videos/revival_') !== -1);
const audioInVideo = audioInVideoParam || matchInVideo;
this.audioInVideo = audioInVideo;
console.log('[VideoPlayer] audioInVideo判定: audioInVideoParam=', audioInVideoParam, 'matchInVideo=', matchInVideo, 'videoUrl=', this.videoUrl);
this.cacheKey = this.generateCacheKey(this.videoUrl);
this.loadVideo();
}
if (options.title) {
this.videoTitle = decodeURIComponent(options.title);
}
// 禁用外部音频:播放器只播放视频本身,不叠加/不同步/不处理任何外部音频
this.hasAudio = false;
this.audioUrl = '';
if (audioInVideo) {
// 视频自带音轨:当音频结束时,视频自然结束并停止(无需额外 loop
this.hasAudio = true;
console.log('[VideoPlayer] audioInVideo=1使用视频自带音轨');
} else if (options.audioUrl) {
console.log('[VideoPlayer] 已忽略外部 audioUrl 参数(禁用外部音频)');
}
// 初始化视频上下文
this.$nextTick(() => {
this.videoContext = uni.createVideoContext('videoPlayer', this);
});
},
onUnload() {
this.stopVideoLoop();
this.stopPlaybackWatchdog();
if (this.pendingCacheSaveTimer) {
clearTimeout(this.pendingCacheSaveTimer);
this.pendingCacheSaveTimer = null;
}
if (this.waitingTimer) {
clearTimeout(this.waitingTimer);
this.waitingTimer = null;
}
try {
if (this.downloadTask && typeof this.downloadTask.abort === 'function') {
this.downloadTask.abort();
}
} catch (e) {}
this.downloadTask = null;
if (this.audioContext) {
this.audioContext.stop();
this.audioContext.destroy();
}
},
methods: {
handleLoadingOverlayClick() {
if (!this.videoWaiting) return;
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
// 外置音频播放中时,直接对视频 play 可能触发内核 stop 音频;先暂停音频再尝试恢复
if (!this.audioInVideo && this.hasAudio && this.audioContext && !this.audioEnded) {
try {
if (this.isAudioPlaying) {
this.audioPauseTime = this.audioContext.currentTime;
this.audioContext.pause();
}
} catch (e) {}
}
const t = Number(this.lastVideoTime) || 0;
const seekTo = Math.max(0, t - 0.3);
console.log('[VideoPlayer] waiting手动重试: seekTo=', seekTo);
try {
this.videoContext.seek && this.videoContext.seek(seekTo);
} catch (e) {}
setTimeout(() => {
if (!this.videoContext) return;
this.videoContext.play && this.videoContext.play();
}, 120);
},
scheduleSaveVideoToCache(tempPath, delayMs) {
if (!tempPath) return;
this.pendingCacheSaveTempPath = tempPath;
if (this.pendingCacheSaveTimer) {
clearTimeout(this.pendingCacheSaveTimer);
this.pendingCacheSaveTimer = null;
}
const baseDelay = typeof delayMs === 'number' ? delayMs : 1600;
this.pendingCacheSaveTimer = setTimeout(() => {
this.pendingCacheSaveTimer = null;
const p = this.pendingCacheSaveTempPath;
if (!p) return;
// 播放/缓冲中尽量不落盘,避免卡顿;稍后再试
if (this.isVideoPlaying || this.videoWaiting) {
this.scheduleSaveVideoToCache(p, 2000);
return;
}
this.pendingCacheSaveTempPath = '';
uni.saveFile({
tempFilePath: p,
success: (saveRes) => {
const savedPath = saveRes.savedFilePath;
console.log('[VideoPlayer] 视频已缓存:', savedPath);
uni.setStorageSync(this.cacheKey, savedPath);
const cacheInfo = uni.getStorageSync('video_cache_info') || {};
cacheInfo[this.cacheKey] = {
path: savedPath,
url: this.videoUrl,
time: Date.now()
};
uni.setStorageSync('video_cache_info', cacheInfo);
},
fail: () => {
console.log('[VideoPlayer] 视频缓存失败,使用临时文件播放');
}
});
}, Math.max(300, baseDelay));
},
async loadVideoFromDb(audioInVideoParam, options) {
try {
const token = uni.getStorageSync('token') || '';
const userId = uni.getStorageSync('userId') || '';
const url = `${this.API_BASE}/api/photo-revival/videos/${encodeURIComponent(this.videoId)}`;
console.log('[VideoPlayer] 从数据库加载视频详情:', url);
const res = await new Promise((resolve, reject) => {
uni.request({
url,
method: 'GET',
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : ''
},
success: resolve,
fail: reject
});
});
const data = res && res.data ? res.data : null;
const v = data && data.success ? (data.video || {}) : {};
const dbUrl = v.edited_video_url || v.local_video_path || v.video_url || '';
if (dbUrl) {
this.videoUrl = this.normalizeMediaUrl(String(dbUrl));
console.log('[VideoPlayer] 使用数据库视频URL:', this.videoUrl);
} else {
console.log('[VideoPlayer] 数据库未返回视频URL回退使用参数videoUrl:', this.videoUrl);
}
const matchInVideo = !!(this.videoUrl && this.videoUrl.indexOf('/static/videos/revival_') !== -1);
const audioInVideo = audioInVideoParam || matchInVideo;
this.audioInVideo = audioInVideo;
console.log('[VideoPlayer] audioInVideo判定(DB): audioInVideoParam=', audioInVideoParam, 'matchInVideo=', matchInVideo, 'videoUrl=', this.videoUrl);
this.cacheKey = this.generateCacheKey(this.videoUrl);
this.loadVideo();
} catch (e) {
console.error('[VideoPlayer] 数据库加载视频详情失败回退使用参数videoUrl:', e);
const matchInVideo = !!(this.videoUrl && this.videoUrl.indexOf('/static/videos/revival_') !== -1);
const audioInVideo = audioInVideoParam || matchInVideo;
this.audioInVideo = audioInVideo;
this.cacheKey = this.generateCacheKey(this.videoUrl);
this.loadVideo();
}
},
handleTogglePlay() {
if (!this.videoContext) {
this.$nextTick(() => {
this.videoContext = uni.createVideoContext('videoPlayer', this);
this.userPaused = false;
this.videoContext && this.videoContext.play && this.videoContext.play();
});
return;
}
// 视频已结束,从头播放
if (this.videoEnded || this.pausedByAudioEnd) {
console.log('[VideoPlayer] 视频已结束,从头播放');
this.isRestarting = true; // 标记正在重新播放
this.videoEnded = false;
this.audioEnded = false;
this.pausedByAudioEnd = false;
this.videoEndedWaitingAudio = false;
this.videoWaiting = false;
this.userPaused = false;
this.videoHasStarted = false;
this.lastVideoTime = 0;
this.isVideoPlaying = false;
this.retryCount = 0;
this.stopPlaybackWatchdog();
// 部分端:结束态下仅 seek(0) + play 不会真正重启,需先 stop
try {
this.videoContext.stop && this.videoContext.stop();
} catch (e) {}
try {
this.videoContext.seek && this.videoContext.seek(0);
} catch (e) {}
setTimeout(() => {
if (this.videoContext) {
this.videoContext.play && this.videoContext.play();
}
// 延迟清除标志,确保 onVideoEnded 不会干扰
setTimeout(() => {
this.isRestarting = false;
}, 500);
}, 100);
// 触觉反馈
uni.vibrateShort && uni.vibrateShort({ type: 'light' });
return;
}
if (this.isVideoPlaying) {
this.userPaused = true;
this.videoContext.pause();
// 触觉反馈
uni.vibrateShort && uni.vibrateShort({ type: 'light' });
return;
}
this.userPaused = false;
this.videoContext.play();
// 触觉反馈
uni.vibrateShort && uni.vibrateShort({ type: 'light' });
},
startPlaybackWatchdog() {
if (this.playbackWatchdogTimer) return;
this.lastVideoTimeUpdateAt = Date.now();
this.playbackWatchdogTimer = setInterval(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) return;
if (this.audioEnded) return;
const now = Date.now();
// 小程序可能出现“静默暂停/卡住但不触发事件”,用 timeupdate 超时检测
if (this.isVideoPlaying && this.lastVideoTimeUpdateAt && (now - this.lastVideoTimeUpdateAt) > 2500) {
console.log('[VideoPlayer] 检测到视频疑似卡住(无timeupdate)尝试恢复播放。lastVideoTime=', this.lastVideoTime);
this.videoContext.play();
}
}, 1500);
},
stopPlaybackWatchdog() {
if (this.playbackWatchdogTimer) {
clearInterval(this.playbackWatchdogTimer);
this.playbackWatchdogTimer = null;
}
},
goBack() {
try {
const pages = getCurrentPages ? getCurrentPages() : [];
if (pages && pages.length > 1) {
uni.navigateBack();
return;
}
} catch (e) {}
uni.switchTab({
url: '/pages/history/history'
});
},
// 生成缓存key使用URL的简单hash
generateCacheKey(url) {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'video_' + Math.abs(hash);
},
generateAudioCacheKey(url) {
let hash = 0;
for (let i = 0; i < url.length; i++) {
const char = url.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return 'audio_' + Math.abs(hash);
},
normalizeMediaUrl(url) {
if (!url) return url;
// Relative path
if (url.startsWith('/')) {
return `${this.API_BASE}${url}`;
}
// Old hardcoded IP http endpoint -> use current API_BASE (https)
if (url.startsWith('http://115.190.167.176:20002')) {
return url.replace('http://115.190.167.176:20002', this.API_BASE);
}
return url;
},
toPlayableLocalPath(filePath) {
if (!filePath) return filePath;
try {
// App(5+) 下部分本地路径需要转换成原生可识别的绝对路径,否则 video 可能黑屏
if (typeof plus !== 'undefined' && plus.io && typeof plus.io.convertLocalFileSystemURL === 'function') {
return plus.io.convertLocalFileSystemURL(filePath);
}
} catch (e) {}
return filePath;
},
// 加载视频(优先使用缓存)
loadVideo() {
if (!this.videoUrl) return;
this.videoHasStarted = false;
this.videoWaiting = false;
this.videoEnded = false;
// 1. 检查缓存映射
const cachedPath = uni.getStorageSync(this.cacheKey);
if (cachedPath) {
console.log('[VideoPlayer] 找到缓存视频:', cachedPath);
// 验证缓存文件是否存在
uni.getSavedFileInfo({
filePath: cachedPath,
success: (res) => {
console.log('[VideoPlayer] 缓存文件有效,直接使用');
this.localVideoPath = this.toPlayableLocalPath(cachedPath);
this.isVideoReady = true;
this.isVideoDownloading = false;
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) {
this.videoContext.play && this.videoContext.play();
}
}, 80);
});
},
fail: () => {
console.log('[VideoPlayer] 缓存文件已失效,重新下载');
uni.removeStorageSync(this.cacheKey);
// 缓存失效使用URL播放并后台下载
this.localVideoPath = this.videoUrl;
this.downloadAndCacheVideo();
this.isVideoReady = true;
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) {
this.videoContext.play && this.videoContext.play();
}
}, 80);
});
}
});
} else {
console.log('[VideoPlayer] 无缓存,先在线播放,后台下载缓存');
// App端优先尝试网络URL直连秒开若短时间无法开始播放则自动切换到下载临时文件兜底
if (typeof plus !== 'undefined') {
this.tryAppNetworkPlaybackThenFallback();
return;
}
// 非App先使用URL直接播放避免等待整段下载
this.localVideoPath = this.videoUrl;
this.isVideoReady = true;
// #ifndef MP-WEIXIN
this.downloadAndCacheVideo();
// #endif
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) {
this.videoContext.play && this.videoContext.play();
}
}, 80);
});
}
},
tryAppNetworkPlaybackThenFallback() {
this.appFallbackTriggered = false;
if (this.appNetworkFallbackTimer) {
clearTimeout(this.appNetworkFallbackTimer);
this.appNetworkFallbackTimer = null;
}
this.localVideoPath = this.videoUrl;
this.isVideoReady = true;
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) {
this.videoContext.play && this.videoContext.play();
}
}, 80);
});
const delayMs = Number(this.appNetworkFallbackDelayMs) || 900;
this.appNetworkFallbackTimer = setTimeout(() => {
if (this.videoHasStarted) return;
if (this.appFallbackTriggered) return;
console.log('[VideoPlayer] App网络直连未能快速开始播放切换为下载兜底');
this.appFallbackTriggered = true;
this.downloadForPlaybackAndCache();
}, Math.max(300, delayMs));
},
downloadForPlaybackAndCache() {
console.log('[VideoPlayer] App无缓存先下载临时文件播放再写入缓存:', this.videoUrl);
this.isVideoDownloading = true;
this.downloadProgress = 0;
const task = uni.downloadFile({
url: this.videoUrl,
success: (res) => {
if (res.statusCode !== 200) {
console.error('[VideoPlayer] App临时下载失败状态码:', res.statusCode);
this.isVideoDownloading = false;
this.isVideoReady = true;
this.localVideoPath = this.videoUrl;
this.downloadTask = null;
return;
}
const tempPath = res.tempFilePath;
this.localVideoPath = this.toPlayableLocalPath(tempPath);
this.isVideoReady = true;
this.isVideoDownloading = false;
this.downloadTask = null;
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (!this.videoHasStarted) {
this.videoContext.play && this.videoContext.play();
}
}, 80);
});
// 避免与播放抢占IO/资源导致卡顿:延迟写入缓存
this.scheduleSaveVideoToCache(tempPath, 1800);
},
fail: (err) => {
console.error('[VideoPlayer] App临时下载失败:', err);
this.isVideoDownloading = false;
this.isVideoReady = true;
this.localVideoPath = this.videoUrl;
this.downloadTask = null;
}
});
this.downloadTask = task || null;
if (this.downloadTask && typeof this.downloadTask.onProgressUpdate === 'function') {
this.downloadTask.onProgressUpdate((p) => {
if (p && typeof p.progress === 'number') {
this.downloadProgress = Math.max(0, Math.min(100, Math.floor(p.progress)));
}
});
}
},
// 下载并缓存视频
downloadAndCacheVideo() {
// #ifdef MP-WEIXIN
// 微信小程序端存储空间有限,避免 saveFile 永久缓存统一走URL直连播放
return;
// #endif
console.log('[VideoPlayer] 开始下载视频:', this.videoUrl);
this.isVideoDownloading = true;
this.downloadProgress = 0;
const task = uni.downloadFile({
url: this.videoUrl,
success: (res) => {
if (res.statusCode === 200) {
console.log('[VideoPlayer] 视频下载成功(临时文件),延迟写入缓存以避免卡顿');
this.isVideoReady = true;
this.isVideoDownloading = false;
this.downloadTask = null;
// 避免与播放抢占IO/资源导致卡顿:延迟写入缓存
this.scheduleSaveVideoToCache(res.tempFilePath, 2200);
} else {
console.error('[VideoPlayer] 视频下载失败,状态码:', res.statusCode);
this.isVideoDownloading = false;
this.downloadTask = null;
// 保持使用在线播放
if (!this.localVideoPath) {
this.localVideoPath = this.videoUrl;
}
this.isVideoReady = true;
this.downloadTask = null;
}
},
fail: (err) => {
console.error('[VideoPlayer] 视频下载失败:', err);
// 下载失败保持URL直接播放
if (!this.localVideoPath) {
this.localVideoPath = this.videoUrl;
}
this.isVideoReady = true;
this.isVideoDownloading = false;
this.downloadTask = null;
uni.showToast({
title: '下载失败,使用在线播放',
icon: 'none'
});
}
});
this.downloadTask = task || null;
if (this.downloadTask && typeof this.downloadTask.onProgressUpdate === 'function') {
this.downloadTask.onProgressUpdate((p) => {
if (p && typeof p.progress === 'number') {
this.downloadProgress = Math.max(0, Math.min(100, Math.floor(p.progress)));
}
});
}
},
// 初始化音频
initAudio() {
if (!this.audioUrl) return;
this.audioContext = uni.createInnerAudioContext();
this.audioContext.src = this.audioUrl;
this.audioContext.autoplay = false;
this.audioReady = false;
this.audioEnded = false;
this.pausedBySilence = false;
this.lastAudioTime = 0;
this.lastAudioTimeUpdateAt = 0;
this.silenceSegments = [];
this.silenceLoaded = false;
this.audioCacheKey = this.generateAudioCacheKey(this.audioUrl);
const cachedAudioPath = uni.getStorageSync(this.audioCacheKey);
if (cachedAudioPath) {
uni.getSavedFileInfo({
filePath: cachedAudioPath,
success: () => {
console.log('[VideoPlayer] 找到缓存音频:', cachedAudioPath);
if (this.audioContext) {
this.audioContext.src = cachedAudioPath;
}
},
fail: () => {
uni.removeStorageSync(this.audioCacheKey);
}
});
}
if (!cachedAudioPath) {
uni.downloadFile({
url: this.audioUrl,
timeout: 60000,
success: (res) => {
if (res.statusCode === 200 && this.audioContext) {
uni.saveFile({
tempFilePath: res.tempFilePath,
success: (saveRes) => {
const savedPath = saveRes.savedFilePath;
console.log('[VideoPlayer] 音频已缓存:', savedPath);
uni.setStorageSync(this.audioCacheKey, savedPath);
if (this.audioContext) {
this.audioContext.src = savedPath;
}
},
fail: () => {
console.log('[VideoPlayer] 音频缓存失败,使用临时文件播放');
this.audioContext.src = res.tempFilePath;
}
});
}
},
fail: (err) => {
console.error('[VideoPlayer] 音频下载失败回退到URL直连:', err);
}
});
}
if (this.enableSilencePause) {
this.fetchSilenceSegments();
}
this.audioContext.onCanplay(() => {
this.audioReady = true;
const d = Number(this.audioContext.duration);
if (!Number.isNaN(d) && d > 0) {
this.audioDuration = d;
}
console.log('[VideoPlayer] 音频时长:', this.audioDuration, '秒');
// 音频 ready 后:如果视频已开始并满足延迟条件,立即启动并对齐
if (!this.audioStarted && !this.audioEnded && this.videoHasStarted && this.lastVideoTime >= this.audioStartDelaySec) {
const startAt = Math.max(0, (this.lastVideoTime || 0) - (this.audioStartDelaySec || 0));
console.log('[VideoPlayer] 音频已就绪且视频已到达延迟点开始播放音频seek=', startAt);
this.startAudioFromVideoTime(startAt);
return;
}
});
// 某些端 onCanplay 触发时 duration 仍为 0稍后再取一次
setTimeout(() => {
if (this.audioContext) {
const d = Number(this.audioContext.duration);
if (!Number.isNaN(d) && d > 0) {
this.audioDuration = d;
}
console.log('[VideoPlayer] 音频时长(延迟校准):', this.audioDuration, '秒');
}
}, 200);
this.audioContext.onPlay(() => {
console.log('[VideoPlayer] 音频开始播放');
this.isAudioPlaying = true;
});
if (this.audioContext.onTimeUpdate) {
this.audioContext.onTimeUpdate(() => {
if (!this.audioContext) return;
const t = Number(this.audioContext.currentTime) || 0;
this.lastAudioTime = t;
this.lastAudioTimeUpdateAt = Date.now();
if (this.enableSilencePause) {
this.handleAudioTimeUpdate(t);
}
});
}
this.audioContext.onPause(() => {
console.log('[VideoPlayer] 音频被暂停,当前位置:', this.audioContext.currentTime, '秒');
this.isAudioPlaying = false;
});
this.audioContext.onStop(() => {
console.log('[VideoPlayer] 音频被停止');
this.isAudioPlaying = false;
});
this.audioContext.onWaiting(() => {
console.log('[VideoPlayer] 音频加载中...');
});
this.audioContext.onError((err) => {
console.error('[VideoPlayer] 音频播放失败:', err);
});
this.audioContext.onEnded(() => {
console.log('[VideoPlayer] 音频播放结束,总时长:', this.audioContext.duration, '秒,播放到:', this.audioContext.currentTime, '秒');
this.isAudioPlaying = false;
this.audioEnded = true;
this.pausedBySilence = false;
this.videoEndedWaitingAudio = false;
this.stopVideoLoop();
// 兼容小程序:可能出现音频 duration 获取不准/过早 ended。
// 只在“确认音频确实播到结尾”时才联动停止视频。
const audioCur = Number(this.audioContext.currentTime) || 0;
const audioCurStable = Math.max(audioCur, Number(this.lastAudioTime) || 0);
const audioDur = Number(this.audioDuration) || Number(this.audioContext.duration) || 0;
// 小程序端偶发onEnded 触发时 currentTime=0但 lastAudioTime 接近结尾。
// 这里放宽判断:接近结尾或已播放比例很高,都认为“确实结束”,联动停止视频。
const audioEndedForSure = audioDur > 0 && (
audioCurStable >= (audioDur - 0.6) ||
(audioCurStable / audioDur) >= 0.95
);
const videoNearEnd = this.videoDuration && this.lastVideoTime >= (this.videoDuration - 0.3);
if (this.videoContext && (audioEndedForSure || videoNearEnd)) {
console.log('[VideoPlayer] 音频结束联动停止视频。audioEndedForSure=', audioEndedForSure, 'videoNearEnd=', videoNearEnd);
// 清理 waiting 状态,避免 pause 后仍被自动恢复逻辑拉起
this.videoWaiting = false;
// 视为“用户暂停”,阻断一切自动恢复/自动播放
this.userPaused = true;
this.pausedByAudioEnd = true;
this.stopPlaybackWatchdog();
this.videoContext.pause();
// 兜底:小程序端偶发 pause 未生效/被系统回拉,短延迟再 pause 一次
setTimeout(() => {
if (!this.videoContext) return;
if (!this.audioEnded || !this.pausedByAudioEnd) return;
this.videoContext.pause && this.videoContext.pause();
}, 180);
this.isVideoPlaying = false;
} else {
console.log('[VideoPlayer] 音频 onEnded 触发但无法确认已到结尾跳过联动停止视频。audioDur=', audioDur, 'audioCur=', audioCur, 'lastAudioTime=', this.lastAudioTime);
}
});
},
fetchSilenceSegments() {
if (!this.audioUrl) return;
const userId = uni.getStorageSync('userId') || '';
const token = uni.getStorageSync('token') || '';
if (!token) {
return;
}
uni.request({
url: `${this.API_BASE}/api/video/silence-detect`,
method: 'POST',
timeout: 120000,
header: {
'X-User-Id': userId,
'Authorization': token ? `Bearer ${token}` : '',
'Content-Type': 'application/json'
},
data: {
audioUrl: this.audioUrl
},
success: (res) => {
const ok = res.statusCode === 200 && (
res.data?.success === true ||
res.data?.success === 'true' ||
res.data?.success === 1 ||
res.data?.success === '1'
);
if (!ok) {
console.warn('[VideoPlayer] 静音检测失败:', res.data);
return;
}
const segs = Array.isArray(res.data?.segments) ? res.data.segments : [];
this.silenceSegments = segs
.map((s) => ({
start: Number(s.start) || 0,
end: Number(s.end) || 0
}))
.filter((s) => s.end > s.start)
.sort((a, b) => a.start - b.start);
this.silenceLoaded = true;
console.log('[VideoPlayer] 静音区间数量:', this.silenceSegments.length);
},
fail: (err) => {
console.error('[VideoPlayer] 静音检测请求失败:', err);
}
});
},
handleAudioTimeUpdate(audioTime) {
if (!this.videoContext || !this.hasAudio) return;
if (!this.enableSilencePause) return;
if (!this.silenceLoaded || this.silenceSegments.length === 0) return;
if (this.audioEnded) return;
let inSilence = false;
for (const seg of this.silenceSegments) {
if (audioTime >= seg.start && audioTime < seg.end) {
inSilence = true;
break;
}
if (audioTime < seg.start) break;
}
if (inSilence) {
if (!this.pausedBySilence) {
this.pausedBySilence = true;
console.log('[VideoPlayer] 进入停顿区间,暂停视频');
this.videoContext.pause();
}
return;
}
if (this.pausedBySilence) {
this.pausedBySilence = false;
console.log('[VideoPlayer] 结束停顿,继续播放视频');
this.videoContext.play();
}
},
startAudioFromVideoTime(currentTime) {
if (!this.audioContext || !this.hasAudio) return;
if (this.audioEnded) return;
// 音频还未 ready 时,等待 onCanplay
if (!this.audioReady) {
this.pendingAudioStart = true;
console.log('[VideoPlayer] 音频未就绪,等待 onCanplay 后启动');
return;
}
this.pendingAudioStart = false;
this.audioStarted = true; // 标记音频已开始(或已请求开始)
if (this.audioContext.seek) {
this.audioContext.seek(Math.max(0, currentTime || 0));
}
// 获取实时音频时长(可能仍为 0后面会延迟校准
const actualDuration = this.audioContext.duration || this.audioDuration;
console.log('[VideoPlayer] 实际音频时长:', actualDuration, '秒');
// 使用正常速度播放音频
this.audioContext.playbackRate = 1;
console.log('[VideoPlayer] 使用正常速度播放音频');
this.audioStartTime = Date.now();
this.videoStartTime = Date.now() - (currentTime * 1000);
console.log('[VideoPlayer] 准备播放音频URL:', this.audioUrl);
this.audioContext.play();
},
retryVideo() {
this.hasVideoError = false;
this.videoErrorMessage = '';
this.retryCount++;
console.log('[VideoPlayer] 用户手动重试,第', this.retryCount, '次');
// 清理缓存,重新加载
if (this.cacheKey) {
try {
uni.removeStorageSync(this.cacheKey);
} catch (e) {}
}
this.localVideoPath = '';
this.isVideoReady = false;
this.videoHasStarted = false;
this.videoWaiting = false;
this.videoEnded = false;
this.$nextTick(() => {
this.loadVideo();
});
},
onVideoError(e) {
console.error('[VideoPlayer] 视频加载失败:', e);
const errMsg = e && e.detail && e.detail.errMsg ? String(e.detail.errMsg) : '';
const errCode = e && e.detail && (e.detail.errCode !== undefined && e.detail.errCode !== null) ? String(e.detail.errCode) : '';
const src = this.localVideoPath || this.videoUrl || '';
// 清掉 waiting避免错误后 waiting 自动恢复与重试逻辑互相打断
this.videoWaiting = false;
// 视频已结束或音频已结束,不要重试(避免循环)
if (this.videoEnded || this.pausedByAudioEnd) {
console.log('[VideoPlayer] 视频已结束,忽略错误事件,不重试');
return;
}
// 视频已经播放到接近结尾最后1秒内认为是正常结束不重试
if (this.videoDuration > 0 && this.lastVideoTime >= (this.videoDuration - 1)) {
console.log('[VideoPlayer] 视频已播放到结尾,忽略错误事件,不重试');
return;
}
// App端自动切换下载兜底
if (typeof plus !== 'undefined' && !this.appFallbackTriggered && src === this.videoUrl) {
console.log('[VideoPlayer] App网络直连播放失败切换为下载兜底');
this.appFallbackTriggered = true;
this.downloadForPlaybackAndCache();
return;
}
// 自动重试机制最多3次
if (this.retryCount < this.maxRetryCount) {
this.retryCount++;
console.log('[VideoPlayer] 自动重试,第', this.retryCount, '次');
setTimeout(() => {
// 再次检查是否已结束
if (this.videoEnded || this.pausedByAudioEnd) {
console.log('[VideoPlayer] 重试前检测到视频已结束,取消重试');
return;
}
if (this.videoDuration > 0 && this.lastVideoTime >= (this.videoDuration - 1)) {
console.log('[VideoPlayer] 重试前检测到视频已播放到结尾,取消重试');
return;
}
if (this.cacheKey) {
try {
uni.removeStorageSync(this.cacheKey);
} catch (e) {}
}
// 重置部分状态,但保留 videoHasStarted 避免加载遮罩重新显示
// 标记为“重启中”,忽略期间内核可能抛出的 ended 事件,避免状态机互相打架
this.isRestarting = true;
this.isVideoPlaying = false;
this.videoWaiting = false;
this.localVideoPath = this.videoUrl;
this.isVideoReady = true;
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
if (this.videoContext && !this.userPaused && !this.videoEnded) {
this.videoContext.play && this.videoContext.play();
}
// 短延迟后清除标记,避免长期屏蔽 ended
setTimeout(() => {
this.isRestarting = false;
}, 800);
}, 100);
});
}, 1000 * this.retryCount);
return;
}
// 超过重试次数,显示错误界面
this.hasVideoError = true;
this.videoErrorMessage = errCode ? `错误代码: ${errCode}` : '网络连接失败或视频格式不支持';
},
onVideoLoadedMetadata(e) {
if (e && e.detail && e.detail.duration) {
this.videoDuration = e.detail.duration;
console.log('[VideoPlayer] 视频时长:', this.videoDuration, '秒');
}
this.videoWaiting = false;
},
onVideoPlay() {
console.log('[VideoPlayer] 视频开始播放');
// 如果已经显示错误界面,阻止播放
if (this.hasVideoError) {
console.log('[VideoPlayer] 视频加载失败,阻止播放');
try {
this.videoContext && this.videoContext.pause && this.videoContext.pause();
} catch (e) {}
return;
}
if (this.appNetworkFallbackTimer) {
clearTimeout(this.appNetworkFallbackTimer);
this.appNetworkFallbackTimer = null;
}
this.waitingNeedsUserAction = false;
// 清除 waiting 定时器
if (this.waitingTimer) {
clearTimeout(this.waitingTimer);
this.waitingTimer = null;
}
// 只有在真正结束且用户没有主动重新播放时才阻止
if (this.videoEnded && this.pausedByAudioEnd) {
console.log('[VideoPlayer] 视频已结束且被音频结束暂停,阻止继续播放');
try {
this.videoContext && this.videoContext.pause && this.videoContext.pause();
} catch (e) {}
return;
}
this.videoHasStarted = true;
this.isVideoPlaying = true;
this.videoWaiting = false;
this.lastVideoTimeUpdateAt = Date.now();
this.startPlaybackWatchdog();
// 如果音频已结束并且是由音频结束触发的暂停,则不允许视频被系统/事件回拉播放
if (this.audioEnded && this.pausedByAudioEnd && this.videoContext) {
console.log('[VideoPlayer] 音频已结束,阻止视频继续播放');
this.videoWaiting = false;
this.userPaused = true;
this.videoContext.pause();
this.isVideoPlaying = false;
return;
}
if (!this.hasAudio || !this.audioContext) return;
// 无延迟模式:视频一开始播放就尽量启动音频(音频未 ready 则等待 onCanplay
if (this.audioStartDelaySec === 0 && !this.audioStarted && !this.audioEnded) {
if (this.audioReady) {
const startAt = Math.max(0, this.lastVideoTime || 0);
console.log('[VideoPlayer] 无延迟模式视频开始播放启动音频seek=', startAt);
this.startAudioFromVideoTime(startAt);
return;
}
this.pendingAudioStart = true;
return;
}
// 如果音频还未就绪,先标记,等 onCanplay 且视频到达延迟点再启动
if (!this.audioReady) {
this.pendingAudioStart = true;
console.log('[VideoPlayer] 音频未就绪,等待音频就绪后在指定延迟点播放');
return;
}
// 音频已经开始过且被暂停,则恢复播放
if (this.audioStarted && !this.isAudioPlaying && !this.audioEnded) {
console.log('[VideoPlayer] 恢复音频播放,当前位置:', this.audioContext.currentTime, '秒');
this.audioContext.play();
}
},
onVideoPause() {
console.log('[VideoPlayer] 视频暂停');
// 循环重播时会触发 pause 事件,避免误把音频也暂停
if (this.isLoopingRestart) {
return;
}
// 系统等待/缓冲导致的暂停:不要暂停音频,避免造成“中断后无法恢复”
if (this.videoWaiting || !this.userPaused) {
console.log('[VideoPlayer] 非用户暂停(可能缓冲/系统暂停),不联动暂停音频');
this.isVideoPlaying = false;
return;
}
// 同步暂停音频
if (this.audioContext && this.hasAudio && this.isAudioPlaying) {
console.log('[VideoPlayer] 暂停音频,当前位置:', this.audioContext.currentTime, '秒');
this.audioPauseTime = this.audioContext.currentTime;
this.audioContext.pause();
}
},
onVideoWaiting() {
// 如果视频已经在正常播放(有持续的 timeupdate忽略偶发的 waiting 事件
const now = Date.now();
if (this.isVideoPlaying && this.lastVideoTimeUpdateAt && (now - this.lastVideoTimeUpdateAt) < 500) {
console.log('[VideoPlayer] 视频正在播放中,忽略偶发 waiting 事件');
return;
}
this.videoWaiting = true;
this.waitingNeedsUserAction = false;
this.waitingSince = now;
console.log('[VideoPlayer] 视频缓冲中(waiting)lastVideoTime=', this.lastVideoTime);
if (!this.videoContext) return;
if (this.waitingTimer) {
clearTimeout(this.waitingTimer);
this.waitingTimer = null;
}
this.waitingTimer = setTimeout(() => {
this.waitingTimer = null;
if (!this.videoWaiting) return;
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
// 外置音频未结束:不自动强拉起,提示用户点击重试(避免频繁 play 导致音频 stop
if (!this.audioInVideo && this.hasAudio && !this.audioEnded) {
this.waitingNeedsUserAction = true;
return;
}
const t = Number(this.lastVideoTime) || 0;
const seekTo = Math.max(0, t - 0.3);
console.log('[VideoPlayer] waiting超时自动恢复: seekTo=', seekTo);
try {
this.videoContext.seek && this.videoContext.seek(seekTo);
} catch (e) {}
setTimeout(() => {
if (!this.videoContext) return;
this.videoContext.play && this.videoContext.play();
}, 120);
}, 6000);
if (typeof plus !== 'undefined' && !this.videoHasStarted && !this.appFallbackTriggered && (this.localVideoPath === this.videoUrl)) {
console.log('[VideoPlayer] App首帧 waiting立即切换为下载兜底');
this.appFallbackTriggered = true;
this.downloadForPlaybackAndCache();
return;
}
if (this.videoEnded) {
return;
}
// 视频已结束但音频未结束:等待音频即可,不做任何自动恢复
if (this.videoEndedWaitingAudio && this.hasAudio && !this.audioEnded) {
console.log('[VideoPlayer] 视频已结束且音频未结束:忽略 waiting');
return;
}
// 关键修复:小程序端只要页面同时存在 video+InnerAudioContext
// 在 waiting 阶段频繁调用 video.play() 都可能触发内核把音频系统 stop504/67
// 这里不依赖 isAudioPlaying该状态可能抖动/更新延迟),而是只要“有音频且未结束”就跳过。
if (!this.audioInVideo && this.hasAudio && !this.audioEnded) {
console.log('[VideoPlayer] 存在音频且未结束,跳过 waiting 自动恢复,以避免音频被系统 stop');
return;
}
// waiting 后稍后尝试自动恢复(非用户暂停、非静音段暂停、非音频已结束),并做节流
const nowRecover = Date.now();
if (this.lastWaitingRecoverAt && (nowRecover - this.lastWaitingRecoverAt) < 1500) {
return;
}
this.lastWaitingRecoverAt = nowRecover;
setTimeout(() => {
if (!this.videoContext) return;
if (this.userPaused) return;
if (this.videoEnded) return;
if (this.pausedBySilence) return;
if (this.audioEnded) return;
console.log('[VideoPlayer] waiting后尝试恢复视频播放');
this.videoContext.play();
}, 600);
},
onVideoEnded() {
console.log('[VideoPlayer] 视频播放结束', 'lastVideoTime=', this.lastVideoTime, 'videoDuration=', this.videoDuration, 'audioEnded=', this.audioEnded, 'isRestarting=', this.isRestarting);
// 正在重新播放,忽略此次 ended 事件
if (this.isRestarting) {
console.log('[VideoPlayer] 正在重新播放,忽略 ended 事件');
return;
}
// 如果已经显示错误界面或重试次数已达上限,不要自动重播
if (this.hasVideoError || this.retryCount >= this.maxRetryCount) {
console.log('[VideoPlayer] 视频加载失败或重试次数已达上限,阻止自动重播');
this.isVideoPlaying = false;
this.videoEnded = true;
this.stopPlaybackWatchdog();
return;
}
this.isVideoPlaying = false;
this.videoEnded = true;
this.stopPlaybackWatchdog();
// 视频自带音轨:音轨结束即视频结束。此处强制暂停并阻断任何自动恢复逻辑。
if (this.audioInVideo) {
this.videoWaiting = false;
this.userPaused = true;
this.pausedByAudioEnd = true;
this.audioEnded = true;
this.stopPlaybackWatchdog();
this.stopVideoLoop();
try {
if (this.videoContext && this.videoContext.seek && this.videoDuration) {
this.videoContext.seek(Math.max(0, Number(this.videoDuration) - 0.05));
}
} catch (e) {}
if (this.videoContext && this.videoContext.pause) {
this.videoContext.pause();
}
return;
}
// 音频未结束:不要循环/重播视频(微信内核可能因此 stop 音频)。
// 改为停在末帧等待音频播放完。
if (this.hasAudio && this.audioContext && !this.audioEnded) {
this.videoEndedWaitingAudio = true;
this.videoWaiting = false;
console.log('[VideoPlayer] 音频未结束:视频停在末帧等待音频播完(不循环不重播)');
try {
if (this.videoContext && this.videoContext.seek && this.videoDuration) {
this.videoContext.seek(Math.max(0, Number(this.videoDuration) - 0.05));
}
} catch (e) {}
this.videoContext && this.videoContext.pause && this.videoContext.pause();
return;
}
// 兜底:小程序端偶发音频真实结束但未触发 onEnded导致视频一直循环。
// 在视频结束时,基于 currentTime/lastAudioTime + duration 再推断一次音频是否已到结尾。
if (this.hasAudio && this.audioContext && !this.audioEnded) {
const audioCur = Number(this.audioContext.currentTime) || 0;
const audioCurStable = Math.max(audioCur, Number(this.lastAudioTime) || 0);
const audioDur = Number(this.audioDuration) || Number(this.audioContext.duration) || 0;
const audioEndedForSure = audioDur > 0 && (
audioCurStable >= (audioDur - 0.6) ||
(audioCurStable / audioDur) >= 0.95
);
if (audioEndedForSure) {
console.log('[VideoPlayer] 检测到音频已到结尾(兜底推断)停止视频循环并暂停视频。audioCurStable=', audioCurStable, 'audioDur=', audioDur);
this.audioEnded = true;
this.stopVideoLoop();
if (this.videoContext && this.videoContext.pause) {
this.videoContext.pause();
}
return;
}
}
// 小程序端偶发:缓存视频文件截断/解码异常,导致视频播放到很短时间就 ended。
// 如果音频还在播放且视频明显“提前结束”清理缓存并回退到URL重新加载。
const vd = Number(this.videoDuration) || 0;
const vt = Number(this.lastVideoTime) || 0;
const endedTooEarly = (!vd && vt > 0 && vt < 3) || (vd > 0 && vt > 0 && vt < Math.max(1.5, vd - 0.8));
if (!this.audioEnded && endedTooEarly) {
console.warn('[VideoPlayer] 检测到视频疑似提前结束尝试清理缓存并重新加载。vt=', vt, 'vd=', vd, 'cacheKey=', this.cacheKey);
try {
if (this.cacheKey) {
uni.removeStorageSync(this.cacheKey);
}
} catch (e) {}
// 使用URL直连播放并后台重新下载缓存
this.localVideoPath = this.videoUrl;
this.isVideoReady = true;
this.isVideoDownloading = false;
this.downloadAndCacheVideo();
// 重新开始播放视频(不影响音频继续)
this.$nextTick(() => {
if (!this.videoContext) {
this.videoContext = uni.createVideoContext('videoPlayer', this);
}
setTimeout(() => {
this.videoContext && this.videoContext.play && this.videoContext.play();
}, 120);
});
return;
}
// 无音频或音频已结束:这里不再联动 stop 音频(避免误 stop
},
// 视频时间更新
onTimeUpdate(e) {
if (!e || !e.detail) return;
const currentTime = e.detail.currentTime;
this.lastVideoTime = currentTime;
this.lastVideoTimeUpdateAt = Date.now();
// 兜底timeupdate 能稳定证明视频已开始播放(部分端 play 事件不可靠)
if (!this.videoHasStarted && (Number(currentTime) || 0) > 0) {
this.videoHasStarted = true;
}
if (!this.userPaused && (Number(currentTime) || 0) > 0) {
this.isVideoPlaying = true;
}
// 视频正常播放中,清除所有等待状态
if (this.videoWaiting) {
console.log('[VideoPlayer] timeupdate 清除 waiting 状态currentTime=', currentTime);
this.videoWaiting = false;
this.waitingNeedsUserAction = false;
}
// 清除 waiting 定时器
if (this.waitingTimer) {
clearTimeout(this.waitingTimer);
this.waitingTimer = null;
}
// 确保播放状态正确
if (!this.isVideoPlaying && !this.userPaused) {
this.isVideoPlaying = true;
}
// 视频先播,达到延迟点后再启动音频(仅触发一次)
if (this.hasAudio && this.audioContext && !this.audioStarted && !this.audioEnded) {
if (currentTime >= this.audioStartDelaySec) {
if (this.audioReady) {
const startAt = Math.max(0, (currentTime || 0) - (this.audioStartDelaySec || 0));
console.log('[VideoPlayer] 视频到达延迟点开始播放音频seek=', startAt);
this.startAudioFromVideoTime(startAt);
return;
}
// 音频还没 ready继续等待 onCanplay
this.pendingAudioStart = true;
}
}
},
// 启动视频循环2-6秒
startVideoLoop() {
// 已经在循环中,不重复启动
if (this.videoLoopTimer) return;
console.log('[VideoPlayer] 视频循环已启动');
},
// 停止视频循环
stopVideoLoop() {
if (this.videoLoopTimer) {
clearInterval(this.videoLoopTimer);
this.videoLoopTimer = null;
console.log('[VideoPlayer] 视频循环已停止');
}
}
}
};
</script>
<style lang="scss" scoped>
.player-container {
width: 100%;
height: 100vh;
background: #000;
display: flex;
flex-direction: column;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading-content {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx 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: 32rpx;
font-weight: bold;
}
.loading-hint {
color: rgba(255, 255, 255, 0.7);
font-size: 24rpx;
margin-top: 10rpx;
}
.progress-bar {
width: 400rpx;
height: 8rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 4rpx;
overflow: hidden;
margin-top: 20rpx;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
border-radius: 4rpx;
transition: width 0.3s ease;
}
.header {
background: rgba(0, 0, 0, 0.8);
padding: 60rpx 40rpx 20rpx;
padding-top: calc(60rpx + constant(safe-area-inset-top));
padding-top: calc(60rpx + env(safe-area-inset-top));
display: flex;
justify-content: space-between;
align-items: center;
color: white;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.back-btn {
font-size: 32rpx;
padding: 16rpx;
cursor: pointer;
}
.title {
font-size: 32rpx;
font-weight: bold;
flex: 1;
text-align: center;
}
.placeholder {
width: 80rpx;
}
.video-player {
width: 100%;
height: 100vh;
background: #000;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.play-button {
width: 140rpx;
height: 140rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.45);
position: relative;
}
.play-button::after {
content: '';
position: absolute;
top: 50%;
left: 54%;
transform: translate(-50%, -50%);
width: 0;
height: 0;
border-top: 22rpx solid transparent;
border-bottom: 22rpx solid transparent;
border-left: 34rpx solid #fff;
}
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.error-content {
text-align: center;
padding: 60rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.error-icon {
font-size: 120rpx;
margin-bottom: 20rpx;
}
.error-title {
color: #fff;
font-size: 36rpx;
font-weight: bold;
}
.error-message {
color: rgba(255, 255, 255, 0.7);
font-size: 28rpx;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
}
.error-btn {
padding: 24rpx 48rpx;
border-radius: 12rpx;
font-size: 28rpx;
cursor: pointer;
transition: all 0.3s ease;
}
.retry-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.retry-btn:active {
transform: scale(0.95);
opacity: 0.8;
}
.back-btn-error {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: 2rpx solid rgba(255, 255, 255, 0.3);
}
.back-btn-error:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.15);
}
</style>