DomVideoPlayer.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. <!-- eslint-disable -->
  2. <template>
  3. <view
  4. class="player-wrapper"
  5. :id="videoWrapperId"
  6. :parentId="id"
  7. :randomNum="randomNum"
  8. :change:randomNum="domVideoPlayer.randomNumChange"
  9. :viewportProps="viewportProps"
  10. :change:viewportProps="domVideoPlayer.viewportChange"
  11. :videoSrc="videoSrc"
  12. :change:videoSrc="domVideoPlayer.initVideoPlayer"
  13. :command="eventCommand"
  14. :change:command="domVideoPlayer.triggerCommand"
  15. :func="renderFunc"
  16. :change:func="domVideoPlayer.triggerFunc"
  17. />
  18. </template>
  19. <script>
  20. export default {
  21. props: {
  22. src: {
  23. type: String,
  24. default: ''
  25. },
  26. autoplay: {
  27. type: Boolean,
  28. default: false
  29. },
  30. loop: {
  31. type: Boolean,
  32. default: false
  33. },
  34. controls: {
  35. type: Boolean,
  36. default: false
  37. },
  38. objectFit: {
  39. type: String,
  40. default: 'contain'
  41. },
  42. muted: {
  43. type: Boolean,
  44. default: false
  45. },
  46. playbackRate: {
  47. type: Number,
  48. default: 1
  49. },
  50. isLoading: {
  51. type: Boolean,
  52. default: false
  53. },
  54. poster: {
  55. type: String,
  56. default: ''
  57. },
  58. id: {
  59. type: String,
  60. default: ''
  61. }
  62. },
  63. data() {
  64. return {
  65. randomNum: Math.floor(Math.random() * 100000000),
  66. videoSrc: '',
  67. // 父组件向子组件传递的事件指令(video的原生事件)
  68. eventCommand: null,
  69. // 父组件传递过来的,对 renderjs 层的函数执行(对视频控制的自定义事件)
  70. renderFunc: {
  71. name: null,
  72. params: null
  73. },
  74. // 提供给父组件进行获取的视频属性
  75. currentTime: 0,
  76. duration: 0,
  77. playing: false
  78. }
  79. },
  80. watch: {
  81. // 监听视频资源地址更新
  82. src: {
  83. handler(val) {
  84. if (!val) return
  85. setTimeout(() => {
  86. this.videoSrc = val
  87. }, 0)
  88. },
  89. immediate: true
  90. }
  91. },
  92. computed: {
  93. videoWrapperId() {
  94. return `video-wrapper-${this.randomNum}`
  95. },
  96. // 聚合视图层的所有数据变化,传给renderjs的渲染层
  97. viewportProps() {
  98. return {
  99. autoplay: this.autoplay,
  100. muted: this.muted,
  101. controls: this.controls,
  102. loop: this.loop,
  103. objectFit: this.objectFit,
  104. poster: this.poster,
  105. isLoading: this.isLoading,
  106. playbackRate: this.playbackRate
  107. }
  108. }
  109. },
  110. // 方法
  111. methods: {
  112. // 传递事件指令给父组件
  113. eventEmit({ event, data }) {
  114. this.$emit(event, data)
  115. },
  116. // 修改view视图层的data数据
  117. setViewData({ key, value }) {
  118. key && this.$set(this, key, value)
  119. },
  120. // 重置事件指令
  121. resetEventCommand() {
  122. this.eventCommand = null
  123. },
  124. // 播放指令
  125. play() {
  126. this.eventCommand = 'play'
  127. },
  128. // 暂停指令
  129. pause() {
  130. this.eventCommand = 'pause'
  131. },
  132. // 用户交互触发播放(特别适用于iOS)
  133. playWithUserGesture() {
  134. // 确保在用户交互的上下文中调用
  135. this.eventCommand = 'play'
  136. },
  137. // 重置自定义函数指令
  138. resetFunc() {
  139. this.renderFunc = {
  140. name: null,
  141. params: null
  142. }
  143. },
  144. // 自定义函数 - 移除视频
  145. remove(params) {
  146. this.renderFunc = {
  147. name: 'removeHandler',
  148. params
  149. }
  150. },
  151. // 自定义函数 - 全屏播放
  152. fullScreen(params) {
  153. this.renderFunc = {
  154. name: 'fullScreenHandler',
  155. params
  156. }
  157. },
  158. // 自定义函数 - 跳转到指定时间点
  159. toSeek(sec, isDelay = false) {
  160. this.renderFunc = {
  161. name: 'toSeekHandler',
  162. params: { sec, isDelay }
  163. }
  164. }
  165. }
  166. }
  167. </script>
  168. <script module="domVideoPlayer" lang="renderjs">
  169. const PLAYER_ID = 'DOM_VIDEO_PLAYER'
  170. export default {
  171. data() {
  172. return {
  173. num: '',
  174. videoEl: null,
  175. loadingEl: null,
  176. // 延迟生效的函数
  177. delayFunc: null,
  178. renderProps: {}
  179. }
  180. },
  181. computed: {
  182. playerId() {
  183. return `${PLAYER_ID}_${this.num}`
  184. },
  185. wrapperId() {
  186. return `video-wrapper-${this.num}`
  187. }
  188. },
  189. methods: {
  190. isApple() {
  191. const ua = navigator.userAgent.toLowerCase()
  192. return ua.indexOf('iphone') !== -1 || ua.indexOf('ipad') !== -1
  193. },
  194. async initVideoPlayer(src) {
  195. this.delayFunc = null
  196. await this.$nextTick()
  197. if (!src) return
  198. if (this.videoEl) {
  199. // 切换视频源
  200. if (!this.isApple() && this.loadingEl) {
  201. this.loadingEl.style.display = 'block'
  202. }
  203. this.videoEl.src = src
  204. return
  205. }
  206. const videoEl = document.createElement('video')
  207. this.videoEl = videoEl
  208. // 开始监听视频相关事件
  209. this.listenVideoEvent()
  210. const { autoplay, muted, controls, loop, playbackRate, objectFit, poster } = this.renderProps
  211. videoEl.src = src
  212. videoEl.autoplay = autoplay && muted // iOS只允许静音状态下自动播放
  213. videoEl.controls = controls
  214. videoEl.loop = loop
  215. videoEl.muted = muted
  216. videoEl.playbackRate = playbackRate
  217. videoEl.id = this.playerId
  218. // iOS兼容性属性设置
  219. if (this.isApple()) {
  220. videoEl.setAttribute('playsinline', true)
  221. videoEl.setAttribute('webkit-playsinline', true)
  222. videoEl.setAttribute('x5-playsinline', true)
  223. // iOS需要预加载元数据
  224. videoEl.setAttribute('preload', 'metadata')
  225. } else {
  226. videoEl.setAttribute('preload', 'auto')
  227. }
  228. videoEl.setAttribute('crossorigin', 'anonymous')
  229. videoEl.setAttribute('controlslist', 'nodownload')
  230. videoEl.setAttribute('disablePictureInPicture', true)
  231. videoEl.style.objectFit = objectFit
  232. poster && (videoEl.poster = poster)
  233. videoEl.style.width = '100%'
  234. videoEl.style.height = '100%'
  235. // 插入视频元素
  236. const playerWrapper = document.getElementById(this.wrapperId)
  237. playerWrapper.insertBefore(videoEl, playerWrapper.firstChild)
  238. // 插入loading 元素(遮挡安卓的默认加载过程中的黑色播放按钮)
  239. this.createLoading()
  240. },
  241. // 创建 loading
  242. createLoading() {
  243. const { isLoading } = this.renderProps
  244. if (!this.isApple() && isLoading) {
  245. const loadingEl = document.createElement('div')
  246. this.loadingEl = loadingEl
  247. loadingEl.className = 'loading-wrapper'
  248. loadingEl.style.position = 'absolute'
  249. loadingEl.style.top = '0'
  250. loadingEl.style.left = '0'
  251. loadingEl.style.zIndex = '1'
  252. loadingEl.style.width = '100%'
  253. loadingEl.style.height = '100%'
  254. loadingEl.style.backgroundColor = 'black'
  255. document.getElementById(this.wrapperId).appendChild(loadingEl)
  256. // 创建 loading 动画
  257. const animationEl = document.createElement('div')
  258. animationEl.className = 'loading'
  259. animationEl.style.zIndex = '2'
  260. animationEl.style.position = 'absolute'
  261. animationEl.style.top = '50%'
  262. animationEl.style.left = '50%'
  263. animationEl.style.marginTop = '-15px'
  264. animationEl.style.marginLeft = '-15px'
  265. animationEl.style.width = '30px'
  266. animationEl.style.height = '30px'
  267. animationEl.style.border = '2px solid #FFF'
  268. animationEl.style.borderTopColor = 'rgba(255, 255, 255, 0.2)'
  269. animationEl.style.borderRightColor = 'rgba(255, 255, 255, 0.2)'
  270. animationEl.style.borderBottomColor = 'rgba(255, 255, 255, 0.2)'
  271. animationEl.style.borderRadius = '100%'
  272. animationEl.style.animation = 'circle infinite 0.75s linear'
  273. loadingEl.appendChild(animationEl)
  274. // 创建 loading 动画所需的 keyframes
  275. const style = document.createElement('style')
  276. const keyframes = `
  277. @keyframes circle {
  278. 0% {
  279. transform: rotate(0);
  280. }
  281. 100% {
  282. transform: rotate(360deg);
  283. }
  284. }
  285. `
  286. style.type = 'text/css'
  287. if (style.styleSheet) {
  288. style.styleSheet.cssText = keyframes
  289. } else {
  290. style.appendChild(document.createTextNode(keyframes))
  291. }
  292. document.head.appendChild(style)
  293. }
  294. },
  295. // 监听视频相关事件
  296. listenVideoEvent() {
  297. // 播放事件监听
  298. const playHandler = () => {
  299. this.$ownerInstance.callMethod('eventEmit', { event: 'play' })
  300. this.$ownerInstance.callMethod('setViewData', {
  301. key: 'playing',
  302. value: true
  303. })
  304. if (this.loadingEl) {
  305. this.loadingEl.style.display = 'none'
  306. }
  307. }
  308. this.videoEl.removeEventListener('play', playHandler)
  309. this.videoEl.addEventListener('play', playHandler)
  310. // 暂停事件监听
  311. const pauseHandler = () => {
  312. this.$ownerInstance.callMethod('eventEmit', { event: 'pause' })
  313. this.$ownerInstance.callMethod('setViewData', {
  314. key: 'playing',
  315. value: false
  316. })
  317. }
  318. this.videoEl.removeEventListener('pause', pauseHandler)
  319. this.videoEl.addEventListener('pause', pauseHandler)
  320. // 结束事件监听
  321. const endedHandler = () => {
  322. this.$ownerInstance.callMethod('eventEmit', { event: 'ended' })
  323. this.$ownerInstance.callMethod('resetEventCommand')
  324. }
  325. this.videoEl.removeEventListener('ended', endedHandler)
  326. this.videoEl.addEventListener('ended', endedHandler)
  327. // 加载完成事件监听
  328. const canPlayHandler = () => {
  329. this.$ownerInstance.callMethod('eventEmit', { event: 'canplay' })
  330. this.execDelayFunc()
  331. }
  332. this.videoEl.removeEventListener('canplay', canPlayHandler)
  333. this.videoEl.addEventListener('canplay', canPlayHandler)
  334. // 加载失败事件监听
  335. const errorHandler = (e) => {
  336. if (this.loadingEl) {
  337. this.loadingEl.style.display = 'block'
  338. }
  339. this.$ownerInstance.callMethod('eventEmit', { event: 'error' })
  340. }
  341. this.videoEl.removeEventListener('error', errorHandler)
  342. this.videoEl.addEventListener('error', errorHandler)
  343. // loadedmetadata 事件监听
  344. const loadedMetadataHandler = () => {
  345. this.$ownerInstance.callMethod('eventEmit', { event: 'loadedmetadata' })
  346. // 获取视频的长度
  347. const duration = this.videoEl.duration
  348. this.$ownerInstance.callMethod('eventEmit', {
  349. event: 'durationchange',
  350. data: duration
  351. })
  352. this.$ownerInstance.callMethod('setViewData', {
  353. key: 'duration',
  354. value: duration
  355. })
  356. // 加载首帧视频 模拟出封面图
  357. this.loadFirstFrame()
  358. }
  359. this.videoEl.removeEventListener('loadedmetadata', loadedMetadataHandler)
  360. this.videoEl.addEventListener('loadedmetadata', loadedMetadataHandler)
  361. // 播放进度监听
  362. const timeupdateHandler = (e) => {
  363. const currentTime = e.target.currentTime
  364. this.$ownerInstance.callMethod('eventEmit', {
  365. event: 'timeupdate',
  366. data: currentTime
  367. })
  368. this.$ownerInstance.callMethod('setViewData', {
  369. key: 'currentTime',
  370. value: currentTime
  371. })
  372. }
  373. this.videoEl.removeEventListener('timeupdate', timeupdateHandler)
  374. this.videoEl.addEventListener('timeupdate', timeupdateHandler)
  375. // 倍速播放监听
  376. const ratechangeHandler = (e) => {
  377. const playbackRate = e.target.playbackRate
  378. this.$ownerInstance.callMethod('eventEmit', {
  379. event: 'ratechange',
  380. data: playbackRate
  381. })
  382. }
  383. this.videoEl.removeEventListener('ratechange', ratechangeHandler)
  384. this.videoEl.addEventListener('ratechange', ratechangeHandler)
  385. // 全屏事件监听
  386. if (this.isApple()) {
  387. const webkitbeginfullscreenHandler = () => {
  388. const presentationMode = this.videoEl.webkitPresentationMode
  389. let isFullScreen = null
  390. if (presentationMode === 'fullscreen') {
  391. isFullScreen = true
  392. } else {
  393. isFullScreen = false
  394. }
  395. this.$ownerInstance.callMethod('eventEmit', {
  396. event: 'fullscreenchange',
  397. data: isFullScreen
  398. })
  399. }
  400. this.videoEl.removeEventListener('webkitpresentationmodechanged', webkitbeginfullscreenHandler)
  401. this.videoEl.addEventListener('webkitpresentationmodechanged', webkitbeginfullscreenHandler)
  402. } else {
  403. const fullscreenchangeHandler = () => {
  404. let isFullScreen = null
  405. if (document.fullscreenElement) {
  406. isFullScreen = true
  407. } else {
  408. isFullScreen = false
  409. }
  410. this.$ownerInstance.callMethod('eventEmit', {
  411. event: 'fullscreenchange',
  412. data: isFullScreen
  413. })
  414. }
  415. document.removeEventListener('fullscreenchange', fullscreenchangeHandler)
  416. document.addEventListener('fullscreenchange', fullscreenchangeHandler)
  417. }
  418. },
  419. // 加载首帧视频,模拟出封面图
  420. loadFirstFrame() {
  421. let { autoplay, muted } = this.renderProps
  422. if (this.isApple()) {
  423. // iOS严格限制:只有在用户交互或静音状态下才能自动播放
  424. if (autoplay && muted) {
  425. // 添加错误处理
  426. const playPromise = this.videoEl.play()
  427. if (playPromise !== undefined) {
  428. playPromise.catch(error => {
  429. console.warn('iOS autoplay failed:', error)
  430. // 如果自动播放失败,不做任何操作,等待用户交互
  431. })
  432. }
  433. } else if (!autoplay) {
  434. // 如果不是自动播放,尝试加载第一帧但不播放
  435. this.videoEl.load()
  436. }
  437. } else {
  438. // optimize: timeout 延迟调用是为了规避控制台的`https://goo.gl/LdLk22`这个报错
  439. /**
  440. * 原因:chromium 内核中,谷歌协议规定,视频不允许在非静音状态下进行自动播放
  441. * 解决:在自动播放时,先将视频静音,然后延迟调用 play 方法,播放视频
  442. * 说明:iOS 的 Safari 内核不会有这个,仅在 Android 设备出现,即使有这个报错也不影响的,所以不介意控制台报错的话是可以删掉这个 timeout 的
  443. */
  444. this.videoEl.muted = true
  445. setTimeout(() => {
  446. this.videoEl.play()
  447. this.videoEl.muted = muted
  448. if (!autoplay) {
  449. setTimeout(() => {
  450. this.videoEl.pause()
  451. }, 100)
  452. }
  453. }, 10)
  454. }
  455. },
  456. triggerCommand(eventType) {
  457. if (eventType) {
  458. this.$ownerInstance.callMethod('resetEventCommand')
  459. if (this.videoEl) {
  460. // 为iOS添加Promise错误处理
  461. if (eventType === 'play') {
  462. const playPromise = this.videoEl.play()
  463. if (playPromise !== undefined) {
  464. playPromise.catch(error => {
  465. console.warn('Video play failed:', error)
  466. this.$ownerInstance.callMethod('eventEmit', {
  467. event: 'error',
  468. data: { type: 'play_failed', message: error.message }
  469. })
  470. })
  471. }
  472. } else {
  473. this.videoEl[eventType]()
  474. }
  475. }
  476. }
  477. },
  478. triggerFunc(func) {
  479. const { name, params } = func || {}
  480. if (name) {
  481. this[name](params)
  482. this.$ownerInstance.callMethod('resetFunc')
  483. }
  484. },
  485. removeHandler() {
  486. if (this.videoEl) {
  487. this.videoEl.pause()
  488. this.videoEl.src = ''
  489. this.$ownerInstance.callMethod('setViewData', {
  490. key: 'videoSrc',
  491. value: ''
  492. })
  493. this.videoEl.load()
  494. }
  495. },
  496. fullScreenHandler() {
  497. if (this.isApple()) {
  498. this.videoEl.webkitEnterFullscreen()
  499. } else {
  500. this.videoEl.requestFullscreen()
  501. }
  502. },
  503. toSeekHandler({ sec, isDelay }) {
  504. const func = () => {
  505. if (this.videoEl) {
  506. this.videoEl.currentTime = sec
  507. }
  508. }
  509. // 延迟执行
  510. if (isDelay) {
  511. this.delayFunc = func
  512. } else {
  513. func()
  514. }
  515. },
  516. // 执行延迟函数
  517. execDelayFunc() {
  518. this.delayFunc && this.delayFunc()
  519. this.delayFunc = null
  520. },
  521. viewportChange(props) {
  522. this.renderProps = props
  523. const { autoplay, muted, controls, loop, playbackRate } = props
  524. if (this.videoEl) {
  525. this.videoEl.autoplay = autoplay
  526. this.videoEl.controls = controls
  527. this.videoEl.loop = loop
  528. this.videoEl.muted = muted
  529. this.videoEl.playbackRate = playbackRate
  530. }
  531. },
  532. randomNumChange(val) {
  533. this.num = val
  534. }
  535. }
  536. }
  537. </script>
  538. <style scoped>
  539. .player-wrapper {
  540. overflow: hidden;
  541. height: 100%;
  542. padding: 0;
  543. position: relative;
  544. }
  545. </style>