diff --git a/packages/webgal/package.json b/packages/webgal/package.json index 892349f77..9802cb179 100644 --- a/packages/webgal/package.json +++ b/packages/webgal/package.json @@ -16,6 +16,7 @@ "axios": "^0.30.2", "cloudlogjs": "^1.0.9", "gifuct-js": "^2.1.2", + "gsap": "^3.14.2", "i18next": "^22.4.15", "localforage": "^1.10.0", "lodash": "^4.17.21", diff --git a/packages/webgal/src/Core/Modules/audio/bgmManager.ts b/packages/webgal/src/Core/Modules/audio/bgmManager.ts new file mode 100644 index 000000000..4d3a0b4e6 --- /dev/null +++ b/packages/webgal/src/Core/Modules/audio/bgmManager.ts @@ -0,0 +1,227 @@ +import gsap from 'gsap'; + +class BgmManager { + private static instance: BgmManager; + + public static getInstance(): BgmManager { + if (!BgmManager.instance) { + BgmManager.instance = new BgmManager(); + } + return BgmManager.instance; + } + + private _audios: [HTMLAudioElement, HTMLAudioElement]; + private _currentIndex = 0; + private _targetVolume = 1; + private _loop = true; + private _muted = false; + private _progressListeners: Set<(p: { currentTime: number; duration: number }) => void> = new Set(); + + private constructor() { + this._audios = [new Audio(), new Audio()]; + this._audios.forEach((audio) => { + audio.loop = this._loop; + audio.preload = 'auto'; + audio.crossOrigin = 'anonymous'; + audio.addEventListener('timeupdate', this._onTimeUpdate); + }); + } + + public async play(options: { src?: string; loop?: boolean; volume?: number; fade?: number } = {}): Promise { + const fade = options.fade ?? 0; + if (options.volume !== undefined) this._targetVolume = options.volume; + if (options.loop !== undefined) this.loop = options.loop; + + if (!options.src) { + const current = this._audio; + if (current.src) { + if (current.paused) { + current.volume = 0; + await current.play(); + } + await this._setVolume({ index: this._currentIndex, volume: this._targetVolume, fade }); + } + return; + } + + const oldIndex = this._currentIndex; + const nextIndex = (this._currentIndex + 1) % 2; + const oldAudio = this._audios[oldIndex]; + const nextAudio = this._audios[nextIndex]; + + nextAudio.src = options.src; + nextAudio.volume = fade > 0 ? 0 : this._targetVolume; + nextAudio.muted = this._muted; + + try { + nextAudio.load(); + await new Promise((resolve, reject) => { + const onCanPlay = () => { + nextAudio.removeEventListener('error', onError); + resolve(null); + }; + const onError = (e: any) => { + nextAudio.removeEventListener('canplaythrough', onCanPlay); + reject(e); + }; + nextAudio.addEventListener('canplaythrough', onCanPlay, { once: true }); + nextAudio.addEventListener('error', onError, { once: true }); + }); + + await nextAudio.play(); + this._currentIndex = nextIndex; + + if (fade > 0) { + await Promise.all([ + this._setVolume({ index: oldIndex, volume: 0, fade, stopOnEnd: true }), + this._setVolume({ index: nextIndex, volume: this._targetVolume, fade }), + ]); + } else { + this._stopAudio(oldAudio); + } + } catch (e) { + console.error('BGM Playback failed:', e); + this._stopAudio(nextAudio); + } + } + + public async pause({ fade = 0 }: { fade?: number }): Promise { + if (fade > 0) { + await this._setVolume({ index: this._currentIndex, volume: 0, fade, pauseOnEnd: true }); + } else { + this._audio.pause(); + } + } + + public async stop({ fade = 0 }: { fade?: number }): Promise { + if (fade > 0) { + await this._setVolume({ index: this._currentIndex, volume: 0, fade, stopOnEnd: true }); + } else { + this._audios.forEach((_, i) => this._stopAudio(this._audios[i])); + } + } + + public async fade({ volume, fade = 0 }: { volume: number; fade?: number }): Promise { + this._targetVolume = volume; + return this._setVolume({ index: this._currentIndex, volume, fade }); + } + + public async resume({ fade = 0 }: { fade?: number }): Promise { + return this.play({ fade }); + } + + public addProgressListener(cb: (p: { currentTime: number; duration: number }) => void): () => void { + this._progressListeners.add(cb); + + return () => { + this._progressListeners.delete(cb); + }; + } + + public clearListeners(): void { + this._progressListeners.clear(); + } + + private get _audio() { + return this._audios[this._currentIndex]; + } + + public get currentTime() { + return this._audio.currentTime; + } + public set currentTime(value: number) { + this._audio.currentTime = value; + } + + public get duration() { + return this._audio.duration; + } + public get paused() { + return this._audio.paused; + } + + public get volume() { + return this._targetVolume; + } + public set volume(value: number) { + this._targetVolume = value; + gsap.killTweensOf(this._audio, 'volume'); + this._audio.volume = Math.max(0, Math.min(1, value)); + } + + public get loop() { + return this._loop; + } + public set loop(value: boolean) { + this._loop = value; + this._audios.forEach((a) => { + a.loop = value; + }); + } + + public get muted() { + return this._muted; + } + public set muted(value: boolean) { + this._muted = value; + this._audios.forEach((a) => { + a.muted = value; + }); + } + + private _setVolume(params: { + index: number; + volume: number; + fade: number; + stopOnEnd?: boolean; + pauseOnEnd?: boolean; + }): Promise { + const { index, volume, fade, stopOnEnd, pauseOnEnd } = params; + + const audio = this._audios[index]; + + if (!audio.src || audio.src === window.location.href) { + return Promise.resolve(); + } + + gsap.killTweensOf(audio, 'volume'); + + return new Promise((resolve) => { + if (fade <= 0) { + audio.volume = volume; + if (stopOnEnd) this._stopAudio(audio); + else if (pauseOnEnd) audio.pause(); + resolve(); + return; + } + + gsap.to(audio, { + volume, + duration: fade / 1000, + ease: volume > audio.volume ? 'sine.out' : 'sine.in', + overwrite: 'auto', + onComplete: () => { + if (stopOnEnd) this._stopAudio(audio); + else if (pauseOnEnd) audio.pause(); + resolve(); + }, + onInterrupt: () => resolve(), + }); + }); + } + + private _onTimeUpdate = () => { + if (!this._audio.src || this._progressListeners.size === 0) return; + const { currentTime, duration } = this._audio; + this._progressListeners.forEach((listener) => listener({ currentTime, duration })); + }; + + private _stopAudio(audio: HTMLAudioElement) { + gsap.killTweensOf(audio, 'volume'); + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + } +} + +export const bgmManager = BgmManager.getInstance(); diff --git a/packages/webgal/src/Core/controller/stage/playBgm.ts b/packages/webgal/src/Core/controller/stage/playBgm.ts index b6e76c2e8..72e5a2166 100644 --- a/packages/webgal/src/Core/controller/stage/playBgm.ts +++ b/packages/webgal/src/Core/controller/stage/playBgm.ts @@ -1,23 +1,7 @@ import { webgalStore } from '@/store/store'; import { setStage } from '@/store/stageReducer'; import { logger } from '@/Core/util/logger'; - -// /** -// * 停止bgm -// */ -// export const eraseBgm = () => { -// logger.debug(`停止bgm`); -// // 停止之前的bgm -// let VocalControl: any = document.getElementById('currentBgm'); -// if (VocalControl !== null) { -// VocalControl.currentTime = 0; -// if (!VocalControl.paused) VocalControl.pause(); -// } -// // 获得舞台状态并设置 -// webgalStore.dispatch(setStage({key: 'bgm', value: ''})); -// }; - -let emptyBgmTimeout: ReturnType; +import { bgmManager } from '@/Core/Modules/audio/bgmManager'; /** * 播放bgm @@ -28,21 +12,11 @@ let emptyBgmTimeout: ReturnType; export function playBgm(url: string, enter = 0, volume = 100): void { logger.debug('playing bgm' + url); if (url === '') { - emptyBgmTimeout = setTimeout(() => { - // 淡入淡出效果结束后,将 bgm 置空 - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: '', enter: 0, volume: 100 } })); - }, enter); + bgmManager.stop({ fade: enter }); const lastSrc = webgalStore.getState().stage.bgm.src; webgalStore.dispatch(setStage({ key: 'bgm', value: { src: lastSrc, enter: -enter, volume: volume } })); } else { - // 不要清除bgm了! - clearTimeout(emptyBgmTimeout); webgalStore.dispatch(setStage({ key: 'bgm', value: { src: url, enter: enter, volume: volume } })); } - setTimeout(() => { - const audioElement = document.getElementById('currentBgm') as HTMLAudioElement; - if (audioElement.src) { - audioElement?.play(); - } - }, 0); + bgmManager.play({ src: url, volume: volume / 100, fade: enter }); } diff --git a/packages/webgal/src/Core/controller/stage/setVolume.ts b/packages/webgal/src/Core/controller/stage/setVolume.ts deleted file mode 100644 index 14f0ec36f..000000000 --- a/packages/webgal/src/Core/controller/stage/setVolume.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { logger } from '../../util/logger'; -import { webgalStore } from '@/store/store'; - -/** - * 设置音量 - */ -export const setVolume = () => { - const userDataState = webgalStore.getState().userData; - const mainVol = userDataState.optionData.volumeMain; - const vocalVol = mainVol * 0.01 * userDataState.optionData.vocalVolume * 0.01; - const bgmVol = mainVol * 0.01 * userDataState.optionData.bgmVolume * 0.01; - logger.debug(`设置背景音量:${bgmVol},语音音量:${vocalVol}`); - // const bgmElement: any = document.getElementById('currentBgm'); - // if (bgmElement) { - // bgmElement.volume = bgmVol.toString(); - // } - // const vocalElement: any = document.getElementById('currentVocal'); - // if (vocalElement) { - // vocalElement.volume = vocalVol.toString(); - // } -}; diff --git a/packages/webgal/src/Core/gameScripts/playVideo.tsx b/packages/webgal/src/Core/gameScripts/playVideo.tsx index d3dbd4e69..1b4d31c20 100644 --- a/packages/webgal/src/Core/gameScripts/playVideo.tsx +++ b/packages/webgal/src/Core/gameScripts/playVideo.tsx @@ -7,14 +7,17 @@ import { webgalStore } from '@/store/store'; import { getRandomPerformName, PerformController } from '@/Core/Modules/perform/performController'; import { getBooleanArgByKey } from '@/Core/util/getSentenceArg'; import { WebGAL } from '@/Core/WebGAL'; +import { bgmManager } from '../Modules/audio/bgmManager'; /** * 播放一段视频 * @param sentence */ export const playVideo = (sentence: ISentence): IPerform => { + const stageState = webgalStore.getState().stage; const userDataState = webgalStore.getState().userData; const mainVol = userDataState.optionData.volumeMain; const vocalVol = mainVol * 0.01 * userDataState.optionData.vocalVolume * 0.01; const bgmVol = mainVol * 0.01 * userDataState.optionData.bgmVolume * 0.01; + const bgmEnter = stageState.bgm.enter; const performInitName: string = getRandomPerformName(); let blockingNextFlag = getBooleanArgByKey(sentence, 'skipOff') ?? false; @@ -31,7 +34,7 @@ export const playVideo = (sentence: ISentence): IPerform => { performName: 'none', duration: 0, isHoldOn: false, - stopFunction: () => {}, + stopFunction: () => { }, blockingNext: () => blockingNextFlag, blockingAuto: () => true, stopTimeout: undefined, // 暂时不用,后面会交给自动清除 @@ -69,12 +72,9 @@ export const playVideo = (sentence: ISentence): IPerform => { /** * 恢复音量 */ - const bgmElement: any = document.getElementById('currentBgm'); - if (bgmElement) { - bgmElement.volume = bgmVol.toString(); - } + bgmManager.resume({ fade: bgmEnter }); const vocalElement: any = document.getElementById('currentVocal'); - if (bgmElement) { + if (vocalElement) { vocalElement.volume = vocalVol.toString(); } // eslint-disable-next-line react/no-deprecated @@ -93,12 +93,9 @@ export const playVideo = (sentence: ISentence): IPerform => { */ const vocalVol2 = 0; const bgmVol2 = 0; - const bgmElement: any = document.getElementById('currentBgm'); - if (bgmElement) { - bgmElement.volume = bgmVol2.toString(); - } + bgmManager.pause({ fade: bgmEnter }); const vocalElement: any = document.getElementById('currentVocal'); - if (bgmElement) { + if (vocalElement) { vocalElement.volume = vocalVol2.toString(); } diff --git a/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx b/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx index c82e80c92..175ced184 100644 --- a/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx +++ b/packages/webgal/src/Stage/AudioContainer/AudioContainer.tsx @@ -3,6 +3,7 @@ import { RootState, webgalStore } from '@/store/store'; import { setStage } from '@/store/stageReducer'; import { useEffect, useState } from 'react'; import { logger } from '@/Core/util/logger'; +import { bgmManager } from '@/Core/Modules/audio/bgmManager'; export const AudioContainer = () => { const stageStore = useSelector((webgalStore: RootState) => webgalStore.stage); @@ -19,56 +20,15 @@ export const AudioContainer = () => { const uiSeVol = mainVol * 0.01 * (userDataState.optionData.uiSeVolume ?? 50) * 0.01; const isEnterGame = useSelector((state: RootState) => state.GUI.isEnterGame); - // 淡入淡出定时器 - const [fadeTimer, setFadeTimer] = useState(setTimeout(() => {}, 0)); - - /** - * 淡入BGM - * @param bgm 背景音乐 - * @param maxVol 最大音量 - * @param time 淡入时间 - */ - const bgmFadeIn = (bgm: HTMLAudioElement, maxVol: number, time: number) => { - // 设置初始音量 - time >= 0 ? (bgm.volume = 0) : (bgm.volume = maxVol); - // 设置音量递增时间间隔 - const duration = 10; - // 计算每duration的音量增量 - const volumeStep = (maxVol / time) * duration; - // 基于递归调用实现淡入淡出效果 - const fade = () => { - const timer = setTimeout(() => { - if (bgm.volume + volumeStep >= maxVol) { - // 如果音量接近或达到最大值,则设置最终音量(淡入) - bgm.volume = maxVol; - } else if (bgm.volume + volumeStep <= 0) { - // 如果音量接近或达到最小值,则设置最终音量(淡出) - bgm.volume = 0; - // 淡出效果结束后,将 bgm 置空 - webgalStore.dispatch(setStage({ key: 'bgm', value: { src: '', enter: 0, volume: 100 } })); - } else { - // 否则增加音量,并递归调用 - bgm.volume += volumeStep; - fade(); - } - }, duration); - // 将定时器引用存储到 fadeTimer 中 - setFadeTimer(timer); - }; - // 调用淡入淡出函数 - fade(); - }; - useEffect(() => { - // 清除之前的淡入淡出定时器 - clearTimeout(fadeTimer); - // 获取当前背景音乐元素 - const bgmElement = document.getElementById('currentBgm') as HTMLAudioElement; - // 如果当前背景音乐元素存在,则淡入淡出 - if (bgmElement) { - bgmEnter === 0 ? (bgmElement.volume = bgmVol) : bgmFadeIn(bgmElement, bgmVol, bgmEnter); + if (!isEnterGame) return; + + if (isShowTitle) { + bgmManager.play({ src: titleBgm, volume: bgmVol, fade: bgmEnter }); + } else { + bgmManager.play({ src: stageStore.bgm.src, volume: bgmVol, fade: bgmEnter }); } - }, [isShowTitle, titleBgm, stageStore.bgm.src, bgmVol, bgmEnter]); + }, [isEnterGame, isShowTitle, titleBgm, stageStore.bgm.src]); useEffect(() => { logger.debug(`设置背景音量:${bgmVol}`); @@ -118,13 +78,6 @@ export const AudioContainer = () => { return (
-
); diff --git a/packages/webgal/src/UI/Extra/ExtraBgm.tsx b/packages/webgal/src/UI/Extra/ExtraBgm.tsx index 7c9bba9b2..71f6ef723 100644 --- a/packages/webgal/src/UI/Extra/ExtraBgm.tsx +++ b/packages/webgal/src/UI/Extra/ExtraBgm.tsx @@ -1,5 +1,5 @@ import { useDispatch, useSelector } from 'react-redux'; -import { RootState } from '@/store/store'; +import { RootState, webgalStore } from '@/store/store'; import React from 'react'; import styles from '@/UI/Extra/extra.module.scss'; import { useValue } from '@/hooks/useValue'; @@ -7,12 +7,18 @@ import { setStage } from '@/store/stageReducer'; import { GoEnd, GoStart, MusicList, PlayOne, SquareSmall } from '@icon-park/react'; import useSoundEffect from '@/hooks/useSoundEffect'; import { setGuiAsset } from '@/store/GUIReducer'; +import { bgmManager } from '@/Core/Modules/audio/bgmManager'; export function ExtraBgm() { const { playSeClick, playSeEnter } = useSoundEffect(); // 检查当前正在播放的bgm是否在bgm列表内 const currentBgmSrc = useSelector((state: RootState) => state.GUI.titleBgm); + const stageStore = useSelector((webgalStore: RootState) => webgalStore.stage); const extraState = useSelector((state: RootState) => state.userData.appreciationData); + const userDataState = useSelector((state: RootState) => state.userData); + const mainVol = userDataState.optionData.volumeMain; + const bgmVol = mainVol * 0.01 * userDataState.optionData.bgmVolume * 0.01; + const bgmEnter = stageStore.bgm.enter; const initName = 'Title_BGM'; // 是否展示 bgm 列表 const isShowBgmList = useValue(false); @@ -88,8 +94,7 @@ export function ExtraBgm() {
{ playSeClick(); - const bgmControl: HTMLAudioElement = document.getElementById('currentBgm') as HTMLAudioElement; - bgmControl?.play().then(); + bgmManager.play({ src: currentBgmSrc, volume: bgmVol, fade: bgmEnter }); }} onMouseEnter={playSeEnter} className={styles.bgmControlButton} @@ -113,8 +118,7 @@ export function ExtraBgm() {
{ playSeClick(); - const bgmControl: HTMLAudioElement = document.getElementById('currentBgm') as HTMLAudioElement; - bgmControl.pause(); + bgmManager.stop({ fade: bgmEnter }); }} onMouseEnter={playSeEnter} className={styles.bgmControlButton} diff --git a/packages/webgal/src/UI/Title/Title.tsx b/packages/webgal/src/UI/Title/Title.tsx index 061df48a6..8485a7e1a 100644 --- a/packages/webgal/src/UI/Title/Title.tsx +++ b/packages/webgal/src/UI/Title/Title.tsx @@ -37,7 +37,6 @@ export default function Title() {
{ - playBgm(GUIState.titleBgm); dispatch(setVisibility({ component: 'isEnterGame', visibility: true })); if (fullScreen === fullScreenOption.on) { document.documentElement.requestFullscreen(); @@ -100,9 +99,8 @@ export default function Title() {
{GUIState.enableAppreciationMode && (
{ if (hasAppreciationItems) { playSeClick(); @@ -125,7 +123,7 @@ export default function Title() { leftFunc: () => { window.close(); }, - rightFunc: () => {}, + rightFunc: () => { }, }); }} onMouseEnter={playSeEnter} diff --git a/packages/webgal/src/store/stageReducer.ts b/packages/webgal/src/store/stageReducer.ts index 57e1ffe42..dc94e6cf6 100644 --- a/packages/webgal/src/store/stageReducer.ts +++ b/packages/webgal/src/store/stageReducer.ts @@ -46,7 +46,7 @@ export const initState: IStageState = { bgm: { // 背景音乐 src: '', // 背景音乐 文件地址(相对或绝对) - enter: 0, // 背景音乐 淡入或淡出的毫秒数 + enter: 1000, // 背景音乐 淡入或淡出的毫秒数 volume: 100, // 背景音乐 音量调整(0 - 100) }, uiSe: '', // 用户界面音效 文件地址(相对或绝对) diff --git a/yarn.lock b/yarn.lock index 062e2199d..e3b6194ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3165,6 +3165,11 @@ graphemer@^1.4.0: resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== +gsap@^3.14.2: + version "3.14.2" + resolved "https://registry.yarnpkg.com/gsap/-/gsap-3.14.2.tgz#6a9ea31e5046948e0be61eae006ae576ca5937d6" + integrity sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA== + has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"