index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. import {
  2. defineComponent,
  3. nextTick,
  4. onMounted,
  5. reactive,
  6. render,
  7. toRefs,
  8. watch
  9. } from 'vue';
  10. import 'plyr/dist/plyr.css';
  11. import Plyr from 'plyr';
  12. import styles from './index.module.less';
  13. import {
  14. iconVideoBg,
  15. iconLoop,
  16. iconLoopActive,
  17. iconPlay,
  18. iconPause,
  19. iconSpeed
  20. } from '../../image/icons.json';
  21. import { Popup, Slider } from 'vant';
  22. import { useElementBounding } from '@vueuse/core';
  23. export default defineComponent({
  24. name: 'video-play',
  25. props: {
  26. item: {
  27. type: Object,
  28. default: () => {
  29. return {};
  30. }
  31. },
  32. activeModel: {
  33. type: Boolean,
  34. default: true
  35. }
  36. },
  37. emits: ['play', 'pause', 'ended', 'close'],
  38. setup(props, { emit, expose }) {
  39. const { item } = toRefs(props);
  40. const data = reactive({
  41. videoContianerRef: null as unknown as HTMLAudioElement,
  42. videoState: 'pause' as 'init' | 'play' | 'pause',
  43. animationState: 'start' as 'start' | 'end',
  44. videoItem: null as unknown as Plyr,
  45. speedControl: false,
  46. speedStyle: {
  47. left: '1px'
  48. },
  49. defaultSpeed: 1 // 默认速度
  50. });
  51. const controlID = 'v' + Date.now() + Math.floor(Math.random() * 100);
  52. const playBtnId = 'play' + Date.now() + Math.floor(Math.random() * 100);
  53. const loopBtnId = 'loop' + Date.now() + Math.floor(Math.random() * 100);
  54. const speedBtnId = 'speed' + Date.now() + Math.floor(Math.random() * 100);
  55. const speedPopoverId =
  56. 'popover' + Date.now() + Math.floor(Math.random() * 100);
  57. const togglePlay = (e: Event) => {
  58. e.stopPropagation();
  59. data.speedControl = false;
  60. if (!data.videoContianerRef.paused) {
  61. data.videoItem?.pause();
  62. } else {
  63. data.videoContianerRef?.play();
  64. }
  65. };
  66. const toggleLoop = () => {
  67. data.speedControl = false;
  68. const loopBtn = document.getElementById(loopBtnId);
  69. if (!loopBtn || !data.videoItem) return;
  70. const isLoop = data.videoItem.loop;
  71. if (isLoop) {
  72. loopBtn.classList.remove(styles.active);
  73. } else {
  74. loopBtn.classList.add(styles.active);
  75. }
  76. data.videoItem.loop = !data.videoItem.loop;
  77. };
  78. const onDefault = () => {
  79. document
  80. .getElementById(controlID)
  81. ?.addEventListener('click', (e: Event) => {
  82. e.stopPropagation();
  83. data.speedControl = false;
  84. if (data.videoContianerRef.paused) return;
  85. emit('close');
  86. });
  87. document.getElementById(controlID)?.addEventListener('touchmove', () => {
  88. data.speedControl = false;
  89. if (data.videoContianerRef.paused) return;
  90. emit('close');
  91. });
  92. document.getElementById(playBtnId)?.addEventListener('click', togglePlay);
  93. document.getElementById(loopBtnId)?.addEventListener('click', toggleLoop);
  94. document.getElementById(speedBtnId)?.addEventListener('click', e => {
  95. e.stopPropagation();
  96. data.speedControl = !data.speedControl;
  97. });
  98. setName();
  99. };
  100. const setName = () => {
  101. const nameEl = document.getElementById('videoItemName');
  102. if (nameEl) {
  103. nameEl.innerHTML = item.value.name || '';
  104. }
  105. };
  106. const changePlayBtn = (code: string) => {
  107. const playBtn = document.getElementById(playBtnId);
  108. if (!playBtn) return;
  109. if (code == 'play') {
  110. playBtn.classList.remove(styles.btnPause);
  111. playBtn.classList.add(styles.btnPlay);
  112. } else {
  113. playBtn.classList.remove(styles.btnPlay);
  114. playBtn.classList.add(styles.btnPause);
  115. }
  116. };
  117. const controls = `
  118. <div id="${controlID}" class="plyr__controls bottomFixed ${styles.controls}">
  119. <div class="${styles.time}">
  120. <div class="plyr__time plyr__time--current" aria-label="Current time">00:00</div>
  121. <div class="plyr__time plyr__time--duration" aria-label="Duration">00:00</div>
  122. </div>
  123. <div class="${styles.slider}">
  124. <div class="plyr__progress">
  125. <input data-plyr="seek" type="range" min="0" max="100" step="0.01" value="0" aria-label="Seek">
  126. <progress class="plyr__progress__buffer" min="0" max="100" value="0">% buffered</progress>
  127. <span role="tooltip" class="plyr__tooltip">00:00</span>
  128. </div>
  129. </div>
  130. <div class="${styles.actions}">
  131. <div class="${styles.actionWrap}">
  132. <button id="${playBtnId}" class="${styles.actionBtn}">
  133. <div class="van-loading van-loading--circular" aria-live="polite" aria-busy="true"><span class="van-loading__spinner van-loading__spinner--circular" style="color: rgb(255, 255, 255);"><svg class="van-loading__circular" viewBox="25 25 50 50"><circle cx="50" cy="50" r="20" fill="none"></circle></svg></span></div>
  134. <img class="${styles.playIcon}" src="${iconPlay}" />
  135. <img class="${styles.playIcon}" src="${iconPause}" />
  136. </button>
  137. <button id="${loopBtnId}" class="${styles.actionBtn} ${styles.loopBtn}" style="margin:0 2px;">
  138. <img class="loop" src="${iconLoop}" />
  139. <img class="loopActive" src="${iconLoopActive}" />
  140. </button>
  141. <div style="position: relative">
  142. <button id="${speedBtnId}" class="${styles.actionBtn} ${styles.speedBtn}">
  143. <img class="loop" src="${iconSpeed}" />
  144. </button>
  145. </div>
  146. </div>
  147. <div id="videoItemName"></div>
  148. </div>
  149. </div>`;
  150. onMounted(() => {
  151. data.videoItem = new Plyr(data.videoContianerRef, {
  152. autoplay: true,
  153. controls: controls,
  154. ratio: '16:9', // 强制所有视频的纵横比
  155. hideControls: false, // 在 2 秒没有鼠标或焦点移动、控制元素模糊(制表符退出)、播放开始或进入全屏时自动隐藏视频控件。只要移动鼠标、聚焦控制元素或暂停播放,控件就会立即重新出现。
  156. clickToPlay: false, // 单击(或点击)视频容器将切换播放/暂停
  157. fullscreen: { enabled: false, fallback: false, iosNative: false } // 不适用全屏
  158. });
  159. nextTick(() => {
  160. onDefault();
  161. });
  162. });
  163. const toggleHideControl = (isShow: boolean) => {
  164. data.videoItem?.toggleControls(isShow);
  165. if (!isShow) {
  166. data.speedControl = isShow;
  167. }
  168. };
  169. watch(
  170. () => props.activeModel,
  171. () => {
  172. toggleHideControl(props.activeModel);
  173. }
  174. );
  175. watch(
  176. () => props.item,
  177. () => {
  178. setName();
  179. // 设置视屏播放器的默认速度
  180. if (data.videoItem) data.videoItem.speed = data.defaultSpeed || 1;
  181. // 切换的时候隐藏
  182. data.speedControl = false;
  183. }
  184. );
  185. let videoTimer = null as any;
  186. const handlePlayVideo = () => {
  187. clearTimeout(videoTimer);
  188. nextTick(() => {
  189. data.videoContianerRef.play().catch(err => {
  190. console.log('🚀 ~ err:', err);
  191. videoTimer = setTimeout(() => {
  192. if (err?.message?.includes('play()')) {
  193. emit('play');
  194. }
  195. handlePlayVideo();
  196. }, 1000);
  197. });
  198. });
  199. };
  200. let videoErrorTimer = null as any;
  201. let videoErrorCount = 0;
  202. const handleErrorVideo = () => {
  203. if (videoErrorCount > 5) {
  204. return;
  205. }
  206. clearTimeout(videoErrorTimer);
  207. nextTick(() => {
  208. videoErrorTimer = setTimeout(() => {
  209. data.videoContianerRef.src = props.item?.content;
  210. emit('play');
  211. data.videoContianerRef.load();
  212. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  213. handleErrorVideo();
  214. }, 1000);
  215. });
  216. videoErrorCount++;
  217. };
  218. const getVideoRef = () => {
  219. return data.videoContianerRef;
  220. };
  221. expose({
  222. getVideoRef
  223. });
  224. return () => (
  225. <div class={styles.videoWrap}>
  226. <video
  227. ref={el =>
  228. (data.videoContianerRef = el as unknown as HTMLAudioElement)
  229. }
  230. class={styles.itemDiv}
  231. src={props.item?.content}
  232. poster={iconVideoBg}
  233. webkit-playsinline
  234. playsinline
  235. x5-video-player-type="h5"
  236. onLoadedmetadata={() => {
  237. data.videoState = 'pause';
  238. changePlayBtn('play');
  239. nextTick(() => {
  240. data.videoContianerRef.currentTime = 0;
  241. nextTick(handlePlayVideo);
  242. });
  243. }}
  244. onPlay={() => {
  245. videoErrorCount = 0;
  246. // console.log('开始播放')
  247. data.videoState = 'play';
  248. changePlayBtn('pause');
  249. emit('close');
  250. emit('play');
  251. clearTimeout(videoErrorTimer);
  252. }}
  253. onPause={() => {
  254. // console.log('暂停播放')
  255. data.videoState = 'pause';
  256. changePlayBtn('play');
  257. emit('pause');
  258. }}
  259. onEnded={() => {
  260. // console.log('播放结束')
  261. data.videoState = 'pause';
  262. changePlayBtn('play');
  263. emit('ended');
  264. }}
  265. onError={handleErrorVideo}></video>
  266. <div
  267. style={{
  268. display: data.speedControl ? 'block' : 'none'
  269. }}>
  270. <div
  271. class={styles.sliderPopup}
  272. onClick={(e: Event) => {
  273. e.stopPropagation();
  274. }}>
  275. <i
  276. class={styles.iconAdd}
  277. onClick={() => {
  278. if (data.defaultSpeed >= 1.5) {
  279. return;
  280. }
  281. if (data.videoItem) {
  282. data.defaultSpeed = (data.defaultSpeed * 10 + 1) / 10;
  283. data.videoItem.speed = data.defaultSpeed;
  284. }
  285. }}></i>
  286. <Slider
  287. min={0.5}
  288. max={1.5}
  289. step={0.1}
  290. v-model={data.defaultSpeed}
  291. vertical
  292. barHeight={5}
  293. reverse
  294. onChange={() => {
  295. if (data.videoItem) {
  296. data.videoItem.speed = data.defaultSpeed;
  297. }
  298. }}>
  299. {{
  300. button: () => (
  301. <div class={styles.sliderPoint}>
  302. {data.defaultSpeed}
  303. <span>x</span>
  304. </div>
  305. )
  306. }}
  307. </Slider>
  308. <i
  309. class={[styles.iconCut]}
  310. onClick={() => {
  311. if (data.defaultSpeed <= 0.5) {
  312. return;
  313. }
  314. if (data.videoItem) {
  315. data.defaultSpeed = (data.defaultSpeed * 10 - 1) / 10;
  316. data.videoItem.speed = data.defaultSpeed;
  317. }
  318. }}></i>
  319. </div>
  320. </div>
  321. </div>
  322. );
  323. }
  324. });