<template>
<view class="container">
<!-- 视频播放区 -->
<view class="video-container">
<video
ref="videoPlayer"
:muted="liveData.muted"
:autoplay="liveData.autoplay"
playsinline
webkit-playsinline
x5-playsinline
object-fit="contain"
:poster="liveData.poster"
class="video"
v-show="!hasError"
@error="handleVideoError"
@play="onPlay"
@pause="onPause"
@waiting="onBuffering"
@touchstart="handleVideoTouch"
></video>
<!-- 状态提示 -->
<view v-if="isLoadingComponent" class="loading-mask">
<view class="spinner"></view>
<text class="loading-text">视频组件加载中...</text>
</view>
<view v-if="isLoadingPlayer && !hasError" class="loading-mask">
<view class="spinner"></view>
<text class="loading-text">直播加载中...{{ loadProgress }}%</text>
</view>
<view v-if="hasError" class="error-mask">
<text class="error-title">{{ errorTitle }}</text>
<text class="error-desc">{{ errorDesc }}</text>
<button class="retry-btn" @click="reloadPlayer">重试</button>
<button class="switch-btn" @click="switchSource" v-if="!isSingleSource">切换源</button>
</view>
<!-- 左下角透明聊天功能 -->
<view class="chat-container" :class="{ 'chat-expanded': isChatExpanded }">
<view class="chat-header" @click="toggleChatExpand">
<text class="chat-title">直播聊天</text>
<text class="chat-toggle-icon">{{ isChatExpanded ? '−' : '+' }}</text>
</view>
<view class="chat-messages" v-if="isChatExpanded">
<view class="chat-message" v-for="(msg, idx) in chatMessages" :key="idx">
<image
class="chat-avatar"
:src="msg.avatar || msg.userAvatar"
mode="widthFix"
></image>
<view class="chat-content-wrap">
<text class="chat-username">{{ msg.username||msg.userNickname }}:</text>
<text class="chat-content" :class="{ 'system-msg': msg.type !== 'chat' }">
{{ msg.content }}
</text>
</view>
</view>
</view>
<view class="chat-input-area">
<input
class="chat-input"
v-model="chatInput"
placeholder="输入消息..."
placeholder-style="color: rgba(255,255,255,0.5)"
@confirm="sendChatMessage"
:disabled="!isChatExpanded"
/>
<button
class="chat-send-btn"
@click="sendChatMessage"
:disabled="!isChatExpanded || !chatInput.trim()"
>
<!-- || wsStatus !== 'open' 需要在disabled里面添加这个实时通信 -->
发送
</button>
</view>
</view>
</view>
</view>
</template>
<script>
import Hls from 'hls.js';
import { livecomment,liveroomlike } from '@/api/graphic.js';
export default {
data() {
return {
queryliat:{//直播间评论
roomId:80,
userId:null,
content:null,
},
statusBarHeight: 0,
isUsingCustomSource: true,
isSingleSource: false,
sources: {
test: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
custom: 'http://139.159.156.31:8082/live/test_stream.m3u8',
appBackup: 'https://backup-cdn.com/live/app_stream.m3u8'
},
liveData: {
muted: false,
autoplay: true,
poster: 'https://picsum.photos/800/450'
},
isLoadingComponent: true,
isLoadingPlayer: false,
isBuffering: false,
hasError: false,
errorTitle: '',
errorDesc: '',
loadProgress: 0,
initRetryCount: 0,
maxInitRetry: 3,
hls: null,
isNativeHLS: false,
isPlaying: false,
// 聊天功能相关
isChatExpanded: false,
chatInput: '',
chatMessages: [
{ username: '系统', content: '欢迎进入直播聊天室~', type: 'system', avatar: '' }
],
// 实时通信相关
ws: null,
wsUrl: 'ws://192.168.1.4:8081/ws/live/comment', // 替换为实际WebSocket服务地址
wsStatus: 'closed', // closed/connecting/open/error
reconnectCount: 0,
maxReconnect: 5,
roomId: '',
userInfo: {
username: uni.getStorageSync('username') || '观众' + Math.floor(Math.random() * 1000),
avatar: uni.getStorageSync('avatar') || 'https://picsum.photos/100/100?random=' + Math.floor(Math.random() * 100)
}
};
},
computed: {
currentSource() {
return this.isUsingCustomSource ? this.sources.custom : this.sources.test;
}
},
onLoad(options) {
console.log('接收的直播参数:', options);
// 初始化直播间ID(用于隔离聊天房间)
this.roomId = options.roomId || options.id || 'defaultLiveRoom';
if (options.id) this.sources.custom = options.id;
const systemInfo = uni.getSystemInfoSync();
this.statusBarHeight = systemInfo.statusBarHeight || 0;
this.checkCustomSourceAvailability();
setTimeout(() => {
this.$nextTick(() => {
this.initVideoComponent();
this.initWebSocket(); // 初始化实时聊天
});
}, 500);
},
onUnload() {
this.destroyPlayer();
// 关闭WebSocket连接
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.sendWsMessage({ type: 'leave', content: '离开了直播间' });
setTimeout(() => {
this.ws.close(1000, '用户离开');
}, 500);
}
},
methods: {
checkCustomSourceAvailability() {
// 移除XMLHttpRequest相关代码,改用uni.request
uni.request({
url: this.sources.custom,
method: 'HEAD', // 发送HEAD请求检测源是否可用
timeout: 3000, // 超时时间3秒
success: (res) => {
// 状态码200-399表示源可访问
if (res.statusCode >= 200 && res.statusCode < 400) {
console.log('自定义源可访问');
} else {
console.warn('自定义源不可访问,切换到测试源');
this.isUsingCustomSource = false;
this.isSingleSource = false;
}
},
fail: (err) => {
console.error('自定义源请求失败,切换到测试源', err);
this.isUsingCustomSource = false;
this.isSingleSource = false;
}
});
},
initVideoComponent() {
const uniVideoRef = this.$refs.videoPlayer;
if (!uniVideoRef) {
this.retryInit('$refs.videoPlayer 未找到(组件未渲染)');
return;
}
// 第二步:检查$el是否存在(部分场景下组件可能已卸载)
if (!uniVideoRef.$el) {
this.retryInit('video组件的$el不存在(组件已卸载)');
return;
}
// 第三步:安全获取video元素
const videoEl = uniVideoRef.$el.querySelector('video');
if (!videoEl || videoEl.tagName !== 'VIDEO') {
this.retryInit('未提取到原生video标签');
return;
}
this.isLoadingComponent = false;
this.checkNativeHLSSupport(videoEl);
this.initPlayer(videoEl);
},
retryInit(reason) {
console.warn(`初始化重试(${this.initRetryCount + 1}/${this.maxInitRetry}):${reason}`);
if (this.initRetryCount < this.maxInitRetry) {
this.initRetryCount++;
setTimeout(() => this.initVideoComponent(), 1000);
return;
}
this.isLoadingComponent = false;
this.setError('组件初始化失败', reason + ',请重启项目或检查设备');
},
// 修改checkNativeHLSSupport方法,增加更严格的格式检测
checkNativeHLSSupport(videoEl) {
try {
// 增加HLS格式白名单检测
const hlsTypes = ['application/vnd.apple.mpegurl', 'application/x-mpegURL'];
const supportLevel = hlsTypes.map(type => videoEl.canPlayType(type))
.find(level => level === 'probably' || level === 'maybe');
this.isNativeHLS = !!supportLevel;
console.log('严格HLS支持检测:', this.isNativeHLS);
} catch (err) {
this.isNativeHLS = false;
console.error('HLS支持检测异常:', err);
}
},
initPlayer(videoEl) {
this.isLoadingPlayer = true;
// App端特殊处理
if (uni.getSystemInfoSync().platform !== 'h5') {
// 先静音播放(绕过自动播放限制)
videoEl.muted = true;
this.liveData.muted = true;
// 增加用户交互后恢复声音
const restoreAudio = () => {
videoEl.muted = false;
this.liveData.muted = false;
document.removeEventListener('click', restoreAudio);
};
document.addEventListener('click', restoreAudio, { once: true });
}
if (this.isNativeHLS) {
this.playWithNative(videoEl);
return;
}
this.playWithHLS(videoEl);
},
playWithNative(videoEl) {
videoEl.src = this.currentSource;
videoEl.addEventListener('progress', () => {
if (videoEl.buffered.length > 0) {
const loadedEnd = videoEl.buffered.end(videoEl.buffered.length - 1);
this.loadProgress = Math.floor((loadedEnd / (videoEl.duration || 1)) * 100) || 0;
}
});
videoEl.play()
.then(() => {
this.isLoadingPlayer = false;
this.isPlaying = true;
})
.catch(err => this.setError('原生播放失败', err.message || '自动播放被拦截'));
},
playWithHLS(videoEl) {
if (uni.getSystemInfoSync().platform === 'ios' ||
uni.getSystemInfoSync().platform === 'android') {
// App端强制使用原生播放(如果可能)
if (this.isNativeHLS) {
this.playWithNative(videoEl);
return;
}
// 增加App端HLS.js配置
this.hls = new Hls({
// App端需要关闭的配置项
enableWorker: false,
lowLatencyMode: true,
// 增加App端专用配置
appSpecific: {
useNativeHLSWhenAvailable: true
}
});
}
if (typeof Hls === 'undefined') {
this.setError('依赖缺失', '请安装HLS.js(npm install hls.js)');
this.isLoadingPlayer = false;
return;
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
this.hls = new Hls({
maxBufferLength: 20,
maxMaxBufferLength: 30,
startLevel: 0,
enableWorker: false,
lowLatencyMode: false,
fragLoadTimeOut: 30000,
fragMaxRetry: 3
});
this.hls.attachMedia(videoEl);
this.hls.on(Hls.Events.ERROR, (_, data) => {
console.error('HLS.js错误:', data);
if (data.fatal) {
const errMap = {
networkError: '网络错误(检查网络或源地址)',
mediaError: '格式不兼容(需H.264+AAC编码)',
manifestError: 'm3u8清单格式错误'
};
this.setError(errMap[data.type] || 'HLS播放失败', data.details || '未知错误');
this.isSingleSource = Object.keys(this.sources).length <= 1;
this.isLoadingPlayer = false;
}
});
this.hls.loadSource(this.currentSource);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS清单解析成功');
this.isLoadingPlayer = false;
videoEl.play().then(() => {
this.isPlaying = true;
}).catch(err => {
this.setError('播放失败', err.name === 'NotAllowedError' ? '自动播放被拦截(点击视频开启)' : err.message);
});
});
this.hls.on(Hls.Events.BUFFER_LEVEL_STATE_CHANGED, (_, data) => {
if (data.level === -1) return;
this.loadProgress = Math.floor((data.bufferLength / (data.targetDuration || 1)) * 100) || 0;
});
},
switchSource() {
this.isUsingCustomSource = !this.isUsingCustomSource;
this.destroyPlayer();
this.$nextTick(() => {
this.isLoadingComponent = true;
this.initVideoComponent();
});
},
reloadPlayer() {
this.hasError = false;
this.isLoadingComponent = true;
this.initRetryCount = 0;
this.destroyPlayer();
this.$nextTick(() => {
this.initVideoComponent();
});
},
destroyPlayer() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
const uniVideoRef = this.$refs.videoPlayer;
if (uniVideoRef) {
const videoEl = uniVideoRef.$el.querySelector('video');
if (videoEl) {
videoEl.pause();
videoEl.src = '';
videoEl.load();
}
}
this.isLoadingPlayer = false;
this.loadProgress = 0;
this.isPlaying = false;
},
setError(title, desc) {
this.hasError = true;
this.errorTitle = title;
this.errorDesc = desc;
this.isLoadingPlayer = false;
this.isLoadingComponent = false;
},
handleVideoError(err) {
const errDetail = err.detail || {};
if (errDetail.errCode || errDetail.errMsg) {
console.error('uni-video错误:', errDetail);
this.setError('视频组件错误', `错误码: ${errDetail.errCode || '未知'}, 信息: ${errDetail.errMsg || '无'}`);
}
},
toggleMute() {
this.liveData.muted = !this.liveData.muted;
const uniVideoRef = this.$refs.videoPlayer;
if (uniVideoRef) {
const videoEl = uniVideoRef.$el.querySelector('video');
if (videoEl) videoEl.muted = this.liveData.muted;
}
},
onPlay() {
this.isBuffering = false;
this.isPlaying = true;
},
onPause() {
if (!this.hasError) this.isBuffering = true;
this.isPlaying = false;
},
onBuffering() {
this.isBuffering = true;
},
// 聊天功能方法
toggleChatExpand() {
this.isChatExpanded = !this.isChatExpanded;
this.$nextTick(() => this.scrollToChatBottom());
},
sendChatMessage() {
const userlist = uni.getStorageSync('UID');
const datalist = uni.getStorageSync('USER_INFO');
const userInfo = JSON.parse(datalist);
this.queryliat.userId=userlist
const content = this.chatInput.trim();
this.queryliat.content=content
if (!content) return;
this.chatInput = '';
this.scrollToChatBottom();
livecomment(80,userlist,content).then(res => {
console.log(res,'asdas')
});
// 发送到WebSocket后端
this.sendWsMessage({ content: content });
},
scrollToChatBottom() {
if (!this.isChatExpanded) return;
const msgContainer = uni.createSelectorQuery().in(this).select('.chat-messages');
msgContainer.fields({ scrollOffset: true, size: true }, res => {
if (res) {
msgContainer.scrollOffset({
scrollTop: res.scrollHeight,
duration: 300
});
}
}).exec();
},
// 实时通信方法
initWebSocket() {
// 避免重复连接
if (this.wsStatus === 'connecting' || this.wsStatus === 'open') return;
// 1. 验证WebSocket URL格式(防止URL错误)
if (!this.wsUrl.startsWith('ws://') && !this.wsUrl.startsWith('wss://')) {
console.error('WebSocket URL格式错误,必须以ws://或wss://开头');
this.showToast('聊天服务地址错误');
return;
}
// 拼接完整URL(固定roomId为80,确保参数正确)
const wsUrlWithRoom = `${this.wsUrl}/80`;
console.log('尝试连接WebSocket:', wsUrlWithRoom);
this.wsStatus = 'connecting';
// 2. 增加连接超时处理(防止一直卡在connecting状态)
const connectionTimeout = setTimeout(() => {
if (this.wsStatus === 'connecting') {
console.error('WebSocket连接超时(10秒未响应)');
this.wsStatus = 'error';
this.showToast('聊天连接超时');
this.destroyWebSocket(); // 主动销毁超时连接
this.reconnectWebSocket();
}
}, 10000); // 10秒超时
try {
this.ws = new WebSocket(wsUrlWithRoom);
// 连接成功
this.ws.onopen = () => {
clearTimeout(connectionTimeout); // 清除超时器
console.log('WebSocket连接成功');
this.wsStatus = 'open';
this.reconnectCount = 0; // 重置重连计数
this.sendWsMessage({
type: 'enter',
content: '进入了直播间'
});
};
// 接收消息(保持不变)
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.username === this.userInfo.username && msg.type !== 'enter') return;
console.log(msg.data,'获取消息')
this.chatMessages.push(msg.data);
this.$nextTick(() => this.scrollToChatBottom());
} catch (err) {
console.error('解析WebSocket消息失败:', err);
}
};
// 3. 增强错误信息输出(关键修改)
this.ws.onerror = (event) => {
clearTimeout(connectionTimeout);
// 打印完整错误上下文
console.error('WebSocket错误详情:');
console.error('连接URL:', wsUrlWithRoom);
console.error('错误事件:', event);
console.error('WebSocket状态:', this.ws?.readyState ?
['连接中', '已连接', '关闭中', '已关闭'][this.ws.readyState] : '未初始化');
// 根据状态提示可能的原因
let errorMsg = '聊天连接失败';
if (this.ws?.readyState === 3) { // 已关闭状态
errorMsg += '(服务端拒绝连接或地址错误)';
} else if (this.ws?.readyState === 0) { // 连接中出错
errorMsg += '(网络异常或服务未启动)';
}
this.wsStatus = 'error';
this.showToast(errorMsg);
this.reconnectWebSocket();
};
// 4. 处理关闭事件(根据关闭代码判断原因)
this.ws.onclose = (event) => {
clearTimeout(connectionTimeout);
console.log(`WebSocket关闭 - 代码: ${event.code}, 原因: ${event.reason || '无'}`);
this.wsStatus = 'closed';
// 常见关闭代码含义:
// 1000: 正常关闭;1006: 异常关闭(服务端未启动/网络断连);1011: 服务端错误
const shouldReconnect = [1006, 1011, 1012].includes(event.code) &&
this.reconnectCount < this.maxReconnect;
if (shouldReconnect) {
this.reconnectWebSocket();
} else if (event.code !== 1000) {
this.showToast(`聊天连接已关闭(代码: ${event.code})`);
}
};
} catch (err) {
clearTimeout(connectionTimeout);
console.error('创建WebSocket实例失败:', err);
this.wsStatus = 'error';
// 捕获URL格式错误等初始化异常
this.showToast('聊天服务初始化失败(可能是地址格式错误)');
this.reconnectWebSocket();
}
},
// 新增:主动销毁WebSocket连接的方法
destroyWebSocket() {
if (this.ws) {
try {
this.ws.close(1000, '主动关闭');
} catch (err) {
console.error('关闭WebSocket失败:', err);
}
this.ws = null;
}
},
// 优化:重连机制(指数退避策略)
reconnectWebSocket() {
if (this.reconnectCount >= this.maxReconnect) {
console.error(`已达最大重连次数(${this.maxReconnect}次),停止重连`);
this.showToast('多次连接失败,请检查服务状态');
return;
}
// 指数退避:1s → 2s → 4s → 8s(最多8秒)
const delay = Math.min(1000 * Math.pow(2, this.reconnectCount), 8000);
this.reconnectCount++;
console.log(`第${this.reconnectCount}次重连(延迟${delay}ms)`);
setTimeout(() => {
this.destroyWebSocket(); // 先销毁旧连接
this.initWebSocket();
}, delay);
},
reconnectWebSocket() {
if (this.reconnectCount >= this.maxReconnect) {
this.showToast('聊天连接失败,请刷新页面');
return;
}
const delay = (this.reconnectCount + 1) * 2000;
setTimeout(() => {
this.reconnectCount++;
console.log(`第${this.reconnectCount}次重连`);
this.initWebSocket();
}, delay);
},
sendWsMessage(msgData) {
if (this.wsStatus !== 'open' || !this.ws) {
this.showToast('聊天连接中,请稍后');
return;
}
const msg = {
username: this.userInfo.username,
avatar: this.userInfo.avatar,
content: '',
type: 'chat',
timestamp: new Date().getTime(),
...msgData
};
try {
this.ws.send(JSON.stringify(msg));
} catch (err) {
console.error('发送消息失败:', err);
this.showToast('消息发送失败');
}
},
showToast(title) {
uni.showToast({
title,
icon: 'none',
duration: 1500
});
}
}
};
</script>
<style scoped>
.container {
width: 100%;
height: 100vh;
background-color: #000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.video-container {
flex: 1;
position: relative;
width: 100%;
}
/* 隐藏视频控件 */
::v-deep video::-webkit-media-controls { display: none !important; }
::v-deep video::-moz-media-controls { display: none !important; }
::v-deep video::-ms-media-controls { display: none !important; }
::v-deep video::-o-media-controls { display: none !important; }
::v-deep video::-webkit-media-controls-enclosure { display: none !important; }
::v-deep video::-webkit-media-controls-panel { display: none !important; }
.video {
width: 100%;
height: 100%;
pointer-events: none;
}
/* 状态提示样式 */
.loading-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 5;
}
.spinner {
width: 80rpx;
height: 80rpx;
border: 8rpx solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: #fff;
font-size: 28rpx;
margin-top: 20rpx;
}
.error-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
gap: 20rpx;
z-index: 5;
}
.error-title {
color: #ff4d4f;
font-size: 32rpx;
text-align: center;
}
.error-desc {
color: #fff;
font-size: 26rpx;
text-align: center;
line-height: 1.5;
}
.retry-btn, .switch-btn {
background-color: #1677ff;
color: #fff;
border-radius: 30rpx;
padding: 15rpx 60rpx;
font-size: 28rpx;
width: auto;
margin-top: 10rpx;
}
.switch-btn {
background-color: #4CAF50;
}
/* 聊天功能样式 */
.chat-container {
position: absolute;
bottom: 0rpx;
left: 5rpx;
width: calc(100% - 20rpx);
background-color: rgba(0, 0, 0, 0.5);
border-radius: 20rpx;
overflow: hidden;
z-index: 4;
transition: height 0.3s ease;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15rpx 20rpx;
background-color: rgba(0, 0, 0, 0.6);
}
.chat-title {
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
.chat-toggle-icon {
color: #fff;
font-size: 32rpx;
font-weight: bold;
}
.chat-messages {
height: 300rpx;
padding: 15rpx 20rpx;
overflow-y: auto;
display: none;
}
.chat-expanded .chat-messages {
display: block;
}
.chat-message {
display: flex;
align-items: flex-start;
margin-bottom: 15rpx;
line-height: 1.5;
}
.chat-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 10rpx;
margin-top: 5rpx;
}
.chat-content-wrap {
display: flex;
flex-direction: column;
}
.chat-username {
color: #40a9ff;
font-size: 26rpx;
margin-right: 10rpx;
font-weight: 500;
}
.chat-content {
color: #fff;
font-size: 26rpx;
}
.system-msg {
color: #b3b3b3;
font-style: italic;
}
.chat-input-area {
display: flex;
align-items: center;
padding: 15rpx 20rpx;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.chat-input {
flex: 1;
height: 50rpx;
color: #fff;
font-size: 26rpx;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 30rpx;
padding: 0 20rpx;
margin-right: 15rpx;
}
.chat-send-btn {
background-color: #1677ff;
color: #fff;
font-size: 26rpx;
border-radius: 30rpx;
padding: 10rpx 30rpx;
}
.chat-send-btn:disabled {
background-color: rgba(255, 255, 255, 0.3);
color: rgba(255, 255, 255, 0.6);
}
/* 动画和滚动条 */
@keyframes spin { to { transform: rotate(360deg); } }
.chat-messages::-webkit-scrollbar { width: 8rpx; }
.chat-messages::-webkit-scrollbar-track {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 10rpx;
}
.chat-messages::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.3);
border-radius: 10rpx;
}
</style>把这个代码兼容app播放