Skip to content

Commit

Permalink
refactor: refactor HTMLAudioPlayer
Browse files Browse the repository at this point in the history
  • Loading branch information
yu-ogi committed Jul 10, 2024
1 parent 20c5bda commit e7f411c
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 135 deletions.
155 changes: 20 additions & 135 deletions src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,170 +2,55 @@ import type * as pdi from "@akashic/pdi-types";
import type { AudioManager } from "../../AudioManager";
import { AudioPlayer } from "../AudioPlayer";
import type { HTMLAudioAsset } from "./HTMLAudioAsset";
import { setupChromeMEIWorkaround } from "./HTMLAudioAutoplayHelper";
import { HTMLAudioPlayerContext } from "./HTMLAudioPlayerContext";

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 _context: HTMLAudioPlayerContext | null;

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

play(asset: HTMLAudioAsset): void {
if (this.currentAudio) {
this.stop();
if (this._context) {
if (asset.id === this._context.asset.id) {
// 同一 ID のアセットは使い回す
super.stop();
this._context.rewind();
super.play(asset);
return;
}
this._context.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 context = new HTMLAudioPlayerContext({ asset });
context.onStop.add(this.stop, this);
context.play();
this._context = context;

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._context?.pause();
super.stop();
}

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

_changeMuted(muted: boolean): void {
super._changeMuted(muted);
if (this._audioInstance) {
this._audioInstance.volume = this._calculateVolume();
}
this._context?.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._context?.setVolume(this._calculateVolume());
}

private _calculateVolume(): number {
Expand Down
142 changes: 142 additions & 0 deletions src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/* eslint-disable @typescript-eslint/typedef */
/* eslint-disable @typescript-eslint/explicit-function-return-type */

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

export interface HTMLAudioPlayerContextParameterObject {
asset: HTMLAudioAsset;
}

export class HTMLAudioPlayerContext {
onStop: Trigger<void>;

asset: HTMLAudioAsset;
element: HTMLAudioElement | null;
offsetStart: number;
offsetEnd: number;
loopOffset: number;
duration: number;
loop: boolean;

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

constructor({ asset }: HTMLAudioPlayerContextParameterObject) {
this.asset = asset;
this.duration = asset.duration ?? +Infinity;
this.offsetStart = asset.offset ?? 0;
this.offsetEnd = this.offsetStart + this.duration;
this.loop = !!asset.loop;
this.loopOffset = asset.loopOffset ?? 0;
this.onStop = new Trigger();
this._dummyTimerId = null;
this._reachEndTimerId = null;

const element = asset.cloneElement();
if (element) {
setupChromeMEIWorkaround(element);
element.addEventListener("timeupdate", this._onTimeupdate, false);
element.addEventListener("ended", this._onEnded, false);
element.currentTime = this.offsetStart / 1000;
} else {
if (!asset.loop && asset.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();
};
}

0 comments on commit e7f411c

Please sign in to comment.