index.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. import { computed, defineComponent, nextTick, reactive, ref, toRefs } from "vue";
  2. import styles from "./index.module.less";
  3. import { api_back } from "/src/helpers/communication";
  4. import state from "/src/state";
  5. import iconBack from "./image/icon-back.svg";
  6. import iconShiyi from "./image/icon-shiyi.svg";
  7. import iconhuifang from "./image/icon-huifang.svg";
  8. import shiyiTop from "./image/shiyi-top.png";
  9. import shiyiClose from "./image/shiyi-close.svg";
  10. import firstLeft from "./image/first-left.svg";
  11. import firstRight from "./image/first-right.svg";
  12. import firstTop from "./image/first-top.svg";
  13. import firstBottom from "./image/first-bottom.svg";
  14. import firstCorrect from "./image/first-correct.svg";
  15. import firstError from "./image/first-error.svg";
  16. import firstNot from "./image/first-not.svg";
  17. import firstLack from "./image/first-lack.svg";
  18. import { Grid, GridItem, Popup } from "vant";
  19. import videobg from "./image/videobg.png";
  20. import "plyr/dist/plyr.css";
  21. import Plyr from "plyr";
  22. import { browser } from "/src/utils";
  23. import Note from "../note";
  24. import { storeData } from "/src/store";
  25. import Title from "/src/page-instrument/header-top/title";
  26. import { Vue3Lottie } from "vue3-lottie";
  27. import audioBga from "./image/audioBga.json";
  28. import audioBga1 from "./image/leftCloud.json";
  29. import audioBga2 from "./image/rightCloud.json";
  30. type IItemType = "intonation" | "cadence" | "integrity";
  31. export default defineComponent({
  32. name: "header-top",
  33. props: {
  34. scoreData: {
  35. type: Object,
  36. default: () => ({}),
  37. },
  38. },
  39. setup(props, {expose}) {
  40. const browserInfo = browser();
  41. const { scoreData } = toRefs(props);
  42. const shareData = reactive({
  43. show: false,
  44. shiyiShow: false,
  45. isInitPlyr: false,
  46. _plrl: null as any,
  47. });
  48. const lottieDom = ref<any>()
  49. const lottieDom1 = ref<any>()
  50. const lottieDom2 = ref<any>()
  51. const level: any = {
  52. BEGINNER: "入门级",
  53. ADVANCED: "进阶级",
  54. PERFORMER: "大师级",
  55. };
  56. // console.log("🚀 ~ scoreData:", scoreData.value)
  57. const itemType = ref<IItemType>("intonation");
  58. /** 返回 */
  59. const handleBack = () => {
  60. api_back();
  61. };
  62. const handleChange = (type: IItemType) => {
  63. itemType.value = type;
  64. scoreData.value.itemType = type
  65. };
  66. // 资源类型
  67. const mediaType = computed((): "audio" | "video" => {
  68. const subfix = (scoreData.value.videoFilePath || "").split(".").pop();
  69. if (subfix === "wav" || subfix === "mp3" || subfix === "m4a") {
  70. return "audio";
  71. }
  72. return "video";
  73. });
  74. const openAudioAndVideo = () => {
  75. shareData.show = true;
  76. if (shareData.isInitPlyr) return;
  77. nextTick(() => {
  78. const id = mediaType.value === "audio" ? "#audioSrc" : "#videoSrc";
  79. shareData._plrl = new Plyr(id, {
  80. controls: ["play-large", "play", "progress", "current-time", "duration"],
  81. fullscreen: { enabled: false },
  82. });
  83. // 创建音波数据
  84. if(mediaType.value === "audio"){
  85. setTimeout(() => {
  86. const audioDom = document.querySelector("#audioSrc") as HTMLAudioElement
  87. const canvasDom = document.querySelector("#audioVisualizer") as HTMLCanvasElement
  88. const { pauseVisualDraw, playVisualDraw } = audioVisualDraw(audioDom, canvasDom)
  89. shareData._plrl.on('play', () => {
  90. lottieDom.value.play()
  91. lottieDom1.value.play()
  92. lottieDom2.value.play()
  93. playVisualDraw()
  94. });
  95. shareData._plrl.on('pause', () => {
  96. lottieDom.value.pause()
  97. lottieDom1.value.pause()
  98. lottieDom2.value.pause()
  99. pauseVisualDraw()
  100. });
  101. }, 300); // 弹窗动画是0.25秒 这里用定时器 确保canvas 能获取到宽高
  102. }
  103. shareData.isInitPlyr = true;
  104. });
  105. };
  106. /**
  107. * 音频可视化
  108. * @param audioDom
  109. * @param canvasDom
  110. * @param fftSize 2的幂数,最小为32
  111. */
  112. function audioVisualDraw(audioDom: HTMLAudioElement, canvasDom: HTMLCanvasElement, fftSize = 128) {
  113. type propsType = { canvWidth: number; canvHeight: number; canvFillColor: string; lineColor: string; lineGap: number }
  114. // canvas
  115. const canvasCtx = canvasDom.getContext("2d")!
  116. const { width, height } = canvasDom.getBoundingClientRect()
  117. canvasDom.width = width
  118. canvasDom.height = height
  119. // audio
  120. let audioCtx : AudioContext | null = null
  121. let analyser : AnalyserNode | null = null
  122. let source : MediaElementAudioSourceNode | null = null
  123. const dataArray = new Uint8Array(fftSize / 2)
  124. const draw = (data: Uint8Array, ctx: CanvasRenderingContext2D, { lineGap, canvWidth, canvHeight, canvFillColor, lineColor }: propsType) => {
  125. if (!ctx) return
  126. const w = canvWidth
  127. const h = canvHeight
  128. fillCanvasBackground(ctx, w, h, canvFillColor)
  129. // 可视化
  130. const dataLen = data.length
  131. let step = (w / 2 - lineGap * dataLen) / dataLen
  132. step < 1 && (step = 1)
  133. const midX = w / 2
  134. const midY = h / 2
  135. let xLeft = midX
  136. for (let i = 0; i < dataLen; i++) {
  137. const value = data[i]
  138. const percent = value / 255 // 最大值为255
  139. const barHeight = percent * midY
  140. canvasCtx.fillStyle = lineColor
  141. // 中间加间隙
  142. if (i === 0) {
  143. xLeft -= lineGap / 2
  144. }
  145. canvasCtx.fillRect(xLeft - step, midY - barHeight, step, barHeight)
  146. canvasCtx.fillRect(xLeft - step, midY, step, barHeight)
  147. xLeft -= step + lineGap
  148. }
  149. let xRight = midX
  150. for (let i = 0; i < dataLen; i++) {
  151. const value = data[i]
  152. const percent = value / 255 // 最大值为255
  153. const barHeight = percent * midY
  154. canvasCtx.fillStyle = lineColor
  155. if (i === 0) {
  156. xRight += lineGap / 2
  157. }
  158. canvasCtx.fillRect(xRight, midY - barHeight, step, barHeight)
  159. canvasCtx.fillRect(xRight, midY, step, barHeight)
  160. xRight += step + lineGap
  161. }
  162. }
  163. const fillCanvasBackground = (ctx: CanvasRenderingContext2D, w: number, h: number, colors: string) => {
  164. ctx.clearRect(0, 0, w, h)
  165. ctx.fillStyle = colors
  166. ctx.fillRect(0, 0, w, h)
  167. }
  168. const requestAnimationFrameFun = () => {
  169. requestAnimationFrame(() => {
  170. analyser?.getByteFrequencyData(dataArray)
  171. draw(dataArray, canvasCtx, {
  172. lineGap: 2,
  173. canvWidth: width,
  174. canvHeight: height,
  175. canvFillColor: "transparent",
  176. lineColor: "rgba(255, 255, 255, 0.3)"
  177. })
  178. if (!isPause) {
  179. requestAnimationFrameFun()
  180. }
  181. })
  182. }
  183. let isPause = true
  184. const playVisualDraw = () => {
  185. if (!audioCtx) {
  186. audioCtx = new AudioContext()
  187. source = audioCtx.createMediaElementSource(audioDom)
  188. analyser = audioCtx.createAnalyser()
  189. analyser.fftSize = fftSize
  190. source?.connect(analyser)
  191. analyser.connect(audioCtx.destination)
  192. }
  193. //audioCtx.resume() // 重新更新状态 加了暂停和恢复音频音质发生了变化 所以这里取消了
  194. isPause = false
  195. requestAnimationFrameFun()
  196. }
  197. const pauseVisualDraw = () => {
  198. isPause = true
  199. //audioCtx?.suspend() // 暂停 加了暂停和恢复音频音质发生了变化 所以这里取消了
  200. // source?.disconnect()
  201. // analyser?.disconnect()
  202. }
  203. return {
  204. playVisualDraw,
  205. pauseVisualDraw
  206. }
  207. }
  208. return () => (
  209. <div class={[styles.headerTop, browserInfo.android && styles.android]}>
  210. <div class={styles.left}>
  211. <div class={[styles.back, !storeData.isApp && styles.disabled]} onClick={handleBack}>
  212. <img src={iconBack} />
  213. </div>
  214. <div class={styles.leftContent}>
  215. {/* <div class={styles.lcName}>{state.examSongName}</div> */}
  216. <Title class={styles.lcName} text={state.examSongName} rightView={false} />
  217. <div class={styles.lcScore}>{level[scoreData.value.heardLevel]}|综合分数:{scoreData.value.score}分</div>
  218. </div>
  219. </div>
  220. {/* 音准、节奏、完整度纬度 */}
  221. <div class={styles.middle}>
  222. {
  223. state.isPercussion ? null :
  224. <div
  225. onClick={() => handleChange("intonation")}
  226. class={[styles.cItem, itemType.value === "intonation" && styles.active]}>
  227. <span class={styles.mScore}>{scoreData.value.intonation}分</span>
  228. <span class={styles.mLabel}>音准</span>
  229. </div>
  230. }
  231. <div
  232. onClick={() => handleChange("cadence")}
  233. class={[styles.cItem, itemType.value === "cadence" && styles.active]}>
  234. <span class={styles.mScore}>{scoreData.value.cadence}分</span>
  235. <span class={styles.mLabel}>节奏</span>
  236. </div>
  237. {
  238. state.isPercussion ? null :
  239. <div
  240. onClick={() => handleChange("integrity")}
  241. class={[styles.cItem, itemType.value === "integrity" && styles.active]}>
  242. <span class={styles.mScore}>{scoreData.value.integrity}分</span>
  243. <span class={styles.mLabel}>完成度</span>
  244. </div>
  245. }
  246. </div>
  247. {/* <div class={styles.center}>
  248. <div class={styles.cItem}>
  249. <div>{level[scoreData.value.heardLevel]}</div>
  250. <div>难度</div>
  251. </div>
  252. <div class={styles.cItem}>
  253. <div>{scoreData.value.score}分</div>
  254. <div>评测分数</div>
  255. </div>
  256. {state.isPercussion ? null : (
  257. <>
  258. <div
  259. onClick={() => handleChange("intonation")}
  260. class={[styles.cItem, itemType.value === "intonation" && styles.active]}
  261. >
  262. <div style={{ color: "rgb(45, 199, 170)" }}>{scoreData.value.intonation}分</div>
  263. <div>音准</div>
  264. </div>
  265. <div
  266. onClick={() => handleChange("cadence")}
  267. class={[styles.cItem, itemType.value === "cadence" && styles.active]}
  268. >
  269. <div style={{ color: "#FF4E19" }}>{scoreData.value.cadence}分</div>
  270. <div>节奏</div>
  271. </div>
  272. <div
  273. onClick={() => handleChange("integrity")}
  274. class={[styles.cItem, itemType.value === "integrity" && styles.active]}
  275. >
  276. <div style={{ color: "rgb(255, 196, 89)" }}>{scoreData.value.integrity}分</div>
  277. <div>完成度</div>
  278. </div>
  279. </>
  280. )}
  281. </div> */}
  282. <div class={styles.right}>
  283. <div
  284. style={{ display: scoreData.value.videoFilePath ? "" : "none" }}
  285. class={styles.btn}
  286. onClick={openAudioAndVideo}
  287. >
  288. <img class={styles.iconBtn} src={iconhuifang} />
  289. <span>回放</span>
  290. </div>
  291. <div class={styles.btn} onClick={() => (shareData.shiyiShow = true)}>
  292. <img class={styles.iconBtn} src={iconShiyi} />
  293. <span>释义</span>
  294. </div>
  295. {/* <div class={styles.btn}>
  296. <img class={styles.iconBtn} src={iconhuifang} />
  297. <span>再来一遍</span>
  298. </div> */}
  299. </div>
  300. {/* 五线谱,简谱类型提示 */}
  301. {
  302. scoreData.value.musicType === 'staff' ?
  303. <>
  304. {state.isPercussion ? null : (
  305. <div class={styles.demos}>
  306. {itemType.value === "intonation" && (
  307. <>
  308. <div>
  309. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-2} y={0} />
  310. <span>演奏偏高</span>
  311. </div>
  312. <div>
  313. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-1} y={-3} />
  314. <span>演奏偏低</span>
  315. </div>
  316. </>
  317. )}
  318. {itemType.value === "cadence" && (
  319. <>
  320. <div>
  321. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={0.5} y={-1} />
  322. <span>节奏偏快</span>
  323. </div>
  324. <div>
  325. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-3} y={-2.5} />
  326. <span>演奏偏低</span>
  327. </div>
  328. </>
  329. )}
  330. {(itemType.value === "intonation" || itemType.value === "cadence") && (
  331. <>
  332. <div>
  333. <Note fill="#2ABC6F" />
  334. <span>演奏正确</span>
  335. </div>
  336. <div>
  337. <Note fill="#FF2B29" />
  338. <span>演奏错误</span>
  339. </div>
  340. </>
  341. )}
  342. {(itemType.value === "intonation" || itemType.value === "integrity") && (
  343. <div>
  344. <Note fill="#8F4EFB" />
  345. <span>时值不足</span>
  346. </div>
  347. )}
  348. {
  349. itemType.value === "integrity" &&
  350. <div>
  351. <Note fill="#2ABC6F" />
  352. <span>时值正确</span>
  353. </div>
  354. }
  355. <div>
  356. <Note fill="#ADADAD" />
  357. <span>未演奏</span>
  358. </div>
  359. </div>
  360. )}
  361. </> :
  362. <>
  363. {state.isPercussion ? null : (
  364. <div class={styles.demos}>
  365. {itemType.value === "intonation" && (
  366. <>
  367. <div>
  368. <img class={styles.firstIcon1} src={firstTop} />
  369. <span>演奏偏高</span>
  370. </div>
  371. <div>
  372. <img class={styles.firstIcon1} src={firstBottom} />
  373. <span>演奏偏低</span>
  374. </div>
  375. </>
  376. )}
  377. {itemType.value === "cadence" && (
  378. <>
  379. <div>
  380. <img class={styles.firstIcon2} src={firstLeft} />
  381. <span>节奏偏快</span>
  382. </div>
  383. <div>
  384. <img class={styles.firstIcon2} src={firstRight} />
  385. <span>节奏偏慢</span>
  386. </div>
  387. </>
  388. )}
  389. {(itemType.value === "intonation" || itemType.value === "cadence") && (
  390. <>
  391. <div>
  392. <img class={styles.firstIcon3} src={firstCorrect} />
  393. <span>演奏正确</span>
  394. </div>
  395. <div>
  396. <img class={styles.firstIcon3} src={firstError} />
  397. <span>演奏错误</span>
  398. </div>
  399. </>
  400. )}
  401. {(itemType.value === "intonation" || itemType.value === "integrity") && (
  402. <div>
  403. <img class={styles.firstIcon3} src={firstLack} />
  404. <span>时值不足</span>
  405. </div>
  406. )}
  407. {
  408. itemType.value === "integrity" &&
  409. <div>
  410. <img class={styles.firstIcon3} src={firstCorrect} />
  411. <span>时值正确</span>
  412. </div>
  413. }
  414. <div>
  415. <img class={styles.firstIcon3} src={firstNot} />
  416. <span>未演奏</span>
  417. </div>
  418. </div>
  419. )}
  420. </>
  421. }
  422. <Popup
  423. teleport="body"
  424. class={["popup-custom", "van-scale", styles.popup]}
  425. transition="van-scale"
  426. v-model:show={shareData.show}
  427. closeable
  428. onClose={() => {
  429. shareData._plrl?.pause();
  430. }}
  431. >
  432. <div class={styles.playerBox}>
  433. {
  434. mediaType.value === "audio" ?
  435. <div class={styles.audioBox}>
  436. <canvas class={styles.audioVisualizer} id="audioVisualizer"></canvas>
  437. <Vue3Lottie ref={lottieDom} class={styles.audioBga} animationData={audioBga} autoPlay={false} loop={true}></Vue3Lottie>
  438. <Vue3Lottie ref={lottieDom1} class={styles.audioBga1} animationData={audioBga1} autoPlay={false} loop={true}></Vue3Lottie>
  439. <Vue3Lottie ref={lottieDom2} class={styles.audioBga2} animationData={audioBga2} autoPlay={false} loop={true}></Vue3Lottie>
  440. <audio
  441. crossorigin="anonymous"
  442. id="audioSrc"
  443. src={scoreData.value.videoFilePath}
  444. controls="false"
  445. preload="metadata"
  446. playsinline
  447. />
  448. </div> :
  449. <video
  450. id="videoSrc"
  451. class={styles.videoBox}
  452. src={scoreData.value.videoFilePath}
  453. data-poster={videobg}
  454. preload="metadata"
  455. playsinline
  456. />
  457. }
  458. </div>
  459. </Popup>
  460. <Popup
  461. v-model:show={shareData.shiyiShow}
  462. class="popup-custom van-scale center-closeBtn shiyiBox"
  463. transition="van-scale"
  464. teleport="body"
  465. closeable
  466. >
  467. <img onClick={() => shareData.shiyiShow = false } class={styles.shiyiClose} src={shiyiClose} />
  468. {scoreData.value.musicType === 'staff' ?
  469. <div class={styles.shiyiPopup}>
  470. <img class={styles.shiyiTop} src={shiyiTop} />
  471. <div class={styles.items}>
  472. <div class={styles.item}>
  473. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-2} y={0} />
  474. <span>黄色音符在上:演奏偏高</span>
  475. </div>
  476. <div class={styles.item}>
  477. <Note fill="#2ABC6F" />
  478. <span>绿色音符:演奏/时值正确</span>
  479. </div>
  480. <div class={styles.item}>
  481. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-1} y={-3} />
  482. <span>黄色音符在下:演奏偏低</span>
  483. </div>
  484. <div class={styles.item}>
  485. <Note fill="#FF2B29" />
  486. <span>红色音符:演奏错误</span>
  487. </div>
  488. <div class={styles.item}>
  489. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={0.5} y={-1} />
  490. <span>黄色音符在左:节奏偏快</span>
  491. </div>
  492. <div class={styles.item}>
  493. <Note fill="#8F4EFB" />
  494. <span>紫色音符:时值不足</span>
  495. </div>
  496. <div class={styles.item}>
  497. <Note fill="rgba(42, 188, 111, 1)" shadowFill="#FFAB25" shadow x={-3} y={-2.5} />
  498. <span>黄色音符在右:节奏偏慢</span>
  499. </div>
  500. <div class={styles.item}>
  501. <Note fill="#ADADAD" />
  502. <span>灰色音符:未演奏</span>
  503. </div>
  504. </div>
  505. </div> :
  506. <div class={styles.shiyiPopup}>
  507. <img class={styles.shiyiTop} src={shiyiTop} />
  508. <div class={styles.items}>
  509. <div class={styles.itemTone}>
  510. <img class={styles.firstIcon1} src={firstTop} />
  511. <span>黄色箭头朝上:演奏偏高</span>
  512. </div>
  513. <div class={styles.itemTone}>
  514. <img class={styles.firstIcon3} src={firstCorrect} />
  515. <span>绿色音符:演奏/时值正确</span>
  516. </div>
  517. <div class={styles.itemTone}>
  518. <img class={styles.firstIcon1} src={firstBottom} />
  519. <span>黄色箭头朝下:演奏偏低</span>
  520. </div>
  521. <div class={styles.itemTone}>
  522. <img class={styles.firstIcon3} src={firstError} />
  523. <span>红色音符:演奏错误</span>
  524. </div>
  525. <div class={styles.itemTone}>
  526. <img class={[styles.firstIcon2, styles.fiz]} src={firstLeft} />
  527. <span>黄色箭头朝左:节奏偏快</span>
  528. </div>
  529. <div class={styles.itemTone}>
  530. <img class={styles.firstIcon3} src={firstLack} />
  531. <span>紫色音符:时值不足</span>
  532. </div>
  533. <div class={styles.itemTone}>
  534. <img class={styles.firstIcon2} src={firstRight} />
  535. <span>黄色箭头朝右:节奏偏慢</span>
  536. </div>
  537. <div class={styles.itemTone}>
  538. <img class={styles.firstIcon3} src={firstNot} />
  539. <span>灰色音符:未演奏</span>
  540. </div>
  541. </div>
  542. </div>
  543. }
  544. </Popup>
  545. </div>
  546. );
  547. },
  548. });