index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. import { defineComponent, onMounted, reactive, ref, onUnmounted, watch } from 'vue';
  2. import { useRoute, useRouter } from 'vue-router';
  3. import { browser, vaildMusicScoreUrl } from "@/helpers/utils"
  4. import styles from './index.module.less';
  5. import "plyr/dist/plyr.css";
  6. import Plyr from "plyr";
  7. import { Vue3Lottie } from "vue3-lottie";
  8. import audioBga from "../images/audioBga.json";
  9. import videobg from "../images/videobg.png";
  10. import backImg from "../images/back.png";
  11. import {
  12. postMessage
  13. } from '@/helpers/native-message';
  14. import { usePageVisibility } from '@vant/use';
  15. export default defineComponent({
  16. name: 'playCreation',
  17. setup() {
  18. const {isApp} = browser()
  19. const route = useRoute();
  20. const router = useRouter();
  21. const resourceUrl = decodeURIComponent(route.query.resourceUrl as string || '');
  22. const musicSheetName = decodeURIComponent(route.query.musicSheetName as string || '');
  23. const username = decodeURIComponent(route.query.username as string || '');
  24. const musicSheetId = decodeURIComponent(route.query.musicSheetId as string || '');
  25. const playType = resourceUrl.lastIndexOf('mp4') !== -1 ? 'Video' : 'Audio'
  26. const lottieDom = ref()
  27. const landscapeScreen = ref(false)
  28. let _plrl:any
  29. const playIngShow = ref(true)
  30. const loaded = ref(false)
  31. let isInitAudioVisualDraw = false
  32. const { registerDrag, unRegisterDrag } = landscapeScreenDrag()
  33. watch(landscapeScreen, ()=>{
  34. if(landscapeScreen.value){
  35. registerDrag()
  36. }else{
  37. unRegisterDrag()
  38. }
  39. })
  40. // 谱面
  41. const staffState = reactive({
  42. staffSrc: "",
  43. isShow: false,
  44. height:"initial",
  45. speedRate:Number(decodeURIComponent(route.query.speedRate as string || "1")),
  46. musicRenderType:decodeURIComponent(route.query.musicRenderType as string || "staff"),
  47. partIndex:Number(decodeURIComponent(route.query.partIndex as string || "0"))
  48. })
  49. const staffDom= ref<HTMLIFrameElement>()
  50. const {playStaff, pauseStaff, updateProgressStaff} = staffMoveInstance()
  51. function initPlay(){
  52. const id = playType === "Audio" ? "#audioMediaSrc" : "#videoMediaSrc";
  53. _plrl = new Plyr(id, {
  54. controls: ["play", "progress", "current-time", "duration"]
  55. });
  56. // 在微信中运行的时候,微信没有开放自动加载资源的权限,所以要等播放之后才显示播放控制器
  57. _plrl.on('loadedmetadata', () => {
  58. loaded.value = true
  59. });
  60. _plrl.on('play', () => {
  61. playIngShow.value = false
  62. playStaff()
  63. });
  64. _plrl.on('pause', () => {
  65. playIngShow.value = true
  66. pauseStaff()
  67. });
  68. _plrl.on('seeked', () => {
  69. // 暂停的时候调用
  70. if(!_plrl.playing){
  71. updateProgressStaff(_plrl.currentTime)
  72. }
  73. });
  74. }
  75. // 注册 横屏时候的自定义拖动事件 解决旋转时候拖动进度条坐标的问题
  76. function landscapeScreenDrag() {
  77. let progressBar: HTMLElement
  78. function onDown(e: MouseEvent | TouchEvent) {
  79. e.preventDefault();
  80. e.stopPropagation();
  81. // 记录拖动之前的状态
  82. const playing = _plrl.playing
  83. _plrl.pause()
  84. const isTouchEv = isTouchEvent(e);
  85. const event = isTouchEv ? e.touches[0] : e;
  86. setProgress(event)
  87. function onUp() {
  88. document.removeEventListener(
  89. isTouchEv ? 'touchmove' : 'mousemove',
  90. onMove
  91. );
  92. document.removeEventListener(isTouchEv ? 'touchend' : 'mouseup', onUp);
  93. playing && _plrl.play() // 之前是播放状态 就播放
  94. }
  95. function onMove(e: MouseEvent | TouchEvent){
  96. e.preventDefault();
  97. e.stopPropagation();
  98. const event = isTouchEvent(e) ? e.touches[0] : e;
  99. setProgress(event)
  100. }
  101. function setProgress(event: MouseEvent | Touch) {
  102. const {top, height} = progressBar.getBoundingClientRect();
  103. const progress = (event.clientY - top) / height;
  104. const progressNum = Math.min(Math.max(progress, 0), 1);
  105. _plrl.currentTime = progressNum * _plrl.duration
  106. }
  107. document.addEventListener(isTouchEv ? 'touchmove' : 'mousemove', onMove);
  108. document.addEventListener(isTouchEv ? 'touchend' : 'mouseup', onUp);
  109. }
  110. function registerDrag() {
  111. if(!progressBar){
  112. progressBar = document.querySelector('#landscapeScreenPlay .plyr__progress__container') as HTMLElement;
  113. }
  114. progressBar.addEventListener('mousedown', onDown);
  115. progressBar.addEventListener('touchstart', onDown);
  116. }
  117. function unRegisterDrag() {
  118. progressBar.removeEventListener('mousedown', onDown);
  119. progressBar.removeEventListener('touchstart', onDown);
  120. }
  121. return {
  122. registerDrag,
  123. unRegisterDrag
  124. }
  125. }
  126. function isTouchEvent(e: MouseEvent | TouchEvent): e is TouchEvent {
  127. return window.TouchEvent && e instanceof window.TouchEvent;
  128. }
  129. /**
  130. * 音频可视化
  131. * @param audioDom
  132. * @param canvasDom
  133. * @param fftSize 2的幂数,最小为32
  134. */
  135. function audioVisualDraw(audioDom: HTMLAudioElement, canvasDom: HTMLCanvasElement, fftSize = 128) {
  136. type propsType = { canvWidth: number; canvHeight: number; canvFillColor: string; lineColor: string; lineGap: number }
  137. // canvas
  138. const canvasCtx = canvasDom.getContext("2d")!
  139. let { width, height } = canvasDom.getBoundingClientRect()
  140. // 这里横屏的时候需要旋转,所以宽高变化一下
  141. if(landscapeScreen.value){
  142. const _width = width
  143. width = height
  144. height = _width
  145. }
  146. canvasDom.width = width
  147. canvasDom.height = height
  148. // audio
  149. const audioCtx = new AudioContext()
  150. const source = audioCtx.createMediaElementSource(audioDom)
  151. const analyser = audioCtx.createAnalyser()
  152. analyser.fftSize = fftSize
  153. source?.connect(analyser)
  154. analyser.connect(audioCtx.destination)
  155. const dataArray = new Uint8Array(fftSize / 2)
  156. const draw = (data: Uint8Array, ctx: CanvasRenderingContext2D, { lineGap, canvWidth, canvHeight, canvFillColor, lineColor }: propsType) => {
  157. if (!ctx) return
  158. const w = canvWidth
  159. const h = canvHeight
  160. fillCanvasBackground(ctx, w, h, canvFillColor)
  161. // 可视化
  162. const dataLen = data.length
  163. let step = (w / 2 - lineGap * dataLen) / dataLen
  164. step < 1 && (step = 1)
  165. const midX = w / 2
  166. const midY = h / 2
  167. let xLeft = midX
  168. for (let i = 0; i < dataLen; i++) {
  169. const value = data[i]
  170. const percent = value / 255 // 最大值为255
  171. const barHeight = percent * midY
  172. canvasCtx.fillStyle = lineColor
  173. // 中间加间隙
  174. if (i === 0) {
  175. xLeft -= lineGap / 2
  176. }
  177. canvasCtx.fillRect(xLeft - step, midY - barHeight, step, barHeight)
  178. canvasCtx.fillRect(xLeft - step, midY, step, barHeight)
  179. xLeft -= step + lineGap
  180. }
  181. let xRight = midX
  182. for (let i = 0; i < dataLen; i++) {
  183. const value = data[i]
  184. const percent = value / 255 // 最大值为255
  185. const barHeight = percent * midY
  186. canvasCtx.fillStyle = lineColor
  187. if (i === 0) {
  188. xRight += lineGap / 2
  189. }
  190. canvasCtx.fillRect(xRight, midY - barHeight, step, barHeight)
  191. canvasCtx.fillRect(xRight, midY, step, barHeight)
  192. xRight += step + lineGap
  193. }
  194. }
  195. const fillCanvasBackground = (ctx: CanvasRenderingContext2D, w: number, h: number, colors: string) => {
  196. ctx.clearRect(0, 0, w, h)
  197. ctx.fillStyle = colors
  198. ctx.fillRect(0, 0, w, h)
  199. }
  200. const requestAnimationFrameFun = () => {
  201. requestAnimationFrame(() => {
  202. analyser?.getByteFrequencyData(dataArray)
  203. draw(dataArray, canvasCtx, {
  204. lineGap: 2,
  205. canvWidth: width,
  206. canvHeight: height,
  207. canvFillColor: "transparent",
  208. lineColor: "rgba(255, 255, 255, 0.7)"
  209. })
  210. if (!isPause) {
  211. requestAnimationFrameFun()
  212. }
  213. })
  214. }
  215. let isPause = true
  216. const playVisualDraw = () => {
  217. //audioCtx.resume() // 重新更新状态 加了暂停和恢复音频音质发生了变化 所以这里取消了
  218. isPause = false
  219. requestAnimationFrameFun()
  220. }
  221. const pauseVisualDraw = () => {
  222. isPause = true
  223. //audioCtx?.suspend() // 暂停 加了暂停和恢复音频音质发生了变化 所以这里取消了
  224. // source?.disconnect()
  225. // analyser?.disconnect()
  226. }
  227. return {
  228. playVisualDraw,
  229. pauseVisualDraw
  230. }
  231. }
  232. function handlerBack(event:MouseEvent){
  233. event.stopPropagation()
  234. router.back()
  235. }
  236. //点击改变播放状态
  237. function handlerClickPlay(event:MouseEvent){
  238. //由于ios低版本必须在用户操作之后才能初始化 createMediaElementSource 所以必须在用户操作之后初始化
  239. if(!isInitAudioVisualDraw && playType === "Audio"){
  240. isInitAudioVisualDraw = true
  241. // 创建音波数据
  242. const audioDom = document.querySelector("#audioMediaSrc") as HTMLAudioElement
  243. const canvasDom = document.querySelector("#audioVisualizer") as HTMLCanvasElement
  244. const { pauseVisualDraw, playVisualDraw } = audioVisualDraw(audioDom, canvasDom)
  245. _plrl.on('play', () => {
  246. lottieDom.value.play()
  247. playVisualDraw()
  248. });
  249. _plrl.on('pause', () => {
  250. lottieDom.value.pause()
  251. pauseVisualDraw()
  252. });
  253. }
  254. // 原生 播放暂停按钮 点击的时候 不触发
  255. // @ts-ignore
  256. if(event.target?.matches('button.plyr__control')){
  257. return
  258. }
  259. if (_plrl.playing) {
  260. _plrl.pause();
  261. } else {
  262. _plrl.play();
  263. }
  264. }
  265. function handlerLandscapeScreen(){
  266. // app端调用app的横屏
  267. if(isApp){
  268. postMessage({
  269. api: "setRequestedOrientation",
  270. content: {
  271. orientation: 0,
  272. },
  273. });
  274. }else{
  275. // web端使用旋转的方式
  276. updateLandscapeScreenState()
  277. window.addEventListener('resize', updateLandscapeScreenState)
  278. }
  279. }
  280. function updateLandscapeScreenState(){
  281. // 下一帧 计算 横竖屏切换太快 获取的宽高不准
  282. requestAnimationFrame(()=>{
  283. if(window.innerWidth > window.innerHeight){
  284. landscapeScreen.value = false
  285. }else{
  286. landscapeScreen.value = true
  287. }
  288. })
  289. }
  290. const pageVisibility = usePageVisibility();
  291. watch(pageVisibility, value => {
  292. if (value === 'hidden') {
  293. _plrl?.pause();
  294. }
  295. });
  296. // 初始化五线谱
  297. function initStaff(){
  298. const src = `${vaildMusicScoreUrl()}/instrument/#/simple-detail?id=${musicSheetId}&musicRenderType=${staffState.musicRenderType}&part-index=${staffState.partIndex}`;
  299. //const src = `https://dev.kt.colexiu.com/instrument/#/simple-detail?id=${musicSheetId}&musicRenderType=${staffState.musicRenderType}&part-index=${staffState.partIndex}`;
  300. staffState.staffSrc = src
  301. window.addEventListener('message', (event) => {
  302. const { api, height } = event.data;
  303. if (api === 'api_musicPage') {
  304. staffState.isShow = true
  305. staffState.height = height + "px"
  306. // 如果是播放中自动开始 播放
  307. if(_plrl.playing){
  308. playStaff()
  309. }
  310. }
  311. });
  312. }
  313. function staffMoveInstance(){
  314. let isPause = true
  315. const requestAnimationFrameFun = () => {
  316. requestAnimationFrame(() => {
  317. staffDom.value?.contentWindow?.postMessage(
  318. {
  319. api: 'api_playProgress',
  320. content: {
  321. currentTime: _plrl.currentTime * staffState.speedRate
  322. }
  323. },
  324. "*"
  325. )
  326. if (!isPause) {
  327. requestAnimationFrameFun()
  328. }
  329. })
  330. }
  331. const playStaff = () => {
  332. // 没渲染不执行
  333. if(!staffState.isShow) return
  334. isPause = false
  335. staffDom.value?.contentWindow?.postMessage(
  336. {
  337. api: 'api_play'
  338. },
  339. "*"
  340. )
  341. requestAnimationFrameFun()
  342. }
  343. const pauseStaff = () => {
  344. // 没渲染不执行
  345. if(!staffState.isShow) return
  346. isPause = true
  347. staffDom.value?.contentWindow?.postMessage(
  348. {
  349. api: 'api_paused'
  350. },
  351. "*"
  352. )
  353. }
  354. const updateProgressStaff = (currentTime: number) => {
  355. // 没渲染不执行
  356. if(!staffState.isShow) return
  357. staffDom.value?.contentWindow?.postMessage(
  358. {
  359. api: 'api_updateProgress',
  360. content: {
  361. currentTime: currentTime * staffState.speedRate
  362. }
  363. },
  364. "*"
  365. )
  366. }
  367. return {
  368. playStaff,
  369. pauseStaff,
  370. updateProgressStaff
  371. }
  372. }
  373. onMounted(()=>{
  374. // 五线谱
  375. initStaff()
  376. initPlay()
  377. handlerLandscapeScreen()
  378. })
  379. onUnmounted(()=>{
  380. if(isApp){
  381. postMessage({
  382. api: "setRequestedOrientation",
  383. content: {
  384. orientation: 1,
  385. },
  386. });
  387. }else{
  388. window.removeEventListener('resize', updateLandscapeScreenState)
  389. }
  390. })
  391. return () =>
  392. <div id="landscapeScreenPlay" class={[styles.playCreation,landscapeScreen.value && styles.landscapeScreen,!loaded.value && styles.notLoaded]} onClick={handlerClickPlay}>
  393. <div class={styles.backBox}>
  394. <img class={styles.backImg} src={backImg} onClick={handlerBack} />
  395. <div class={styles.musicDetail}>
  396. <div class={styles.musicSheetName}>{musicSheetName}</div>
  397. <div class={styles.username}>{username}</div>
  398. </div>
  399. </div>
  400. {
  401. playType === 'Audio' ?
  402. <div class={styles.audioBox}>
  403. <canvas class={styles.audioVisualizer} id="audioVisualizer"></canvas>
  404. <Vue3Lottie ref={lottieDom} class={styles.audioBga} animationData={audioBga} autoPlay={false} loop={true}></Vue3Lottie>
  405. <audio
  406. crossorigin="anonymous"
  407. id="audioMediaSrc"
  408. src={resourceUrl}
  409. controls="false"
  410. preload="metadata"
  411. playsinline
  412. />
  413. </div>
  414. :
  415. <video
  416. id="videoMediaSrc"
  417. class={styles.videoBox}
  418. src={resourceUrl}
  419. data-poster={videobg}
  420. preload="metadata"
  421. playsinline
  422. />
  423. }
  424. <div class={[styles.playLarge, playIngShow.value && styles.playIngShow]}></div>
  425. {/* 谱面 */}
  426. {
  427. staffState.staffSrc &&
  428. <div
  429. class={[styles.staffBox, staffState.isShow && styles.staffBoxShow]}
  430. style={
  431. {
  432. '--staffBoxHeight':staffState.height
  433. }
  434. }
  435. >
  436. <div class={styles.mask}></div>
  437. <iframe
  438. ref={staffDom}
  439. class={styles.staff}
  440. frameborder="0"
  441. src={staffState.staffSrc}>
  442. </iframe>
  443. </div>
  444. }
  445. </div>;
  446. }
  447. });