|
@@ -4,7 +4,22 @@
|
|
:down="downOption" :up="upOption">
|
|
:down="downOption" :up="upOption">
|
|
<view class="list">
|
|
<view class="list">
|
|
<view class="list-item" @click="goLive(item)" v-for="(item,index) in list" :key="index">
|
|
<view class="list-item" @click="goLive(item)" v-for="(item,index) in list" :key="index">
|
|
- <image :src="item.liveImgUrl"></image>
|
|
|
|
|
|
+
|
|
|
|
+ <!-- <image :src="item.liveImgUrl"></image> -->
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ <video v-if="item.flvHlsUrl && item.liveId" :id="'myVideo_' + item.liveId" :src="item.flvHlsUrl"
|
|
|
|
+ :autoplay="false" :controls='false' object-fit='contain' :custom-cache="false"
|
|
|
|
+ :enable-progress-gesture="false" vslide-gesture-in-fullscreen='true'
|
|
|
|
+ :show-center-play-btn="false" :http-cache="false" @error="videoError">
|
|
|
|
+ </video>
|
|
|
|
+
|
|
|
|
+ <video v-if="item.videoUrl" :src="item.videoUrl" :autoplay="false" :controls='false'
|
|
|
|
+ object-fit='contain' :custom-cache="false" :enable-progress-gesture="false"
|
|
|
|
+ vslide-gesture-in-fullscreen='true' :show-center-play-btn="false" :http-cache="false" loop
|
|
|
|
+ @error="videoError">
|
|
|
|
+ </video>
|
|
|
|
+
|
|
<view class="info">
|
|
<view class="info">
|
|
<text>{{item.liveName}}</text>
|
|
<text>{{item.liveName}}</text>
|
|
</view>
|
|
</view>
|
|
@@ -16,20 +31,31 @@
|
|
|
|
|
|
</template>
|
|
</template>
|
|
<script>
|
|
<script>
|
|
|
|
+ import Hls from 'hls.js';
|
|
import {
|
|
import {
|
|
liveList
|
|
liveList
|
|
} from '@/api/list'
|
|
} from '@/api/list'
|
|
import MescrollMixin from "@/uni_modules/mescroll-uni/components/mescroll-uni/mescroll-mixins.js";
|
|
import MescrollMixin from "@/uni_modules/mescroll-uni/components/mescroll-uni/mescroll-mixins.js";
|
|
- // import { LiveWS } from '@/utils/liveWS'
|
|
|
|
- // import { login,loginByWeChat,getUserInfo,loginByApple } from '@/api/user'
|
|
|
|
export default {
|
|
export default {
|
|
mixins: [MescrollMixin], // 使用mixin
|
|
mixins: [MescrollMixin], // 使用mixin
|
|
data() {
|
|
data() {
|
|
return {
|
|
return {
|
|
|
|
+
|
|
|
|
+ hlsPlayer: null, // HLS播放器实例,
|
|
|
|
+ // 统一直播/录播类型标识
|
|
|
|
+ LIVE_TYPE: {
|
|
|
|
+ LIVE: 1, // 直播
|
|
|
|
+ RECORD: 2 // 录播
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
list: [],
|
|
list: [],
|
|
liveId: null, // mescroll配置
|
|
liveId: null, // mescroll配置
|
|
downOption: {
|
|
downOption: {
|
|
- offset: 80,
|
|
|
|
|
|
+ offset: 80,
|
|
use: true,
|
|
use: true,
|
|
auto: false // 是否在初始化后自动执行下拉回调
|
|
auto: false // 是否在初始化后自动执行下拉回调
|
|
},
|
|
},
|
|
@@ -40,36 +66,244 @@
|
|
num: 0, // 当前页码
|
|
num: 0, // 当前页码
|
|
size: 10 // 每页数据条数
|
|
size: 10 // 每页数据条数
|
|
}
|
|
}
|
|
-
|
|
|
|
|
|
+
|
|
},
|
|
},
|
|
mescroll: null // mescroll实例
|
|
mescroll: null // mescroll实例
|
|
}
|
|
}
|
|
},
|
|
},
|
|
onLoad() {
|
|
onLoad() {
|
|
- if(!uni.getStorageSync("AppToken")){
|
|
|
|
|
|
+ if (!uni.getStorageSync("AppToken")) {
|
|
uni.navigateTo({
|
|
uni.navigateTo({
|
|
url: '/pages/auth/login'
|
|
url: '/pages/auth/login'
|
|
});
|
|
});
|
|
-
|
|
|
|
|
|
+
|
|
|
|
+ } // 销毁HLS播放器(如果使用了hls.js)
|
|
|
|
+ if (this.hlsPlayer) {
|
|
|
|
+ this.hlsPlayer.destroy();
|
|
|
|
+ this.hlsPlayer = null;
|
|
}
|
|
}
|
|
},
|
|
},
|
|
- methods: {
|
|
|
|
- goLive(item) {
|
|
|
|
- this.liveId = item.liveId
|
|
|
|
- console.log("要传的liveId", this.liveId)
|
|
|
|
- uni.navigateTo({
|
|
|
|
- url: `/pages/home/living?liveId=${item.liveId}&immediate=true`
|
|
|
|
|
|
+ onUnload() {
|
|
|
|
+ this.list.forEach(item => {
|
|
|
|
+ this.$set(item, 'isDestroyed', true); // 标记已销毁,阻止播放
|
|
|
|
+ this.pauseAndCleanVideo(item); // 统一清理
|
|
|
|
+ });
|
|
|
|
+ },
|
|
|
|
+ methods: { // 处理单个视频项,适配m3u8流
|
|
|
|
+ initVideoPlayer(item) {
|
|
|
|
+ // 基础校验:无地址/无ID,不初始化
|
|
|
|
+ const isLive = item.liveType === 1;
|
|
|
|
+ const hasValidUrl = isLive ? (item.flvHlsUrl?.includes('.m3u8')) : !!item.videoUrl;
|
|
|
|
+ if (!hasValidUrl || !item.liveId) return;
|
|
|
|
+
|
|
|
|
+ const videoId = `myVideo_${item.liveId}`;
|
|
|
|
+ console.log(`[视口监听] 初始化:${videoId}`);
|
|
|
|
+
|
|
|
|
+ // 1. 创建交叉观察器(监听是否进入视口)
|
|
|
|
+ const observer = uni.createIntersectionObserver(this, {
|
|
|
|
+ thresholds: [0.2], // 20% 进入视口触发
|
|
|
|
+ observeAll: false
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 2. 监听视口状态,触发按需播放/暂停
|
|
|
|
+ observer.relativeTo('.list').observe(`#${videoId}`, (res) => {
|
|
|
|
+ const isInView = res.intersectionRatio > 0; // 是否在视口内
|
|
|
|
+
|
|
|
|
+ if (isInView) {
|
|
|
|
+ console.log(`[视口监听] 进入视口,触发按需播放:${videoId}`);
|
|
|
|
+ // 标记为在视口,避免重复调用
|
|
|
|
+ this.$set(item, 'isInView', true);
|
|
|
|
+ // 调用统一播放入口,实现按需播放
|
|
|
|
+ this.setupVideoPlayback(item);
|
|
|
|
+ } else {
|
|
|
|
+ console.log(`[视口监听] 离开视口,暂停播放:${videoId}`);
|
|
|
|
+ // 标记为离开视口
|
|
|
|
+ this.$set(item, 'isInView', false);
|
|
|
|
+ // 暂停播放并清理资源
|
|
|
|
+ this.pauseAndCleanVideo(item);
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 3. 存储观察器到 item,便于后续销毁
|
|
|
|
+ this.$set(item, 'observer', observer);
|
|
|
|
+ // 标记未销毁,避免 setupVideoPlayback 跳过
|
|
|
|
+ this.$set(item, 'isDestroyed', false);
|
|
|
|
+ },
|
|
|
|
+ pauseAndCleanVideo(item) {
|
|
|
|
+ // 1. 暂停录播/小程序直播(通过 videoContext)
|
|
|
|
+ if (item.videoContext) {
|
|
|
|
+ item.videoContext.pause();
|
|
|
|
+ this.$set(item, 'videoContext', null); // 清空上下文,避免重复调用
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 2. 销毁 HLS 实例(直播 H5 场景)
|
|
|
|
+ if (item.hlsInstance) {
|
|
|
|
+ item.hlsInstance.destroy();
|
|
|
|
+ this.$set(item, 'hlsInstance', null);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 3. 销毁视口监听(避免列表刷新后仍监听)
|
|
|
|
+ if (item.observer) {
|
|
|
|
+ item.observer.disconnect();
|
|
|
|
+ this.$set(item, 'observer', null);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 4. 标记为已销毁(防止重复初始化)
|
|
|
|
+ this.$set(item, 'isDestroyed', true);
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ setupVideoPlayback(item) {
|
|
|
|
+ // 1. 基础校验:先判断类型,再校验 URL(避免无效执行)
|
|
|
|
+ const isLive = item.liveType === this.LIVE_TYPE.LIVE;
|
|
|
|
+ const videoUrl = isLive ? item.flvHlsUrl : item.videoUrl;
|
|
|
|
+
|
|
|
|
+ // 无效场景直接返回(录播无URL、直播无m3u8、已销毁)
|
|
|
|
+ if (!videoUrl || item.isDestroyed || (isLive && !videoUrl.includes('.m3u8'))) {
|
|
|
|
+ console.warn(`[播放校验] 跳过无效播放:`, item.liveId, '类型:', isLive ? '直播' : '录播');
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const videoId = `myVideo_${item.liveId}`;
|
|
|
|
+ console.log(`[按需播放] 触发:${isLive ? '直播' : '录播'},ID:${videoId}`);
|
|
|
|
+
|
|
|
|
+ // 2. 先清理旧资源(避免重复初始化)
|
|
|
|
+ this.pauseAndCleanVideo(item);
|
|
|
|
+
|
|
|
|
+ // 3. 直播逻辑:仅 H5 平台用 HLS,其他平台(小程序/App)用原生 video
|
|
|
|
+ if (isLive) {
|
|
|
|
+ // #ifdef H5
|
|
|
|
+ // 直播 + H5 + m3u8:初始化 HLS
|
|
|
|
+ this.initHlsPlayer(item);
|
|
|
|
+ // #endif
|
|
|
|
+
|
|
|
|
+ // #ifndef H5
|
|
|
|
+ // 小程序/App 直播:直接用原生 videoContext(无需 HLS,平台自带 m3u8 支持)
|
|
|
|
+ this.initNativeVideo(item, videoId, isLive);
|
|
|
|
+ // #endif
|
|
|
|
+ } else {
|
|
|
|
+ // 4. 录播逻辑:所有平台统一用原生 videoContext(无需 HLS)
|
|
|
|
+ this.initNativeVideo(item, videoId, isLive);
|
|
|
|
+ }
|
|
|
|
+ },
|
|
|
|
+ /**
|
|
|
|
+ * 初始化原生视频(录播 + 小程序/App 直播)
|
|
|
|
+ * @param {Object} item - 列表项数据
|
|
|
|
+ * @param {String} videoId - 视频DOM ID
|
|
|
|
+ * @param {Boolean} isLive - 是否为直播
|
|
|
|
+ */
|
|
|
|
+ initNativeVideo(item, videoId, isLive) {
|
|
|
|
+ uni.createSelectorQuery().in(this)
|
|
|
|
+ .select(`#${videoId}`)
|
|
|
|
+ .fields({
|
|
|
|
+ context: true
|
|
|
|
+ }) // 仅获取视频上下文
|
|
|
|
+ .exec((res) => {
|
|
|
|
+ if (!res || !res[0]?.context) {
|
|
|
|
+ console.warn(`[原生视频] 未找到上下文:${videoId}`);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const videoContext = res[0].context;
|
|
|
|
+ this.$set(item, 'videoContext', videoContext); // 存储上下文,用于后续暂停
|
|
|
|
+
|
|
|
|
+ // 直播:直接播放(需用户交互,失败时提示)
|
|
|
|
+ if (isLive) {
|
|
|
|
+ videoContext.play().catch(err => {
|
|
|
|
+ console.log(`[直播播放] 自动播放失败(需点击):${item.liveId}`, err);
|
|
|
|
+ // 可选:添加「播放按钮」,点击后调用 videoContext.play()
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ // 录播:支持断点续播(从上次进度开始)
|
|
|
|
+ else {
|
|
|
|
+ const startDuration = item.nowDuration || 0; // 从接口获取的上次播放进度
|
|
|
|
+ videoContext.seek(startDuration); // 跳转到指定进度
|
|
|
|
+ videoContext.play().catch(err => {
|
|
|
|
+ console.log(`[录播播放] 失败:${item.liveId}`, err);
|
|
|
|
+ });
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+ },
|
|
|
|
+
|
|
|
|
+ // 初始化HLS播放器
|
|
|
|
+ initHlsPlayer(item) {
|
|
|
|
+ // 兜底校验:确保是直播且有 m3u8 地址
|
|
|
|
+ if (item.liveType !== this.LIVE_TYPE.LIVE || !item.flvHlsUrl?.includes('.m3u8')) {
|
|
|
|
+ console.error(`[HLS 错误] 非直播场景调用:${item.liveId}`);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ const videoUrl = item.flvHlsUrl;
|
|
|
|
+ if (!Hls.isSupported()) {
|
|
|
|
+ console.error(`[HLS 错误] 浏览器不支持 HLS:${item.liveId}`);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 获取 H5 视频 DOM 节点
|
|
|
|
+ const video = document.getElementById(`myVideo_${item.liveId}`);
|
|
|
|
+ if (!video) {
|
|
|
|
+ console.warn(`[HLS 错误] 未找到视频节点:myVideo_${item.liveId}`);
|
|
|
|
+ return;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ // 初始化 HLS(低延迟配置,适合直播)
|
|
|
|
+ const hls = new Hls({
|
|
|
|
+ enableWorker: true,
|
|
|
|
+ lowLatencyMode: true, // 直播低延迟关键配置
|
|
|
|
+ debug: false,
|
|
|
|
+ maxBufferLength: 3, // 减少缓冲,降低延迟(根据需求调整)
|
|
|
|
+ maxMaxBufferLength: 10
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 绑定视频节点 + 加载流
|
|
|
|
+ hls.attachMedia(video);
|
|
|
|
+ hls.loadSource(videoUrl);
|
|
|
|
+
|
|
|
|
+ // 解析成功后播放
|
|
|
|
+ hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
|
+ console.log(`[HLS 直播] 解析成功:${item.liveId}`);
|
|
|
|
+ video.play().catch(err => {
|
|
|
|
+ console.log(`[HLS 播放] 自动播放失败(需点击):${item.liveId}`, err);
|
|
|
|
+ });
|
|
});
|
|
});
|
|
|
|
+
|
|
|
|
+ // 错误处理(自动恢复或销毁)
|
|
|
|
+ hls.on(Hls.Events.ERROR, (event, data) => {
|
|
|
|
+ console.error(`[HLS 错误] ${item.liveId}:`, data);
|
|
|
|
+ if (data.fatal) {
|
|
|
|
+ switch (data.type) {
|
|
|
|
+ case Hls.ErrorTypes.NETWORK_ERROR:
|
|
|
|
+ hls.startLoad(); // 网络错误:重试加载
|
|
|
|
+ break;
|
|
|
|
+ default:
|
|
|
|
+ hls.destroy(); // 其他致命错误:销毁实例
|
|
|
|
+ this.$set(item, 'hlsInstance', null);
|
|
|
|
+ break;
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 存储 HLS 实例到 item(便于后续销毁)
|
|
|
|
+ this.$set(item, 'hlsInstance', hls);
|
|
},
|
|
},
|
|
|
|
+
|
|
|
|
+
|
|
// mescroll初始化
|
|
// mescroll初始化
|
|
mescrollInit(mescroll) {
|
|
mescrollInit(mescroll) {
|
|
this.mescroll = mescroll;
|
|
this.mescroll = mescroll;
|
|
},
|
|
},
|
|
// 下拉刷新回调
|
|
// 下拉刷新回调
|
|
downCallback(mescroll) {
|
|
downCallback(mescroll) {
|
|
- // 重置列表数据
|
|
|
|
|
|
+ // 1. 先清理所有旧列表的视频资源(直播 HLS + 录播上下文)
|
|
|
|
+ this.list.forEach(item => {
|
|
|
|
+ this.pauseAndCleanVideo(item);
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ // 2. 重置列表和分页(必须在清理后执行)
|
|
this.list = [];
|
|
this.list = [];
|
|
- mescroll.resetUpScroll();
|
|
|
|
|
|
+ mescroll.resetUpScroll(); // 重置上拉加载的页码
|
|
|
|
+
|
|
|
|
+ // 3. 可选:下拉后自动加载第一页(符合用户预期)
|
|
|
|
+ this.mescroll.triggerUpScroll();
|
|
},
|
|
},
|
|
// 上拉加载回调
|
|
// 上拉加载回调
|
|
upCallback(mescroll) {
|
|
upCallback(mescroll) {
|
|
@@ -80,25 +314,28 @@
|
|
pageSize: pageSize,
|
|
pageSize: pageSize,
|
|
page: pageNum,
|
|
page: pageNum,
|
|
}
|
|
}
|
|
|
|
+
|
|
liveList(data).then(res => {
|
|
liveList(data).then(res => {
|
|
if (res.code == 200) {
|
|
if (res.code == 200) {
|
|
- // 请求成功,处理数据
|
|
|
|
let curPageData = res.rows || [];
|
|
let curPageData = res.rows || [];
|
|
- let curPageLen = curPageData.length;
|
|
|
|
let totalSize = res.total || 0;
|
|
let totalSize = res.total || 0;
|
|
|
|
|
|
- // 如果是第一页,直接赋值
|
|
|
|
if (pageNum === 1) {
|
|
if (pageNum === 1) {
|
|
this.list = [];
|
|
this.list = [];
|
|
}
|
|
}
|
|
|
|
|
|
// 追加新数据
|
|
// 追加新数据
|
|
- this.list = this.list.concat(curPageData);
|
|
|
|
|
|
+ this.list = this.list.concat(curPageData);
|
|
|
|
|
|
- mescroll.endBySize(curPageLen, totalSize);
|
|
|
|
|
|
+ // 关键修改:用 $nextTick 等待DOM渲染完成
|
|
|
|
+ this.$nextTick(() => {
|
|
|
|
+ curPageData.forEach(item => {
|
|
|
|
+ this.initVideoPlayer(item); // 此时视频节点已存在
|
|
|
|
+ });
|
|
|
|
+ });
|
|
|
|
|
|
- } else {
|
|
|
|
- // 请求失败
|
|
|
|
|
|
+ mescroll.endBySize(curPageData.length, totalSize);
|
|
|
|
+ } else {
|
|
mescroll.endErr();
|
|
mescroll.endErr();
|
|
uni.showToast({
|
|
uni.showToast({
|
|
title: res.msg,
|
|
title: res.msg,
|
|
@@ -106,11 +343,22 @@
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}).catch(err => {
|
|
}).catch(err => {
|
|
- // 请求异常
|
|
|
|
mescroll.endErr();
|
|
mescroll.endErr();
|
|
console.log("请求异常:" + JSON.stringify(err));
|
|
console.log("请求异常:" + JSON.stringify(err));
|
|
});
|
|
});
|
|
},
|
|
},
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+
|
|
|
|
+ goLive(item) {
|
|
|
|
+ this.liveId = item.liveId
|
|
|
|
+ console.log("要传的liveId", this.liveId)
|
|
|
|
+ uni.navigateTo({
|
|
|
|
+ url: `/pages/home/living?liveId=${item.liveId}&immediate=true`
|
|
|
|
+ });
|
|
|
|
+ },
|
|
|
|
+
|
|
// getList() {
|
|
// getList() {
|
|
// const data = {
|
|
// const data = {
|
|
// page: 1,
|
|
// page: 1,
|
|
@@ -163,6 +411,12 @@
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
position: relative;
|
|
position: relative;
|
|
|
|
|
|
|
|
+ video {
|
|
|
|
+ width: 100%;
|
|
|
|
+ height: 100%;
|
|
|
|
+ /* 视频填满列表项,确保可视区域判断准确 */
|
|
|
|
+ }
|
|
|
|
+
|
|
.info {
|
|
.info {
|
|
position: absolute;
|
|
position: absolute;
|
|
left: 20rpx;
|
|
left: 20rpx;
|