index.tsx 15 KB

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