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 {