index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  1. <template>
  2. <view class=" content ">
  3. <mescroll-body bottom="0" ref="mescrollRef" @init="mescrollInit" @down="downCallback" @up="upCallback"
  4. :down="downOption" :up="upOption">
  5. <view class="list">
  6. <view class="list-item" @click="goLive(item)" v-for="(item,index) in list" :key="index">
  7. <!-- <image :src="item.liveImgUrl"></image> -->
  8. <video v-if="item.flvHlsUrl && item.liveId" :id="'myVideo_' + item.liveId" :src="item.flvHlsUrl"
  9. :autoplay="false" :controls='false' object-fit='contain' :custom-cache="false"
  10. :enable-progress-gesture="false" vslide-gesture-in-fullscreen='true'
  11. :show-center-play-btn="false" :http-cache="false" @error="videoError">
  12. </video>
  13. <video v-if="item.videoUrl" :src="item.videoUrl" :autoplay="false" :controls='false'
  14. object-fit='contain' :custom-cache="false" :enable-progress-gesture="false"
  15. vslide-gesture-in-fullscreen='true' :show-center-play-btn="false" :http-cache="false" loop
  16. @error="videoError">
  17. </video>
  18. <view class="info">
  19. <text>{{item.liveName}}</text>
  20. </view>
  21. </view>
  22. </view>
  23. </mescroll-body>
  24. </view>
  25. </template>
  26. <script>
  27. import Hls from 'hls.js';
  28. import {
  29. liveList
  30. } from '@/api/list'
  31. import MescrollMixin from "@/uni_modules/mescroll-uni/components/mescroll-uni/mescroll-mixins.js";
  32. export default {
  33. mixins: [MescrollMixin], // 使用mixin
  34. data() {
  35. return {
  36. hlsPlayer: null, // HLS播放器实例,
  37. // 统一直播/录播类型标识
  38. LIVE_TYPE: {
  39. LIVE: 1, // 直播
  40. RECORD: 2 // 录播
  41. },
  42. list: [],
  43. liveId: null, // mescroll配置
  44. downOption: {
  45. offset: 80,
  46. use: true,
  47. auto: false // 是否在初始化后自动执行下拉回调
  48. },
  49. upOption: {
  50. use: true,
  51. auto: true, // 是否在初始化时自动执行上拉回调
  52. page: {
  53. num: 0, // 当前页码
  54. size: 10 // 每页数据条数
  55. }
  56. },
  57. mescroll: null // mescroll实例
  58. }
  59. },
  60. onLoad() {
  61. if (!uni.getStorageSync("AppToken")) {
  62. uni.navigateTo({
  63. url: '/pages/auth/login'
  64. });
  65. } // 销毁HLS播放器(如果使用了hls.js)
  66. if (this.hlsPlayer) {
  67. this.hlsPlayer.destroy();
  68. this.hlsPlayer = null;
  69. }
  70. },
  71. onUnload() {
  72. this.list.forEach(item => {
  73. this.$set(item, 'isDestroyed', true); // 标记已销毁,阻止播放
  74. this.pauseAndCleanVideo(item); // 统一清理
  75. });
  76. },
  77. methods: { // 处理单个视频项,适配m3u8流
  78. initVideoPlayer(item) {
  79. // 基础校验:无地址/无ID,不初始化
  80. const isLive = item.liveType === 1;
  81. const hasValidUrl = isLive ? (item.flvHlsUrl?.includes('.m3u8')) : !!item.videoUrl;
  82. if (!hasValidUrl || !item.liveId) return;
  83. const videoId = `myVideo_${item.liveId}`;
  84. console.log(`[视口监听] 初始化:${videoId}`);
  85. // 1. 创建交叉观察器(监听是否进入视口)
  86. const observer = uni.createIntersectionObserver(this, {
  87. thresholds: [0.2], // 20% 进入视口触发
  88. observeAll: false
  89. });
  90. // 2. 监听视口状态,触发按需播放/暂停
  91. observer.relativeTo('.list').observe(`#${videoId}`, (res) => {
  92. const isInView = res.intersectionRatio > 0; // 是否在视口内
  93. if (isInView) {
  94. console.log(`[视口监听] 进入视口,触发按需播放:${videoId}`);
  95. // 标记为在视口,避免重复调用
  96. this.$set(item, 'isInView', true);
  97. // 调用统一播放入口,实现按需播放
  98. this.setupVideoPlayback(item);
  99. } else {
  100. console.log(`[视口监听] 离开视口,暂停播放:${videoId}`);
  101. // 标记为离开视口
  102. this.$set(item, 'isInView', false);
  103. // 暂停播放并清理资源
  104. this.pauseAndCleanVideo(item);
  105. }
  106. });
  107. // 3. 存储观察器到 item,便于后续销毁
  108. this.$set(item, 'observer', observer);
  109. // 标记未销毁,避免 setupVideoPlayback 跳过
  110. this.$set(item, 'isDestroyed', false);
  111. },
  112. pauseAndCleanVideo(item) {
  113. // 1. 暂停录播/小程序直播(通过 videoContext)
  114. if (item.videoContext) {
  115. item.videoContext.pause();
  116. this.$set(item, 'videoContext', null); // 清空上下文,避免重复调用
  117. }
  118. // 2. 销毁 HLS 实例(直播 H5 场景)
  119. if (item.hlsInstance) {
  120. item.hlsInstance.destroy();
  121. this.$set(item, 'hlsInstance', null);
  122. }
  123. // 3. 销毁视口监听(避免列表刷新后仍监听)
  124. if (item.observer) {
  125. item.observer.disconnect();
  126. this.$set(item, 'observer', null);
  127. }
  128. // 4. 标记为已销毁(防止重复初始化)
  129. this.$set(item, 'isDestroyed', true);
  130. },
  131. setupVideoPlayback(item) {
  132. // 1. 基础校验:先判断类型,再校验 URL(避免无效执行)
  133. const isLive = item.liveType === this.LIVE_TYPE.LIVE;
  134. const videoUrl = isLive ? item.flvHlsUrl : item.videoUrl;
  135. // 无效场景直接返回(录播无URL、直播无m3u8、已销毁)
  136. if (!videoUrl || item.isDestroyed || (isLive && !videoUrl.includes('.m3u8'))) {
  137. console.warn(`[播放校验] 跳过无效播放:`, item.liveId, '类型:', isLive ? '直播' : '录播');
  138. return;
  139. }
  140. const videoId = `myVideo_${item.liveId}`;
  141. console.log(`[按需播放] 触发:${isLive ? '直播' : '录播'},ID:${videoId}`);
  142. // 2. 先清理旧资源(避免重复初始化)
  143. this.pauseAndCleanVideo(item);
  144. // 3. 直播逻辑:仅 H5 平台用 HLS,其他平台(小程序/App)用原生 video
  145. if (isLive) {
  146. // #ifdef H5
  147. // 直播 + H5 + m3u8:初始化 HLS
  148. this.initHlsPlayer(item);
  149. // #endif
  150. // #ifndef H5
  151. // 小程序/App 直播:直接用原生 videoContext(无需 HLS,平台自带 m3u8 支持)
  152. this.initNativeVideo(item, videoId, isLive);
  153. // #endif
  154. } else {
  155. // 4. 录播逻辑:所有平台统一用原生 videoContext(无需 HLS)
  156. this.initNativeVideo(item, videoId, isLive);
  157. }
  158. },
  159. /**
  160. * 初始化原生视频(录播 + 小程序/App 直播)
  161. * @param {Object} item - 列表项数据
  162. * @param {String} videoId - 视频DOM ID
  163. * @param {Boolean} isLive - 是否为直播
  164. */
  165. initNativeVideo(item, videoId, isLive) {
  166. uni.createSelectorQuery().in(this)
  167. .select(`#${videoId}`)
  168. .fields({
  169. context: true
  170. }) // 仅获取视频上下文
  171. .exec((res) => {
  172. if (!res || !res[0]?.context) {
  173. console.warn(`[原生视频] 未找到上下文:${videoId}`);
  174. return;
  175. }
  176. const videoContext = res[0].context;
  177. this.$set(item, 'videoContext', videoContext); // 存储上下文,用于后续暂停
  178. // 直播:直接播放(需用户交互,失败时提示)
  179. if (isLive) {
  180. videoContext.play().catch(err => {
  181. console.log(`[直播播放] 自动播放失败(需点击):${item.liveId}`, err);
  182. // 可选:添加「播放按钮」,点击后调用 videoContext.play()
  183. });
  184. }
  185. // 录播:支持断点续播(从上次进度开始)
  186. else {
  187. const startDuration = item.nowDuration || 0; // 从接口获取的上次播放进度
  188. videoContext.seek(startDuration); // 跳转到指定进度
  189. videoContext.play().catch(err => {
  190. console.log(`[录播播放] 失败:${item.liveId}`, err);
  191. });
  192. }
  193. });
  194. },
  195. // 初始化HLS播放器
  196. initHlsPlayer(item) {
  197. // 兜底校验:确保是直播且有 m3u8 地址
  198. if (item.liveType !== this.LIVE_TYPE.LIVE || !item.flvHlsUrl?.includes('.m3u8')) {
  199. console.error(`[HLS 错误] 非直播场景调用:${item.liveId}`);
  200. return;
  201. }
  202. const videoUrl = item.flvHlsUrl;
  203. if (!Hls.isSupported()) {
  204. console.error(`[HLS 错误] 浏览器不支持 HLS:${item.liveId}`);
  205. return;
  206. }
  207. // 获取 H5 视频 DOM 节点
  208. const video = document.getElementById(`myVideo_${item.liveId}`);
  209. if (!video) {
  210. console.warn(`[HLS 错误] 未找到视频节点:myVideo_${item.liveId}`);
  211. return;
  212. }
  213. // 初始化 HLS(低延迟配置,适合直播)
  214. const hls = new Hls({
  215. enableWorker: true,
  216. lowLatencyMode: true, // 直播低延迟关键配置
  217. debug: false,
  218. maxBufferLength: 3, // 减少缓冲,降低延迟(根据需求调整)
  219. maxMaxBufferLength: 10
  220. });
  221. // 绑定视频节点 + 加载流
  222. hls.attachMedia(video);
  223. hls.loadSource(videoUrl);
  224. // 解析成功后播放
  225. hls.on(Hls.Events.MANIFEST_PARSED, () => {
  226. console.log(`[HLS 直播] 解析成功:${item.liveId}`);
  227. video.play().catch(err => {
  228. console.log(`[HLS 播放] 自动播放失败(需点击):${item.liveId}`, err);
  229. });
  230. });
  231. // 错误处理(自动恢复或销毁)
  232. hls.on(Hls.Events.ERROR, (event, data) => {
  233. console.error(`[HLS 错误] ${item.liveId}:`, data);
  234. if (data.fatal) {
  235. switch (data.type) {
  236. case Hls.ErrorTypes.NETWORK_ERROR:
  237. hls.startLoad(); // 网络错误:重试加载
  238. break;
  239. default:
  240. hls.destroy(); // 其他致命错误:销毁实例
  241. this.$set(item, 'hlsInstance', null);
  242. break;
  243. }
  244. }
  245. });
  246. // 存储 HLS 实例到 item(便于后续销毁)
  247. this.$set(item, 'hlsInstance', hls);
  248. },
  249. // mescroll初始化
  250. mescrollInit(mescroll) {
  251. this.mescroll = mescroll;
  252. },
  253. // 下拉刷新回调
  254. downCallback(mescroll) {
  255. // 1. 先清理所有旧列表的视频资源(直播 HLS + 录播上下文)
  256. this.list.forEach(item => {
  257. this.pauseAndCleanVideo(item);
  258. });
  259. // 2. 重置列表和分页(必须在清理后执行)
  260. this.list = [];
  261. mescroll.resetUpScroll(); // 重置上拉加载的页码
  262. // 3. 可选:下拉后自动加载第一页(符合用户预期)
  263. this.mescroll.triggerUpScroll();
  264. },
  265. // 上拉加载回调
  266. upCallback(mescroll) {
  267. const pageNum = mescroll.num;
  268. const pageSize = mescroll.size;
  269. let data = {
  270. pageSize: pageSize,
  271. page: pageNum,
  272. }
  273. liveList(data).then(res => {
  274. if (res.code == 200) {
  275. let curPageData = res.rows || [];
  276. let totalSize = res.total || 0;
  277. if (pageNum === 1) {
  278. this.list = [];
  279. }
  280. // 追加新数据
  281. this.list = this.list.concat(curPageData);
  282. // 关键修改:用 $nextTick 等待DOM渲染完成
  283. this.$nextTick(() => {
  284. curPageData.forEach(item => {
  285. this.initVideoPlayer(item); // 此时视频节点已存在
  286. });
  287. });
  288. mescroll.endBySize(curPageData.length, totalSize);
  289. } else {
  290. mescroll.endErr();
  291. uni.showToast({
  292. title: res.msg,
  293. icon: 'none'
  294. });
  295. }
  296. }).catch(err => {
  297. mescroll.endErr();
  298. console.log("请求异常:" + JSON.stringify(err));
  299. });
  300. },
  301. goLive(item) {
  302. this.liveId = item.liveId
  303. console.log("要传的liveId", this.liveId)
  304. uni.navigateTo({
  305. url: `/pages/home/living?liveId=${item.liveId}&immediate=true`
  306. });
  307. },
  308. // getList() {
  309. // const data = {
  310. // page: 1,
  311. // page_size: 10,
  312. // };
  313. // uni.showLoading({
  314. // title: "处理中..."
  315. // });
  316. // liveList(data)
  317. // .then(res => {
  318. // if (res.code == 200) {
  319. // this.list = res.rows; // 直接赋值给 this.list
  320. // console.log("list>>", this.list); // ✅ 打印已定义的 this.list
  321. // } else {
  322. // uni.showToast({
  323. // title: res.msg,
  324. // icon: 'none'
  325. // });
  326. // }
  327. // })
  328. // .catch(rej => {
  329. // console.log("请求失败:", JSON.stringify(rej));
  330. // })
  331. // .finally(() => {
  332. // uni.hideLoading();
  333. // });
  334. // }
  335. }
  336. }
  337. </script>
  338. <style lang="scss" scoped>
  339. .content {
  340. background-color: #111;
  341. min-height: 100vh;
  342. padding: 24rpx;
  343. .list {
  344. display: flex;
  345. justify-content: space-between;
  346. flex-wrap: wrap;
  347. .list-item {
  348. border-radius: 16rpx;
  349. width: 340rpx;
  350. height: 600rpx;
  351. background-color: #0d0d0d;
  352. margin-right: 10rpx;
  353. margin-bottom: 24rpx;
  354. overflow: hidden;
  355. position: relative;
  356. video {
  357. width: 100%;
  358. height: 100%;
  359. /* 视频填满列表项,确保可视区域判断准确 */
  360. }
  361. .info {
  362. position: absolute;
  363. left: 20rpx;
  364. bottom: 14rpx;
  365. color: #ffffff;
  366. }
  367. image {
  368. width: 100%;
  369. height: 100%;
  370. }
  371. }
  372. }
  373. }
  374. </style>