diff --git a/src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts b/src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts
new file mode 100644
index 0000000..2b337e1
--- /dev/null
+++ b/src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts
@@ -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;
+
+ 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();
+ };
+}
diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts
index ace7835..03b6d49 100644
--- a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts
+++ b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts
@@ -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 {