feat(ipub): soundtrack support with fade transition
This commit is contained in:
@@ -21,6 +21,151 @@ class IPUBElement extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IPUBAudio extends IPUBElement {
|
||||||
|
static elementName = "ipub-audio";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} [forced=false]
|
||||||
|
* @throws {Error}
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
play(forced = false) {
|
||||||
|
if (!this.#audioElement.readyState > HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
throw new Error("IPUBAudio: audio is not ready");
|
||||||
|
}
|
||||||
|
if (forced) {
|
||||||
|
this.setAttribute("forced", "true");
|
||||||
|
}
|
||||||
|
return this.#audioElement.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} [forced=false]
|
||||||
|
*/
|
||||||
|
pause(forced = false) {
|
||||||
|
if (forced) {
|
||||||
|
this.setAttribute("forced", "true");
|
||||||
|
}
|
||||||
|
this.#audioElement.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} state
|
||||||
|
*/
|
||||||
|
setLoop(state) {
|
||||||
|
this.#audioElement.loop = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
getLoop() {
|
||||||
|
return this.#audioElement.loop;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
const audio = this.querySelector("audio");
|
||||||
|
if (!audio) {
|
||||||
|
console.error("IPUBAudio: Missing child <audio> element");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#audioElement = audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @type {HTMLAudioElement}
|
||||||
|
*/
|
||||||
|
#audioElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} volume
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {number} [options.fadetime=0]
|
||||||
|
* @param {() => void} [options.onfinish=() => {}]
|
||||||
|
*/
|
||||||
|
setVolume(volume, { fadetimeMS = 0, onFinishFade = () => {} } = {}) {
|
||||||
|
if (fadetimeMS === 0) {
|
||||||
|
this.#audioElement.volume = volume / 100;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#isFading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#onFinishFade = onFinishFade;
|
||||||
|
|
||||||
|
const diff = volume - this.#audioElement.volume * 100;
|
||||||
|
const ticks = diff < 0 ? Math.abs(diff) : diff;
|
||||||
|
let tick = 0;
|
||||||
|
|
||||||
|
const interval = fadetimeMS / ticks;
|
||||||
|
|
||||||
|
this.#isFading = true;
|
||||||
|
this.#fadeTask = setInterval(() => {
|
||||||
|
tick++;
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
this.#isFading = false;
|
||||||
|
if (onFinishFade) {
|
||||||
|
onFinishFade();
|
||||||
|
}
|
||||||
|
clearInterval(this.#fadeTask);
|
||||||
|
this.#onFinishFade = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!this.#audioElement) {
|
||||||
|
cancel();
|
||||||
|
console.error("IPUBAudio: Missing child <audio> element");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticks < tick) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (volume === this.getVolume()) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff === 0) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nvol =
|
||||||
|
(diff > 0
|
||||||
|
? Math.ceil(this.#audioElement.volume * 100 + 1)
|
||||||
|
: Math.floor(this.#audioElement.volume * 100 - 1)) / 100;
|
||||||
|
|
||||||
|
if (nvol > 1 || nvol < 0) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#audioElement.volume = nvol;
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getVolume() {
|
||||||
|
return Math.floor((this.#audioElement?.volume ?? 0) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#isFading = false;
|
||||||
|
#fadeTask = 0;
|
||||||
|
/** @type {() => void | null} */
|
||||||
|
#onFinishFade = null;
|
||||||
|
}
|
||||||
|
|
||||||
class IPUBBackground extends IPUBElement {
|
class IPUBBackground extends IPUBElement {
|
||||||
static elementName = "ipub-background";
|
static elementName = "ipub-background";
|
||||||
static observedAttributes = ["nofade"].concat(super.observedAttributes);
|
static observedAttributes = ["nofade"].concat(super.observedAttributes);
|
||||||
@@ -182,9 +327,11 @@ class IPUBBody extends IPUBElement {
|
|||||||
|
|
||||||
static defineContentElements() {
|
static defineContentElements() {
|
||||||
for (const e of [
|
for (const e of [
|
||||||
|
IPUBAudio,
|
||||||
IPUBBackground,
|
IPUBBackground,
|
||||||
IPUBImage,
|
IPUBImage,
|
||||||
IPUBInteraction,
|
IPUBInteraction,
|
||||||
|
IPUBSoundtrack,
|
||||||
]) {
|
]) {
|
||||||
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
|
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
|
||||||
globalThis.customElements.define(e.elementName, e);
|
globalThis.customElements.define(e.elementName, e);
|
||||||
@@ -288,12 +435,130 @@ class IPUBInteraction extends IPUBElement {
|
|||||||
static elementName = "ipub-interaction";
|
static elementName = "ipub-interaction";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class IPUBSoundtrack extends IPUBElement {
|
||||||
|
static elementName = "ipub-soundtrack";
|
||||||
|
|
||||||
|
// TODO: Toggle automatic soundtrack playing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
static #player = setInterval(() => {
|
||||||
|
const last = Array.from(this.#onScreenStack).pop();
|
||||||
|
if (!last) {
|
||||||
|
// TODO: Fallback to previous soundtrack if there's no audio
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Get siblings based by group OR parent
|
||||||
|
/** @type {NodeListOf<IPUBSoundtrack> | undefined} */
|
||||||
|
const siblings = last.parentElement?.querySelectorAll(
|
||||||
|
IPUBSoundtrack.elementName,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (siblings) {
|
||||||
|
siblings.forEach((el) => {
|
||||||
|
if (el !== last) {
|
||||||
|
el.fadeOut();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
last.fadeIn();
|
||||||
|
} catch (e) {
|
||||||
|
// TODO: Fallback to previous soundtrack on error
|
||||||
|
console.error(
|
||||||
|
`IPUBSoundtrack: error while trying to play audio, error: ${e}`,
|
||||||
|
{
|
||||||
|
error: e,
|
||||||
|
audio: audio,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
static #observer = (() => {
|
||||||
|
return new IntersectionObserver((entries) => {
|
||||||
|
for (const { intersectionRatio, target, time } of entries) {
|
||||||
|
/** @type {IPUBSoundtrack} */
|
||||||
|
const soundtrack = target;
|
||||||
|
|
||||||
|
if (intersectionRatio > 0) {
|
||||||
|
console.debug(`${soundtrack.id} is on screen at ${time}`, soundtrack);
|
||||||
|
this.#onScreenStack.add(soundtrack);
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
`${soundtrack.id} is not on screen ${time}`,
|
||||||
|
soundtrack,
|
||||||
|
);
|
||||||
|
this.#onScreenStack.delete(soundtrack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
* @type {Set<IPUBSoundtrack>}
|
||||||
|
*/
|
||||||
|
static #onScreenStack = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws {Error}
|
||||||
|
*/
|
||||||
|
fadeIn() {
|
||||||
|
/** @type {IPUBAudio | undefined} */
|
||||||
|
const audio = this.querySelector(IPUBAudio.elementName);
|
||||||
|
if (!audio) {
|
||||||
|
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Global volume settings
|
||||||
|
|
||||||
|
audio.play();
|
||||||
|
audio.setVolume(10, { fadetimeMS: IPUBSoundtrack.FADE_TIME_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws {Error}
|
||||||
|
*/
|
||||||
|
fadeOut() {
|
||||||
|
/** @type {IPUBAudio | undefined} */
|
||||||
|
const audio = this.querySelector(IPUBAudio.elementName);
|
||||||
|
if (!audio) {
|
||||||
|
throw new Error("IPUBSoundtrack.fadeIn: missing audio element");
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.setVolume(0, {
|
||||||
|
fadetimeMS: IPUBSoundtrack.FADE_TIME_MS,
|
||||||
|
onFinishFade: () => {
|
||||||
|
audio.pause();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Readonly<number>} */
|
||||||
|
static FADE_TIME_MS = 1000 * 3;
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
/** @type {IPUBAudio | undefined} */
|
||||||
|
const audio = this.querySelector(IPUBAudio.elementName);
|
||||||
|
if (audio) {
|
||||||
|
audio.setVolume(0);
|
||||||
|
} else {
|
||||||
|
console.error("IPUBSoundtrack: missing audio element");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IPUBSoundtrack.#observer.observe(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(guz013): Handle if element is moved, it's group should be updated
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,6 +27,21 @@
|
|||||||
<ipub-background id="background0001">
|
<ipub-background id="background0001">
|
||||||
<img src="../images/background0001.jpg" width="100" height="100" />
|
<img src="../images/background0001.jpg" width="100" height="100" />
|
||||||
</ipub-background>
|
</ipub-background>
|
||||||
|
<ipub-soundtrack style="--ipub-color:cyan">
|
||||||
|
<!-- TODO: Search on how to make this more accessible, more semantic as using <details> -->
|
||||||
|
<figure>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<figcaption>Soundtrack 1</figcaption>
|
||||||
|
</label>
|
||||||
|
<ipub-audio>
|
||||||
|
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||||
|
disableremoteplayback="">
|
||||||
|
<source src="../audios/track1.webm" />
|
||||||
|
</audio>
|
||||||
|
</ipub-audio>
|
||||||
|
</figure>
|
||||||
|
</ipub-soundtrack>
|
||||||
<ipub-image>
|
<ipub-image>
|
||||||
<img src="../images/image0001.png" />
|
<img src="../images/image0001.png" />
|
||||||
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
|
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
|
||||||
@@ -41,6 +56,20 @@
|
|||||||
<ipub-image>
|
<ipub-image>
|
||||||
<img src="../images/image0002.png" />
|
<img src="../images/image0002.png" />
|
||||||
</ipub-image>
|
</ipub-image>
|
||||||
|
<ipub-soundtrack style="--ipub-color:green;">
|
||||||
|
<figure>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<figcaption>Soundtrack 2</figcaption>
|
||||||
|
</label>
|
||||||
|
<ipub-audio>
|
||||||
|
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||||
|
disableremoteplayback="">
|
||||||
|
<source src="../audios/track2.webm" />
|
||||||
|
</audio>
|
||||||
|
</ipub-audio>
|
||||||
|
</figure>
|
||||||
|
</ipub-soundtrack>
|
||||||
<ipub-background id="background0002">
|
<ipub-background id="background0002">
|
||||||
<picture>
|
<picture>
|
||||||
<img src="../images/background0002.jpg" />
|
<img src="../images/background0002.jpg" />
|
||||||
@@ -60,6 +89,20 @@
|
|||||||
<ipub-image>
|
<ipub-image>
|
||||||
<img src="../images/image0002.png" />
|
<img src="../images/image0002.png" />
|
||||||
</ipub-image>
|
</ipub-image>
|
||||||
|
<ipub-soundtrack style="--ipub-color:yellow;">
|
||||||
|
<figure>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" />
|
||||||
|
<figcaption>Soundtrack 3</figcaption>
|
||||||
|
</label>
|
||||||
|
<ipub-audio>
|
||||||
|
<audio controls="true" volume="0" controlslist="nofullscreen"
|
||||||
|
disableremoteplayback="">
|
||||||
|
<source src="../audios/track3.webm" />
|
||||||
|
</audio>
|
||||||
|
</ipub-audio>
|
||||||
|
</figure>
|
||||||
|
</ipub-soundtrack>
|
||||||
<ipub-image>
|
<ipub-image>
|
||||||
<img src="../images/image0003.png" />
|
<img src="../images/image0003.png" />
|
||||||
</ipub-image>
|
</ipub-image>
|
||||||
|
|||||||
Reference in New Issue
Block a user