index.tsx 21 KB

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