index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. <template>
  2. <div
  3. class="video-player"
  4. :class="{ 'hide-controller': hideController }"
  5. :style="{
  6. width: width * scale + 'px',
  7. height: height * scale + 'px',
  8. transform: `scale(${1 / scale})`
  9. }"
  10. @mousemove="autoHideController()"
  11. @click="autoHideController()"
  12. >
  13. <div class="video-wrap" @click="toggle()">
  14. <div class="load-error" v-if="loadError">视频加载失败</div>
  15. <video
  16. class="video"
  17. ref="videoRef"
  18. :src="src"
  19. :poster="poster"
  20. webkit-playsinline
  21. playsinline
  22. @durationchange="handleDurationchange()"
  23. @timeupdate="handleTimeupdate()"
  24. @ended="handleEnded()"
  25. @progress="handleProgress()"
  26. @play="
  27. () => {
  28. autoHideController()
  29. paused = false
  30. }
  31. "
  32. @pause="autoHideController()"
  33. @error="handleError()"
  34. ></video>
  35. <div class="bezel">
  36. <span class="bezel-icon" :class="{ 'bezel-transition': bezelTransition }" @animationend="bezelTransition = false">
  37. <IconPause v-if="paused" />
  38. <IconPlayOne v-else />
  39. </span>
  40. </div>
  41. </div>
  42. <div class="controller-mask"></div>
  43. <div class="controller">
  44. <div class="icons icons-left">
  45. <div class="icon play-icon" @click="toggle()">
  46. <span class="icon-content">
  47. <IconPlayOne v-if="paused" />
  48. <IconPause v-else />
  49. </span>
  50. </div>
  51. <div class="volume">
  52. <div class="icon volume-icon" @click="toggleVolume()">
  53. <span class="icon-content">
  54. <IconVolumeMute v-if="volume === 0" />
  55. <IconVolumeNotice v-else-if="volume === 1" />
  56. <IconVolumeSmall v-else />
  57. </span>
  58. </div>
  59. <div
  60. class="volume-bar-wrap"
  61. @mousedown="handleMousedownVolumeBar()"
  62. @touchstart="handleMousedownVolumeBar()"
  63. @click="$event => handleClickVolumeBar($event)"
  64. >
  65. <div class="volume-bar" ref="volumeBarRef">
  66. <div class="volume-bar-inner" :style="{ width: volumeBarWidth }">
  67. <span class="thumb"></span>
  68. </div>
  69. </div>
  70. </div>
  71. </div>
  72. <span class="time">
  73. <span class="ptime">{{ ptime }}</span> / <span class="dtime">{{ dtime }}</span>
  74. </span>
  75. </div>
  76. <div class="icons icons-right">
  77. <div class="speed">
  78. <div class="icon speed-icon">
  79. <span class="icon-content" @click="speedMenuVisible = !speedMenuVisible">{{ playbackRate === 1 ? "倍速" : playbackRate + "x" }}</span>
  80. <div class="speed-menu" v-if="speedMenuVisible" @mouseleave="speedMenuVisible = false">
  81. <div
  82. class="speed-menu-item"
  83. :class="{ active: item.value === playbackRate }"
  84. v-for="item in speedOptions"
  85. :key="item.label"
  86. @click="speed(item.value)"
  87. >
  88. {{ item.label }}
  89. </div>
  90. </div>
  91. </div>
  92. </div>
  93. <div class="loop" @click="toggleLoop()">
  94. <div class="icon loop-icon" :class="{ active: loop }">
  95. <span class="icon-content">循环{{ loop ? "开" : "关" }}</span>
  96. </div>
  97. </div>
  98. </div>
  99. <div
  100. class="bar-wrap"
  101. ref="playBarWrap"
  102. @mousedown="handleMousedownPlayBar()"
  103. @touchstart="handleMousedownPlayBar()"
  104. @mousemove="$event => handleMousemovePlayBar($event)"
  105. @mouseenter="playBarTimeVisible = true"
  106. @mouseleave="playBarTimeVisible = false"
  107. >
  108. <div class="bar-time" :class="{ hidden: !playBarTimeVisible }" :style="{ left: playBarTimeLeft }">{{ playBarTime }}</div>
  109. <div class="bar">
  110. <div class="loaded" :style="{ width: loadedBarWidth }"></div>
  111. <div class="played" :style="{ width: playedBarWidth }">
  112. <span class="thumb"></span>
  113. </div>
  114. </div>
  115. </div>
  116. </div>
  117. </div>
  118. </template>
  119. <script lang="ts" setup>
  120. import { computed, ref, watch } from "vue"
  121. import useMSE from "./useMSE"
  122. const props = withDefaults(
  123. defineProps<{
  124. width: number
  125. height: number
  126. src: string
  127. poster?: string
  128. autoplay?: boolean
  129. scale?: number
  130. needWaitAnimation?: boolean
  131. }>(),
  132. {
  133. poster: "",
  134. autoplay: false,
  135. scale: 1
  136. }
  137. )
  138. watch(
  139. () => props.needWaitAnimation,
  140. () => {
  141. if (props.autoplay && !props.needWaitAnimation) {
  142. play()
  143. } else if (props.needWaitAnimation) {
  144. pause()
  145. }
  146. }
  147. )
  148. const secondToTime = (second = 0) => {
  149. if (second === 0 || isNaN(second)) return "00:00"
  150. const add0 = (num: number) => (num < 10 ? "0" + num : "" + num)
  151. const hour = Math.floor(second / 3600)
  152. const min = Math.floor((second - hour * 3600) / 60)
  153. const sec = Math.floor(second - hour * 3600 - min * 60)
  154. return (hour > 0 ? [hour, min, sec] : [min, sec]).map(add0).join(":")
  155. }
  156. const getBoundingClientRectViewLeft = (element: HTMLElement) => {
  157. return element.getBoundingClientRect().left
  158. }
  159. const videoRef = ref<HTMLVideoElement>()
  160. const playBarWrap = ref<HTMLElement>()
  161. const volumeBarRef = ref<HTMLElement>()
  162. const volume = ref(0.5)
  163. const paused = ref(true)
  164. const currentTime = ref(0)
  165. const duration = ref(0)
  166. const loaded = ref(0)
  167. const loop = ref(false)
  168. const bezelTransition = ref(false)
  169. const playbackRate = ref(1)
  170. const playBarTimeVisible = ref(false)
  171. const playBarTime = ref("00:00")
  172. const playBarTimeLeft = ref("0")
  173. const ptime = computed(() => secondToTime(currentTime.value))
  174. const dtime = computed(() => secondToTime(duration.value))
  175. const playedBarWidth = computed(() => (currentTime.value / duration.value) * 100 + "%")
  176. const loadedBarWidth = computed(() => (loaded.value / duration.value) * 100 + "%")
  177. const volumeBarWidth = computed(() => volume.value * 100 + "%")
  178. const speedMenuVisible = ref(false)
  179. const speedOptions = [
  180. { label: "2x", value: 2 },
  181. { label: "1.5x", value: 1.5 },
  182. { label: "1.25x", value: 1.25 },
  183. { label: "1x", value: 1 },
  184. { label: "0.75x", value: 0.75 },
  185. { label: "0.5x", value: 0.5 }
  186. ]
  187. const seek = (time: number) => {
  188. if (!videoRef.value) return
  189. time = Math.max(time, 0)
  190. time = Math.min(time, duration.value)
  191. videoRef.value.currentTime = time
  192. currentTime.value = time
  193. }
  194. const play = () => {
  195. if (!videoRef.value) return
  196. paused.value = false
  197. videoRef.value.play()
  198. bezelTransition.value = true
  199. }
  200. const pause = () => {
  201. if (!videoRef.value) return
  202. paused.value = true
  203. videoRef.value.pause()
  204. bezelTransition.value = true
  205. }
  206. const toggle = () => {
  207. if (paused.value) play()
  208. else pause()
  209. }
  210. const setVolume = (percentage: number) => {
  211. if (!videoRef.value) return
  212. percentage = Math.max(percentage, 0)
  213. percentage = Math.min(percentage, 1)
  214. videoRef.value.volume = percentage
  215. volume.value = percentage
  216. if (videoRef.value.muted && percentage !== 0) videoRef.value.muted = false
  217. }
  218. const speed = (rate: number) => {
  219. if (videoRef.value) videoRef.value.playbackRate = rate
  220. playbackRate.value = rate
  221. }
  222. const handleDurationchange = () => {
  223. duration.value = videoRef.value?.duration || 0
  224. }
  225. const handleTimeupdate = () => {
  226. currentTime.value = videoRef.value?.currentTime || 0
  227. }
  228. const handleEnded = () => {
  229. if (!loop.value) pause()
  230. else {
  231. seek(0)
  232. play()
  233. }
  234. }
  235. const handleProgress = () => {
  236. loaded.value = videoRef.value?.buffered.length ? videoRef.value.buffered.end(videoRef.value.buffered.length - 1) : 0
  237. }
  238. const loadError = ref(false)
  239. const handleError = () => (loadError.value = true)
  240. const thumbMove = (e: MouseEvent | TouchEvent) => {
  241. if (!videoRef.value || !playBarWrap.value) return
  242. const clientX = "clientX" in e ? e.clientX : e.changedTouches[0].clientX
  243. let percentage = (clientX - getBoundingClientRectViewLeft(playBarWrap.value)) / playBarWrap.value.clientWidth
  244. percentage = Math.max(percentage, 0)
  245. percentage = Math.min(percentage, 1)
  246. const time = percentage * duration.value
  247. videoRef.value.currentTime = time
  248. currentTime.value = time
  249. }
  250. const thumbUp = (e: MouseEvent | TouchEvent) => {
  251. if (!videoRef.value || !playBarWrap.value) return
  252. const clientX = "clientX" in e ? e.clientX : e.changedTouches[0].clientX
  253. let percentage = (clientX - getBoundingClientRectViewLeft(playBarWrap.value)) / playBarWrap.value.clientWidth
  254. percentage = Math.max(percentage, 0)
  255. percentage = Math.min(percentage, 1)
  256. const time = percentage * duration.value
  257. videoRef.value.currentTime = time
  258. currentTime.value = time
  259. document.removeEventListener("mousemove", thumbMove)
  260. document.removeEventListener("touchmove", thumbMove)
  261. document.removeEventListener("mouseup", thumbUp)
  262. document.removeEventListener("touchend", thumbUp)
  263. }
  264. const handleMousedownPlayBar = () => {
  265. document.addEventListener("mousemove", thumbMove)
  266. document.addEventListener("touchmove", thumbMove)
  267. document.addEventListener("mouseup", thumbUp)
  268. document.addEventListener("touchend", thumbUp)
  269. }
  270. const volumeMove = (e: MouseEvent | TouchEvent) => {
  271. if (!volumeBarRef.value) return
  272. const clientX = "clientX" in e ? e.clientX : e.changedTouches[0].clientX
  273. const percentage = (clientX - getBoundingClientRectViewLeft(volumeBarRef.value)) / 45
  274. setVolume(percentage)
  275. }
  276. const volumeUp = () => {
  277. document.removeEventListener("mousemove", volumeMove)
  278. document.removeEventListener("touchmove", volumeMove)
  279. document.removeEventListener("mouseup", volumeUp)
  280. document.removeEventListener("touchend", volumeUp)
  281. }
  282. const handleMousedownVolumeBar = () => {
  283. document.addEventListener("mousemove", volumeMove)
  284. document.addEventListener("touchmove", volumeMove)
  285. document.addEventListener("mouseup", volumeUp)
  286. document.addEventListener("touchend", volumeUp)
  287. }
  288. const handleClickVolumeBar = (e: MouseEvent) => {
  289. if (!volumeBarRef.value) return
  290. const percentage = (e.clientX - getBoundingClientRectViewLeft(volumeBarRef.value)) / 45
  291. setVolume(percentage)
  292. }
  293. const handleMousemovePlayBar = (e: MouseEvent) => {
  294. if (duration.value && playBarWrap.value) {
  295. const px = playBarWrap.value.getBoundingClientRect().left
  296. const tx = e.clientX - px
  297. if (tx < 0 || tx > playBarWrap.value.offsetWidth) return
  298. const time = duration.value * (tx / playBarWrap.value.offsetWidth)
  299. playBarTimeLeft.value = `${tx - (time >= 3600 ? 25 : 20)}px`
  300. playBarTime.value = secondToTime(time)
  301. playBarTimeVisible.value = true
  302. }
  303. }
  304. const toggleVolume = () => {
  305. if (!videoRef.value) return
  306. if (videoRef.value.muted) {
  307. videoRef.value.muted = false
  308. setVolume(0.5)
  309. } else {
  310. videoRef.value.muted = true
  311. setVolume(0)
  312. }
  313. }
  314. const toggleLoop = () => {
  315. loop.value = !loop.value
  316. }
  317. const autoHideControllerTimer = ref(-1)
  318. const hideController = ref(false)
  319. const autoHideController = () => {
  320. hideController.value = false
  321. clearTimeout(autoHideControllerTimer.value)
  322. autoHideControllerTimer.value = setTimeout(() => {
  323. if (videoRef.value?.played.length) hideController.value = true
  324. }, 3000)
  325. }
  326. useMSE(props.src, videoRef)
  327. </script>
  328. <style scoped lang="scss">
  329. .video-player {
  330. position: relative;
  331. overflow: hidden;
  332. user-select: none;
  333. line-height: 1;
  334. transform-origin: 0 0;
  335. &.hide-controller {
  336. cursor: none;
  337. .controller-mask {
  338. opacity: 0;
  339. transform: translateY(100%);
  340. }
  341. .controller {
  342. opacity: 0;
  343. transform: translateY(100%);
  344. }
  345. }
  346. }
  347. .video-wrap {
  348. position: relative;
  349. background: #000;
  350. font-size: 0;
  351. width: 100%;
  352. height: 100%;
  353. .video {
  354. width: 100%;
  355. height: 100%;
  356. }
  357. }
  358. .controller-mask {
  359. background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==)
  360. repeat-x bottom;
  361. height: 98px;
  362. width: 100%;
  363. position: absolute;
  364. bottom: 0;
  365. transition: all 0.3s ease;
  366. }
  367. .controller {
  368. position: absolute;
  369. bottom: 0;
  370. left: 0;
  371. right: 0;
  372. height: 41px;
  373. padding: 0 20px;
  374. user-select: none;
  375. transition: all 0.3s ease;
  376. .bar-wrap {
  377. padding: 5px 0;
  378. cursor: pointer;
  379. position: absolute;
  380. bottom: 33px;
  381. width: calc(100% - 40px);
  382. height: 3px;
  383. &:hover .bar .played .thumb {
  384. transform: scale(1);
  385. }
  386. .bar-time {
  387. position: absolute;
  388. left: 0;
  389. top: -20px;
  390. border-radius: 4px;
  391. padding: 5px 7px;
  392. background-color: rgba(0, 0, 0, 0.62);
  393. color: #fff;
  394. font-size: 12px;
  395. text-align: center;
  396. opacity: 1;
  397. transition: opacity 0.1s ease-in-out;
  398. word-wrap: normal;
  399. word-break: normal;
  400. z-index: 2;
  401. pointer-events: none;
  402. &.hidden {
  403. opacity: 0;
  404. }
  405. }
  406. .bar {
  407. position: relative;
  408. height: 3px;
  409. width: 100%;
  410. background: rgba(255, 255, 255, 0.2);
  411. cursor: pointer;
  412. .loaded {
  413. position: absolute;
  414. left: 0;
  415. top: 0;
  416. bottom: 0;
  417. background: rgba(255, 255, 255, 0.4);
  418. height: 3px;
  419. transition: all 0.5s ease;
  420. will-change: width;
  421. }
  422. .played {
  423. position: absolute;
  424. left: 0;
  425. top: 0;
  426. bottom: 0;
  427. height: 3px;
  428. will-change: width;
  429. background-color: #fff;
  430. .thumb {
  431. position: absolute;
  432. top: 0;
  433. right: 5px;
  434. margin-top: -4px;
  435. margin-right: -10px;
  436. height: 11px;
  437. width: 11px;
  438. border-radius: 50%;
  439. cursor: pointer;
  440. transition: all 0.3s ease-in-out;
  441. transform: scale(0);
  442. background-color: #fff;
  443. }
  444. }
  445. }
  446. }
  447. .icons {
  448. height: 38px;
  449. position: absolute;
  450. bottom: 0;
  451. display: flex;
  452. align-items: center;
  453. &.icons-right {
  454. right: 15px;
  455. }
  456. .time {
  457. line-height: 38px;
  458. color: #eee;
  459. text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
  460. vertical-align: middle;
  461. font-size: 13px;
  462. cursor: default;
  463. }
  464. .icon {
  465. width: 40px;
  466. height: 100%;
  467. position: relative;
  468. cursor: pointer;
  469. display: flex;
  470. align-items: center;
  471. font-size: 20px;
  472. &.play-icon {
  473. font-size: 26px;
  474. }
  475. .icon-content {
  476. transition: all 0.2s ease-in-out;
  477. opacity: 0.8;
  478. color: #fff;
  479. }
  480. &.loop-icon {
  481. font-size: 12px;
  482. .icon-content {
  483. opacity: 0.5;
  484. }
  485. }
  486. &.speed-icon {
  487. font-size: 12px;
  488. position: relative;
  489. }
  490. .speed-menu {
  491. width: 70px;
  492. position: absolute;
  493. bottom: 30px;
  494. left: -23px;
  495. background-color: #22211b;
  496. padding: 5px 0;
  497. color: #ddd;
  498. .speed-menu-item {
  499. padding: 8px 0;
  500. text-align: center;
  501. &:hover {
  502. background-color: #393833;
  503. color: #fff;
  504. }
  505. &.active {
  506. font-weight: 700;
  507. color: #fff;
  508. }
  509. }
  510. }
  511. &.active .icon-content {
  512. opacity: 1;
  513. }
  514. &:hover .icon-content {
  515. opacity: 1;
  516. }
  517. }
  518. .volume {
  519. height: 100%;
  520. position: relative;
  521. cursor: pointer;
  522. display: flex;
  523. align-items: center;
  524. &:hover {
  525. .volume-bar-wrap .volume-bar {
  526. width: 45px;
  527. }
  528. .volume-bar-wrap .volume-bar .volume-bar-inner .thumb {
  529. transform: scale(1);
  530. }
  531. }
  532. &.volume-active {
  533. .volume-bar-wrap .volume-bar {
  534. width: 45px;
  535. }
  536. .volume-bar-wrap .volume-bar .volume-bar-inner .thumb {
  537. transform: scale(1);
  538. }
  539. }
  540. }
  541. .volume-bar-wrap {
  542. display: inline-block;
  543. margin: 0 15px 0 -5px;
  544. vertical-align: middle;
  545. height: 100%;
  546. }
  547. .volume-bar {
  548. position: relative;
  549. top: 17px;
  550. width: 0;
  551. height: 3px;
  552. background: #aaa;
  553. transition: all 0.3s ease-in-out;
  554. .volume-bar-inner {
  555. position: absolute;
  556. bottom: 0;
  557. left: 0;
  558. height: 100%;
  559. transition: all 0.1s ease;
  560. will-change: width;
  561. background-color: #fff;
  562. .thumb {
  563. position: absolute;
  564. top: 0;
  565. right: 5px;
  566. margin-top: -4px;
  567. margin-right: -10px;
  568. height: 11px;
  569. width: 11px;
  570. border-radius: 50%;
  571. cursor: pointer;
  572. transition: all 0.3s ease-in-out;
  573. transform: scale(0);
  574. background-color: #fff;
  575. }
  576. }
  577. }
  578. .loop {
  579. display: inline-block;
  580. height: 100%;
  581. }
  582. }
  583. }
  584. .bezel {
  585. position: absolute;
  586. left: 0;
  587. right: 0;
  588. top: 0;
  589. bottom: 0;
  590. font-size: 22px;
  591. color: #fff;
  592. pointer-events: none;
  593. .bezel-icon {
  594. position: absolute;
  595. top: 50%;
  596. left: 50%;
  597. margin: -26px 0 0 -26px;
  598. height: 52px;
  599. width: 52px;
  600. padding: 12px;
  601. display: flex;
  602. justify-content: center;
  603. align-items: center;
  604. background: rgba(0, 0, 0, 0.5);
  605. border-radius: 50%;
  606. opacity: 0;
  607. pointer-events: none;
  608. font-size: 40px;
  609. &.bezel-transition {
  610. animation: bezel-hide 0.5s linear;
  611. }
  612. @keyframes bezel-hide {
  613. from {
  614. opacity: 1;
  615. transform: scale(1);
  616. }
  617. to {
  618. opacity: 0;
  619. transform: scale(2);
  620. }
  621. }
  622. }
  623. }
  624. .load-error {
  625. position: absolute;
  626. left: 0;
  627. right: 0;
  628. top: 0;
  629. bottom: 0;
  630. font-size: 15px;
  631. color: #fff;
  632. pointer-events: none;
  633. display: flex;
  634. justify-content: center;
  635. align-items: center;
  636. }
  637. </style>