feat(ipub): soundtrack support with fade transition

This commit is contained in:
Guz
2025-11-03 14:58:21 -03:00
parent a3c2efd5b0
commit 7d1a21430c
3 changed files with 417 additions and 6 deletions

View File

@@ -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 {
static elementName = "ipub-background";
static observedAttributes = ["nofade"].concat(super.observedAttributes);
@@ -182,9 +327,11 @@ class IPUBBody extends IPUBElement {
static defineContentElements() {
for (const e of [
IPUBAudio,
IPUBBackground,
IPUBImage,
IPUBInteraction,
IPUBSoundtrack,
]) {
console.info(`IPUBBody: Defining custom element <${e.elementName}>`);
globalThis.customElements.define(e.elementName, e);
@@ -288,14 +435,132 @@ class IPUBInteraction extends IPUBElement {
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
}
/**
* @param {HTMLElement} el
* @param {string} tagName

View File

@@ -27,6 +27,21 @@
<ipub-background id="background0001">
<img src="../images/background0001.jpg" width="100" height="100" />
</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>
<img src="../images/image0001.png" />
<ipub-interaction style="--ipub-y:88.5%;--ipub-x:6%" circle="">
@@ -41,6 +56,20 @@
<ipub-image>
<img src="../images/image0002.png" />
</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">
<picture>
<img src="../images/background0002.jpg" />
@@ -60,6 +89,20 @@
<ipub-image>
<img src="../images/image0002.png" />
</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>
<img src="../images/image0003.png" />
</ipub-image>

View File

@@ -64,12 +64,14 @@ ipub-body {
flex-direction: column;
align-items: center;
& > *:first-child:not(ipub-background),
& > ipub-background:first-child + *:first-of-type {
& > *:first-child:not(ipub-background):not(ipub-soundtrack),
& > ipub-background:first-of-type + *:first-of-type:not(ipub-soundtrack),
& > ipub-soundtrack:first-of-type + *:first-of-type:not(ipub-background) {
margin-top: var(--ipub-padding-t);
margin-bottom: calc(var(--ipub-gap) / 2);
}
& > *:not(ipub-background) {
& > *:not(ipub-background):not(ipub-soundtrack) {
margin-top: calc(var(--ipub-gap) / 2);
margin-right: var(--ipub-padding-r);
margin-left: var(--ipub-padding-l);
@@ -77,12 +79,113 @@ ipub-body {
}
& > *:last-child:not(ipub-background),
& > ipub-background:last-child + *:last-of-type {
& > *:last-child:not(ipub-soundtrack),
& > ipub-background:last-of-type + *:last-of-type:not(ipub-soundtrack),
& > ipub-soundtrack:last-of-type + *:last-of-type:not(ipub-background) {
margin-top: calc(var(--ipub-gap) / 2);
margin-bottom: var(--ipub-padding-b);
}
}
}
ipub-soundtrack {
display: inline-block;
z-index: var(--z-overlays);
--ipub-color: red;
top: 0;
left: 0;
width: 100%;
height: 0;
position: sticky;
align-self: start;
border-top: 0.1rem dashed var(--ipub-color);
figure {
margin: 0;
height: 1.5rem;
font-size: small;
width: 100%;
display: flex;
align-items: center;
flex-direction: row;
flex: 1;
label {
background-color: var(--ipub-color);
border-end-end-radius: 0.5rem;
padding: 0.1rem 0.4rem;
width: max-content;
max-width: 50%;
height: 100%;
white-space: nowrap;
display: flex;
align-items: center;
}
&:has(input:checked) label {
border-end-end-radius: 0;
}
figcaption::before {
content: "0 "; /* TODO: change to an icon and better positioning */
}
figcaption::after {
content: " >"; /* TODO: change to an icon and better positioning */
}
&:has(input:checked) figcaption::after {
content: " <"; /* TODO: change to an icon and better positioning */
}
input {
width: 0;
height: 0;
display: none;
}
&:has(input) audio {
width: 0;
height: 0;
}
&:has(input:checked) audio {
width: 100%;
@media (width >= 40rem) {
width: 70%;
}
height: 100%;
background-color: var(--ipub-color);
@media (width >= 40rem) {
border-end-end-radius: 0.5rem;
}
padding: 0.1rem 0.4rem;
}
audio::-webkit-media-controls-enclosure {
background-color: transparent;
padding: 0;
border-radius: 0px;
}
audio::-webkit-media-controls-panel {
margin: 0;
padding: 0;
}
}
&[playing] figure figcaption::before {
content: "P "; /* TODO: change to an icon and better positioning */
}
}
ipub-background {
--ipub-mask: linear-gradient(
rgba(0, 0, 0, 0) 0%,