index.tsx 18 KB

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