Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/webgal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
227 changes: 227 additions & 0 deletions packages/webgal/src/Core/Modules/audio/bgmManager.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const fade = options.fade ?? 0;
if (options.volume !== undefined) this._targetVolume = options.volume;
if (options.loop !== undefined) this.loop = options.loop;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前 play 方法在被调用时,即使传入的 src 与当前播放的音轨相同,也会重新加载并播放音频,这会导致音乐从头开始。在某些场景下(例如,仅音量变化时),这可能不是预期的行为。

建议在 play 方法的开头增加一个判断:如果请求的 src 与当前正在播放的音轨相同且并未暂停,那么应该只调整音量,而不是重启整个音轨。这能让 play 方法在重复调用时表现更符合预期,尤其是在 React useEffect 中使用时。

    const fade = options.fade ?? 0;
    if (options.volume !== undefined) this._targetVolume = options.volume;
    if (options.loop !== undefined) this.loop = options.loop;

    // If src is provided and is the same as the current one, just adjust volume
    if (options.src && options.src === this._audio.src && !this._audio.paused) {
      await this._setVolume({ index: this._currentIndex, volume: this._targetVolume, fade });
      return;
    }


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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

为了增强代码的类型安全性和可读性,建议为 onError 回调函数中的事件参数 e 指定更明确的类型 Event,而不是使用 any

Suggested change
const onError = (e: any) => {
const onError = (e: Event) => {

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<void> {
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<void> {
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<void> {
this._targetVolume = volume;
return this._setVolume({ index: this._currentIndex, volume, fade });
}

public async resume({ fade = 0 }: { fade?: number }): Promise<void> {
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<void> {
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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

_onTimeUpdate 方法中,使用 !this._audio.src 来判断是否加载了音频源可能不够可靠。当通过 removeAttribute('src') 清除音源后,src 属性可能会指向当前页面的 URL。建议使用 this._audio.currentSrc 进行判断,当没有媒体加载时,它会是一个空字符串,这样更健壮。

Suggested change
if (!this._audio.src || this._progressListeners.size === 0) return;
if (!this._audio.currentSrc || 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();
32 changes: 3 additions & 29 deletions packages/webgal/src/Core/controller/stage/playBgm.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout>;
import { bgmManager } from '@/Core/Modules/audio/bgmManager';

/**
* 播放bgm
Expand All @@ -28,21 +12,11 @@ let emptyBgmTimeout: ReturnType<typeof setTimeout>;
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 });
}
21 changes: 0 additions & 21 deletions packages/webgal/src/Core/controller/stage/setVolume.ts

This file was deleted.

19 changes: 8 additions & 11 deletions packages/webgal/src/Core/gameScripts/playVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,7 +34,7 @@ export const playVideo = (sentence: ISentence): IPerform => {
performName: 'none',
duration: 0,
isHoldOn: false,
stopFunction: () => {},
stopFunction: () => { },
blockingNext: () => blockingNextFlag,
blockingAuto: () => true,
stopTimeout: undefined, // 暂时不用,后面会交给自动清除
Expand Down Expand Up @@ -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
Expand All @@ -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();
}

Expand Down
Loading
Loading