2 Commits

3 changed files with 529 additions and 62 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);
@@ -28,49 +173,84 @@ class IPUBBackground extends IPUBElement {
/**
* @private
*/
static #observer = (() => {
/** @type {Map<string, IPUBBackground>} */
const instancesOnScreen = new Map();
document.addEventListener("scroll", () => {
for (const [_, instance] of instancesOnScreen) {
const perc = getPercentageInView(
instance.querySelector("img") || instance,
static #instancesOnScreen = {
/** @type {Map<IPUBBody, Set<IPUBBackground>>} */
map: new Map(),
/**
* @param {IPUBBackground} background
*/
add(background) {
const body = getAncestor(background, IPUBBody.elementName);
if (!body) {
console.error(
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
);
instance.fade(perc);
return;
}
});
return new IntersectionObserver((entries) =>
entries.forEach((e) => {
let instance = e.target.parentElement;
let set = this.map.get(body);
if (!set) {
set = new Set();
body.addEventListener("scroll", () => {
for (const instance of set.values()) {
const perc = getPercentageInView(
instance.querySelector("img") || instance,
);
instance.fade(perc);
}
});
}
set.add(background);
this.map.set(body, set);
},
/**
* @param {IPUBBackground} background
*/
remove(background) {
const body = getAncestor(background, IPUBBody.elementName);
if (!body) {
console.error(
`IPUBBackground: ${background.id} does not have a valid ipub-body ancestor`,
);
return;
}
if (
instance.tagName.toLowerCase() !==
IPUBBackground.elementName.toLowerCase()
) {
instance = instance.parentElement;
}
const set = this.map.get(body);
if (!set) {
return;
}
if (
instance.tagName.toLowerCase() !==
IPUBBackground.elementName.toLowerCase()
) {
console.error(
"IPUBBackground: malformed <ipub-background> element",
e.target,
);
return;
}
set.delete(background);
if (e.intersectionRatio > 0 && instance.id) {
instancesOnScreen.set(instance.id, instance);
} else if (instance.id) {
instancesOnScreen.delete(instance.id);
}
}),
);
})();
if (set.size === 0) {
this.map.delete(body);
}
},
};
static addToScreen() {}
/**
* @private
*/
static #observer = new IntersectionObserver((entries) => {
for (const { intersectionRatio, target: image } of entries) {
const instance = getAncestor(image, IPUBBackground.elementName);
if (!instance) {
console.error(
"IPUBBackground: malformed <ipub-background> element",
image,
);
return;
}
if (intersectionRatio > 0 && instance.id) {
IPUBBackground.#instancesOnScreen.add(instance);
} else if (instance.id) {
IPUBBackground.#instancesOnScreen.remove(instance);
}
}
});
connectedCallback() {
super.connectedCallback();
@@ -126,8 +306,6 @@ class IPUBBackground extends IPUBElement {
* @returns {void | Promise<void>}
*/
fade(perc) {
console.debug(`IPUBBackground: ${this.id} is ${perc} on screen`);
if (!this.style.getPropertyValue("--ipub-fade")) {
this.style.setProperty("--ipub-fade", `${perc}%`);
return;
@@ -149,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);
@@ -255,16 +435,146 @@ 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
* @returns {HTMLElement | undefined}
*/
function getAncestor(el, tagName) {
if (!el.parentElement) {
return undefined;
}
if (el.parentElement.tagName.toLowerCase() === tagName.toLowerCase()) {
return el.parentElement;
}
return getAncestor(el.parentElement, tagName);
}
/**
* @param {Readonly<Element>} el
@@ -330,3 +640,4 @@ function getPercentageInView(element) {
return Math.round((inView / globalThis.innerHeight) * 100);
}

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,43 +79,151 @@ 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%,
rgba(0, 0, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1))
);
--ipub-width: 100vw;
--ipub-height: 100vh;
&[sticky] {
display: inline-block;
top: 0;
left: 0;
width: 0;
height: 0;
position: sticky;
align-self: start;
display: inline-block;
top: 0;
left: 0;
width: 0;
height: 0;
position: sticky;
align-self: start;
&:first-of-type,
&[nofade] {
--ipub-mask: unset;
}
&[fade] img {
img {
/* For testing */
/* background-image: linear-gradient( */
/* rgba(266, 0, 0, 1) 0%, */
/* rgba(0, 266, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1)), */
/* rgba(266, 0, 266, 1) 100% */
/* ) !important; */
--mask: linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) calc(100% + calc(var(--ipub-fade, 100%) * -1))
) !important;
/* background-image: var(--mask); */
mask-image: var(--mask);
-webkit-mask-image: var(--mask);
mask-image: var(--ipub-mask);
-webkit-mask-image: var(--ipub-mask);
}
& > picture {
position: absolute;
top: 0;
left: 0;
display: block;
width: var(--ipub-width);
height: var(--ipub-height);
@@ -125,6 +235,9 @@ ipub-background {
}
/* Support standalone img element */
& > img {
position: absolute;
top: 0;
left: 0;
display: block;
object-fit: cover;
width: var(--ipub-width);