index.tsx 20 KB

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