1429 lines
42 KiB
Vue
1429 lines
42 KiB
Vue
<template>
|
||
<view class="native-rtsp-player" :style="containerStyle">
|
||
<!-- 视频信息标题(可选) -->
|
||
<view v-if="showHeader" class="header">
|
||
<view class="back-button" v-if="showBackButton" @click="$emit('back')">
|
||
<u-icon name="arrow-left" color="#FFFFFF" size="32"></u-icon>
|
||
</view>
|
||
<view class="title">{{ videoTitle }}</view>
|
||
</view>
|
||
|
||
<!-- 视频播放区域 -->
|
||
<view class="video-container" :style="{ height: videoHeight }">
|
||
<!-- #ifdef APP-PLUS -->
|
||
<video
|
||
:id="videoId"
|
||
class="video-player"
|
||
:src="playUrl"
|
||
:autoplay="autoplay"
|
||
:controls="showNativeControls"
|
||
:muted="isMuted"
|
||
:show-center-play-btn="showCenterPlayBtn"
|
||
:show-fullscreen-btn="showFullscreenBtn"
|
||
@error="onVideoError"
|
||
@timeupdate="onTimeUpdate"
|
||
@loadedmetadata="onLoadedMetadata"
|
||
@ended="onVideoEnded"
|
||
@play="onPlay"
|
||
@pause="onPause"
|
||
></video>
|
||
<!-- #endif -->
|
||
|
||
<!-- #ifndef APP-PLUS -->
|
||
<view class="h5-notice">
|
||
<u-icon name="warning-circle" color="#FF6B6B" size="60"></u-icon>
|
||
<text class="notice-text">H5环境不支持原生RTSP播放</text>
|
||
<text class="notice-subtext">请在APP环境中使用</text>
|
||
</view>
|
||
<!-- #endif -->
|
||
</view>
|
||
|
||
<!-- 自定义控制按钮区域(可选) -->
|
||
<view v-if="showCustomControls" class="controls-container">
|
||
<view class="control-buttons">
|
||
<view class="control-btn" @click="togglePlayPause">
|
||
<u-icon :name="isPlaying ? 'pause' : 'play'" color="#498CEC" size="48"></u-icon>
|
||
<text class="btn-text">{{ isPlaying ? '暂停' : '播放' }}</text>
|
||
</view>
|
||
|
||
<view class="control-btn" @click="toggleMute">
|
||
<u-icon :name="isMuted ? 'volume-mute' : 'volume-medium'" color="#498CEC" size="48"></u-icon>
|
||
<text class="btn-text">{{ isMuted ? '静音' : '有声' }}</text>
|
||
</view>
|
||
|
||
<view class="control-btn" @click="toggleFullscreen">
|
||
<u-icon name="expand" color="#498CEC" size="48"></u-icon>
|
||
<text class="btn-text">全屏</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 播放进度 -->
|
||
<view class="progress-container" v-if="duration > 0">
|
||
<text class="time-text">{{ formatTime(currentTime) }}</text>
|
||
<slider
|
||
v-model="progressValue"
|
||
class="slider"
|
||
activeColor="#498CEC"
|
||
backgroundColor="#E0E0E0"
|
||
blockColor="#498CEC"
|
||
block-size="20"
|
||
@change="onProgressChange"
|
||
/>
|
||
<text class="time-text">{{ formatTime(duration) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 加载提示 -->
|
||
<u-loading-page v-if="isLoading && showLoading" :loading-text="loadingText"></u-loading-page>
|
||
|
||
<!-- 错误提示 -->
|
||
<u-modal v-if="showErrorModal" v-model="showError" title="播放错误" :content="errorMessage" :show-cancel-button="false" confirm-text="确定"></u-modal>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { isString } from '@/utils/tool';
|
||
import uView from '@/uview-ui';
|
||
|
||
export default {
|
||
name: 'NativeRtspPlayer',
|
||
components: {
|
||
...uView
|
||
},
|
||
props: {
|
||
// RTSP流地址(必填)
|
||
rtspUrl: {
|
||
type: String,
|
||
required: true,
|
||
default: ''
|
||
},
|
||
// 视频信息对象
|
||
videoInfo: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
// 视频标题
|
||
title: {
|
||
type: String,
|
||
default: ''
|
||
},
|
||
// 视频容器高度
|
||
height: {
|
||
type: [String, Number],
|
||
default: '500rpx'
|
||
},
|
||
// 是否自动播放
|
||
autoplay: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示原生控件
|
||
showNativeControls: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示自定义控件
|
||
showCustomControls: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示加载提示
|
||
showLoading: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示错误弹窗
|
||
showErrorModal: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示头部
|
||
showHeader: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示返回按钮
|
||
showBackButton: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示中心播放按钮
|
||
showCenterPlayBtn: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 是否显示全屏按钮
|
||
showFullscreenBtn: {
|
||
type: Boolean,
|
||
default: true
|
||
},
|
||
// 最大重试次数
|
||
maxRetryCount: {
|
||
type: Number,
|
||
default: 3
|
||
},
|
||
// 重试延迟时间(毫秒)
|
||
retryDelay: {
|
||
type: Number,
|
||
default: 2000
|
||
},
|
||
// 容器样式
|
||
containerStyle: {
|
||
type: Object,
|
||
default: () => ({})
|
||
}
|
||
},
|
||
data() {
|
||
return {
|
||
// 处理后的播放地址
|
||
playUrl: '',
|
||
videoHeight: this.height,
|
||
|
||
// 播放状态
|
||
isPlaying: this.autoplay,
|
||
isMuted: false,
|
||
currentTime: 0,
|
||
duration: 0,
|
||
progressValue: 0,
|
||
isFullscreen: false,
|
||
|
||
// 加载和错误状态
|
||
isLoading: this.autoplay,
|
||
loadingText: '正在加载视频...',
|
||
showError: false,
|
||
errorMessage: '',
|
||
|
||
// 重试相关
|
||
retryCount: 0,
|
||
|
||
// 平台信息
|
||
platform: '',
|
||
isIOS: false,
|
||
isAndroid: false,
|
||
|
||
// 定时器
|
||
retryTimer: null,
|
||
|
||
// 视频ID(用于创建唯一的视频上下文)
|
||
videoId: `rtsp-video-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||
|
||
// 音频上下文
|
||
audioContext: null,
|
||
useSeparateAudio: false, // 是否使用独立音频播放
|
||
|
||
// 音频轨道检测
|
||
hasAudioTrack: false, // RTSP流是否包含音频轨道
|
||
isAudioDetected: false // 是否已完成音频轨道检测
|
||
};
|
||
},
|
||
computed: {
|
||
// 计算视频标题
|
||
videoTitle() {
|
||
if (this.title) return this.title;
|
||
if (this.videoInfo && this.videoInfo.videoName) return this.videoInfo.videoName;
|
||
return 'RTSP视频播放';
|
||
}
|
||
},
|
||
|
||
watch: {
|
||
// 监听RTSP地址变化
|
||
rtspUrl: {
|
||
handler(newVal) {
|
||
if (newVal) {
|
||
this.initPlayer();
|
||
}
|
||
},
|
||
immediate: true
|
||
},
|
||
|
||
// 监听高度变化
|
||
height(newVal) {
|
||
this.videoHeight = newVal;
|
||
}
|
||
},
|
||
|
||
mounted() {
|
||
// 初始化平台信息
|
||
this.initPlatformInfo();
|
||
|
||
// 延迟初始化播放器,确保DOM已渲染
|
||
this.$nextTick(() => {
|
||
if (this.rtspUrl && this.autoplay) {
|
||
this.initPlayer();
|
||
}
|
||
});
|
||
},
|
||
|
||
beforeUnmount() {
|
||
// 清理资源
|
||
this.cleanupResources();
|
||
},
|
||
|
||
methods: {
|
||
/**
|
||
* 停用音频会话(暂停时使用)
|
||
*/
|
||
deactivateAudioSession() {
|
||
try {
|
||
if (!this.isIOS || typeof window === 'undefined' || typeof window.plus === 'undefined' || typeof window.plus.ios === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const AVAudioSession = window.plus.ios.import('AVAudioSession');
|
||
if (AVAudioSession && typeof AVAudioSession.sharedInstance === 'function') {
|
||
const session = AVAudioSession.sharedInstance();
|
||
if (session && typeof session.setActive === 'function') {
|
||
try {
|
||
session.setActive(false);
|
||
console.log('音频会话已停用');
|
||
} catch (e) {
|
||
console.warn('停用音频会话失败:', e);
|
||
}
|
||
}
|
||
|
||
// 释放资源
|
||
if (typeof window.plus.ios.deleteObject === 'function') {
|
||
window.plus.ios.deleteObject(session);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('停用音频会话时发生错误:', error);
|
||
}
|
||
},
|
||
/**
|
||
* 初始化平台信息
|
||
*/
|
||
initPlatformInfo() {
|
||
try {
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
this.platform = systemInfo.platform;
|
||
this.isIOS = systemInfo.platform === 'ios';
|
||
this.isAndroid = systemInfo.platform === 'android';
|
||
} catch (error) {
|
||
console.error('获取系统信息失败:', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 设置视频容器高度
|
||
*/
|
||
setVideoHeight() {
|
||
try {
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
const windowHeight = systemInfo.windowHeight;
|
||
// 视频高度设为屏幕高度的50%
|
||
this.videoHeight = `${windowHeight * 0.5}px`;
|
||
} catch (error) {
|
||
console.error('设置视频高度失败:', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 初始化播放器
|
||
*/
|
||
initPlayer() {
|
||
// #ifdef APP-PLUS
|
||
this.isLoading = true;
|
||
|
||
// 根据平台处理RTSP地址
|
||
this.processRtspUrl();
|
||
|
||
// 延迟执行以确保video组件已渲染
|
||
setTimeout(() => {
|
||
this.startPlayback();
|
||
}, 500);
|
||
// #endif
|
||
|
||
// #ifndef APP-PLUS
|
||
this.handleError('H5环境不支持原生RTSP播放');
|
||
// #endif
|
||
},
|
||
|
||
/**
|
||
* 处理RTSP地址
|
||
*/
|
||
processRtspUrl() {
|
||
// 对于iOS,某些原生播放器可能需要特殊处理
|
||
// 这里根据实际情况调整URL格式
|
||
if (this.isIOS) {
|
||
// iOS平台处理逻辑
|
||
// 某些情况下可能需要使用第三方原生插件来播放RTSP
|
||
// 这里假设直接使用video标签可以播放RTSP
|
||
this.playUrl = this.rtspUrl;
|
||
} else if (this.isAndroid) {
|
||
// Android平台处理逻辑
|
||
this.playUrl = this.rtspUrl;
|
||
} else {
|
||
// 其他平台
|
||
this.playUrl = this.rtspUrl;
|
||
}
|
||
|
||
// 如果音频上下文已初始化,更新音频源
|
||
if (this.useSeparateAudio && this.audioContext) {
|
||
this.setAudioSource(this.playUrl);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 开始播放
|
||
*/
|
||
startPlayback() {
|
||
// #ifdef APP-PLUS
|
||
try {
|
||
// 对于iOS,先确保音频会话已激活,再播放视频
|
||
if (this.isIOS) {
|
||
// 先尝试激活音频会话(用户交互时)
|
||
this.setupiOSAudio();
|
||
|
||
// 额外尝试:在iOS上,有时需要显式请求音频播放权限
|
||
try {
|
||
if (typeof window !== 'undefined' && typeof window.plus !== 'undefined' && typeof window.plus.device !== 'undefined') {
|
||
// 获取设备信息,确认iOS版本
|
||
const device = window.plus.device;
|
||
const osInfo = device.osInfo || {};
|
||
console.log('设备信息:', { platform: device.platform, version: osInfo.version });
|
||
}
|
||
} catch (e) {
|
||
console.warn('获取设备信息失败:', e);
|
||
}
|
||
}
|
||
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
|
||
// 播放视频
|
||
videoContext.play();
|
||
this.isPlaying = true;
|
||
this.$emit('play');
|
||
|
||
// 延迟尝试再次激活音频会话(iOS上有时需要在播放后再次激活)
|
||
if (this.isIOS) {
|
||
setTimeout(() => {
|
||
try {
|
||
this.ensureAudioActive();
|
||
} catch (e) {
|
||
console.warn('延迟激活音频失败:', e);
|
||
}
|
||
}, 500);
|
||
}
|
||
|
||
// 5秒后如果还在加载,显示重试选项
|
||
setTimeout(() => {
|
||
if (this.isLoading) {
|
||
this.loadingText = '加载中,请稍候...';
|
||
}
|
||
}, 5000);
|
||
|
||
} catch (error) {
|
||
console.error('开始播放失败:', error);
|
||
this.handlePlaybackError(error);
|
||
}
|
||
// #endif
|
||
},
|
||
|
||
/**
|
||
* 确保音频会话处于激活状态
|
||
* 用于在视频播放后再次尝试激活音频,提高iOS设备上RTSP流音频播放成功率
|
||
*/
|
||
ensureAudioActive() {
|
||
try {
|
||
if (!this.isIOS || typeof window === 'undefined' || typeof window.plus === 'undefined' || typeof window.plus.ios === 'undefined') {
|
||
return;
|
||
}
|
||
|
||
const AVAudioSession = window.plus.ios.import('AVAudioSession');
|
||
if (AVAudioSession && typeof AVAudioSession.sharedInstance === 'function') {
|
||
const session = AVAudioSession.sharedInstance();
|
||
if (session && typeof session.setActive === 'function') {
|
||
try {
|
||
// 先停用再重新激活,有时能解决音频问题
|
||
session.setActive(false);
|
||
session.setActive(true);
|
||
console.log('音频会话重新激活成功');
|
||
} catch (e) {
|
||
console.warn('重新激活音频会话失败:', e);
|
||
}
|
||
}
|
||
|
||
// 释放资源
|
||
if (typeof window.plus.ios.deleteObject === 'function') {
|
||
window.plus.ios.deleteObject(session);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('确保音频激活时发生错误:', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 初始化音频上下文
|
||
*/
|
||
initAudioContext() {
|
||
try {
|
||
// 安全检查
|
||
if (typeof uni === 'undefined' || typeof uni.createInnerAudioContext !== 'function') {
|
||
console.warn('当前环境不支持createInnerAudioContext');
|
||
return false;
|
||
}
|
||
|
||
// 创建独立音频上下文
|
||
this.audioContext = uni.createInnerAudioContext();
|
||
|
||
// 安全地设置可能的可写属性,避免修改只读属性
|
||
try {
|
||
// 只尝试设置可能可写的属性
|
||
if (typeof this.audioContext.setAutoplay === 'function') {
|
||
this.audioContext.setAutoplay(false);
|
||
} else if (Object.getOwnPropertyDescriptor(this.audioContext, 'autoplay')?.writable) {
|
||
this.audioContext.autoplay = false;
|
||
}
|
||
} catch (e) {
|
||
console.warn('设置autoplay属性失败,跳过:', e);
|
||
}
|
||
|
||
try {
|
||
if (typeof this.audioContext.setObeyMuteSwitch === 'function') {
|
||
this.audioContext.setObeyMuteSwitch(false);
|
||
} else if (Object.getOwnPropertyDescriptor(this.audioContext, 'obeyMuteSwitch')?.writable) {
|
||
this.audioContext.obeyMuteSwitch = false;
|
||
}
|
||
} catch (e) {
|
||
console.warn('设置obeyMuteSwitch属性失败,跳过:', e);
|
||
}
|
||
|
||
// 安全地添加事件监听器
|
||
try {
|
||
if (typeof this.audioContext.onPlay === 'function') {
|
||
this.audioContext.onPlay(() => {
|
||
console.log('音频开始播放');
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('添加onPlay监听器失败,跳过:', e);
|
||
}
|
||
|
||
try {
|
||
if (typeof this.audioContext.onPause === 'function') {
|
||
this.audioContext.onPause(() => {
|
||
console.log('音频暂停');
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('添加onPause监听器失败,跳过:', e);
|
||
}
|
||
|
||
try {
|
||
if (typeof this.audioContext.onError === 'function') {
|
||
this.audioContext.onError((res) => {
|
||
console.warn('音频播放错误:', res);
|
||
// 音频错误不应影响视频播放
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn('添加onError监听器失败,跳过:', e);
|
||
}
|
||
|
||
console.log('音频上下文初始化成功');
|
||
return true;
|
||
} catch (error) {
|
||
console.warn('初始化音频上下文失败:', error);
|
||
this.audioContext = null;
|
||
return false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 设置音频源
|
||
*/
|
||
setAudioSource(url) {
|
||
try {
|
||
if (this.audioContext && url) {
|
||
try {
|
||
// 安全地设置音频源
|
||
if (typeof this.audioContext.src === 'string' || Object.getOwnPropertyDescriptor(this.audioContext, 'src')?.writable) {
|
||
this.audioContext.src = url;
|
||
console.log('音频源设置成功:', url);
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
console.warn('设置音频源失败:', e);
|
||
}
|
||
}
|
||
return false;
|
||
} catch (error) {
|
||
console.warn('设置音频源时发生错误:', error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* iOS音频设置
|
||
*/
|
||
setupiOSAudio() {
|
||
try {
|
||
// 尝试初始化独立音频上下文
|
||
this.useSeparateAudio = this.initAudioContext();
|
||
|
||
if (this.useSeparateAudio) {
|
||
console.log('将使用createInnerAudioContext处理音频');
|
||
// 尝试设置音频源
|
||
if (this.playUrl) {
|
||
this.setAudioSource(this.playUrl);
|
||
}
|
||
} else {
|
||
// 安全检查:确保只在APP-PLUS环境下执行,避免在浏览器中报错
|
||
const isPlusEnv = typeof window !== 'undefined' && typeof window.plus !== 'undefined';
|
||
|
||
if (!isPlusEnv) {
|
||
console.log('非APP环境,跳过音频设置');
|
||
return;
|
||
}
|
||
|
||
// 尝试不同的音频设置方法,优雅降级
|
||
try {
|
||
// 方案1: 尝试使用plus.audio API(如果可用)
|
||
if (typeof plus.audio !== 'undefined' && typeof plus.audio.createAudioContext === 'function') {
|
||
try {
|
||
const audioContext = plus.audio.createAudioContext();
|
||
if (audioContext && typeof audioContext.play === 'function') {
|
||
console.log('尝试使用plus.audio API');
|
||
}
|
||
} catch (e) {
|
||
console.warn('创建音频上下文失败:', e);
|
||
}
|
||
}
|
||
} catch (e1) {
|
||
console.warn('plus.audio API操作失败:', e1);
|
||
}
|
||
|
||
try {
|
||
// 方案2: 尝试使用AVAudioSession(如果可用)
|
||
if (typeof plus.ios !== 'undefined' && typeof plus.ios.import === 'function') {
|
||
try {
|
||
const AVAudioSession = plus.ios.import('AVAudioSession');
|
||
if (AVAudioSession && typeof AVAudioSession.sharedInstance === 'function') {
|
||
const session = AVAudioSession.sharedInstance();
|
||
|
||
// 先尝试设置音频会话类别 - 这是iOS上音频播放的关键
|
||
if (session && typeof session.setCategory === 'function') {
|
||
try {
|
||
// 对于视频播放,使用playback类别
|
||
// 尝试不同的类别设置方式
|
||
try {
|
||
// iOS 10+ 推荐方式
|
||
const CategoryPlayback = plus.ios.newString('AVAudioSessionCategoryPlayback');
|
||
const ModeDefault = plus.ios.newString('AVAudioSessionModeDefault');
|
||
const options = 1; // AVAudioSessionCategoryOptionMixWithOthers
|
||
session.setCategory_withMode_options_(CategoryPlayback, ModeDefault, options);
|
||
console.log('AVAudioSession类别设置成功(10+)');
|
||
plus.ios.deleteObject(CategoryPlayback);
|
||
plus.ios.deleteObject(ModeDefault);
|
||
} catch (catError) {
|
||
console.warn('设置音频会话类别(10+)失败,尝试旧方式:', catError);
|
||
// 尝试旧版API
|
||
try {
|
||
const CategoryPlayback = plus.ios.newString('AVAudioSessionCategoryPlayback');
|
||
session.setCategory_(CategoryPlayback);
|
||
console.log('AVAudioSession类别设置成功(旧版)');
|
||
plus.ios.deleteObject(CategoryPlayback);
|
||
} catch (oldError) {
|
||
console.warn('设置音频会话类别(旧版)失败:', oldError);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('设置音频会话类别失败:', e);
|
||
}
|
||
}
|
||
|
||
// 然后激活音频会话
|
||
if (session && typeof session.setActive === 'function') {
|
||
try {
|
||
// 尝试以不同的方式激活
|
||
try {
|
||
// iOS 6+ 方式
|
||
session.setActive_withOptions_(true, 1); // 1 = AVAudioSessionSetActiveOptionNotifyOthersOnDeactivation
|
||
console.log('AVAudioSession激活成功(带选项)');
|
||
} catch (activeError) {
|
||
console.warn('激活音频会话(带选项)失败,尝试简单方式:', activeError);
|
||
// 尝试简单激活
|
||
session.setActive(true);
|
||
console.log('AVAudioSession激活成功(简单方式)');
|
||
}
|
||
} catch (e) {
|
||
console.warn('激活音频会话失败:', e);
|
||
}
|
||
}
|
||
|
||
// 检查音频会话状态
|
||
if (session && typeof session.category === 'function') {
|
||
try {
|
||
const category = session.category();
|
||
console.log('当前音频会话类别:', category);
|
||
} catch (e) {
|
||
console.warn('获取音频会话类别失败:', e);
|
||
}
|
||
}
|
||
|
||
// 释放资源
|
||
if (typeof plus.ios.deleteObject === 'function') {
|
||
plus.ios.deleteObject(session);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('AVAudioSession操作失败:', e);
|
||
}
|
||
}
|
||
} catch (e2) {
|
||
console.warn('AVAudioSession相关操作失败:', e2);
|
||
}
|
||
}
|
||
|
||
console.log('音频设置完成');
|
||
} catch (error) {
|
||
console.warn('音频设置失败,将继续播放视频:', error);
|
||
// 即使音频设置失败,也不阻止视频播放
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 切换播放/暂停
|
||
*/
|
||
togglePlayPause() {
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
if (this.isPlaying) {
|
||
// 暂停视频
|
||
videoContext.pause();
|
||
|
||
// 暂停独立音频(如果启用)
|
||
if (this.useSeparateAudio && this.audioContext && typeof this.audioContext.pause === 'function') {
|
||
try {
|
||
this.audioContext.pause();
|
||
console.log('音频已暂停');
|
||
} catch (e) {
|
||
console.warn('暂停独立音频失败:', e);
|
||
}
|
||
}
|
||
|
||
// iOS平台暂停时的处理
|
||
if (this.isIOS) {
|
||
try {
|
||
// 可以选择在这里暂停后暂时关闭音频会话,节省资源
|
||
this.deactivateAudioSession();
|
||
} catch (e) {
|
||
console.warn('停用音频会话失败:', e);
|
||
}
|
||
}
|
||
|
||
this.$emit('pause');
|
||
} else {
|
||
// iOS平台播放前再次确保音频会话激活
|
||
if (this.isIOS) {
|
||
try {
|
||
this.setupiOSAudio();
|
||
console.log('播放前重新设置音频会话');
|
||
} catch (e) {
|
||
console.warn('播放前设置音频失败:', e);
|
||
}
|
||
}
|
||
|
||
// 播放视频
|
||
videoContext.play();
|
||
|
||
// 播放独立音频(如果启用)
|
||
if (this.useSeparateAudio && this.audioContext && typeof this.audioContext.play === 'function') {
|
||
try {
|
||
// 确保音频源已设置
|
||
if (!this.audioContext.src || this.audioContext.src !== this.playUrl) {
|
||
this.setAudioSource(this.playUrl);
|
||
}
|
||
// 播放音频
|
||
this.audioContext.play();
|
||
console.log('音频开始播放');
|
||
} catch (e) {
|
||
console.warn('播放独立音频失败:', e);
|
||
}
|
||
}
|
||
|
||
this.$emit('play');
|
||
}
|
||
this.isPlaying = !this.isPlaying;
|
||
} catch (error) {
|
||
console.error('切换播放状态失败:', error);
|
||
this.handleError('控制播放失败');
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 切换静音状态
|
||
*/
|
||
toggleMute() {
|
||
try {
|
||
const newMuteState = !this.isMuted;
|
||
|
||
// 优先使用独立音频上下文(如果启用)
|
||
if (this.useSeparateAudio && this.audioContext) {
|
||
try {
|
||
this.audioContext.volume = newMuteState ? 0 : 1;
|
||
this.isMuted = newMuteState;
|
||
this.$emit('volume-change', newMuteState ? 0 : 1);
|
||
console.log('使用独立音频上下文切换静音状态');
|
||
return;
|
||
} catch (e) {
|
||
console.warn('独立音频上下文操作失败:', e);
|
||
}
|
||
}
|
||
|
||
// 回退到视频上下文控制
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
|
||
// 方法1: 尝试使用setVolume(如果可用)
|
||
if (videoContext && typeof videoContext.setVolume === 'function') {
|
||
try {
|
||
videoContext.setVolume(newMuteState ? 0.0 : 1.0);
|
||
this.isMuted = newMuteState;
|
||
this.$emit('volume-change', newMuteState ? 0 : 1);
|
||
return;
|
||
} catch (e) {
|
||
console.warn('setVolume方法调用失败,尝试替代方案:', e);
|
||
}
|
||
}
|
||
|
||
// 方法2: 尝试使用HTML5标准的muted属性(通过DOM操作)
|
||
try {
|
||
const videoElement = document.getElementById(this.videoId);
|
||
if (videoElement) {
|
||
videoElement.muted = newMuteState;
|
||
this.isMuted = newMuteState;
|
||
this.$emit('volume-change', newMuteState ? 0 : 1);
|
||
console.log('使用DOM muted属性切换静音状态');
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.warn('DOM操作失败,静默状态更新但可能未应用:', e);
|
||
}
|
||
|
||
// 方法3: 至少更新内部状态,确保UI一致性
|
||
this.isMuted = newMuteState;
|
||
this.$emit('volume-change', newMuteState ? 0 : 1);
|
||
console.log('仅更新内部静音状态');
|
||
|
||
} catch (error) {
|
||
console.warn('切换静音状态时发生错误:', error);
|
||
// 即使出错也更新内部状态,保持UI响应性
|
||
this.isMuted = !this.isMuted;
|
||
this.$emit('volume-change', this.isMuted ? 0 : 1);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 切换全屏
|
||
*/
|
||
toggleFullscreen() {
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
if (this.isFullscreen) {
|
||
videoContext.exitFullScreen();
|
||
this.$emit('fullscreen-change', false);
|
||
} else {
|
||
videoContext.requestFullScreen({ direction: 90 });
|
||
this.$emit('fullscreen-change', true);
|
||
}
|
||
this.isFullscreen = !this.isFullscreen;
|
||
} catch (error) {
|
||
console.error('切换全屏失败:', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 处理进度条变化
|
||
*/
|
||
onProgressChange(e) {
|
||
try {
|
||
const value = e.detail.value;
|
||
const seekTime = (value / 100) * this.duration;
|
||
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
videoContext.seek(seekTime);
|
||
|
||
this.currentTime = seekTime;
|
||
this.progressValue = value;
|
||
this.$emit('seek', seekTime);
|
||
} catch (error) {
|
||
console.error('进度调整失败:', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 格式化时间
|
||
*/
|
||
formatTime(seconds) {
|
||
if (!seconds || isNaN(seconds)) return '00:00';
|
||
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
|
||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||
},
|
||
|
||
/**
|
||
* 视频错误回调
|
||
*/
|
||
onVideoError(e) {
|
||
console.error('视频播放错误:', e);
|
||
this.handlePlaybackError(e);
|
||
},
|
||
|
||
/**
|
||
* 视频时间更新回调
|
||
*/
|
||
onTimeUpdate(e) {
|
||
this.currentTime = e.detail.currentTime || 0;
|
||
if (this.duration > 0) {
|
||
this.progressValue = (this.currentTime / this.duration) * 100;
|
||
}
|
||
|
||
// 视频开始播放后关闭加载提示
|
||
if (this.isLoading) {
|
||
this.isLoading = false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 视频元数据加载完成回调
|
||
*/
|
||
onLoadedMetadata(e) {
|
||
this.duration = e.detail.duration || 0;
|
||
this.isLoading = false;
|
||
|
||
// 尝试检测音频轨道
|
||
this.detectAudioTrack();
|
||
},
|
||
|
||
/**
|
||
* 检测RTSP流是否包含音频轨道
|
||
* 此方法使用多种策略尝试确定当前视频流是否包含音频
|
||
*/
|
||
detectAudioTrack() {
|
||
try {
|
||
// 策略1: 尝试通过DOM元素获取音频相关属性
|
||
try {
|
||
const videoElement = document.getElementById(this.videoId);
|
||
if (videoElement) {
|
||
// 检查是否有音频轨道
|
||
if (videoElement.audioTracks && videoElement.audioTracks.length > 0) {
|
||
this.hasAudioTrack = true;
|
||
this.isAudioDetected = true;
|
||
console.log('通过audioTracks检测到音频轨道');
|
||
this.$emit('audio-detected', true);
|
||
return true;
|
||
}
|
||
|
||
// 检查是否有音视频轨道(一些浏览器可能使用不同的API)
|
||
if (videoElement.textTracks && videoElement.textTracks.length > 0) {
|
||
// 这不是确定的音频检测方法,但可以作为参考
|
||
console.log('检测到媒体轨道,但不确定是否为音频');
|
||
}
|
||
}
|
||
} catch (domError) {
|
||
console.warn('DOM音频检测失败:', domError);
|
||
}
|
||
|
||
// 策略2: 尝试通过独立音频上下文检测
|
||
if (this.useSeparateAudio && this.audioContext && this.playUrl) {
|
||
try {
|
||
// 临时创建一个音频上下文进行测试
|
||
const testAudio = uni.createInnerAudioContext();
|
||
|
||
// 监听加载成功事件
|
||
const onLoadSuccess = () => {
|
||
// 音频能够加载通常意味着有音频轨道
|
||
this.hasAudioTrack = true;
|
||
this.isAudioDetected = true;
|
||
console.log('通过音频上下文加载检测到音频');
|
||
this.$emit('audio-detected', true);
|
||
|
||
// 清理测试音频上下文
|
||
try {
|
||
testAudio.offLoad();
|
||
testAudio.offError();
|
||
testAudio.stop();
|
||
if (typeof testAudio.destroy === 'function') {
|
||
testAudio.destroy();
|
||
}
|
||
} catch (cleanupError) {
|
||
console.warn('清理测试音频上下文失败:', cleanupError);
|
||
}
|
||
};
|
||
|
||
// 监听错误事件
|
||
const onLoadError = () => {
|
||
// 加载失败可能意味着没有音频轨道或格式不支持
|
||
this.hasAudioTrack = false;
|
||
this.isAudioDetected = true;
|
||
console.log('音频上下文加载失败,可能没有音频轨道');
|
||
this.$emit('audio-detected', false);
|
||
|
||
// 清理测试音频上下文
|
||
try {
|
||
testAudio.offLoad();
|
||
testAudio.offError();
|
||
testAudio.stop();
|
||
if (typeof testAudio.destroy === 'function') {
|
||
testAudio.destroy();
|
||
}
|
||
} catch (cleanupError) {
|
||
console.warn('清理测试音频上下文失败:', cleanupError);
|
||
}
|
||
};
|
||
|
||
testAudio.onLoad(onLoadSuccess);
|
||
testAudio.onError(onLoadError);
|
||
|
||
// 设置源并尝试加载
|
||
testAudio.src = this.playUrl;
|
||
|
||
// 5秒后超时处理
|
||
setTimeout(() => {
|
||
if (!this.isAudioDetected) {
|
||
console.warn('音频检测超时');
|
||
this.isAudioDetected = true;
|
||
this.$emit('audio-detected', false);
|
||
|
||
try {
|
||
testAudio.offLoad();
|
||
testAudio.offError();
|
||
testAudio.stop();
|
||
if (typeof testAudio.destroy === 'function') {
|
||
testAudio.destroy();
|
||
}
|
||
} catch (cleanupError) {
|
||
console.warn('清理超时测试音频上下文失败:', cleanupError);
|
||
}
|
||
}
|
||
}, 5000);
|
||
} catch (audioError) {
|
||
console.warn('独立音频上下文检测失败:', audioError);
|
||
}
|
||
}
|
||
|
||
// 策略3: 尝试通过视频上下文检测
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
// 在某些环境中,视频上下文可能有获取音频状态的方法
|
||
if (typeof videoContext.getAudioTracks === 'function') {
|
||
const tracks = videoContext.getAudioTracks();
|
||
if (tracks && tracks.length > 0) {
|
||
this.hasAudioTrack = true;
|
||
this.isAudioDetected = true;
|
||
console.log('通过视频上下文getAudioTracks检测到音频轨道');
|
||
this.$emit('audio-detected', true);
|
||
return true;
|
||
}
|
||
}
|
||
} catch (videoContextError) {
|
||
console.warn('视频上下文音频检测失败:', videoContextError);
|
||
}
|
||
|
||
// 如果所有检测策略都失败或未完成,设置为未检测到音频
|
||
if (!this.isAudioDetected) {
|
||
// 注意:这只是默认值,实际可能有音频但我们无法检测到
|
||
console.log('无法确定是否包含音频轨道,默认为false');
|
||
}
|
||
|
||
return this.hasAudioTrack;
|
||
} catch (error) {
|
||
console.warn('检测音频轨道时发生错误:', error);
|
||
return false;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 获取当前RTSP流的音频支持状态
|
||
* 此方法可以被父组件调用,用于查询音频支持情况
|
||
* @returns {Object} 包含音频状态信息的对象
|
||
*/
|
||
getAudioStatus() {
|
||
return {
|
||
hasAudio: this.hasAudioTrack,
|
||
detected: this.isAudioDetected,
|
||
usingSeparateAudio: this.useSeparateAudio
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 重新检测音频轨道
|
||
* 强制重新执行音频检测逻辑
|
||
* @returns {Promise<boolean>} 表示检测结果的Promise
|
||
*/
|
||
reDetectAudioTrack() {
|
||
return new Promise((resolve) => {
|
||
try {
|
||
// 重置检测状态
|
||
this.hasAudioTrack = false;
|
||
this.isAudioDetected = false;
|
||
|
||
// 移除之前的监听器,避免多次触发
|
||
this.$off('audio-detected');
|
||
|
||
// 添加新的监听器来返回结果
|
||
this.$once('audio-detected', (hasAudio) => {
|
||
resolve(hasAudio);
|
||
});
|
||
|
||
// 执行检测
|
||
this.detectAudioTrack();
|
||
|
||
// 设置30秒超时
|
||
setTimeout(() => {
|
||
if (!this.isAudioDetected) {
|
||
console.warn('重新检测音频超时,返回false');
|
||
this.isAudioDetected = true;
|
||
resolve(false);
|
||
}
|
||
}, 30000);
|
||
} catch (error) {
|
||
console.error('重新检测音频失败:', error);
|
||
resolve(false);
|
||
}
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 视频播放结束回调
|
||
*/
|
||
onVideoEnded() {
|
||
this.isPlaying = false;
|
||
this.$emit('ended');
|
||
// 直播流可能不会触发此事件
|
||
},
|
||
|
||
/**
|
||
* 视频开始播放回调
|
||
*/
|
||
onPlay() {
|
||
this.isPlaying = true;
|
||
this.$emit('play');
|
||
},
|
||
|
||
/**
|
||
* 视频暂停回调
|
||
*/
|
||
onPause() {
|
||
this.isPlaying = false;
|
||
this.$emit('pause');
|
||
},
|
||
|
||
/**
|
||
* 处理播放错误
|
||
*/
|
||
handlePlaybackError(error) {
|
||
this.isLoading = false;
|
||
|
||
// 重试逻辑
|
||
if (this.retryCount < this.maxRetryCount) {
|
||
this.retryCount++;
|
||
this.loadingText = `连接失败,${this.retryCount}/${this.maxRetryCount}秒后重试...`;
|
||
this.isLoading = true;
|
||
|
||
this.retryTimer = setTimeout(() => {
|
||
this.loadingText = `正在重试 (${this.retryCount}/${this.maxRetryCount})...`;
|
||
this.startPlayback();
|
||
}, this.retryDelay);
|
||
} else {
|
||
// 达到最大重试次数,显示错误
|
||
let errorMsg = '播放失败,请检查网络或视频地址';
|
||
if (isString(error)) {
|
||
errorMsg = error;
|
||
} else if (error && error.message) {
|
||
errorMsg = error.message;
|
||
}
|
||
this.handleError(errorMsg);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 显示错误信息
|
||
*/
|
||
handleError(message) {
|
||
this.isLoading = false;
|
||
this.errorMessage = message;
|
||
this.showError = true;
|
||
this.$emit('error', new Error(message));
|
||
},
|
||
|
||
/**
|
||
* 组件方法:开始播放
|
||
*/
|
||
play() {
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
videoContext.play();
|
||
|
||
// 同步播放音频
|
||
if (this.useSeparateAudio && this.audioContext && typeof this.audioContext.play === 'function') {
|
||
try {
|
||
// 确保音频源已设置
|
||
if (!this.audioContext.src || this.audioContext.src !== this.playUrl) {
|
||
this.setAudioSource(this.playUrl);
|
||
}
|
||
this.audioContext.play();
|
||
} catch (e) {
|
||
console.warn('播放独立音频失败:', e);
|
||
}
|
||
}
|
||
|
||
this.isPlaying = true;
|
||
this.$emit('play');
|
||
} catch (error) {
|
||
console.error('播放失败:', error);
|
||
this.$emit('error', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 组件方法:暂停播放
|
||
*/
|
||
pause() {
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
videoContext.pause();
|
||
|
||
// 同步暂停音频
|
||
if (this.useSeparateAudio && this.audioContext && typeof this.audioContext.pause === 'function') {
|
||
try {
|
||
this.audioContext.pause();
|
||
} catch (e) {
|
||
console.warn('暂停独立音频失败:', e);
|
||
}
|
||
}
|
||
|
||
this.isPlaying = false;
|
||
this.$emit('pause');
|
||
} catch (error) {
|
||
console.error('暂停失败:', error);
|
||
this.$emit('error', error);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 组件方法:重新加载视频
|
||
*/
|
||
reload() {
|
||
this.reloadVideo();
|
||
},
|
||
|
||
/**
|
||
* 清理资源
|
||
*/
|
||
cleanupResources() {
|
||
// 清除定时器
|
||
if (this.retryTimer) {
|
||
clearTimeout(this.retryTimer);
|
||
}
|
||
|
||
// 清理独立音频上下文(如果有)
|
||
if (this.audioContext) {
|
||
try {
|
||
if (typeof this.audioContext.stop === 'function') {
|
||
this.audioContext.stop();
|
||
}
|
||
if (typeof this.audioContext.destroy === 'function') {
|
||
this.audioContext.destroy();
|
||
}
|
||
this.audioContext = null;
|
||
console.log('音频上下文已清理');
|
||
} catch (error) {
|
||
console.error('清理音频上下文失败:', error);
|
||
}
|
||
}
|
||
|
||
// 停止播放
|
||
try {
|
||
const videoContext = uni.createVideoContext(this.videoId, this);
|
||
videoContext.pause();
|
||
} catch (error) {
|
||
console.error('停止播放失败:', error);
|
||
}
|
||
|
||
// iOS平台清理音频会话
|
||
if (this.isIOS) {
|
||
try {
|
||
const AVAudioSession = plus.ios.import('AVAudioSession');
|
||
const session = AVAudioSession.sharedInstance();
|
||
session.setActive(false);
|
||
plus.ios.deleteObject(session);
|
||
} catch (error) {
|
||
console.error('iOS音频会话清理失败:', error);
|
||
}
|
||
}
|
||
|
||
// 触发销毁事件
|
||
this.$emit('destroy');
|
||
|
||
// 重置音频检测状态
|
||
this.hasAudioTrack = false;
|
||
this.isAudioDetected = false;
|
||
},
|
||
|
||
/**
|
||
* 重新加载视频
|
||
*/
|
||
reloadVideo() {
|
||
this.retryCount = 0;
|
||
this.isLoading = true;
|
||
this.loadingText = '正在重新加载...';
|
||
this.showError = false;
|
||
this.errorMessage = '';
|
||
|
||
// 触发重新加载事件
|
||
this.$emit('reload');
|
||
|
||
// 重置音频检测状态
|
||
this.hasAudioTrack = false;
|
||
this.isAudioDetected = false;
|
||
|
||
this.$nextTick(() => {
|
||
this.initPlayer();
|
||
});
|
||
},
|
||
}
|
||
};
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.native-rtsp-player {
|
||
width: 100%;
|
||
min-height: 300rpx;
|
||
background-color: #000000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
|
||
.header {
|
||
height: 88rpx;
|
||
background-color: #1E1E1E;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 30rpx;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.back-button {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.title {
|
||
flex: 1;
|
||
color: #FFFFFF;
|
||
font-size: 36rpx;
|
||
font-weight: 500;
|
||
text-align: center;
|
||
padding-right: 80rpx; // 为返回按钮留出空间
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.video-container {
|
||
flex: 1;
|
||
position: relative;
|
||
background-color: #000000;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.video-player {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.h5-notice {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #FFFFFF;
|
||
padding: 60rpx;
|
||
}
|
||
|
||
.notice-text {
|
||
margin-top: 30rpx;
|
||
font-size: 36rpx;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.notice-subtext {
|
||
margin-top: 20rpx;
|
||
font-size: 28rpx;
|
||
color: #CCCCCC;
|
||
}
|
||
|
||
.controls-container {
|
||
padding: 30rpx;
|
||
background-color: #1E1E1E;
|
||
border-top: 1rpx solid #333333;
|
||
}
|
||
|
||
.control-buttons {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-bottom: 30rpx;
|
||
}
|
||
|
||
.control-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 20rpx;
|
||
}
|
||
|
||
.btn-text {
|
||
margin-top: 10rpx;
|
||
font-size: 24rpx;
|
||
color: #498CEC;
|
||
}
|
||
|
||
.progress-container {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-top: 20rpx;
|
||
}
|
||
|
||
.time-text {
|
||
color: #CCCCCC;
|
||
font-size: 24rpx;
|
||
width: 80rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.slider {
|
||
flex: 1;
|
||
margin: 0 20rpx;
|
||
}
|
||
|
||
/* 适配不同平台 */
|
||
/* #ifdef MP-WEIXIN */
|
||
.video-player {
|
||
object-fit: cover;
|
||
}
|
||
/* #endif */
|
||
|
||
/* #ifdef APP-PLUS */
|
||
.video-container {
|
||
background-color: #000000;
|
||
}
|
||
|
||
.video-player {
|
||
background-color: #000000;
|
||
}
|
||
/* #endif */
|
||
</style> |