ai-clone/frontend-ai/pages/video-player/video-player.vue
2026-03-05 14:29:21 +08:00

1704 lines
54 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<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>