Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: refactor HTMLAudioPlayer #316

Merged
merged 3 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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();
Copy link
Member

Choose a reason for hiding this comment

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

この箇所に対応する、 play() 時の HTMLAudioElement#volume の設定箇所が消えたように見えますが、外部影響ないでしょうか?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

見落としてました。5c4045f にて修正済みです。

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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

HTMLAudioElement を直接操作する抽象化的なクラスを新規で作成します。

Copy link
Member

Choose a reason for hiding this comment

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

切り出す方針同意なのですが、名前づけが難しいですね。

  • AudioPlayer と g.AudiPlayContext
  • HTMLAudioPlayer と HTMLAudioPlayerContext

で "player" と "context" の関係が逆になっているように思います (前者は context が player で実装されている一方、後者は context で player が実装されている) 。 "play context" と "player context" で別の語だと解釈することもできそうですが、名前からはあまり違いが読み取れなさそうです。

コメントされているとおり主眼は HTMLAudioElement の制御にあると思いますから、いっそのこと PDI 切り離して、純粋に 「HTMLAudioElement を制御する役」にしてしまうのはどうでしょうか? HTMLAudioElement (cloneElement() された) と、duration や offset を生成時の引数として受け取る形にすれば、PDI 依存もなくなると思います。("たまたま" asset と同じ形の interface で duration, offset, loopOffset などを受け取ることもできると思います)

この時、名前は AudioElement を軸に命名できそうです。それでも名付けにくいですが、例えば LoopableAudioElement とか、 AudioElementController とかですかね。

Copy link
Contributor Author

Choose a reason for hiding this comment

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

おっしゃるとおり 「HTMLAudioElement を制御するプレイヤー実装」という切り出し方にした方がシンプルになりそうなので、88025ed にて修正しました。

名前については変に長くなっても分かりづらいだけな気がしたため AudioElementPlayer としました。

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);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

HTMLAudioPlayer が複雑になっている要因は「複数箇所で同じような処理をしている」「それらの内どちらかの処理が終わっていればもう一方の処理を中断する」というような状態管理によるものだと仮定しました。

その仮定のもと

  • 音声の再生・停止・巻き戻しを機能単位で切り分け
    • HTMLAudioPlayer はそれらの機能を呼ぶだけ
  • 音声が終端に達したときの処理 (非ループの場合は停止、ループの場合は先頭に戻るなど) を一元化

するように修正しました。

}
};

_onEnded = () => {
this._onReachEnd();
};
}
Loading