1727 lines
55 KiB
Vue
1727 lines
55 KiB
Vue
<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>
|
||
|
||
<!-- AI生成提示标签 -->
|
||
<view class="ai-tag">
|
||
<text class="ai-tag-text">AI生成</text>
|
||
</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;
|
||
// 关键修复:如果有 timeupdate(lastVideoTime > 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() 都可能触发内核把音频系统 stop(504/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);
|
||
}
|
||
|
||
/* AI生成提示标签 */
|
||
.ai-tag {
|
||
position: absolute;
|
||
top: 140rpx;
|
||
right: 24rpx;
|
||
z-index: 100;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
padding: 8rpx 20rpx;
|
||
border-radius: 30rpx;
|
||
backdrop-filter: blur(10rpx);
|
||
}
|
||
|
||
.ai-tag-text {
|
||
font-size: 22rpx;
|
||
color: #fff;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|