Skip to content

Commit

Permalink
Merge pull request #316 from akashic-games/refactor-html-audio-player
Browse files Browse the repository at this point in the history
refactor: refactor HTMLAudioPlayer
  • Loading branch information
yu-ogi authored Jul 18, 2024
2 parents 20c5bda + 5c4045f commit ae750cc
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 135 deletions.
145 changes: 145 additions & 0 deletions src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/* eslint-disable @typescript-eslint/typedef */
/* eslint-disable @typescript-eslint/explicit-function-return-type */

import { Trigger } from "@akashic/trigger";
import { setupChromeMEIWorkaround } from "./HTMLAudioAutoplayHelper";

export interface AudioElementPlayerParameterObject {
id: string;
element: HTMLAudioElement | null;
duration: number;
offset: number;
loop: boolean;
loopOffset: number;
}

export class AudioElementPlayer {
onStop: Trigger<void>;

id: string;
element: HTMLAudioElement | null;
offsetStart: number;
offsetEnd: number;
loopOffset: number;
duration: number;
loop: boolean;

_dummyTimerId: number | null;
_reachEndTimerId: number | null;
_previousCurrentTime: number;

constructor({ id, element, duration, offset, loopOffset, loop }: AudioElementPlayerParameterObject) {
this.id = id;
this.duration = duration;
this.offsetStart = offset;
this.offsetEnd = offset + duration;
this.loop = loop;
this.loopOffset = loopOffset;
this.onStop = new Trigger();
this._dummyTimerId = null;
this._reachEndTimerId = null;

if (element) {
setupChromeMEIWorkaround(element);
element.addEventListener("timeupdate", this._onTimeupdate, false);
element.addEventListener("ended", this._onEnded, false);
element.currentTime = this.offsetStart / 1000;
} else {
if (!loop && duration != null) {
this._setDummyTimer(this.duration);
}
}
this.element = element;
this._previousCurrentTime = element?.currentTime ?? 0;
}

rewind() {
this.pause();
this.setCurrentTime(this.loopOffset || this.offsetStart);
this.play();
}

play() {
this.element?.play().catch((_err) => {
// user interact の前に play() を呼ぶとエラーになる。これは HTMLAudioAutoplayHelper で吸収する
});
}

pause() {
this.element?.pause();
this._clearRewindTimer();
}

setCurrentTime(offset: number) {
if (!this.element) return;
this.element.currentTime = offset / 1000;
this._previousCurrentTime = this.element.currentTime;
}

setVolume(volume: number) {
if (!this.element) return;
this.element.volume = volume;
}

destroy() {
this.onStop.destroy();
const element = this.element;
if (element) {
element.removeEventListener("timeupdate", this._onTimeupdate, false);
element.removeEventListener("ended", this._onEnded, false);
}
this._clearDummyTimer();
this._clearRewindTimer();
}

_setDummyTimer(duration: number) {
this._clearDummyTimer();
this._dummyTimerId = window.setTimeout(() => this.pause(), duration);
}

_clearDummyTimer() {
if (this._dummyTimerId == null) return;
window.clearTimeout(this._dummyTimerId);
this._dummyTimerId = null;
}

_setRewindTimer(duration: number) {
this._clearRewindTimer();
this._reachEndTimerId = window.setTimeout(() => this._onReachEnd(), duration);
}

_clearRewindTimer() {
if (this._reachEndTimerId == null) return;
window.clearTimeout(this._reachEndTimerId);
this._reachEndTimerId = null;
}

_onReachEnd() {
if (this.loop) {
this.rewind();
} else {
this.pause();
this.onStop.fire();
}

this._clearRewindTimer();
}

_onTimeupdate = () => {
const element = this.element!; // this.element が存在する場合にのみ呼び出される
if (element.paused) return;

const currentOffset = element.currentTime * 1000;
const deltaSinceLastCall = Math.max(element.currentTime * 1000 - this._previousCurrentTime, 0);
const deltaUntilEnd = Math.max(this.offsetEnd - element.currentTime * 1000, 0);
this._previousCurrentTime = element.currentTime * 1000;

if (this.offsetEnd < currentOffset + deltaSinceLastCall || this.offsetEnd <= currentOffset) {
this._setRewindTimer(deltaUntilEnd);
}
};

_onEnded = () => {
this._onReachEnd();
};
}
163 changes: 28 additions & 135 deletions src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts
Original file line number Diff line number Diff line change
@@ -1,171 +1,64 @@
import type * as pdi from "@akashic/pdi-types";
import type { AudioManager } from "../../AudioManager";
import { AudioPlayer } from "../AudioPlayer";
import { AudioElementPlayer } from "./AudioElementPlayer";
import type { HTMLAudioAsset } from "./HTMLAudioAsset";
import { setupChromeMEIWorkaround } from "./HTMLAudioAutoplayHelper";

export class HTMLAudioPlayer extends AudioPlayer {
private _endedEventHandler: () => void;
private _audioInstance: HTMLAudioElement | null = null;
private _manager: AudioManager;
private _isWaitingPlayEvent: boolean = false;
private _isStopRequested: boolean = false;
private _onPlayEventHandler: () => void;
private _dummyDurationWaitTimerId: any;
// "timeupdate" によるイベント通知間隔はシステム負荷に依存するため、
// 次の "timeupdate" 通知タイミングより先にループすべき duration に到達してしまう可能性があり、適切なタイミングでループ処理を行うことができない。
// そのため、 `setTimeout()` を併用することで適切なタイミングでループ処理を行う。
// この値はそのときの timeoutID を示す。
private _onEndedCallTimerId: any;
private _player: AudioElementPlayer | null;

constructor(system: pdi.AudioSystem, manager: AudioManager) {
super(system);
this._manager = manager;
this._endedEventHandler = () => {
this._onAudioEnded();
};
this._onPlayEventHandler = () => {
this._onPlayEvent();
};
this._dummyDurationWaitTimerId = null;
this._player = null;
}

play(asset: HTMLAudioAsset): void {
if (this.currentAudio) {
this.stop();
if (this._player) {
if (asset.id === this._player.id) {
// 同一 ID のアセットは使い回す
super.stop();
this._player.rewind();
super.play(asset);
return;
}
this._player.destroy();
}
const audio = asset.cloneElement();

if (audio) {
// NOTE: 後方互換のため、offset の指定がない場合は duration を無視 (終端まで再生)
const duration = (asset.duration != null && asset.offset != null) ? asset.duration / 1000 : null;
const offset = (asset.offset ?? 0) / 1000;
const loopStart = (asset.loop && asset.loopOffset != null) ? asset.loopOffset / 1000 : offset;
const end = (duration != null) ? offset + duration : null;

audio.currentTime = offset;
if (loopStart === 0 && end == null) {
audio.loop = asset.loop;
audio.addEventListener("ended", this._endedEventHandler);
} else {
if (!asset.loop) {
audio.addEventListener("ended", this._endedEventHandler);
} else {
audio.addEventListener("ended", () => {
this._clearOnEndedCallTimer();
audio.currentTime = loopStart;
audio.play();
});
}
if (end != null) {
let previousCurrentTime = 0;
const onEnded = (): void => {
this._clearOnEndedCallTimer();
if (!asset.loop) {
audio.pause();
} else {
audio.currentTime = loopStart;
}
};
audio.addEventListener("timeupdate", () => {
const diff = Math.max(0, audio.currentTime - previousCurrentTime);
previousCurrentTime = audio.currentTime;
if (end <= audio.currentTime) {
onEnded();
} else if (end <= audio.currentTime + diff) { // 次の timeupdate イベントまでに end を超えることが確定していれば、見越し時間で停止処理を行う
this._clearOnEndedCallTimer();
this._onEndedCallTimerId = setTimeout(() => {
onEnded();
}, (end - audio.currentTime) * 1000);
}
});
}
}
const player = new AudioElementPlayer({
id: asset.id,
element: asset.cloneElement(),
duration: asset.duration ?? +Infinity,
offset: asset.offset ?? 0,
loop: !!asset.loop,
loopOffset: asset.loopOffset ?? 0,
});
player.setVolume(this._calculateVolume());
player.onStop.add(this.stop, this);
player.play();
this._player = player;

setupChromeMEIWorkaround(audio);
audio.volume = this._calculateVolume();
audio.play().catch((_err) => { /* user interactの前にplay()を呼ぶとエラーになる。これはHTMLAudioAutoplayHelperで吸収する */});
audio.addEventListener("play", this._onPlayEventHandler, false);
this._isWaitingPlayEvent = true;
this._audioInstance = audio;
} else {
// 再生できるオーディオがない場合。duration後に停止処理だけ行う(処理のみ進め音は鳴らさない)
this._dummyDurationWaitTimerId = setTimeout(this._endedEventHandler, asset.duration);
}
super.play(asset);
}

stop(): void {
if (!this.currentAudio) {
super.stop();
return;
}
this._clearEndedEventHandler();

if (this._audioInstance) {
if (!this._isWaitingPlayEvent) {
// _audioInstance が再び play されることは無いので、 removeEventListener("play") する必要は無い
this._audioInstance.pause();
this._audioInstance = null;
} else {
this._isStopRequested = true;
}
}
this._player?.pause();
super.stop();
}

changeVolume(volume: number): void {
super.changeVolume(volume);
if (this._audioInstance) {
this._audioInstance.volume = this._calculateVolume();
}
this._player?.setVolume(this._calculateVolume());
}

_changeMuted(muted: boolean): void {
super._changeMuted(muted);
if (this._audioInstance) {
this._audioInstance.volume = this._calculateVolume();
}
this._player?.setVolume(this._calculateVolume());
}

notifyMasterVolumeChanged(): void {
if (this._audioInstance) {
this._audioInstance.volume = this._calculateVolume();
}
}

private _onAudioEnded(): void {
this._clearEndedEventHandler();
super.stop();
}

private _clearOnEndedCallTimer(): void {
if (this._onEndedCallTimerId != null) {
clearTimeout(this._onEndedCallTimerId);
this._onEndedCallTimerId = null;
}
}

private _clearEndedEventHandler(): void {
if (this._audioInstance)
this._audioInstance.removeEventListener("ended", this._endedEventHandler, false);
if (this._dummyDurationWaitTimerId != null) {
clearTimeout(this._dummyDurationWaitTimerId);
this._dummyDurationWaitTimerId = null;
}
this._clearOnEndedCallTimer();
}

// audio.play() は非同期なので、 play が開始される前に stop を呼ばれた場合はこのハンドラ到達時に停止する
private _onPlayEvent(): void {
if (!this._isWaitingPlayEvent) return;
this._isWaitingPlayEvent = false;
if (this._isStopRequested) {
this._isStopRequested = false;
// _audioInstance が再び play されることは無いので、 removeEventListener("play") する必要は無い
this._audioInstance?.pause();
this._audioInstance = null;
}
this._player?.setVolume(this._calculateVolume());
}

private _calculateVolume(): number {
Expand Down

0 comments on commit ae750cc

Please sign in to comment.