liushengqiang 1 năm trước cách đây
mục cha
commit
f7961aa4c7

+ 1 - 1
osmd-extended

@@ -1 +1 @@
-Subproject commit 5924dd1d9de7ff3e0a646fafb1e8a7da6ac8434e
+Subproject commit 8a20e631ae5bc342e56cba7558abbc85a116f4af

+ 11 - 0
src/components/The-error/index.tsx

@@ -0,0 +1,11 @@
+import { Empty } from "vant";
+import { defineComponent } from "vue";
+
+export default defineComponent({
+    name: 'The-error',
+    setup(props, ctx) {
+        return () => <div>
+            <Empty image="error" />
+        </div>
+    },
+})

+ 9 - 3
src/helpers/formateMusic.ts

@@ -24,7 +24,7 @@ export const getFixTime = (speed: number) => {
 	// 	// 音频制作问题仅2拍不重复
 	// 	numerator = numerator === 2 ? 4 : numerator;
 	// }
-	return !state.needTick && !state.skipTick ? (60 / speed) * formatBeatUnit(beatUnit) * (numerator / denominator) : 0;
+	return state.isOpenMetronome ? (60 / speed) * formatBeatUnit(beatUnit) * (numerator / denominator) : 0;
 };
 
 const getLinkId = (): string => {
@@ -978,6 +978,7 @@ export const formatXML = (xml: string): string => {
 	return new XMLSerializer().serializeToString(xmlParse);
 };
 
+/** 获取所有音符的时值,以及格式化音符 */
 export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 	const detailId = state.examSongId?.toString();
 	const partIndex = state.partIndex + "";
@@ -985,7 +986,7 @@ export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 	const allNotes: any[] = [];
 	const allNoteId: string[] = [];
 	const allMeasures: any[] = [];
-	const { baseSpeed = 90 } = state;
+	const { speed: baseSpeed } = state;
 	const formatRealKey = (realKey: number, detail: any) => {
 		// 长笛的LEVEL 2-5-1条练习是泛音练习,以每小节第一个音的指法为准,高音不变变指法。
 		const olnyOneIds = ["906"];
@@ -1046,7 +1047,7 @@ export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 	let isSetNextNoteReal = false;
 	let differFrom = 0;
 	while (!iterator.EndReached) {
-		console.log({ ...iterator });
+		// console.log({ ...iterator });
 		const voiceEntries = iterator.CurrentVoiceEntries?.[0] ? [iterator.CurrentVoiceEntries?.[0]] : [];
 		let currentVoiceEntries: any[] = [];
 		// 单声部多声轨
@@ -1177,6 +1178,7 @@ export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 			// 如果有节拍器,需要将节拍器的时间算出来
 			if (i === 0) {
 				fixtime += getFixTime(beatSpeed);
+				console.log("🚀 ~ fixtime:", fixtime, beatSpeed)
 			}
 			// console.log(getTimeByBeatUnit(beatUnit, measureSpeed, iterator.currentMeasure.activeTimeSignature.Denominator))
 			let gradualLength = 0;
@@ -1290,6 +1292,9 @@ export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 			}
 			// console.log(note.tie)
 			const nodeDetail = {
+				measureListIndex: note.sourceMeasure.measureListIndex,
+				MeasureNumberXML: note.sourceMeasure.MeasureNumberXML,
+				svgElement: svgElelent,
 				difftime,
 				octaveOffset: activeVerticalMeasureList[0]?.octaveOffset,
 				frequency: note.pitch?.frequency,
@@ -1350,5 +1355,6 @@ export const formateTimes = (osmd: OpenSheetMusicDisplay) => {
 	try {
 		osmd.cursor.reset();
 	} catch (error) {}
+	state.activeMeasureIndex = sortArray[0].MeasureNumberXML
 	return sortArray;
 };

+ 5 - 4
src/page-gym/App.tsx

@@ -1,6 +1,7 @@
 import request from "umi-request";
 import { computed, defineComponent, onBeforeMount } from "vue";
 import { RouterView } from "vue-router";
+import TheError from "../components/The-error";
 import { setUserInfo, storeData } from "../store";
 import { setToken } from "../utils";
 import { getQuery } from "../utils/queryString";
@@ -24,10 +25,10 @@ export default defineComponent({
 		};
 		const setUser = async () => {
 			const res = await getUserInfo();
-			const {student} = res?.data || {}
+			const { student } = res?.data || {};
 			setUserInfo({
-				membershipEndTime: student.membershipEndTime
-			})
+				membershipEndTime: student.membershipEndTime,
+			});
 			// console.log("🚀 ~ res:", res);
 		};
 		onBeforeMount(() => {
@@ -40,6 +41,6 @@ export default defineComponent({
 		const inited = computed(() => {
 			return storeData.status === "login";
 		});
-		return () => <>{storeData.status === "error" ? <Notfind /> : inited.value ? <RouterView /> : null}</>;
+		return () => <>{storeData.status === "error" ? <TheError /> : inited.value ? <RouterView /> : null}</>;
 	},
 });

+ 20 - 6
src/page-gym/detail/index.module.less

@@ -1,4 +1,3 @@
-
 .skeleton {
     position: fixed;
     left: 0;
@@ -9,28 +8,43 @@
     background-color: #fff;
     --van-skeleton-paragraph-height: .8rem;
 }
-.detail{
+
+.detail {
     display: flex;
     flex-direction: column;
     width: 100vw;
     height: 100vh;
     overflow: hidden;
     background-color: var(--van-primary-color);
-    .headHeight{
+
+    .headHeight {
         height: 62px;
     }
-    .container{
+
+    .container {
         margin: 0 18px 18px 18px;
         flex: 1;
         overflow-y: auto;
         border-radius: 10px;
         background: #fff;
-        &::-webkit-scrollbar{
+
+        &::-webkit-scrollbar {
             width: 0;
             display: none;
         }
     }
-    .musicContainer{
+
+    .musicContainer {
         position: relative;
     }
 }
+
+:global {
+    
+    #musicContainer #cursorImg-0 {
+        background-repeat: repeat-y;
+        background-color: transparent;
+        background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAAXNSR0IArs4c6QAAABRJREFUCB1jZDy49T8DEDCBCBAAACTkAnq23WmtAAAAAElFTkSuQmCC);
+        background-position-x: center;
+    }
+}

+ 11 - 5
src/page-gym/detail/index.tsx

@@ -12,6 +12,7 @@ import MusicScore from "../../view/music-score";
 import { sysMusicScoreAccompanimentQueryPage, sysMusicScoreCategoriesQueryTree } from "../api";
 import HeaderTop from "../header-top";
 import styles from "./index.module.less";
+import Selection from "/src/view/selection";
 
 //特殊教材分类id
 const classids = [1, 30, 97]; // [大雅金唐, 竖笛教程, 声部训练]
@@ -24,6 +25,8 @@ export default defineComponent({
 		const detailData = reactive({
 			isLoading: true,
 			isRenderLoading: true, //曲谱渲染
+			/** 可以加载点击浮层  */
+			showSelection: false,
 		});
 		// console.log(route.params, query)
 		/** 获取曲谱数据 */
@@ -90,6 +93,7 @@ export default defineComponent({
 				}
 			}
 			state.isOpenMetronome = data.isOpenMetronome;
+			state.needTick = data.isOpenMetronome;
 			state.isShowFingering = data.isShowFingering;
 			state.music = data.metronomeMp3Url;
 			state.accompany = data.metronomeUrl;
@@ -101,7 +105,7 @@ export default defineComponent({
 		};
 
 		onMounted(async () => {
-			;(window as any).appName = 'gym';
+			(window as any).appName = "gym";
 			await getCategory();
 			await getMusicInfo();
 		});
@@ -116,6 +120,7 @@ export default defineComponent({
 				metronomeData.metro = new Metronome({ speed: state.activeSpeed });
 				metronomeData.metro.init(state.times);
 			} catch (error) {}
+			detailData.showSelection = true;
 		};
 		return () => (
 			<div class={styles.detail}>
@@ -129,11 +134,12 @@ export default defineComponent({
 				</div>
 				<div class={styles.container}>
 					{!detailData.isLoading && (
-						<>
-							<MusicScore class={styles.musicContainer} onRendered={handleRendered} />
-							<AudioList />
-						</>
+						<div class={styles.musicContainer}>
+							<MusicScore onRendered={handleRendered} />
+							{detailData.showSelection && <Selection />}
+						</div>
 					)}
+					{!detailData.isLoading && <AudioList />}
 				</div>
 			</div>
 		);

+ 3 - 4
src/page-gym/header-top/index.tsx

@@ -9,7 +9,6 @@ import { useRect } from "@vant/use";
 import { Badge, Circle, Popover } from "vant";
 import { metronomeData } from "../../helpers/metronome";
 import Speed from "./speed";
-import { audioList } from "../../view/audio-list";
 
 export default defineComponent({
 	name: "header-top",
@@ -41,9 +40,9 @@ export default defineComponent({
 					</div>
 					<div class={styles.btn} id="tips-step-5" onClick={() => togglePlay()}>
 						<div class={styles.btnWrap}>
-							<img style={{ display: state.playState === "paused" ? "block" : "none" }} class={styles.iconBtn} src={headImg("icon-play.svg")} />
-							<img style={{ display: state.playState !== "paused" ? "block" : "none" }} class={styles.iconBtn} src={headImg("icon-pause.svg")} />
-							<Circle class={styles.progress} stroke-width={80} currentRate={state.audioData.progress} rate={100} layerColor="#01C1B5" color="#FFC830" />
+							<img style={{ marginTop: '-1px', display: state.playState === "paused" ? "block" : "none" }} class={styles.iconBtn} src={headImg("icon-play.svg")} />
+							<img style={{ marginTop: '-1px', display: state.playState !== "paused" ? "block" : "none" }} class={styles.iconBtn} src={headImg("icon-pause.svg")} />
+							<Circle class={styles.progress} stroke-width={80} currentRate={state.playProgress} rate={100} layerColor="#01C1B5" color="#FFC830" />
 						</div>
 						{/* <Button class={styles.button} icon={runtime.playState === "play" || runtime.metroing ? Icons.pause : Icons.play} disabled={this.playerButtonIsDisabled} color={runtime.loading ? "#01C1B5" : ""} />
 						<Circle class={classnames(styles.circle, styles.button)} stroke-width={80} currentRate={progress} rate={100} layerColor="#01C1B5" color="#FFC830" /> */}

+ 124 - 13
src/state.ts

@@ -2,7 +2,6 @@ import { Toast } from "vant";
 import { reactive, watchEffect } from "vue";
 import { OpenSheetMusicDisplay } from "../osmd-extended/src";
 import { GradualNote, GradualTimes, GradualVersion, IMode } from "./type";
-import { audioList } from "./view/audio-list";
 
 const state = reactive({
 	/** 曲谱资源URL */
@@ -53,13 +52,17 @@ const state = reactive({
 	playState: "paused" as "play" | "paused",
 	/** 原音,伴奏 */
 	playSource: "music" as "music" | "background",
-	/** 播放器实例 */
-	audioData: {
-		/** 播放进度 */
-		progress: 0
+	/** 播放进度 */
+	playProgress: 0,
+	/** 激活的note index */
+	activeNoteIndex: 0,
+	/** 激活的小节 */
+	activeMeasureIndex: 0,
+	/** 原音ref */
+	songEl: null as unknown as HTMLAudioElement,
+	/** 背景音乐ref */
+	backgroundEl: null as unknown as HTMLAudioElement,
 
-	},
-	
 	repeatedBeats: 0,
 
 	sectionStatus: false,
@@ -108,17 +111,125 @@ const state = reactive({
 /** 音频加载完成 */
 export const onLoadedmetadata = (evt: Event) => {
 	// console.log(evt)
-}
-/** 播放中事件 */
-export const onTimeupdate = (evt: Event) => {
-	console.log(evt.timeStamp)
 };
+/** 在渲染前后计算光标应该走到的音符 */
+const setStep = () => {
+	let startTime = Date.now();
+	requestAnimationFrame(() => {
+		const endTime = Date.now();
+		// 渲染时间大于16.6,就会让页面卡顿, 如果渲染时间大与16.6就下一个渲染帧去计算
+		if (endTime - startTime < 16.6) {
+			if (state.playState !== "play") {
+				return;
+			}
+			if (state.songEl) {
+				state.playProgress = (state.songEl.currentTime / state.songEl.duration) * 100;
+				// console.log("🚀 ~ state.playProgress:", state.playProgress);
+				const item = getNote(state.songEl.currentTime);
+				if (item) gotoNext(item);
+			}
+		}
+		setStep();
+	});
+};
+/** 开始播放 */
+export const onPlay = () => {
+	setStep();
+};
+/** 播放中事件 */
+export const onTimeupdate = (evt: Event) => {};
 /** 播放完成事件 */
-export const onEnded = (evt: Event) => {};
+export const onEnded = () => {
+	state.playState = "paused";
+};
 
+/** 切换播放 */
 export const togglePlay = () => {
 	state.playState = state.playState === "paused" ? "play" : "paused";
-	audioList.audioPlay(state.playState);
+	if (state.playState == "play") {
+		state.songEl?.play();
+		state.backgroundEl?.play();
+	} else {
+		state.songEl?.pause();
+		state.backgroundEl?.pause();
+	}
+};
+/** 结束播放 */
+const handleStopPlay = () => {
+	state.playState = "paused";
+	state.songEl?.pause();
+	state.backgroundEl?.pause();
+	skipNotePlay(0)
+};
+
+/** 跳转到指定音符 */
+export const gotoCustomNote = (index: number) => {
+	try {
+		state.osmd.cursor.reset();
+	} catch (error) {}
+	for (let i = 0; i < index; i++) {
+		state.osmd.cursor.next();
+	}
+};
+/** 跳转到下一个音符 */
+export const gotoNext = (note: any) => {
+	if (note) {
+		const num = note.i;
+		// console.log('next', state.activeNoteIndex, num)
+		if (state.activeNoteIndex === note.i) return;
+		const osmd = state.osmd;
+		let prev = state.activeNoteIndex;
+		state.activeNoteIndex = num;
+		state.activeMeasureIndex = note.MeasureNumberXML;
+		if (prev && num - prev === 1) {
+			osmd.cursor.next();
+		} else if (prev && num - prev > 0) {
+			while (num - prev > 0) {
+				prev++;
+				osmd.cursor.next();
+			}
+		} else {
+			gotoCustomNote(num);
+		}
+	}
+};
+/** 获取指定音符 */
+export const getNote = (currentTime: number) => {
+	const times = state.times;
+	const len = state.times.length;
+	/** 播放超过了最后一个音符的时间,直接结束 */
+	if (currentTime >= times[len - 1]) {
+		handleStopPlay();
+		return;
+	}
+	let _item = null as any;
+	for (let i = state.activeNoteIndex; i < len; i++) {
+		const item = times[i];
+		const prevItem = times[i - 1];
+		if (currentTime >= item.time) {
+			if (!prevItem || item.time != prevItem.time) {
+				_item = item;
+			}
+		} else {
+			break;
+		}
+	}
+	// console.log("activeNoteIndex", currentTime, state.activeNoteIndex, _item.i);
+	return _item;
+};
+/** 跳转到指定音符开始播放 */
+export const skipNotePlay = (itemIndex: number) => {
+	const item = state.times[itemIndex];
+	console.log("🚀 ~ item:", itemIndex);
+	if (state.songEl) {
+		state.songEl.currentTime = item.time;
+	}
+	if (state.backgroundEl) {
+		state.backgroundEl.currentTime = item.time;
+	}
+	state.activeNoteIndex = item.i;
+	state.activeMeasureIndex = item.MeasureNumberXML;
+	gotoCustomNote(item.i);
 };
 
 export default state;

+ 8 - 7
src/utils/request.ts

@@ -18,8 +18,8 @@ request.interceptors.request.use(
 	(url, options) => {
 		const _prefix = storeData.proxy + storeData.platformApi;
 		// 只有后台才去设置
-		if (storeData.platformType === 'WEB' && (apiRouter as any)[url]) {
-      url = (apiRouter as any)[url]
+		if (storeData.platformType === "WEB" && (apiRouter as any)[url]) {
+			url = (apiRouter as any)[url];
 		}
 		const Authorization = getToken();
 		const authHeaders: any = {};
@@ -46,6 +46,7 @@ request.interceptors.response.use(
 	async (res, options) => {
 		if (res.status > 299 || res.status < 200) {
 			const msg = "服务器错误,状态码" + res.status;
+			storeData.status = 'error'
 			showToast(msg);
 			throw new Error(msg);
 		}
@@ -56,11 +57,11 @@ request.interceptors.response.use(
 			if (!(data.code === 403 || data.code === 401)) {
 				showToast(msg);
 			}
-      if (data.code === 403) {
-        if (browserInfo.isApp) {
-          postMessage({ api: "login" });
-        }
-      }
+			if (data.code === 403) {
+				if (browserInfo.isApp) {
+					postMessage({ api: "login" });
+				}
+			}
 			throw new Error(msg);
 		}
 		return data;

+ 4 - 17
src/view/audio-list/index.tsx

@@ -1,19 +1,6 @@
 import { computed, defineComponent, reactive } from "vue";
-import state, { onEnded, onLoadedmetadata, onTimeupdate } from "../../state";
-import styles from './index.module.less'
-
-export const audioList = reactive({
-	song: [] as HTMLAudioElement[],
-	audioPlay: async (playState: "play" | "paused") => {
-		for (let _audio of audioList.song) {
-			if (playState == "play") {
-				_audio.play();
-			} else {
-				_audio.pause();
-			}
-		}
-	},
-});
+import state, { onEnded, onLoadedmetadata, onPlay, onTimeupdate } from "../../state";
+import styles from "./index.module.less";
 
 export default defineComponent({
 	name: "audio-list",
@@ -24,8 +11,8 @@ export default defineComponent({
 		});
 		return () => (
 			<div class={styles.audioList}>
-				<audio muted={!isMusicMuted.value} preload="auto" ref={(el) => audioList.song.push(el as HTMLAudioElement)} controls src={state.music} onLoadedmetadata={onLoadedmetadata} onTimeupdate={onTimeupdate} onEnded={onEnded} />
-				<audio muted={isMusicMuted.value} preload="auto" ref={(el) => audioList.song.push(el as HTMLAudioElement)} controls src={state.accompany} />
+				<audio muted={!isMusicMuted.value} preload="auto" ref={(el) => (state.songEl = el as HTMLAudioElement)} controls src={state.music} onLoadedmetadata={onLoadedmetadata} onPlay={onPlay} onTimeupdate={onTimeupdate} onEnded={onEnded} />
+				<audio muted={isMusicMuted.value} preload="auto" ref={(el) => (state.backgroundEl = el as HTMLAudioElement)} controls src={state.accompany} />
 			</div>
 		);
 	},

+ 13 - 0
src/view/music-score/index.module.less

@@ -0,0 +1,13 @@
+.box {
+    :global {
+        #cursorImg-0 {
+            min-height: 58PX;
+            content: url('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
+            margin-top: -14PX;
+            background: var(--van-primary-color);
+            border-radius: 2px;
+        }
+
+    }
+
+}

+ 2 - 8
src/view/music-score/index.tsx

@@ -30,10 +30,9 @@ export default defineComponent({
 				autoResize: false,
 				followCursor: false,
 				drawPartNames: false, // 是否渲染声部
-
+				drawComposer: false, // 渲染作者
 				// autoBeam: true,
 				// drawMetronomeMarks: false,
-				// drawComposer: false,
 				// drawLyricist: false,
 				// ...this.opotions,
 			}, );
@@ -56,12 +55,7 @@ export default defineComponent({
             musicData.isRenderLoading = false;
 		});
 		return () => (
-			<div>
-                {/* <button onClick={() => {
-                    state.osmd.cursor.next()
-                }}>next</button> */}
-				<div id="musicContainer"></div>
-			</div>
+			<div id="musicContainer" class={styles.box}></div>
 		);
 	},
 });

+ 16 - 0
src/view/selection/index.module.less

@@ -0,0 +1,16 @@
+.selectionContainer{
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+}
+.position{
+    position: absolute;
+
+}
+.note{
+    // background-color: rgba(0, 0, 0, .3);
+}
+.staveBox{
+    background-color: rgba(1, 193, 181, 0.2);
+}

+ 90 - 0
src/view/selection/index.tsx

@@ -0,0 +1,90 @@
+import { computed, defineComponent, onMounted, reactive } from "vue";
+import state, { gotoCustomNote, skipNotePlay } from "/src/state";
+import styles from "./index.module.less";
+
+export default defineComponent({
+	name: "selection",
+	setup() {
+		const selectData = reactive({
+			notes: [] as any[],
+		});
+		const calcNoteData = () => {
+			const musicContainer = document.getElementById("musicContainer")?.getBoundingClientRect() || { x: 0, y: 0 };
+			const parentLeft = musicContainer.x || 0;
+			const parentTop = musicContainer.y || 0;
+			const notes = state.times;
+			const notesList: string[] = []
+			const MeasureNumberXMLList: number[] = [];
+			for (let i = 0; i < notes.length; i++) {
+				const item = notes[i];
+				// console.log("🚀 ~ item:", item)
+				const noteItem = {
+					index: i,
+					MeasureNumberXML: item.MeasureNumberXML,
+					bbox: null as any,
+					staveBox: null as any,
+				};
+				if (item.svgElement) {
+					const noteEle = document.querySelector(`#vf-${item.svgElement?.attrs?.id}`);
+					const noteEleBox = item.svgElement.getBoundingBox?.() || { h: 40 };
+					// console.log("🚀 ~ noteEle:", noteEle, item.svgElement, noteEleBox.h)
+					if (noteEle) {
+						const noteBbox = noteEle.getBoundingClientRect?.() || { x: 0, width: 0 };
+						noteItem.bbox = {
+							left: noteBbox.x - parentLeft + "px",
+							top: noteBbox.y - parentTop + "px",
+							width: noteBbox.width + "px",
+							height: noteEleBox.h * state.zoom + "px",
+						};
+					}
+				}
+				if (!MeasureNumberXMLList.includes(item.MeasureNumberXML)) {
+					// console.log(item);
+					if (item.stave) {
+						if (item.stave?.attrs?.id) {
+							const staveEle = document.querySelector(`#${item.stave.attrs.id}`);
+							const staveBbox = staveEle?.getBoundingClientRect?.() || { x: 0, width: 0 };
+							noteItem.staveBox = {
+								left: staveBbox.x - parentLeft + "px",
+								top: ((item.stave.y || 0) - 5) * state.zoom + "px",
+								width: staveBbox.width + "px",
+								height: 50 * state.zoom + "px",
+							};
+						}
+						MeasureNumberXMLList.push(item.MeasureNumberXML);
+					}
+				}
+
+				if (!notesList.includes(item.id)) {
+					selectData.notes.push(noteItem);
+					notesList.push(item.id)
+				}
+			}
+			console.log("🚀 ~ selectData.notes:", selectData.notes);
+		};
+		const staveBoxList = computed(() => {
+			return selectData.notes.filter((item) => item.staveBox);
+		});
+		onMounted(() => {
+			calcNoteData();
+		});
+		return () => (
+			<div class={styles.selectionContainer}>
+				<button onClick={() => gotoCustomNote(4)}>next</button>
+				<button onClick={() => state.osmd.cursor.next()}>next</button>
+				{staveBoxList.value.map((item: any) => {
+					// console.log(state.activeMeasureIndex , item.MeasureNumberXML, '')
+					return (
+						<>
+							{/* <div class={styles.note} style={item.bbox}></div> */}
+							{item.staveBox && <div class={[styles.position, state.activeMeasureIndex == item.MeasureNumberXML && styles.staveBox]} style={item.staveBox}></div>}
+						</>
+					);
+				})}
+				{selectData.notes.map((item: any) => {
+					return <div class={[styles.position, styles.note]} style={item.bbox} onClick={() => skipNotePlay(item.index)}></div>;
+				})}
+			</div>
+		);
+	},
+});