From e7f411c48f9d4326b0b2ac794c84ddd3afc7ba8d Mon Sep 17 00:00:00 2001 From: yu-ogi Date: Wed, 10 Jul 2024 16:07:37 +0900 Subject: [PATCH 1/3] refactor: refactor HTMLAudioPlayer --- src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts | 155 +++--------------- .../HTMLAudioPlugin/HTMLAudioPlayerContext.ts | 142 ++++++++++++++++ 2 files changed, 162 insertions(+), 135 deletions(-) create mode 100644 src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts index ace7835..16a0dc2 100644 --- a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts +++ b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts @@ -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 { diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts new file mode 100644 index 0000000..6d693d0 --- /dev/null +++ b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts @@ -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; + + 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(); + }; +} From 88025ed466c9bfe0edfc17f51ed33a71aa546764 Mon Sep 17 00:00:00 2001 From: yu-ogi Date: Tue, 16 Jul 2024 17:37:40 +0900 Subject: [PATCH 2/3] fix: rename class --- ...PlayerContext.ts => AudioElementPlayer.ts} | 31 +++++++++------- src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts | 37 +++++++++++-------- 2 files changed, 39 insertions(+), 29 deletions(-) rename src/plugin/HTMLAudioPlugin/{HTMLAudioPlayerContext.ts => AudioElementPlayer.ts} (84%) diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts b/src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts similarity index 84% rename from src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts rename to src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts index 6d693d0..2b337e1 100644 --- a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayerContext.ts +++ b/src/plugin/HTMLAudioPlugin/AudioElementPlayer.ts @@ -2,17 +2,21 @@ /* 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 interface AudioElementPlayerParameterObject { + id: string; + element: HTMLAudioElement | null; + duration: number; + offset: number; + loop: boolean; + loopOffset: number; } -export class HTMLAudioPlayerContext { +export class AudioElementPlayer { onStop: Trigger; - asset: HTMLAudioAsset; + id: string; element: HTMLAudioElement | null; offsetStart: number; offsetEnd: number; @@ -24,25 +28,24 @@ export class HTMLAudioPlayerContext { _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; + 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; - 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) { + if (!loop && duration != null) { this._setDummyTimer(this.duration); } } diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts index 16a0dc2..15368c3 100644 --- a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts +++ b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts @@ -1,56 +1,63 @@ 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 { HTMLAudioPlayerContext } from "./HTMLAudioPlayerContext"; export class HTMLAudioPlayer extends AudioPlayer { private _manager: AudioManager; - private _context: HTMLAudioPlayerContext | null; + private _player: AudioElementPlayer | null; constructor(system: pdi.AudioSystem, manager: AudioManager) { super(system); this._manager = manager; - this._context = null; + this._player = null; } play(asset: HTMLAudioAsset): void { - if (this._context) { - if (asset.id === this._context.asset.id) { + if (this._player) { + if (asset.id === this._player.id) { // 同一 ID のアセットは使い回す super.stop(); - this._context.rewind(); + this._player.rewind(); super.play(asset); return; } - this._context.destroy(); + this._player.destroy(); } - const context = new HTMLAudioPlayerContext({ asset }); - context.onStop.add(this.stop, this); - context.play(); - this._context = context; + 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.onStop.add(this.stop, this); + player.play(); + this._player = player; super.play(asset); } stop(): void { - this._context?.pause(); + this._player?.pause(); super.stop(); } changeVolume(volume: number): void { super.changeVolume(volume); - this._context?.setVolume(this._calculateVolume()); + this._player?.setVolume(this._calculateVolume()); } _changeMuted(muted: boolean): void { super._changeMuted(muted); - this._context?.setVolume(this._calculateVolume()); + this._player?.setVolume(this._calculateVolume()); } notifyMasterVolumeChanged(): void { - this._context?.setVolume(this._calculateVolume()); + this._player?.setVolume(this._calculateVolume()); } private _calculateVolume(): number { From 5c4045fe4ae885fd23399102f0c3c5cb0c77e545 Mon Sep 17 00:00:00 2001 From: yu-ogi Date: Tue, 16 Jul 2024 17:38:13 +0900 Subject: [PATCH 3/3] fix: set initial volume --- src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts index 15368c3..03b6d49 100644 --- a/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts +++ b/src/plugin/HTMLAudioPlugin/HTMLAudioPlayer.ts @@ -34,6 +34,7 @@ export class HTMLAudioPlayer extends AudioPlayer { loop: !!asset.loop, loopOffset: asset.loopOffset ?? 0, }); + player.setVolume(this._calculateVolume()); player.onStop.add(this.stop, this); player.play(); this._player = player;